diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip46Signer/Nip46SignerState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip46Signer/Nip46SignerState.kt index c91bc3ded3..fad4f4e7f1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip46Signer/Nip46SignerState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip46Signer/Nip46SignerState.kt @@ -79,12 +79,13 @@ class Nip46SignerState( private val authorizer = Nip46PermissionAuthorizer( ledger = ledger, + signerPubKey = signer.pubKey, validateSecret = { clientPubKey, offered -> // A new app pairs with the current bunker secret; an already-connected app // re-authenticates by identity (it already holds a trust level in the ledger). val secret = settings.nip46BunkerSecret.value (secret.isNotEmpty() && offered == secret) || - ledger.hasPolicy(Nip46PermissionAuthorizer.coordinateFor(clientPubKey)) + ledger.hasPolicy(Nip46PermissionAuthorizer.coordinateFor(signer.pubKey, clientPubKey)) }, ) @@ -163,7 +164,7 @@ class Nip46SignerState( client.publish(reply, offer.relays) // Register the app (the paste is the user's consent) and listen on its relays. - val coordinate = Nip46PermissionAuthorizer.coordinateFor(offer.clientPubKey) + val coordinate = authorizer.coordinateFor(offer.clientPubKey) if (!ledger.hasPolicy(coordinate)) { ledger.setPolicy(coordinate, authorizer.defaultPolicyOnConnect) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppsScreen.kt index 7c9f310a73..4fd4fc0fea 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppsScreen.kt @@ -105,7 +105,7 @@ fun ConnectedAppsScreen( LaunchedEffect(Unit) { val initial = withContext(Dispatchers.Default) { - loadConnectedApps(capabilityLedger, signerLedger) + loadConnectedApps(capabilityLedger, signerLedger, accountViewModel.account.signer.pubKey) } items = initial // Only include real napplet authors in the manifest subscription — skip the "browser" @@ -442,12 +442,18 @@ private fun AppSignerPolicy.shortLabel(): String = private suspend fun loadConnectedApps( capabilityLedger: NappletPermissionLedger, signerLedger: NostrSignerPermissionLedger, + signerPubKey: HexKey, ): List { val capGrants = capabilityLedger.allPersistedGrants() val signerPolicies = signerLedger.store.allPolicies() val allCoordinates = (capGrants.keys + signerPolicies.keys).toSet() return allCoordinates - .map { coordinate -> + // NIP-46 grants are namespaced by signer, so only surface this account's remote clients; + // napplet/browser grants stay app-global and are shown for every account as before. + .filter { coordinate -> + coordinate.substringBefore(':') != Nip46PermissionAuthorizer.COORDINATE_PREFIX || + Nip46PermissionAuthorizer.belongsTo(coordinate, signerPubKey) + }.map { coordinate -> ConnectedAppEntry( coordinate = coordinate, signerPolicy = signerPolicies[coordinate], diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/nip46/Nip46SignerScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/nip46/Nip46SignerScreen.kt index a609bcf60f..c15648de23 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/nip46/Nip46SignerScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/nip46/Nip46SignerScreen.kt @@ -125,7 +125,7 @@ fun Nip46SignerScreen( account.signerPermissionLedger.store .allPolicies() .keys - .count { it.startsWith("${Nip46PermissionAuthorizer.COORDINATE_PREFIX}:") } + .count { Nip46PermissionAuthorizer.belongsTo(it, account.signer.pubKey) } } fun onConnect(uri: String) { diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/Nip46PermissionAuthorizer.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/Nip46PermissionAuthorizer.kt index 37fdca977a..bf7cc7c6f7 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/Nip46PermissionAuthorizer.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/Nip46PermissionAuthorizer.kt @@ -58,6 +58,8 @@ import com.vitorpamplona.quartz.nip46RemoteSigner.server.Nip46RequestAuthorizer */ class Nip46PermissionAuthorizer( val ledger: NostrSignerPermissionLedger, + /** The user's own signer pubkey — namespaces this account's grants in the app-global store. */ + val signerPubKey: HexKey, /** Validates the connect secret for a client (bunker secret, or the offer secret in the nostrconnect flow). */ val validateSecret: suspend (clientPubKey: HexKey, offeredSecret: String?) -> Boolean, /** Trust level assigned to a freshly paired app that has no policy yet. */ @@ -65,6 +67,9 @@ class Nip46PermissionAuthorizer( /** Invoked after a successful connect so the host can persist display metadata (name/url/image). */ val onConnected: (suspend (clientPubKey: HexKey, request: BunkerRequestConnect) -> Unit)? = null, ) : Nip46RequestAuthorizer { + /** The ledger coordinate for [clientPubKey] under this account. */ + fun coordinateFor(clientPubKey: HexKey): String = coordinateFor(signerPubKey, clientPubKey) + override suspend fun onConnect( clientPubKey: HexKey, request: BunkerRequestConnect, @@ -99,17 +104,35 @@ class Nip46PermissionAuthorizer( return allowed } + override suspend fun onLogout(clientPubKey: HexKey) { + // The client asked to disconnect — drop its standing grant so it must pair again. + ledger.revokeAll(coordinateFor(clientPubKey)) + } + companion object { /** Ledger coordinate namespace for NIP-46 remote-signer clients. */ const val COORDINATE_PREFIX = "nip46" private const val ACK = "ack" - /** The Connected-Apps ledger coordinate for a NIP-46 client, e.g. `nip46:`. */ - fun coordinateFor(clientPubKey: HexKey): String = "$COORDINATE_PREFIX:$clientPubKey" + /** + * The Connected-Apps ledger coordinate for a NIP-46 client, namespaced by + * the user's signer so the same client paired with two accounts on one + * device gets independent grants: `nip46::`. + */ + fun coordinateFor( + signerPubKey: HexKey, + clientPubKey: HexKey, + ): String = "$COORDINATE_PREFIX:$signerPubKey:$clientPubKey" - /** The client pubkey of a `nip46:` coordinate, or `null` if it is not one. */ - fun clientPubKeyOf(coordinate: String): HexKey? = if (coordinate.startsWith("$COORDINATE_PREFIX:")) coordinate.substringAfter(':') else null + /** True when [coordinate] is a NIP-46 grant belonging to [signerPubKey]. */ + fun belongsTo( + coordinate: String, + signerPubKey: HexKey, + ): Boolean = coordinate.startsWith("$COORDINATE_PREFIX:$signerPubKey:") + + /** The client pubkey of a `nip46::` coordinate, or `null` if it is not one. */ + fun clientPubKeyOf(coordinate: String): HexKey? = if (coordinate.startsWith("$COORDINATE_PREFIX:")) coordinate.substringAfterLast(':') else null /** Maps a signing/encryption/decryption [BunkerRequest] to the [NostrSignerOp] it needs. */ fun BunkerRequest.toSignerOp(): NostrSignerOp? = diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/Nip46PermissionAuthorizerTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/Nip46PermissionAuthorizerTest.kt index c0e1e89eb5..ff4d2f02f8 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/Nip46PermissionAuthorizerTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/Nip46PermissionAuthorizerTest.kt @@ -34,8 +34,9 @@ import kotlin.test.assertFalse import kotlin.test.assertTrue class Nip46PermissionAuthorizerTest { + private val signer = "a".repeat(64) private val client = "c".repeat(64) - private val coordinate = Nip46PermissionAuthorizer.coordinateFor(client) + private val coordinate = Nip46PermissionAuthorizer.coordinateFor(signer, client) private fun ledger() = NostrSignerPermissionLedger(InMemoryNostrSignerPermissionStore()) @@ -45,7 +46,7 @@ class Nip46PermissionAuthorizerTest { fun connectWithValidSecretRegistersReasonablePolicyAndEchoesSecret() = runTest { val ledger = ledger() - val authorizer = Nip46PermissionAuthorizer(ledger, validateSecret = { _, s -> s == "good" }) + val authorizer = Nip46PermissionAuthorizer(ledger, signerPubKey = signer, validateSecret = { _, s -> s == "good" }) val decision = authorizer.onConnect(client, BunkerRequestConnect(id = "1", remoteKey = client, secret = "good")) @@ -58,7 +59,7 @@ class Nip46PermissionAuthorizerTest { fun connectWithBadSecretRejectsAndDoesNotRegister() = runTest { val ledger = ledger() - val authorizer = Nip46PermissionAuthorizer(ledger, validateSecret = { _, s -> s == "good" }) + val authorizer = Nip46PermissionAuthorizer(ledger, signerPubKey = signer, validateSecret = { _, s -> s == "good" }) val decision = authorizer.onConnect(client, BunkerRequestConnect(id = "1", remoteKey = client, secret = "bad")) @@ -71,7 +72,7 @@ class Nip46PermissionAuthorizerTest { runTest { val ledger = ledger() ledger.setPolicy(coordinate, AppSignerPolicy.FULL_TRUST) - val authorizer = Nip46PermissionAuthorizer(ledger, validateSecret = { _, _ -> true }) + val authorizer = Nip46PermissionAuthorizer(ledger, signerPubKey = signer, validateSecret = { _, _ -> true }) authorizer.onConnect(client, BunkerRequestConnect(id = "1", remoteKey = client, secret = "x")) @@ -83,7 +84,7 @@ class Nip46PermissionAuthorizerTest { runTest { val ledger = ledger() ledger.setPolicy(coordinate, AppSignerPolicy.REASONABLE) - val authorizer = Nip46PermissionAuthorizer(ledger, validateSecret = { _, _ -> true }) + val authorizer = Nip46PermissionAuthorizer(ledger, signerPubKey = signer, validateSecret = { _, _ -> true }) assertTrue(authorizer.authorize(client, signRequest(TextNoteEvent.KIND))) assertFalse(authorizer.authorize(client, BunkerRequestNip44Decrypt("2", client, "ct"))) @@ -94,7 +95,7 @@ class Nip46PermissionAuthorizerTest { runTest { val ledger = ledger() ledger.setPolicy(coordinate, AppSignerPolicy.PARANOID) - val authorizer = Nip46PermissionAuthorizer(ledger, validateSecret = { _, _ -> true }) + val authorizer = Nip46PermissionAuthorizer(ledger, signerPubKey = signer, validateSecret = { _, _ -> true }) assertFalse(authorizer.authorize(client, signRequest(TextNoteEvent.KIND))) @@ -107,4 +108,26 @@ class Nip46PermissionAuthorizerTest { assertEquals(client, Nip46PermissionAuthorizer.clientPubKeyOf(coordinate)) assertEquals(null, Nip46PermissionAuthorizer.clientPubKeyOf("browser:https://x.com")) } + + @Test + fun sameClientOnTwoAccountsGetsIndependentCoordinates() { + val otherSigner = "d".repeat(64) + val a = Nip46PermissionAuthorizer.coordinateFor(signer, client) + val b = Nip46PermissionAuthorizer.coordinateFor(otherSigner, client) + assertTrue(a != b) + assertTrue(Nip46PermissionAuthorizer.belongsTo(a, signer)) + assertFalse(Nip46PermissionAuthorizer.belongsTo(a, otherSigner)) + } + + @Test + fun logoutRevokesTheClientsGrant() = + runTest { + val ledger = ledger() + ledger.setPolicy(coordinate, AppSignerPolicy.FULL_TRUST) + val authorizer = Nip46PermissionAuthorizer(ledger, signerPubKey = signer, validateSecret = { _, _ -> true }) + + authorizer.onLogout(client) + + assertEquals(null, ledger.store.loadPolicy(coordinate)) + } }