Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013Hc6Rvdz3DxLucqNtPE2iP
12 KiB
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:
mainpost-v0.8.1 (2fa4d68tag; HEAD59ebc28) - Driver: Product audit
docs/superpowers/reviews/2026-06-20-product-audit.mdrecommendation #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-sidegit verify-commitandrelicario-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-commitfails) → org write degrades to a follow-up lift; v0.9.0 still ships org read (A0–A2, 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.
Spike result + transport decision (resolved 2026-06-25)
The spike (Dev-C, commit 426b82a; evidence in docs/superpowers/spikes/2026-06-20-org-signed-commit-spike.md) returned GO on the signature mechanism: a SW-built SSHSIG commit (raw sign_for_git + manual SSHSIG framing — no new WASM export) passes both git verify-commit and relicario-server verify-org-commit, including the item+manifest dual-write with grant/slug authz. The hook matches the signing-key fingerprint → members[].ed25519_pubkey (committer/author text is free-form, not checked — this corrects the "committer identity must match" assumption).
Transport finding (corrects this section's planned approach): the GitHub-style Git Data API path is GitHub-only. Research (vs Gitea 1.26.4 / roadmap / the repo_file.go structs / open issues #30955, #22100) confirms no Gitea version exposes a write Git Data API or accepts a caller-supplied commit signature (the Contents API signs with the instance key or not at all), and none is planned. So A3 does not use the host REST API. Org write ships via native git-receive-pack packfile push (isomorphic-git in the SW) — host-agnostic, works at any Gitea version, preserves the SSHSIG byte-for-byte; the extension's host_permissions removes the usual CORS-proxy requirement. This becomes the single universal write path for both Gitea and GitHub (~68KB gzip dependency).
Decision (user, 2026-06-25): build the universal packfile-push write path now, in v0.9.0 — not the deferred fallback. A3 (Plan C) is re-scoped: a small SSHSIG-through-isomorphic-git mini-spike, then the universal commitSigned, then the write handlers + UI (held until A0–A2 land). A focused /security-review on the push path is mandatory before merge.
Latent bug noted (separate follow-up): extension/src/service-worker/gitea.ts putBlob calls nonexistent Gitea /git write endpoints — large attachments would 404 on a Gitea host (pre-existing ~v0.8.2).
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— unwrapkeys/<member-id>.encinto the org master key held in WASMZeroizing, returning an opaque slot handle in the same pattern as the personalSessionHandle(the key never crosses to JS).org_item_encrypt/decrypt(OrgHandle, …)andorg_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.tsis a single module-scopeSessionHandle | 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 andlockzero every handle. Org handles are never written tolocalStorage,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 personalvaultConfig. (The device key inchrome.storage.local.device_private_keyis reused to unwrap org keys; no new device identity.) - Org GitHost. One
GitHostper org repo via the existingcreateGitHostfactory. - Read flow. On switch to an org: read public
members.json+collections.json(unencrypted) → locate this device's member record by ed25519 fingerprint → take itscollectionsgrant list →org_unwrap_key→ fetch +org_manifest_decrypt→ filter manifest entries to granted collection slugs → cache. Items decrypt on demand viaorg_item_decrypt. - Offline. If the org
GitHostfetch throws a network error, serve the last-pulled manifest read-only and set anorgOfflineflag. - 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 thePopupMessageunion ANDPOPUP_ONLY_TYPESAND 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 sendsorg_switchand reloads the list. - Reuse. The
popup/components/*renderers areStateHost-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 fromchrome.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 pathitems/<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 ". - 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
- Org-write signing spike (§ above) — the gating unknown.
- 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. - 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.