From 6532bfc45fa1c384ff9e5751978af000812d94fd Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sat, 12 Sep 2026 20:28:57 +0200 Subject: [PATCH] feat(identity): list, start and read as an identity that holds no key Phase 3 of docs/npub-sign-in.md. listIdentities runs the migration from nostr-keys.dat before its read -- there rather than inside the reader, so a listing that writes is a named step and not a surprise, and from init, before anything else touches either file. An old file that cannot be read is left where it is and reported the way an unreadable credentials file is, so the user sees an error rather than silently losing every imported identity. The selector says "read only" under the npub of a public-key identity, before the tap: two identities can share an avatar and a name, and only one of them will let the user send a message. Identity.toKeyPair is now the one place a quartz KeyPair is built from an identity. With a secret it is the pair as before, the public key a cross-check; without one it is quartz's read-only constructor. What it must never be is KeyPair(privKey = null) with nothing else -- that is quartz's "make me a new key" -- and decryptGiftWrapSeal builds exactly that from a forwarded pair, which is why the helper has the trap on it. The synchronization view model runs the two pumps that read for every identity and the two that write only for one that can sign. Stated as a rule because Phase 5 leans on it: a read-only identity never sends anything to a relay, not a signature and not a copy. The broadcast pump would find nothing, and the live subscriptions are the gift-wrap inbox and the group-membership follow, both fetching what this identity cannot open. One guard in NostrDao.indexNostrEvent, after the isAddressedTo check: a wrap addressed to a key the pair does not hold is kept, event and wrap, the way someone else's mail already is. storeNostrEvent is @Transaction and indexes inside it, so before this the unseal ran with a key nobody has, threw, and the throw took the event with it while logging as a decryption failure. Verified both ways: the new DAO test fails on exactly that case with the guard removed and passes with it, and the same wrap opens once the pair holds its key. Co-Authored-By: Claude Opus 5 Pulled-From: curated/curated@7db863e3925641bc731816f72bfed90f020566c6 --- .../composeResources/values/strings.xml | 1 + .../mantra/compose/database/dao/NostrDao.kt | 11 ++ .../press/mantra/compose/identity/Identity.kt | 18 +++ .../widgets/wallet/WalletsSelector.kt | 13 +++ .../ui/view/model/SovereignWalletViewModel.kt | 22 ++++ .../ui/view/model/SynchronizationViewModel.kt | 29 +++-- .../dao/ReadOnlyGiftWrapDaoJvmTest.kt | 106 ++++++++++++++++++ 7 files changed, 191 insertions(+), 9 deletions(-) create mode 100644 composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/ReadOnlyGiftWrapDaoJvmTest.kt diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 9244b7d6..6c2c5394 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -265,6 +265,7 @@ This will be the display name for this chat room. This will be the display name for your profile and also important for search. This will give you read only access to the profile. + Read only This will give you write access to the profile. Mantra broadcasts what you publish to a distributed set of relays, so it stays decentralised. Translate chunk diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrDao.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrDao.kt index 4750536b..f650955b 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrDao.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrDao.kt @@ -445,6 +445,17 @@ abstract class NostrDao( return@let } + if (activeKeyPair.privKey == null) { + // Addressed to us, and we cannot open it: a read-only identity. Keep the + // event and the wrap, as the branch above does for wraps addressed to + // someone else; the key that opens this one may be signed in later. Without + // this, the unseal below would run with a key nobody has and throw, and + // the throw would take the event with it. + logger.d("GiftWrap ${nostrEvent.id} is addressed to us, but this identity holds no key") + + return@let + } + giftWrapMessage.decryptGiftWrapSeal( activeKeyPair ).let { giftWrapSeal -> diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/Identity.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/Identity.kt index 9ed2737e..7ccd555a 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/Identity.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/Identity.kt @@ -1,6 +1,8 @@ package press.mantra.compose.identity import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import fr.acinq.bitcoin.Crypto import fr.acinq.bitcoin.PrivateKey import fr.acinq.bitcoin.XonlyPublicKey @@ -135,6 +137,22 @@ data class Identity( } } +/** + * The one place a quartz [KeyPair] is built from an identity. + * + * With a secret, quartz recomputes the public key from it and the second argument is a + * cross-check. Without one it is quartz's read-only constructor, which its own source + * comments as "this is a read-only account". What it must never be is + * `KeyPair(privKey = null)` with nothing else: that is quartz's *make me a new key*, and + * a read-only pair forwarded into it would try to open the user's gift wraps with a key + * nobody has, fail, and look in the logs like a decryption failure. This helper exists so + * that the trap has one place to be avoided. + */ +fun Identity.toKeyPair(): KeyPair = KeyPair( + privKey = nostrPrivateKey?.value?.toByteArray(), + pubKey = nostrPublicKey.hexToByteArray(), +) + /** * The [WalletId] of an identity that has no node id to hash. * diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/wallet/WalletsSelector.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/wallet/WalletsSelector.kt index ad609bd3..b273d2d1 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/wallet/WalletsSelector.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/wallet/WalletsSelector.kt @@ -49,6 +49,9 @@ import fr.acinq.phoenix.utils.preferences.GlobalPrefs import fr.acinq.phoenix.utils.preferences.UserWalletMetadata import fr.acinq.phoenix.utils.preferences.getByWalletIdOrDefault import press.mantra.compose.extensions.hexToNpubHrp +import mantra.composeapp.generated.resources.Res +import mantra.composeapp.generated.resources.read_only +import org.jetbrains.compose.resources.stringResource import press.mantra.compose.identity.StoredIdentity import press.mantra.compose.ui.theme.spacing @@ -159,6 +162,16 @@ private fun AvailableWalletView( Text(text = metadata.nameOrDefault(), modifier = Modifier, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.bodyMedium) Spacer(Modifier.height(MaterialTheme.spacing.space25)) Text(text = identity.nostrPublicKey.hexToNpubHrp(), modifier = Modifier, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.displayMedium.copy(fontFamily = FontFamily.Monospace, fontSize = 12.sp)) + if (identity is StoredIdentity.NostrPublic) { + // Said before the tap, not after: two identities can share an avatar + // and a name, and only one of them will let the user send a message. + Spacer(Modifier.height(MaterialTheme.spacing.space25)) + Text( + text = stringResource(Res.string.read_only), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } } } if (isCurrent && canEdit) { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SovereignWalletViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SovereignWalletViewModel.kt index ecb26905..d000c5c2 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SovereignWalletViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SovereignWalletViewModel.kt @@ -15,6 +15,7 @@ import fr.acinq.phoenix.PhoenixGlobal import com.vitorpamplona.quartz.nip01Core.core.HexKey import fr.acinq.bitcoin.PrivateKey import fr.acinq.phoenix.data.DecryptNostrCredentialsResult +import fr.acinq.phoenix.data.DecryptNostrKeysResult import fr.acinq.phoenix.data.DecryptSeedResult import fr.acinq.phoenix.data.ElectrumConfig import fr.acinq.phoenix.data.ListWalletState @@ -215,6 +216,27 @@ class SovereignWalletViewModel( is DecryptSeedResult.Success -> result.userWalletsMap } + // Before the read, and here rather than in the reader: a listing that writes + // would be a surprise, and this runs from init, before anything else touches + // either file. An old file that cannot be read is left alone and reported the + // way an unreadable credentials file is, so the user sees the error rather + // than silently losing every imported identity. + when (val migration = NostrCredentialManager.migrateFromNostrKeys(phoenixGlobal)) { + is NostrCredentialManager.MigrationResult.Migrated -> log.i { "migrated ${migration.count} nostr key(s) into the credentials file" } + is NostrCredentialManager.MigrationResult.NotNeeded -> Unit + is NostrCredentialManager.MigrationResult.Failed -> { + log.e { "nostr-keys.dat could not be migrated: ${migration.failure}" } + _listWalletState.value = when (val failure = migration.failure) { + is DecryptNostrKeysResult.Failure.SerializationError -> ListWalletState.Error.Serialization + is DecryptNostrKeysResult.Failure.DecryptionError -> ListWalletState.Error.DecryptionError.GeneralException(failure.cause) + is DecryptNostrKeysResult.Failure.KeyStoreFailure -> ListWalletState.Error.DecryptionError.KeystoreFailure(failure.cause) + is DecryptNostrKeysResult.Failure.FileUnreadable, + is DecryptNostrKeysResult.Failure.FileNotFound -> ListWalletState.Error.Generic(null) + } + return@launch + } + } + val credentials: Map = when (val result = NostrCredentialManager.loadAndDecrypt(phoenixGlobal)) { is DecryptNostrCredentialsResult.Failure.SerializationError -> { log.e { "cannot deserialize nostr credentials file" } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SynchronizationViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SynchronizationViewModel.kt index cd4428a0..63dcd40a 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SynchronizationViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SynchronizationViewModel.kt @@ -30,6 +30,7 @@ import com.vitorpamplona.quartz.nip77Negentropy.NegCloseCmd import com.vitorpamplona.quartz.nip77Negentropy.NegMsgCmd import com.vitorpamplona.quartz.nip77Negentropy.NegOpenCmd import press.mantra.compose.identity.Identity +import press.mantra.compose.identity.toKeyPair import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -191,10 +192,10 @@ class SynchronizationViewModel( scope.launch { activeIdentityStateFlow.collectLatest { identity -> logger.d("Synchronization identity: $identity") - identity?.nostrPrivateKey?.let { nostrPrivateKey -> - val keyPair = KeyPair( - privKey = nostrPrivateKey.value.toByteArray() - ) + identity?.let { + // Through the helper, never by hand: a read-only identity has no private + // key, and quartz's KeyPair given neither key generates a fresh one. + val keyPair = identity.toKeyPair() // Anything a previous run left mid-flight is ours to retry: it was flipped to // "processing" before the publish and nothing observes that status, so @@ -215,13 +216,23 @@ class SynchronizationViewModel( // throw in one must not take the other two down with it for the rest of the // session. Each pump also guards its own per-request body (see guardPump). supervisorScope { - launch(Dispatchers.IO) { observePendingBroadcastNostrEventRequests(keyPair) } launch(Dispatchers.IO) { observePendingSyncNostrEventRequests(keyPair) } launch(Dispatchers.IO) { observePendingNegentropySynchronizeRequests(keyPair) } - // Runs until cancelled rather than draining a queue, but it belongs - // here for the same reason the pumps do: it needs the active wallet's - // key pair, and a wallet switch must tear it down. - launch(Dispatchers.IO) { liveSubscriptionManager.observe(keyPair) } + // A read-only identity runs the two pumps that read and neither of + // the two that write. The rule Phase 5 of docs/npub-sign-in.md + // leans on: such an identity never sends anything to a relay, not a + // signature and not a copy. The broadcast pump would find nothing -- + // nothing is ever signed -- and the live subscriptions are the + // gift-wrap inbox and the group-membership follow, both fetching + // things this identity cannot open. Not asking is not an + // optimisation; it is the identity not asking for what it cannot use. + if (identity.canSign) { + launch(Dispatchers.IO) { observePendingBroadcastNostrEventRequests(keyPair) } + // Runs until cancelled rather than draining a queue, but it + // belongs here for the same reason the pumps do: it needs the + // active wallet's key pair, and a wallet switch must tear it down. + launch(Dispatchers.IO) { liveSubscriptionManager.observe(keyPair) } + } } } } diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/ReadOnlyGiftWrapDaoJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/ReadOnlyGiftWrapDaoJvmTest.kt new file mode 100644 index 00000000..7a4478fc --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/ReadOnlyGiftWrapDaoJvmTest.kt @@ -0,0 +1,106 @@ +package press.mantra.compose.database.dao + +import androidx.room3.Room +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent +import kotlinx.coroutines.runBlocking +import press.mantra.compose.database.MantraDatabase +import press.mantra.compose.database.builder.getRoomDatabase +import press.mantra.compose.database.model.NostrEvent +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.time.Instant + +/** + * A gift wrap addressed to a read-only identity is kept and not opened. + * + * `storeNostrEvent` is `@Transaction` and indexes inside it, and a wrap addressed to the + * active key that cannot be unsealed throws -- so before the guard in `indexNostrEvent` + * the throw took the event with it, and looked in the logs like a decryption failure. + * Worse, `decryptGiftWrapSeal` builds `KeyPair(privKey = keyPair.privKey)`, and in + * quartz a `KeyPair` given neither key generates a fresh one: the unseal would have run + * with a key nobody has. A read-only identity holds no key by construction, and the + * wrap is what the branch for someone else's mail already does with it: stored, so the + * key that opens it can be signed in later. + */ +class ReadOnlyGiftWrapDaoJvmTest { + + private val db: MantraDatabase = getRoomDatabase( + Room.inMemoryDatabaseBuilder() + ) + + @AfterTest + fun closeDb() = db.close() + + private val relay = "wss://relay.example" + + /** The user, as the sync pump sees them when the device holds only their public key. */ + private val us = KeyPair() + private val readOnlyUs = KeyPair(pubKey = us.pubKey) + private val peer = KeyPair() + + private fun wrapTo(recipient: KeyPair): NostrEvent { + val signer = NostrSignerSync(peer) + val seal = signer.signNormal( + createdAt = 1_700_000_000, + kind = SealedRumorEvent.KIND, + tags = emptyArray(), + content = signer.nip44Encrypt( + plaintext = """{"kind":14,"content":"dumela"}""", + toPublicKey = recipient.pubKey.toHexKey(), + ), + ) + val giftWrap = GiftWrapEvent.create( + event = seal, + recipientPubKey = recipient.pubKey.toHexKey(), + createdAt = 1_700_000_100, + ) + return NostrEvent( + id = giftWrap.id, + pubKey = giftWrap.pubKey, + kind = giftWrap.kind, + tags = giftWrap.tags, + content = giftWrap.content, + sig = giftWrap.sig, + createdAt = Instant.fromEpochSeconds(giftWrap.createdAt), + ) + } + + @Test + fun `a wrap addressed to a read-only identity is stored, and its seal is not`() = runBlocking { + val wrap = wrapTo(us) + + db.nostrDao().storeNostrEvent( + nostrEvent = wrap, + relayURL = relay, + synchronizationRelayURLs = listOf(relay), + level = 0, + activeKeyPair = readOnlyUs, + ) + + assertNotNull(db.nostrEventDao().getNostrEventById(wrap.id), "the event itself must survive the failed unseal") + assertEquals(listOf(wrap.id), db.giftWrapMessageDao().getAllGiftWrapMessages().map { it.id }) + assertEquals(emptyList(), db.giftWrapSealDao().getAllGiftWrapSeals(), "nothing was opened") + } + + /** The same wrap, once the key is here: the seal comes out. The guard is only about the key's absence. */ + @Test + fun `the same wrap opens once the identity holds its key`() = runBlocking { + val wrap = wrapTo(us) + + db.nostrDao().storeNostrEvent( + nostrEvent = wrap, + relayURL = relay, + synchronizationRelayURLs = listOf(relay), + level = 0, + activeKeyPair = us, + ) + + assertEquals(1, db.giftWrapSealDao().getAllGiftWrapSeals().size) + } +}