mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
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:
@@ -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).
|
||||
+125
@@ -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))
|
||||
}
|
||||
}
|
||||
+54
@@ -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>
|
||||
}
|
||||
+92
@@ -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)
|
||||
}
|
||||
+47
@@ -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"
|
||||
}
|
||||
+57
@@ -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,
|
||||
}
|
||||
+101
@@ -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() }
|
||||
}
|
||||
}
|
||||
+69
@@ -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
|
||||
}
|
||||
}
|
||||
+112
@@ -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
|
||||
}
|
||||
}
|
||||
+66
@@ -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
|
||||
}
|
||||
+196
@@ -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())
|
||||
}
|
||||
}
|
||||
+64
@@ -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)
|
||||
}
|
||||
}
|
||||
+121
@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user