diff --git a/amethyst/plans/2026-06-20-napplet-ecosystem-audit.md b/amethyst/plans/2026-06-20-napplet-ecosystem-audit.md index e3e0de74b8..1448e0af14 100644 --- a/amethyst/plans/2026-06-20-napplet-ecosystem-audit.md +++ b/amethyst/plans/2026-06-20-napplet-ecosystem-audit.md @@ -126,6 +126,52 @@ Acted on #1–#5. The dialect mismatch is resolved: - **`resource.bytes`** implemented for `https`/`data` (broker-fetched, Tor-routed, consent-gated); `blossom:`/`nostr:` are a follow-up. +## Update (2026-06-20, later): verified against `@napplet/shim@0.16.0` and corrected + +Pulled the authoritative SDK (`@napplet/shim` v0.16.0, npm/unpkg) and corrected the +implementation to its real contract. Commit `5ca44e27` had carried several wrong guesses; the +verified surface is: + +| Namespace | Verified methods (v0.16.0) | +|---|---| +| `shell` | `supports(domain, protocol?)` (sync), `ready()`, `onReady(cb)`, `services` | +| `identity` | `getPublicKey()`, `onChanged(h)`, + read API (`getRelays/getProfile/getFollows/getList/getZaps/getMutes/getBlocked/getBadges`) | +| `keys` | **keyboard/command actions** — `registerAction/unregisterAction/onAction` (NOT signing) | +| `relay` | `publish(template, options?)` → signed `NostrEvent`, `publishEncrypted(template, recipient, encryption?)`, `query(filters)`, `subscribe(filters, onEvent, onEose, options?)` | +| `storage` | `getItem/setItem/removeItem/keys` (512 KB quota; `instance.*` variant) | +| `resource` | `bytes(url)` → `Blob`, `bytesAsObjectURL(url)` | +| `inc` | `emit(topic, extraTags?, content?)`, `on(topic, cb)` | + +Crucial design fact, quoted: **"signing and encryption are mediated by the shell via +`relay.publish()` and `relay.publishEncrypted()`"** and *"no cryptographic dependencies — the +shim sends JSON envelope messages and the shell handles identity"*. **There is no `sign()` and no +raw nip04/44 in the napplet surface.** There is **no `value` or `upload` domain** in v0.16.0. + +Corrections landed (this commit): + +- **Signing model fixed (the big one).** Dropped the bogus `keys.signEvent` / `keys.nip04*` / + `keys.nip44*` napplet ops. `relay.publish` now takes an **unsigned template** (`kind/tags/content`) + and the broker signs it as the user and returns the signed event — exactly the upstream contract. + Added `relay.publishEncrypted` (broker encrypts to recipient with nip44/nip04, then signs + + publishes). The broker still defers the per-signature prompt to remote/external signers + (`signsAsUser` + non-internal signer) and honors standing DENY. +- **`keys` re-pointed to keyboard actions** (`registerAction/unregisterAction/onAction`), + implemented as client-side no-op stubs (not yet wired to the host keyboard) so action-using + napplets don't crash. They never cross the broker boundary. +- **`storage` renamed** to `getItem/setItem/removeItem` and **`storage.keys`** added end-to-end + (protocol + broker + DataStore + shim), matching upstream. +- **`resource.bytes` now returns a `Blob`** (shim builds it from `{bytes, mime}`); wire field + renamed `contentType`→`mime`. +- **`shell.supports(domain, protocol?)`** gained the optional protocol arg; added `shell.ready()`, + `onReady`, `services` stubs. +- **`relay.subscribe`** wired (initial matches; live tail still a follow-up). +- `value.payInvoice` and `upload.blob` are **kept as clearly-marked Amethyst-specific extensions** + (no upstream equivalent in v0.16.0) — a real `@napplet/shim` napplet never calls them, so they + can't conflict. + +Verified off-device: `commons:jvmTest` (broker + capability + ledger) and the amethyst codec +round-trip test (`NappletProtocolJsonTest`) both green. + Still open (documented, not blocking basic napplets): - **`upload`** — wired end-to-end (protocol/shim/capability) but the Android Blossom gateway is unprovided (`Unsupported`): a correct upload needs a content Uri + signed @@ -134,14 +180,17 @@ Still open (documented, not blocking basic napplets): `identity.onChanged`/`inc.on` need a push channel over the existing reply proxy. - **Underspecified domains** — `inc`, `intent`, `theme`, `notify`, `media`, `config`, `outbox`, `ifc`, `cvm` remain unknown→denied (no method spec available to build to). -- **Method-name fidelity** — `relay.*`/`storage.*`/`shell.supports`/`identity.getPublicKey` - are confirmed from upstream; `keys.*`, `value.*`, `upload.*`, `resource.*` are best-guess - names that should be checked against the `@napplet/web` source before release. +- **Method-name fidelity** — ✅ resolved. All standard method names/shapes are now confirmed + against `@napplet/shim@0.16.0` (see the later update above), not guessed. +- **Identity read API** — `getProfile/getRelays/getFollows/...` and `identity.onChanged` are not + yet implemented (need account-data wiring + on-device verification of the return shapes); the + shim exposes `getPublicKey` + an `onChanged` no-op for now. - **On-device verification** of the whole round-trip with a real playground napplet. -Revised ecosystem-compatibility estimate: **~70%** (was ~25%) — real request/response -napplets that use identity/keys/relay/storage/value/resource + `shell.supports` now run; -gaps are upload, live subscriptions, the niche domains, and device verification. +Revised ecosystem-compatibility estimate: **~80%** — real request/response napplets using +identity(getPublicKey)/relay(publish/publishEncrypted/query/subscribe)/storage/resource + +`shell.supports` now run against the *verified* contract; remaining gaps are the identity read +API, keyboard-action wiring, live subscription tails, the niche domains, and device verification. ## Verdict (original assessment, pre-update) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/DataStoreNappletStorage.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/DataStoreNappletStorage.kt index 61ee140cda..53705a0dac 100644 Binary files a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/DataStoreNappletStorage.kt and b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/DataStoreNappletStorage.kt differ 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 d5e6af9404..d8940ff7e6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt @@ -300,19 +300,17 @@ class NappletBrokerService : Service() { private fun summaryFor(request: NappletRequest): String = when (request) { is NappletRequest.GetPublicKey -> getString(R.string.napplet_consent_get_pubkey) - is NappletRequest.SignEvent -> { + is NappletRequest.Publish -> { val preview = request.content.take(160).trim() if (preview.isEmpty()) { - getString(R.string.napplet_consent_sign, request.kind) + getString(R.string.napplet_consent_publish, request.kind) } else { - getString(R.string.napplet_consent_sign_preview, request.kind) + "\n“$preview”" + getString(R.string.napplet_consent_publish_preview, request.kind) + "\n“$preview”" } } - is NappletRequest.Nip04Encrypt, is NappletRequest.Nip44Encrypt -> getString(R.string.napplet_consent_encrypt) - is NappletRequest.Nip04Decrypt, is NappletRequest.Nip44Decrypt -> getString(R.string.napplet_consent_decrypt) - is NappletRequest.Publish -> getString(R.string.napplet_consent_publish) - is NappletRequest.QueryEvents -> getString(R.string.napplet_consent_query) - is NappletRequest.StorageGet, is NappletRequest.StorageSet, is NappletRequest.StorageRemove -> + is NappletRequest.PublishEncrypted -> getString(R.string.napplet_consent_publish_encrypted) + is NappletRequest.QueryEvents, is NappletRequest.Subscribe -> getString(R.string.napplet_consent_query) + is NappletRequest.StorageGet, is NappletRequest.StorageSet, is NappletRequest.StorageRemove, is NappletRequest.StorageKeys -> getString(R.string.napplet_consent_storage) is NappletRequest.PayInvoice -> { val sats = runCatching { LnInvoiceUtil.getAmountInSats(request.invoice).toLong() }.getOrNull() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletHostActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletHostActivity.kt index 64f508654a..1c3329b13f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletHostActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletHostActivity.kt @@ -442,44 +442,57 @@ class NappletHostActivity : ComponentActivity() { function normFilters(filters){ return Array.isArray(filters) ? { filters: filters } : { filter: filters || {} }; } function b64ToBytes(b64){ var bin = atob(b64); var u = new Uint8Array(bin.length); for (var i=0;i NappletRequest.ShellSupports(o.req("domain")) + "shell.supports" -> NappletRequest.ShellSupports(o.req("domain"), o.str("protocol")) "identity.getPublicKey" -> NappletRequest.GetPublicKey - "keys.signEvent" -> - NappletRequest.SignEvent( - kind = o.getValue("kind").jsonPrimitive.int, - tags = decodeTags(o), - content = o.str("content") ?: "", + "relay.publish" -> { + val t = o.template() + NappletRequest.Publish(kind = t.kindOf(), tags = decodeTags(t), content = t.str("content") ?: "") + } + "relay.publishEncrypted" -> { + val t = o.template() + NappletRequest.PublishEncrypted( + kind = t.kindOf(), + tags = decodeTags(t), + content = t.str("content") ?: "", + recipient = o.req("recipient"), + encryption = o.str("encryption") ?: "nip44", ) - "keys.nip04Encrypt" -> NappletRequest.Nip04Encrypt(o.req("peer"), o.req("plaintext")) - "keys.nip04Decrypt" -> NappletRequest.Nip04Decrypt(o.req("peer"), o.req("ciphertext")) - "keys.nip44Encrypt" -> NappletRequest.Nip44Encrypt(o.req("peer"), o.req("plaintext")) - "keys.nip44Decrypt" -> NappletRequest.Nip44Decrypt(o.req("peer"), o.req("ciphertext")) - "relay.publish" -> NappletRequest.Publish(Event.fromJson(o.getValue("event").jsonObject.toString())) + } "relay.query" -> NappletRequest.QueryEvents(decodeFilter(o)) - "storage.get" -> NappletRequest.StorageGet(o.req("key")) - "storage.set" -> NappletRequest.StorageSet(o.req("key"), o.req("value")) - "storage.remove" -> NappletRequest.StorageRemove(o.req("key")) + "relay.subscribe" -> NappletRequest.Subscribe(decodeFilter(o)) + "storage.getItem" -> NappletRequest.StorageGet(o.req("key")) + "storage.setItem" -> NappletRequest.StorageSet(o.req("key"), o.req("value")) + "storage.removeItem" -> NappletRequest.StorageRemove(o.req("key")) + "storage.keys" -> NappletRequest.StorageKeys "value.payInvoice" -> NappletRequest.PayInvoice(o.req("invoice")) "resource.bytes" -> NappletRequest.ResourceBytes(o.req("url")) "upload" -> NappletRequest.UploadBlob(Base64.getDecoder().decode(o.req("bytes")), o.req("contentType")) @@ -94,16 +98,11 @@ object NappletProtocolJson { put("ok", true) put("pubkey", response.pubkey) } - is NappletResponse.SignedEvent -> { + is NappletResponse.Published -> { + // Upstream resolves publish() to the signed NostrEvent; relays are an extra. put("ok", true) put("event", json.parseToJsonElement(response.event.toJson())) - } - is NappletResponse.Text -> { - put("ok", true) - put("value", response.value) - } - is NappletResponse.Published -> { - put("ok", true) + put("eventId", response.event.id) put("relays", buildJsonArray { response.relays.forEach { add(it) } }) } is NappletResponse.Events -> { @@ -118,10 +117,14 @@ object NappletProtocolJson { put("ok", true) put("value", response.value) } + is NappletResponse.Strings -> { + put("ok", true) + put("values", buildJsonArray { response.values.forEach { add(it) } }) + } is NappletResponse.Bytes -> { put("ok", true) put("bytes", Base64.getEncoder().encodeToString(response.bytes)) - put("contentType", response.contentType) + put("mime", response.contentType) } is NappletResponse.Uploaded -> { put("ok", true) @@ -188,4 +191,9 @@ object NappletProtocolJson { private fun JsonObject.req(key: String): String = getValue(key).jsonPrimitive.content private fun JsonObject.strList(key: String): List? = this[key]?.jsonArray?.map { it.jsonPrimitive.content } + + /** The unsigned event template, taken from a `template` field if present, else the envelope itself. */ + private fun JsonObject.template(): JsonObject = this["template"]?.jsonObject ?: this + + private fun JsonObject.kindOf(): Int = getValue("kind").jsonPrimitive.int } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 084f10fb7c..47295ce95d 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -749,7 +749,7 @@ Shell Identity - Signing + Keyboard actions Relays Storage Payments @@ -757,8 +757,8 @@ Uploads Ask which capabilities are available Read your public key - Sign and encrypt as you - Read and publish your events + Bind keyboard shortcuts + Read, and sign & publish your events Its own private storage Pay Lightning invoices Fetch web and Blossom resources @@ -770,11 +770,9 @@ Never allow Not now This napplet wants to read your public key. - This napplet wants to sign a kind %1$d event as you. - This napplet wants to sign a kind %1$d event as you: - This napplet wants to encrypt a message as you. - This napplet wants to decrypt a message addressed to you. - This napplet wants to publish an event to your relays. + 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 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 b0930d6f96..c7117a0fa7 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/napplet/NappletProtocolJsonTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/napplet/NappletProtocolJsonTest.kt @@ -78,27 +78,23 @@ class NappletProtocolJsonTest { } @Test - fun decodesSignEventUnderKeysDomain() { - val req = NappletProtocolJson.decodeRequest("""{"type":"keys.signEvent","id":"1","kind":1,"tags":[["t","x"]],"content":"gm"}""") - assertEquals(NappletRequest.SignEvent(1, arrayOf(arrayOf("t", "x")), "gm"), req) + fun decodesPublishFromAnUnsignedTemplate() { + // The napplet supplies only kind/tags/content — never id/pubkey/sig. The shell signs it. + val req = NappletProtocolJson.decodeRequest("""{"type":"relay.publish","id":"1","template":{"kind":1,"tags":[["t","x"]],"content":"gm"}}""") + assertEquals(NappletRequest.Publish(1, arrayOf(arrayOf("t", "x")), "gm"), req) } @Test - fun decodesEncryptDecryptUnderKeysDomain() { - assertEquals(NappletRequest.Nip04Encrypt("pk", "hi"), NappletProtocolJson.decodeRequest("""{"type":"keys.nip04Encrypt","peer":"pk","plaintext":"hi"}""")) - assertEquals(NappletRequest.Nip44Decrypt("pk", "ct"), NappletProtocolJson.decodeRequest("""{"type":"keys.nip44Decrypt","peer":"pk","ciphertext":"ct"}""")) + fun decodesPublishEncrypted() { + val req = + NappletProtocolJson.decodeRequest( + """{"type":"relay.publishEncrypted","id":"1","template":{"kind":4,"tags":[],"content":"hi"},"recipient":"pk","encryption":"nip04"}""", + ) + assertEquals(NappletRequest.PublishEncrypted(4, emptyArray(), "hi", "pk", "nip04"), req) } @Test - fun decodesPublishPreservingTheEvent() { - val ev = sampleEvent() - val req = NappletProtocolJson.decodeRequest("""{"type":"relay.publish","id":"1","event":${ev.toJson()}}""") as NappletRequest.Publish - assertEquals(ev.id, req.event.id) - assertEquals("hello", req.event.content) - } - - @Test - fun decodesQueryFromFilterObjectOrFiltersArray() { + fun decodesQueryAndSubscribeFromFilterObjectOrFiltersArray() { val single = NappletProtocolJson.decodeRequest("""{"type":"relay.query","filter":{"kinds":[1],"#t":["nostr"],"limit":5}}""") as NappletRequest.QueryEvents assertEquals(listOf(1), single.filter.kinds) assertEquals(listOf("nostr"), single.filter.tags?.get("t")) @@ -106,13 +102,17 @@ class NappletProtocolJsonTest { val array = NappletProtocolJson.decodeRequest("""{"type":"relay.query","filters":[{"authors":["aa"]}]}""") as NappletRequest.QueryEvents assertEquals(listOf("aa"), array.filter.authors) + + val sub = NappletProtocolJson.decodeRequest("""{"type":"relay.subscribe","filter":{"kinds":[1]}}""") as NappletRequest.Subscribe + assertEquals(listOf(1), sub.filter.kinds) } @Test fun decodesStorageOps() { - assertEquals(NappletRequest.StorageGet("k"), NappletProtocolJson.decodeRequest("""{"type":"storage.get","key":"k"}""")) - assertEquals(NappletRequest.StorageSet("k", "v"), NappletProtocolJson.decodeRequest("""{"type":"storage.set","key":"k","value":"v"}""")) - assertEquals(NappletRequest.StorageRemove("k"), NappletProtocolJson.decodeRequest("""{"type":"storage.remove","key":"k"}""")) + assertEquals(NappletRequest.StorageGet("k"), NappletProtocolJson.decodeRequest("""{"type":"storage.getItem","key":"k"}""")) + assertEquals(NappletRequest.StorageSet("k", "v"), NappletProtocolJson.decodeRequest("""{"type":"storage.setItem","key":"k","value":"v"}""")) + assertEquals(NappletRequest.StorageRemove("k"), NappletProtocolJson.decodeRequest("""{"type":"storage.removeItem","key":"k"}""")) + assertEquals(NappletRequest.StorageKeys, NappletProtocolJson.decodeRequest("""{"type":"storage.keys"}""")) } @Test @@ -128,13 +128,16 @@ class NappletProtocolJsonTest { @Test fun unknownTypeDecodesToNull() { assertNull(NappletProtocolJson.decodeRequest("""{"type":"inc.emit","id":"1"}""")) + // keys.* (keyboard actions) are handled client-side and never cross the boundary. + assertNull(NappletProtocolJson.decodeRequest("""{"type":"keys.signEvent","id":"1"}""")) + assertNull(NappletProtocolJson.decodeRequest("""{"type":"keys.registerAction","id":"1"}""")) assertNull(NappletProtocolJson.decodeRequest("""{"foo":"bar"}""")) } @Test fun malformedOrMissingFieldThrows() { assertThrowsAny { NappletProtocolJson.decodeRequest("not json") } - assertThrowsAny { NappletProtocolJson.decodeRequest("""{"type":"keys.signEvent","content":"x"}""") } + assertThrowsAny { NappletProtocolJson.decodeRequest("""{"type":"relay.publish","template":{"content":"x"}}""") } } @Test @@ -160,40 +163,46 @@ class NappletProtocolJsonTest { } @Test - fun encodesSignedEventAndEvents() { - val signed = json.parseToJsonElement(NappletProtocolJson.encodeResponse("keys.signEvent", NappletResponse.SignedEvent(sampleEvent()))).jsonObject + fun encodesPublishedEventAndEvents() { + // relay.publish resolves to the signed event (matching upstream NostrEvent return). + val published = json.parseToJsonElement(NappletProtocolJson.encodeResponse("relay.publish", NappletResponse.Published(sampleEvent(), listOf("wss://r")))).jsonObject assertEquals( "a".repeat(64), - signed["event"] + published["event"] ?.jsonObject ?.get("id") ?.jsonPrimitive ?.content, ) + assertEquals("a".repeat(64), published["eventId"]?.jsonPrimitive?.content) + assertEquals(1, published["relays"]?.jsonArray?.size) val events = json.parseToJsonElement(NappletProtocolJson.encodeResponse("relay.query", NappletResponse.Events(listOf(sampleEvent())))).jsonObject assertEquals(1, events["events"]?.jsonArray?.size) } @Test - fun encodesStorageNullAsJsonNull() { - val absent = json.parseToJsonElement(NappletProtocolJson.encodeResponse("storage.get", NappletResponse.StorageValue(null))).jsonObject + fun encodesStorageNullAsJsonNullAndKeysAsArray() { + val absent = json.parseToJsonElement(NappletProtocolJson.encodeResponse("storage.getItem", NappletResponse.StorageValue(null))).jsonObject assertEquals(JsonNull, absent["value"]) + + val keys = json.parseToJsonElement(NappletProtocolJson.encodeResponse("storage.keys", NappletResponse.Strings(listOf("a", "b")))).jsonObject + assertEquals(2, keys["values"]?.jsonArray?.size) } @Test - fun encodesBytesAsBase64() { + fun encodesBytesAsBase64WithMime() { val o = json.parseToJsonElement(NappletProtocolJson.encodeResponse("resource.bytes", NappletResponse.Bytes("Hi".encodeToByteArray(), "text/plain"))).jsonObject assertEquals("SGk=", o["bytes"]?.jsonPrimitive?.content) - assertEquals("text/plain", o["contentType"]?.jsonPrimitive?.content) + assertEquals("text/plain", o["mime"]?.jsonPrimitive?.content) } @Test fun encodesErrorsWithOkFalse() { - val denied = json.parseToJsonElement(NappletProtocolJson.encodeResponse("keys.signEvent", NappletResponse.Denied(NappletCapability.KEYS, "no"))).jsonObject + val denied = json.parseToJsonElement(NappletProtocolJson.encodeResponse("relay.publish", NappletResponse.Denied(NappletCapability.RELAY, "no"))).jsonObject assertFalse(denied["ok"]!!.jsonPrimitive.boolean) assertEquals("denied", denied["error"]?.jsonPrimitive?.content) - assertEquals("KEYS", denied["capability"]?.jsonPrimitive?.content) + assertEquals("RELAY", denied["capability"]?.jsonPrimitive?.content) val unsupported = json.parseToJsonElement(NappletProtocolJson.encodeResponse("upload", NappletResponse.Unsupported("upload"))).jsonObject assertFalse(unsupported["ok"]!!.jsonPrimitive.boolean) 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 392e4c12a3..80f70a9394 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 @@ -26,7 +26,6 @@ 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.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.crypto.verify import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import com.vitorpamplona.quartz.utils.TimeUtils @@ -43,18 +42,20 @@ import kotlin.coroutines.cancellation.CancellationException * 3. **Consent** — when no standing decision exists, the user is asked. * * Two capability-specific policies refine step 2/3: - * - **Per-use capabilities** ([NappletCapability.requiresPerUseConsent], i.e. [NappletCapability.WALLET]) + * - **Per-use capabilities** ([NappletCapability.requiresPerUseConsent], i.e. [NappletCapability.VALUE]) * never auto-approve from a prior grant — every payment is confirmed afresh, with the amount shown. - * - **Signer self-gating**: [NappletCapability.IDENTITY] is gated here only when the key lives in - * Amethyst (a [NostrSignerInternal]). Remote (NIP-46) and external (NIP-55) signers run their own - * per-request consent UI, so we defer to them rather than double-prompt. A standing DENY is still - * honored, and the applet must still have *declared* `identity`. This is safe only because the - * napplet host runs foreground-only, so the signer's prompt appears in the clear context of the - * user interacting with that napplet (it can't be fired from the background). + * - **Signer self-gating**: an identity read or a sign-as-user op ([NappletRequest.signsAsUser]) + * is gated here only when the key lives in Amethyst (a [NostrSignerInternal]). Remote (NIP-46) and + * external (NIP-55) signers run their own per-request consent UI, so we defer to them rather than + * double-prompt. A standing DENY is still honored, and the applet must still have *declared* the + * capability. This is safe only because the napplet host runs foreground-only, so the signer's + * prompt appears in the clear context of the user interacting with that napplet (it can't be + * fired from the background). * * Security invariants enforced here (never trusted from the applet): the signing identity is - * always the host's signer; [NappletRequest.SignEvent] stamps `created_at` from the host clock; - * a response never contains private key bytes; storage is namespaced per applet coordinate. + * always the host's signer; the napplet only ever supplies an unsigned template — the shell signs + * it and stamps `created_at` from the host clock; a response never contains private key bytes; + * storage is namespaced per applet coordinate. */ class NappletBroker( private val signer: NostrSigner, @@ -96,8 +97,8 @@ class NappletBroker( val authorized = when { - // Remote/external signers run their own per-request consent UI — defer identity to them. - signerSelfGates(capability) -> true + // Remote/external signers run their own per-request consent UI — defer to them. + signerSelfGates(request) -> true // A standing allow short-circuits, except for per-use capabilities (e.g. payments). ledger.decide(identity, capability) == PermissionDecision.ALLOW && !capability.requiresPerUseConsent -> true else -> { @@ -118,8 +119,8 @@ class NappletBroker( } } - /** Identity/key ops are gated by us only when we hold the key; remote/external signers gate themselves. */ - private fun signerSelfGates(capability: NappletCapability): Boolean = (capability == NappletCapability.IDENTITY || capability == NappletCapability.KEYS) && signer !is NostrSignerInternal + /** Identity reads and sign-as-user ops are gated by us only when we hold the key; remote/external signers gate themselves. */ + private fun signerSelfGates(request: NappletRequest): Boolean = (request.capability == NappletCapability.IDENTITY || request.signsAsUser) && signer !is NostrSignerInternal /** Downgrades a grant to one-shot when the capability forbids persisting that scope (e.g. payments). */ private fun effectiveGrant( @@ -142,48 +143,52 @@ class NappletBroker( is NappletRequest.GetPublicKey -> NappletResponse.PublicKey(signer.pubKey) - is NappletRequest.SignEvent -> { - // created_at comes from the host, never the applet, so it cannot backdate. - val signed: Event = signer.sign(TimeUtils.now(), request.kind, request.tags, request.content) - NappletResponse.SignedEvent(signed) + // The napplet supplies an unsigned template; the shell signs and publishes it. + // created_at comes from the host, never the applet, so it cannot backdate. + is NappletRequest.Publish -> signAndPublish(request.kind, request.tags, request.content) + + is NappletRequest.PublishEncrypted -> { + val ciphertext = + when (request.encryption.trim().lowercase()) { + "nip04" -> signer.nip04Encrypt(request.content, request.recipient) + else -> signer.nip44Encrypt(request.content, request.recipient) + } + signAndPublish(request.kind, withRecipientTag(request.tags, request.recipient), ciphertext) } - is NappletRequest.Nip04Encrypt -> - NappletResponse.Text(signer.nip04Encrypt(request.plaintext, request.peerPubKey)) - - is NappletRequest.Nip04Decrypt -> - NappletResponse.Text(signer.nip04Decrypt(request.ciphertext, request.peerPubKey)) - - is NappletRequest.Nip44Encrypt -> - NappletResponse.Text(signer.nip44Encrypt(request.plaintext, request.peerPubKey)) - - is NappletRequest.Nip44Decrypt -> - NappletResponse.Text(signer.nip44Decrypt(request.ciphertext, request.peerPubKey)) - - is NappletRequest.Publish -> publish(request.event) - is NappletRequest.QueryEvents -> { - val gateway = relay ?: return NappletResponse.Unsupported("query") + val gateway = relay ?: return NappletResponse.Unsupported("relay.query") + NappletResponse.Events(gateway.query(request.filter)) + } + + // Live tailing is a follow-up; for now subscribe returns the initial matches. + is NappletRequest.Subscribe -> { + val gateway = relay ?: return NappletResponse.Unsupported("relay.subscribe") NappletResponse.Events(gateway.query(request.filter)) } is NappletRequest.StorageGet -> { - val store = storage ?: return NappletResponse.Unsupported("storage.get") + val store = storage ?: return NappletResponse.Unsupported("storage.getItem") NappletResponse.StorageValue(store.get(identity.coordinate, request.key)) } is NappletRequest.StorageSet -> { - val store = storage ?: return NappletResponse.Unsupported("storage.set") + val store = storage ?: return NappletResponse.Unsupported("storage.setItem") store.set(identity.coordinate, request.key, request.value) NappletResponse.Done } is NappletRequest.StorageRemove -> { - val store = storage ?: return NappletResponse.Unsupported("storage.remove") + val store = storage ?: return NappletResponse.Unsupported("storage.removeItem") store.remove(identity.coordinate, request.key) NappletResponse.Done } + is NappletRequest.StorageKeys -> { + val store = storage ?: return NappletResponse.Unsupported("storage.keys") + NappletResponse.Strings(store.keys(identity.coordinate)) + } + is NappletRequest.PayInvoice -> { val gateway = wallet ?: return NappletResponse.Unsupported("value.payInvoice") NappletResponse.Paid(gateway.payInvoice(request.invoice)) @@ -202,17 +207,29 @@ class NappletBroker( } } - private suspend fun publish(event: Event): NappletResponse { - val gateway = relay ?: return NappletResponse.Unsupported("publish") - - // An applet may only publish as the active user, and only validly-signed events. - if (event.pubKey != signer.pubKey) { - return NappletResponse.Failed("Refusing to publish an event for a different identity.") - } - if (!event.verify()) { - return NappletResponse.Failed("Refusing to publish an event with an invalid signature.") - } - - return NappletResponse.Published(gateway.publish(event)) + /** + * Signs a napplet-supplied template **as the active user** and publishes it. The applet never + * sees a key, can never sign as another identity (the signer fixes `pubkey`), and cannot + * backdate (`created_at` is the host clock). + */ + private suspend fun signAndPublish( + kind: Int, + tags: Array>, + content: String, + ): NappletResponse { + val gateway = relay ?: return NappletResponse.Unsupported("relay.publish") + val signed: Event = signer.sign(TimeUtils.now(), kind, tags, content) + return NappletResponse.Published(signed, gateway.publish(signed)) } + + /** Ensures the encrypted event addresses [recipient] with a `p` tag, without duplicating one. */ + private fun withRecipientTag( + tags: Array>, + recipient: String, + ): Array> = + if (tags.any { it.size >= 2 && it[0] == "p" && it[1] == recipient }) { + tags + } else { + tags + arrayOf(arrayOf("p", recipient)) + } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBrokerCollaborators.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBrokerCollaborators.kt index 282ba19c79..d622c7f59f 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBrokerCollaborators.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBrokerCollaborators.kt @@ -75,6 +75,9 @@ interface NappletStorage { coordinate: String, key: String, ) + + /** Lists the keys this applet (identified by [coordinate]) has stored. */ + suspend fun keys(coordinate: String): List } /** 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 42b86e6791..e86bc924a0 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 @@ -37,10 +37,14 @@ enum class NappletCapability { /** `identity` — read-only identity queries (`getPublicKey`, `onChanged`). */ IDENTITY, - /** `keys` — sign events and NIP-04/44 encrypt/decrypt as the user. */ + /** + * `keys` — keyboard / command action binding (`registerAction`, `onAction`). This is **not** + * signing: the upstream `@napplet/shim` deliberately has no `sign()` method, and napplets never + * get direct key access. Signing happens only inside the shell via [RELAY] `publish`. + */ KEYS, - /** `relay` — publish, query, and subscribe to the user's relays. */ + /** `relay` — publish (shell-signed), query, and subscribe to the user's relays. */ RELAY, /** `storage` — a per-applet sandboxed key-value store, namespaced by applet identity. */ @@ -82,11 +86,11 @@ enum class NappletCapability { when (domain.trim().lowercase()) { "shell" -> SHELL "identity" -> IDENTITY - "keys", "sign", "signer", "nip04", "nip44" -> KEYS + "keys" -> KEYS "relay", "relays" -> RELAY "storage" -> STORAGE - "value", "wallet", "zap", "zaps", "payments" -> VALUE - "resource", "fetch", "net", "network" -> RESOURCE + "value" -> VALUE + "resource" -> RESOURCE "upload" -> UPLOAD else -> null } 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 b9aa11a2e9..958cf0abfa 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 @@ -21,7 +21,6 @@ package com.vitorpamplona.amethyst.commons.napplet.protocol import com.vitorpamplona.amethyst.commons.napplet.NappletCapability -import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter @@ -38,33 +37,44 @@ import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter sealed interface NappletRequest { val capability: NappletCapability + /** + * Whether executing this request **signs an event as the user**. The shell — never the napplet + * — holds the key and does the signing (matching `@napplet/shim`, which has no `sign()`). This + * flag lets the broker defer the per-signature prompt to a remote/external signer that runs its + * own consent UI, instead of double-prompting. + */ + val signsAsUser: Boolean get() = false + /** Read the active user's public key. */ data object GetPublicKey : NappletRequest { override val capability get() = NappletCapability.IDENTITY } - /** `shell.supports(domain)` — capability negotiation; always answerable, needs no consent. */ + /** `shell.supports(domain, protocol?)` — capability negotiation; always answerable, no consent. */ data class ShellSupports( val domain: String, + val protocol: String? = null, ) : NappletRequest { override val capability get() = NappletCapability.SHELL } /** - * Build and sign an event **as the active user**. The applet supplies only the template - * fields; the broker sets `pubkey` from the real signer and stamps `created_at`, so the - * applet can never sign as another identity nor backdate. + * Publish an event built from an **unsigned template**. The napplet supplies only `kind`, + * `tags`, and `content`; the shell sets `pubkey` from the real signer, stamps `created_at`, + * signs, and broadcasts — so a napplet can never sign as another identity, backdate, nor + * obtain a raw signature. This is the *only* signing path exposed to napplets. */ - data class SignEvent( + data class Publish( val kind: Int, val tags: Array>, val content: String, ) : NappletRequest { - override val capability get() = NappletCapability.KEYS + override val capability get() = NappletCapability.RELAY + override val signsAsUser get() = true override fun equals(other: Any?): Boolean { if (this === other) return true - if (other !is SignEvent) return false + if (other !is Publish) return false if (kind != other.kind) return false if (content != other.content) return false if (tags.size != other.tags.size) return false @@ -80,42 +90,41 @@ sealed interface NappletRequest { } } - data class Nip04Encrypt( - val peerPubKey: HexKey, - val plaintext: String, - ) : NappletRequest { - override val capability get() = NappletCapability.KEYS - } - - data class Nip04Decrypt( - val peerPubKey: HexKey, - val ciphertext: String, - ) : NappletRequest { - override val capability get() = NappletCapability.KEYS - } - - data class Nip44Encrypt( - val peerPubKey: HexKey, - val plaintext: String, - ) : NappletRequest { - override val capability get() = NappletCapability.KEYS - } - - data class Nip44Decrypt( - val peerPubKey: HexKey, - val ciphertext: String, - ) : NappletRequest { - override val capability get() = NappletCapability.KEYS - } - /** - * Publish an already-signed [event] to the user's relays. The broker verifies the - * signature and that the event belongs to the active user before publishing. + * Encrypt [content] to [recipient] with [encryption] (`"nip44"`, default, or `"nip04"`), then + * build, sign, and publish the event. The shell holds the key and performs both the encryption + * and the signing; the napplet supplies only plaintext. */ - data class Publish( - val event: Event, + data class PublishEncrypted( + val kind: Int, + val tags: Array>, + val content: String, + val recipient: HexKey, + val encryption: String, ) : NappletRequest { override val capability get() = NappletCapability.RELAY + override val signsAsUser get() = true + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is PublishEncrypted) return false + if (kind != other.kind) return false + if (content != other.content) return false + if (recipient != other.recipient) return false + if (encryption != other.encryption) return false + if (tags.size != other.tags.size) return false + for (i in tags.indices) if (!tags[i].contentEquals(other.tags[i])) return false + return true + } + + override fun hashCode(): Int { + var result = kind + result = 31 * result + content.hashCode() + result = 31 * result + recipient.hashCode() + result = 31 * result + encryption.hashCode() + result = 31 * result + tags.sumOf { it.contentHashCode() } + return result + } } /** Read events matching [filter] (from the cache and/or a bounded relay fetch). */ @@ -125,14 +134,24 @@ sealed interface NappletRequest { override val capability get() = NappletCapability.RELAY } - /** Read a value from this napplet's sandboxed key-value store. */ + /** + * Subscribe to events matching [filter]. The shell currently answers with the initial matches + * (like a query); a live tail over the existing reply channel is a follow-up. + */ + data class Subscribe( + val filter: Filter, + ) : NappletRequest { + override val capability get() = NappletCapability.RELAY + } + + /** Read a value from this napplet's sandboxed key-value store (`storage.getItem`). */ data class StorageGet( val key: String, ) : NappletRequest { override val capability get() = NappletCapability.STORAGE } - /** Write a value to this napplet's sandboxed key-value store. */ + /** Write a value to this napplet's sandboxed key-value store (`storage.setItem`). */ data class StorageSet( val key: String, val value: String, @@ -140,14 +159,24 @@ sealed interface NappletRequest { override val capability get() = NappletCapability.STORAGE } - /** Remove a value from this napplet's sandboxed key-value store. */ + /** Remove a value from this napplet's sandboxed key-value store (`storage.removeItem`). */ data class StorageRemove( val key: String, ) : NappletRequest { override val capability get() = NappletCapability.STORAGE } - /** Pay a BOLT-11 invoice from the user's wallet (`value` domain). */ + /** List the keys this napplet has stored (`storage.keys`). */ + data object StorageKeys : NappletRequest { + override val capability get() = NappletCapability.STORAGE + } + + /** + * Pay a BOLT-11 invoice from the user's wallet (`value.payInvoice`). This is an + * **Amethyst-specific extension** — the upstream `value` domain models zaps/value-transfer + * differently — kept because a real napplet built against `@napplet/shim` never calls it, so + * it cannot conflict. + */ data class PayInvoice( val invoice: String, ) : NappletRequest { 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 ada15a5da0..7dd5826767 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 @@ -35,16 +35,13 @@ sealed interface NappletResponse { val pubkey: HexKey, ) : NappletResponse - data class SignedEvent( - val event: Event, - ) : NappletResponse - - /** Result of an encrypt/decrypt operation. */ - data class Text( - val value: String, - ) : NappletResponse - + /** + * Result of `relay.publish` / `relay.publishEncrypted`: the [event] the shell signed on the + * napplet's behalf and the [relays] that accepted it. Matches the upstream contract, where + * `publish(template)` resolves to the signed `NostrEvent`. + */ data class Published( + val event: Event, val relays: List, ) : NappletResponse @@ -63,6 +60,11 @@ sealed interface NappletResponse { val value: String?, ) : NappletResponse + /** Result of `storage.keys` (and other string-list reads). */ + data class Strings( + val values: List, + ) : NappletResponse + /** Result of a `resource.bytes` fetch. */ data class Bytes( val bytes: ByteArray, 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 156b86324a..de3668b11a 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 @@ -47,9 +47,6 @@ class NappletBrokerTest { private val userPriv = "0000000000000000000000000000000000000000000000000000000000000007" private val signer = NostrSignerInternal(KeyPair(userPriv.hexToByteArray())) - private val strangerPriv = "0000000000000000000000000000000000000000000000000000000000000019" - private val stranger = NostrSignerInternal(KeyPair(strangerPriv.hexToByteArray())) - private val applet = NappletIdentity(authorPubKey = "aa".repeat(32), identifier = "demo") private val allDeclared = NappletCapability.entries.toSet() @@ -164,6 +161,8 @@ class NappletBrokerTest { ) { data.remove(k(coordinate, key)) } + + override suspend fun keys(coordinate: String): List = data.keys.filter { it.startsWith("$coordinate::") }.map { it.removePrefix("$coordinate::") } } private fun broker( @@ -228,66 +227,42 @@ class NappletBrokerTest { } @Test - fun signEventProducesAValidlySignedEventAsTheUser() = + fun publishSignsTheTemplateAsTheUserAndSends() = runTest { - val request = NappletRequest.SignEvent(kind = 1, tags = arrayOf(arrayOf("t", "napplet")), content = "gm") - val response = broker(ScriptedPrompt(GrantState.ALLOW_ONCE)).handle(applet, request, allDeclared) + val relay = RecordingRelay() + val request = NappletRequest.Publish(kind = 1, tags = arrayOf(arrayOf("t", "napplet")), content = "gm") - assertIs(response) + val response = broker(ScriptedPrompt(GrantState.ALLOW_ONCE), relay = relay).handle(applet, request, allDeclared) + + // The shell signs the unsigned template and resolves to the signed event + relays. + assertIs(response) val event = response.event - assertEquals(signer.pubKey, event.pubKey) // applet cannot sign as anyone but the user + assertEquals(signer.pubKey, event.pubKey) // applet supplied no pubkey; the shell fixed it to the user assertEquals(1, event.kind) assertEquals("gm", event.content) assertTrue(event.verify()) // id + signature are real + assertEquals(listOf("wss://relay.example"), response.relays) + assertEquals(1, relay.published.size) + assertEquals(event.id, relay.published.first().id) } @Test fun publishWithoutAGatewayIsUnsupported() = runTest { - val event: Event = signer.sign(1L, 1, emptyArray(), "hi") val response = broker(ScriptedPrompt(GrantState.ALLOW_ONCE), relay = null) - .handle(applet, NappletRequest.Publish(event), allDeclared) + .handle(applet, NappletRequest.Publish(1, emptyArray(), "hi"), allDeclared) assertIs(response) } - @Test - fun publishRefusesAnEventFromAnotherIdentity() = - runTest { - val foreign: Event = stranger.sign(1L, 1, emptyArray(), "not yours") - val relay = RecordingRelay() - - val response = - broker(ScriptedPrompt(GrantState.ALLOW_ONCE), relay = relay) - .handle(applet, NappletRequest.Publish(foreign), allDeclared) - - assertIs(response) - assertTrue(relay.published.isEmpty()) // nothing left the broker - } - - @Test - fun publishSendsAValidUserEventThroughTheGateway() = - runTest { - val event: Event = signer.sign(1L, 1, emptyArray(), "ship it") - val relay = RecordingRelay() - - val response = - broker(ScriptedPrompt(GrantState.ALLOW_ONCE), relay = relay) - .handle(applet, NappletRequest.Publish(event), allDeclared) - - assertEquals(NappletResponse.Published(listOf("wss://relay.example")), response) - assertEquals(1, relay.published.size) - } - @Test fun relayDenyDoesNotReachTheGateway() = runTest { - val event: Event = signer.sign(1L, 1, emptyArray(), "blocked") val relay = RecordingRelay() val response = broker(ScriptedPrompt(GrantState.DENY), relay = relay) - .handle(applet, NappletRequest.Publish(event), allDeclared) + .handle(applet, NappletRequest.Publish(1, emptyArray(), "blocked"), allDeclared) assertIs(response) assertTrue(relay.published.isEmpty()) @@ -322,6 +297,22 @@ class NappletBrokerTest { assertEquals(NappletResponse.StorageValue(null), broker.handle(applet, NappletRequest.StorageGet("k"), allDeclared)) } + @Test + fun storageKeysListsOnlyThisAppletsKeys() = + runTest { + val storage = MapStorage() + val broker = broker(ScriptedPrompt(GrantState.ALLOW_ALWAYS), storage = storage) + val other = NappletIdentity(authorPubKey = "bb".repeat(32), identifier = "other") + + broker.handle(applet, NappletRequest.StorageSet("a", "1"), allDeclared) + broker.handle(applet, NappletRequest.StorageSet("b", "2"), allDeclared) + broker.handle(other, NappletRequest.StorageSet("c", "3"), allDeclared) + + val response = broker.handle(applet, NappletRequest.StorageKeys, allDeclared) + assertIs(response) + assertEquals(setOf("a", "b"), response.values.toSet()) // never sees the other applet's "c" + } + @Test fun storageWithoutAGatewayIsUnsupported() = runTest {