diff --git a/docs/superpowers/reviews/2026-06-20-product-audit.md b/docs/superpowers/reviews/2026-06-20-product-audit.md new file mode 100644 index 0000000..6c63e15 --- /dev/null +++ b/docs/superpowers/reviews/2026-06-20-product-audit.md @@ -0,0 +1,128 @@ +# Product Audit — Relicario — 2026-06-20 · fast + +> Generated by the `product-expert` skill (roadmap audit, fast mode). Competitive +> read grounded in `references/competitive-landscape.md` (last-reviewed 2026-06-20). +> Advisory only — record of what was considered, not a commitment. + +## Reality check + +v0.8.1 tagged today: `relicario org add`/`edit` now covers **all 7 item types** +with collection-scoped, grant-enforced attachments — sitting on the +cryptographically serious v0.8.0 org backend (ECIES per-member key wrap, +signature-verifying pre-receive hook). The personal vault is genuinely complete +with full CLI↔extension parity. But the **defining reality is an asymmetry**: +Relicario has now built an entire enterprise org vault that *cannot be touched +from a browser* — the extension has zero org concept. The biggest recent +investment has no GUI surface. No lift is currently active. + +**Drift found** (low severity, but catching it is this skill's job): +- `STATUS.md:7` — "Last release tagged: **v0.6.0**". Stale: v0.8.0 and v0.8.1 are + both tagged (`git tag`; release commit `2fa4d68`). +- `STATUS.md:8` + `ROADMAP.md:10` — "tag pending PM". Stale: the v0.8.1 tag is cut. +- `docs/user_docs/` (12-page end-user guide) merged as a fast-follow *after* the + tag — fine, just not inside the v0.8.1 tag. + +## Assessment + +**Strengths:** the wedge sits in a near-empty competitive cell — two factors +*into the KDF* + self-host + **zero server metadata** + git audit log (1Password +has the 2-factor KDF but is cloud-only; vaultwarden self-hosts but is +single-factor KDF). Personal vault is complete. Org backend is real cryptographic +work, now feature-broad. + +**Gaps:** (1) the org vault is **invisible in the GUI** — extension has no org +read or write; the whole enterprise feature is stranded behind the CLI (rated +*critical*; traces to `docs/superpowers/specs/2026-06-20-extension-cli-parity-gap-analysis.md`). +(2) Personal-side parity holes that make a "parity-is-a-design-value" product feel +unfinished — favorites (no UI on either surface), group/tag editing only on some +forms, and autofill matching by **exact hostname** (so `www.github.com` misses a +login saved as `github.com`). (3) The pitch leads with steganography — the most +friction-heavy, least load-bearing part of the wedge. + +**Risks:** mobile absence caps total addressable market — but for Relicario's +*self-selected* desktop/CLI audience that's a ceiling, not a bleeding wound, and +treating it as an emergency would import mass-market logic that doesn't fit this +product. The sharper risk is that a GUI-less org vault only ever reaches +CLI-native shops — a fraction of the market the org spec implies — stranding the +investment. + +## Recommendations (leverage-ordered) + +1. **REORDER — Put a GUI on the org vault you already built: extension org *read* + next, then *write*.** *Why:* the v0.8.0+v0.8.1 backend is stranded without it; + "unlock value already built" is the highest-ROI class of move; it's already + roadmap item #1, and CLI reached all-7-type org write in v0.8.1 so the write + path is unblocked. Outranks the command palette and personal-parity polish. + *Impact/Effort:* H / M. *Risk:* browser GitHost has no commit-signing path, so + write is harder than read — ship read first as its own slice. *Refinement:* + scope to org **item usage** (read/add/edit a shared credential), NOT admin ops + (member/key management staying CLI-only is a legitimate design choice; item + usage being CLI-only is not). + +2. **PIVOT (positioning) — Re-lead with the thesis, demote stego to an *option*.** + *Why:* the most important thing the roadmap doesn't mention. A plain key file + delivers the identical 256-bit second factor; stego's only marginal benefit is + the niche "dead-drop on social media" story, while it carries the most unlock + friction and a SPOF the project already had to paper over with the recovery-QR. + The README leads with the gimmick and buries the moat. *Impact/Effort:* H / L + (messaging; keep the feature). *Risk:* stego is the product's identity — keep + it first-class-*optional*, don't delete it. *Adjacent thesis-level call:* + offering a plain key file as an alternative second factor would lower + onboarding friction for users who find "hide a secret in a JPEG" too weird — a + real ADD candidate, not just messaging. + +3. **ADD (cheap, high-ROI) — Autofill matches by registrable domain (eTLD+1), not + exact hostname.** *Why:* exact-equality silently fails on the most common case + (`www.` vs apex), making the extension feel broken; small, contained fix. + *Impact/Effort:* M / L. *Risk:* use a public-suffix list to avoid over-matching. + +4. **ADD — Close the personal parity holes: favorites UI + group/tag editing on + every item-type form.** *Why:* CLI↔extension parity is a stated design value; + family/individual users organize by exactly these. *Impact/Effort:* M / M. + +5. **REORDER (defer) — Keep org phase-2 (SSO/LDAP, read audit, per-collection + subkeys, HTTP plane) parked behind extension org parity.** *Why:* high-effort, + no demand, pointless while the org feature has no GUI. *Impact/Effort:* M / H. + +6. **CUT (future investment, not deletion) — Stop *deepening* the over-served + areas:** no more stego-robustness work, no recovery-QR elaboration, leave + field-history's knobs alone. Don't remove working features — just stop + investing in them. + +7. **Housekeeping — sync `STATUS.md` and `ROADMAP.md:10`** to reflect v0.8.1 as + tagged. Five minutes; it's the exact drift this audit exists to catch. + +**On mobile & v1.0:** mobile is the single biggest TAM ceiling, but a high-effort, +post-v1.0 bet that partly contradicts the desktop/CLI shape of the product — a +separate-product-scale investment, not the next move. Frame **v1.0 = the thesis, +fully usable on the surfaces you already support**: extension org parity + +personal parity holes closed + positioning sharpened. Mobile is a v1.x conversation. + +## PM brief + +```markdown +## PRODUCT DIRECTIVE TO PM +Time: 2026-06-20 (local) +Source: /product-expert roadmap audit (fast) + +Reality note: v0.8.1 is TAGGED (org item-type parity). The org vault backend is +fully shipped but has ZERO extension GUI — the whole enterprise feature is +CLI-only. STATUS.md still says "Last release tagged: v0.6.0" and "tag pending PM"; +sync those (5-min housekeeping) before anything else. + +Roadmap changes (priority order): +1. REORDER — extension org READ (org switch + collection-filtered browse) is the + next slice; org WRITE follows as its own slice. Scope to item usage, not admin + ops. This outranks the command palette and personal-parity polish. +2. PIVOT (positioning) — re-lead messaging with "two secrets into the KDF + + self-host + zero server metadata + git audit"; present the stego image as an + optional second-factor flavor, not the headline. Keep the feature. +3. ADD — autofill: match by registrable domain (eTLD+1), not exact hostname. +4. ADD — favorites UI + group/tag editing across all item-type forms (parity). + +Recommended next slice: extension org READ (H impact / M effort — puts a usable +face on the backend you already paid for). + +Out of scope / do NOT pick up: org phase-2 (SSO/LDAP, read audit, per-collection +subkeys) until org has a GUI; further stego/recovery-QR hardening; mobile (post-v1.0). +``` diff --git a/docs/superpowers/specs/2026-06-20-extension-org-gui-design.md b/docs/superpowers/specs/2026-06-20-extension-org-gui-design.md new file mode 100644 index 0000000..c7791dd --- /dev/null +++ b/docs/superpowers/specs/2026-06-20-extension-org-gui-design.md @@ -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 (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. + +## 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/.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 }` 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 / ``… 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//.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/.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 + +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. diff --git a/docs/superpowers/specs/2026-06-20-pluggable-second-factor-design.md b/docs/superpowers/specs/2026-06-20-pluggable-second-factor-design.md new file mode 100644 index 0000000..dbc8541 --- /dev/null +++ b/docs/superpowers/specs/2026-06-20-pluggable-second-factor-design.md @@ -0,0 +1,76 @@ +# Pluggable Second Factor (Key File) + Positioning Pivot — Design Spec + +- **Date:** 2026-06-20 +- **Status:** Approved (brainstorming) — ready for writing-plans +- **Release target:** v0.9.0 (one multi-agent train, alongside the Extension Org Vault GUI spec) +- **Anchor:** `main` post-v0.8.1 (`2fa4d68` tag; HEAD `59ebc28`) +- **Driver:** Product audit `docs/superpowers/reviews/2026-06-20-product-audit.md` recommendation #2 (PIVOT) — re-lead positioning with the durable thesis (two secrets into the KDF + zero server metadata + git audit) and treat the steganographic image as **one option** for the second factor, with a plain key file as the alternative. +- **Builds on:** `2026-04-11-relicario-design.md` (crypto pipeline), `docs/CRYPTO.md`, `docs/FORMATS.md`, `extension/ARCHITECTURE.md` (setup wizard). + +## Purpose & scope + +Make the vault's second factor **pluggable**: the 256-bit secret can be carried by the existing steganographic reference image (default) **or** by a plain **key file** — chosen at vault creation, with the same secret and the same KDF underneath. Re-lead the project's positioning on the durable thesis and frame stego as an option rather than the headline. + +**Key insight — this is crypto-light.** The second factor is *already* just 32 bytes (`image_secret`); stego is only the storage/transport. The Argon2id KDF (`passphrase || image_secret → master_key`) and everything downstream are **byte-for-byte unchanged**. Only the *source* of the 32 bytes changes. No new crypto primitive. + +**Mental model (chosen in brainstorming):** at creation you pick the container — **Reference Image** (default) or **Key File** — and both materialize the same random 32-byte secret. The vault records a non-secret container-type hint so unlock prompts for the right thing. Because it is literally the same secret, the recovery-QR already fits this model, and export/convert between containers is a natural (optional) add-on. + +**In scope:** key-file generation at init; unlock from a key file; the non-secret container hint; CLI + extension support; the positioning/docs pivot. + +**Out of scope (optional stretch, not core):** `keyfile export` / convert-an-existing-image-vault-to-keyfile. It needs the secret in hand (a re-provide-then-write flow), so it is deferred to keep the lift tight; noted as a fast-follow. + +## Crypto model + +- **Container hint.** `.relicario/params.json` (non-secret, already holds Argon2id params) gains `"second_factor": "image" | "keyfile"`. **Absent ⇒ `"image"`** (back-compat for every existing vault). Read pre-unlock to choose the prompt; reveals nothing secret (container type is not a secret). +- **Raw-secret unlock path.** Today `unlock(passphrase, jpeg_bytes, salt, params)` extracts the 32-byte secret from the JPEG internally (`extension/ARCHITECTURE.md` notes "unlock takes JPEG bytes … extracts internally"). Add an explicit `unlock_with_secret(passphrase, secret: &[u8;32], salt, params)` that skips extraction. The KDF and AEAD are identical; this is the only core seam. +- **Key-file armor.** Core owns the format so CLI and WASM share it: `keyfile_encode(secret) -> Vec` and `keyfile_decode(bytes) -> [u8;32]`. Layout: a `relicario-keyfile-v1` header line + base64 of the 32 bytes + trailing newline. `keyfile_decode` validates the header, rejects malformed input, and holds the secret in `Zeroizing`. Suggested extension: `.relkey`. + +## Stream decomposition + +### B1 · core + WASM + +- `relicario-core`: `keyfile_encode` / `keyfile_decode` (Zeroizing), `unlock_with_secret`, and read/write of the `second_factor` field in the params struct (default `image`). +- `relicario-wasm`: bind `keyfile_encode`, `keyfile_decode`, `unlock_with_secret`. +- Equivalence test: for a given 32-byte secret, `unlock_with_secret(pass, secret, …)` derives the **same** master key as unlocking from a JPEG that embeds that secret — proves the seam is transport-only. + +### B2 · CLI + +- `relicario init`: a container choice — `--key-file ` (or interactive) generates the 32-byte secret, writes the `.relkey` via `keyfile_encode`, and sets params `second_factor: "keyfile"`; the existing `--image`/`--output` path stays the default and sets `"image"`. +- `unlock` across all commands: read the factor per the params hint — if `keyfile`, from `--key-file` or `RELICARIO_KEYFILE` (mirroring `RELICARIO_IMAGE`); `keyfile_decode` → `unlock_with_secret`. +- Help text + `docs/user_docs/` reflect the choice. + +### B3 · extension + +- **Setup wizard step 3** gains a container choice (Reference Image | Key File). Key-file mode: generate the secret, offer the `.relkey` for download, set the params hint. +- **Unlock**: per the params hint, prompt for the key file (file picker) instead of the image; `keyfile_decode` → `unlock_with_secret`. +- **Local storage (chosen default):** store the key-file bytes in `chrome.storage.local` as `keyfileBase64`, re-read each unlock — exactly as `imageBase64` works today. Same "something you have" threat model and the same offline behavior; documented as equivalent, not weaker. + +### B4 · docs / positioning pivot + +- **README** re-led: open with the thesis (two independent secrets into the KDF, self-host, zero server metadata, git audit); present the steganographic image as a distinctive **option** for the second factor (with the key file as the plain alternative), not the headline. Keep the dead-drop story as flavor, not the lead. +- **DESIGN.md** secrets-map + **docs/CRYPTO.md** (pluggable-transport framing: "the second factor is 32 bytes; image/key-file/recovery-QR are interchangeable containers") + **docs/FORMATS.md** (`.relkey` armor + params `second_factor` field). + +## Security-review gate (before merge) + +A focused `/security-review` pass on the key-file path: + +- **No weaker than stego:** same 32-byte entropy, same Argon2id, same AEAD. The equivalence test (B1) is the evidence. +- **Armor parsing** rejects malformed/short input without panics or oracles. +- **Threat-model honesty:** `.relkey` and `keyfileBase64` are the second factor *in the clear* — exactly the same posture as the reference JPEG / `imageBase64` today. Document this in `docs/SECURITY.md`; do not imply the key file is encrypted (it is the "something you have", protected by needing the passphrase too). +- **No oracle differences** between the image and key-file unlock failure paths (both surface the deliberately-ambiguous "wrong passphrase or reference image/key"). + +## Error handling + +- Malformed/empty key file → `invalid_key_file` (CLI + extension), distinct from a wrong-secret AEAD failure. +- Missing key file at unlock when params say `keyfile` → prompt/`RELICARIO_KEYFILE` guidance. +- Params `second_factor` present but unknown value → reject with a clear message (forward-compat guard). + +## Testing + +- **core:** keyfile encode/decode round-trip; `unlock_with_secret` master-key equivalence vs JPEG unlock; params back-compat (absent ⇒ image); malformed-armor rejection. +- **CLI:** `init --key-file → unlock --key-file` lifecycle against a temp vault; `RELICARIO_KEYFILE` env path; image vault still unlocks unchanged. +- **extension (vitest):** setup key-file path writes the hint + offers download; unlock reads `keyfileBase64` and derives a session; an existing image vault is unaffected. + +## Living-docs impact + +`README.md`, `DESIGN.md`, `docs/CRYPTO.md`, `docs/FORMATS.md`, `docs/SECURITY.md`, `crates/relicario-core/ARCHITECTURE.md` (new core functions), `extension/ARCHITECTURE.md` (setup container choice + `keyfileBase64`), `CHANGELOG.md`.