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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Hge2jR1BPnyZse75VQ4kg
This commit is contained in:
Claude
2026-09-10 23:30:44 +00:00
parent 3c6eb8ee55
commit 44b7ae165f
15 changed files with 262 additions and 4 deletions
@@ -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
@@ -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)
@@ -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 =
@@ -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
+4
View File
@@ -401,6 +401,7 @@
<string name="napplet_cap_identity">Identity</string>
<string name="napplet_cap_keys">Keyboard actions</string>
<string name="napplet_cap_relay">Relays</string>
<string name="napplet_cap_signer">Encryption</string>
<string name="napplet_cap_storage">Storage</string>
<string name="napplet_cap_value">Payments</string>
<string name="napplet_cap_resource">Network</string>
@@ -409,6 +410,7 @@
<string name="napplet_cap_identity_desc">Read your public key</string>
<string name="napplet_cap_keys_desc">Bind keyboard shortcuts</string>
<string name="napplet_cap_relay_desc">Read, and sign &amp; publish your events</string>
<string name="napplet_cap_signer_desc">Encrypt and decrypt private messages with your key</string>
<string name="napplet_cap_storage_desc">Its own private storage</string>
<string name="napplet_cap_value_desc">Pay Lightning invoices</string>
<string name="napplet_cap_resource_desc">Fetch web and Blossom resources</string>
@@ -426,6 +428,8 @@
<string name="napplet_consent_publish">This nApplet wants to sign and publish a kind %1$d event as you.</string>
<string name="napplet_consent_publish_preview">This nApplet wants to sign and publish a kind %1$d event as you:</string>
<string name="napplet_consent_publish_encrypted">This nApplet wants to send an encrypted event as you.</string>
<string name="napplet_consent_nip44_encrypt">This site wants to encrypt a message to %1$s with your Nostr key.</string>
<string name="napplet_consent_nip44_decrypt">This site wants to decrypt a message from %1$s with your Nostr key.</string>
<string name="napplet_consent_query">This nApplet wants to read events from your relays.</string>
<string name="napplet_consent_storage">This nApplet wants to use its private storage.</string>
<string name="napplet_consent_pay">This nApplet wants to pay a Lightning invoice.</string>
@@ -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)
}
}
@@ -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'); }
})
});
}
})();
@@ -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))
@@ -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,
@@ -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<Filter>,
@@ -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
}
@@ -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<String>,
@@ -745,4 +745,109 @@ class NappletBrokerTest {
assertIs<NappletResponse.ResourceFailure>(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<NappletResponse.Text>(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<NappletResponse.Text>(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<NappletResponse.Denied>(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<NappletResponse.Denied>(response)
}
}
@@ -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`.
@@ -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<String>): Set<NappletCapability> =
when (this) {
WEBSITE -> setOf(NappletCapability.IDENTITY, NappletCapability.RELAY)
WEBSITE -> setOf(NappletCapability.IDENTITY, NappletCapability.RELAY, NappletCapability.SIGNER)
NAPPLET -> resolveRequiredCapabilities(requires).capabilities.toSet()
}