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:
@@ -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>
|
||||
|
||||
@@ -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 ->
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user