diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/service/ApplicationBackup.kt b/app/src/main/java/com/greenart7c3/nostrsigner/service/ApplicationBackup.kt index f1255c8e..ca459bf8 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/service/ApplicationBackup.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/service/ApplicationBackup.kt @@ -12,6 +12,8 @@ import com.greenart7c3.nostrsigner.database.ApplicationPermissionsEntity import com.greenart7c3.nostrsigner.database.ApplicationWithPermissions import com.greenart7c3.nostrsigner.models.Account import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.crypto.verify import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.publishAndConfirm import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayConnectionListener @@ -21,6 +23,9 @@ import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip44Encryption.crypto.Hkdf +import com.vitorpamplona.quartz.utils.Secp256k1Instance import com.vitorpamplona.quartz.utils.TimeUtils import java.util.UUID import kotlin.coroutines.cancellation.CancellationException @@ -39,6 +44,16 @@ private val INBOX_FALLBACK_RELAYS = listOfNotNull( RelayUrlNormalizer.normalizeOrNull("wss://nos.lol/"), ) +// HKDF domain separation for the backup encryption key. Payloads are encrypted +// to a keypair derived from the account key, so a client app holding only a +// decrypt permission cannot read the publicly stored backup event. Deliberately +// outside the "nip44kd"/EncryptionKeyDerivation namespace: that is the domain +// NIP-46/NIP-55 derive_key requests draw from, and an app that could request +// the backup nonce must never land on the backup key. +private val BACKUP_KEY_SALT = "amber-app-backup-salt-v1".encodeToByteArray() +private val BACKUP_KEY_INFO = "amber-app-backup-key".encodeToByteArray() +private val backupHkdf = Hkdf() + data class BackupPermission( @param:JsonProperty("type") val type: String, @param:JsonProperty("kind") val kind: Int?, @@ -80,6 +95,32 @@ sealed interface RestoreResult { data class Failed(val message: String) : RestoreResult } +/** + * Deterministically derives the backup encryption keypair from the account + * private key: RFC 5869 HKDF-SHA256 via Quartz's [Hkdf] (the same primitive + * NIP-44 v3 uses for its key schedule), with a counter-step retry on the + * near-impossible invalid scalar — the same pattern as Quartz's + * GeohashKeyDerivation. The keypair is never stored: after a key restore it is + * re-derived, so only the account secret can decrypt. + */ +internal fun backupKeyPair(identityPrivKey: ByteArray): KeyPair { + val prk = backupHkdf.extract(identityPrivKey, BACKUP_KEY_SALT) + var counter = 1 + while (true) { + val okm = backupHkdf.expand(prk, BACKUP_KEY_INFO + byteArrayOf(counter.toByte()), 32) + if (Secp256k1Instance.isPrivateKeyValid(okm)) return KeyPair(privKey = okm) + counter++ + check(counter < 256) { "ApplicationBackup: could not derive a valid backup key" } + } +} + +internal fun backupSigner(account: Account): NostrSignerInternal { + val identityPrivKey = checkNotNull(account.signer.keyPair.privKey) { + "ApplicationBackup: account has no private key" + } + return NostrSignerInternal(backupKeyPair(identityPrivKey)) +} + object ApplicationBackup { private val mapper = jacksonObjectMapper() .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) @@ -216,7 +257,8 @@ object ApplicationBackup { return true } val json = toJson(payload) - val encrypted = account.nip44Encrypt(json, account.hexKey) + val backupSigner = backupSigner(account) + val encrypted = backupSigner.nip44Encrypt(json, backupSigner.keyPair.pubKey.toHexKey()) val event = account.signSync( TimeUtils.now(), BACKUP_KIND, @@ -286,13 +328,34 @@ object ApplicationBackup { return received.maxByOrNull { it.key }?.value } - suspend fun decryptPayload(event: Event, account: Account): BackupPayload? = try { - val json = account.nip44Decrypt(event.content, account.hexKey) - fromJson(json) + /** + * Decrypts backup content with the derived backup key. Falls back to the + * legacy identity-key decryption for backups published before the derived + * key existed; the next publish overwrites those replaceable events. + */ + internal suspend fun decryptContent(content: String, account: Account): String? = try { + val backupSigner = backupSigner(account) + backupSigner.nip44Decrypt(content, backupSigner.keyPair.pubKey.toHexKey()) } catch (e: Exception) { if (e is CancellationException) throw e - AmberLog.e(Amber.TAG, "ApplicationBackup: failed to decrypt/parse backup payload", e) - null + try { + account.nip44Decrypt(content, account.hexKey) + } catch (legacy: Exception) { + if (legacy is CancellationException) throw legacy + AmberLog.e(Amber.TAG, "ApplicationBackup: failed to decrypt backup payload", legacy) + null + } + } + + suspend fun decryptPayload(event: Event, account: Account): BackupPayload? { + val json = decryptContent(event.content, account) ?: return null + return try { + fromJson(json) + } catch (e: Exception) { + if (e is CancellationException) throw e + AmberLog.e(Amber.TAG, "ApplicationBackup: failed to parse backup payload", e) + null + } } suspend fun restoreFromPayload(npub: String, payload: BackupPayload): RestoreResult = try { diff --git a/app/src/test/java/com/greenart7c3/nostrsigner/service/ApplicationBackupCryptoTest.kt b/app/src/test/java/com/greenart7c3/nostrsigner/service/ApplicationBackupCryptoTest.kt new file mode 100644 index 00000000..f2eb76e9 --- /dev/null +++ b/app/src/test/java/com/greenart7c3/nostrsigner/service/ApplicationBackupCryptoTest.kt @@ -0,0 +1,121 @@ +package com.greenart7c3.nostrsigner.service + +import com.greenart7c3.nostrsigner.AmberLog +import com.greenart7c3.nostrsigner.models.Account +import com.vitorpamplona.quartz.experimental.decoupling.EncryptionKeyDerivation +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions +import com.vitorpamplona.quartz.nip19Bech32.toNpub +import io.mockk.every +import io.mockk.mockkObject +import io.mockk.unmockkObject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +class ApplicationBackupCryptoTest { + private val payload = """{"v":1,"ts":1,"applications":[]}""" + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private lateinit var account: Account + + @Before + fun setUp() { + // decryptContent's failure path logs; android.util.Log is not mocked on the JVM. + mockkObject(AmberLog) + every { AmberLog.e(any(), any(), any()) } returns Unit + val identity = KeyPair() + account = Account( + signer = NostrSignerInternal(identity), + hexKey = identity.pubKey.toHexKey(), + npub = identity.pubKey.toNpub(), + name = MutableStateFlow(""), + picture = MutableStateFlow(""), + signPolicy = 1, + didBackup = true, + scope = scope, + ) + } + + @After + fun tearDown() { + unmockkObject(AmberLog) + } + + @Test + fun `backup key derivation is deterministic and domain separated`() { + val identity = KeyPair().privKey!! + val first = backupKeyPair(identity) + val second = backupKeyPair(identity) + assertTrue(first.privKey.contentEquals(second.privKey)) + assertTrue(KeyPair(privKey = first.privKey).pubKey.contentEquals(first.pubKey)) + } + + @Test + fun `identity key cannot decrypt backup content`() { + val identityPrivKey = KeyPair().privKey!! + val backupSigner = NostrSignerInternal(backupKeyPair(identityPrivKey)) + val derivedPubKey = backupSigner.keyPair.pubKey.toHexKey() + + val ciphertext = runBlocking { backupSigner.nip44Encrypt(payload, derivedPubKey) } + assertEquals(payload, runBlocking { backupSigner.nip44Decrypt(ciphertext, derivedPubKey) }) + + assertThrows(SignerExceptions.CouldNotPerformException::class.java) { + runBlocking { + NostrSignerInternal(KeyPair(privKey = identityPrivKey)).nip44Decrypt(ciphertext, derivedPubKey) + } + } + + // The pre-fix scheme encrypted to the identity key itself (encrypt-to-self); + // that content IS readable by any decrypt-capable client, which is the leak + // the derived key removes. + val identitySigner = NostrSignerInternal(KeyPair(privKey = identityPrivKey)) + val leakedCiphertext = runBlocking { identitySigner.nip44Encrypt(payload, identitySigner.keyPair.pubKey.toHexKey()) } + assertEquals(payload, runBlocking { identitySigner.nip44Decrypt(leakedCiphertext, identitySigner.keyPair.pubKey.toHexKey()) }) + } + + @Test + fun `backup key is outside the app-requestable derive_key domain`() { + val identityPrivKey = KeyPair().privKey!! + val backupPrivKey = backupKeyPair(identityPrivKey).privKey + + // Nonces a client could pass to a future NIP-55/NIP-46 derive_key: the + // backup labels themselves, the scheme prefix, and empty. None may land + // on the backup key — the derivations must stay independent so shipping + // derive_key later cannot expose backup key material. + val appRequestableNonces = listOf( + "amber-app-backup", + "amber-app-backup-salt-v1", + "amber-app-backup-key", + "nip44kd", + "nip44kdamber-app-backup", + "", + ) + for (nonce in appRequestableNonces) { + val derived = EncryptionKeyDerivation.derivePrivateKey(identityPrivKey, nonce.encodeToByteArray()) + assertFalse("nonce='$nonce' collides with the backup key", backupPrivKey.contentEquals(derived)) + } + } + + @Test + fun `decryptContent reads new and legacy backups and rejects garbage`() = runBlocking { + val backupSigner = backupSigner(account) + val newCiphertext = backupSigner.nip44Encrypt(payload, backupSigner.keyPair.pubKey.toHexKey()) + val legacyCiphertext = account.nip44Encrypt(payload, account.hexKey) + + assertEquals(payload, ApplicationBackup.decryptContent(newCiphertext, account)) + assertEquals(payload, ApplicationBackup.decryptContent(legacyCiphertext, account)) + assertNull(ApplicationBackup.decryptContent("not-a-backup-payload", account)) + } +}