feat(napplet): trust-boundary core for sandboxed nsite/napplet rendering

Adds the platform-agnostic core for hosting untrusted napplet (NIP-5D) /
nsite (NIP-5A) web content behind a hard trust boundary, so applet HTML/JS
can never reach the nsec, app storage, or LocalCache.

The Android host runs the WebView in a separate OS process (:napplet) that
holds no secrets and brokers every dangerous operation over IPC to the main
process. This commit lands the verifiable heart of that boundary in commons
commonMain (KMP-pure, fully unit-tested):

- NappletCapability + NAP-domain mapping (default-deny on unknown domains)
- NappletIdentity keyed by addressable coordinate (grants survive updates)
- NappletPermissionLedger / GrantState / store (persistent vs session vs once;
  standing DENY is authoritative)
- NappletRequest/NappletResponse wire protocol (no response carries key bytes)
- NappletBroker: the only holder of the signer; enforces consent, signs as the
  user only, refuses to publish foreign or unsigned events

Architecture, process model, IPC schema, WebView hardening, and consent UX are
documented in amethyst/plans/2026-06-19-napplet-sandbox-host.md. The Android
:napplet process, WebView host, AIDL broker, and consent UI are the next phase.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
This commit is contained in:
Claude
2026-06-19 23:22:45 +00:00
parent 19b1797f2d
commit 119442293a
13 changed files with 1302 additions and 0 deletions
@@ -0,0 +1,198 @@
# Napplet / nsite sandbox host — design
**Date:** 2026-06-19
**Status:** Draft — core (commons) implemented + tested; Android host specified, not yet built
**Companion:** `quartz/plans/2026-06-19-napplet-nip5a-resolver.md` (the bottom half — manifest parsing + verified Blossom resolution — already landed in `quartz`).
## Goal
Render NIP-5A static sites (nsites) and NIP-5D napplets *inside* Amethyst, with
the applet's HTML/CSS/JS running behind a **hard trust boundary**: it must never
be able to read the user's `nsec` or any other secret, read app storage,
`LocalCache`, or other accounts' data, or sign / encrypt / publish / zap without
explicit per-applet user consent. A napplet is untrusted third-party code served
from an untrusted Blossom CDN; we treat it accordingly.
## Threat model
**Adversary:** the applet bundle (HTML/CSS/JS), authored by an untrusted party,
delivered from an untrusted CDN.
It must NOT be able to:
1. Read the `nsec` / any `NostrSigner`-held secret, or decrypted key material.
2. Read app private storage, `LocalCache`, DataStore, other accounts, or another
applet's sandboxed storage.
3. Sign, encrypt/decrypt, publish, subscribe, or zap without explicit consent.
4. Escalate to native code, other installed apps, or the file system.
5. Reach the network directly to exfiltrate or fingerprint (network is a *brokered
capability*, default-deny).
6. Forge content past the signed manifest (already handled by `StaticSiteResolver`:
manifest is authority, CDN is untrusted, every blob is sha256-verified).
**Trusted:** the main Amethyst process, the broker, the consent UI, quartz
verification. **Assumption:** the Android System WebView (Chromium) renderer
sandbox is sound — and we add an OS-process boundary on top so that even a full
WebView/renderer escape lands in a process that holds no secrets.
## Why a separate OS process is the load-bearing decision
Android processes have isolated address spaces. The decrypted `privKey`, the
`KeyPair`, and the `NostrSigner` instance live **only in the main process heap**.
The applet host runs in a separate process (`android:process=":napplet"`) that:
- never constructs a `NostrSigner`, never touches `SecureKeyStorage`/Keystore,
never holds an `Account` or `LocalCache`;
- only holds an IPC handle to *request operations*, whose **results carry no key
material** (a signed event, a ciphertext, a pubkey — never the private key).
So even arbitrary code execution inside the WebView renderer (already its own
sandboxed process) or inside the `:napplet` app process cannot read the main
process's memory where the secret lives. This is the guarantee the same-process
approach cannot make.
## Process & component model
```
┌─────────────────────────────────────────┐ ┌───────────────────────────────────┐
│ Main process (com.vitorpamplona.amethyst)│ │ Applet process (…:napplet) │
│ │ │ │
│ Account · NostrSigner · SecureKeyStorage │ │ NappletHostActivity │
│ LocalCache · NostrClient · Blossom · NWC │ │ └─ WebView │
│ │ │ ├─ shell page (trusted, │
│ NappletBrokerService (bound, not exported)│◀──AIDL─▶│ │ app-asset origin) │
│ └─ commons NappletBroker │ Binder │ │ exposes bridge → broker │
│ └─ NappletPermissionLedger │ (UID │ └─ <iframe sandbox= │
│ │ checked)│ "allow-scripts"> │
│ NappletConsentActivity (consent UI) │ │ = applet, opaque origin│
└─────────────────────────────────────────┘ └───────────────────────────────────┘
holds secrets holds NO secrets
```
### Three transport hops (each a trust step-down)
1. **applet iframe ↔ shell page** — `postMessage` with strict origin checks. The
shell injects a tiny `window.napplet.*` client shim into the applet that wraps
postMessage calls into promises (request-id correlation). This is the NIP-5D
capability surface the applet codes against.
2. **shell page ↔ `:napplet` native** — exactly one audited bridge,
`WebView.addWebMessageListener` restricted to the **shell origin only** (the
applet's opaque-origin iframe cannot reach it). The shell forwards validated
requests.
3. **`:napplet` process ↔ main-process broker** — AIDL/`Messenger`. The broker
checks `Binder.getCallingUid() == Process.myUid()` (reject anything not from
our own app), consults the permission ledger, runs the operation with the real
signer/client, returns only the result.
### WebView hardening (`:napplet`)
- Shell document served from app assets via `WebViewAssetLoader` at a fixed
internal origin (`https://napplet.localhost/`). The applet lives in a child
`<iframe sandbox="allow-scripts">` **without `allow-same-origin`** → unique
opaque origin: no access to the shell DOM, cookies, `localStorage`,
`IndexedDB`, or the bridge.
- Applet resources are served by `shouldInterceptRequest` from an **in-memory map
of already-verified blob bytes** (resolved + sha256-checked by
`StaticSiteResolver` *before* the WebView loads). Only manifest-declared paths
resolve; everything else → 404. No `file://`, no `content://`.
- `WebSettings`: `allowFileAccess=false`, `allowContentAccess=false`,
`allowFileAccessFromFileURLs=false`, `allowUniversalAccessFromFileURLs=false`,
`setGeolocationEnabled(false)`, `mediaPlaybackRequiresUserGesture=true`,
`safeBrowsingEnabled=true`, no insecure mixed content. `domStorageEnabled`
only for a partitioned per-applet store (or off in v1).
- **CSP**: the shell injects `connect-src 'none'` for the applet by default — the
applet has *no direct network*. Relay/Blossom/identity all go through the
broker. Direct network is itself a capability (`net`) that widens `connect-src`
to user-approved origins only.
- External navigation blocked in `shouldOverrideUrlLoading`; links open in the
system browser only after consent.
## Capability / NAP-domain model
`NappletManifest.requires()` already yields the bare NAP domains
(`identity`, `relay`, `storage`, …; see `quartz/.../tags/RequiresTag.kt`). Map
each to a `NappletCapability` with the concrete broker operations it unlocks:
| NAP domain | Capability | Broker operations |
|---|---|---|
| `identity` | `IDENTITY` | `getPublicKey`, `signEvent`, `nip04/44 encrypt/decrypt` |
| `relay` | `RELAY` | `publish`, scoped `subscribe` (read) |
| `value` / `wallet` | `WALLET` | NIP-57 zap request / invoice; NWC pay (stricter, separate grant) |
| `storage` | `STORAGE` | per-applet sandboxed KV store, namespaced by applet identity — **never** app storage |
| `net` | `NET` | widen CSP `connect-src` to approved origins |
| *(unknown)* | — | **denied by default**, surfaced to the user |
**Applet identity for the ledger** = author pubkey + `d` identifier (the
addressable coordinate), *not* the blob hash, so grants survive updates. We still
record the manifest aggregate hash (`computeAggregateHash()`) so a user who picks
"ask again on code change" is re-prompted when the bundle changes.
## Permission ledger
`NappletPermissionLedger` — per-applet-identity grants, each
`NappletCapability → GrantState` (`ASK` / `ALLOW_ONCE` / `ALLOW_SESSION` /
`ALLOW_ALWAYS` / `DENY`), persisted via a `NappletPermissionStore` interface
(DataStore actual on Android). The broker consults it on every request:
- `DENY` → immediate `Denied` response.
- `ALLOW_*` → execute.
- `ASK` → suspend, launch `NappletConsentActivity` (main process), await the
user's decision, optionally persist, then execute or deny.
Consent UI reuses the signer-prompt design
(`amethyst/plans/2026-05-25-appfunctions-signer-prompts.md`) and
`commons/.../ui/signing`.
## Module placement
- **`quartz`** — protocol done. (Optional later: a canonical `NapDomain` constant
set once the upstream NAP list stabilizes — an open question in the resolver
plan.)
- **`commons/commonMain`** — new CLI-safe `napplet/` feature package:
- `napplet/NappletCapability.kt` — NAP-domain ↔ capability mapping.
- `napplet/protocol/` — `NappletRequest` / `NappletResponse` sealed families + JSON codec.
- `napplet/permissions/` — `NappletPermissionLedger`, `GrantState`, `NappletPermissionStore`.
- `napplet/NappletBroker.kt` — platform-agnostic broker: `(NostrSigner +
relay/blossom/zap handles + ledger) → (NappletRequest → NappletResponse)`.
The heart of the boundary; **fully unit-testable on the JVM**.
- `napplet/ui/` — shared consent composables.
- **`amethyst/androidMain`** —
- `NappletHostActivity` (`:napplet`) + WebView + `WebViewAssetLoader` + the
shell HTML/JS shim asset.
- `NappletBrokerService` (main process, bound, `exported=false`) wrapping the
commons broker; `Binder` UID check.
- `NappletConsentActivity` (main process) using the commons consent UI.
- AIDL/`Messenger` plumbing; OkHttp `BlobFetcher` wiring (the resolver plan's
named follow-up).
- AndroidManifest: `:napplet` process declaration, the bound service, the
consent activity.
- **`desktopApp`** — out of scope for v1 (the `:napplet` process model is
Android-specific; desktop needs a separate child-process/WebView strategy).
## Inter-applet communication (v2)
Napplets' differentiator is talking to each other. Model: applets address each
other by napplet coordinate; `napplet.send(target, msg)` is **routed through the
broker** (shell→broker→shell) so two opaque-origin iframes never share an origin
or memory, and the user (or a manifest-declared allowlist) consents to the link.
Deferred to v2; v1 nails the single-applet boundary first.
## Testing & verification
- **commons (now, JVM):** broker decision logic per capability
(granted/denied/ask), ledger state transitions + persistence semantics,
protocol JSON round-trips, and security cases — unknown NAP domain denied,
request for an undeclared path 404s without fetching, signer responses never
contain key bytes.
- **Android (needs emulator):** instrumented tests for the WebView host, the
opaque-origin iframe isolation, and the process boundary — flagged as on-device
verification.
## Phasing (within the "full napplet" milestone)
1. **Core (commons, tested)** — protocol + capability model + ledger + broker. ✅ verifiable now.
2. **Android host** — `:napplet` process + WebView + asset loader rendering a
static, no-capability applet (proves sandbox + verified blob serving).
3. **Broker IPC + `identity`** — getPublicKey/signEvent + consent UI.
4. **`relay` + `value` + `storage`** capabilities.
5. **Inter-applet** (v2).
@@ -0,0 +1,125 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.napplet
import com.vitorpamplona.amethyst.commons.napplet.permissions.NappletPermissionLedger
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.utils.TimeUtils
import kotlin.coroutines.cancellation.CancellationException
/**
* The trust boundary, expressed as code. The broker is the **only** component that holds a
* [NostrSigner] and turns an untrusted napplet's [NappletRequest] into a [NappletResponse],
* gating every dangerous operation through the [ledger] (and, when no standing grant exists,
* the [consentPrompt]). On Android it runs in the main process behind an IPC boundary; the
* applet's process never sees this object, the signer, or any key material.
*
* Security invariants enforced here (not trusted from the applet):
* - The signing identity is always the host's signer — an applet can never sign or publish as
* someone else, and [NappletRequest.SignEvent] sets `created_at` from the host clock.
* - A response never contains private key bytes (see [NappletResponse]).
* - Every request is authorized against the ledger before it touches the signer or relays.
*/
class NappletBroker(
private val signer: NostrSigner,
private val ledger: NappletPermissionLedger,
private val consentPrompt: NappletConsentPrompt,
private val relay: NappletRelayGateway? = null,
) {
/**
* Authorizes and runs [request] on behalf of [identity]. Re-throws
* [CancellationException] (e.g. the host tore the applet down mid-prompt) and converts any
* other failure into [NappletResponse.Failed] — a thrown exception must never cross back to
* the applet process carrying host internals.
*/
suspend fun handle(
identity: NappletIdentity,
request: NappletRequest,
): NappletResponse {
val capability = request.capability
val authorized =
when (ledger.decide(identity, capability)) {
PermissionDecision.ALLOW -> true
PermissionDecision.DENY ->
return NappletResponse.Denied(capability, "Blocked by a standing denial.")
PermissionDecision.ASK -> {
val grant = consentPrompt.request(identity, capability, request)
ledger.record(identity, capability, grant)
grant.allowsExecution
}
}
if (!authorized) return NappletResponse.Denied(capability, "The user declined.")
return try {
execute(request)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
NappletResponse.Failed(e.message ?: e::class.simpleName ?: "Unknown error")
}
}
private suspend fun execute(request: NappletRequest): NappletResponse =
when (request) {
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)
}
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)
}
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))
}
}
@@ -0,0 +1,54 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.napplet
import com.vitorpamplona.amethyst.commons.napplet.permissions.GrantState
import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletRequest
import com.vitorpamplona.quartz.nip01Core.core.Event
/**
* Asks the user to decide a capability the napplet does not yet have a standing grant for.
* The implementation drives the consent UI (on Android, an Activity in the **main** process)
* and suspends until the user answers. Returning [GrantState.DENY] (or any non-allowing
* state) blocks the in-flight request.
*
* It receives the concrete [request] as well as the [capability] so the prompt can describe
* exactly what the applet is asking to do (e.g. the kind of event it wants signed), not just
* the broad capability class.
*/
fun interface NappletConsentPrompt {
suspend fun request(
identity: NappletIdentity,
capability: NappletCapability,
request: NappletRequest,
): GrantState
}
/**
* Bridges the broker to the user's relays for the [NappletCapability.RELAY] capability.
* Supplied by the host (an OkHttp/NostrClient-backed implementation in `amethyst`); kept as
* an interface so the broker stays platform-agnostic and testable. A `null` gateway makes the
* broker answer relay requests with [com.vitorpamplona.amethyst.commons.napplet.protocol.NappletResponse.Unsupported].
*/
fun interface NappletRelayGateway {
/** Publishes [event] and returns the relay URLs that accepted it (empty = nowhere reached). */
suspend fun publish(event: Event): List<String>
}
@@ -0,0 +1,92 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.napplet
/**
* The capability classes a napplet shell can broker, each gating a set of dangerous
* operations behind the trust boundary. A napplet declares the NAP domains it needs
* via `requires` tags (`NappletManifest.requires()`); [fromNapDomain] maps each bare
* domain string to the capability the broker enforces.
*
* The mapping is intentionally **default-deny**: an unrecognized NAP domain maps to
* `null` and the shell must surface it as unknown rather than silently granting it.
*/
enum class NappletCapability {
/** Read the active pubkey, sign events, and NIP-04/44 encrypt/decrypt as the user. */
IDENTITY,
/** Publish events to, and read events from, the user's relays. */
RELAY,
/** Request NIP-57 zaps / Lightning invoices (and, behind a stricter grant, NWC pay). */
WALLET,
/** A per-applet sandboxed key-value store, namespaced by [NappletIdentity] — never app storage. */
STORAGE,
/** Direct outbound network to user-approved origins (widens the applet's CSP `connect-src`). */
NET,
;
companion object {
/**
* Maps a bare NAP domain (e.g. `identity`, `relay`, `storage`) to the capability the
* broker enforces, case-insensitively. Returns `null` for any domain the shell does
* not recognize — callers MUST treat that as "unknown, do not grant".
*/
fun fromNapDomain(domain: String): NappletCapability? =
when (domain.trim().lowercase()) {
"identity", "sign", "signer" -> IDENTITY
"relay", "relays" -> RELAY
"value", "wallet", "zap", "payments" -> WALLET
"storage" -> STORAGE
"net", "network", "fetch" -> NET
else -> null
}
}
}
/** A NAP domain the manifest declared that this shell does not recognize. */
data class UnknownNapDomain(
val domain: String,
)
/** Outcome of resolving a manifest's `requires` list against the capabilities this shell brokers. */
data class NappletRequiredCapabilities(
val capabilities: Set<NappletCapability>,
val unknown: List<UnknownNapDomain>,
) {
val hasUnknown: Boolean get() = unknown.isNotEmpty()
}
/**
* Resolves a manifest's `requires` domains into the [NappletCapability] set this shell can
* broker plus the list of domains it does not recognize (which the user must be warned about).
*/
fun resolveRequiredCapabilities(napDomains: List<String>): NappletRequiredCapabilities {
val caps = mutableSetOf<NappletCapability>()
val unknown = mutableListOf<UnknownNapDomain>()
for (domain in napDomains) {
val cap = NappletCapability.fromNapDomain(domain)
if (cap != null) caps.add(cap) else unknown.add(UnknownNapDomain(domain))
}
return NappletRequiredCapabilities(caps, unknown)
}
@@ -0,0 +1,47 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.napplet
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.HexKey
/**
* Stable identity of a napplet for the permission ledger.
*
* Keyed by the **addressable coordinate** — the manifest author's pubkey plus the
* `d` identifier — and deliberately **not** the bundle's content hash, so a user's
* grants survive routine code updates. The current [aggregateHash] is carried
* alongside (the NIP-5A aggregate over the manifest's `path` set, see
* `NappletManifest.computeAggregateHash()`) so a user who chose "ask again when the
* code changes" can be re-prompted when the bundle actually changes.
*
* [identifier] is empty for a root (kind 15129) napplet and the `d` value for a
* named (kind 35129) one.
*/
@Immutable
data class NappletIdentity(
val authorPubKey: HexKey,
val identifier: String,
val aggregateHash: HexKey? = null,
) {
/** The ledger key: coordinate only, never the [aggregateHash], so grants survive updates. */
val coordinate: String = "$authorPubKey:$identifier"
}
@@ -0,0 +1,57 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.napplet.permissions
/**
* A user's standing decision for one (napplet, capability) pair.
*
* Persistence differs by scope, which is what makes these distinct rather than a
* single boolean:
* - [DENY] / [ALLOW_ALWAYS] are **persistent** — written to the [NappletPermissionStore].
* - [ALLOW_SESSION] lives **in memory** for the lifetime of one ledger instance.
* - [ALLOW_ONCE] is **transient** — it authorizes exactly the in-flight request and is
* never recorded, so the next request prompts again.
* - [ASK] is the absence of a standing decision: prompt the user.
*/
enum class GrantState {
ASK,
ALLOW_ONCE,
ALLOW_SESSION,
ALLOW_ALWAYS,
DENY,
;
/** Whether this state authorizes the current request to proceed. */
val allowsExecution: Boolean
get() = this == ALLOW_ONCE || this == ALLOW_SESSION || this == ALLOW_ALWAYS
}
/** The broker's verdict for a request after consulting standing grants. */
enum class PermissionDecision {
/** A standing grant authorizes execution; run without prompting. */
ALLOW,
/** A standing denial blocks execution; reject without prompting. */
DENY,
/** No standing decision exists; the user must be prompted. */
ASK,
}
@@ -0,0 +1,101 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.napplet.permissions
import com.vitorpamplona.amethyst.commons.napplet.NappletCapability
import com.vitorpamplona.amethyst.commons.napplet.NappletIdentity
import com.vitorpamplona.amethyst.commons.util.KmpLock
import com.vitorpamplona.amethyst.commons.util.withLock
/**
* The per-applet permission ledger: the single place that decides whether a brokered
* operation may run. It layers two scopes:
*
* - **persistent** grants ([GrantState.ALLOW_ALWAYS] / [GrantState.DENY]) from the
* injected [NappletPermissionStore], and
* - **session** grants ([GrantState.ALLOW_SESSION]) held in memory for this instance.
*
* A persistent [GrantState.DENY] always wins — a user who blocked a capability is never
* silently re-prompted or overridden by a session grant. Recording an [GrantState.ALLOW_ONCE]
* is a no-op (it authorizes only the in-flight request and is applied by the broker, not
* remembered here).
*/
class NappletPermissionLedger(
private val store: NappletPermissionStore,
) {
private val lock = KmpLock()
// coordinate -> capability -> ALLOW_SESSION (the only state kept here).
private val session = mutableMapOf<String, MutableMap<NappletCapability, GrantState>>()
/**
* The standing decision for ([identity], [capability]) without prompting. A persistent
* [GrantState.DENY] takes precedence over any session allow.
*/
suspend fun decide(
identity: NappletIdentity,
capability: NappletCapability,
): PermissionDecision {
val persistent = store.load(identity.coordinate)[capability]
if (persistent == GrantState.DENY) return PermissionDecision.DENY
if (persistent == GrantState.ALLOW_ALWAYS) return PermissionDecision.ALLOW
val sessionGrant = lock.withLock { session[identity.coordinate]?.get(capability) }
if (sessionGrant == GrantState.ALLOW_SESSION) return PermissionDecision.ALLOW
return PermissionDecision.ASK
}
/**
* Persists or remembers a user's decision. [GrantState.ALLOW_ALWAYS] and
* [GrantState.DENY] go to the store; [GrantState.ALLOW_SESSION] is kept in memory;
* [GrantState.ALLOW_ONCE] and [GrantState.ASK] are not recorded.
*/
suspend fun record(
identity: NappletIdentity,
capability: NappletCapability,
grant: GrantState,
) {
when (grant) {
GrantState.ALLOW_ALWAYS, GrantState.DENY -> {
// A new persistent decision supersedes any lingering session grant.
lock.withLock { session[identity.coordinate]?.remove(capability) }
store.store(identity.coordinate, capability, grant)
}
GrantState.ALLOW_SESSION ->
lock.withLock {
session.getOrPut(identity.coordinate) { mutableMapOf() }[capability] = grant
}
GrantState.ALLOW_ONCE, GrantState.ASK -> Unit // transient; not remembered
}
}
/** Forgets all grants for [identity] — both the persisted and the in-session ones. */
suspend fun revokeAll(identity: NappletIdentity) {
lock.withLock { session.remove(identity.coordinate) }
store.clear(identity.coordinate)
}
/** Drops only the in-memory session grants (e.g. when the user closes all applets). */
fun endSession() {
lock.withLock { session.clear() }
}
}
@@ -0,0 +1,69 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.napplet.permissions
import com.vitorpamplona.amethyst.commons.napplet.NappletCapability
import com.vitorpamplona.amethyst.commons.util.KmpLock
import com.vitorpamplona.amethyst.commons.util.withLock
/**
* Persistence for the **standing** napplet grants ([GrantState.ALLOW_ALWAYS] and
* [GrantState.DENY]). Keyed by `coordinate` (see [com.vitorpamplona.amethyst.commons.napplet.NappletIdentity]).
*
* Session and one-shot grants are NOT persisted here — the ledger keeps those in memory.
* The Android actual is DataStore-backed; tests use [InMemoryNappletPermissionStore].
*/
interface NappletPermissionStore {
suspend fun load(coordinate: String): Map<NappletCapability, GrantState>
suspend fun store(
coordinate: String,
capability: NappletCapability,
grant: GrantState,
)
suspend fun clear(coordinate: String)
}
/** A thread-safe in-memory [NappletPermissionStore] for tests and ephemeral sessions. */
class InMemoryNappletPermissionStore : NappletPermissionStore {
private val lock = KmpLock()
private val data = mutableMapOf<String, MutableMap<NappletCapability, GrantState>>()
override suspend fun load(coordinate: String): Map<NappletCapability, GrantState> = lock.withLock { data[coordinate]?.toMap() ?: emptyMap() }
override suspend fun store(
coordinate: String,
capability: NappletCapability,
grant: GrantState,
) = lock.withLock {
// Only persistent decisions belong in the store; the ledger filters, but guard here too.
if (grant == GrantState.ALLOW_ALWAYS || grant == GrantState.DENY) {
data.getOrPut(coordinate) { mutableMapOf() }[capability] = grant
}
}
override suspend fun clear(coordinate: String) =
lock.withLock {
data.remove(coordinate)
Unit
}
}
@@ -0,0 +1,112 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.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
/**
* A capability request from a napplet, after it has crossed the postMessage + IPC edge
* and been deserialized into a typed object. Each variant declares the [capability] the
* broker must check before running it; the broker never trusts a value the applet sends
* for that — it is derived here from the request type.
*
* Wire (JSON over postMessage, then a string across Binder) is marshalled at the Android
* edge so this layer stays KMP-pure and unit-testable. Identity-bearing fields the applet
* could try to spoof (e.g. "sign as someone else") are constrained by the broker, not here.
*/
sealed interface NappletRequest {
val capability: NappletCapability
/** Read the active user's public key. */
data object GetPublicKey : NappletRequest {
override val capability get() = NappletCapability.IDENTITY
}
/**
* 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.
*/
data class SignEvent(
val kind: Int,
val tags: Array<Array<String>>,
val content: String,
) : NappletRequest {
override val capability get() = NappletCapability.IDENTITY
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is SignEvent) return false
if (kind != other.kind) return false
if (content != other.content) 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 + tags.sumOf { it.contentHashCode() }
return result
}
}
data class Nip04Encrypt(
val peerPubKey: HexKey,
val plaintext: String,
) : NappletRequest {
override val capability get() = NappletCapability.IDENTITY
}
data class Nip04Decrypt(
val peerPubKey: HexKey,
val ciphertext: String,
) : NappletRequest {
override val capability get() = NappletCapability.IDENTITY
}
data class Nip44Encrypt(
val peerPubKey: HexKey,
val plaintext: String,
) : NappletRequest {
override val capability get() = NappletCapability.IDENTITY
}
data class Nip44Decrypt(
val peerPubKey: HexKey,
val ciphertext: String,
) : NappletRequest {
override val capability get() = NappletCapability.IDENTITY
}
/**
* 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.
*/
data class Publish(
val event: Event,
) : NappletRequest {
override val capability get() = NappletCapability.RELAY
}
}
@@ -0,0 +1,66 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.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
/**
* The broker's reply to a [NappletRequest]. Crucially, **no variant ever carries private
* key material** — only public results (a pubkey, a signed event, a ciphertext/plaintext,
* a publish ack) or a typed failure. This is the property the process boundary exists to
* preserve.
*/
sealed interface NappletResponse {
data class PublicKey(
val pubkey: HexKey,
) : NappletResponse
data class SignedEvent(
val event: Event,
) : NappletResponse
/** Result of an encrypt/decrypt operation. */
data class Text(
val value: String,
) : NappletResponse
data class Published(
val relays: List<String>,
) : NappletResponse
/** The user (or a standing DENY) refused the [capability]. */
data class Denied(
val capability: NappletCapability,
val reason: String,
) : NappletResponse
/** The operation is not implemented by this shell yet. */
data class Unsupported(
val operation: String,
) : NappletResponse
/** The operation was authorized but failed while executing. */
data class Failed(
val reason: String,
) : NappletResponse
}
@@ -0,0 +1,196 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.napplet
import com.vitorpamplona.amethyst.commons.napplet.permissions.GrantState
import com.vitorpamplona.amethyst.commons.napplet.permissions.InMemoryNappletPermissionStore
import com.vitorpamplona.amethyst.commons.napplet.permissions.NappletPermissionLedger
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.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.crypto.verify
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertIs
import kotlin.test.assertTrue
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 class ScriptedPrompt(
private val answer: GrantState,
) : NappletConsentPrompt {
var calls = 0
private set
override suspend fun request(
identity: NappletIdentity,
capability: NappletCapability,
request: NappletRequest,
): GrantState {
calls++
return answer
}
}
private class RecordingRelay : NappletRelayGateway {
val published = mutableListOf<Event>()
override suspend fun publish(event: Event): List<String> {
published.add(event)
return listOf("wss://relay.example")
}
}
private fun broker(
prompt: NappletConsentPrompt,
relay: NappletRelayGateway? = null,
ledger: NappletPermissionLedger = NappletPermissionLedger(InMemoryNappletPermissionStore()),
) = NappletBroker(signer, ledger, prompt, relay)
@Test
fun getPublicKeyReturnsTheUsersKeyWhenAllowed() =
runTest {
val response = broker(ScriptedPrompt(GrantState.ALLOW_ONCE)).handle(applet, NappletRequest.GetPublicKey)
assertEquals(NappletResponse.PublicKey(signer.pubKey), response)
}
@Test
fun standingDenyBlocksWithoutPrompting() =
runTest {
val ledger = NappletPermissionLedger(InMemoryNappletPermissionStore())
ledger.record(applet, NappletCapability.IDENTITY, GrantState.DENY)
val prompt = ScriptedPrompt(GrantState.ALLOW_ALWAYS)
val response = broker(prompt, ledger = ledger).handle(applet, NappletRequest.GetPublicKey)
assertIs<NappletResponse.Denied>(response)
assertEquals(0, prompt.calls) // never asked the user — the standing DENY is authoritative
}
@Test
fun userDeclineYieldsDenied() =
runTest {
val response = broker(ScriptedPrompt(GrantState.DENY)).handle(applet, NappletRequest.GetPublicKey)
assertIs<NappletResponse.Denied>(response)
}
@Test
fun allowAlwaysPromptsOnceThenRemembers() =
runTest {
val prompt = ScriptedPrompt(GrantState.ALLOW_ALWAYS)
val broker = broker(prompt)
broker.handle(applet, NappletRequest.GetPublicKey)
broker.handle(applet, NappletRequest.GetPublicKey)
broker.handle(applet, NappletRequest.GetPublicKey)
assertEquals(1, prompt.calls) // only the first request prompted
}
@Test
fun allowOncePromptsEveryTime() =
runTest {
val prompt = ScriptedPrompt(GrantState.ALLOW_ONCE)
val broker = broker(prompt)
broker.handle(applet, NappletRequest.GetPublicKey)
broker.handle(applet, NappletRequest.GetPublicKey)
assertEquals(2, prompt.calls)
}
@Test
fun signEventProducesAValidlySignedEventAsTheUser() =
runTest {
val request = NappletRequest.SignEvent(kind = 1, tags = arrayOf(arrayOf("t", "napplet")), content = "gm")
val response = broker(ScriptedPrompt(GrantState.ALLOW_ONCE)).handle(applet, request)
assertIs<NappletResponse.SignedEvent>(response)
val event = response.event
assertEquals(signer.pubKey, event.pubKey) // applet cannot sign as anyone but the user
assertEquals(1, event.kind)
assertEquals("gm", event.content)
assertTrue(event.verify()) // id + signature are real
}
@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))
assertIs<NappletResponse.Unsupported>(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))
assertIs<NappletResponse.Failed>(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))
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))
assertIs<NappletResponse.Denied>(response)
assertTrue(relay.published.isEmpty())
}
}
@@ -0,0 +1,64 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.napplet
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue
class NappletCapabilityTest {
@Test
fun mapsKnownDomainsCaseInsensitively() {
assertEquals(NappletCapability.IDENTITY, NappletCapability.fromNapDomain("identity"))
assertEquals(NappletCapability.IDENTITY, NappletCapability.fromNapDomain(" IDENTITY "))
assertEquals(NappletCapability.RELAY, NappletCapability.fromNapDomain("Relay"))
assertEquals(NappletCapability.WALLET, NappletCapability.fromNapDomain("value"))
assertEquals(NappletCapability.STORAGE, NappletCapability.fromNapDomain("storage"))
assertEquals(NappletCapability.NET, NappletCapability.fromNapDomain("net"))
}
@Test
fun unknownDomainMapsToNullNotAFallbackGrant() {
assertNull(NappletCapability.fromNapDomain("filesystem"))
assertNull(NappletCapability.fromNapDomain(""))
assertNull(NappletCapability.fromNapDomain("nostr"))
}
@Test
fun resolveSeparatesKnownFromUnknownAndFlags() {
val resolved = resolveRequiredCapabilities(listOf("identity", "relay", "filesystem", "value"))
assertEquals(
setOf(NappletCapability.IDENTITY, NappletCapability.RELAY, NappletCapability.WALLET),
resolved.capabilities,
)
assertEquals(listOf(UnknownNapDomain("filesystem")), resolved.unknown)
assertTrue(resolved.hasUnknown)
}
@Test
fun resolveWithOnlyKnownDomainsHasNoUnknownFlag() {
val resolved = resolveRequiredCapabilities(listOf("identity"))
assertFalse(resolved.hasUnknown)
}
}
@@ -0,0 +1,121 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.napplet.permissions
import com.vitorpamplona.amethyst.commons.napplet.NappletCapability
import com.vitorpamplona.amethyst.commons.napplet.NappletIdentity
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
class NappletPermissionLedgerTest {
private val applet = NappletIdentity(authorPubKey = "aa".repeat(32), identifier = "chess")
private val other = NappletIdentity(authorPubKey = "bb".repeat(32), identifier = "wallet")
private fun ledger(store: NappletPermissionStore = InMemoryNappletPermissionStore()) = NappletPermissionLedger(store)
@Test
fun unknownGrantDefaultsToAsk() =
runTest {
val ledger = ledger()
assertEquals(PermissionDecision.ASK, ledger.decide(applet, NappletCapability.IDENTITY))
}
@Test
fun allowAlwaysIsPersistedAndAllows() =
runTest {
val store = InMemoryNappletPermissionStore()
ledger(store).record(applet, NappletCapability.IDENTITY, GrantState.ALLOW_ALWAYS)
// A brand-new ledger over the same store still sees the grant.
assertEquals(PermissionDecision.ALLOW, ledger(store).decide(applet, NappletCapability.IDENTITY))
}
@Test
fun denyIsPersistedAndBlocks() =
runTest {
val store = InMemoryNappletPermissionStore()
ledger(store).record(applet, NappletCapability.RELAY, GrantState.DENY)
assertEquals(PermissionDecision.DENY, ledger(store).decide(applet, NappletCapability.RELAY))
}
@Test
fun sessionGrantAllowsButDoesNotSurviveANewLedger() =
runTest {
val store = InMemoryNappletPermissionStore()
val ledger = ledger(store)
ledger.record(applet, NappletCapability.IDENTITY, GrantState.ALLOW_SESSION)
assertEquals(PermissionDecision.ALLOW, ledger.decide(applet, NappletCapability.IDENTITY))
// A fresh ledger over the same store does not see the in-memory session grant.
assertEquals(PermissionDecision.ASK, ledger(store).decide(applet, NappletCapability.IDENTITY))
}
@Test
fun onceAndAskAreNeverRemembered() =
runTest {
val ledger = ledger()
ledger.record(applet, NappletCapability.IDENTITY, GrantState.ALLOW_ONCE)
ledger.record(applet, NappletCapability.RELAY, GrantState.ASK)
assertEquals(PermissionDecision.ASK, ledger.decide(applet, NappletCapability.IDENTITY))
assertEquals(PermissionDecision.ASK, ledger.decide(applet, NappletCapability.RELAY))
}
@Test
fun persistentDenyWinsOverASessionAllow() =
runTest {
val ledger = ledger()
ledger.record(applet, NappletCapability.IDENTITY, GrantState.ALLOW_SESSION)
ledger.record(applet, NappletCapability.IDENTITY, GrantState.DENY)
assertEquals(PermissionDecision.DENY, ledger.decide(applet, NappletCapability.IDENTITY))
}
@Test
fun grantsAreScopedPerAppletCoordinate() =
runTest {
val ledger = ledger()
ledger.record(applet, NappletCapability.IDENTITY, GrantState.ALLOW_ALWAYS)
assertEquals(PermissionDecision.ALLOW, ledger.decide(applet, NappletCapability.IDENTITY))
assertEquals(PermissionDecision.ASK, ledger.decide(other, NappletCapability.IDENTITY))
}
@Test
fun revokeAllClearsPersistentAndSessionGrants() =
runTest {
val ledger = ledger()
ledger.record(applet, NappletCapability.IDENTITY, GrantState.ALLOW_ALWAYS)
ledger.record(applet, NappletCapability.RELAY, GrantState.ALLOW_SESSION)
ledger.revokeAll(applet)
assertEquals(PermissionDecision.ASK, ledger.decide(applet, NappletCapability.IDENTITY))
assertEquals(PermissionDecision.ASK, ledger.decide(applet, NappletCapability.RELAY))
}
@Test
fun aggregateHashDoesNotAffectTheLedgerKey() =
runTest {
val ledger = ledger()
val v1 = applet.copy(aggregateHash = "11".repeat(32))
val v2 = applet.copy(aggregateHash = "22".repeat(32))
ledger.record(v1, NappletCapability.IDENTITY, GrantState.ALLOW_ALWAYS)
// A code update (new aggregate hash) keeps the grant — same coordinate.
assertEquals(PermissionDecision.ALLOW, ledger.decide(v2, NappletCapability.IDENTITY))
}
}