From 44b7ae165ffdd92888fe75a73932c1b29d8940a7 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 23:30:44 +0000 Subject: [PATCH 1/3] feat(napplet): expose NIP-44 encrypt/decrypt on the injected window.nostr The injected NIP-07 provider offered only getPublicKey/getRelays/signEvent, so a page hosted in the nSite viewer or the in-app browser could sign but never seal: a NIP-59 kind:13 seal is NIP-44 ciphertext authored by the real key, and signEvent alone cannot produce one. That put NIP-17 DMs and every gift-wrapped app protocol out of reach of any web app logging in with Amethyst. Adds nostr.nip44Encrypt/nip44Decrypt to the broker, behind a new SIGNER capability. SIGNER is website-only by construction: no NIP-5D domain maps to it, so resolveRequiredCapabilities can never hand it to a locked napplet however its manifest is written. Individual calls still pass the per-operation signer ledger, reusing the vocabulary NIP-46 already uses for the same ops -- encrypt auto-allows under REASONABLE, decrypt always asks. nip04 stays deliberately absent. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012Hge2jR1BPnyZse75VQ4kg --- .../napplet/NappletCapabilityLabels.kt | 2 + .../amethyst/napplet/NappletConsentSummary.kt | 8 ++ .../amethyst/napplet/NostrSignerOpLabels.kt | 18 +++ .../loggedIn/napplets/NappletCapabilityExt.kt | 1 + amethyst/src/main/res/values/strings.xml | 4 + .../napplet/NappletProtocolJsonTest.kt | 29 +++++ .../composeResources/files/napplet/shim.js | 11 +- .../amethyst/commons/napplet/NappletBroker.kt | 6 + .../commons/napplet/NappletCapability.kt | 15 +++ .../napplet/protocol/NappletRequest.kt | 35 ++++++ .../protocol/NappletRequestSignerOp.kt | 5 + .../napplet/protocol/NappletResponse.kt | 8 ++ .../commons/napplet/NappletBrokerTest.kt | 105 ++++++++++++++++++ .../napplet/protocol/NappletProtocolJson.kt | 8 ++ .../amethyst/napplethost/HostProfile.kt | 11 +- 15 files changed, 262 insertions(+), 4 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletCapabilityLabels.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletCapabilityLabels.kt index 15ab1abd71..5e988ce809 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletCapabilityLabels.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletCapabilityLabels.kt @@ -31,6 +31,7 @@ fun NappletCapability.labelRes(): Int = NappletCapability.IDENTITY -> R.string.napplet_cap_identity NappletCapability.KEYS -> R.string.napplet_cap_keys NappletCapability.RELAY -> R.string.napplet_cap_relay + NappletCapability.SIGNER -> R.string.napplet_cap_signer NappletCapability.STORAGE -> R.string.napplet_cap_storage NappletCapability.VALUE -> R.string.napplet_cap_value NappletCapability.RESOURCE -> R.string.napplet_cap_resource @@ -47,6 +48,7 @@ fun NappletCapability.descriptionRes(): Int = NappletCapability.IDENTITY -> R.string.napplet_cap_identity_desc NappletCapability.KEYS -> R.string.napplet_cap_keys_desc NappletCapability.RELAY -> R.string.napplet_cap_relay_desc + NappletCapability.SIGNER -> R.string.napplet_cap_signer_desc NappletCapability.STORAGE -> R.string.napplet_cap_storage_desc NappletCapability.VALUE -> R.string.napplet_cap_value_desc NappletCapability.RESOURCE -> R.string.napplet_cap_resource_desc diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConsentSummary.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConsentSummary.kt index 583cda40b4..6b520ba30a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConsentSummary.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConsentSummary.kt @@ -275,6 +275,14 @@ class NappletConsentSummary( } } is NappletRequest.PublishEncrypted -> context.getString(R.string.napplet_consent_publish_encrypted) + is NappletRequest.Nip44Encrypt -> { + val preview = request.plaintext.take(160).trim() + val summary = context.getString(R.string.napplet_consent_nip44_encrypt, counterpartyLabel(request.peer)) + if (preview.isEmpty()) summary else summary + "\n\u201C$preview\u201D" + } + // The ciphertext is meaningless to show, so name the counterparty instead — that is + // the decision the user is actually making ("let this page read messages from X"). + is NappletRequest.Nip44Decrypt -> context.getString(R.string.napplet_consent_nip44_decrypt, counterpartyLabel(request.peer)) is NappletRequest.QueryEvents, is NappletRequest.Subscribe -> context.getString(R.string.napplet_consent_query) is NappletRequest.StorageGet, is NappletRequest.StorageSet, is NappletRequest.StorageRemove, is NappletRequest.StorageKeys -> context.getString(R.string.napplet_consent_storage) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NostrSignerOpLabels.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NostrSignerOpLabels.kt index 3472835691..af0c734305 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NostrSignerOpLabels.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NostrSignerOpLabels.kt @@ -84,6 +84,10 @@ fun buildSignerConsentInfo( is NappletRequest.Publish -> request.content.take(160).trim() is NappletRequest.SignEvent -> request.content.take(160).trim() is NappletRequest.PublishEncrypted -> request.content.take(160).trim() + // Encryption shows the plaintext the page wants sealed; decryption has only ciphertext, + // which tells the user nothing, so its preview stays empty and the counterparty in + // rawData carries the meaning. + is NappletRequest.Nip44Encrypt -> request.plaintext.take(160).trim() else -> "" } val rawData = @@ -105,6 +109,20 @@ fun buildSignerConsentInfo( node.put("content", request.content) JacksonMapper.mapper.writerWithDefaultPrettyPrinter().writeValueAsString(node) } + is NappletRequest.Nip44Encrypt -> { + val node = JacksonMapper.mapper.createObjectNode() + node.put("operation", "nip44.encrypt") + node.put("peer", request.peer) + node.put("plaintext", request.plaintext) + JacksonMapper.mapper.writerWithDefaultPrettyPrinter().writeValueAsString(node) + } + is NappletRequest.Nip44Decrypt -> { + val node = JacksonMapper.mapper.createObjectNode() + node.put("operation", "nip44.decrypt") + node.put("peer", request.peer) + node.put("ciphertext", request.ciphertext) + JacksonMapper.mapper.writerWithDefaultPrettyPrinter().writeValueAsString(node) + } else -> "" } val previewTemplate = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/NappletCapabilityExt.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/NappletCapabilityExt.kt index 201c818aa3..c6f554e7b3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/NappletCapabilityExt.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/NappletCapabilityExt.kt @@ -29,6 +29,7 @@ internal fun NappletCapability.symbol(): MaterialSymbol = NappletCapability.IDENTITY -> MaterialSymbols.AccountCircle NappletCapability.KEYS -> MaterialSymbols.Key NappletCapability.RELAY -> MaterialSymbols.Public + NappletCapability.SIGNER -> MaterialSymbols.Lock NappletCapability.STORAGE -> MaterialSymbols.Storage NappletCapability.VALUE -> MaterialSymbols.Bolt NappletCapability.RESOURCE -> MaterialSymbols.Language diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index da24c209c9..f4e2d47349 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -401,6 +401,7 @@ Identity Keyboard actions Relays + Encryption Storage Payments Network @@ -409,6 +410,7 @@ Read your public key Bind keyboard shortcuts Read, and sign & publish your events + Encrypt and decrypt private messages with your key Its own private storage Pay Lightning invoices Fetch web and Blossom resources @@ -426,6 +428,8 @@ This nApplet wants to sign and publish a kind %1$d event as you. This nApplet wants to sign and publish a kind %1$d event as you: This nApplet wants to send an encrypted event as you. + This site wants to encrypt a message to %1$s with your Nostr key. + This site wants to decrypt a message from %1$s with your Nostr key. This nApplet wants to read events from your relays. This nApplet wants to use its private storage. This nApplet wants to pay a Lightning invoice. diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/napplet/NappletProtocolJsonTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/napplet/NappletProtocolJsonTest.kt index 211ea1a7be..1f2422de4b 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/napplet/NappletProtocolJsonTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/napplet/NappletProtocolJsonTest.kt @@ -368,4 +368,33 @@ class NappletProtocolJsonTest { val failed = json.parseToJsonElement(NappletProtocolJson.encodeResponse("relay.publish", NappletResponse.Failed("boom"))).jsonObject assertEquals("boom", failed["reason"]?.jsonPrimitive?.content) } + + @Test + fun decodesNip44EncryptAndDecrypt() { + // The envelope the injected window.nostr.nip44 shim posts. Field names are the contract + // between shim.js and this decoder — renaming either side silently breaks NIP-17 in nSites. + assertEquals( + NappletRequest.Nip44Encrypt("pk", "gm"), + NappletProtocolJson.decodeRequest("""{"type":"nostr.nip44Encrypt","id":"1","peer":"pk","plaintext":"gm"}"""), + ) + assertEquals( + NappletRequest.Nip44Decrypt("pk", "cipher"), + NappletProtocolJson.decodeRequest("""{"type":"nostr.nip44Decrypt","id":"1","peer":"pk","ciphertext":"cipher"}"""), + ) + } + + @Test + fun nip44WithoutAPeerIsRejectedRatherThanDefaultedToSomeKey() { + // `peer` is required: silently substituting a default would encrypt to the wrong party. + assertThrowsAny { NappletProtocolJson.decodeRequest("""{"type":"nostr.nip44Encrypt","id":"1","plaintext":"gm"}""") } + assertThrowsAny { NappletProtocolJson.decodeRequest("""{"type":"nostr.nip44Decrypt","id":"1","ciphertext":"c"}""") } + } + + @Test + fun encodesTextResultsUnderValue() { + val result = json.parseToJsonElement(NappletProtocolJson.encodeResponse("nostr.nip44Encrypt", NappletResponse.Text("CIPHER"))).jsonObject + assertEquals("nostr.nip44Encrypt.result", result["type"]?.jsonPrimitive?.content) + assertTrue(result["ok"]!!.jsonPrimitive.boolean) + assertEquals("CIPHER", result["value"]?.jsonPrimitive?.content) + } } diff --git a/commons/src/commonMain/composeResources/files/napplet/shim.js b/commons/src/commonMain/composeResources/files/napplet/shim.js index f36c48280b..1d91fae295 100644 --- a/commons/src/commonMain/composeResources/files/napplet/shim.js +++ b/commons/src/commonMain/composeResources/files/napplet/shim.js @@ -812,11 +812,20 @@ // window.__nappletNip07 synchronously before this shim). Lets standard Nostr web apps "log in with // Amethyst" and sign, bridged to the same consent-gated signer: getPublicKey + getRelays reuse the // identity reads; signEvent is sign-only (no publish) and honors the app's created_at. + // nip44 is optional in NIP-07 but not optional in practice: a kind:13 seal is NIP-44 ciphertext + // authored by the real key, so without it a page can sign yet cannot build a NIP-59 gift wrap — + // NIP-17 DMs and every gift-wrapped app protocol are simply unreachable. The shell does the crypto + // and returns only the result; the key never enters the page. nip04 stays deliberately absent + // (deprecated, and nothing that still needs it should be encouraged). if (window.__nappletNip07 && !window.nostr) { window.nostr = Object.freeze({ getPublicKey: function(){ return field(call('identity.getPublicKey'), 'pubkey'); }, getRelays: function(){ return field(call('identity.getRelays'), 'relays'); }, - signEvent: function(event){ return field(call('nostr.signEvent', { event: event }), 'event'); } + signEvent: function(event){ return field(call('nostr.signEvent', { event: event }), 'event'); }, + nip44: Object.freeze({ + encrypt: function(peer, plaintext){ return field(call('nostr.nip44Encrypt', { peer: peer, plaintext: String(plaintext) }), 'value'); }, + decrypt: function(peer, ciphertext){ return field(call('nostr.nip44Decrypt', { peer: peer, ciphertext: String(ciphertext) }), 'value'); } + }) }); } })(); diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBroker.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBroker.kt index b7f9adf73d..4f17c22b08 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBroker.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBroker.kt @@ -245,6 +245,12 @@ class NappletBroker( signAndPublish(request.kind, withRecipientTag(request.tags, request.recipient), ciphertext) } + // NIP-07 nip44.encrypt/decrypt: the shell runs the crypto with the real key and hands back + // only the result, so the page can build its own NIP-59 seals without ever seeing the key. + is NappletRequest.Nip44Encrypt -> NappletResponse.Text(signer.nip44Encrypt(request.plaintext, request.peer)) + + is NappletRequest.Nip44Decrypt -> NappletResponse.Text(signer.nip44Decrypt(request.ciphertext, request.peer)) + is NappletRequest.QueryEvents -> { val gateway = relay ?: return NappletResponse.Unsupported("relay.query") NappletResponse.Events(gateway.query(request.filters)) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletCapability.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletCapability.kt index 33ee94cf7c..063fb26574 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletCapability.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletCapability.kt @@ -44,6 +44,21 @@ enum class NappletCapability { /** `relay` — publish (shell-signed), query, and subscribe to the user's relays. */ RELAY, + /** + * NIP-44 encrypt/decrypt with the user's key, returning the ciphertext/plaintext to the caller + * instead of publishing it. Needed by any standard Nostr web app that builds NIP-59 seals + * itself (NIP-17 DMs, gift-wrapped app protocols) — signing alone cannot produce a seal. + * + * Deliberately **not** in [fromNapDomain]: no NIP-5D domain maps here, so a locked napplet can + * never declare it. It is granted only to the website posture (the NIP-07 `window.nostr` + * surface), where the page is already trusted with `signEvent`. Every individual call still + * passes the per-operation signer ledger + * ([Encrypt][com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerOp.Encrypt] / + * [Decrypt][com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerOp.Decrypt]), + * which is what actually keeps decryption behind a prompt. + */ + SIGNER, + /** `storage` — a per-applet sandboxed key-value store, namespaced by applet identity. */ STORAGE, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletRequest.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletRequest.kt index 25a58bca43..385a28596a 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletRequest.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletRequest.kt @@ -175,6 +175,41 @@ sealed interface NappletRequest { } } + /** + * NIP-07 `window.nostr.nip44.encrypt`: NIP-44 encrypt [plaintext] to [peer] with the user's key + * and return the ciphertext **without publishing it**. This is what lets a web app build its own + * NIP-59 seals (a kind:13 seal is NIP-44 ciphertext authored by the real key, so `signEvent` + * alone cannot produce one) — NIP-17 DMs and every gift-wrapped app protocol need it. + * + * The page never touches the key: the shell encrypts and hands back only the result. + * + * [signsAsUser] stays false — this produces no event and no signature; the gate that matters is + * the [NostrSignerOp][com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerOp] + * mapping in `toSignerOp`. + */ + data class Nip44Encrypt( + val peer: HexKey, + val plaintext: String, + ) : NappletRequest { + override val capability get() = NappletCapability.SIGNER + } + + /** + * NIP-07 `window.nostr.nip44.decrypt`: NIP-44 decrypt [ciphertext] from [peer] with the user's + * key and return the plaintext. The counterpart of [Nip44Encrypt] — a NIP-17 client needs it to + * open incoming seals. + * + * Strictly more dangerous than encryption (it reads, rather than writes, private content), so it + * maps to [NostrSignerOp.Decrypt][com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerOp.Decrypt], + * which always asks under the REASONABLE policy instead of auto-approving. + */ + data class Nip44Decrypt( + val peer: HexKey, + val ciphertext: String, + ) : NappletRequest { + override val capability get() = NappletCapability.SIGNER + } + /** Read events matching [filters] (from the cache and/or a bounded relay fetch). */ data class QueryEvents( val filters: List, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletRequestSignerOp.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletRequestSignerOp.kt index 905f137517..01aa8510dc 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletRequestSignerOp.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletRequestSignerOp.kt @@ -34,5 +34,10 @@ fun NappletRequest.toSignerOp(): NostrSignerOp? = is NappletRequest.Publish -> NostrSignerOp.SignKind(kind) is NappletRequest.SignEvent -> NostrSignerOp.SignKind(kind) is NappletRequest.PublishEncrypted -> NostrSignerOp.Encrypt + is NappletRequest.Nip44Encrypt -> NostrSignerOp.Encrypt + // The broad grant, matching what NIP-46's nip44_decrypt maps to. The narrower + // DecryptFrom(peer) exists, but only the NIP-46 authorizer honours it today; recording one + // here would be a grant this broker never reads back, so the user would re-prompt forever. + is NappletRequest.Nip44Decrypt -> NostrSignerOp.Decrypt else -> null } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletResponse.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletResponse.kt index 07f0394b0c..f87cff6438 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletResponse.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletResponse.kt @@ -65,6 +65,14 @@ sealed interface NappletResponse { val value: String?, ) : NappletResponse + /** + * A single opaque string result, returned in the `value` field. Used by the NIP-44 ops, where the + * payload is ciphertext one way and plaintext the other and the host has nothing to interpret. + */ + data class Text( + val value: String, + ) : NappletResponse + /** Result of `storage.keys` (and other string-list reads). */ data class Strings( val values: List, diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBrokerTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBrokerTest.kt index fc59bb7f87..749da8665d 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBrokerTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBrokerTest.kt @@ -745,4 +745,109 @@ class NappletBrokerTest { assertIs(tooLarge) assertEquals("too-large", tooLarge.error) } + + // ---- NIP-44 (window.nostr.nip44) ------------------------------------------------------------ + // + // The gap these close: a page could sign but not encrypt, so it could not build a kind:13 seal + // and every NIP-59 / NIP-17 flow was unreachable through the in-app browser. + + @Test + fun nip44RoundTripsThroughTheBrokerWithoutExposingTheKey() = + runTest { + val peer = NostrSignerInternal(KeyPair("11".repeat(32).hexToByteArray())) + val broker = broker(ScriptedPrompt(GrantState.ALLOW_ALWAYS)) + + val encrypted = + broker.handle(applet, NappletRequest.Nip44Encrypt(peer.pubKey, "gm from the nsite"), allDeclared) + assertIs(encrypted) + + // The peer decrypts it with its own key: proof this is real NIP-44 to that pubkey and + // not some shell-local encoding. + assertEquals("gm from the nsite", peer.nip44Decrypt(encrypted.value, signer.pubKey)) + + // ...and back the other way, which is what an inbound seal needs. + val sealed = peer.nip44Encrypt("hello back", signer.pubKey) + val decrypted = broker.handle(applet, NappletRequest.Nip44Decrypt(peer.pubKey, sealed), allDeclared) + assertIs(decrypted) + assertEquals("hello back", decrypted.value) + } + + @Test + fun nip44IsRefusedWhenTheHostDidNotDeclareTheSignerCapability() = + runTest { + val prompt = ScriptedPrompt(GrantState.ALLOW_ALWAYS) + val broker = broker(prompt) + + // A locked napplet's declared set can never contain SIGNER (no NAP domain maps to it), + // so the request must die at the capability gate without ever reaching a prompt. + val response = + broker.handle( + applet, + NappletRequest.Nip44Decrypt("bb".repeat(32), "cipher"), + setOf(NappletCapability.IDENTITY, NappletCapability.RELAY), + ) + + assertIs(response) + assertEquals(NappletCapability.SIGNER, response.capability) + assertEquals(0, prompt.calls) + } + + @Test + fun noNapDomainCanEverGrantTheSignerCapability() { + // The website-only guarantee is structural, not a policy someone can misconfigure: if any + // domain string ever mapped to SIGNER, a manifest could ask for the user's decryption. + assertNull(NappletCapability.fromNapDomain("signer")) + assertTrue(NappletCapability.supportedNapDomains.none { NappletCapability.fromNapDomain(it) == NappletCapability.SIGNER }) + } + + @Test + fun decryptAsksEveryTimeUnderTheReasonablePolicyWhileEncryptDoesNot() = + runTest { + // The asymmetry is the point: encrypting writes content the user is already composing, + // decrypting reads private content, so only the latter keeps prompting. + val signerLedger = NostrSignerPermissionLedger(InMemoryNostrSignerPermissionStore()) + signerLedger.setPolicy("napplet:${signer.pubKey}:${applet.coordinate}", AppSignerPolicy.REASONABLE) + + val opPrompt = ScriptedSignerPrompt(SignerOpGrant.AllowOnce) + val broker = + NappletBroker( + signer = signer, + ledger = NappletPermissionLedger(InMemoryNappletPermissionStore()), + consentPrompt = ScriptedPrompt(GrantState.ALLOW_ALWAYS), + signerLedger = signerLedger, + signerConsentPrompt = opPrompt, + ) + + val peer = NostrSignerInternal(KeyPair("22".repeat(32).hexToByteArray())) + broker.handle(applet, NappletRequest.Nip44Encrypt(peer.pubKey, "a"), allDeclared) + broker.handle(applet, NappletRequest.Nip44Encrypt(peer.pubKey, "b"), allDeclared) + assertEquals(0, opPrompt.calls) + + val sealed = peer.nip44Encrypt("secret", signer.pubKey) + broker.handle(applet, NappletRequest.Nip44Decrypt(peer.pubKey, sealed), allDeclared) + broker.handle(applet, NappletRequest.Nip44Decrypt(peer.pubKey, sealed), allDeclared) + assertEquals(2, opPrompt.calls) + } + + @Test + fun aDeclinedDecryptReturnsNoPlaintext() = + runTest { + val signerLedger = NostrSignerPermissionLedger(InMemoryNostrSignerPermissionStore()) + signerLedger.setPolicy("napplet:${signer.pubKey}:${applet.coordinate}", AppSignerPolicy.PARANOID) + + val broker = + NappletBroker( + signer = signer, + ledger = NappletPermissionLedger(InMemoryNappletPermissionStore()), + consentPrompt = ScriptedPrompt(GrantState.ALLOW_ALWAYS), + signerLedger = signerLedger, + signerConsentPrompt = ScriptedSignerPrompt(SignerOpGrant.DenyOnce), + ) + + val peer = NostrSignerInternal(KeyPair("33".repeat(32).hexToByteArray())) + val sealed = peer.nip44Encrypt("secret", signer.pubKey) + + val response = broker.handle(applet, NappletRequest.Nip44Decrypt(peer.pubKey, sealed), allDeclared) + assertIs(response) + } } diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletProtocolJson.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletProtocolJson.kt index 7cfb2a95e0..027cae99df 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletProtocolJson.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletProtocolJson.kt @@ -169,6 +169,10 @@ object NappletProtocolJson { createdAt = t["created_at"]?.jsonPrimitive?.long ?: (System.currentTimeMillis() / 1000), ) } + // NIP-07 nip44.encrypt/decrypt: crypto only, no publish. `peer` is the counterparty + // pubkey the NIP-07 call names as its first argument. + "nostr.nip44Encrypt" -> NappletRequest.Nip44Encrypt(peer = o.req("peer"), plaintext = o.str("plaintext") ?: "") + "nostr.nip44Decrypt" -> NappletRequest.Nip44Decrypt(peer = o.req("peer"), ciphertext = o.str("ciphertext") ?: "") "storage.get" -> NappletRequest.StorageGet(o.req("key"), o.storageScope()) "storage.set" -> NappletRequest.StorageSet(o.req("key"), o.req("value"), o.storageScope()) "storage.remove" -> NappletRequest.StorageRemove(o.req("key"), o.storageScope()) @@ -253,6 +257,10 @@ object NappletProtocolJson { put("ok", true) put("value", response.value) } + is NappletResponse.Text -> { + put("ok", true) + put("value", response.value) + } is NappletResponse.Strings -> { put("ok", true) // storage.keys returns `keys`; other string-list reads use `values`. diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/HostProfile.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/HostProfile.kt index eb9e32e67c..497670c654 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/HostProfile.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/HostProfile.kt @@ -41,12 +41,17 @@ enum class HostProfile { /** * What this posture is allowed to ask the broker for — THE security decision, minted into the - * launch token in the trusted main process. A website gets the IDENTITY + RELAY pair NIP-07 needs - * (consent-gated); a locked napplet gets only what its manifest `requires` declares. + * launch token in the trusted main process. A website gets the IDENTITY + RELAY + SIGNER set + * NIP-07 needs (consent-gated); a locked napplet gets only what its manifest `requires` declares. + * + * SIGNER (NIP-44 encrypt/decrypt) is website-only by construction: no NIP-5D domain maps to it, + * so [resolveRequiredCapabilities] can never produce it for a napplet however its manifest is + * written. Without it a page can sign but not seal, which locks it out of NIP-17 and every other + * gift-wrapped protocol. */ fun declaredCapabilities(requires: List): Set = when (this) { - WEBSITE -> setOf(NappletCapability.IDENTITY, NappletCapability.RELAY) + WEBSITE -> setOf(NappletCapability.IDENTITY, NappletCapability.RELAY, NappletCapability.SIGNER) NAPPLET -> resolveRequiredCapabilities(requires).capabilities.toSet() } From 5a5c6cffa8a7a136f6ef612b4f253019d7481dc0 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 23:30:52 +0000 Subject: [PATCH 2/3] feat(nip52): emit the uppercase D day-index tags on kind 31923 NIP-52 requires a `D` tag on time-based calendar events -- the day-granularity unix timestamp floor(unix_seconds / 86400), one per UTC day the start..end range spans. Amethyst emitted none, so events it authored were invisible to clients that discover calendar events by date (#D queries) rather than by scanning every 31923 in existence. CalendarTimeSlotEvent.build now emits the full set, which covers editing too: the create/edit screen rebuilds the whole event through build(), and the builder extension removes before it adds, so shortening an event drops the days it no longer covers instead of leaving them claiming it forever. end is exclusive per the spec, so an event finishing exactly at midnight does not tag the following day. The range is capped so a mistyped end date cannot produce an event too large to publish. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012Hge2jR1BPnyZse75VQ4kg --- .../nip52Calendar/appt/tags/DayIndexTag.kt | 89 ++++++++++++ .../appt/time/CalendarTimeSlotEvent.kt | 7 + .../appt/time/TagArrayBuilderExt.kt | 11 ++ .../quartz/nip52Calendar/DayIndexTagTest.kt | 133 ++++++++++++++++++ 4 files changed, 240 insertions(+) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip52Calendar/appt/tags/DayIndexTag.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip52Calendar/DayIndexTagTest.kt diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip52Calendar/appt/tags/DayIndexTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip52Calendar/appt/tags/DayIndexTag.kt new file mode 100644 index 0000000000..a1181ee75b --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip52Calendar/appt/tags/DayIndexTag.kt @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip52Calendar.appt.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** + * NIP-52's uppercase `D` day index on a kind:31923 time-based calendar event: the + * day-granularity unix timestamp `floor(unix_seconds / 86400)`, one tag per UTC calendar + * day the event's `start`..`end` range spans. + * + * It is what makes an event discoverable by date — a client browsing "what's on the 14th" + * queries `{"kinds":[31923],"#D":[""]}` instead of pulling every calendar event in + * existence and filtering client-side. Uppercase single-letter tags are indexed by relays + * exactly like lowercase ones; the case only distinguishes it from the `d` identifier. + */ +class DayIndexTag { + companion object { + const val TAG_NAME = "D" + + const val SECONDS_IN_DAY = 86400L + + /** + * The most `D` tags one event may carry. NIP-52 puts no ceiling on the range, but a + * multi-year event would otherwise emit thousands of tags and blow past relay event-size + * limits; capping keeps a mistyped end date from producing an unpublishable event. Beyond + * the cap the range is truncated (the start day is always emitted), never dropped. + */ + const val MAX_DAYS = 366 + + /** The day index containing [timestamp] (unix seconds). */ + fun dayIndex(timestamp: Long): Long = timestamp.floorDiv(SECONDS_IN_DAY) + + fun parse(tag: Array): Long? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + return tag[1].toLongOrNull() + } + + fun assemble(dayIndex: Long) = arrayOf(TAG_NAME, dayIndex.toString()) + + /** + * Every day index a `start`..`end` range touches, in order. + * + * `end` is **exclusive** per NIP-52, so an event that finishes exactly at midnight does not + * claim the following day; an event with no end (or an end at/before start) is instantaneous + * and occupies only its start day. Result is capped at [MAX_DAYS]. + */ + fun dayIndexes( + start: Long, + end: Long? = null, + ): List { + val first = dayIndex(start) + if (end == null || end <= start) return listOf(first) + + // An exclusive end lands on the first instant NOT in the range, so the last day is the + // one holding end-1. Without the -1, an event ending at 00:00:00 would tag the next day. + val last = dayIndex(end - 1) + if (last <= first) return listOf(first) + + return (first..minOf(last, first + MAX_DAYS - 1)).toList() + } + + /** [dayIndexes] already assembled into tags. */ + fun assembleAll( + start: Long, + end: Long? = null, + ) = dayIndexes(start, end).map { assemble(it) } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip52Calendar/appt/time/CalendarTimeSlotEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip52Calendar/appt/time/CalendarTimeSlotEvent.kt index af20d0abfa..a5888a45c2 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip52Calendar/appt/time/CalendarTimeSlotEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip52Calendar/appt/time/CalendarTimeSlotEvent.kt @@ -37,6 +37,7 @@ import com.vitorpamplona.quartz.nip23LongContent.tags.SummaryTag import com.vitorpamplona.quartz.nip23LongContent.tags.TitleTag import com.vitorpamplona.quartz.nip50Search.IndexableFieldVisitor import com.vitorpamplona.quartz.nip50Search.SearchableEvent +import com.vitorpamplona.quartz.nip52Calendar.appt.tags.DayIndexTag import com.vitorpamplona.quartz.nip52Calendar.appt.tags.LocationTag import com.vitorpamplona.quartz.utils.TimeUtils import kotlin.uuid.ExperimentalUuidApi @@ -76,6 +77,9 @@ class CalendarTimeSlotEvent( fun endTzId() = tags.firstTagValue("end_tzid") + /** The NIP-52 `D` day indexes this event claims. Empty for an event published without them. */ + fun dayIndexes() = tags.mapNotNull(DayIndexTag.Companion::parse) + fun summary() = tags.firstNotNullOfOrNull(SummaryTag.Companion::parse) fun image() = tags.firstNotNullOfOrNull(ImageTag.Companion::parse) @@ -109,6 +113,9 @@ class CalendarTimeSlotEvent( end?.let { endTimestamp(it) } startTzId?.let { startTzId(it) } endTzId?.let { endTzId(it) } + // NIP-52 requires the `D` day index on 31923; without it the event is invisible to + // date-indexed calendar clients, which query by day rather than scanning every event. + dayIndexes(start, end) initializer() } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip52Calendar/appt/time/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip52Calendar/appt/time/TagArrayBuilderExt.kt index 092eab71e0..9c8e4e06e1 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip52Calendar/appt/time/TagArrayBuilderExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip52Calendar/appt/time/TagArrayBuilderExt.kt @@ -26,6 +26,7 @@ import com.vitorpamplona.quartz.nip01Core.tags.people.PTag import com.vitorpamplona.quartz.nip23LongContent.tags.ImageTag import com.vitorpamplona.quartz.nip23LongContent.tags.SummaryTag import com.vitorpamplona.quartz.nip23LongContent.tags.TitleTag +import com.vitorpamplona.quartz.nip52Calendar.appt.tags.DayIndexTag import com.vitorpamplona.quartz.nip52Calendar.appt.tags.LocationTag fun TagArrayBuilder.titleTime(title: String) = addUnique(TitleTag.assemble(title)) @@ -34,6 +35,16 @@ fun TagArrayBuilder.startTimestamp(timestamp: Long) = add fun TagArrayBuilder.endTimestamp(timestamp: Long) = addUnique(arrayOf("end", timestamp.toString())) +/** + * Replaces the `D` day-index set with the one [start]..[end] spans (NIP-52). Removing first is what + * makes an edit correct: shortening an event that used to run three days must drop the two days it + * no longer covers, and appending alone would leave them claiming it forever. + */ +fun TagArrayBuilder.dayIndexes( + start: Long, + end: Long? = null, +) = remove(DayIndexTag.TAG_NAME).addAll(DayIndexTag.assembleAll(start, end)) + fun TagArrayBuilder.startTzId(tzId: String) = addUnique(arrayOf("start_tzid", tzId)) fun TagArrayBuilder.endTzId(tzId: String) = addUnique(arrayOf("end_tzid", tzId)) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip52Calendar/DayIndexTagTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip52Calendar/DayIndexTagTest.kt new file mode 100644 index 0000000000..3805ebd246 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip52Calendar/DayIndexTagTest.kt @@ -0,0 +1,133 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip52Calendar + +import com.vitorpamplona.quartz.nip01Core.core.firstTagValue +import com.vitorpamplona.quartz.nip52Calendar.appt.tags.DayIndexTag +import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent +import com.vitorpamplona.quartz.nip52Calendar.appt.time.dayIndexes +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * NIP-52's uppercase `D` day index on kind:31923 — the tag that makes a calendar event + * discoverable by date. Covers the exclusive-`end` boundary and the case split from the + * lowercase `d` identifier, which shares the same tag map. + */ +class DayIndexTagTest { + // 2026-02-14T00:00:00Z — day index 20498. + private val feb14 = 1771027200L + private val dayIndexFeb14 = 20498L + + @Test + fun dayIndexMatchesTheSpecFormula() { + // The worked example from NIP-52 itself. + assertEquals(82549L, DayIndexTag.dayIndex(82549L * 86400L)) + assertEquals(dayIndexFeb14, DayIndexTag.dayIndex(feb14)) + // Any instant inside the day maps to the same index. + assertEquals(dayIndexFeb14, DayIndexTag.dayIndex(feb14 + 86399)) + } + + @Test + fun noEndMeansASingleDay() { + assertEquals(listOf(dayIndexFeb14), DayIndexTag.dayIndexes(feb14 + 3600)) + } + + @Test + fun endBeforeOrAtStartStillEmitsTheStartDay() { + assertEquals(listOf(dayIndexFeb14), DayIndexTag.dayIndexes(feb14, feb14)) + assertEquals(listOf(dayIndexFeb14), DayIndexTag.dayIndexes(feb14 + 100, feb14)) + } + + @Test + fun aMultiDayRangeCoversEveryDayItTouches() { + // 14th 10:00 → 16th 15:00 spans three days. + val days = DayIndexTag.dayIndexes(feb14 + 10 * 3600, feb14 + 2 * 86400 + 15 * 3600) + assertEquals(listOf(dayIndexFeb14, dayIndexFeb14 + 1, dayIndexFeb14 + 2), days) + } + + @Test + fun endIsExclusiveSoMidnightDoesNotClaimTheNextDay() { + // Ends exactly at the 15th 00:00:00 — that instant belongs to the 15th, but the event + // does not, so only the 14th is tagged. + assertEquals(listOf(dayIndexFeb14), DayIndexTag.dayIndexes(feb14, feb14 + 86400)) + // One second later it genuinely runs into the 15th. + assertEquals( + listOf(dayIndexFeb14, dayIndexFeb14 + 1), + DayIndexTag.dayIndexes(feb14, feb14 + 86401), + ) + } + + @Test + fun anAbsurdRangeIsCappedRatherThanEmittingThousandsOfTags() { + val days = DayIndexTag.dayIndexes(feb14, feb14 + 4000L * 86400L) + assertEquals(DayIndexTag.MAX_DAYS, days.size) + assertEquals(dayIndexFeb14, days.first()) + } + + @Test + fun buildEmitsTheDayTagsAndKeepsTheIdentifierSeparate() { + val template = + CalendarTimeSlotEvent.build( + title = "Nostrautica", + start = feb14 + 10 * 3600, + end = feb14 + 86400 + 2 * 3600, + dTag = "my-event", + ) + + val dayTags = template.tags.filter { it[0] == DayIndexTag.TAG_NAME }.map { it[1] } + assertEquals(listOf(dayIndexFeb14.toString(), (dayIndexFeb14 + 1).toString()), dayTags) + + // The uppercase D must not disturb the lowercase d identifier — they collide in the + // builder's tag map if either side is ever case-folded. + assertEquals("my-event", template.tags.firstTagValue("d")) + } + + @Test + fun parseReadsBackWhatBuildWrote() { + val template = CalendarTimeSlotEvent.build(title = "T", start = feb14, end = feb14 + 86401) + val event = CalendarTimeSlotEvent("id", "pub", 0L, template.tags, template.content, "sig") + + assertEquals(listOf(dayIndexFeb14, dayIndexFeb14 + 1), event.dayIndexes()) + } + + @Test + fun rebuildingAfterAnEditDropsDaysTheEventNoLongerCovers() { + // A three-day event shortened to one: the two dropped days must not linger, or they keep + // advertising the event on dates it no longer runs. + val shortened = + CalendarTimeSlotEvent.build(title = "T", start = feb14, end = feb14 + 3 * 86400, dTag = "same") { + dayIndexes(feb14, feb14 + 3600) + } + + val dayTags = shortened.tags.filter { it[0] == DayIndexTag.TAG_NAME }.map { it[1] } + assertEquals(listOf(dayIndexFeb14.toString()), dayTags) + } + + @Test + fun parseRejectsNonDayTagsAndGarbage() { + assertEquals(null, DayIndexTag.parse(arrayOf("d", "1234"))) + assertEquals(null, DayIndexTag.parse(arrayOf("D"))) + assertEquals(null, DayIndexTag.parse(arrayOf("D", "not-a-number"))) + assertTrue(DayIndexTag.parse(arrayOf("D", "82549")) == 82549L) + } +} From e577071a75bcf12b3be60ec5eea7fc0050dead94 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 11 Sep 2026 01:26:04 +0000 Subject: [PATCH 3/3] fix(napplet): grant SIGNER to the browser, and never widen a narrow decrypt grant Two defects found auditing the NIP-44 change. The in-app browser mints its own per-origin launch token and never consulted HostProfile, so it still granted IDENTITY+RELAY. Those are exactly the surfaces that set __nappletNip07, so the shim advertised window.nostr.nip44 and the broker then denied every call -- worse than not advertising it, since apps stop falling back. The website set now lives once, in NappletCapability, and both mints read it. Second, the broker recorded a consent grant under the REQUESTED op rather than the op the grant itself carried. Nip44Decrypt is the first napplet-side request with a narrower alternative (DecryptFrom(peer)), so a user tapping "always allow for Alice" would have been stored as a broad "allow decrypt" -- every conversation, forever, from one tap. Recording now goes through NostrSignerPermissionLedger.record, which uses the grant's own op, and a standing narrow grant is honoured on later requests instead of re-prompting. This mirrors the NIP-46 authorizer, which already got both right. With the recording fixed, the consent dialog can safely name the counterparty: Nip44Decrypt now supplies it, so the prompt reads "read your private messages with Alice" and offers the scoped grant beside the broad one. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012Hge2jR1BPnyZse75VQ4kg --- .../amethyst/napplet/NappletBrokerService.kt | 2 +- .../amethyst/napplet/NostrSignerOpLabels.kt | 20 +++- .../amethyst/commons/napplet/NappletBroker.kt | 70 +++++++------ .../commons/napplet/NappletCapability.kt | 12 +++ .../protocol/NappletRequestSignerOp.kt | 28 ++++++ .../commons/napplet/NappletBrokerTest.kt | 98 +++++++++++++++++++ .../amethyst/napplethost/HostProfile.kt | 2 +- 7 files changed, 198 insertions(+), 34 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt index 5a77e2a369..de5815af52 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt @@ -277,7 +277,7 @@ class NappletBrokerService : Service() { // Bind to the account active at mint time: a browser token minted for one account must // never sign as another if the user switches while the page is still open. val mintAccount = Amethyst.instance.sessionManager.loggedInAccount() ?: return true - val token = NappletLaunchRegistry.register(identity, setOf(NappletCapability.IDENTITY, NappletCapability.RELAY), mintAccount.pubKey) + val token = NappletLaunchRegistry.register(identity, NappletCapability.WEBSITE_CAPABILITIES, mintAccount.pubKey) val response = Message.obtain(null, NappletIpc.MSG_BROWSER_TOKEN).apply { this.data = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NostrSignerOpLabels.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NostrSignerOpLabels.kt index af0c734305..e53b9aceac 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NostrSignerOpLabels.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NostrSignerOpLabels.kt @@ -27,6 +27,8 @@ import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerOp import com.vitorpamplona.amethyst.commons.napplet.NappletCapability import com.vitorpamplona.amethyst.commons.napplet.NappletIdentity import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletRequest +import com.vitorpamplona.amethyst.commons.napplet.protocol.counterpartyPubKey +import com.vitorpamplona.amethyst.commons.napplet.protocol.toNarrowSignerOp import com.vitorpamplona.amethyst.connectedApps.consent.SignerConnectInfo import com.vitorpamplona.amethyst.connectedApps.consent.SignerConsentInfo import com.vitorpamplona.amethyst.favorites.BrowserIconRegistry @@ -78,7 +80,13 @@ fun buildSignerConsentInfo( } else { resolveNappletMeta(identity.authorPubKey, identity.identifier, untitled) } - val summary = op.label(context) + // A decrypt grant can be scoped to one conversation: offer "always allow for Alice" next to the + // broad "always allow", instead of only the all-conversations-forever choice. Mirrors the NIP-46 + // dialog, so the same decision reads the same way whichever surface asked. + val narrowOp = request.toNarrowSignerOp() + val counterparty = request.counterpartyPubKey() + // For decrypt this names the counterparty ("read your private messages with Alice"). + val summary = (narrowOp ?: op).label(context) val preview = when (request) { is NappletRequest.Publish -> request.content.take(160).trim() @@ -140,6 +148,16 @@ fun buildSignerConsentInfo( rawData = rawData, iconUrl = iconUrl, previewTemplate = previewTemplate, + counterpartyName = counterparty?.let { counterpartyLabel(it) }, + counterpartyPicture = counterparty?.let { LocalCache.getUserIfExists(it)?.profilePicture() }, + counterpartyPubKey = counterparty, + narrowOp = narrowOp, + // Read the pubkey off the narrow op itself: the dialog drops the button unless BOTH halves + // are present, so deriving them from one value keeps them from disagreeing. + narrowOpLabel = + (narrowOp as? NostrSignerOp.DecryptFrom)?.let { + context.getString(R.string.nip46_signer_allow_always_for, counterpartyLabel(it.counterparty)) + }, ) } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBroker.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBroker.kt index 4f17c22b08..31f5bea4af 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBroker.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBroker.kt @@ -34,6 +34,7 @@ import com.vitorpamplona.amethyst.commons.napplet.permissions.PermissionDecision import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletRequest import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletResponse import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletStorageScope +import com.vitorpamplona.amethyst.commons.napplet.protocol.toNarrowSignerOp import com.vitorpamplona.amethyst.commons.napplet.protocol.toSignerOp import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner @@ -455,51 +456,58 @@ class NappletBroker( ): Boolean = signerConsentLock.withLock { val sl = signerLedger ?: return@withLock true + val coordinate = signerCoordinateFor(identity) + // A decrypt request also carries a narrower op ("decrypt messages from THIS + // counterparty"). A standing narrow grant satisfies it without widening the broad one. + val narrowOp = request.toNarrowSignerOp() + // Session grants win immediately without touching storage. Scoped to this applet: a // grant made for one app never authorizes another. - if (sessionKey(signerCoordinateFor(identity), op) in sessionAllows) { - sl.updateLastUsed(signerCoordinateFor(identity)) + if (sessionKey(coordinate, op) in sessionAllows) { + sl.updateLastUsed(coordinate) return@withLock true } - when (sl.decide(signerCoordinateFor(identity), op)) { + when (sl.decide(coordinate, op)) { NostrOpDecision.ALLOW -> { - sl.updateLastUsed(signerCoordinateFor(identity)) + sl.updateLastUsed(coordinate) true } + // An explicit DENY on the broad op is final — a narrow grant never overrides it. NostrOpDecision.DENY -> false NostrOpDecision.ASK -> { - val prompt = signerConsentPrompt ?: return@withLock true - when (val grant = prompt.request(identity, op, request)) { - is SignerOpGrant.AllowAll -> { - sl.setPolicy(signerCoordinateFor(identity), AppSignerPolicy.FULL_TRUST) - sl.updateLastUsed(signerCoordinateFor(identity)) - true - } - is SignerOpGrant.AllowForOp -> { - sl.setOpDecision(signerCoordinateFor(identity), op, NostrOpDecision.ALLOW) - sl.updateLastUsed(signerCoordinateFor(identity)) - true - } - is SignerOpGrant.AllowForSession -> { - sessionAllows.add(sessionKey(signerCoordinateFor(identity), op)) - sl.updateLastUsed(signerCoordinateFor(identity)) - true - } - is SignerOpGrant.AllowUntil -> { - sl.setTimedOpDecision(signerCoordinateFor(identity), op, NostrOpDecision.ALLOW, grant.expiresAt) - sl.updateLastUsed(signerCoordinateFor(identity)) - true - } - is SignerOpGrant.DenyForOp -> { - sl.setOpDecision(signerCoordinateFor(identity), op, NostrOpDecision.DENY) - false - } - else -> grant.isAllowed + if (narrowOp != null && isNarrowAllowed(sl, coordinate, narrowOp)) { + sl.updateLastUsed(coordinate) + return@withLock true } + val prompt = signerConsentPrompt ?: return@withLock true + val grant = prompt.request(identity, op, request) + // Record the GRANT's own op, never the requested one: the dialog may hand back a + // narrower op ("only from this counterparty"), and a stored grant must never be + // wider than what the user actually tapped. + sl.record(coordinate, grant) + if (grant is SignerOpGrant.AllowForSession) { + sessionAllows.add(sessionKey(coordinate, grant.op)) + } + if (grant.isAllowed) sl.updateLastUsed(coordinate) + grant.isAllowed } } } + /** + * True when a standing or session grant exists for the narrower [narrowOp] (e.g. decrypt-from-X). + * Only an explicit per-op override counts: [NostrSignerPermissionLedger.decide] would otherwise + * fall through to the app's policy, and FULL_TRUST/REASONABLE would answer for an op nobody ever + * granted. Mirrors the NIP-46 authorizer so both surfaces honour a narrow grant identically. + */ + private suspend fun isNarrowAllowed( + sl: NostrSignerPermissionLedger, + coordinate: String, + narrowOp: NostrSignerOp, + ): Boolean = + sessionKey(coordinate, narrowOp) in sessionAllows || + sl.store.loadOpDecision(coordinate, narrowOp)?.let { sl.decide(coordinate, narrowOp) == NostrOpDecision.ALLOW } ?: false + /** * The signer-ledger coordinate for [identity] under the current account. The signer permission * store is shared with NIP-46, which already namespaces by account diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletCapability.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletCapability.kt index 063fb26574..ad26a962ce 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletCapability.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletCapability.kt @@ -107,6 +107,18 @@ enum class NappletCapability { get() = !requiresPerUseConsent companion object { + /** + * What a page in the **website** posture (the NIP-07 `window.nostr` surface) may ask the + * broker for. There are two independent mints of this set — the nSite host derives it from + * `HostProfile.WEBSITE`, while the in-app browser mints a fresh per-origin token — so it + * lives here, once: when the two drifted, the browser silently denied every call to a + * capability the injected shim was still advertising. + * + * Widening this widens what any visited site can request, so it is a security decision, not + * a convenience list. + */ + val WEBSITE_CAPABILITIES: Set = setOf(IDENTITY, RELAY, SIGNER) + /** * Maps a bare, currently supported NAP domain to the capability the broker enforces. * Returns `null` for unknown and partial/legacy domains — callers MUST treat that as diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletRequestSignerOp.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletRequestSignerOp.kt index 01aa8510dc..34821e119b 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletRequestSignerOp.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletRequestSignerOp.kt @@ -41,3 +41,31 @@ fun NappletRequest.toSignerOp(): NostrSignerOp? = is NappletRequest.Nip44Decrypt -> NostrSignerOp.Decrypt else -> null } + +/** + * The NARROWER op a request may alternatively be granted — today only + * [NostrSignerOp.DecryptFrom], i.e. "always allow, but only for this counterparty". Mirrors the + * NIP-46 authorizer's `toNarrowSignerOp`. + * + * A single broad "always allow decrypt" hands an app every private conversation the user will ever + * have; this is the granular alternative the consent dialog offers alongside it. `null` for every + * request without a counterparty — signing and encryption already name the thing being granted. + */ +fun NappletRequest.toNarrowSignerOp(): NostrSignerOp? = + when (this) { + is NappletRequest.Nip44Decrypt -> NostrSignerOp.DecryptFrom(peer) + else -> null + } + +/** + * The counterparty whose conversation a decrypt request asks to read, or `null` for every other + * request. Scoped to decryption to match the NIP-46 authorizer and what the consent dialog + * documents: it drives "X wants to read your messages with Alice", a categorically different + * decision from the encrypt/sign case, where the counterparty is already part of what the user + * is composing. + */ +fun NappletRequest.counterpartyPubKey(): String? = + when (this) { + is NappletRequest.Nip44Decrypt -> peer + else -> null + } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBrokerTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBrokerTest.kt index 749da8665d..a5d33e5a23 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBrokerTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBrokerTest.kt @@ -850,4 +850,102 @@ class NappletBrokerTest { val response = broker.handle(applet, NappletRequest.Nip44Decrypt(peer.pubKey, sealed), allDeclared) assertIs(response) } + + @Test + fun theWebsiteCapabilitySetCarriesEverythingNip07Needs() { + // Two places mint this set (the nSite HostProfile and the in-app browser's per-origin + // token). They drifted once: the browser kept IDENTITY+RELAY while the shim advertised + // nip44, so every call was denied by a capability the page was told it had. + assertTrue(NappletCapability.IDENTITY in NappletCapability.WEBSITE_CAPABILITIES) + assertTrue(NappletCapability.RELAY in NappletCapability.WEBSITE_CAPABILITIES) + assertTrue(NappletCapability.SIGNER in NappletCapability.WEBSITE_CAPABILITIES) + } + + @Test + fun nip44WorksUnderTheWebsiteCapabilitySetAlone() = + runTest { + // What a browsed page actually gets — not `allDeclared`, which would hide a missing grant. + val peer = NostrSignerInternal(KeyPair("44".repeat(32).hexToByteArray())) + val broker = broker(ScriptedPrompt(GrantState.ALLOW_ALWAYS)) + + val response = + broker.handle( + applet, + NappletRequest.Nip44Encrypt(peer.pubKey, "gm"), + NappletCapability.WEBSITE_CAPABILITIES, + ) + + assertIs(response) + assertEquals("gm", peer.nip44Decrypt(response.value, signer.pubKey)) + } + + @Test + fun anAlwaysAllowForOneCounterpartyDoesNotUnlockTheRest() = + runTest { + // The dialog can hand back DecryptFrom(alice) instead of the broad Decrypt. Recording the + // REQUESTED op there would silently upgrade "only Alice" into every conversation forever. + val signerLedger = NostrSignerPermissionLedger(InMemoryNostrSignerPermissionStore()) + signerLedger.setPolicy("napplet:${signer.pubKey}:${applet.coordinate}", AppSignerPolicy.REASONABLE) + + val alice = NostrSignerInternal(KeyPair("55".repeat(32).hexToByteArray())) + val bob = NostrSignerInternal(KeyPair("66".repeat(32).hexToByteArray())) + + val opPrompt = ScriptedSignerPrompt(SignerOpGrant.AllowForOp(NostrSignerOp.DecryptFrom(alice.pubKey))) + val broker = + NappletBroker( + signer = signer, + ledger = NappletPermissionLedger(InMemoryNappletPermissionStore()), + consentPrompt = ScriptedPrompt(GrantState.ALLOW_ALWAYS), + signerLedger = signerLedger, + signerConsentPrompt = opPrompt, + ) + + val fromAlice = alice.nip44Encrypt("hi", signer.pubKey) + val fromBob = bob.nip44Encrypt("hi", signer.pubKey) + + // 1. First read from Alice prompts; the user allows, but only for Alice. + assertIs(broker.handle(applet, NappletRequest.Nip44Decrypt(alice.pubKey, fromAlice), allDeclared)) + assertEquals(1, opPrompt.calls) + + // 2. Reading Alice again rides the narrow grant — no second prompt. + assertIs(broker.handle(applet, NappletRequest.Nip44Decrypt(alice.pubKey, fromAlice), allDeclared)) + assertEquals(1, opPrompt.calls) + + // 3. Bob is a different conversation and must ask again. If the broad Decrypt had been + // recorded in step 1, this would sail through without the user ever agreeing to it. + broker.handle(applet, NappletRequest.Nip44Decrypt(bob.pubKey, fromBob), allDeclared) + assertEquals(2, opPrompt.calls) + + // The broad grant was never written. + assertNull(signerLedger.store.loadOpDecision("napplet:${signer.pubKey}:${applet.coordinate}", NostrSignerOp.Decrypt)) + } + + @Test + fun aSessionGrantIsStoredNoWiderThanTheUserGaveIt() = + runTest { + val signerLedger = NostrSignerPermissionLedger(InMemoryNostrSignerPermissionStore()) + signerLedger.setPolicy("napplet:${signer.pubKey}:${applet.coordinate}", AppSignerPolicy.PARANOID) + + val alice = NostrSignerInternal(KeyPair("77".repeat(32).hexToByteArray())) + val bob = NostrSignerInternal(KeyPair("88".repeat(32).hexToByteArray())) + + val opPrompt = ScriptedSignerPrompt(SignerOpGrant.AllowForSession(NostrSignerOp.DecryptFrom(alice.pubKey))) + val broker = + NappletBroker( + signer = signer, + ledger = NappletPermissionLedger(InMemoryNappletPermissionStore()), + consentPrompt = ScriptedPrompt(GrantState.ALLOW_ALWAYS), + signerLedger = signerLedger, + signerConsentPrompt = opPrompt, + ) + + broker.handle(applet, NappletRequest.Nip44Decrypt(alice.pubKey, alice.nip44Encrypt("a", signer.pubKey)), allDeclared) + assertEquals(1, opPrompt.calls) + + // Same counterparty rides the session grant; a different one must not. + broker.handle(applet, NappletRequest.Nip44Decrypt(alice.pubKey, alice.nip44Encrypt("a", signer.pubKey)), allDeclared) + assertEquals(1, opPrompt.calls) + broker.handle(applet, NappletRequest.Nip44Decrypt(bob.pubKey, bob.nip44Encrypt("b", signer.pubKey)), allDeclared) + assertEquals(2, opPrompt.calls) + } } diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/HostProfile.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/HostProfile.kt index 497670c654..698362883e 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/HostProfile.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/HostProfile.kt @@ -51,7 +51,7 @@ enum class HostProfile { */ fun declaredCapabilities(requires: List): Set = when (this) { - WEBSITE -> setOf(NappletCapability.IDENTITY, NappletCapability.RELAY, NappletCapability.SIGNER) + WEBSITE -> NappletCapability.WEBSITE_CAPABILITIES NAPPLET -> resolveRequiredCapabilities(requires).capabilities.toSet() }