diff --git a/research/permission-manifest-design.md b/research/permission-manifest-design.md new file mode 100644 index 00000000..2f73d011 --- /dev/null +++ b/research/permission-manifest-design.md @@ -0,0 +1,778 @@ +# Permission Manifests — one-consent Swarm grants for dweb apps + +Status: DESIGN v3.1 — revised after three peer-review passes (2026-07-20). +v1's capability/discovery/provenance gaps and v2's consent-state, +crash-recovery, identity-preservation, and navigation-lifecycle gaps are +addressed below; v3.1 adds non-voluntary privileged-method freshness and +fail-closed transport-switch handling. Implemented as a first version in +[PR #168](https://github.com/solardev-xyz/freedom-browser/pull/168). The +implementation-neutral interoperability profile is +[swarm-app-permission-manifest.md](swarm-app-permission-manifest.md). +Scope: **Swarm permissions only, bzz-hosted apps only (v1).** A wallet +extension is sketched in Appendix A but is explicitly NOT part of this +proposal — see §0. +Audience: freedom-browser implementer (desktop Electron first; iOS later) +Origin: ddrive/Freedom Office onboarding work (2026-07). Companion ideas: +`swarm_deriveAppSecret` (not covered here), origin continuity (partially +already shipped — see §1.4). + +--- + +## 0. TL;DR and scope rationale + +A dweb app ships a declarative `freedom-manifest.json` alongside its +build. On first connect the browser fetches it, renders ONE consolidated +consent sheet, and a single Approve projects the whole batch into the +EXISTING permission stores. Main-process method authorization and limits +do not change; the renderer gains a manifest-freshness gate before any +privileged method may consume stored authority (§5.1). The manifest is a +batch-grant front-end over grants that already exist. +The grant is bound to the manifest's **capability set**: a redeploy that +broadens it triggers a diff prompt; one that narrows or removes it prunes +manifest-managed authority. Manual Settings changes always win over the +manifest (§6.3). + +**Why Swarm-only.** The dialog fatigue is entirely on the Swarm side: +connect → publish approval → feed approval (its own grant + identity +choice) → signing approval → messaging-tier grant + send approvals. The +wallet is quiet by design: ddrive-class apps sign chain transactions with +their own derived agent key over plain RPC — `window.ethereum` never sees +them. The wallet's only touchpoints are one `personal_sign` at login +(one-time via the existing signing auto-approve, +`dapp-permissions.js:185-212`) and funding transfers, which must ALWAYS +prompt (`dapp-permissions.js:217` — hard invariant, manifest or not). +Nothing worth batching there today. + +**Why bzz-only (v1).** Named origins normalize transport away +(`bzz://name.gwei` and `ipfs://name.gwei` share one permission key — +§1.4), but manifest discovery must fetch from the transport the page +actually committed on. Rather than specify same-snapshot binding for +every transport, v1 supports manifests only for pages whose committed URL +is `bzz:` (raw ref or name) — sufficient for ddrive. First-contact and +unmanaged origins on other transports fall back to today's per-action +flow. A previously Bzz-manifest-tracked named origin is pruned before +that fallback so managed authority cannot cross the unsupported +transport boundary (§5.4). + +--- + +## 1. Current state (what the manifest layer sits on) + +### 1.1 Swarm permissions — `src/main/swarm/swarm-permissions.js` + +- Schema (`:8-15`): `{ origin, connectedAt, lastUsed, autoApprove: + { publish, feeds, signing, messaging }, messaging?: { grantedAt } }`. +- `VALID_AUTO_APPROVE_TYPES` (`:156`), all default false. +- `grantPermission(origin)` (`:78`), `grantMessaging(origin)` (`:208`), + `setAutoApprove(origin, type, enabled)` (`:183`). +- **Gaps to close for this design** (prerequisite work, §10): + `revokeMessaging` does not exist (only whole-record + `revokePermission`, `:103`); `onRevoke` (`:124`) holds a SINGLE + listener slot (provider layer uses it for subscription teardown) — turn + it into a listener list. + +### 1.2 Feed identity + feed grant — `src/main/swarm/feed-store.js` + +A separate store the v1 draft of this doc missed. Per-origin schema +(`:13`): `{ activeIdentityId, identities, feedGranted, grantedAt, +feeds }`. Key facts: + +- `feedGranted` (`hasFeedGrant`, `:879`) is its OWN consent, distinct + from `autoApprove.feeds`: the renderer's feed/signing path + (`swarm-provider.js:164-190`) prompts when the feed grant is missing + BEFORE it ever consults auto-approve, then requires an unlocked vault, + then consults auto-approve. Projecting only `autoApprove.feeds = true` + would therefore NOT remove the first feed prompt. +- The feed grant carries a **publisher identity choice** (`:17-23`): + default 'app-scoped' (dedicated key at `m/44'/73406'/{index}'/0/0`) vs + 'bee-wallet' (node-global). Identity switching is an explicit user + action; identities are never silently forgotten. +- Vault unlock is a RUNTIME condition, not a grant — manifests do not + and must not interact with it (the unlock prompt stays, §3). + +### 1.3 Prompt plumbing (renderer) + +`src/renderer/lib/swarm-provider.js` orders the checks (permission → +tier/feed grant → vault → auto-approve) and shows prompts; screens live +in `src/renderer/lib/wallet/swarm-connect.js` — `showSwarmConnect` +(`:250`), publish (`:416`), messaging (`:548`), feed/signing (`:680`). +`handleRequestAccess` (`swarm-provider.js:238`) SHORT-CIRCUITS when a +permission record exists — the manifest check for already-granted origins +must hook exactly there (§5.1). Origin comes from +`getDisplayUrlForWebview` (`:14,61`). + +### 1.4 Origin identity — `src/shared/origin-utils.js` + +Origins with an ENS-name host are keyed by NAME (`bzz://ddrive.gwei/x` → +`ddrive.gwei`); raw content origins by root ref. **Named-app grant +continuity across redeploys already exists.** The manifest layer adds: +continuity is only silent while the capability set is unchanged (§6). +Note the permission key LOSES transport — which is why discovery binds to +the committed page URL, not the key (§5.4). Keep the renderer mirror +`src/renderer/lib/origin-utils.js` in sync. + +--- + +## 2. Design overview + +1. **Manifest file** — `freedom-manifest.json` at the app origin root, + declaring capabilities from an explicit registry (§3) with plain-text + justifications (§4). +2. **Main-process-owned lifecycle** — fetch, validation, hashing, + diffing, and grant application all live in main; the renderer only + displays a model and approves an opaque pending-consent token (§7). +3. **One consent sheet** with three outcomes: Allow all / Connect with + individual approvals / Don't allow (§8). +4. **Projection with provenance** — Approve projects through existing + store APIs; each projected grant is marked manifest-managed; manual + Settings changes detach management and always win (§6). + +Invariants: + +- A manifest can only batch grants the browser could already give through + individual prompts + checkboxes. No new authority; no wallet reach + (unknown capability groups reject the whole manifest). +- Apps without a manifest: today's flow, unchanged. +- Main-process provider dispatch/authorization and LIMITS + (`swarm-provider-ipc.js`) are untouched. Renderer routing adds the + §5.1 freshness gate before stored grants are consulted. +- Vault unlock prompts are untouched. + +--- + +## 3. Capability registry (explicit, not derived) + +The schema is defined by this registry — NOT by whatever +`VALID_AUTO_APPROVE_TYPES` happens to contain. Each capability maps to +the COMPLETE set of grants that today's prompt sequence would produce: + +| Capability | Projects to | +|---|---| +| (implicit) | `swarm-permissions.grantPermission(origin)` — the base connect record | +| `publish` | `autoApprove.publish = true` | +| `feeds` | feed-store: `feedGranted = true` + ensure a publisher identity exists; `autoApprove.feeds = true` | +| `signing` | same feed-store grant + identity as `feeds`; `autoApprove.signing = true` | +| `messaging` | `grantMessaging(origin)` + `autoApprove.messaging = true` | + +Publisher identity projection is **ensure, never replace**: + +- If the origin already has an active publisher identity (identity + metadata survives disconnect), preserve it. Manifest approval never + switches a bee-wallet, Ethereum-wallet, or existing app-scoped choice. + The sheet names the identity that will remain active. +- If the origin has no identity, create the feed-store's + privacy-preserving app-scoped identity metadata and make it active. + The sheet states *"a new app-scoped signing identity will be created + for this app"*. The bee-wallet choice remains available in Settings. + +Creating the app-scoped feed-store record allocates derivation metadata; +it does not derive or expose the private key (`createAppScopedIdentity`, +`feed-store.js:600`, calls the metadata-only `createIdentity` at `:114`). +Key material is resolved later at cryptographic use +(`resolveSignerKey`, `swarm-provider-ipc.js:993-998`). Therefore identity +metadata **and `feedGranted` are projected +immediately even while the vault is locked**. Actual key use +still triggers the existing runtime vault-unlock prompt +(`swarm-provider.js:176-178`). There is no deferred permission state and +no provider-enforcement exception for manifests. + +`feeds` and `signing` share the feed-store projection; granting either +marks the feed grant manifest-managed once (provenance is per projected +FLAG, §6.3, so pruning `signing` alone never removes the feed grant that +`feeds` still justifies). + +--- + +## 4. Manifest schema + +`freedom-manifest.json` at the app origin root. Schema `v1`: + +```json +{ + "schema": "freedom-manifest/1", + "name": "ddrive", + "description": "Encrypted drive + docs on Swarm and Gnosis", + "capabilities": { + "swarm": { + "publish": { "why": "Store your encrypted files and documents" }, + "feeds": { "why": "Keep a stable address for each document" }, + "signing": { "why": "Anchor drive data in single-owner chunks" }, + "messaging": { "why": "Live collaboration presence and sync" } + } + } +} +``` + +Validation is strict and fail-closed (invalid manifest ⇒ per-action flow ++ console warning for the developer): + +- Every JSON object uses an explicit allowlist (`additionalProperties: + false` semantics). Root requires exactly `schema`, `name`, and + `capabilities`, with optional `description`; `schema` must equal + `freedom-manifest/1`. `capabilities` requires exactly a non-empty + `swarm` object. Each capability value requires exactly `{ "why": ... }`. + +- `capabilities.swarm.*` keys MUST be from the §3 registry. Unknown + swarm keys or unknown capability GROUPS (e.g. a future `"wallet"`) + reject the manifest as a whole — the forward-compat rule: an older + browser meeting a newer manifest ignores it entirely rather than + granting a subset its sheet never showed. +- `why`: non-empty string, ≤ 140 Unicode code points, plain text + (attacker-controlled — no markup/URLs honored). +- `name`: non-empty string, ≤ 32 Unicode code points. `description` is an + optional string ≤ 160 Unicode code points. The sheet shows the ORIGIN + as the primary identity; name/description are secondary flavor only + (§8). +- Reject (do not silently strip) C0/C1 controls, line/paragraph + separators, and Unicode bidi embedding/override/isolate controls in + all displayed strings. Validation and consent history therefore refer + to exactly the same sanitized-free values. +- Size cap 8 KB, enforced DURING streaming of the fetch (abort past the + cap, don't buffer-then-check). + +--- + +## 5. Discovery lifecycle + +### 5.1 When to check + +`swarm_requestAccess` is the eager/natural app-init trigger, but it is +**not the security boundary**: an already-authorized page can currently +call publish/feed/signing/messaging methods without calling +`requestAccess` again. Before the renderer consults any stored base, +tier, feed, or auto-approve grant for a privileged method, it calls +`ensureManifestFresh(webview, committedNavigation, origin)`. + +The freshness gate behaves as follows: + +- A manifest-tracked origin whose current committed navigation has not + been checked runs the full §5–6 lifecycle before the privileged method + continues. If a diff sheet appears, Allow all continues with projected + grants, individual approval continues into today's per-action prompt, + and Don't allow rejects the triggering method. +- An origin with no base permission still receives today's UNAUTHORIZED + result and must call `swarm_requestAccess`; a direct privileged call + does not become an alternate connection prompt. +- An existing untracked/legacy origin keeps today's behavior. Its bounded + manifest discovery remains tied to `swarm_requestAccess`; its authority + is user-owned rather than subject to a manifest-binding claim. +- Permission-free public methods (`swarm_getCapabilities`, public + reads/listing) bypass the gate. Teardown such as `swarm_unsubscribe` + also bypasses it so cleanup can never be blocked by discovery or UI. + +Concurrent eager or lazy checks are deduplicated per origin + committed +navigation (one in-flight check + sheet; all callers await it). A +completed check is cached only for that committed top-level navigation, +identified by the renderer-owned webContents/navigation sequence and +committed display URL — **not for the origin's whole browser session**. +Reloading or navigating a named origin starts a new check even when its +display URL is unchanged, so a redeploy observed during the same browser +launch still diffs/prunes promptly. + +- **No permission record** (first contact): fetch manifest. Found → + consent sheet. Not found / invalid / transport unsupported → legacy + `showSwarmConnect`. +- **Record exists, manifest-tracked** (a `manifest-grants.json` entry, + whether its acknowledged rows are managed or individual): fetch once + for every committed navigation that calls `requestAccess` or reaches + the lazy privileged-method gate. Hook both the `handleRequestAccess` + short-circuit (`swarm-provider.js:238`) and the top-level privileged + dispatch paths (`:72-113`). Outcome per §6.2. +- **Record exists, unmanaged** (legacy grant, or app added a manifest + later, or first-contact fetch failed transiently): retry discovery at + a bounded cadence — at most once per committed navigation and with a + browser-session backoff after `unresolved`. Re-enter through the DIFF + path (§6.2), treating a capability as already satisfied only when its + **complete §3 projection** is currently true. Fully satisfied, + user-owned capabilities are acknowledged as `individual` and are not + re-asked; partial projections are additions because the manifest asks + for persistent auto-approval, not merely the underlying tier grant. + This closes the progressive-enhancement trap where one transient + timeout at first contact would otherwise freeze an origin in legacy + mode forever. + +### 5.2 How to fetch + +Main process only. Resolve `/freedom-manifest.json` through +the browser's own content path — never an external gateway: + +- `bzz://` origins: local Bee/Ant HTTP API (`getAntApiUrl()`, + `src/main/service-registry.js`, as `swarm-provider-ipc.js:44`). +- Named origins committed on bzz: `resolveEnsContent(name)` + (`src/main/ens-resolver.js:1451`) → fetch within the snapshot returned + by the same resolver/cache path used by `bzz:` loading. Record that + resolved snapshot ref alongside the result (audit trail, §6.1). + +For a raw-ref URL, this is exact content binding. For a named URL, the +security principal is the normalized name: the committed URL does not +carry its resolved ref, so a later manifest check is not cryptographic +proof that the already-rendered page and manifest are byte-for-byte from +the same snapshot. Capturing the fetch snapshot closes accidental +cross-fetches and provides audit evidence; exact loaded-snapshot binding +is future hardening. Security claims in §9 intentionally use +**same-origin resolution path**, not "same committed bytes." + +Timeout: same bounded retrieval behavior as `bzz:` page content — a cold +collection entry can legitimately take longer than 2s; do NOT use an +aggressive fixed timeout that turns cold-cache into "no manifest". The +8 KB cap is enforced while streaming. + +### 5.3 Outcome classification (drives §6.2) + +- `found(manifestBytes)` — parsed + validated. +- `absent` — DEFINITIVE 404 within a successfully resolved snapshot. +- `unresolved` — timeout, node down, resolution failure. Never treated + as absent. +- `invalid` — present but fails validation. Treated like `absent` for + lifecycle purposes (it cannot express a capability set), plus dev + warning. +- `unsupported_transport` — committed page is not `bzz:`. Classification + is local and definitive; handling depends on whether the origin is + already manifest-tracked (§5.4). + +### 5.4 Transport binding + +Discovery uses the COMMITTED page URL (from the tab's display URL, the +same source the trust model already relies on), not the permission key: +the key has lost transport for named origins (§1.4). Consequently, +`bzz://name.gwei` and `ipfs://name.gwei` can consume the same stored +permission flags even though v1 can validate a manifest only for the +former. + +v1 rules for a committed URL that is not `bzz:`-transported: + +- First-contact or untracked/legacy origin → skip discovery and use the + legacy flow. +- Manifest-tracked origin → under the §7 journal/mutex, treat the managed + capability set as empty: prune every still-managed projection, preserve + user-owned/unmanaged grants, drop manifest tracking, then continue via + the legacy flow. This runs from both `requestAccess` and the lazy + privileged-method freshness gate, so changing transport cannot be used + to retain Bzz-manifest authority. + +Extending manifests to ipfs/ipns/https is future work and requires +per-transport retrieval and snapshot rules. + +--- + +## 6. Grant state: fingerprints, provenance, diffs + +### 6.1 `manifest-grants.json` (new store; same userData-JSON pattern, +module cache, `_resetCache`) + +```js +{ "": { + version: 1, + observed: { // latest successfully fetched manifest + capabilities: ["feeds", "messaging", "publish", "signing"], + capabilityFingerprint, + rawHash, // sha256 of served bytes — audit/debug only + snapshotRef, // snapshot used for this fetch — audit only + observedAt + }, + acknowledged: { // consent baseline, distinct from observation + "publish": { + decision: "managed" | "individual", + source: "sheet" | "existing-grant", + whyShown?, // present only when a sheet showed the row + decidedAt + }, ... + }, + managed: { // projection provenance: flag → owning rows + "swarm.autoApprove.publish": ["publish"], + "feedStore.feedGranted": ["feeds", "signing"], ... + }, + receipts: [{ // bounded audit trail of sheets acted upon + decidedAt, outcome: "managed" | "individual", + originShown, manifestNameShown, manifestDescriptionShown, + rows: [{ capability, browserLabelVersion, whyShown }], + builtInCopyVersion, rawHash, snapshotRef + }], + unresolvedSince?: number, + transaction?: { ... } // write-ahead recovery record (§7) +} } +``` + +`observed` answers "what does the current manifest request?"; +`acknowledged` answers "which rows has the user already made a batch or +individual decision about?" They MUST NOT be collapsed into one +fingerprint. This matters after a mixed diff whose removals were applied +but whose additions were rejected: observed might be `{feeds, +messaging}`, while acknowledged is only `{feeds}`. + +The semantic fingerprint includes the schema identifier plus sorted +capability keys. `rawHash` never drives authority. A redeploy that edits +only description/why/whitespace updates `observed.rawHash`, but existing +`acknowledged.*.whyShown` and receipts remain what the user actually saw; +new wording appears only on a future sheet containing that row. The +sheet says "bound to this set of permissions," not "this exact file." + +Receipts are capped (implementation constant; suggested latest 20 per +origin) so attacker-driven manifest churn cannot grow the store without +bound. `acknowledged` is operational state; receipts are display/audit +history. + +### 6.2 Per-navigation check outcomes (manifest-tracked origins) + +For `found`, let `current` be the served capability set and `known` be +the keys of `acknowledged`. First apply removals `known − current` through +the journaled mutation path (§7): remove their acknowledgement, remove +their ownership from `managed`, and prune flags whose owner list becomes +empty. These removals are unconditional and stick even if later +additions are rejected. + +Then compute additions `current − acknowledged`: + +- Before prompting, an addition whose complete §3 projection is already + true is acknowledged as `individual`; it is not re-asked and does not + acquire new managed provenance. Existing ownership belonging to a + different acknowledged row (for example signing's ownership of the + shared feed grant) remains unchanged. +- No remaining additions → update `observed`, silent continuity. +- Additions remain → show a DIFF sheet listing only those rows. + - **Allow all:** project only the shown rows, acknowledge them as + `managed`, and update provenance dependency-by-dependency: a false + flag is set and owned; an already manifest-managed shared flag gains + the new row as an owner; an already true-but-unmanaged flag stays + user-owned and gains no manifest owner. + - **Connect with individual approvals:** acknowledge the shown rows as + `individual` without projecting them. The same manifest does not + re-raise a batch sheet on the next navigation; those operations use + today's prompts. If a future manifest adds different rows, only the + new rows may be offered in a diff sheet. + - **Don't allow:** do not acknowledge additions and do not project + them; keep session-scoped rejection memory. They may be offered again + after that rejection scope expires. + +This single algorithm covers unchanged, additions-only, removals-only, +and mixed manifests without requiring `observed` to pretend rejected +rows were approved. For an already tracked origin, every successful +`found` updates `observed` even when additions are rejected; +`acknowledged` remains the consent baseline. First-contact **Don't +allow** is the exception: keep the observation/token only in session +memory and do not create a disk record for an origin that has neither a +permission nor an acknowledged decision. + +Non-`found` outcomes: + +- `absent` / `invalid` → treat as an EMPTY capability set: prune all + still-manifest-managed grants, drop the record (origin becomes an + unmanaged legacy grantee of whatever survives, i.e. user-made grants). + This is what makes "bound to the manifest" true — a named-origin + redeploy cannot shed its manifest yet inherit broad auto-approvals. +- `unsupported_transport` → for a manifest-tracked origin, use the same + empty-set prune/drop transition before legacy handling; for an + untracked origin, there is no manifest state to mutate (§5.4). +- `unresolved` → keep everything, set `unresolvedSince`, retry next + qualifying navigation subject to the §5.1 session backoff — never + auto-prune on `unresolved`. For a TRACKED origin the freshness gate + cannot be satisfied, so privileged methods FAIL with a temporary + availability error until a check succeeds (this is what PR #168 + implements and what the interoperability profile §2.3 requires: + "neither broadens nor revokes authority, but blocks its use until + freshness is established" — earlier revisions of this doc were + ambiguous here). Untracked origins are unaffected and continue + through per-action prompts. + +### 6.3 Provenance rules (the manual-override contract) + +Every flag the projection sets records the manifest rows that own it in +`managed`. Rules: + +- **Settings mutations detach.** Any manual toggle of a flag (either + direction) via the Settings/permission UI clears its `managed` entry. + Renderer-exposed Settings and per-action-prompt mutation IPCs call a + user-mutation wrapper that detaches; main-process manifest projection + uses separate internal setters and MUST NOT trigger detachment. Do not + accept a renderer-provided `source: "manifest"` escape hatch. +- **Diffs never touch unmanaged flags.** Additions: if the flag is + already true-but-unmanaged, approving the diff does NOT re-mark it + managed silently — it stays the user's. Removals: only flags still in + `managed` are pruned. Consequently: a user who manually disabled feeds + will never have feeds silently re-enabled by a later diff approval + that only showed messaging (re-projection re-applies ONLY the rows + shown+approved on the diff sheet, never the unchanged remainder). +- **Shared projections prune conservatively**: removing feeds/signing + removes that row from `managed["feedStore.feedGranted"]`; the feed grant + is demoted only when its owner list becomes empty. An `individual` row + is not a manifest owner. Publisher identity records and active-identity + selection are never managed or pruned by the manifest. +- Pruning uses real revocation APIs: `setAutoApprove(…, false)`, the new + `revokeMessaging`, and a feed-store demotion that clears `feedGranted` + WITHOUT deleting identities or feed records (identities are never + silently forgotten — `feed-store.js:23`). + +### 6.4 Full revocation + +Route Settings "disconnect" through a main-process origin-state +coordinator under the same §7 per-origin mutex. It journals the intent, +revokes the base permission, demotes feed access, cancels live resources +through the multi-listener `onRevoke` chain, then removes the manifest +record last. This makes disconnect win over an in-flight consent token +and closes the existing renderer-side two-IPC partial-disconnect window. +The manifest-store revoke listener remains as fallback cleanup for any +legacy/internal caller that invokes `revokePermission` directly; it must +not delete an in-progress disconnect journal before recovery can finish. + +--- + +## 7. Main-process flow: pending-consent tokens, atomicity + +The renderer NEVER sends a manifest as grant authority. One flow, owned +by main: + +``` +renderer main +requestAccess / privileged gate ──▶ manifest check (§5) + fetch, validate, fingerprint, diff +◀── { kind: 'consent'|'diff', + model, consentToken } (token: opaque, single-use, + session-scoped, bound to origin + + navigation + observed fingerprint + + manifest-record revision + shown rows) +render sheet from model +user decides ─────────────────────▶ manifest:decide(consentToken, outcome) + journal + apply outcome (§3, §6.2) +◀── connected/granted/rejected commit manifest record if mutated +``` + +The three authority stores are separate JSON files, so use a write-ahead +transaction in `manifest-grants.json`; **do not write the manifest record +last without a journal**. That would make partially projected flags look +user-owned after a crash and they could escape later pruning. + +All manifest-driven mutations — managed/individual decisions, automatic +removals, absent/invalid pruning, Settings detachment, and full +disconnect — run under a per-origin main-process mutex: + +1. Validate the token/revision when consent is involved. Compute explicit + set-to-value operations, provenance-owner changes, acknowledgement + changes, and the receipt/observed result. +2. Persist `transaction: { id, state: "applying", baseRevision, + observedFingerprint, operations, targetRecord }` **before** changing + any authority store. The durable transaction proves which partial + flags are manifest-owned. +3. Apply operations idempotently to swarm-permissions and feed-store. + Identity creation is ensure-if-absent; setters never toggle. Store + methods used by this coordinator MUST propagate persistence failures + instead of logging-and-returning success. +4. Verify that every authority-store write is durably persisted (not + merely reflected in a module cache). Persist `targetRecord` with the + transaction removed and an incremented revision only after all writes + succeed. The JSON stores, including the journal, use temp-file + + atomic-rename replacement so a process crash cannot leave truncated + JSON. On failure, leave `transaction` applying for recovery. + +On startup, before provider requests are served, recover every +`state:"applying"` transaction by finishing its idempotent operations and +committing `targetRecord`. The user decision or narrowing operation was +durable before projection began, so completion is safer than guessing +which partial writes to keep. The next normal manifest check handles any +deployment that changed while the browser was down. + +Consent tokens are logically single-use, but approval is retry-safe: the +main process keeps the in-flight/completed result for each token for the +session. A duplicate call returns/awaits that same result. An unknown +token, or a token whose origin/navigation/fingerprint/base revision no +longer matches before journaling starts, is stale and triggers a fresh +check. + +The pending-consent model also kills prompt races: one token per origin + +committed navigation at a time; matching `swarm_requestAccess` calls +while a sheet is open await the same resolution. The per-origin mutation +mutex serializes a navigation change, Settings detachment, disconnect, +and manifest approval so a stale token cannot overwrite newer state. + +--- + +## 8. Consent sheet (renderer) + +New screen alongside the existing ones in +`src/renderer/lib/wallet/swarm-connect.js`: + +``` + ddrive.gwei ← ORIGIN, primary identity + "ddrive — Encrypted drive + docs" ← manifest name/desc, secondary + + This app wants to use your Swarm node: + + ✓ Publish data Store your encrypted files and documents + ✓ Create and update feeds Keep a stable address for each document + A new app-scoped signing identity will be created for this app.¹ + ✓ Sign single-owner chunks Anchor drive data in single-owner chunks + ✓ Live messaging (PSS/GSOC) Live collaboration presence and sync + + Publishing uses your node's storage stamps and bandwidth. + This grant is bound to this set of permissions. If a future version + asks for more, you'll be asked again. Manage anytime in Settings. + + [ Don't allow ] [ Use individual approvals ] [ Allow all ] + + ¹ If an identity already exists, instead show: + "Uses your existing ; the manifest will not change it." +``` + +- **Three outcomes.** `Allow all` → token approval (§7). `Use individual + approvals` → grant only the base connection (when needed), persist + a manifest-tracked record acknowledging every shown row as + `individual`, and append an individual-outcome receipt. No + auto-approval/tier projection is performed. The app's later operations + therefore use today's operation/tier-specific prompts, but the same + manifest does not offer the batch sheet again on the next navigation. + A future manifest may offer only genuinely new rows. `Don't allow` → reject the + triggering request without acknowledging rows; rejection memory is + scoped to origin + observed fingerprint for the browser session, so a + different manifest is not accidentally suppressed. Next launch may + ask again. +- Row labels are browser-owned per capability; only the `why` column is + app text. Origin display: names as-is, raw refs truncated. +- Diff sheets render only the added rows with the same outcomes. The + individual option keeps old grants and acknowledges only the shown + additions as individual. + +--- + +## 9. Security analysis + +- **No authority expansion** — every projected grant is reachable today + via prompts + checkboxes; the manifest changes WHEN consent happens, + not WHAT is grantable. Vault-unlock and stamp economics untouched. +- **Consent fatigue is the threat model** — five sequential dialogs + train reflexive approval; one structured sheet is read with context. +- **Manifest = attacker-controlled input** — streaming size cap, schema + fail-closed, plain-text `why`/`name`, browser-owned row labels, origin + as primary identity (a manifest cannot dress up as another app). +- **Origin + resolution-path binding** — grants key off the + renderer-derived committed display URL (`swarm-provider-ipc.js:11-22`) + and manifests use the browser's own local `bzz:`/name-resolution path + (§5.2, §5.4). Raw refs bind exact bytes. Named origins bind the name + principal and record the fetch snapshot for audit; v1 does not claim + cryptographic equality with an already-rendered named snapshot. +- **Downgrade honesty** — capability-fingerprint binding + absent-means- + empty (§6.2) closes the "redeploy without a manifest, keep the broad + grants" hole. Unsupported transport is also empty for tracked origins, + so normalized name-key continuity cannot carry Bzz-managed authority + into IPFS/IPNS content; `unresolved` never prunes and never broadens. +- **Non-voluntary freshness** — `requestAccess` is the eager UX trigger, + but every privileged path is gated before stored manifest-managed + authority is consumed. An app cannot retain stale grants by omitting + `swarm_requestAccess` (§5.1). +- **Manual-override supremacy** — provenance rules (§6.3) guarantee an + unchanged manifest or unrelated diff can never re-enable what a user + turned off, and pruning never touches user-made grants. A capability + removed and later re-added is shown again; explicit approval of that + row is allowed to supersede the older manual choice. +- **Crash-safe provenance** — a durable write-ahead record exists before + any cross-store projection or prune, so recovery cannot misclassify a + partially written manifest flag as user-owned (§7). + +--- + +## 10. Implementation plan + +Foundation work (substantial infrastructure; split into independently +landable changes where practical, but schedule as part of the M1 security +unit): + +- `swarm-permissions.js`: add `revokeMessaging`; convert `onRevoke` to a + listener list. +- `feed-store.js`: expose a projection API — grant feed access with + ensure-if-absent app-scoped identity provisioning while preserving any + active identity, and a demotion that clears `feedGranted` without + touching identities/feeds. +- Manifest store: versioned observed/acknowledged/provenance records, + per-origin revision/mutex, write-ahead transaction recovery before + provider startup, and bounded receipts (§6–7). +- Authority-store mutation APIs used by the coordinator: atomic file + replacement, propagated write failures, and durable-success results; + cache-only verification is insufficient (§7). +- Renderer-exposed Settings and per-action write paths: use dedicated + user-mutation wrappers that detach provenance (§6.3); manifest code + calls internal setters. +- Replace the renderer's sequential permission/feed disconnect calls + with the journaled main-process origin-state coordinator (§6.4). +- Add `ensureManifestFresh` to renderer request routing before every + privileged method that can consume base/tier/feed/auto-approve state; + explicitly exempt permission-free reads and teardown (§5.1). + +**M1 — the security unit (ships together, not separately):** manifest +fetch/validate/fingerprint (§4–5), pending-consent flow (§7), consent + +diff sheets with three outcomes (§8), projection with provenance (§3, +§6.3), full lifecycle including absent-prunes and unresolved handling +(§6.2), privileged-method freshness gating and unsupported-transport +pruning (§5), session rejection memory, AND the Settings surface (show the +acknowledged capability decisions, effective grant state, and consent +receipts per origin; per-row revoke with detach). Launching batch grants +without the downgrade lifecycle or +visibility would be a net security regression — they are one unit. + +Tests: strict-schema fixtures (valid / missing or extra fields / unknown +group or key / empty capabilities / description limits / bidi-controls / +oversize streaming / non-JSON); projection round-trip against the REAL +stores, including locked-vault metadata provisioning and preservation of +each existing identity mode; diff matrix (unchanged / add / remove / +mixed-approve / mixed-reject / absent / invalid / unresolved / +unsupported-transport); +observed-vs-acknowledged and individual-decision persistence; provenance +matrix (manual-disable then unrelated diff; remove/re-add with explicit +approval; manual-enable then manifest-remove; shared feeds/signing +owners); crash recovery after every journal/store write boundary; +durable-write failure with cache divergence and truncated-file recovery; +duplicate-token result replay and stale-token rejection; concurrent +requestAccess dedupe plus a second committed navigation in the same +browser session; direct publish/feed/signing/messaging calls without +`requestAccess`; tracked `bzz://name` → `ipfs://name` transport switch; +public-read and unsubscribe gate bypass; fetch-failure → legacy fallback +→ later unmanaged upgrade. + +**M2 — polish:** unresolved-notice UX, iOS port (separate Swift stores, +same design — `swarm-mobile-ios` SwarmPermissionStore/feed equivalents). + +App-side integration (completed in ddrive): emit `freedom-manifest.json` +from `freedom-drive/scripts/deploy-workspace.mjs` into the collection +root. One manifest per origin — co-deployed drive+docs share it (the +union = ddrive's list above). + +--- + +## Appendix A — future wallet extension (NOT part of this proposal) + +Recorded so the thinking isn't lost; do not build any of this now. + +**Why it's out of scope:** ddrive-class apps sign chain transactions with +their own derived agent key directly over RPC — the browser wallet never +sees them. The wallet's only touchpoints are one `personal_sign` at login +(one-time via the existing signing auto-approve, +`dapp-permissions.js:185-212`) and funding transfers, which must always +prompt (`:217`). There is no wallet dialog fatigue to fix today. + +**The trigger that would change this:** moving app transaction signing +INTO the browser wallet. That would be a real security upgrade — it +eliminates the hot agent private key the app keeps in localStorage — but +it is only ergonomically survivable with pattern-scoped auto-approve, +because every ddrive portal is a freshly minted contract and per-contract +rules (`isTransactionAutoApproved`, `dapp-permissions.js:224-240`) never +generalize. If that day comes, the manifest grows a `wallet` capability +group with: + +- **Named sign messages**: exact message strings, narrower than the + blanket signing flag (store field `autoApprove.signMessages`). +- **Registry-mediated tx scopes**: "allow calls to any contract this + registry attests it created", restricted to declared function + signatures (browser derives selectors), `value == 0` enforced, + fail-closed on RPC errors. Verification against the Fileverse registry + is already confirmed feasible (local `fileverse-smartcontracts` + checkout, `contracts/FileversePortalRegistry.sol`): + `portalInfo(address) → Portal` (`:120`, call-verify — zeroed struct for + unknown addresses), `event Mint(address indexed account, address + indexed portal)` (`:51`, log-verify), and `ownedPortal(owner, …)` + (`:164`) for a tightest-scope "owned-by-caller" variant. Real + signatures for the ddrive case: `addFile(string,string,string,uint8, + uint256)`, `editFile(uint256,string,string,string,uint8,uint256)`, + `updateMetadata(string)`, `mint(string,string,string,bytes32,bytes32, + bytes32,bytes32)`. + +The v1 schema's fail-closed rule for unknown capability groups (§4) is +what makes this a clean later addition: a `wallet` group in a manifest +today rejects the whole manifest (per-action flow), so an M1-era browser +can never be tricked into granting wallet scopes it cannot render. diff --git a/research/swarm-app-permission-manifest.md b/research/swarm-app-permission-manifest.md new file mode 100644 index 00000000..db5de1ac --- /dev/null +++ b/research/swarm-app-permission-manifest.md @@ -0,0 +1,370 @@ +# Swarm Application Permission Manifest + +Status: **Draft interoperability profile, version 1** (2026-07-20). + +This document specifies the portable behavior of the application permission +manifest implemented by Freedom Browser in +[PR #168](https://github.com/solardev-xyz/freedom-browser/pull/168). It is a +starting point for coordination with other Swarm clients and SwarmID, not an +adopted Swarm standard. The filename and schema identifier match the working +implementation and remain open to change in a future, jointly versioned +profile. + +The detailed Freedom Browser design and implementation rationale remain in +[permission-manifest-design.md](permission-manifest-design.md). + +## 1. Scope and terminology + +A Swarm application can publish a small declarative manifest at the root of +its Bzz origin. The manifest tells a compatible client which existing Swarm +capabilities the application wants and why. The client can present those +requests together, remember the user's decision, and safely reconcile that +decision when the application is redeployed with a different capability set. + +The manifest batches consent; it does not define new provider methods, grant +new kinds of authority, unlock keys, select postage stamps, or bypass runtime +resource and policy checks. + +The key words **MUST**, **MUST NOT**, **SHOULD**, **SHOULD NOT**, and **MAY** +describe interoperability or security requirements. + +- **Application origin**: the client's canonical permission principal for the + committed top-level page. A named origin is normally keyed by name; a raw + content origin is normally keyed by its root reference. +- **Capability row**: one entry in `capabilities.swarm`. +- **Projection**: the complete set of existing client grants represented by a + capability row. +- **Manifest-managed grant**: a grant that the client enabled because the user + approved a manifest row and that remains subject to manifest removal. +- **User-owned grant**: a grant created or modified outside manifest approval. + Manifest reconciliation MUST NOT revoke or silently take ownership of it. +- **Tracked origin**: an origin for which the client retains a manifest + observation, acknowledgements, or manifest-managed provenance. + +## 2. Discovery + +### 2.1 Location and transport + +Version 1 applies only to applications whose committed top-level URL uses the +`bzz:` scheme. The manifest URL is: + +```text +bzz:///freedom-manifest.json +``` + +The manifest is rooted at the host and does not inherit the page path. + +The client MUST retrieve the manifest through the same native Bzz content and +name-resolution path it uses to load application content. It MUST NOT use an +unrelated public HTTP gateway. Before applying the result, it MUST verify that +the canonical origin derived from the committed URL equals the origin whose +permissions are being considered. + +For a raw Swarm reference, this binds discovery to immutable content. For a +named origin, version 1 binds authority to the name and the same resolution +path; it does not claim that the page and a later manifest fetch are +byte-for-byte from the same resolved snapshot. A client SHOULD record the +resolved snapshot reference as audit metadata when its resolution layer makes +that value available. The snapshot reference MUST NOT replace the application +origin as the permission principal. + +Other transports are outside version 1. If a tracked named origin is opened +through another transport, the client MUST treat the manifest-managed +capability set as empty before falling back to its non-manifest permission +flow. This prevents permissions established by a Bzz manifest from silently +carrying over to content loaded through an unsupported resolution path. + +### 2.2 When to check + +A client MUST check a tracked origin at least once for every committed +top-level navigation before allowing that navigation to consume stored +manifest-managed authority. Calling an explicit connection method can trigger +discovery early, but it MUST NOT be the only freshness boundary: an already +authorized application might call a privileged operation directly. + +Concurrent checks for the same origin and committed navigation SHOULD share +one in-flight discovery and consent result. A completed result MUST NOT be +cached across later top-level navigations, even when a named URL is unchanged. + +Public reads and capability introspection that require no permission MAY +bypass discovery. Cleanup operations such as unsubscribe SHOULD bypass it so +that transient retrieval failures cannot prevent resource teardown. + +For an untracked origin, a client MAY limit discovery to its explicit +connection flow. A transient discovery failure MUST NOT permanently classify +that origin as manifest-free; later qualifying navigations SHOULD retry with a +bounded session backoff. The current Freedom Browser profile uses delays of 2, +10, 30, and then 60 seconds between unresolved attempts. + +### 2.3 Retrieval and outcomes + +The response body MUST be no larger than 8 KiB. The limit MUST be enforced +while streaming, not after buffering an arbitrarily large response. The body +MUST be decoded as strict UTF-8 and parsed as JSON. + +Discovery has five outcomes: + +| Outcome | Meaning | +|---|---| +| `found` | A successful response was decoded, parsed, and validated. | +| `absent` | Retrieval definitively returned HTTP 404. | +| `invalid` | Content was present but failed retrieval or schema rules, including other definitive 4xx responses. | +| `unresolved` | The node, network, or name resolution failed temporarily, timed out, or returned a non-definitive server failure. | +| `unsupported_transport` | The committed top-level page did not use `bzz:`. | + +For a tracked origin, `absent`, `invalid`, and `unsupported_transport` MUST be +treated as an empty manifest capability set: remove manifest-managed authority +and drop manifest tracking while preserving user-owned grants. An invalid +manifest SHOULD also produce a developer-visible diagnostic. + +`unresolved` MUST NOT be treated as absence and MUST NOT revoke grants. A +tracked navigation MUST NOT consume manifest-managed authority until freshness +can be established; the client should return a temporary availability error +and retry subject to backoff. An untracked origin MAY continue through the +client's ordinary per-action permission flow. + +## 3. Version 1 schema + +The top-level JSON object has this form: + +```json +{ + "schema": "freedom-manifest/1", + "name": "Example app", + "description": "An optional short description", + "capabilities": { + "swarm": { + "publish": { "why": "Store your encrypted files" }, + "feeds": { "why": "Maintain stable document addresses" }, + "signing": { "why": "Create signed Swarm updates" }, + "messaging": { "why": "Synchronize live collaboration" } + } + } +} +``` + +Validation is strict: + +- The root object MUST contain `schema`, `name`, and `capabilities`. It MAY + contain `description`. No other root member is allowed. +- `schema` MUST equal `freedom-manifest/1`. +- `name` MUST be a non-blank string of at most 32 Unicode code points. +- `description`, when present, MUST be a string of at most 160 Unicode code + points. It may be empty. +- `capabilities` MUST contain exactly one member, `swarm`. +- `capabilities.swarm` MUST be a non-empty object. Its keys MUST come from the + version 1 registry in section 4. +- Each capability value MUST be an object containing exactly one member, + `why`. +- `why` MUST be a non-blank string of at most 140 Unicode code points. + +All displayed strings MUST be treated as plain, attacker-controlled text. The +client MUST reject rather than strip a string containing: + +- C0 control characters (`U+0000`-`U+001F`); +- C1 control characters (`U+007F`-`U+009F`); +- line or paragraph separators (`U+2028`, `U+2029`); or +- bidirectional embedding, override, or isolate controls + (`U+202A`-`U+202E`, `U+2066`-`U+2069`). + +Unknown fields, capability keys, capability groups, and schema identifiers +invalidate the whole manifest. A version 1 client MUST NOT silently grant the +subset it recognizes because that subset may not match the consent presentation +intended by a newer application. + +## 4. Capability registry + +Approving a capability represents its complete projection below. A client MAY +use different internal storage, but the resulting consent boundaries MUST be +equivalent. + +| Capability | Projection | +|---|---| +| `publish` | Establish the base application connection and allow publishing without a repeated per-operation approval. | +| `feeds` | Establish the base connection, grant feed access, ensure a publisher identity exists, and allow feed creation and updates without repeated approval. | +| `signing` | Establish the base connection, grant feed/signing access, ensure a publisher identity exists, and allow Swarm content signing without repeated approval. | +| `messaging` | Establish the base connection, grant the messaging tier, and allow supported PSS/GSOC messaging operations without repeated approval. | + +**Informative method mapping.** Capability semantics above are stated +behaviorally because clients differ in internal permission granularity. +For clients exposing the Swarm provider API (the `window.swarm` SWIP +draft and its messaging extension), the reference implementation maps +capabilities to provider methods as follows; a successor profile should +make this mapping normative once the provider API is finalized: + +| Category | Provider methods covered | +|---|---| +| Connection establishment | `swarm_requestAccess` | +| Base connection only | `swarm_getUploadStatus` (origin-owned uploads), `swarm_unsubscribe` (origin-owned teardown; freshness bypassed) | +| `publish` | `swarm_publishData`, `swarm_publishFiles`, `swarm_publishChunk` | +| `feeds` | `swarm_createFeed`, `swarm_updateFeed`, `swarm_writeFeedEntry` | +| `signing` | `swarm_writeSingleOwnerChunk`, `swarm_getSigningIdentity` | +| `messaging` | `swarm_getMessagingIdentity`, `swarm_subscribe`, `swarm_sendPss`, `swarm_sendGsoc` | + +Permission-free methods (`swarm_getCapabilities`, `swarm_readFeedEntry`, +`swarm_readChunk`, `swarm_readSingleOwnerChunk`, `swarm_listFeeds`) are +not affected by any capability. `swarm_unsubscribe` remains connection- and +origin-scoped, but bypasses the freshness boundary so teardown cannot be +blocked (section 2.2). + +The `feeds` and `signing` projections share the feed grant and publisher +identity dependency. A client MUST track those shared dependencies so removing +one capability does not revoke a dependency still justified by the other. + +Publisher identity handling follows an **ensure, never replace** rule: + +- If the application already has an active publisher identity, approval MUST + preserve it regardless of its identity mode. +- If no publisher identity exists, the client creates or reserves its + privacy-preserving app-scoped identity and makes it active. +- Removing capabilities or disconnecting MUST NOT silently delete identity + records or change the user's identity selection. +- Creating identity metadata MUST NOT unlock a vault or expose key material. + Any runtime vault-unlock requirement remains in force when the key is used. + +The base connection is implicit rather than a manifest row. Choosing +individual approvals may establish only that connection; each declared +capability then follows the client's ordinary per-action or per-tier prompts. + +## 5. Consent and change semantics + +### 5.1 Consent presentation + +The application origin MUST be the primary identity shown to the user. +Manifest-provided `name` and `description` are secondary context. Capability +labels and explanations of their authority MUST be client-owned; only the +corresponding `why` text comes from the application. + +The client MUST offer three semantically distinct outcomes: + +1. **Allow all**: acknowledge the displayed rows as managed and apply their + projections. Only grants newly enabled by this decision become + manifest-managed; an already-enabled user-owned grant stays user-owned. +2. **Use individual approvals**: acknowledge the displayed rows as individual + without enabling their capability projections. The client may establish the + implicit base connection. Later operations use ordinary prompts, and the + same rows are not offered again as a batch unless they are removed and later + re-added. +3. **Don't allow**: do not acknowledge or project the displayed additions. + Removals already discovered from a mixed update still apply. On first + contact, the client MUST NOT create a durable manifest record solely because + of rejection. It SHOULD suppress duplicate prompts for at least the current + committed navigation. + +If `feeds` or `signing` would create an app-scoped identity, the consent view +MUST say so. If an identity already exists, it MUST say that the existing +identity will be preserved. + +### 5.2 Semantic comparison + +Authority is bound to the set of capability keys, not to the exact manifest +bytes. Clients MUST compare the schema identifier plus the sorted capability +keys when deciding whether the authority request changed. + +A SHA-256 hash of the exact served bytes MAY be retained for audit and +debugging, but it MUST NOT control authority. Changes only to whitespace, +`name`, `description`, or `why` do not require renewed consent. Consent history +MUST preserve the text actually shown when a decision was made. + +Clients MUST separately represent: + +- the latest successfully observed capability set; and +- the capability rows the user has acknowledged as managed or individual. + +These values differ after, for example, a mixed update whose removals were +applied but whose additions were rejected. + +### 5.3 Additions and removals + +For a successfully validated manifest, let `current` be its capability keys and +`acknowledged` be the rows with a prior managed or individual decision. + +1. Apply removals, `acknowledged - current`, before considering additions. + Remove each acknowledgement and its ownership of projected grants. Disable + a projected grant only when it has no remaining manifest owners and has not + become user-owned. +2. Compute additions, `current - acknowledged`. +3. If an addition's complete projection is already enabled through user action, + acknowledge it as individual without prompting or taking ownership. +4. If additions remain, display only those new rows. Allowing them projects only + those rows; it MUST NOT silently reapply unchanged rows. + +Removing a capability and later adding it again creates a new addition and +requires a new decision. Removed publisher identity records are the exception: +they are retained as described in section 4. + +### 5.4 Manual changes, revocation, and provenance + +A client MUST retain enough provenance to distinguish manifest-managed grants +from user-owned grants. + +- Any manual change to a projected grant detaches that grant from manifest + management, whether the user turns it on or off. +- Manifest reconciliation MUST NOT change a detached or otherwise user-owned + grant. +- Approval of a later diff applies only to the rows displayed in that diff. It + MUST NOT re-enable an unchanged capability that the user manually disabled. +- When multiple rows share a projected grant, the client MUST retain all owning + rows and revoke that grant only after the last owner is removed. +- A full disconnect MUST revoke the base connection and applicable runtime + grants, terminate live resources, demote feed access, and remove manifest + tracking as one serialized logical operation. Identity records remain. + +## 6. Security and state requirements + +- **No new authority:** a manifest projection MUST be equivalent to authority + the client could already grant through its ordinary prompts. Runtime limits, + postage economics, and vault requirements remain independent. +- **Authoritative processing:** fetching, validation, semantic comparison, + provenance, and grant mutation MUST occur in a trusted client component. An + untrusted application or rendering context MUST NOT submit manifest bytes as + the authority to grant permissions. +- **Consent binding:** a consent action MUST be bound to an opaque or otherwise + unforgeable pending decision containing at least the origin, observed + semantic capability set, displayed rows, and current permission-state + revision. Expired, unknown, replayed with a different result, or stale + decisions MUST NOT grant authority. Retrying the same completed decision MAY + return its original result. +- **Origin serialization:** manifest decisions, navigation reconciliation, + manual permission changes, and disconnects for the same origin MUST be + serialized. A stale approval MUST NOT overwrite newer state. +- **Crash consistency:** if one decision updates multiple authority stores, the + client MUST durably record intent before applying changes and MUST recover by + completing idempotent operations. A crash MUST NOT cause a partially applied + manifest grant to be mistaken for a user-owned grant. +- **Fail-closed parsing:** size, UTF-8, schema, key, and displayed-text rules are + enforced before a consent model is created. +- **Fail-safe retrieval:** a definitive empty capability set removes only + manifest-managed authority. A temporary retrieval failure neither broadens + nor revokes authority, but blocks its use until freshness is established. +- **Bounded state:** consent receipts, completed-decision replay state, and + retry bookkeeping SHOULD be bounded so an application cannot cause + unbounded client storage growth. + +## 7. Versioning and coordination + +The on-wire version 1 compatibility points are currently: + +- filename: `freedom-manifest.json`; +- schema identifier: `freedom-manifest/1`; +- one required capability group: `swarm`; and +- capability keys: `publish`, `feeds`, `signing`, and `messaging`. + +The Freedom-specific names are historical and provisional from a standards +perspective, but changing either one requires a new compatibility profile or a +defined dual-discovery transition. + +Most importantly, a SwarmID capability group cannot be added to a version 1 +manifest while remaining compatible with current clients: unknown groups +invalidate the whole file by design. SwarmID permissions therefore require a +coordinated successor schema (or another explicitly negotiated extension +mechanism) that defines: + +- the group name and individual capability semantics; +- whether partial understanding is ever safe; +- how clients advertise supported schema versions and groups; +- how consent and revocation interact with Swarm identity selection; and +- how applications migrate while version 1 clients remain in use. + +Until that successor is agreed, applications targeting the implemented profile +MUST emit exactly the version 1 schema described here. diff --git a/src/main/index.js b/src/main/index.js index c1a04fb5..96ca2df5 100644 --- a/src/main/index.js +++ b/src/main/index.js @@ -229,6 +229,7 @@ const { registerSwarmProviderIpc } = require('./swarm/swarm-provider-ipc'); const { registerRadiclePermissionsIpc } = require('./radicle/radicle-permissions'); const { registerRadicleProviderIpc } = require('./radicle/radicle-provider-ipc'); const { registerFeedStoreIpc } = require('./swarm/feed-store'); +const { registerPermissionManifestIpc } = require('./swarm/permission-manifests'); const { registerGithubBridgeIpc, cleanupTempDirs } = require('./github-bridge'); const { registerServiceRegistryIpc } = require('./service-registry'); const { promptForDefaultExternalCandidates } = require('./profile-external-candidates'); @@ -311,6 +312,7 @@ async function bootstrap() { registerRadiclePermissionsIpc(); registerRadicleProviderIpc(); registerFeedStoreIpc(); + registerPermissionManifestIpc(); // Resolve any pending broadcast txs that didn't get a final receipt // before the previous run exited. Fire-and-forget — the wallet stack diff --git a/src/main/preload.js b/src/main/preload.js index 81d047dd..8a4e5df4 100644 --- a/src/main/preload.js +++ b/src/main/preload.js @@ -564,9 +564,18 @@ contextBridge.exposeInMainWorld('swarmPermissions', { setAutoApprove: (origin, type, enabled) => ipcRenderer.invoke('swarm:set-auto-approve', origin, type, enabled), grantMessaging: (origin) => ipcRenderer.invoke('swarm:grant-messaging', origin), + revokeMessaging: (origin) => ipcRenderer.invoke('swarm:revoke-messaging', origin), hasMessagingGrant: (origin) => ipcRenderer.invoke('swarm:has-messaging-grant', origin), }); +contextBridge.exposeInMainWorld('swarmManifest', { + check: (request) => ipcRenderer.invoke('swarm:manifest-check', request), + decide: (token, outcome) => ipcRenderer.invoke('swarm:manifest-decide', { token, outcome }), + get: (origin) => ipcRenderer.invoke('swarm:manifest-get', origin), + useIndividual: (origin, capability) => ipcRenderer.invoke('swarm:manifest-use-individual', { origin, capability }), + disconnect: (origin) => ipcRenderer.invoke('swarm:manifest-disconnect', origin), +}); + contextBridge.exposeInMainWorld('swarmProvider', { // meta carries renderer-only routing info (e.g. the subscribing // webview's webContentsId for swarm_subscribe message delivery). diff --git a/src/main/preload.test.js b/src/main/preload.test.js index 4056b8b7..cf24ecfc 100644 --- a/src/main/preload.test.js +++ b/src/main/preload.test.js @@ -83,7 +83,7 @@ describe('preload', () => { beeApiEnv: 'http://127.0.0.1:1700', }); - expect(contextBridge.exposeInMainWorld).toHaveBeenCalledTimes(24); + expect(contextBridge.exposeInMainWorld).toHaveBeenCalledTimes(25); expect(Object.keys(exposures)).toEqual([ 'nodeConfig', 'internalPages', @@ -105,6 +105,7 @@ describe('preload', () => { 'sitePermissions', 'dappPermissions', 'swarmPermissions', + 'swarmManifest', 'swarmProvider', 'radiclePermissions', 'radicleProvider', @@ -170,6 +171,12 @@ describe('preload', () => { [exposures.githubBridge, 'validateUrl', ['https://github.com/openai/project'], IPC.GITHUB_BRIDGE_VALIDATE_URL, ['https://github.com/openai/project']], [exposures.githubBridge, 'checkExisting', ['https://github.com/openai/project'], IPC.GITHUB_BRIDGE_CHECK_EXISTING, ['https://github.com/openai/project']], [exposures.serviceRegistry, 'getRegistry', [], IPC.SERVICE_REGISTRY_GET, []], + [exposures.swarmPermissions, 'revokeMessaging', ['origin.eth'], IPC.SWARM_REVOKE_MESSAGING, ['origin.eth']], + [exposures.swarmManifest, 'check', [{ origin: 'origin.eth', committedUrl: 'bzz://origin.eth/' }], IPC.SWARM_MANIFEST_CHECK, [{ origin: 'origin.eth', committedUrl: 'bzz://origin.eth/' }]], + [exposures.swarmManifest, 'decide', ['token', 'allow'], IPC.SWARM_MANIFEST_DECIDE, [{ token: 'token', outcome: 'allow' }]], + [exposures.swarmManifest, 'get', ['origin.eth'], IPC.SWARM_MANIFEST_GET, ['origin.eth']], + [exposures.swarmManifest, 'useIndividual', ['origin.eth', 'feeds'], IPC.SWARM_MANIFEST_USE_INDIVIDUAL, [{ origin: 'origin.eth', capability: 'feeds' }]], + [exposures.swarmManifest, 'disconnect', ['origin.eth'], IPC.SWARM_MANIFEST_DISCONNECT, ['origin.eth']], [exposures.swarmFeedStore, 'previewAppScopedIdentity', ['origin.eth', { label: 'Draft' }], IPC.SWARM_PREVIEW_APP_SCOPED_IDENTITY, ['origin.eth', { label: 'Draft' }]], [exposures.swarmFeedStore, 'ensureEthereumWalletIdentity', ['origin.eth', 2, { activate: true }], IPC.SWARM_ENSURE_ETHEREUM_WALLET_IDENTITY, ['origin.eth', 2, { activate: true }]], [exposures.sitePermissions, 'respondToPrompt', [{ id: 1, decision: 'allow', remember: true }], IPC.PERMISSIONS_PROMPT_RESPONSE, [{ id: 1, decision: 'allow', remember: true }]], diff --git a/src/main/swarm/feed-store.js b/src/main/swarm/feed-store.js index e4c7c88b..df3e6fac 100644 --- a/src/main/swarm/feed-store.js +++ b/src/main/swarm/feed-store.js @@ -28,6 +28,7 @@ const path = require('path'); const fs = require('fs'); const IPC = require('../../shared/ipc-channels'); const { normalizeOrigin } = require('../../shared/origin-utils'); +const { withOriginLock } = require('./origin-mutation-lock'); const { getDerivedKeys, getPublisherKey, @@ -46,6 +47,23 @@ const ETHEREUM_WALLET_ID_PREFIX = 'ethereum-wallet'; let feedsCache = null; +// Manifest-ownership hook, mirroring swarm-permissions.js. A permission +// manifest can own the feed grant and the publisher identity of an origin; +// when the *user* changes either by hand, that ownership has to be detached +// or the manifest record keeps claiming the flag (Settings still reads +// "feeds · allowed by manifest") and a later projection silently re-asserts +// it. Manifest-sourced calls pass `{ source: 'manifest' }` and notify +// nothing — they are the projection, not a user mutation. +let manifestMutationListener = null; + +function onManifestMutation(listener) { + manifestMutationListener = listener; +} + +function notifyManifestMutation(origin, projectionKey) { + manifestMutationListener?.(normalizeOrigin(origin), projectionKey); +} + class PreserveFeedStoreError extends Error { constructor(message, backupSuffix = 'unsupported') { super(message); @@ -383,6 +401,8 @@ function saveFeeds() { fs.renameSync(tempPath, filePath); } catch (err) { log.error('[FeedStore] Failed to save feeds:', err.message); + feedsCache = null; + throw err; } } @@ -618,10 +638,22 @@ async function previewAppScopedIdentity(origin, options = {}) { }; } +// A manifest can own an origin's publisher identity (the `feeds`/`signing` +// capabilities project it). Any user-driven change of the *active* identity +// makes that ownership claim untrue, so it is reported like the other user +// mutations. Only an actual switch counts — re-ensuring the identity that is +// already active changes nothing to detach. +function notifyIdentityChange(key, previousActiveId, source) { + if (source === 'manifest') return; + if (loadFeeds().origins[key]?.activeIdentityId === previousActiveId) return; + notifyManifestMutation(key, 'identity'); +} + function createAppScopedIdentity(origin, options = {}) { const store = loadFeeds(); const key = normalizeOrigin(origin); const entry = store.origins[key] || createOriginShell(); + const previousActiveId = entry.activeIdentityId; const publisherKeyIndex = allocatePublisherKeyIndexInStore(store); const identity = createIdentity('app-scoped', publisherKeyIndex, Date.now(), options.label); @@ -640,6 +672,7 @@ function createAppScopedIdentity(origin, options = {}) { store.origins[key] = entry; saveFeeds(); log.info(`[FeedStore] Created app-scoped identity ${identity.id} for ${key}`); + notifyIdentityChange(key, previousActiveId, options.source); return getOriginEntry(origin); } @@ -647,6 +680,7 @@ function ensureAntWalletIdentity(origin, options = {}) { const store = loadFeeds(); const key = normalizeOrigin(origin); const entry = store.origins[key] || createOriginShell(); + const previousActiveId = entry.activeIdentityId; const existing = entry.identities?.[BEE_WALLET_IDENTITY_ID]; const identity = existing || createIdentity('bee-wallet', null, Date.now(), options.label); @@ -665,6 +699,7 @@ function ensureAntWalletIdentity(origin, options = {}) { store.origins[key] = entry; saveFeeds(); log.info(`[FeedStore] Ensured Ant wallet identity for ${key}`); + notifyIdentityChange(key, previousActiveId, options.source); return getOriginEntry(origin); } @@ -687,6 +722,7 @@ async function ensureEthereumWalletIdentity(origin, walletIndex, options = {}) { const store = loadFeeds(); const key = normalizeOrigin(origin); const entry = store.origins[key] || createOriginShell(); + const previousActiveId = entry.activeIdentityId; const identityId = getIdentityId('ethereum-wallet', null, walletIndex); const existing = entry.identities?.[identityId]; const identity = existing || createIdentity('ethereum-wallet', null, Date.now(), wallet.name, walletIndex); @@ -709,10 +745,11 @@ async function ensureEthereumWalletIdentity(origin, walletIndex, options = {}) { store.origins[key] = entry; saveFeeds(); log.info(`[FeedStore] Ensured Ethereum wallet identity ${identity.id} for ${key}`); + notifyIdentityChange(key, previousActiveId, options.source); return getOriginEntry(origin); } -function activateIdentity(origin, identityId) { +function activateIdentity(origin, identityId, { source = 'user' } = {}) { const store = loadFeeds(); const key = normalizeOrigin(origin); const entry = store.origins[key]; @@ -723,10 +760,12 @@ function activateIdentity(origin, identityId) { throw new Error(`Publisher identity not found: ${identityId}`); } + const previousActiveId = entry.activeIdentityId; entry.activeIdentityId = identityId; entry.identities[identityId].lastUsedAt = Date.now(); saveFeeds(); log.info(`[FeedStore] Activated identity ${identityId} for ${key}`); + notifyIdentityChange(key, previousActiveId, source); return getOriginEntry(origin); } @@ -913,26 +952,30 @@ function hasFeedGrant(origin) { /** * Grant feed access for an origin. Called after the feed approval prompt. * @param {string} origin + * @param {{source?: string}} [options] */ -function grantFeedAccess(origin) { +function grantFeedAccess(origin, { source = 'user' } = {}) { const store = loadFeeds(); const key = normalizeOrigin(origin); if (!store.origins[key]) return; store.origins[key].feedGranted = true; saveFeeds(); + if (source === 'user') notifyManifestMutation(key, 'feedGrant'); } /** * Revoke feed access for an origin. Called on disconnect. * Identity metadata (identityMode, publisherKeyIndex, feeds) is preserved. * @param {string} origin + * @param {{source?: string}} [options] */ -function revokeFeedAccess(origin) { +function revokeFeedAccess(origin, { source = 'user' } = {}) { const store = loadFeeds(); const key = normalizeOrigin(origin); if (!store.origins[key]) return; store.origins[key].feedGranted = false; saveFeeds(); + if (source === 'user') notifyManifestMutation(key, 'feedGrant'); } /** @@ -964,52 +1007,67 @@ function registerFeedStoreIpc() { return previewAppScopedIdentity(origin, options); }); + // Everything arriving over IPC is a user action by definition: `source` is + // forced here so a renderer cannot pass `{ source: 'manifest' }` and mutate + // an origin's identity without detaching the manifest's claim on it. ipcMain.handle(IPC.SWARM_CREATE_APP_SCOPED_IDENTITY, async (_event, origin, options = {}) => { - createAppScopedIdentity(origin, options); - return getOriginIdentityStateWithOwners(origin); + return withOriginLock(origin, async () => { + createAppScopedIdentity(origin, { ...options, source: 'user' }); + return getOriginIdentityStateWithOwners(origin); + }); }); ipcMain.handle(IPC.SWARM_ENSURE_ANT_WALLET_IDENTITY, async (_event, origin, options = {}) => { - ensureAntWalletIdentity(origin, options); - return getOriginIdentityStateWithOwners(origin); + return withOriginLock(origin, async () => { + ensureAntWalletIdentity(origin, { ...options, source: 'user' }); + return getOriginIdentityStateWithOwners(origin); + }); }); ipcMain.handle(IPC.SWARM_ENSURE_ETHEREUM_WALLET_IDENTITY, async (_event, origin, walletIndex, options = {}) => { - await ensureEthereumWalletIdentity(origin, walletIndex, options); - return getOriginIdentityStateWithOwners(origin); + return withOriginLock(origin, async () => { + await ensureEthereumWalletIdentity(origin, walletIndex, { ...options, source: 'user' }); + return getOriginIdentityStateWithOwners(origin); + }); }); ipcMain.handle(IPC.SWARM_ACTIVATE_FEED_IDENTITY, async (_event, origin, identityId) => { - activateIdentity(origin, identityId); - return getOriginIdentityStateWithOwners(origin); + return withOriginLock(origin, async () => { + activateIdentity(origin, identityId); + return getOriginIdentityStateWithOwners(origin); + }); }); // Idempotent for identity: if the origin already has an identity mode set, // return the existing entry without allocating a new key index. // Always grants feed access (feedGranted = true). ipcMain.handle(IPC.SWARM_SET_FEED_IDENTITY, (_event, origin, identityMode) => { - if (!VALID_IDENTITY_MODES.includes(identityMode)) { - throw new Error(`Invalid identity mode: ${identityMode}. Must be one of: ${VALID_IDENTITY_MODES.join(', ')}`); - } + return withOriginLock(origin, () => { + if (!VALID_IDENTITY_MODES.includes(identityMode)) { + throw new Error(`Invalid identity mode: ${identityMode}. Must be one of: ${VALID_IDENTITY_MODES.join(', ')}`); + } - const existing = getOriginEntry(origin); - if (identityMode === 'ethereum-wallet' && !existing?.activeIdentityId) { - throw new Error('Use ensureEthereumWalletIdentity(origin, walletIndex) before setting ethereum-wallet feed identity'); - } - if (existing && existing.activeIdentityId) { - // Identity already set — just re-grant feed access - if (!existing.feedGranted) { - grantFeedAccess(origin); + const existing = getOriginEntry(origin); + if (identityMode === 'ethereum-wallet' && !existing?.activeIdentityId) { + throw new Error('Use ensureEthereumWalletIdentity(origin, walletIndex) before setting ethereum-wallet feed identity'); + } + if (existing && existing.activeIdentityId) { + // Identity already set — just re-grant feed access + if (!existing.feedGranted) { + grantFeedAccess(origin); + } + return getOriginEntry(origin); } - return getOriginEntry(origin); - } - return setOriginEntry(origin, { identityMode, feedGranted: true }); + return setOriginEntry(origin, { identityMode, feedGranted: true }); + }); }); ipcMain.handle(IPC.SWARM_REVOKE_FEED_ACCESS, (_event, origin) => { - revokeFeedAccess(origin); - return true; + return withOriginLock(origin, () => { + revokeFeedAccess(origin); + return true; + }); }); log.info('[FeedStore] IPC handlers registered'); @@ -1040,6 +1098,7 @@ module.exports = { hasFeedGrant, grantFeedAccess, revokeFeedAccess, + onManifestMutation, registerFeedStoreIpc, VALID_IDENTITY_MODES, _resetCache, diff --git a/src/main/swarm/feed-store.test.js b/src/main/swarm/feed-store.test.js index f90e5246..eedcc985 100644 --- a/src/main/swarm/feed-store.test.js +++ b/src/main/swarm/feed-store.test.js @@ -66,6 +66,7 @@ const { hasFeedGrant, grantFeedAccess, revokeFeedAccess, + onManifestMutation, registerFeedStoreIpc, _resetCache, } = require('./feed-store'); @@ -695,6 +696,64 @@ describe('feed-store', () => { }); }); + // A permission manifest can own the feed grant and the publisher identity + // of an origin. When the user changes either by hand, that ownership has to + // be detached or the manifest record keeps claiming the flag and re-asserts + // it on the next projection. + describe('manifest ownership detach', () => { + let mutations; + + beforeEach(() => { + mutations = []; + onManifestMutation((origin, projection) => mutations.push([origin, projection])); + setOriginEntry('myapp.eth', { identityMode: 'app-scoped', publisherKeyIndex: 0, feedGranted: true }); + mutations.length = 0; + }); + + afterEach(() => { + onManifestMutation(null); + }); + + test('a user revoking feed access detaches manifest ownership of the grant', () => { + revokeFeedAccess('myapp.eth'); + expect(mutations).toEqual([['myapp.eth', 'feedGrant']]); + }); + + test('a manifest projecting the feed grant reports no user mutation', () => { + revokeFeedAccess('myapp.eth', { source: 'manifest' }); + grantFeedAccess('myapp.eth', { source: 'manifest' }); + createAppScopedIdentity('myapp.eth', { activate: true, source: 'manifest' }); + expect(mutations).toEqual([]); + }); + + test('a user switching the publisher identity detaches manifest ownership of it', () => { + createAppScopedIdentity('myapp.eth'); + expect(mutations).toEqual([['myapp.eth', 'identity']]); + + mutations.length = 0; + activateIdentity('myapp.eth', 'app-scoped:0'); + expect(mutations).toEqual([['myapp.eth', 'identity']]); + }); + + test('re-activating the identity that is already active detaches nothing', () => { + const entry = getOriginEntry('myapp.eth'); + activateIdentity('myapp.eth', entry.activeIdentityId); + expect(mutations).toEqual([]); + }); + + test('a renderer cannot pass itself off as the manifest over IPC', async () => { + registerFeedStoreIpc(); + await ipcHandlers[IPC.SWARM_CREATE_APP_SCOPED_IDENTITY]({}, 'myapp.eth', { source: 'manifest' }); + expect(mutations).toEqual([['myapp.eth', 'identity']]); + }); + + test('an unknown origin never reports a mutation', () => { + revokeFeedAccess('nothing-here.eth'); + grantFeedAccess('nothing-here.eth'); + expect(mutations).toEqual([]); + }); + }); + describe('IPC handlers', () => { beforeAll(() => { registerFeedStoreIpc(); @@ -717,76 +776,76 @@ describe('feed-store', () => { expect(result).toBe(false); }); - test('set-feed-identity creates origin entry with app-scoped mode', () => { + test('set-feed-identity creates origin entry with app-scoped mode', async () => { _resetCache(); - const result = ipcHandlers[IPC.SWARM_SET_FEED_IDENTITY]({}, 'ipc-test.eth', 'app-scoped'); + const result = await ipcHandlers[IPC.SWARM_SET_FEED_IDENTITY]({}, 'ipc-test.eth', 'app-scoped'); expect(result.identityMode).toBe('app-scoped'); expect(result.publisherKeyIndex).toEqual(expect.any(Number)); }); - test('set-feed-identity is idempotent — does not allocate new key index', () => { + test('set-feed-identity is idempotent — does not allocate new key index', async () => { _resetCache(); - const first = ipcHandlers[IPC.SWARM_SET_FEED_IDENTITY]({}, 'ipc-idem.eth', 'app-scoped'); + const first = await ipcHandlers[IPC.SWARM_SET_FEED_IDENTITY]({}, 'ipc-idem.eth', 'app-scoped'); const firstIndex = first.publisherKeyIndex; - const second = ipcHandlers[IPC.SWARM_SET_FEED_IDENTITY]({}, 'ipc-idem.eth', 'app-scoped'); + const second = await ipcHandlers[IPC.SWARM_SET_FEED_IDENTITY]({}, 'ipc-idem.eth', 'app-scoped'); expect(second.publisherKeyIndex).toBe(firstIndex); }); - test('set-feed-identity ignores different mode on re-grant', () => { + test('set-feed-identity ignores different mode on re-grant', async () => { _resetCache(); - ipcHandlers[IPC.SWARM_SET_FEED_IDENTITY]({}, 'ipc-mode.eth', 'app-scoped'); - const second = ipcHandlers[IPC.SWARM_SET_FEED_IDENTITY]({}, 'ipc-mode.eth', 'bee-wallet'); + await ipcHandlers[IPC.SWARM_SET_FEED_IDENTITY]({}, 'ipc-mode.eth', 'app-scoped'); + const second = await ipcHandlers[IPC.SWARM_SET_FEED_IDENTITY]({}, 'ipc-mode.eth', 'bee-wallet'); // Should return existing entry, not switch mode expect(second.identityMode).toBe('app-scoped'); }); - test('set-feed-identity rejects invalid identity mode', () => { + test('set-feed-identity rejects invalid identity mode', async () => { _resetCache(); - expect(() => ipcHandlers[IPC.SWARM_SET_FEED_IDENTITY]({}, 'ipc-bad.eth', 'invalid')) - .toThrow('Invalid identity mode'); + await expect(ipcHandlers[IPC.SWARM_SET_FEED_IDENTITY]({}, 'ipc-bad.eth', 'invalid')) + .rejects.toThrow('Invalid identity mode'); }); - test('set-feed-identity rejects ethereum-wallet for a new origin without wallet index setup', () => { + test('set-feed-identity rejects ethereum-wallet for a new origin without wallet index setup', async () => { _resetCache(); - expect(() => ipcHandlers[IPC.SWARM_SET_FEED_IDENTITY]({}, 'ipc-eth.eth', 'ethereum-wallet')) - .toThrow('ensureEthereumWalletIdentity'); + await expect(ipcHandlers[IPC.SWARM_SET_FEED_IDENTITY]({}, 'ipc-eth.eth', 'ethereum-wallet')) + .rejects.toThrow('ensureEthereumWalletIdentity'); }); - test('has-feed-identity returns true after identity set', () => { + test('has-feed-identity returns true after identity set', async () => { _resetCache(); - ipcHandlers[IPC.SWARM_SET_FEED_IDENTITY]({}, 'ipc-test2.eth', 'bee-wallet'); + await ipcHandlers[IPC.SWARM_SET_FEED_IDENTITY]({}, 'ipc-test2.eth', 'bee-wallet'); const result = ipcHandlers[IPC.SWARM_HAS_FEED_IDENTITY]({}, 'ipc-test2.eth'); expect(result).toBe(true); }); - test('set-feed-identity also grants feed access', () => { + test('set-feed-identity also grants feed access', async () => { _resetCache(); - ipcHandlers[IPC.SWARM_SET_FEED_IDENTITY]({}, 'ipc-grant.eth', 'app-scoped'); + await ipcHandlers[IPC.SWARM_SET_FEED_IDENTITY]({}, 'ipc-grant.eth', 'app-scoped'); expect(hasFeedGrant('ipc-grant.eth')).toBe(true); }); - test('revoke-feed-access clears feed grant', () => { + test('revoke-feed-access clears feed grant', async () => { _resetCache(); - ipcHandlers[IPC.SWARM_SET_FEED_IDENTITY]({}, 'ipc-revoke.eth', 'bee-wallet'); + await ipcHandlers[IPC.SWARM_SET_FEED_IDENTITY]({}, 'ipc-revoke.eth', 'bee-wallet'); expect(hasFeedGrant('ipc-revoke.eth')).toBe(true); - ipcHandlers[IPC.SWARM_REVOKE_FEED_ACCESS]({}, 'ipc-revoke.eth'); + await ipcHandlers[IPC.SWARM_REVOKE_FEED_ACCESS]({}, 'ipc-revoke.eth'); expect(hasFeedGrant('ipc-revoke.eth')).toBe(false); // Identity preserved expect(hasIdentityMode('ipc-revoke.eth')).toBe(true); }); - test('set-feed-identity re-grants after revocation without new key', () => { + test('set-feed-identity re-grants after revocation without new key', async () => { _resetCache(); - const first = ipcHandlers[IPC.SWARM_SET_FEED_IDENTITY]({}, 'ipc-regrant.eth', 'app-scoped'); - ipcHandlers[IPC.SWARM_REVOKE_FEED_ACCESS]({}, 'ipc-regrant.eth'); - const second = ipcHandlers[IPC.SWARM_SET_FEED_IDENTITY]({}, 'ipc-regrant.eth', 'app-scoped'); + const first = await ipcHandlers[IPC.SWARM_SET_FEED_IDENTITY]({}, 'ipc-regrant.eth', 'app-scoped'); + await ipcHandlers[IPC.SWARM_REVOKE_FEED_ACCESS]({}, 'ipc-regrant.eth'); + const second = await ipcHandlers[IPC.SWARM_SET_FEED_IDENTITY]({}, 'ipc-regrant.eth', 'app-scoped'); expect(second.feedGranted).toBe(true); expect(second.publisherKeyIndex).toBe(first.publisherKeyIndex); }); test('identity management IPC creates, ensures, and activates identities', async () => { _resetCache(); - ipcHandlers[IPC.SWARM_SET_FEED_IDENTITY]({}, 'ipc-manage.eth', 'app-scoped'); + await ipcHandlers[IPC.SWARM_SET_FEED_IDENTITY]({}, 'ipc-manage.eth', 'app-scoped'); const withNewIdentity = await ipcHandlers[IPC.SWARM_CREATE_APP_SCOPED_IDENTITY]({}, 'ipc-manage.eth', { label: 'Second identity', diff --git a/src/main/swarm/origin-mutation-lock.js b/src/main/swarm/origin-mutation-lock.js new file mode 100644 index 00000000..b74bf783 --- /dev/null +++ b/src/main/swarm/origin-mutation-lock.js @@ -0,0 +1,25 @@ +const { normalizeOrigin } = require('../../shared/origin-utils'); + +const tails = new Map(); + +/** + * Serialize authority checks and mutations for one normalized origin. + * A rejected task never poisons the queue for later work. + */ +function withOriginLock(origin, task) { + const key = normalizeOrigin(origin); + const previous = tails.get(key) || Promise.resolve(); + const result = previous.then(task, task); + const settled = result.then(() => undefined, () => undefined); + tails.set(key, settled); + settled.then(() => { + if (tails.get(key) === settled) tails.delete(key); + }); + return result; +} + +function _resetForTests() { + tails.clear(); +} + +module.exports = { withOriginLock, _resetForTests }; diff --git a/src/main/swarm/origin-mutation-lock.test.js b/src/main/swarm/origin-mutation-lock.test.js new file mode 100644 index 00000000..c54f9dc0 --- /dev/null +++ b/src/main/swarm/origin-mutation-lock.test.js @@ -0,0 +1,52 @@ +const { withOriginLock, _resetForTests } = require('./origin-mutation-lock'); + +function deferred() { + let resolve; + const promise = new Promise((done) => { resolve = done; }); + return { promise, resolve }; +} + +beforeEach(() => { + _resetForTests(); +}); + +describe('origin mutation lock', () => { + test('serializes work for equivalent normalized origins', async () => { + const gate = deferred(); + const events = []; + const first = withOriginLock('bzz://app.eth/page', async () => { + events.push('first:start'); + await gate.promise; + events.push('first:end'); + }); + const second = withOriginLock('app.eth', () => { + events.push('second'); + }); + + await Promise.resolve(); + expect(events).toEqual(['first:start']); + gate.resolve(); + await Promise.all([first, second]); + expect(events).toEqual(['first:start', 'first:end', 'second']); + }); + + test('allows unrelated origins to proceed independently', async () => { + const gate = deferred(); + const events = []; + const first = withOriginLock('one.eth', () => gate.promise); + const second = withOriginLock('two.eth', () => events.push('two')); + + await second; + expect(events).toEqual(['two']); + gate.resolve(); + await first; + }); + + test('continues the queue after a rejected task', async () => { + const first = withOriginLock('app.eth', () => Promise.reject(new Error('expected'))); + const second = withOriginLock('app.eth', () => 'recovered'); + + await expect(first).rejects.toThrow('expected'); + await expect(second).resolves.toBe('recovered'); + }); +}); diff --git a/src/main/swarm/permission-manifests.js b/src/main/swarm/permission-manifests.js new file mode 100644 index 00000000..6e08eed4 --- /dev/null +++ b/src/main/swarm/permission-manifests.js @@ -0,0 +1,674 @@ +/** + * Main-process authority for bzz-hosted Swarm permission manifests. + * + * The renderer receives only display data and an opaque, short-lived token. + * Manifest bytes, persisted decisions, grant projection, and pruning stay here. + */ + +const { app, ipcMain } = require('electron'); +const crypto = require('crypto'); +const fs = require('fs'); +const path = require('path'); +const IPC = require('../../shared/ipc-channels'); +const { normalizeOrigin } = require('../../shared/origin-utils'); +const { handleBzzRequest } = require('./bzz-protocol'); +const permissions = require('./swarm-permissions'); +const feeds = require('./feed-store'); +const { withOriginLock } = require('./origin-mutation-lock'); + +const STORE_FILE = 'swarm-manifest-grants.json'; +const MAX_BYTES = 8 * 1024; +// The per-attempt fetch timeout in `bzz-protocol.js` is cleared the moment +// response headers arrive, so nothing bounds the body after that: a gateway +// that answers 200 and then stalls half-open would keep this read pending +// until undici's ~5 min default body timeout, and every non-public swarm +// method for the origin queues behind the cached check promise. Each read +// gets its own inactivity deadline — a slow-but-steady body keeps going, a +// stalled one is aborted and classified transient, like any dropped socket. +const BODY_IDLE_TIMEOUT_MS = 15_000; +const TOKEN_TTL_MS = 5 * 60 * 1000; +const MAX_RECEIPTS = 20; +const UNRESOLVED_BACKOFF_MS = [2_000, 10_000, 30_000, 60_000]; +const CAPABILITY_KEYS = ['publish', 'feeds', 'signing', 'messaging']; +const CAPABILITY_META = { + publish: { label: 'Publish content', detail: 'Use your postage stamps and bandwidth.' }, + feeds: { label: 'Manage feeds', detail: 'Create and update app feeds without repeated approval.' }, + signing: { label: 'Sign Swarm content', detail: 'Use an app-scoped publisher identity.' }, + messaging: { label: 'Send and receive messages', detail: 'Use PSS and GSOC messaging.' }, +}; +const PROJECTIONS = { + publish: ['connection', 'autoApprove.publish'], + feeds: ['connection', 'identity', 'feedGrant', 'autoApprove.feeds'], + signing: ['connection', 'identity', 'feedGrant', 'autoApprove.signing'], + messaging: ['connection', 'messagingGrant', 'autoApprove.messaging'], +}; + +let storeCache = null; +let storeLoadedFromBackup = false; +let faultInjector = null; +const tokens = new Map(); +const completedTokens = new Map(); +const unresolvedBackoff = new Map(); + +function getStorePath() { + return path.join(app.getPath('userData'), STORE_FILE); +} + +function emptyStore() { + return { version: 1, records: {} }; +} + +function loadStore() { + if (storeCache) return storeCache; + const filePath = getStorePath(); + if (!fs.existsSync(filePath)) { + storeCache = emptyStore(); + return storeCache; + } + try { + storeCache = { ...emptyStore(), ...JSON.parse(fs.readFileSync(filePath, 'utf8')) }; + } catch (err) { + const backupPath = `${filePath}.bak`; + try { + storeCache = { ...emptyStore(), ...JSON.parse(fs.readFileSync(backupPath, 'utf8')) }; + storeLoadedFromBackup = true; + console.warn('[PermissionManifests] Recovered state from backup:', err.message); + } catch { + console.error('[PermissionManifests] Failed to load state:', err); + throw err; + } + } + return storeCache; +} + +function saveStore() { + const filePath = getStorePath(); + const tempPath = `${filePath}.tmp`; + try { + if (!storeLoadedFromBackup && fs.existsSync(filePath)) { + fs.copyFileSync(filePath, `${filePath}.bak`); + } + fs.writeFileSync(tempPath, JSON.stringify(storeCache, null, 2), 'utf8'); + fs.renameSync(tempPath, filePath); + storeLoadedFromBackup = false; + } catch (err) { + storeCache = null; + throw err; + } +} + +function hasUnsafeText(value, maxLength) { + if (typeof value !== 'string' || Array.from(value).length > maxLength) return true; + return Array.from(value).some((character) => { + const codePoint = character.codePointAt(0); + return codePoint <= 0x1f + || (codePoint >= 0x7f && codePoint <= 0x9f) + || (codePoint >= 0x202a && codePoint <= 0x202e) + || codePoint === 0x2028 + || codePoint === 0x2029 + || (codePoint >= 0x2066 && codePoint <= 0x2069); + }); +} + +function assertExactKeys(value, allowed, label) { + if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error(`${label} must be an object`); + for (const key of Object.keys(value)) { + if (!allowed.includes(key)) throw new Error(`${label} contains unknown field: ${key}`); + } +} + +function validateManifest(value) { + assertExactKeys(value, ['schema', 'name', 'description', 'capabilities'], 'manifest'); + if (value.schema !== 'freedom-manifest/1') throw new Error('unsupported manifest schema'); + if (hasUnsafeText(value.name, 32) || value.name.trim() === '') throw new Error('invalid manifest name'); + if (value.description !== undefined && hasUnsafeText(value.description, 160)) throw new Error('invalid manifest description'); + assertExactKeys(value.capabilities, ['swarm'], 'capabilities'); + assertExactKeys(value.capabilities.swarm, CAPABILITY_KEYS, 'capabilities.swarm'); + if (Object.keys(value.capabilities.swarm).length === 0) throw new Error('capabilities.swarm must not be empty'); + + const capabilities = {}; + for (const key of CAPABILITY_KEYS) { + const row = value.capabilities.swarm[key]; + if (row === undefined) continue; + assertExactKeys(row, ['why'], `capability ${key}`); + if (hasUnsafeText(row.why, 140) || row.why.trim() === '') throw new Error(`invalid reason for ${key}`); + capabilities[key] = { why: row.why }; + } + return { + schema: value.schema, + name: value.name, + description: value.description || '', + capabilities, + }; +} + +// A body that starts arriving and then dies — bee restart, dropped socket — is +// a transport failure, indistinguishable in outcome from one that never got +// past the request. It must feed the `unresolved` backoff like any other +// transient hiccup, never the `invalid` path that prunes the origin's +// manifest-managed authority. Only bytes we actually hold and cannot accept +// (oversized, non-JSON, schema-violating) are `invalid`. +function transportError(err) { + const wrapped = new Error(err?.message || String(err)); + wrapped.transport = true; + return wrapped; +} + +// Resolves with `promise`, or rejects once `timeoutMs` passes with no answer. +// Applied per read, so the deadline is an inactivity one: every chunk that +// arrives restarts it. +function withIdleDeadline(promise, timeoutMs) { + let timer; + return Promise.race([ + promise, + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error('manifest body read stalled')), timeoutMs); + }), + ]).finally(() => clearTimeout(timer)); +} + +async function readLimitedBody(response, idleTimeoutMs = BODY_IDLE_TIMEOUT_MS) { + const reader = response.body?.getReader?.(); + if (!reader) { + let buffer; + try { + buffer = Buffer.from(await withIdleDeadline(response.arrayBuffer(), idleTimeoutMs)); + } + catch (err) { + throw transportError(err); + } + if (buffer.length > MAX_BYTES) throw new Error('manifest exceeds 8 KiB'); + return buffer; + } + const chunks = []; + let length = 0; + while (true) { + let chunk; + try { + chunk = await withIdleDeadline(reader.read(), idleTimeoutMs); + } + catch (err) { + await reader.cancel().catch(() => {}); + throw transportError(err); + } + if (chunk.done) break; + length += chunk.value.byteLength; + if (length > MAX_BYTES) { + await reader.cancel().catch(() => {}); + throw new Error('manifest exceeds 8 KiB'); + } + chunks.push(Buffer.from(chunk.value)); + } + return Buffer.concat(chunks); +} + +async function readLimitedJson(response) { + const raw = await readLimitedBody(response); + return { raw, value: JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(raw)) }; +} + +// Semantic fingerprint: schema plus sorted capability keys, per +// research/permission-manifest-design.md §6.1. It deliberately excludes the +// `why` texts — a wording-only redeploy must not re-ask for authority the +// user already granted. Receipt provenance is kept honest instead by binding +// each consent token to the `rawHash` of the bytes its sheet was built from +// (see `checkManifest`/`decideManifest`), so a receipt never pairs the +// wording one manifest showed with the hash of another. +function fingerprint(manifest) { + return crypto.createHash('sha256').update(JSON.stringify({ + schema: manifest.schema, + capabilities: Object.keys(manifest.capabilities).sort(), + })).digest('hex'); +} + +async function discover(committedUrl, fetchManifest = handleBzzRequest) { + let url; + try { + url = new URL(committedUrl); + } catch { + return { status: 'unsupported' }; + } + if (url.protocol !== 'bzz:') return { status: 'unsupported' }; + + const manifestUrl = `bzz://${url.host}/freedom-manifest.json`; + let response; + try { + response = await fetchManifest(new Request(manifestUrl)); + } catch (err) { + return { status: 'unresolved', error: err.message }; + } + if (response.status === 404) return { status: 'absent' }; + if (!response.ok) return { status: response.status >= 400 && response.status < 500 ? 'invalid' : 'unresolved' }; + try { + const { raw, value } = await readLimitedJson(response); + const manifest = validateManifest(value); + return { + status: 'found', + manifest, + rawHash: crypto.createHash('sha256').update(raw).digest('hex'), + fingerprint: fingerprint(manifest), + }; + } catch (err) { + return { status: err.transport ? 'unresolved' : 'invalid', error: err.message }; + } +} + +function currentProjectionValue(origin, key) { + if (key === 'connection') return !!permissions.getPermission(origin); + if (key === 'feedGrant') return feeds.hasFeedGrant(origin); + if (key === 'identity') return feeds.hasIdentityMode(origin); + if (key === 'messagingGrant') return permissions.hasMessagingGrant(origin); + if (key.startsWith('autoApprove.')) return permissions.getAutoApprove(origin, key.slice(12)); + return false; +} + +function applyProjectionValue(origin, key, enabled) { + if (key === 'connection') { + if (enabled && !permissions.getPermission(origin)) permissions.grantPermission(origin); + if (!enabled) permissions.revokePermission(origin, { source: 'manifest' }); + } else if (key === 'feedGrant') { + // `source: 'manifest'` keeps these projections out of the feed store's + // user-mutation notifications — a manifest applying its own grant must + // not detach the ownership it is establishing. + if (enabled) feeds.grantFeedAccess(origin, { source: 'manifest' }); + else feeds.revokeFeedAccess(origin, { source: 'manifest' }); + } else if (key === 'identity') { + if (enabled && !feeds.hasIdentityMode(origin)) { + feeds.createAppScopedIdentity(origin, { activate: true, source: 'manifest' }); + } + } else if (key === 'messagingGrant') { + if (enabled) permissions.grantMessaging(origin, { source: 'manifest' }); + else permissions.revokeMessaging(origin, { source: 'manifest' }); + } else if (key.startsWith('autoApprove.')) { + permissions.setAutoApprove(origin, key.slice(12), enabled, { source: 'manifest' }); + } +} + +function removeOwners(record, capabilities) { + const operations = []; + record.managed ||= {}; + for (const [projection, owners] of Object.entries(record.managed)) { + const remaining = owners.filter((owner) => !capabilities.includes(owner)); + if (remaining.length === 0) { + if (projection !== 'identity') operations.push({ projection, enabled: false }); + delete record.managed[projection]; + } else { + record.managed[projection] = remaining; + } + } + return operations.sort((left, right) => Number(left.projection === 'connection') - Number(right.projection === 'connection')); +} + +function addManagedCapability(origin, record, capability) { + const operations = []; + record.managed ||= {}; + for (const projection of PROJECTIONS[capability]) { + if (record.detached?.[projection]) continue; + const owners = record.managed[projection] || []; + if (owners.length > 0) { + if (!owners.includes(capability)) record.managed[projection] = [...owners, capability]; + continue; + } + if (!currentProjectionValue(origin, projection)) { + record.managed[projection] = [capability]; + operations.push({ projection, enabled: true }); + } + } + return operations; +} + +function runTransaction(origin, record, operations) { + const state = loadStore(); + state.pending = { origin, record, operations }; + saveStore(); + faultInjector?.('after-journal'); + for (const [index, operation] of operations.entries()) { + applyProjectionValue(origin, operation.projection, operation.enabled); + faultInjector?.(`after-operation:${index}:${operation.projection}`); + } + if (record) state.records[origin] = record; + else delete state.records[origin]; + delete state.pending; + faultInjector?.('before-commit'); + saveStore(); +} + +function recoverPending() { + const state = loadStore(); + if (!state.pending) return; + const { origin, record, operations } = state.pending; + for (const operation of operations) applyProjectionValue(origin, operation.projection, operation.enabled); + if (record) state.records[origin] = record; + else delete state.records[origin]; + delete state.pending; + saveStore(); +} + +function pruneRecord(origin) { + const state = loadStore(); + const record = state.records[origin]; + if (!record) return; + const operations = removeOwners(record, CAPABILITY_KEYS); + runTransaction(origin, null, operations); +} + +function detachManaged(origin, projection) { + recoverPending(); + const key = normalizeOrigin(origin); + if (projection === 'connection') { + disconnect(key); + return; + } + const state = loadStore(); + const record = state.records[key]; + if (!record) return; + record.detached ||= {}; + record.detached[projection] = true; + if (record.managed) delete record.managed[projection]; + record.revision = (record.revision || 0) + 1; + runTransaction(key, record, []); +} + +// `firstContact` is deliberately not compared: the first check of a fresh +// origin persists its observation, so a sibling tab checking a moment later +// sees a record and computes `false`. The outstanding token's value is the +// truthful one — the record it sees exists only because of that check — and +// keeping it is what still drops the observation on a denial. +function sameConsent(left, right) { + return left.origin === right.origin + && left.fingerprint === right.fingerprint + && left.baseRevision === right.baseRevision + && left.changed.length === right.changed.length + && left.changed.every((capability, index) => capability === right.changed[index]); +} + +/** + * Two tabs of the same app check concurrently, see identical state, and would + * otherwise each mint their own token off the same baseRevision. The user then + * answers two identical sheets, and the second decision fails the revision + * guard because the first one bumped the revision. One outstanding consent per + * (origin, manifest, state) instead: the second decide() replays the recorded + * result, and the renderer coalesces the duplicate sheet on the shared token. + */ +function outstandingToken(candidate) { + const now = Date.now(); + let match = null; + for (const [token, pending] of tokens) { + if (pending.expiresAt < now) { + tokens.delete(token); + continue; + } + if (!match && sameConsent(pending, candidate)) match = token; + } + return match; +} + +function buildConsentModel(origin, record, manifest, changed, removed) { + return { + origin, + name: manifest.name, + description: manifest.description, + changed: changed.map((key) => ({ key, ...CAPABILITY_META[key], why: manifest.capabilities[key].why })), + removed: removed.map((key) => ({ key, label: CAPABILITY_META[key].label })), + createsIdentity: changed.some((key) => ['feeds', 'signing'].includes(key)) && !feeds.hasIdentityMode(origin), + preservedIdentity: changed.some((key) => ['feeds', 'signing'].includes(key)) && feeds.hasIdentityMode(origin), + isUpdate: !!record, + }; +} + +async function checkManifest({ origin, committedUrl, eager = false }, deps = {}) { + recoverPending(); + const key = normalizeOrigin(origin); + const state = loadStore(); + const existing = state.records[key]; + const firstContact = !existing && !permissions.getPermission(key); + if (!existing && !eager) return { kind: 'legacy' }; + + const backoff = unresolvedBackoff.get(key); + if (backoff && Date.now() < backoff.retryAt) { + return existing + ? { kind: 'unresolved', retryAt: backoff.retryAt } + : { kind: 'legacy', reason: 'unresolved-backoff', retryAt: backoff.retryAt }; + } + + const found = await discover(committedUrl, deps.fetchManifest); + if (found.status === 'unresolved') { + const failures = (backoff?.failures || 0) + 1; + const delay = UNRESOLVED_BACKOFF_MS[Math.min(failures - 1, UNRESOLVED_BACKOFF_MS.length - 1)]; + const retryAt = Date.now() + delay; + unresolvedBackoff.set(key, { failures, retryAt }); + return existing ? { kind: 'unresolved', retryAt } : { kind: 'legacy', reason: 'unresolved', retryAt }; + } + unresolvedBackoff.delete(key); + // Defense-in-depth: bail before any prune if the origin and committedUrl + // don't agree, so origin A's record can never be pruned on origin B's + // manifest state. (Unreachable from today's renderer — both derive from the + // same displayUrl — but cheap to enforce here.) + if (normalizeOrigin(committedUrl) !== key) return { kind: 'legacy' }; + if (found.status !== 'found') { + if (existing) pruneRecord(key); + return { kind: 'legacy', pruned: !!existing, reason: found.status }; + } + + const manifest = found.manifest; + const nextKeys = Object.keys(manifest.capabilities); + const acknowledged = existing?.acknowledged || {}; + const removed = Object.keys(acknowledged).filter((capability) => !nextKeys.includes(capability)); + const additions = nextKeys.filter((capability) => !acknowledged[capability]); + + const record = structuredClone(existing || { managed: {}, acknowledged: {} }); + const removalOperations = removeOwners(record, removed); + for (const capability of removed) delete record.acknowledged[capability]; + record.observed = { fingerprint: found.fingerprint, rawHash: found.rawHash, capabilities: manifest.capabilities, checkedAt: Date.now() }; + record.app = { name: manifest.name, description: manifest.description }; + const satisfied = additions.filter((capability) => ( + PROJECTIONS[capability].every((projection) => currentProjectionValue(key, projection)) + )); + for (const capability of satisfied) { + record.acknowledged[capability] = { + decision: 'individual', + source: 'existing-grant', + decidedAt: Date.now(), + }; + } + const changed = additions.filter((capability) => !satisfied.includes(capability)); + record.revision = existing?.revision || 0; + if (removalOperations.length > 0 || removed.length > 0 || satisfied.length > 0) { + record.revision += 1; + runTransaction(key, record, removalOperations); + } + else { + // `state` was read before the discover() await. `saveStore()` serializes + // whatever `storeCache` is *now* — a concurrent operation on another + // origin that failed its save meanwhile has nulled it — so re-read the + // store and mutate that, exactly as `runTransaction` does. + const current = loadStore(); + current.records[key] = record; + saveStore(); + } + + if (changed.length === 0) return { kind: 'ready' }; + const pending = { + origin: key, + manifest, + fingerprint: found.fingerprint, + // The bytes this sheet's wording came from. `record.observed.rawHash` can + // move under an outstanding token (a wording-only redeploy re-checked by + // another tab updates it without bumping the revision), so the receipt + // must attest this hash, not the latest one. + rawHash: found.rawHash, + baseRevision: record.revision, + firstContact, + changed, + expiresAt: Date.now() + TOKEN_TTL_MS, + }; + let token = outstandingToken(pending); + if (!token) { + token = crypto.randomBytes(24).toString('base64url'); + tokens.set(token, pending); + } else { + // A fresh tab just coalesced onto this consent — extend the window so the + // shared token doesn't expire on the earliest requester's clock (a sheet + // reused 4.5 min into a 5 min TTL would otherwise die 30 s later). + tokens.get(token).expiresAt = pending.expiresAt; + } + return { kind: 'consent', token, model: buildConsentModel(key, existing, manifest, changed, removed) }; +} + +function decideManifest(token, outcome) { + recoverPending(); + if (completedTokens.has(token)) return completedTokens.get(token); + const pending = tokens.get(token); + if (!pending || pending.expiresAt < Date.now()) throw new Error('Manifest consent expired'); + if (!['allow', 'individual', 'deny'].includes(outcome)) throw new Error('Invalid manifest decision'); + + const state = loadStore(); + if (state.records[pending.origin]?.observed?.fingerprint !== pending.fingerprint) { + tokens.delete(token); + throw new Error('Manifest consent is stale'); + } + if ((state.records[pending.origin]?.revision || 0) !== pending.baseRevision) { + tokens.delete(token); + throw new Error('Manifest consent is stale'); + } + const record = structuredClone(state.records[pending.origin] || { managed: {}, acknowledged: {} }); + const operations = []; + if (outcome !== 'deny') { + if (outcome === 'individual') { + record.detached ||= {}; + record.detached.connection = true; + delete record.managed?.connection; + // Journaled like every other mutation in this flow rather than applied + // ahead of `runTransaction`: a crash between the grant and the journal + // would otherwise leave the origin connected with no record, receipt or + // acknowledgement, and nothing for `recoverPending` to replay. + if (!permissions.getPermission(pending.origin)) { + operations.push({ projection: 'connection', enabled: true }); + } + } + for (const capability of pending.changed) { + record.acknowledged[capability] = { + decision: outcome === 'allow' ? 'managed' : 'individual', + source: 'sheet', + whyShown: pending.manifest.capabilities[capability].why, + decidedAt: Date.now(), + }; + if (outcome === 'allow') operations.push(...addManagedCapability(pending.origin, record, capability)); + else operations.push(...removeOwners(record, [capability])); + } + record.receipts ||= []; + record.receipts.push({ + decidedAt: Date.now(), + outcome: outcome === 'allow' ? 'managed' : 'individual', + originShown: pending.origin, + manifestNameShown: pending.manifest.name, + manifestDescriptionShown: pending.manifest.description, + rows: pending.changed.map((capability) => ({ + capability, + browserLabelVersion: 1, + whyShown: pending.manifest.capabilities[capability].why, + })), + rawHash: pending.rawHash, + }); + record.receipts = record.receipts.slice(-MAX_RECEIPTS); + record.revision = pending.baseRevision + 1; + runTransaction(pending.origin, record, operations); + } else if (pending.firstContact) { + runTransaction(pending.origin, null, []); + } + const result = { allowed: outcome !== 'deny', mode: outcome }; + tokens.delete(token); + completedTokens.set(token, result); + if (completedTokens.size > 100) completedTokens.delete(completedTokens.keys().next().value); + return result; +} + +function useIndividual(origin, capability) { + recoverPending(); + if (!CAPABILITY_KEYS.includes(capability)) throw new Error('Unknown capability'); + const key = normalizeOrigin(origin); + const record = structuredClone(getRecord(key)); + if (!record?.acknowledged?.[capability]) return false; + record.detached ||= {}; + record.detached.connection = true; + delete record.managed?.connection; + const operations = removeOwners(record, [capability]); + record.acknowledged[capability] = { + ...record.acknowledged[capability], + decision: 'individual', + decidedAt: Date.now(), + }; + record.revision = (record.revision || 0) + 1; + runTransaction(key, record, operations); + return true; +} + +function disconnect(origin) { + recoverPending(); + const key = normalizeOrigin(origin); + runTransaction(key, null, [ + { projection: 'feedGrant', enabled: false }, + { projection: 'connection', enabled: false }, + ]); + return true; +} + +function getRecord(origin) { + recoverPending(); + return loadStore().records[normalizeOrigin(origin)] || null; +} + +function registerPermissionManifestIpc() { + recoverPending(); + // Both stores that hold manifest-projected state report user mutations, so + // a grant the user changes by hand (revoking feed access, switching the + // publisher identity) drops manifest ownership instead of leaving a record + // that claims it — and can re-assert it on the next projection. + permissions.onManifestMutation(detachManaged); + feeds.onManifestMutation(detachManaged); + ipcMain.handle(IPC.SWARM_MANIFEST_CHECK, (_event, request) => ( + withOriginLock(request.origin, () => checkManifest(request)) + )); + ipcMain.handle(IPC.SWARM_MANIFEST_DECIDE, (_event, { token, outcome }) => { + const origin = tokens.get(token)?.origin || `consent-token:${token}`; + return withOriginLock(origin, () => decideManifest(token, outcome)); + }); + ipcMain.handle(IPC.SWARM_MANIFEST_GET, (_event, origin) => ( + withOriginLock(origin, () => getRecord(origin)) + )); + ipcMain.handle(IPC.SWARM_MANIFEST_USE_INDIVIDUAL, (_event, { origin, capability }) => ( + withOriginLock(origin, () => useIndividual(origin, capability)) + )); + ipcMain.handle(IPC.SWARM_MANIFEST_DISCONNECT, (_event, origin) => ( + withOriginLock(origin, () => disconnect(origin)) + )); + console.log('[PermissionManifests] IPC handlers registered'); +} + +function _resetForTests() { + storeCache = null; + storeLoadedFromBackup = false; + tokens.clear(); + completedTokens.clear(); + unresolvedBackoff.clear(); + faultInjector = null; +} + +function _setFaultInjectorForTests(injector) { + faultInjector = injector; +} + +module.exports = { + BODY_IDLE_TIMEOUT_MS, + validateManifest, + discover, + checkManifest, + decideManifest, + detachManaged, + disconnect, + getRecord, + useIndividual, + registerPermissionManifestIpc, + _resetForTests, + _setFaultInjectorForTests, +}; diff --git a/src/main/swarm/permission-manifests.recovery.test.js b/src/main/swarm/permission-manifests.recovery.test.js new file mode 100644 index 00000000..cb66be91 --- /dev/null +++ b/src/main/swarm/permission-manifests.recovery.test.js @@ -0,0 +1,206 @@ +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +jest.mock('electron', () => ({ + app: { getPath: jest.fn() }, + ipcMain: { handle: jest.fn() }, +})); + +jest.mock('electron-log', () => ({ + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), +})); + +jest.mock('../identity-manager', () => ({ + getDerivedKeys: jest.fn(() => ({ + beeWallet: { address: '0xbee', privateKey: '0xkey' }, + })), + getPublisherKey: jest.fn(async (index) => ({ + address: `0xpublisher${index}`, + privateKey: `0xpublisherkey${index}`, + })), + getDerivedWallets: jest.fn(async () => []), +})); + +jest.mock('./bzz-protocol', () => ({ handleBzzRequest: jest.fn() })); + +const { app } = require('electron'); +const permissions = require('./swarm-permissions'); +const feeds = require('./feed-store'); +const manifests = require('./permission-manifests'); + +const ORIGIN = 'recovery.eth'; +const ALL_CAPABILITIES = { + publish: { why: 'Publish content' }, + feeds: { why: 'Update feeds' }, + signing: { why: 'Sign updates' }, + messaging: { why: 'Exchange messages' }, +}; + +let tempDir; + +function responseForManifest() { + return new Response(JSON.stringify({ + schema: 'freedom-manifest/1', + name: 'Recovery app', + capabilities: { swarm: ALL_CAPABILITIES }, + })); +} + +async function createConsent() { + return manifests.checkManifest({ + origin: ORIGIN, + committedUrl: `bzz://${ORIGIN}/`, + eager: true, + }, { fetchManifest: async () => responseForManifest() }); +} + +function simulateRestart() { + manifests._resetForTests(); + permissions._resetCache(); + feeds._resetCache(); +} + +function expectFullyRecovered() { + const record = manifests.getRecord(ORIGIN); + const permission = permissions.getPermission(ORIGIN); + expect(record.acknowledged).toMatchObject({ + publish: { decision: 'managed' }, + feeds: { decision: 'managed' }, + signing: { decision: 'managed' }, + messaging: { decision: 'managed' }, + }); + expect(permission.autoApprove).toEqual({ + publish: true, + feeds: true, + signing: true, + messaging: true, + }); + expect(permissions.hasMessagingGrant(ORIGIN)).toBe(true); + expect(feeds.hasIdentityMode(ORIGIN)).toBe(true); + expect(feeds.hasFeedGrant(ORIGIN)).toBe(true); + expect(record.managed.feedGrant).toEqual(['feeds', 'signing']); +} + +function failRename(fileName, occurrence) { + const destination = path.join(tempDir, fileName); + const originalRename = fs.renameSync.bind(fs); + let seen = 0; + return jest.spyOn(fs, 'renameSync').mockImplementation((from, to) => { + if (to === destination) { + seen += 1; + if (seen === occurrence) throw new Error(`injected ${fileName} write failure ${occurrence}`); + } + return originalRename(from, to); + }); +} + +beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'manifest-recovery-')); + app.getPath.mockReturnValue(tempDir); + simulateRestart(); + permissions.onRevoke(null); +}); + +afterEach(() => { + jest.restoreAllMocks(); + fs.rmSync(tempDir, { recursive: true, force: true }); +}); + +describe('permission manifest durable recovery with real stores', () => { + test('retries safely when the write-ahead journal itself cannot be persisted', async () => { + const consent = await createConsent(); + const rename = failRename('swarm-manifest-grants.json', 1); + + expect(() => manifests.decideManifest(consent.token, 'allow')).toThrow('injected'); + rename.mockRestore(); + expect(manifests.decideManifest(consent.token, 'allow')).toEqual({ allowed: true, mode: 'allow' }); + expectFullyRecovered(); + }); + + test.each([1, 2, 3, 4, 5, 6])( + 'recovers after permission-store atomic write %i fails', + async (occurrence) => { + const consent = await createConsent(); + const rename = failRename('swarm-permissions.json', occurrence); + + expect(() => manifests.decideManifest(consent.token, 'allow')).toThrow('injected'); + rename.mockRestore(); + simulateRestart(); + expectFullyRecovered(); + } + ); + + test.each([1, 2])('recovers after feed-store atomic write %i fails', async (occurrence) => { + const consent = await createConsent(); + const rename = failRename('swarm-feeds.json', occurrence); + + expect(() => manifests.decideManifest(consent.token, 'allow')).toThrow('injected'); + rename.mockRestore(); + simulateRestart(); + expectFullyRecovered(); + }); + + test('recovers when the final manifest commit cannot replace the journal', async () => { + const consent = await createConsent(); + const rename = failRename('swarm-manifest-grants.json', 2); + + expect(() => manifests.decideManifest(consent.token, 'allow')).toThrow('injected'); + rename.mockRestore(); + simulateRestart(); + expectFullyRecovered(); + }); + + test('replays the connection grant an individual decision makes', async () => { + const consent = await createConsent(); + // The `individual` outcome connects the origin without managing + // anything. That grant is part of the decision, so it has to be journaled + // with it: a stop right after the journal must still leave the origin + // connected once recovery replays, and must not have connected it before + // the journal existed (that is the state nothing can replay or undo). + manifests._setFaultInjectorForTests((point) => { + if (point === 'after-journal') throw new Error('simulated crash at after-journal'); + }); + + expect(() => manifests.decideManifest(consent.token, 'individual')).toThrow('simulated crash'); + expect(permissions.getPermission(ORIGIN)).toBeNull(); + + manifests._setFaultInjectorForTests(null); + simulateRestart(); + + const record = manifests.getRecord(ORIGIN); + expect(permissions.getPermission(ORIGIN)).not.toBeNull(); + expect(record.acknowledged).toMatchObject({ + publish: { decision: 'individual' }, + feeds: { decision: 'individual' }, + signing: { decision: 'individual' }, + messaging: { decision: 'individual' }, + }); + expect(record.detached.connection).toBe(true); + expect(record.managed.connection).toBeUndefined(); + }); + + test.each([ + 'after-journal', + 'after-operation:0:connection', + 'after-operation:1:autoApprove.publish', + 'after-operation:2:identity', + 'after-operation:3:feedGrant', + 'after-operation:4:autoApprove.feeds', + 'after-operation:5:autoApprove.signing', + 'after-operation:6:messagingGrant', + 'after-operation:7:autoApprove.messaging', + 'before-commit', + ])('recovers after a simulated process stop at %s', async (boundary) => { + const consent = await createConsent(); + manifests._setFaultInjectorForTests((point) => { + if (point === boundary) throw new Error(`simulated crash at ${point}`); + }); + + expect(() => manifests.decideManifest(consent.token, 'allow')).toThrow('simulated crash'); + simulateRestart(); + expectFullyRecovered(); + }); +}); diff --git a/src/main/swarm/permission-manifests.test.js b/src/main/swarm/permission-manifests.test.js new file mode 100644 index 00000000..6dc3e7c6 --- /dev/null +++ b/src/main/swarm/permission-manifests.test.js @@ -0,0 +1,628 @@ +const crypto = require('crypto'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const ipcHandlers = {}; +jest.mock('electron', () => ({ + app: { getPath: jest.fn() }, + ipcMain: { handle: (channel, handler) => { ipcHandlers[channel] = handler; } }, +})); + +const mockPermissionState = {}; +jest.mock('./swarm-permissions', () => ({ + getPermission: jest.fn((origin) => mockPermissionState[origin] || null), + grantPermission: jest.fn((origin) => { + mockPermissionState[origin] = { origin, autoApprove: {} }; + return mockPermissionState[origin]; + }), + revokePermission: jest.fn((origin) => { delete mockPermissionState[origin]; }), + getAutoApprove: jest.fn((origin, type) => mockPermissionState[origin]?.autoApprove?.[type] === true), + setAutoApprove: jest.fn((origin, type, enabled) => { + mockPermissionState[origin].autoApprove[type] = enabled; + return true; + }), + hasMessagingGrant: jest.fn((origin) => mockPermissionState[origin]?.messaging === true), + grantMessaging: jest.fn((origin) => { mockPermissionState[origin].messaging = true; }), + revokeMessaging: jest.fn((origin) => { delete mockPermissionState[origin].messaging; }), + onManifestMutation: jest.fn(), +})); + +const mockFeedState = {}; +jest.mock('./feed-store', () => ({ + hasIdentityMode: jest.fn((origin) => mockFeedState[origin]?.identity === true), + createAppScopedIdentity: jest.fn((origin) => { + mockFeedState[origin] = { ...(mockFeedState[origin] || {}), identity: true }; + }), + hasFeedGrant: jest.fn((origin) => mockFeedState[origin]?.granted === true), + grantFeedAccess: jest.fn((origin) => { + mockFeedState[origin] = { ...(mockFeedState[origin] || {}), granted: true }; + }), + revokeFeedAccess: jest.fn((origin) => { + mockFeedState[origin] = { ...(mockFeedState[origin] || {}), granted: false }; + }), + onManifestMutation: jest.fn(), +})); + +jest.mock('./bzz-protocol', () => ({ handleBzzRequest: jest.fn() })); + +const { app } = require('electron'); +const { + BODY_IDLE_TIMEOUT_MS, + validateManifest, + discover, + checkManifest, + decideManifest, + detachManaged, + disconnect, + getRecord, + useIndividual, + registerPermissionManifestIpc, + _resetForTests, +} = require('./permission-manifests'); +const { handleBzzRequest: mockHandleBzzRequest } = require('./bzz-protocol'); +const mockFeeds = require('./feed-store'); + +let tempDir; + +function manifest(capabilities) { + return { + schema: 'freedom-manifest/1', + name: 'Test app', + description: 'Exercises manifests', + capabilities: { swarm: capabilities }, + }; +} + +function responseFor(value, status = 200) { + return new Response(status === 200 ? JSON.stringify(value) : '', { + status, + headers: { 'content-type': 'application/json' }, + }); +} + +// A 200 whose body dies partway through: bee restart, dropped socket. The +// headers already landed, so only the read fails. +function severedResponse(prefix = '{"schema":"freedom-mani') { + return new Response(new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(prefix)); + controller.error(new Error('socket hang up')); + }, + }), { status: 200, headers: { 'content-type': 'application/json' } }); +} + +beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'permission-manifests-')); + app.getPath.mockReturnValue(tempDir); + for (const key of Object.keys(mockPermissionState)) delete mockPermissionState[key]; + for (const key of Object.keys(mockFeedState)) delete mockFeedState[key]; + _resetForTests(); + jest.clearAllMocks(); +}); + +afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); +}); + +describe('permission manifests', () => { + test('strictly validates schema, fields, and display text', () => { + expect(validateManifest(manifest({ publish: { why: 'Publish releases' } })).capabilities) + .toEqual({ publish: { why: 'Publish releases' } }); + expect(() => validateManifest({ ...manifest({}), extra: true })).toThrow('unknown field'); + expect(() => validateManifest(manifest({}))).toThrow('must not be empty'); + expect(() => validateManifest(manifest({ publish: { why: 'safe\u202Ehidden' } }))).toThrow('invalid reason'); + }); + + test('distinguishes absence, transient failure, and a valid manifest', async () => { + await expect(discover('bzz://app.eth/', async () => responseFor({}, 404))) + .resolves.toEqual({ status: 'absent' }); + await expect(discover('bzz://app.eth/', async () => responseFor({}, 503))) + .resolves.toEqual({ status: 'unresolved' }); + const result = await discover( + 'bzz://app.eth/', + async () => responseFor(manifest({ publish: { why: 'Publish releases' } })) + ); + expect(result).toMatchObject({ status: 'found', manifest: { name: 'Test app' } }); + }); + + test('rejects non-JSON and streams no more than 8 KiB', async () => { + await expect(discover('bzz://app.eth/', async () => new Response('not json'))) + .resolves.toMatchObject({ status: 'invalid' }); + await expect(discover('bzz://app.eth/', async () => new Response('x'.repeat(8193)))) + .resolves.toMatchObject({ status: 'invalid', error: 'manifest exceeds 8 KiB' }); + }); + + test('treats a body severed mid-stream as transient, not as a bad manifest', async () => { + await expect(discover('bzz://app.eth/', async () => severedResponse())) + .resolves.toMatchObject({ status: 'unresolved' }); + }); + + test('aborts a body that stalls after the headers instead of hanging the origin', async () => { + jest.useFakeTimers(); + try { + let cancelled = false; + // 200 OK, one chunk, then a half-open socket: no more bytes, no end. + // The fetch attempt timer is long gone by now (it is cleared once the + // headers arrive), so only the read's own deadline can end this. + const stalled = new Response(new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('{"schema":"freedom-mani')); + }, + cancel() { cancelled = true; }, + }), { status: 200, headers: { 'content-type': 'application/json' } }); + + const discovery = discover('bzz://app.eth/', async () => stalled); + await jest.advanceTimersByTimeAsync(BODY_IDLE_TIMEOUT_MS + 1_000); + + // Transient, so the caller backs off and keeps existing authority — + // never `invalid`, which would prune it. + await expect(discovery).resolves.toMatchObject({ status: 'unresolved' }); + expect(cancelled).toBe(true); + } finally { + jest.useRealTimers(); + } + }); + + test('keeps reading a slow but still-arriving body', async () => { + jest.useFakeTimers(); + try { + const body = JSON.stringify(manifest({ publish: { why: 'Publish releases' } })); + let controller; + const trickled = new Response(new ReadableStream({ + start(streamController) { + controller = streamController; + controller.enqueue(new TextEncoder().encode(body.slice(0, 10))); + }, + }), { status: 200, headers: { 'content-type': 'application/json' } }); + + const discovery = discover('bzz://app.eth/', async () => trickled); + // Each chunk lands inside the window and restarts it, so a transfer + // that is merely slow must survive well past a single deadline. + for (let offset = 10; offset < body.length; offset += 10) { + await jest.advanceTimersByTimeAsync(BODY_IDLE_TIMEOUT_MS - 1_000); + controller.enqueue(new TextEncoder().encode(body.slice(offset, offset + 10))); + } + controller.close(); + await jest.advanceTimersByTimeAsync(0); + + await expect(discovery).resolves.toMatchObject({ status: 'found' }); + } finally { + jest.useRealTimers(); + } + }); + + test('projects an allow decision and creates identity metadata before the feed grant', async () => { + const fetchManifest = async () => responseFor(manifest({ + feeds: { why: 'Update the app feed' }, + signing: { why: 'Sign app updates' }, + })); + const check = await checkManifest({ + origin: 'app.eth', + committedUrl: 'bzz://app.eth/', + eager: true, + }, { fetchManifest }); + + expect(check.kind).toBe('consent'); + expect(check.model.createsIdentity).toBe(true); + expect(decideManifest(check.token, 'allow')).toEqual({ allowed: true, mode: 'allow' }); + expect(mockPermissionState['app.eth'].autoApprove).toMatchObject({ feeds: true, signing: true }); + expect(mockFeedState['app.eth']).toEqual({ identity: true, granted: true }); + expect(getRecord('app.eth').managed.feedGrant).toEqual(['feeds', 'signing']); + }); + + test('persists individual approvals without batch flags', async () => { + const check = await checkManifest({ + origin: 'app.eth', + committedUrl: 'bzz://app.eth/', + eager: true, + }, { fetchManifest: async () => responseFor(manifest({ messaging: { why: 'Receive updates' } })) }); + + decideManifest(check.token, 'individual'); + expect(mockPermissionState['app.eth']).toBeDefined(); + expect(mockPermissionState['app.eth'].messaging).toBeUndefined(); + expect(getRecord('app.eth').acknowledged.messaging.decision).toBe('individual'); + expect(getRecord('app.eth').detached.connection).toBe(true); + }); + + test('does not persist a rejected first-contact observation', async () => { + const check = await checkManifest({ + origin: 'app.eth', + committedUrl: 'bzz://app.eth/', + eager: true, + }, { fetchManifest: async () => responseFor(manifest({ publish: { why: 'Publish releases' } })) }); + decideManifest(check.token, 'deny'); + expect(getRecord('app.eth')).toBeNull(); + expect(mockPermissionState['app.eth']).toBeUndefined(); + }); + + test('silently acknowledges a complete existing user-owned projection', async () => { + mockPermissionState['app.eth'] = { origin: 'app.eth', autoApprove: { publish: true } }; + const check = await checkManifest({ + origin: 'app.eth', + committedUrl: 'bzz://app.eth/', + eager: true, + }, { fetchManifest: async () => responseFor(manifest({ publish: { why: 'Publish releases' } })) }); + + expect(check).toEqual({ kind: 'ready' }); + expect(getRecord('app.eth').acknowledged.publish).toMatchObject({ + decision: 'individual', + source: 'existing-grant', + }); + expect(getRecord('app.eth').managed).toEqual({}); + }); + + test('replays a completed token result and ignores wording-only redeploys', async () => { + const first = await checkManifest({ + origin: 'app.eth', + committedUrl: 'bzz://app.eth/', + eager: true, + }, { fetchManifest: async () => responseFor(manifest({ publish: { why: 'First wording' } })) }); + const decision = decideManifest(first.token, 'allow'); + expect(decideManifest(first.token, 'allow')).toEqual(decision); + + const wordingOnly = await checkManifest({ + origin: 'app.eth', + committedUrl: 'bzz://app.eth/', + eager: true, + }, { fetchManifest: async () => responseFor(manifest({ publish: { why: 'New wording' } })) }); + expect(wordingOnly).toEqual({ kind: 'ready' }); + expect(getRecord('app.eth').acknowledged.publish.whyShown).toBe('First wording'); + }); + + test('a receipt attests the bytes whose wording the sheet actually showed', async () => { + const request = { origin: 'app.eth', committedUrl: 'bzz://app.eth/', eager: true }; + const shown = manifest({ publish: { why: 'First wording' } }); + const consent = await checkManifest(request, { fetchManifest: async () => responseFor(shown) }); + + // A wording-only redeploy re-checked by a sibling tab while the sheet is + // still open: same capabilities, so the token stays valid, but + // `observed.rawHash` moves on without a revision bump. + await checkManifest(request, { + fetchManifest: async () => responseFor(manifest({ publish: { why: 'New wording' } })), + }); + decideManifest(consent.token, 'allow'); + + const record = getRecord('app.eth'); + const receipt = record.receipts.at(-1); + expect(receipt.rows[0].whyShown).toBe('First wording'); + // Receipt hash and receipt wording come from the same manifest — it must + // not attest text that is not in the bytes it hashes. + expect(receipt.rawHash).toBe(crypto.createHash('sha256').update(JSON.stringify(shown)).digest('hex')); + expect(record.observed.rawHash).not.toBe(receipt.rawHash); + }); + + test('re-reads the store after discovery rather than saving a pre-await snapshot', async () => { + const request = { origin: 'app.eth', committedUrl: 'bzz://app.eth/', eager: true }; + const fetchManifest = async () => responseFor(manifest({ publish: { why: 'Publish releases' } })); + const first = await checkManifest(request, { fetchManifest }); + decideManifest(first.token, 'allow'); + + // A concurrent operation on another origin fails its atomic write while + // discovery is in flight, which drops the module's in-memory store. The + // no-mutation branch must not serialize the snapshot it read before the + // await — that writes `null` over the whole store. + const recheck = await checkManifest(request, { + fetchManifest: async () => { + const blocked = path.join(tempDir, 'swarm-manifest-grants.json.tmp'); + fs.mkdirSync(blocked); + expect(() => disconnect('other.eth')).toThrow(); + fs.rmSync(blocked, { recursive: true }); + return fetchManifest(); + }, + }); + + expect(recheck).toEqual({ kind: 'ready' }); + expect(getRecord('app.eth')).not.toBeNull(); + expect(getRecord('app.eth').acknowledged.publish.decision).toBe('managed'); + const onDisk = JSON.parse(fs.readFileSync(path.join(tempDir, 'swarm-manifest-grants.json'), 'utf8')); + expect(Object.keys(onDisk.records)).toEqual(['app.eth']); + }); + + test('manifest-sourced feed projections never report themselves as user mutations', async () => { + registerPermissionManifestIpc(); + // Both stores that hold projected state report user mutations, so a + // hand-made change detaches manifest ownership. + expect(mockFeeds.onManifestMutation).toHaveBeenCalledWith(detachManaged); + + const check = await checkManifest({ + origin: 'app.eth', + committedUrl: 'bzz://app.eth/', + eager: true, + }, { fetchManifest: async () => responseFor(manifest({ feeds: { why: 'Update feed' } })) }); + decideManifest(check.token, 'allow'); + + expect(mockFeeds.grantFeedAccess).toHaveBeenCalledWith('app.eth', { source: 'manifest' }); + expect(mockFeeds.createAppScopedIdentity).toHaveBeenCalledWith('app.eth', { activate: true, source: 'manifest' }); + expect(mockFeeds.revokeFeedAccess).not.toHaveBeenCalled(); + + disconnect('app.eth'); + expect(mockFeeds.revokeFeedAccess).toHaveBeenCalledWith('app.eth', { source: 'manifest' }); + }); + + test('settings downgrade removes managed flags but keeps the base connection', async () => { + const check = await checkManifest({ + origin: 'app.eth', + committedUrl: 'bzz://app.eth/', + eager: true, + }, { fetchManifest: async () => responseFor(manifest({ publish: { why: 'Publish releases' } })) }); + decideManifest(check.token, 'allow'); + + expect(useIndividual('app.eth', 'publish')).toBe(true); + expect(mockPermissionState['app.eth']).toBeDefined(); + expect(mockPermissionState['app.eth'].autoApprove.publish).toBe(false); + expect(getRecord('app.eth').acknowledged.publish.decision).toBe('individual'); + }); + + test('manual toggles detach provenance so a later diff cannot resurrect them', async () => { + const first = await checkManifest({ + origin: 'app.eth', + committedUrl: 'bzz://app.eth/', + eager: true, + }, { fetchManifest: async () => responseFor(manifest({ publish: { why: 'Publish releases' } })) }); + decideManifest(first.token, 'allow'); + mockPermissionState['app.eth'].autoApprove.publish = false; + detachManaged('app.eth', 'autoApprove.publish'); + + const changed = await checkManifest({ + origin: 'app.eth', + committedUrl: 'bzz://app.eth/', + eager: true, + }, { fetchManifest: async () => responseFor(manifest({ + publish: { why: 'Publish releases' }, + messaging: { why: 'Receive updates' }, + })) }); + decideManifest(changed.token, 'allow'); + expect(mockPermissionState['app.eth'].autoApprove.publish).toBe(false); + expect(getRecord('app.eth').detached['autoApprove.publish']).toBe(true); + }); + + test('rejects an opaque decision token after a newer manifest was observed', async () => { + const oldCheck = await checkManifest({ + origin: 'app.eth', + committedUrl: 'bzz://app.eth/', + eager: true, + }, { fetchManifest: async () => responseFor(manifest({ publish: { why: 'Old reason' } })) }); + await checkManifest({ + origin: 'app.eth', + committedUrl: 'bzz://app.eth/', + eager: true, + }, { fetchManifest: async () => responseFor(manifest({ + publish: { why: 'Old reason' }, + feeds: { why: 'Update feed' }, + })) }); + + expect(() => decideManifest(oldCheck.token, 'allow')).toThrow('stale'); + }); + + test('recovers a truncated primary store from the journaled backup', async () => { + const check = await checkManifest({ + origin: 'app.eth', + committedUrl: 'bzz://app.eth/', + eager: true, + }, { fetchManifest: async () => responseFor(manifest({ publish: { why: 'Publish releases' } })) }); + decideManifest(check.token, 'allow'); + fs.writeFileSync(path.join(tempDir, 'swarm-manifest-grants.json'), '{truncated', 'utf8'); + _resetForTests(); + + expect(getRecord('app.eth').acknowledged.publish.decision).toBe('managed'); + expect(mockPermissionState['app.eth'].autoApprove.publish).toBe(true); + }); + + test('a definitive disappearance prunes managed authority but preserves identity metadata', async () => { + const first = await checkManifest({ + origin: 'app.eth', + committedUrl: 'bzz://app.eth/', + eager: true, + }, { fetchManifest: async () => responseFor(manifest({ feeds: { why: 'Update feed' } })) }); + decideManifest(first.token, 'allow'); + + const result = await checkManifest({ + origin: 'app.eth', + committedUrl: 'bzz://app.eth/', + eager: false, + }, { fetchManifest: async () => responseFor({}, 404) }); + expect(result).toMatchObject({ kind: 'legacy', pruned: true }); + expect(mockPermissionState['app.eth']).toBeUndefined(); + expect(mockFeedState['app.eth']).toEqual({ identity: true, granted: false }); + expect(getRecord('app.eth')).toBeNull(); + }); + + test('applies removals before a mixed-diff rejection and never applies the addition', async () => { + const initial = await checkManifest({ + origin: 'app.eth', + committedUrl: 'bzz://app.eth/', + eager: true, + }, { fetchManifest: async () => responseFor(manifest({ + publish: { why: 'Publish releases' }, + feeds: { why: 'Update feed' }, + })) }); + decideManifest(initial.token, 'allow'); + + const update = await checkManifest({ + origin: 'app.eth', + committedUrl: 'bzz://app.eth/', + eager: false, + }, { fetchManifest: async () => responseFor(manifest({ + feeds: { why: 'Update feed' }, + messaging: { why: 'Receive updates' }, + })) }); + + expect(mockPermissionState['app.eth'].autoApprove.publish).toBe(false); + expect(mockPermissionState['app.eth'].autoApprove.feeds).toBe(true); + decideManifest(update.token, 'deny'); + expect(mockPermissionState['app.eth'].messaging).toBeUndefined(); + expect(getRecord('app.eth').acknowledged.publish).toBeUndefined(); + }); + + test('keeps managed grants during transient discovery failure', async () => { + const initial = await checkManifest({ + origin: 'app.eth', + committedUrl: 'bzz://app.eth/', + eager: true, + }, { fetchManifest: async () => responseFor(manifest({ publish: { why: 'Publish releases' } })) }); + decideManifest(initial.token, 'allow'); + + const result = await checkManifest({ + origin: 'app.eth', + committedUrl: 'bzz://app.eth/', + eager: false, + }, { fetchManifest: async () => responseFor({}, 503) }); + expect(result).toMatchObject({ kind: 'unresolved', retryAt: expect.any(Number) }); + expect(mockPermissionState['app.eth'].autoApprove.publish).toBe(true); + expect(getRecord('app.eth')).not.toBeNull(); + }); + + test('keeps managed grants when the manifest body dies mid-stream', async () => { + const initial = await checkManifest({ + origin: 'app.eth', + committedUrl: 'bzz://app.eth/', + eager: true, + }, { fetchManifest: async () => responseFor(manifest({ + publish: { why: 'Publish releases' }, + feeds: { why: 'Update feed' }, + })) }); + decideManifest(initial.token, 'allow'); + expect(mockFeedState['app.eth'].granted).toBe(true); + + const result = await checkManifest({ + origin: 'app.eth', + committedUrl: 'bzz://app.eth/', + eager: false, + }, { fetchManifest: async () => severedResponse() }); + + expect(result).toMatchObject({ kind: 'unresolved', retryAt: expect.any(Number) }); + expect(mockPermissionState['app.eth'].autoApprove.publish).toBe(true); + expect(mockPermissionState['app.eth'].autoApprove.feeds).toBe(true); + expect(mockFeedState['app.eth'].granted).toBe(true); + expect(getRecord('app.eth')).not.toBeNull(); + }); + + test('backs off unresolved discovery across rapid committed navigations', async () => { + const realNow = Date.now; + let now = 10_000; + Date.now = () => now; + try { + const fetchManifest = jest.fn().mockResolvedValue(responseFor({}, 503)); + const first = await checkManifest({ + origin: 'app.eth', + committedUrl: 'bzz://app.eth/', + eager: true, + }, { fetchManifest }); + const second = await checkManifest({ + origin: 'app.eth', + committedUrl: 'bzz://app.eth/', + eager: true, + }, { fetchManifest }); + + expect(fetchManifest).toHaveBeenCalledTimes(1); + expect(second).toMatchObject({ kind: 'legacy', reason: 'unresolved-backoff', retryAt: first.retryAt }); + + now = first.retryAt; + await checkManifest({ + origin: 'app.eth', + committedUrl: 'bzz://app.eth/', + eager: true, + }, { fetchManifest }); + expect(fetchManifest).toHaveBeenCalledTimes(2); + } finally { + Date.now = realNow; + } + }); + + test('serializes concurrent manifest IPC checks for the same origin', async () => { + registerPermissionManifestIpc(); + let releaseFirst; + mockHandleBzzRequest + .mockImplementationOnce(() => new Promise((resolve) => { releaseFirst = resolve; })) + .mockResolvedValue(responseFor(manifest({ publish: { why: 'Publish releases' } }))); + + const first = ipcHandlers['swarm:manifest-check']({}, { + origin: 'app.eth', + committedUrl: 'bzz://app.eth/', + navigationKey: 'tab-1:1', + eager: true, + }); + await Promise.resolve(); + const second = ipcHandlers['swarm:manifest-check']({}, { + origin: 'app.eth', + committedUrl: 'bzz://app.eth/', + navigationKey: 'tab-2:1', + eager: true, + }); + await Promise.resolve(); + + expect(mockHandleBzzRequest).toHaveBeenCalledTimes(1); + releaseFirst(responseFor(manifest({ publish: { why: 'Publish releases' } }))); + await first; + await second; + expect(mockHandleBzzRequest).toHaveBeenCalledTimes(2); + }); + + test('two tabs of one app share a single consent instead of two stale-able tokens', async () => { + const fetchManifest = async () => responseFor(manifest({ publish: { why: 'Publish releases' } })); + const request = { origin: 'app.eth', committedUrl: 'bzz://app.eth/', eager: true }; + const tabA = await checkManifest(request, { fetchManifest }); + const tabB = await checkManifest(request, { fetchManifest }); + + expect(tabA.kind).toBe('consent'); + expect(tabB.kind).toBe('consent'); + expect(tabB.token).toBe(tabA.token); + + // One sheet, one answer: the second tab's decide replays that answer + // rather than tripping the revision guard the first decision bumped. + const decision = decideManifest(tabA.token, 'allow'); + expect(decision).toEqual({ allowed: true, mode: 'allow' }); + expect(decideManifest(tabB.token, 'allow')).toEqual(decision); + expect(mockPermissionState['app.eth'].autoApprove.publish).toBe(true); + expect(getRecord('app.eth').receipts).toHaveLength(1); + }); + + test('a coalescing second tab refreshes the shared consent TTL', async () => { + jest.useFakeTimers({ now: Date.UTC(2026, 0, 1) }); + try { + const fetchManifest = async () => responseFor(manifest({ publish: { why: 'Publish releases' } })); + const request = { origin: 'app.eth', committedUrl: 'bzz://app.eth/', eager: true }; + const tabA = await checkManifest(request, { fetchManifest }); + // Second tab arrives 4.5 min into the 5 min TTL and reuses the token. + jest.advanceTimersByTime(4.5 * 60 * 1000); + const tabB = await checkManifest(request, { fetchManifest }); + expect(tabB.token).toBe(tabA.token); + // Past the ORIGINAL 5 min expiry but within the refreshed window: the + // reused token must still decide (without the refresh it would throw). + jest.advanceTimersByTime(1 * 60 * 1000); + expect(decideManifest(tabB.token, 'allow')).toEqual({ allowed: true, mode: 'allow' }); + } finally { + jest.useRealTimers(); + } + }); + + test('a shared first-contact denial drops the observation for both tabs', async () => { + const fetchManifest = async () => responseFor(manifest({ messaging: { why: 'Receive updates' } })); + const request = { origin: 'app.eth', committedUrl: 'bzz://app.eth/', eager: true }; + const tabA = await checkManifest(request, { fetchManifest }); + const tabB = await checkManifest(request, { fetchManifest }); + + const decision = decideManifest(tabA.token, 'deny'); + expect(decision).toEqual({ allowed: false, mode: 'deny' }); + expect(decideManifest(tabB.token, 'deny')).toEqual(decision); + expect(getRecord('app.eth')).toBeNull(); + expect(mockPermissionState['app.eth']).toBeUndefined(); + }); + + test('a changed manifest still invalidates the outstanding consent', async () => { + const request = { origin: 'app.eth', committedUrl: 'bzz://app.eth/', eager: true }; + const tabA = await checkManifest(request, { + fetchManifest: async () => responseFor(manifest({ publish: { why: 'Publish releases' } })), + }); + const tabB = await checkManifest(request, { + fetchManifest: async () => responseFor(manifest({ + publish: { why: 'Publish releases' }, + messaging: { why: 'Receive updates' }, + })), + }); + + expect(tabB.token).not.toBe(tabA.token); + expect(() => decideManifest(tabA.token, 'allow')).toThrow('stale'); + }); +}); diff --git a/src/main/swarm/swarm-permissions.js b/src/main/swarm/swarm-permissions.js index 7084fe45..a95db988 100644 --- a/src/main/swarm/swarm-permissions.js +++ b/src/main/swarm/swarm-permissions.js @@ -20,6 +20,7 @@ const path = require('path'); const fs = require('fs'); const IPC = require('../../shared/ipc-channels'); const { normalizeOrigin } = require('../../shared/origin-utils'); +const { withOriginLock } = require('./origin-mutation-lock'); const PERMISSIONS_FILE = 'swarm-permissions.json'; @@ -51,11 +52,14 @@ function loadPermissions() { } function savePermissions() { + const filePath = getPermissionsPath(); + const tempPath = `${filePath}.tmp`; try { - const filePath = getPermissionsPath(); - fs.writeFileSync(filePath, JSON.stringify(permissionsCache, null, 2), 'utf-8'); + fs.writeFileSync(tempPath, JSON.stringify(permissionsCache, null, 2), 'utf-8'); + fs.renameSync(tempPath, filePath); } catch (err) { - console.error('[SwarmPermissions] Failed to save permissions:', err); + permissionsCache = null; + throw err; } } @@ -100,7 +104,7 @@ function grantPermission(origin) { * @param {string} origin * @returns {boolean} True if permission was revoked */ -function revokePermission(origin) { +function revokePermission(origin, { source = 'user' } = {}) { const permissions = loadPermissions(); const key = normalizeOrigin(origin); @@ -108,7 +112,8 @@ function revokePermission(origin) { delete permissions[key]; permissionsCache = permissions; savePermissions(); - if (revokeListener) revokeListener(key); + notifyRevokeListeners(key); + if (source === 'user') notifyManifestMutation(key, 'connection'); console.log('[SwarmPermissions] Revoked permission for:', key); return true; } @@ -119,10 +124,35 @@ function revokePermission(origin) { // Revocation hook — this module is a pure permission store; live-resource // teardown (e.g. cancelling messaging subscriptions) is owned by the // provider layer, which registers itself here at startup. -let revokeListener = null; +const revokeListeners = new Set(); +let manifestMutationListener = null; function onRevoke(listener) { - revokeListener = listener; + if (listener === null) { + revokeListeners.clear(); + return () => {}; + } + if (typeof listener !== 'function') return () => {}; + revokeListeners.add(listener); + return () => revokeListeners.delete(listener); +} + +function onManifestMutation(listener) { + manifestMutationListener = listener; +} + +function notifyManifestMutation(origin, projectionKey) { + manifestMutationListener?.(origin, projectionKey); +} + +function notifyRevokeListeners(origin) { + for (const listener of revokeListeners) { + try { + listener(origin); + } catch (err) { + console.error('[SwarmPermissions] Revoke listener failed:', err); + } + } } /** @@ -180,7 +210,7 @@ function getAutoApprove(origin, type) { * @param {boolean} enabled * @returns {boolean} True if updated */ -function setAutoApprove(origin, type, enabled) { +function setAutoApprove(origin, type, enabled, { source = 'user' } = {}) { if (!VALID_AUTO_APPROVE_TYPES.has(type)) return false; const permissions = loadPermissions(); @@ -195,6 +225,7 @@ function setAutoApprove(origin, type, enabled) { permissions[key].autoApprove[type] = enabled; permissionsCache = permissions; savePermissions(); + if (source === 'user') notifyManifestMutation(key, `autoApprove.${type}`); console.log(`[SwarmPermissions] Auto-approve ${type} ${enabled ? 'enabled' : 'disabled'} for:`, key); return true; @@ -205,7 +236,7 @@ function setAutoApprove(origin, type, enabled) { * @param {string} origin * @returns {boolean} True if granted */ -function grantMessaging(origin) { +function grantMessaging(origin, { source = 'user' } = {}) { const permissions = loadPermissions(); const key = normalizeOrigin(origin); @@ -214,11 +245,26 @@ function grantMessaging(origin) { permissions[key].messaging = { grantedAt: Date.now() }; permissionsCache = permissions; savePermissions(); + if (source === 'user') notifyManifestMutation(key, 'messagingGrant'); console.log('[SwarmPermissions] Messaging granted for:', key); return true; } +function revokeMessaging(origin, { source = 'user' } = {}) { + const permissions = loadPermissions(); + const key = normalizeOrigin(origin); + if (!permissions[key]?.messaging) return false; + + delete permissions[key].messaging; + if (permissions[key].autoApprove) permissions[key].autoApprove.messaging = false; + permissionsCache = permissions; + savePermissions(); + notifyRevokeListeners(key); + if (source === 'user') notifyManifestMutation(key, 'messagingGrant'); + return true; +} + /** * Check whether an origin holds the messaging-tier grant. * @param {string} origin @@ -238,11 +284,11 @@ function registerSwarmPermissionsIpc() { }); ipcMain.handle(IPC.SWARM_GRANT_PERMISSION, (_event, origin) => { - return grantPermission(origin); + return withOriginLock(origin, () => grantPermission(origin)); }); ipcMain.handle(IPC.SWARM_REVOKE_PERMISSION, (_event, origin) => { - return revokePermission(origin); + return withOriginLock(origin, () => revokePermission(origin)); }); ipcMain.handle(IPC.SWARM_GET_ALL_PERMISSIONS, () => { @@ -258,17 +304,21 @@ function registerSwarmPermissionsIpc() { }); ipcMain.handle(IPC.SWARM_SET_AUTO_APPROVE, (_event, origin, type, enabled) => { - return setAutoApprove(origin, type, enabled); + return withOriginLock(origin, () => setAutoApprove(origin, type, enabled)); }); ipcMain.handle(IPC.SWARM_GRANT_MESSAGING, (_event, origin) => { - return grantMessaging(origin); + return withOriginLock(origin, () => grantMessaging(origin)); }); ipcMain.handle(IPC.SWARM_HAS_MESSAGING_GRANT, (_event, origin) => { return hasMessagingGrant(origin); }); + ipcMain.handle(IPC.SWARM_REVOKE_MESSAGING, (_event, origin) => { + return withOriginLock(origin, () => revokeMessaging(origin)); + }); + console.log('[SwarmPermissions] IPC handlers registered'); } @@ -286,8 +336,10 @@ module.exports = { getAutoApprove, setAutoApprove, grantMessaging, + revokeMessaging, hasMessagingGrant, onRevoke, + onManifestMutation, registerSwarmPermissionsIpc, _resetCache, }; diff --git a/src/main/swarm/swarm-permissions.test.js b/src/main/swarm/swarm-permissions.test.js index 4ab4981c..d94c6618 100644 --- a/src/main/swarm/swarm-permissions.test.js +++ b/src/main/swarm/swarm-permissions.test.js @@ -42,6 +42,7 @@ const { getAutoApprove, setAutoApprove, grantMessaging, + revokeMessaging, hasMessagingGrant, onRevoke, registerSwarmPermissionsIpc, @@ -96,11 +97,14 @@ describe('swarm-permissions', () => { test('revocation notifies the registered revoke listener', () => { const listener = jest.fn(); + const secondListener = jest.fn(); onRevoke(listener); + onRevoke(secondListener); grantPermission('myapp.eth'); revokePermission('myapp.eth'); expect(listener).toHaveBeenCalledWith('myapp.eth'); + expect(secondListener).toHaveBeenCalledWith('myapp.eth'); listener.mockClear(); revokePermission('myapp.eth'); @@ -108,6 +112,19 @@ describe('swarm-permissions', () => { onRevoke(null); }); + test('messaging revocation cancels live resources without dropping the base grant', () => { + const listener = jest.fn(); + onRevoke(listener); + grantPermission('myapp.eth'); + grantMessaging('myapp.eth'); + + expect(revokeMessaging('myapp.eth')).toBe(true); + expect(listener).toHaveBeenCalledWith('myapp.eth'); + expect(getPermission('myapp.eth')).not.toBeNull(); + expect(hasMessagingGrant('myapp.eth')).toBe(false); + onRevoke(null); + }); + test('supports messaging auto-approve as a distinct type', () => { grantPermission('myapp.eth'); expect(getAutoApprove('myapp.eth', 'messaging')).toBe(false); diff --git a/src/renderer/index.html b/src/renderer/index.html index 85b217a7..828a6280 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -2993,6 +2993,34 @@

Deposit to Chequebook

+ + + +
Publisher identity
diff --git a/src/renderer/lib/swarm-provider.js b/src/renderer/lib/swarm-provider.js index 8fff225d..ea283790 100644 --- a/src/renderer/lib/swarm-provider.js +++ b/src/renderer/lib/swarm-provider.js @@ -11,8 +11,8 @@ */ import { getPermissionKey } from './dapp-provider.js'; -import { getDisplayUrlForWebview } from './tabs.js'; -import { showSwarmConnect, updateSwarmConnectionBanner, showSwarmPublishApproval, showSwarmFeedApproval, showSwarmMessagingApproval, showVaultUnlock } from './wallet-ui.js'; +import { getDisplayUrlForWebview, getNavigationKeyForWebview } from './tabs.js'; +import { showSwarmConnect, updateSwarmConnectionBanner, showSwarmPublishApproval, showSwarmFeedApproval, showSwarmMessagingApproval, showVaultUnlock, showPermissionManifest } from './wallet-ui.js'; const ERRORS = { USER_REJECTED: { code: 4001, message: 'User rejected the request' }, @@ -23,6 +23,15 @@ const ERRORS = { // Feature flag state (same pattern as dapp-provider.js) let identityWalletEnabled = false; +const manifestChecks = new WeakMap(); +const PUBLIC_METHODS = new Set([ + 'swarm_getCapabilities', + 'swarm_readFeedEntry', + 'swarm_readChunk', + 'swarm_readSingleOwnerChunk', + 'swarm_listFeeds', + 'swarm_unsubscribe', +]); window.electronAPI?.getSettings?.().then((settings) => { identityWalletEnabled = settings?.enableIdentityWallet === true; @@ -64,6 +73,10 @@ async function handleSwarmRequest(webview, request) { try { let result; + if (!PUBLIC_METHODS.has(method)) { + await ensureManifestFresh(webview, displayUrl, permissionKey, method === 'swarm_requestAccess'); + } + if (method === 'swarm_requestAccess') { result = await handleRequestAccess(webview, displayUrl, permissionKey); } else if (method === 'swarm_getCapabilities') { @@ -123,6 +136,50 @@ async function handleSwarmRequest(webview, request) { } } +async function ensureManifestFresh(webview, committedUrl, permissionKey, eager) { + if (!window.swarmManifest?.check || !permissionKey) return; + const navigationKey = getNavigationKeyForWebview(webview); + let navigationCache = manifestChecks.get(webview); + if (!navigationCache || navigationCache.key !== navigationKey) { + navigationCache = { key: navigationKey, origins: new Map() }; + manifestChecks.set(webview, navigationCache); + } + let cached = navigationCache.origins.get(permissionKey); + if (!cached || (eager && !cached.eager)) { + cached = { + eager, + promise: performManifestRefresh({ permissionKey, committedUrl, navigationKey, eager }), + }; + navigationCache.origins.set(permissionKey, cached); + } + + try { + await cached.promise; + } catch (err) { + if (err?.code !== ERRORS.USER_REJECTED.code && navigationCache.origins.get(permissionKey) === cached) { + navigationCache.origins.delete(permissionKey); + } + throw err; + } +} + +async function performManifestRefresh({ permissionKey, committedUrl, navigationKey, eager }) { + const result = await window.swarmManifest.check({ + origin: permissionKey, + committedUrl, + navigationKey, + eager, + }); + if (result.kind === 'unresolved') { + throw { ...ERRORS.DISCONNECTED, message: 'Could not refresh this app’s permission manifest' }; + } + if (result.kind !== 'consent') return; + + const outcome = await showPermissionManifest(result.model, result.token); + const decision = await window.swarmManifest.decide(result.token, outcome); + if (!decision.allowed) throw ERRORS.USER_REJECTED; +} + /** * Messaging methods (PSS/GSOC). The messaging tier is granted once per * origin via the messaging prompt; sends additionally require per-send diff --git a/src/renderer/lib/swarm-provider.test.js b/src/renderer/lib/swarm-provider.test.js new file mode 100644 index 00000000..89754610 --- /dev/null +++ b/src/renderer/lib/swarm-provider.test.js @@ -0,0 +1,121 @@ +const mockShowPermissionManifest = jest.fn(); + +jest.mock('./dapp-provider.js', () => ({ + getPermissionKey: jest.fn(() => 'app.eth'), +})); + +jest.mock('./tabs.js', () => ({ + getDisplayUrlForWebview: jest.fn(() => 'bzz://app.eth/'), + getNavigationKeyForWebview: jest.fn(() => 'tab-1:1'), +})); + +jest.mock('./wallet-ui.js', () => ({ + showSwarmConnect: jest.fn(), + updateSwarmConnectionBanner: jest.fn(), + showSwarmPublishApproval: jest.fn(), + showSwarmFeedApproval: jest.fn(), + showSwarmMessagingApproval: jest.fn(), + showVaultUnlock: jest.fn(), + showPermissionManifest: (...args) => mockShowPermissionManifest(...args), +})); + +function flush() { + return new Promise((resolve) => setImmediate(resolve)); +} + +function createWebview() { + const listeners = {}; + return { + listeners, + addEventListener: jest.fn((name, listener) => { listeners[name] = listener; }), + getWebContentsId: jest.fn(() => 41), + send: jest.fn(), + }; +} + +beforeAll(async () => { + global.window = { + electronAPI: { getSettings: jest.fn().mockResolvedValue({ enableIdentityWallet: true }) }, + addEventListener: jest.fn(), + swarmManifest: { + check: jest.fn(), + decide: jest.fn(), + }, + swarmPermissions: { + getPermission: jest.fn().mockResolvedValue({ origin: 'app.eth', autoApprove: {} }), + updateLastUsed: jest.fn().mockResolvedValue(true), + }, + swarmProvider: { execute: jest.fn() }, + }; + await flush(); +}); + +afterAll(() => { + delete global.window; +}); + +describe('renderer Swarm manifest freshness gate', () => { + let setupSwarmProvider; + + beforeAll(async () => { + ({ setupSwarmProvider } = require('./swarm-provider.js')); + await flush(); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('awaits one manifest sheet and decision before requestAccess consumes grants', async () => { + window.swarmManifest.check.mockResolvedValue({ + kind: 'consent', + token: 'opaque-token', + model: { origin: 'app.eth', changed: [{ key: 'publish' }], removed: [] }, + }); + mockShowPermissionManifest.mockResolvedValue('allow'); + window.swarmManifest.decide.mockResolvedValue({ allowed: true, mode: 'allow' }); + window.swarmProvider.execute.mockResolvedValue({ result: { connected: true } }); + const webview = createWebview(); + setupSwarmProvider(webview); + + webview.listeners['ipc-message']({ + channel: 'swarm:provider-request', + args: [{ id: 1, method: 'swarm_requestAccess', params: {} }], + }); + await flush(); + await flush(); + + expect(window.swarmManifest.check).toHaveBeenCalledWith({ + origin: 'app.eth', + committedUrl: 'bzz://app.eth/', + navigationKey: 'tab-1:1', + eager: true, + }); + expect(window.swarmManifest.decide).toHaveBeenCalledWith('opaque-token', 'allow'); + expect(window.swarmProvider.execute).toHaveBeenCalledWith('swarm_requestAccess', {}, 'app.eth'); + expect(webview.send).toHaveBeenCalledWith('swarm:provider-response', { + id: 1, + result: { connected: true }, + error: null, + }); + }); + + test('public reads bypass manifest discovery', async () => { + window.swarmProvider.execute.mockResolvedValue({ result: { data: 'public' } }); + const webview = createWebview(); + setupSwarmProvider(webview); + + webview.listeners['ipc-message']({ + channel: 'swarm:provider-request', + args: [{ id: 2, method: 'swarm_readChunk', params: { reference: 'abc' } }], + }); + await flush(); + + expect(window.swarmManifest.check).not.toHaveBeenCalled(); + expect(webview.send).toHaveBeenCalledWith('swarm:provider-response', { + id: 2, + result: { data: 'public' }, + error: null, + }); + }); +}); diff --git a/src/renderer/lib/tabs.js b/src/renderer/lib/tabs.js index 6d48e943..313ed54a 100644 --- a/src/renderer/lib/tabs.js +++ b/src/renderer/lib/tabs.js @@ -235,6 +235,12 @@ export const getDisplayUrlForWebview = (webview) => { return tab.navigationState?.committedDisplayUrl || ''; }; +export const getNavigationKeyForWebview = (webview) => { + const tab = tabState.tabs.find((candidate) => candidate.webview === webview); + if (!tab) return ''; + return `${tab.id}:${tab.navigationState?.committedNavigationSequence || 0}`; +}; + // Create default navigation state for a tab const createNavigationState = () => ({ currentPageUrl: '', @@ -260,6 +266,7 @@ const createNavigationState = () => ({ // it so provider permission keys never see unsubmitted drafts or // pending destinations. committedDisplayUrl: '', + committedNavigationSequence: 0, cachedWebContentsId: null, resolvingWebContentsId: null, pendingSwarmProbeId: null, @@ -504,6 +511,7 @@ const createWebview = (tabId, initialUrl) => { // the actual page identity. if (tab.navigationState && event.url && event.url !== 'about:blank') { tab.navigationState.committedDisplayUrl = webviewUrl; + tab.navigationState.committedNavigationSequence += 1; } // Clear any stale favicon from the previous page when navigating to // an internal page — page-favicon-updated will paint one back in if diff --git a/src/renderer/lib/wallet-ui.js b/src/renderer/lib/wallet-ui.js index c706c2d8..f477915f 100644 --- a/src/renderer/lib/wallet-ui.js +++ b/src/renderer/lib/wallet-ui.js @@ -36,10 +36,12 @@ import { initVaultUnlock, showVaultUnlock } from './wallet/vault-unlock.js'; import { initPermissionManage, showDappPermissions, showSwarmPermissions, showX402Permissions, closeDappPerms, closeSwarmPerms, closeX402Perms } from './wallet/permission-manage.js'; import { initPublisherIdentities, closePublisherIdentities } from './wallet/publisher-identities.js'; import { initPublisherIdentityCreate, closePublisherIdentityCreate } from './wallet/publisher-identity-create.js'; +import { initPermissionManifest, showPermissionManifest } from './wallet/permission-manifest.js'; // Re-export public API consumed by dapp-provider.js, swarm-provider.js, and index.js export { showDappConnect, updateConnectionBanner, showDappTxApproval, showDappSignApproval }; export { showSwarmConnect, updateSwarmConnectionBanner, showSwarmPublishApproval, showSwarmFeedApproval, showSwarmMessagingApproval, showVaultUnlock }; +export { showPermissionManifest }; export { updateX402ConnectionBanner }; export { showDappPermissions, showSwarmPermissions, showX402Permissions }; export { getSelectedChainId, setSelectedChainId }; @@ -79,6 +81,7 @@ export function initWalletUi() { initRpcSettings(); initDappConnect(); initSwarmConnect(); + initPermissionManifest(); initVaultUnlock(); initPermissionManage(); initDappTx(); diff --git a/src/renderer/lib/wallet/permission-manage.js b/src/renderer/lib/wallet/permission-manage.js index 665c4ac4..b5fc96dd 100644 --- a/src/renderer/lib/wallet/permission-manage.js +++ b/src/renderer/lib/wallet/permission-manage.js @@ -37,6 +37,9 @@ let swarmPermsSite; let swarmPermsPublishToggle; let swarmPermsFeedsToggle; let swarmPermsSigningToggle; +let swarmPermsMessagingToggle; +let swarmPermsManifestSection; +let swarmPermsManifestList; let swarmPermsIdentitySelector; let swarmPermsIdentityNote; let swarmPermsDisconnect; @@ -82,6 +85,9 @@ export function initPermissionManage() { swarmPermsPublishToggle = document.getElementById('swarm-perms-publish-toggle'); swarmPermsFeedsToggle = document.getElementById('swarm-perms-feeds-toggle'); swarmPermsSigningToggle = document.getElementById('swarm-perms-signing-toggle'); + swarmPermsMessagingToggle = document.getElementById('swarm-perms-messaging-toggle'); + swarmPermsManifestSection = document.getElementById('swarm-perms-manifest-section'); + swarmPermsManifestList = document.getElementById('swarm-perms-manifest-list'); swarmPermsIdentitySelector = document.getElementById('swarm-perms-identity-selector'); swarmPermsIdentityNote = document.getElementById('swarm-perms-identity-note'); swarmPermsDisconnect = document.getElementById('swarm-perms-disconnect'); @@ -107,6 +113,12 @@ export function initPermissionManage() { updateSwarmConnectionBanner(swarmPermsKey); } }); + swarmPermsMessagingToggle?.addEventListener('change', async () => { + if (!swarmPermsKey) return; + if (swarmPermsMessagingToggle.checked) await window.swarmPermissions.grantMessaging(swarmPermsKey); + else await window.swarmPermissions.revokeMessaging(swarmPermsKey); + updateSwarmConnectionBanner(swarmPermsKey); + }); // x402 permission screen x402PermsScreen = document.getElementById('sidebar-x402-permissions'); @@ -244,6 +256,10 @@ export async function showSwarmPermissions(permissionKey, options = {}) { if (swarmPermsSigningToggle) { swarmPermsSigningToggle.checked = permission.autoApprove?.signing === true; } + if (swarmPermsMessagingToggle) { + swarmPermsMessagingToggle.checked = await window.swarmPermissions.hasMessagingGrant(permissionKey); + } + await renderManifestGrant(permissionKey); await refreshSwarmIdentitySection(); walletState.identityView?.classList.add('hidden'); @@ -255,6 +271,53 @@ export async function showSwarmPermissions(permissionKey, options = {}) { } } +async function renderManifestGrant(origin) { + if (!swarmPermsManifestSection || !swarmPermsManifestList) return; + const record = await window.swarmManifest?.get?.(origin); + const entries = Object.entries(record?.acknowledged || {}); + swarmPermsManifestSection.classList.toggle('hidden', entries.length === 0); + swarmPermsManifestList.innerHTML = ''; + for (const [capability, decision] of entries) { + const row = document.createElement('div'); + row.className = 'perms-toggle-row'; + const label = document.createElement('span'); + label.className = 'perms-label'; + label.textContent = `${capability} · ${decision.decision === 'managed' ? 'allowed by manifest' : 'individual approvals'}`; + row.appendChild(label); + if (decision.decision === 'managed') { + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'perms-tx-remove'; + button.textContent = 'Ask each time'; + button.addEventListener('click', async () => { + await window.swarmManifest.useIndividual(origin, capability); + await refreshSwarmPermissionControls(origin); + await renderManifestGrant(origin); + }); + row.appendChild(button); + } + swarmPermsManifestList.appendChild(row); + } + const latestReceipt = record?.receipts?.at?.(-1); + if (latestReceipt) { + const receipt = document.createElement('div'); + receipt.className = 'perms-empty'; + receipt.textContent = `Last batch decision: ${latestReceipt.outcome} · ${new Date(latestReceipt.decidedAt).toLocaleString()}`; + swarmPermsManifestList.appendChild(receipt); + } +} + +async function refreshSwarmPermissionControls(origin) { + const permission = await window.swarmPermissions.getPermission(origin); + if (swarmPermsPublishToggle) swarmPermsPublishToggle.checked = permission?.autoApprove?.publish === true; + if (swarmPermsFeedsToggle) swarmPermsFeedsToggle.checked = permission?.autoApprove?.feeds === true; + if (swarmPermsSigningToggle) swarmPermsSigningToggle.checked = permission?.autoApprove?.signing === true; + if (swarmPermsMessagingToggle) { + swarmPermsMessagingToggle.checked = await window.swarmPermissions.hasMessagingGrant(origin); + } + updateSwarmConnectionBanner(origin); +} + export function closeSwarmPerms() { swarmPermsScreen?.classList.add('hidden'); walletState.identityView?.classList.remove('hidden'); diff --git a/src/renderer/lib/wallet/permission-manifest.js b/src/renderer/lib/wallet/permission-manifest.js new file mode 100644 index 00000000..3f09a484 --- /dev/null +++ b/src/renderer/lib/wallet/permission-manifest.js @@ -0,0 +1,141 @@ +import { walletState, registerScreenHider, hideAllSubscreens } from './wallet-state.js'; +import { open as openSidebarPanel } from '../sidebar.js'; +import { createPromptQueue, setButtonsDisabled } from './prompt-queue.js'; +import { isSignatureInFlight, signatureInFlightError } from './signature-flight.js'; + +let screen; +let site; +let name; +let description; +let rows; +let identityNote; +let rejectBtn; +let individualBtn; +let allowBtn; + +// The manifest sheet owns a single sidebar screen, so concurrent consent +// requests (two tabs on manifest-bearing apps) queue up like every other +// approval prompt instead of overwriting each other. +const manifestQueue = createPromptQueue(presentPermissionManifest, (armed) => { + setButtonsDisabled([rejectBtn, individualBtn, allowBtn], !armed); +}); + +export function initPermissionManifest() { + screen = document.getElementById('sidebar-swarm-manifest'); + site = document.getElementById('swarm-manifest-site'); + name = document.getElementById('swarm-manifest-name'); + description = document.getElementById('swarm-manifest-description'); + rows = document.getElementById('swarm-manifest-rows'); + identityNote = document.getElementById('swarm-manifest-identity-note'); + rejectBtn = document.getElementById('swarm-manifest-reject'); + individualBtn = document.getElementById('swarm-manifest-individual'); + allowBtn = document.getElementById('swarm-manifest-allow'); + + rejectBtn?.addEventListener('click', () => settle('deny')); + individualBtn?.addEventListener('click', () => settle('individual')); + allowBtn?.addEventListener('click', () => settle('allow')); + document.getElementById('swarm-manifest-back')?.addEventListener('click', () => settle('deny')); + + registerScreenHider(() => { + // `presenting` means this queue is showing its own next request — the + // hideAllSubscreens() inside present() is our transition, not a + // dismissal (queued requests behind it must survive it). + if (manifestQueue.presenting) return; + const wasVisible = screen && !screen.classList.contains('hidden'); + screen?.classList.add('hidden'); + // Dismissing the sheet drops queued requests too — the user never sees + // them, so leaving them pending would hang the page. + if (wasVisible) { + for (const pending of manifestQueue.drain()) pending.resolve('deny'); + } + }); +} + +// Concurrent tabs of the same app share one consent token (main hands the +// outstanding one back), and one token means one sheet: a second request for a +// token already queued, on screen, or just answered rides on that single +// answer instead of asking the user the same question twice. Bounded like the +// main process's completed-token replay — tokens live five minutes. +const MAX_TRACKED_OUTCOMES = 100; +const outcomesByToken = new Map(); + +export function showPermissionManifest(model, token) { + // A live device confirmation owns the sidebar (see signature-flight.js). + // Pre-checked like every sibling prompt rather than left to present()'s + // hideAllSubscreens() throwing: the page gets the standard in-flight + // rejection, and a queue entry can never be shifted into a present() that + // throws and drops it with its promise unsettled. + if (isSignatureInFlight()) return Promise.reject(signatureInFlightError()); + + if (!token) return new Promise((resolve) => manifestQueue.show({ model, resolve })); + + const tracked = outcomesByToken.get(token); + if (tracked) return tracked; + + const outcome = new Promise((resolve) => { + manifestQueue.show({ model, token, resolve }); + }); + outcomesByToken.set(token, outcome); + if (outcomesByToken.size > MAX_TRACKED_OUTCOMES) { + outcomesByToken.delete(outcomesByToken.keys().next().value); + } + return outcome; +} + +function presentPermissionManifest({ model }) { + site.textContent = model.origin; + name.textContent = model.name; + description.textContent = model.description; + description.classList.toggle('hidden', !model.description); + rows.innerHTML = ''; + + for (const capability of model.removed) { + const row = document.createElement('div'); + row.className = 'swarm-manifest-row'; + const heading = document.createElement('div'); + heading.className = 'swarm-manifest-row-title'; + heading.textContent = `${capability.label} removed`; + const detail = document.createElement('div'); + detail.className = 'swarm-manifest-row-detail'; + detail.textContent = 'The app no longer requests this capability. Manifest-managed access has been removed.'; + row.append(heading, detail); + rows.appendChild(row); + } + + for (const capability of model.changed) { + const row = document.createElement('div'); + row.className = 'swarm-manifest-row'; + const heading = document.createElement('div'); + heading.className = 'swarm-manifest-row-title'; + heading.textContent = capability.label; + const detail = document.createElement('div'); + detail.className = 'swarm-manifest-row-detail'; + detail.textContent = capability.why; + row.append(heading, detail); + rows.appendChild(row); + } + + identityNote.classList.toggle('hidden', !model.createsIdentity && !model.preservedIdentity); + identityNote.textContent = model.createsIdentity + ? 'A new app-scoped signing identity will be created. Your vault still unlocks at signing time.' + : model.preservedIdentity + ? 'Your existing publisher identity will be kept.' + : ''; + + hideAllSubscreens(); + walletState.identityView?.classList.add('hidden'); + screen?.classList.remove('hidden'); + openSidebarPanel(); +} + +// Every settling path claims the on-screen request first: claim() hands it +// out once, so a repeated click can neither settle it twice nor act on the +// request queued behind it. +function settle(outcome) { + const pending = manifestQueue.claim(); + if (!pending) return; + screen?.classList.add('hidden'); + walletState.identityView?.classList.remove('hidden'); + pending.resolve(outcome); + manifestQueue.settle(); +} diff --git a/src/renderer/lib/wallet/permission-manifest.test.js b/src/renderer/lib/wallet/permission-manifest.test.js new file mode 100644 index 00000000..341b10b6 --- /dev/null +++ b/src/renderer/lib/wallet/permission-manifest.test.js @@ -0,0 +1,295 @@ +const originalWindow = global.window; +const originalDocument = global.document; + +class FakeClassList { + constructor() { + this.classes = new Set(); + } + + add(...names) { + for (const name of names) this.classes.add(name); + } + + remove(...names) { + for (const name of names) this.classes.delete(name); + } + + contains(name) { + return this.classes.has(name); + } + + toggle(name, force) { + const shouldAdd = force === undefined ? !this.classes.has(name) : !!force; + if (shouldAdd) this.classes.add(name); + else this.classes.delete(name); + return shouldAdd; + } +} + +class FakeElement { + constructor() { + this.classList = new FakeClassList(); + this.listeners = new Map(); + this.children = []; + this.textContent = ''; + this.innerHTML = ''; + this.disabled = false; + } + + addEventListener(event, handler) { + if (!this.listeners.has(event)) this.listeners.set(event, []); + this.listeners.get(event).push(handler); + } + + append(...nodes) { + this.children.push(...nodes); + } + + appendChild(node) { + this.children.push(node); + return node; + } + + async fire(event, detail = {}) { + for (const handler of this.listeners.get(event) || []) { + await handler({ type: event, target: this, ...detail }); + } + } +} + +const elementIds = [ + 'sidebar-swarm-manifest', + 'swarm-manifest-site', + 'swarm-manifest-name', + 'swarm-manifest-description', + 'swarm-manifest-rows', + 'swarm-manifest-identity-note', + 'swarm-manifest-reject', + 'swarm-manifest-individual', + 'swarm-manifest-allow', + 'swarm-manifest-back', +]; + +function createDocument() { + const elements = {}; + for (const id of elementIds) { + elements[id] = new FakeElement(); + elements[id].classList.add('hidden'); + } + return { + elements, + getElementById: jest.fn((id) => elements[id] || null), + createElement: jest.fn(() => new FakeElement()), + addEventListener: jest.fn(), + }; +} + +function manifestModel(origin, appName) { + return { + origin, + name: appName, + description: '', + removed: [], + changed: [{ label: 'Publish', why: 'Uploads site content' }], + createsIdentity: false, + preservedIdentity: false, + }; +} + +async function loadPermissionManifest() { + jest.resetModules(); + + const document = createDocument(); + const identityView = new FakeElement(); + const openSidebarPanel = jest.fn(); + + // Real screen-hider wiring: hideAllSubscreens() runs every registered + // hider, which is what made concurrent prompts clobber each other. + const screenHiders = []; + const hideAllSubscreens = jest.fn(() => screenHiders.forEach((fn) => fn())); + + global.document = document; + global.window = {}; + + jest.doMock('./wallet-state.js', () => ({ + walletState: { identityView }, + registerScreenHider: (fn) => screenHiders.push(fn), + hideAllSubscreens, + })); + jest.doMock('../sidebar.js', () => ({ + open: openSidebarPanel, + isVisible: jest.fn(() => false), + })); + + const mod = await import('./permission-manifest.js'); + const signatureFlight = await import('./signature-flight.js'); + mod.initPermissionManifest(); + + return { mod, signatureFlight, elements: document.elements, hideAllSubscreens, openSidebarPanel }; +} + +// Track settlement without awaiting a promise that may never settle. +function trackedConsent(promise) { + const state = { outcome: undefined, settled: false, promise }; + promise.then((outcome) => { + state.settled = true; + state.outcome = outcome; + }); + return state; +} + +// A freshly presented prompt ignores clicks for its input-protection window +// (PROMPT_ARM_DELAY_MS) so a double-click cannot settle a prompt the user has +// not seen. Tests step past it explicitly. +async function armPrompt() { + jest.advanceTimersByTime(600); + await Promise.resolve(); +} + +describe('permission manifest consent queues concurrent requests', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + global.window = originalWindow; + global.document = originalDocument; + jest.restoreAllMocks(); + }); + + test('a second consent request waits its turn instead of clobbering the first', async () => { + const ctx = await loadPermissionManifest(); + const screen = ctx.elements['sidebar-swarm-manifest']; + + const first = trackedConsent(ctx.mod.showPermissionManifest(manifestModel('bzz://alpha', 'Alpha'))); + const second = trackedConsent(ctx.mod.showPermissionManifest(manifestModel('bzz://beta', 'Beta'))); + + // Only the first request is on screen; the second is queued, not denied. + expect(screen.classList.contains('hidden')).toBe(false); + expect(ctx.elements['swarm-manifest-site'].textContent).toBe('bzz://alpha'); + expect(first.settled).toBe(false); + expect(second.settled).toBe(false); + + // Allow all settles the first request only, then shows the queued one. + await armPrompt(); + await ctx.elements['swarm-manifest-allow'].fire('click'); + expect(await first.promise).toBe('allow'); + expect(second.settled).toBe(false); + expect(screen.classList.contains('hidden')).toBe(false); + expect(ctx.elements['swarm-manifest-site'].textContent).toBe('bzz://beta'); + + // "Connect, but ask each time" settles the second and closes the sheet. + await armPrompt(); + await ctx.elements['swarm-manifest-individual'].fire('click'); + expect(await second.promise).toBe('individual'); + expect(screen.classList.contains('hidden')).toBe(true); + }); + + test('dismissing the sheet denies the queued requests too', async () => { + const ctx = await loadPermissionManifest(); + + const first = trackedConsent(ctx.mod.showPermissionManifest(manifestModel('bzz://alpha', 'Alpha'))); + const second = trackedConsent(ctx.mod.showPermissionManifest(manifestModel('bzz://beta', 'Beta'))); + + // Another screen taking over the sidebar fires every screen hider. + ctx.hideAllSubscreens(); + + expect(await first.promise).toBe('deny'); + expect(await second.promise).toBe('deny'); + expect(ctx.elements['sidebar-swarm-manifest'].classList.contains('hidden')).toBe(true); + }); + + test('double-clicking Allow settles one request and leaves the queued one on screen', async () => { + const ctx = await loadPermissionManifest(); + + const first = trackedConsent(ctx.mod.showPermissionManifest(manifestModel('bzz://alpha', 'Alpha'))); + const second = trackedConsent(ctx.mod.showPermissionManifest(manifestModel('bzz://beta', 'Beta'))); + + await armPrompt(); + await Promise.all([ + ctx.elements['swarm-manifest-allow'].fire('click'), + ctx.elements['swarm-manifest-allow'].fire('click'), + ]); + + expect(await first.promise).toBe('allow'); + // The second click landed inside the queued prompt's arm window, so it + // did not settle the request that just took the screen. + expect(second.settled).toBe(false); + expect(ctx.elements['sidebar-swarm-manifest'].classList.contains('hidden')).toBe(false); + expect(ctx.elements['swarm-manifest-site'].textContent).toBe('bzz://beta'); + + await armPrompt(); + await ctx.elements['swarm-manifest-reject'].fire('click'); + expect(await second.promise).toBe('deny'); + }); + + test('buttons are disabled during the input-protection window', async () => { + const ctx = await loadPermissionManifest(); + + const consent = trackedConsent(ctx.mod.showPermissionManifest(manifestModel('bzz://alpha', 'Alpha'))); + expect(ctx.elements['swarm-manifest-allow'].disabled).toBe(true); + expect(ctx.elements['swarm-manifest-individual'].disabled).toBe(true); + expect(ctx.elements['swarm-manifest-reject'].disabled).toBe(true); + + await armPrompt(); + expect(ctx.elements['swarm-manifest-allow'].disabled).toBe(false); + + await ctx.elements['swarm-manifest-allow'].fire('click'); + expect(await consent.promise).toBe('allow'); + }); + + test('a live device confirmation refuses the sheet with the standard error', async () => { + const ctx = await loadPermissionManifest(); + const screen = ctx.elements['sidebar-swarm-manifest']; + const signer = {}; + ctx.signatureFlight.beginSignatureFlight(signer); + + try { + // Same pre-check as the sibling swarm prompts: the request is refused + // with the standard in-flight error instead of a generic internal one, + // and the sheet never touches the sidebar the device prompt owns. + await expect(ctx.mod.showPermissionManifest(manifestModel('bzz://alpha', 'Alpha'), 'tok-1')) + .rejects.toMatchObject({ code: -32002 }); + expect(screen.classList.contains('hidden')).toBe(true); + expect(ctx.hideAllSubscreens).not.toHaveBeenCalled(); + } finally { + ctx.signatureFlight.endSignatureFlight(signer); + } + + // Nothing was cached against the refused token, so the retry the dApp is + // told to make gets a real sheet once the device is done. + const retry = trackedConsent(ctx.mod.showPermissionManifest(manifestModel('bzz://alpha', 'Alpha'), 'tok-1')); + expect(screen.classList.contains('hidden')).toBe(false); + await armPrompt(); + await ctx.elements['swarm-manifest-allow'].fire('click'); + expect(await retry.promise).toBe('allow'); + }); + + test('two tabs sharing one consent token get one sheet and one answer', async () => { + const ctx = await loadPermissionManifest(); + const screen = ctx.elements['sidebar-swarm-manifest']; + + // Main hands both tabs the same outstanding token for the same origin. + const tabA = trackedConsent(ctx.mod.showPermissionManifest(manifestModel('bzz://alpha', 'Alpha'), 'tok-1')); + const tabB = trackedConsent(ctx.mod.showPermissionManifest(manifestModel('bzz://alpha', 'Alpha'), 'tok-1')); + + await armPrompt(); + await ctx.elements['swarm-manifest-allow'].fire('click'); + + // One answer settles both, and no duplicate sheet is left behind. + expect(await tabA.promise).toBe('allow'); + expect(await tabB.promise).toBe('allow'); + expect(screen.classList.contains('hidden')).toBe(true); + + // A tab arriving late on the same token replays the answer, no sheet. + const tabC = trackedConsent(ctx.mod.showPermissionManifest(manifestModel('bzz://alpha', 'Alpha'), 'tok-1')); + expect(await tabC.promise).toBe('allow'); + expect(screen.classList.contains('hidden')).toBe(true); + + // A different consent still gets its own sheet. + trackedConsent(ctx.mod.showPermissionManifest(manifestModel('bzz://beta', 'Beta'), 'tok-2')); + expect(screen.classList.contains('hidden')).toBe(false); + expect(ctx.elements['swarm-manifest-site'].textContent).toBe('bzz://beta'); + }); +}); diff --git a/src/renderer/lib/wallet/prompt-queue.js b/src/renderer/lib/wallet/prompt-queue.js new file mode 100644 index 00000000..35daeac2 --- /dev/null +++ b/src/renderer/lib/wallet/prompt-queue.js @@ -0,0 +1,138 @@ +/** + * Prompt Queue + * + * Shared queueing for the sidebar's dApp approval prompts (Swarm connect, + * publish, messaging, feed, permission manifest). Each prompt owns a single + * sidebar screen, so the queue is what keeps concurrent requests from + * clobbering each other. + */ + +/** + * Input protection: a queued prompt is presented by the very click that + * settles the one before it, so without a dead window the second click of a + * double-click would land on a prompt the user has not had a chance to read + * — approving (or cancelling) a stamp-spending, network-visible request + * sight unseen. Browsers apply the same guard to permission dialogs. + */ +export const PROMPT_ARM_DELAY_MS = 500; + +/** + * Each approval prompt owns a single sidebar screen, so only one request can + * be on screen at a time. Concurrent dApp requests (two swarm.subscribe() + * calls at page load, two sends back to back) queue up and are shown in turn. + * Without a queue the newer request would overwrite the pending one, which + * both orphans the first request and auto-rejects the new one when + * hideAllSubscreens() fires this screen's hider during the transition. + * + * Settling goes through claim(): it hands the on-screen request to exactly + * one caller, so a double-click cannot settle the same request twice (which + * silently consumed the request queued behind it) and cannot act on a prompt + * that only just appeared. + * + * @param {(entry: Object) => void} present - renders and shows the screen + * @param {(armed: boolean) => void} [onArmedChange] - reflect the input-protection + * window in the screen's buttons + */ +export function createPromptQueue(present, onArmedChange) { + const waiting = []; + let current = null; + let armed = false; + let armTimer = null; + let settling = false; + let presenting = false; + + function setArmed(next) { + armed = next; + onArmedChange?.(next); + } + + return { + /** The request currently on screen, or null. */ + get current() { + return current; + }, + + /** True while this queue is mid-transition to a queued request. */ + get presenting() { + return presenting; + }, + + /** False during a freshly-presented prompt's input-protection window. */ + get armed() { + return armed; + }, + + /** Show `entry` now, or queue it behind the request already on screen. */ + show(entry) { + waiting.push(entry); + // `settling` covers the gap between claim() and settle(): the settling + // request is still on screen, so the newcomer waits for its turn. + if (!current && !settling) this.showNext(); + }, + + /** Show the next queued request, if there is one. */ + showNext() { + const next = waiting.shift(); + if (!next) return; + // present() calls hideAllSubscreens(), which runs every screen hider — + // including this screen's. `current` stays null until that has run so + // the hider cannot mistake the incoming request for a dismissed one, + // and `presenting` tells the hider the still-queued requests are not + // being dismissed either. + presenting = true; + if (armTimer) clearTimeout(armTimer); + setArmed(false); + try { + present(next); + } finally { + presenting = false; + } + current = next; + armTimer = setTimeout(() => { + armTimer = null; + setArmed(true); + }, PROMPT_ARM_DELAY_MS); + }, + + /** + * Take the on-screen request for settling. Returns null when there is + * nothing on screen, when the request was already claimed (the second + * click of a double-click), or while the prompt is still inside its + * input-protection window — callers must then do nothing at all. + */ + claim() { + if (!current || !armed) return null; + const claimed = current; + current = null; + settling = true; + return claimed; + }, + + /** The claimed request finished settling: show the next one. */ + settle() { + settling = false; + this.showNext(); + }, + + /** The screen was dismissed: hand back every request so all get rejected. */ + drain() { + const dropped = current ? [current, ...waiting] : waiting.slice(); + current = null; + waiting.length = 0; + if (armTimer) { + clearTimeout(armTimer); + armTimer = null; + } + setArmed(false); + return dropped; + }, + }; +} + +// Buttons are disabled for the input-protection window so the dead click is +// visible rather than mysterious. +export function setButtonsDisabled(buttons, disabled) { + for (const button of buttons) { + if (button) button.disabled = disabled; + } +} diff --git a/src/renderer/lib/wallet/swarm-connect.js b/src/renderer/lib/wallet/swarm-connect.js index bcc5fa98..c1067cd1 100644 --- a/src/renderer/lib/wallet/swarm-connect.js +++ b/src/renderer/lib/wallet/swarm-connect.js @@ -11,6 +11,7 @@ import { formatBytes } from './wallet-utils.js'; import { open as openSidebarPanel, isVisible as isSidebarVisible } from '../sidebar.js'; import { getPermissionKey, getActiveWebview } from '../dapp-provider.js'; import { showSwarmPermissions } from './permission-manage.js'; +import { createPromptQueue, setButtonsDisabled } from './prompt-queue.js'; import { BEE_WALLET_IDENTITY_ID, getActivePublisherIdentity, @@ -87,136 +88,6 @@ let swarmFeedPasswordInput; let swarmFeedPasswordSubmit; let swarmFeedUnlockError; -/** - * Input protection: a queued prompt is presented by the very click that - * settles the one before it, so without a dead window the second click of a - * double-click would land on a prompt the user has not had a chance to read - * — approving (or cancelling) a stamp-spending, network-visible request - * sight unseen. Browsers apply the same guard to permission dialogs. - */ -const PROMPT_ARM_DELAY_MS = 500; - -/** - * Each approval prompt owns a single sidebar screen, so only one request can - * be on screen at a time. Concurrent dApp requests (two swarm.subscribe() - * calls at page load, two sends back to back) queue up and are shown in turn. - * Without a queue the newer request would overwrite the pending one, which - * both orphans the first request and auto-rejects the new one when - * hideAllSubscreens() fires this screen's hider during the transition. - * - * Settling goes through claim(): it hands the on-screen request to exactly - * one caller, so a double-click cannot settle the same request twice (which - * silently consumed the request queued behind it) and cannot act on a prompt - * that only just appeared. - * - * @param {(entry: Object) => void} present - renders and shows the screen - * @param {(armed: boolean) => void} [onArmedChange] - reflect the input-protection - * window in the screen's buttons - */ -function createPromptQueue(present, onArmedChange) { - const waiting = []; - let current = null; - let armed = false; - let armTimer = null; - let settling = false; - let presenting = false; - - function setArmed(next) { - armed = next; - onArmedChange?.(next); - } - - return { - /** The request currently on screen, or null. */ - get current() { - return current; - }, - - /** True while this queue is mid-transition to a queued request. */ - get presenting() { - return presenting; - }, - - /** False during a freshly-presented prompt's input-protection window. */ - get armed() { - return armed; - }, - - /** Show `entry` now, or queue it behind the request already on screen. */ - show(entry) { - waiting.push(entry); - // `settling` covers the gap between claim() and settle(): the settling - // request is still on screen, so the newcomer waits for its turn. - if (!current && !settling) this.showNext(); - }, - - /** Show the next queued request, if there is one. */ - showNext() { - const next = waiting.shift(); - if (!next) return; - // present() calls hideAllSubscreens(), which runs every screen hider — - // including this screen's. `current` stays null until that has run so - // the hider cannot mistake the incoming request for a dismissed one, - // and `presenting` tells the hider the still-queued requests are not - // being dismissed either. - presenting = true; - if (armTimer) clearTimeout(armTimer); - setArmed(false); - try { - present(next); - } finally { - presenting = false; - } - current = next; - armTimer = setTimeout(() => { - armTimer = null; - setArmed(true); - }, PROMPT_ARM_DELAY_MS); - }, - - /** - * Take the on-screen request for settling. Returns null when there is - * nothing on screen, when the request was already claimed (the second - * click of a double-click), or while the prompt is still inside its - * input-protection window — callers must then do nothing at all. - */ - claim() { - if (!current || !armed) return null; - const claimed = current; - current = null; - settling = true; - return claimed; - }, - - /** The claimed request finished settling: show the next one. */ - settle() { - settling = false; - this.showNext(); - }, - - /** The screen was dismissed: hand back every request so all get rejected. */ - drain() { - const dropped = current ? [current, ...waiting] : waiting.slice(); - current = null; - waiting.length = 0; - if (armTimer) { - clearTimeout(armTimer); - armTimer = null; - } - setArmed(false); - return dropped; - }, - }; -} - -// Buttons are disabled for the input-protection window so the dead click is -// visible rather than mysterious. -function setButtonsDisabled(buttons, disabled) { - for (const button of buttons) { - if (button) button.disabled = disabled; - } -} - // Local state const swarmConnectQueue = createPromptQueue(presentSwarmConnect, (armed) => { setButtonsDisabled([swarmConnectApproveBtn, swarmConnectRejectBtn], !armed); @@ -522,8 +393,11 @@ export async function disconnectSwarmApp(permissionKey = null) { if (!key) return; try { - await window.swarmPermissions.revokePermission(key); - await window.swarmFeedStore?.revokeFeedAccess?.(key); + if (window.swarmManifest?.disconnect) await window.swarmManifest.disconnect(key); + else { + await window.swarmPermissions.revokePermission(key); + await window.swarmFeedStore?.revokeFeedAccess?.(key); + } console.log('[SwarmConnect] Disconnected:', key); const webview = getActiveWebview(); @@ -738,6 +612,12 @@ function messagingRequestLabel(method) { * messaging auto-approve. Resolves on approve, rejects (4001) on cancel. */ export function showSwarmMessagingApproval(permissionKey, params, resolve, reject, options = {}) { + // A live device confirmation owns the sidebar (see signature-flight.js). + if (isSignatureInFlight()) { + reject(signatureInFlightError()); + return; + } + swarmMessagingQueue.show({ permissionKey, params, resolve, reject, options }); } diff --git a/src/renderer/lib/wallet/swarm-connect.test.js b/src/renderer/lib/wallet/swarm-connect.test.js index bcbfc1f8..7b6176c0 100644 --- a/src/renderer/lib/wallet/swarm-connect.test.js +++ b/src/renderer/lib/wallet/swarm-connect.test.js @@ -183,10 +183,12 @@ async function loadSwarmConnect() { })); const mod = await import('./swarm-connect.js'); + const signatureFlight = await import('./signature-flight.js'); mod.initSwarmConnect(); return { mod, + signatureFlight, elements: document.elements, hideAllSubscreens, openSidebarPanel, @@ -233,6 +235,46 @@ describe('swarm approval prompts queue concurrent requests', () => { jest.restoreAllMocks(); }); + // Sibling parity with showSwarmConnect / showSwarmPublishApproval / + // showSwarmFeedApproval (and the manifest sheet): none of these may repaint + // over a sidebar a live device confirmation owns. + test('every swarm prompt refuses to open while a signature is in flight', async () => { + const ctx = await loadSwarmConnect(); + const signer = {}; + ctx.signatureFlight.beginSignatureFlight(signer); + + try { + const prompts = [ + trackedPrompt((resolve, reject) => + ctx.mod.showSwarmConnect('https://a.example', 'https://a.example', resolve, reject, null)), + trackedPrompt((resolve, reject) => + ctx.mod.showSwarmPublishApproval('https://a.example', {}, resolve, reject, 'swarm_uploadFile')), + trackedPrompt((resolve, reject) => + ctx.mod.showSwarmMessagingApproval('https://a.example', { topic: 'alpha' }, resolve, reject, { + method: 'swarm_subscribe', + grantMode: true, + })), + trackedPrompt((resolve, reject) => + ctx.mod.showSwarmFeedApproval('https://a.example', {}, resolve, reject, { method: 'swarm_setFeed' })), + ]; + + for (const prompt of prompts) { + await expect(prompt.promise).rejects.toMatchObject({ code: -32002 }); + } + expect(ctx.hideAllSubscreens).not.toHaveBeenCalled(); + for (const id of [ + 'sidebar-swarm-connect', + 'sidebar-swarm-publish-approve', + 'sidebar-swarm-messaging-approve', + 'sidebar-swarm-feed-approve', + ]) { + expect(ctx.elements[id].classList.contains('hidden')).toBe(true); + } + } finally { + ctx.signatureFlight.endSignatureFlight(signer); + } + }); + test('a second messaging request waits its turn instead of clobbering the first', async () => { const ctx = await loadSwarmConnect(); const screen = ctx.elements['sidebar-swarm-messaging-approve']; diff --git a/src/renderer/styles/sidebar.css b/src/renderer/styles/sidebar.css index 36fe1149..2c193fdc 100644 --- a/src/renderer/styles/sidebar.css +++ b/src/renderer/styles/sidebar.css @@ -3173,6 +3173,38 @@ margin-top: 24px; } +.swarm-manifest-rows { + display: flex; + flex-direction: column; + gap: 8px; + margin: 16px 0; +} + +.swarm-manifest-row { + padding: 12px; + border: 1px solid var(--sidebar-border, rgba(127, 127, 127, 0.25)); + border-radius: 8px; +} + +.swarm-manifest-row-title { + font-weight: 600; +} + +.swarm-manifest-row-detail, +.swarm-manifest-description, +.swarm-manifest-choice-note { + margin-top: 4px; + color: var(--sidebar-text-secondary, #8b8b8b); + font-size: 12px; + line-height: 1.4; +} + +.swarm-manifest-actions { + display: grid; + gap: 8px; + margin-top: 16px; +} + .swarm-connect-reject-btn, .swarm-connect-approve-btn { flex: 1; diff --git a/src/shared/ipc-channels.js b/src/shared/ipc-channels.js index 08934d03..544f5bf4 100644 --- a/src/shared/ipc-channels.js +++ b/src/shared/ipc-channels.js @@ -299,8 +299,16 @@ module.exports = { SWARM_GET_AUTO_APPROVE: 'swarm:get-auto-approve', SWARM_SET_AUTO_APPROVE: 'swarm:set-auto-approve', SWARM_GRANT_MESSAGING: 'swarm:grant-messaging', + SWARM_REVOKE_MESSAGING: 'swarm:revoke-messaging', SWARM_HAS_MESSAGING_GRANT: 'swarm:has-messaging-grant', + // Swarm permission manifests + SWARM_MANIFEST_CHECK: 'swarm:manifest-check', + SWARM_MANIFEST_DECIDE: 'swarm:manifest-decide', + SWARM_MANIFEST_GET: 'swarm:manifest-get', + SWARM_MANIFEST_USE_INDIVIDUAL: 'swarm:manifest-use-individual', + SWARM_MANIFEST_DISCONNECT: 'swarm:manifest-disconnect', + // Swarm Provider (main-process authority) SWARM_PROVIDER_EXECUTE: 'swarm:provider-execute', SWARM_PROVIDER_EVENT: 'swarm:provider-event',