Encrypt applications backup to a key derived from the account key

The backup event is public on relays (kind 30078) and was NIP-44
encrypted to the identity key itself, so any app holding a remembered
nip44_decrypt permission could fetch it and read the whole payload,
including per-app NIP-46 secrets and localKeys. Encrypt to a dedicated
keypair derived from the account key via HKDF-SHA256 instead, in a
private domain outside the nip44kd derive_key namespace so a future
NIP-55 derive_key implementation can never hand out the backup key.
Restore re-derives the key from the restored secret; legacy
identity-encrypted backups still restore through a fallback until the
next publish overwrites them.
This commit is contained in:
greenart7c3
2026-09-16 09:33:00 -03:00
parent 8c6f2fd7b0
commit 1f3aa1f327
2 changed files with 190 additions and 6 deletions
@@ -12,6 +12,8 @@ import com.greenart7c3.nostrsigner.database.ApplicationPermissionsEntity
import com.greenart7c3.nostrsigner.database.ApplicationWithPermissions import com.greenart7c3.nostrsigner.database.ApplicationWithPermissions
import com.greenart7c3.nostrsigner.models.Account import com.greenart7c3.nostrsigner.models.Account
import com.vitorpamplona.quartz.nip01Core.core.Event 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.crypto.verify
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.publishAndConfirm import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.publishAndConfirm
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayConnectionListener 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.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer 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 com.vitorpamplona.quartz.utils.TimeUtils
import java.util.UUID import java.util.UUID
import kotlin.coroutines.cancellation.CancellationException import kotlin.coroutines.cancellation.CancellationException
@@ -39,6 +44,16 @@ private val INBOX_FALLBACK_RELAYS = listOfNotNull(
RelayUrlNormalizer.normalizeOrNull("wss://nos.lol/"), 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( data class BackupPermission(
@param:JsonProperty("type") val type: String, @param:JsonProperty("type") val type: String,
@param:JsonProperty("kind") val kind: Int?, @param:JsonProperty("kind") val kind: Int?,
@@ -80,6 +95,32 @@ sealed interface RestoreResult {
data class Failed(val message: String) : 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 { object ApplicationBackup {
private val mapper = jacksonObjectMapper() private val mapper = jacksonObjectMapper()
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
@@ -216,7 +257,8 @@ object ApplicationBackup {
return true return true
} }
val json = toJson(payload) 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<Event>( val event = account.signSync<Event>(
TimeUtils.now(), TimeUtils.now(),
BACKUP_KIND, BACKUP_KIND,
@@ -286,13 +328,34 @@ object ApplicationBackup {
return received.maxByOrNull { it.key }?.value return received.maxByOrNull { it.key }?.value
} }
suspend fun decryptPayload(event: Event, account: Account): BackupPayload? = try { /**
val json = account.nip44Decrypt(event.content, account.hexKey) * Decrypts backup content with the derived backup key. Falls back to the
fromJson(json) * 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) { } catch (e: Exception) {
if (e is CancellationException) throw e if (e is CancellationException) throw e
AmberLog.e(Amber.TAG, "ApplicationBackup: failed to decrypt/parse backup payload", e) try {
null 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 { suspend fun restoreFromPayload(npub: String, payload: BackupPayload): RestoreResult = try {
@@ -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))
}
}