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 <noreply@anthropic.com>
Pulled-From: curated/curated@7db863e392
This commit is contained in:
Kgothatso Ngako
2026-09-12 20:28:57 +02:00
parent d5bd248364
commit 6532bfc45f
7 changed files with 191 additions and 9 deletions

View File

@@ -265,6 +265,7 @@
<string name="this_will_be_the_display_name_for_this_chat">This will be the display name for this chat room.</string>
<string name="this_will_be_the_display_name_for_your">This will be the display name for your profile and also important for search.</string>
<string name="this_will_give_you_read_only_access_to_the">This will give you read only access to the profile.</string>
<string name="read_only">Read only</string>
<string name="this_will_give_you_write_access_to_the">This will give you write access to the profile.</string>
<string name="torch_will_be_broadcast_what_you_publish_to">Mantra broadcasts what you publish to a distributed set of relays, so it stays decentralised.</string>
<string name="translate_chunk">Translate chunk</string>

View File

@@ -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 ->

View File

@@ -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.
*

View File

@@ -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) {

View File

@@ -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<HexKey, NostrCredential> = when (val result = NostrCredentialManager.loadAndDecrypt(phoenixGlobal)) {
is DecryptNostrCredentialsResult.Failure.SerializationError -> {
log.e { "cannot deserialize nostr credentials file" }

View File

@@ -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) }
}
}
}
}

View File

@@ -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<MantraDatabase>()
)
@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<SealedRumorEvent>(
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)
}
}