Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.crypto.Nip01Crypto
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip06KeyDerivation.Nip06
import com.vitorpamplona.quartz.nip19Bech32.toNpub
import com.vitorpamplona.quartz.nip49PrivKeyEnc.Nip49
import com.vitorpamplona.quartz.utils.Hex
Expand Down Expand Up @@ -63,8 +64,10 @@ object AccountExportService {
didBackup = accountData.didBackup,
signer = NostrSignerInternal(KeyPair(privKey.hexToByteArray())),
)
LocalPreferences.switchToAccount(Amber.instance, accountData.npub)
LocalPreferences.updatePrefsForLogin(Amber.instance, account, hexKey.toHexKey(), privKey, accountData.seedWords)
requireBackupIdentityMatches(accountData.npub, account)
val seedWords = decryptExportedSeedWords(account, accountData.seedWords)
LocalPreferences.switchToAccount(Amber.instance, account.npub)
LocalPreferences.updatePrefsForLogin(Amber.instance, account, hexKey.toHexKey(), privKey, seedWords)
} catch (e: Exception) {
if (e is CancellationException) throw e

Expand All @@ -81,6 +84,17 @@ object AccountExportService {
}
}

internal fun requireBackupIdentityMatches(metadataNpub: String, account: Account) {
require(metadataNpub == account.npub) { "Backup account does not match its encrypted private key" }
}

internal suspend fun decryptExportedSeedWords(account: Account, encryptedSeedWords: String?): String? {
if (encryptedSeedWords.isNullOrBlank()) return null
val seedWords = account.nip44Decrypt(encryptedSeedWords, account.hexKey)
require(Nip06().isValidMnemonic(seedWords)) { "Backup seed words are invalid" }
return seedWords
}

/**
* Export each account as a separate (npub, ncryptsec) pair.
* Callers write each pair to its own file named "{npub}.ncryptsec".
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package com.greenart7c3.nostrsigner.service

import com.greenart7c3.nostrsigner.models.Account
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertThrows
import org.junit.Test

class AccountExportSeedRestoreTest {
private val words = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"

@Test
fun `restore returns decrypted words instead of storing ciphertext as a mnemonic`() = runBlocking {
val account = mockk<Account>()
every { account.hexKey } returns "owner"
coEvery { account.nip44Decrypt("encrypted-seed", "owner") } returns words
assertEquals(words, AccountExportService.decryptExportedSeedWords(account, "encrypted-seed"))
}

@Test
fun `absent seed does not overwrite existing words`() = runBlocking {
assertNull(AccountExportService.decryptExportedSeedWords(mockk(), ""))
}

@Test
fun `invalid decrypted words fail before account storage`() {
val account = mockk<Account>()
every { account.hexKey } returns "owner"
coEvery { account.nip44Decrypt("encrypted-seed", "owner") } returns "not a mnemonic"
assertThrows(IllegalArgumentException::class.java) {
runBlocking { AccountExportService.decryptExportedSeedWords(account, "encrypted-seed") }
}
}

@Test
fun `metadata npub must match the restored key identity`() {
val account = mockk<Account>()
every { account.npub } returns "npub-restored"
assertThrows(IllegalArgumentException::class.java) {
AccountExportService.requireBackupIdentityMatches("npub-metadata", account)
}
AccountExportService.requireBackupIdentityMatches("npub-restored", account)
}
}
Loading