mirror of
https://github.com/greenart7c3/Amber.git
synced 2026-10-05 10:58:23 +00:00
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:
@@ -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<Event>(
|
||||
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 {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user