Files
amethyst/commons/src/commonMain
Vitor PamplonaandClaude Opus 4.8 dbf15a2146 fix(napplet): scope applet grants per app and per account
Every napplet/web-app grant was keyed by applet coordinate alone
(`<appAuthor>:<identifier>`), which carries no account. The stores and
ledgers are process-wide singletons shared by all accounts, so grants
leaked in two directions:

- **Across apps.** `NappletBroker.sessionAllows` held a bare `op.key`
  ("sign:1"), and the check ran *before* the per-app ledger lookup. One
  app's "Allow for this session" therefore authorized that op for every
  other applet and browser origin, silently, for the broker's lifetime.

- **Across accounts.** A grant made under one npub authorized the same
  applet under every other npub on the device. For a user keeping a
  pseudonymous account separate from a real one, an app authorized by
  one could sign as the other with no prompt — defeating the point of
  separate accounts.

NIP-46 already solved this shape correctly: `Nip46PermissionAuthorizer`
namespaces by account (`nip46:<signer>:<client>`) and keys session grants
by `(coordinate, op)`. Its comment even claims it "mirrors the napplet
broker's sessionAllows" — the mirror was backwards. This adopts the same
pattern on the napplet side:

- `sessionAllows` and `NappletPermissionLedger.session` are keyed by
  account + coordinate + op.
- Napplet storage and the capability store namespace keys by account,
  read at call time so a switch moves reads/writes with no rebuild.
- The signer ledger is deliberately NOT account-scoped at the store: it
  is shared with NIP-46, whose sessions run for a specific account rather
  than the active one, so scoping it there would break a background
  bunker. The napplet path namespaces its own coordinate instead.

Also here, found while scoping:

- `identity.watch` consulted only the manifest declaration and never the
  ledger, bypassing a standing DENY — it short-circuits before
  `NappletBroker.handle`, where the "a standing denial always wins" rule
  lives. It now applies that rule itself.
- `DataStoreNappletStorage.keys()` filtered on a space separator while
  keys are written with NUL, so it silently matched nothing and always
  returned an empty list.

Existing grants live under the old un-namespaced keys and are not
migrated: users are re-prompted once. Migrating would attribute grants
made under the broken model to whichever account is active, preserving
the bug.

Regression tests cover both leak directions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 18:56:58 -04:00
..