From 44b7ae165ffdd92888fe75a73932c1b29d8940a7 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 23:30:44 +0000 Subject: [PATCH] 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() }