docs(specs): v0.9.0 design — extension org GUI + pluggable second factor

Product audit (product-expert skill) recommended two priority items; this
lands the audit record plus the two approved design specs that will drive
the v0.9.0 multi-agent train.

- reviews/2026-06-20-product-audit.md — the roadmap audit (reality check,
  recommendations, PM brief) that drove the two items.
- specs/2026-06-20-extension-org-gui-design.md — bring the org vault to the
  extension at read+write parity. Org write is gated on a Day-1 signing
  spike (the org hook rejects unsigned commits; the extension pushes
  unsigned today; sign_for_git exists in WASM but is unused). Spike-fail
  degrades to read-only + write follow-up.
- specs/2026-06-20-pluggable-second-factor-design.md — key file as an
  alternative second factor (same 32-byte secret, same KDF; crypto-light),
  chosen at setup via a non-secret params hint, plus the positioning pivot.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQbgrP6KQW5pibjbPEoTSs
This commit is contained in:
adlee-was-taken
2026-06-20 23:01:53 -04:00
parent c3044ed5af
commit 9b38aac188
3 changed files with 308 additions and 0 deletions

View File

@@ -0,0 +1,104 @@
# Extension Org Vault GUI — Design Spec
- **Date:** 2026-06-20
- **Status:** Approved (brainstorming) — ready for writing-plans
- **Release target:** v0.9.0 (one multi-agent train, alongside the Pluggable Second Factor spec)
- **Anchor:** `main` post-v0.8.1 (`2fa4d68` tag; HEAD `59ebc28`)
- **Driver:** Product audit `docs/superpowers/reviews/2026-06-20-product-audit.md` recommendation #1 — the org vault backend (v0.8.0 + v0.8.1) is fully shipped but has **zero extension presence**; the enterprise feature is stranded behind the CLI.
- **Builds on:** `2026-06-06-relicario-enterprise-org-vault-design.md` (§ Extension — Org Context), `2026-06-20-extension-cli-parity-gap-analysis.md` (P3 cluster), `extension/ARCHITECTURE.md`.
## Purpose & scope
Bring the org (enterprise) vault to the browser extension at **read + write** parity, so org members can browse, view, add, edit, and delete shared credentials from the popup and vault tab — not only the CLI. Org **admin** operations (member/collection/grant/rotate/audit) stay CLI-only by design (high-trust, low-frequency; org spec § Extension scopes them out).
**In scope:** org context switching; grant-filtered browse/read of org items (all 7 types) in popup + vault tab; org item write (add/edit/rm, all 7 types); offline read-only indicator; SW acceptance tests.
**Out of scope:** org admin in the extension; per-collection cryptographic isolation, SSO/LDAP, read audit, HTTP plane (all org phase-2); webcam recovery scan.
## The org-write signing gate (highest risk — read first)
The org pre-receive hook **rejects unsigned commits unconditionally**: it shells `git verify-commit` and requires a device ed25519/SSH signature from a current member (`crates/relicario-server/src/main.rs:95-102`). The extension today pushes **unsigned** commits via the host Contents API (`extension/src/service-worker/gitea.ts``/api/v1/.../contents/{path}`), authored under the API token. The ed25519 signing primitive **exists in WASM** (`sign_for_git`, `crates/relicario-wasm/src/lib.rs:253`) but is **unused** by the extension — there is no signed-commit push path, and the Contents API cannot carry a caller-supplied signature.
Therefore org **write** from the extension must construct and push a **signed commit via the Git Data API** (blob → tree → commit-with-signature → update-ref). This is feasible but unproven against the host APIs.
**Stream A3 begins with a spike, gating the rest of A3:**
> Prove that a commit signed in the SW with `sign_for_git`, pushed via the **Gitea** Git Data API, passes server-side `git verify-commit` **and** `relicario-server verify-org-commit`. Then repeat for **GitHub**.
- **Spike passes** → A3 proceeds (signed-push GitHost path + org write UI); v0.9.0 ships read + write.
- **Spike fails** (host API strips/normalizes the SSH signature such that `git verify-commit` fails) → org write degrades to a **follow-up lift**; v0.9.0 still ships **org read (A0A2, A4-read)** + the full Pluggable Second Factor spec. The spike is ~1 day and read + the other spec are unblocked regardless, so a failed spike wastes nothing.
The spike result is recorded back into this spec and `STATUS.md` before A3 build work starts.
## Architecture
An org vault is a second git repo alongside the personal vault, cryptographically isolated (org spec § Architecture). The org master key is a random 256-bit key, wrapped per member via ECIES (X25519 + XChaCha20-Poly1305) to their device ed25519 key. The extension mirrors the CLI: unwrap the org key with the device private key, decrypt items exactly as the personal vault does — but with the org key, not the Argon2id-derived personal key.
The extension keeps its existing architecture (SW is the crypto fortress; popup/vault are StateHost-driven view shells over `chrome.runtime.sendMessage`). Org support is an additive context, not a rewrite.
## Stream decomposition
### A0 · WASM org bridge (prerequisite)
`relicario-core::org` already performs ECIES unwrap and org item crypto for the CLI; none of it is exposed over `relicario-wasm` today (confirmed — no org exports in `crates/relicario-wasm/src/lib.rs`). Expose:
- `org_unwrap_key(keys_blob: &[u8], device_private_openssh: &str) -> OrgHandle` — unwrap `keys/<member-id>.enc` into the org master key held in WASM `Zeroizing`, returning an opaque slot handle in the same pattern as the personal `SessionHandle` (the key never crosses to JS).
- `org_item_encrypt/decrypt(OrgHandle, …)` and `org_manifest_encrypt/decrypt(OrgHandle, …)` — XChaCha20-Poly1305 with the org key directly (no Argon2id), reusing core.
- `org_handle_free(OrgHandle)` — zero the slot.
Everything else depends on A0.
### A1 · SW org foundation
- **Multi-context session.** `extension/src/service-worker/session.ts` is a single module-scope `SessionHandle | null` ("one vault per install"). Replace with a context map — `{ personal: Handle | null, orgs: Map<orgId, OrgHandle> }` plus a current-context pointer. The inactivity timer and `lock` zero **every** handle. Org handles are never written to `localStorage`, `IndexedDB`, or any persistent store (org spec line 231).
- **Org config storage.** `chrome.storage.local.orgConfigs: Array<{ orgId, displayName, hostType, hostUrl, repoPath, apiToken, memberId }>` — mirrors the personal `vaultConfig`. (The device key in `chrome.storage.local.device_private_key` is reused to unwrap org keys; no new device identity.)
- **Org GitHost.** One `GitHost` per org repo via the existing `createGitHost` factory.
- **Read flow.** On switch to an org: read public `members.json` + `collections.json` (unencrypted) → locate this device's member record by ed25519 fingerprint → take its `collections` grant list → `org_unwrap_key` → fetch + `org_manifest_decrypt`**filter manifest entries to granted collection slugs** → cache. Items decrypt on demand via `org_item_decrypt`.
- **Offline.** If the org `GitHost` fetch throws a network error, serve the last-pulled manifest read-only and set an `orgOffline` flag.
- **New SW messages (popup-only):** `org_list_configs`, `org_switch { context: 'personal' | orgId }`, `org_list_items` (grant-filtered), `org_get_item`, `org_list_collections`. Each must be added to the `PopupMessage` union AND `POPUP_ONLY_TYPES` AND a handler arm (`extension/src/shared/messages.ts` — the three-place rule).
### A2 · Org read UI
- **Context switcher.** A top-level Personal / `<org>`… selector in the vault-tab sidebar (primary surface per org spec) and the popup header. Switching sends `org_switch` and reloads the list.
- **Reuse.** The `popup/components/*` renderers are `StateHost`-driven, so org item detail/type views render unchanged once the host projects org state (items, collections). A collection facet in the sidebar mirrors the existing type-category nav.
- **Offline indicator.** "org offline — writes disabled" banner when `orgOffline`.
### A3 · Org write (gated on the signing spike)
- **Signed-push GitHost path.** A new method that builds a commit through the Git Data API, signs the commit object with `sign_for_git` (device key from `chrome.storage.local`), pushes it, and updates the ref. (Generic enough that personal device-auth writes could later adopt it.)
- **SW write handlers.** `org_add_item`, `org_update_item`, `org_delete_item`: encrypt with the org handle, write to the **collection-scoped** path `items/<slug>/<id>.enc`, update the org manifest — **both writes** via signed push (the personal "manifest + item both written" invariant applies, `extension/ARCHITECTURE.md` § Invariants).
- **UI.** Add/edit reuse the existing per-type item forms; add gains a **granted-collection picker**. Delete = soft-delete (trash) in the org manifest, mirroring personal trash semantics — the org CLI already ships `rm`/`restore`/`purge` (v0.8.0), so the backend is ready.
### A4 · Org SW acceptance tests (vitest)
Per org spec § Extension Tests, plus write coverage: org context switch replaces the personal manifest with no cross-contamination; org master key appears only in the `Zeroizing` session, never in `localStorage`/`IndexedDB`; offline read-only triggers on a git network error; grant filtering hides ungranted collections; a write produces a signed commit the hook accepts (mock the hook contract).
## Data flow (read)
```
popup/vault → org_switch(orgId) → SW:
read members.json + collections.json (public)
→ match device fingerprint → grants
→ org_unwrap_key(keys/<member-id>.enc, device_priv) → OrgHandle (Zeroizing, in WASM)
→ fetch manifest.enc → org_manifest_decrypt → filter to grants → cache
popup/vault → org_list_items → SW returns grant-filtered projection (titles/collections, no secrets)
popup/vault → org_get_item(id) → SW org_item_decrypt → resolved item
```
## Error handling
- **Device not a member** (fingerprint absent from `members.json`) → `not_an_org_member` — clear "this device isn't a member of <org>".
- **Ungranted collection** → filtered out on read; rejected client-side on write before push (and by the hook as defense in depth).
- **Offline** → read-only banner; writes blocked client-side with the indicator.
- **Signed push / hook rejection** → surfaced verbatim; the manifest write is not attempted if the item write fails (no half-mutation).
- Reuse the existing snake_case SW error convention + `humanizeError`.
## Living-docs impact
`extension/ARCHITECTURE.md` (org context, multi-context session, signed-push path, new messages), `docs/SECURITY.md` (extension org key handling + signed-commit write path), `ROADMAP.md`/`STATUS.md` (org parity shipped), `CHANGELOG.md`. The org spec's § Extension scope note (read-only phase-1) is superseded by this spec's read+write decision — note that in the org spec.
## Open risks
1. **Org-write signing spike** (§ above) — the gating unknown.
2. **Multi-context session refactor** touches the SW's most security-sensitive module (`session.ts`); the lock/timer-zeroes-all invariant must be preserved and tested.
3. **Git Data API divergence** Gitea vs GitHub for signed commits — the spike must cover both; if only one host works, ship org write for that host and record the limitation.