mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-10 00:16:59 +00:00
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:
+3
-2
@@ -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)
|
||||
}
|
||||
|
||||
+8
-2
@@ -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],
|
||||
|
||||
+1
-1
@@ -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) {
|
||||
|
||||
+27
-4
@@ -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? =
|
||||
|
||||
+29
-6
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user