feat: namespace NIP-46 grants by signer + revoke on logout

The Connected Apps signer store is app-global, so a remote client keyed only by
its own pubkey would share one trust level across every local account. Namespace
the coordinate as `nip46:<signerPubKey>:<clientPubKey>` so the same client paired
with two accounts on one device gets independent grants.

- Nip46PermissionAuthorizer takes the user's signerPubKey; coordinateFor/belongsTo
  encode + match the namespace; clientPubKeyOf reads the trailing segment.
- onLogout now revokes the client's grant (wired through the new quartz hook).
- Connected Apps lists only the active account's remote clients (napplet/browser
  grants stay app-global); the signer screen counts the same way.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
This commit is contained in:
Claude
2026-07-17 15:18:24 +00:00
parent d46ac987e8
commit dea711596e
5 changed files with 68 additions and 15 deletions
@@ -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)
}
@@ -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<ConnectedAppEntry> {
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],
@@ -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) {
@@ -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:<pubkey>`. */
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:<signerPubKey>:<clientPubKey>`.
*/
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:<signer>:<client>` 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? =
@@ -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))
}
}