diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e87c7a..6d63a22 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,54 @@ # Changelog +## Unreleased — enterprise org vault + +Git-native multi-user **org vaults**: a separate org git repository alongside each +member's personal vault, with a 256-bit org master key ECIES-wrapped per member to +their ed25519 device key, collection-scoped item storage, role-based access, and a +signature-verifying pre-receive hook that makes least-privilege enforcement +server-side. Tracked under `docs/superpowers/plans/2026-06-06-enterprise-org-vault.md`. + +### Added +- **relicario-core `org` module** (`crates/relicario-core/src/org.rs`): org types + (`OrgId`, `MemberId`, `OrgRole`, `OrgMember`/`OrgMembers`, `CollectionDef`/ + `OrgCollections`, `OrgMeta`, `OrgManifest`/`OrgManifestEntry`) and ECIES X25519 + key wrap/unwrap (`generate_org_key`, `wrap_org_key`, `unwrap_org_key`) — ed25519→ + X25519 via RFC 7748 clamp, domain-separated `SHA-256(dh || eph_pk || rcpt_pk)` KDF, + XChaCha20-Poly1305 inner cipher, all key material in `Zeroizing`. Adds + `encrypt_org_manifest` / `decrypt_org_manifest` vault wrappers. New dependencies: + `x25519-dalek 2` (`static_secrets`) in core, `ssh-key 0.6` in core and CLI. +- **relicario-server org mode**: `verify-org-commit` (commit-signature verification + against `members.json` ed25519 keys, path-scoped role/grant authorization, + owner-only elevation judged on the signer's pre-commit role, schema-version + monotonicity) and `generate-org-hook`; new `[lib]` target (`classify_path`, + `extract_schema_version`). Audit trail on every push carries verified-signer + attribution; commits whose signer cannot be matched are flagged `TAMPERED`. +- **relicario-cli org admin commands**: `org init`, `add-member` / `remove-member` / + `set-role` (owner-only escalation guard), `create-collection` / `grant` / `revoke`, + `rotate-key` (re-encrypts every item blob + manifest under a fresh org key), + `transfer-ownership`, `delete-org` (local tombstone; hook blocks pushing a + protected-file deletion), `status` / `audit`. Org commits are signed + (`org_git_run` preserves signing). +- **relicario-cli org item CRUD**: `org add` (Login, SecureNote, Identity — each + collection-scoped and grant-enforced), `org get [--show]` (secrets masked + by default; renders Login/SecureNote/Identity/Card/Document/Totp), `org list + [--trashed]` (manifest filtered to your collection grants), `org edit ` + (flag-driven field updates for login/note/identity fields), `org rm` / `org restore` + / `org purge` (soft-delete lifecycle). Audit actions emitted: `item-create`, + `item-update`, `item-delete`, `item-restore`, `item-purge`. + +### Deferred +- `org add` / `org edit` parity for Card, SshKey, Document, and Totp item types + (only Login, SecureNote, Identity supported today; `org get` and `org list` can + display all types already present in the vault). +- Extension org switch + read-only browse parity (Dev-D follow-up). +- Extension org writes. +- Phase-2 features: SSO/LDAP provisioning, read audit trail, per-collection subkeys + (the current shared org master key scopes *writes* via the hook and *read access* + via manifest filtering, but does not cryptographically isolate collections from one + another — a member who obtains the org key can decrypt any blob), HTTP management + plane. + ## v0.7.0 — 2026-06-01 Completes the extension restructure (Plan C) begun under v0.6.0. Phases diff --git a/DESIGN.md b/DESIGN.md index b0759cc..ab6c4a5 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -147,11 +147,25 @@ The threat model differs by codebase. This is the per-secret per-codebase reside | Master key | `Zeroizing<[u8;32]>` returned by `derive_master_key` | `UnlockedVault.master_key` for the lifetime of one CLI invocation | WASM-side memory behind an opaque `SessionHandle`; JS never sees the bytes | Never sees it | | Item secret (password, card number, etc.) | `Zeroizing` / `Zeroizing>` | Same | Briefly held in WASM during `item_decrypt`; results passed to popup as plaintext for display | Held in DOM (the user is staring at it); cleared when view changes | | Device private key | — | Filesystem under `~/.config/relicario/devices/.key` (mode 0600) | `chrome.storage.local.device_private_key` | — | +| Org master key (256-bit, random) | `Zeroizing<[u8;32]>` during `wrap_org_key`/`unwrap_org_key` (never derived from a passphrase) | `UnlockedOrgVault.org_key` for one CLI invocation; recovered by unwrapping `keys/.enc` with the device ed25519 seed | TODO (extension follow-up) | Never sees it | + +The org master key is **never escrowed**: each member holds it ECIES-wrapped to their device key (`keys/.enc`); an owner can always re-wrap it to a replacement device key, so there is no central key store to compromise. See `docs/CRYPTO.md` (Org-key ECIES wrap/unwrap) and `docs/FORMATS.md` (Org vault repo formats). The popup / vault / content surfaces of the extension cannot decrypt an item independently — they all message the SW. Content scripts in particular get back already-prepared payloads (e.g. `{ username, password }`) from `fill_credentials` after the SW resolved everything. The CLI keeps its master key in process memory; if the process exits or crashes, the key is gone (Zeroize on drop). There is no CLI session daemon. The `lock` subcommand exists only for UX parity with the extension and is a no-op. +## Org vault (enterprise, in progress) + +The enterprise org vault is a **second git repository** alongside each member's personal vault, with its own schema (`org.json` / `members.json` / `collections.json` / `keys/.enc` / `manifest.enc` / `items//.enc`). It reuses the same `relicario-core` AEAD; the only new crypto is the per-member ECIES key wrap. Cross-codebase additions: + +- **relicario-core** gains the `org` module (`org.rs`) and the `x25519-dalek = { version = "2", features = ["static_secrets"] }` dependency (`crates/relicario-core/Cargo.toml:19`); `ssh-key` 0.6 is already present (`:20`). +- **relicario-cli** gains `org_session.rs` + `commands/org.rs` and the `ssh-key = "0.6"` dependency (`crates/relicario-cli/Cargo.toml:33`). +- **relicario-server** gains an **org mode**: a new `[lib]` target (`classify_path`, `extract_schema_version`) plus the `verify-org-commit` and `generate-org-hook` subcommands — a signature-verifying, path-scoped pre-receive hook (see `docs/SECURITY.md`). +- **extension** org switch + read parity is a tracked follow-up (Dev-D) — `TODO (extension follow-up)`. + +Status: the backend is complete on `main` — core (A) org module, server hook (C), and the full CLI (all 19 `org` subcommands incl. item CRUD) are merged. Deferred: `org add`/`edit` parity for Card/Key/Document/Totp (Login/SecureNote/Identity ship today), and the extension org switch + read parity (`TODO (extension follow-up)`, Dev-D). + ## Build matrix | Target | Tool | Output | When to run | diff --git a/ROADMAP.md b/ROADMAP.md index 7eb8ecf..f8f4aa3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -7,6 +7,7 @@ | Version | Highlights | |---|---| +| *(untagged, 2026-06-20)* | **Enterprise org vault — backend complete** (`7392795`): relicario-core `org` module (ECIES X25519 key wrap/unwrap, `OrgRole`/`OrgMember`/`OrgManifest` types, `filter_for_member`, `schema_version: 1`); relicario-server org hook (`verify-org-commit`: signature verification, path-scoped authz, `enforce_owner_only_elevation` on parent role, `enforce_schema_monotonicity`, `generate-org-hook`, new `[lib]` target); relicario-cli — all 19 `relicario org` subcommands: init, add-member/remove-member/set-role, create-collection/grant/revoke, rotate-key (re-encrypts all blobs), transfer-ownership, delete-org, status, audit, and item CRUD (add/get/list/edit/rm/restore/purge). **Not yet shipped:** `org add`/`edit` for Card/SshKey/Document/Totp; extension org parity (Dev-D); phase 2 (SSO/LDAP, read audit, per-collection subkeys, HTTP plane). | | v0.7.0 *(2026-06-01)* | Extension restructure (Plan C) complete — Phases 3/4/6 merged via 3 parallel worktree streams under PM coordination: setup wizard crypto migrated into the SW (`create_vault`/`attach_vault`; `setup.ts` 1230→58 LOC + step registry); `vault.ts` split 1037→194 LOC into 5 focused + 2 support modules; `vault_locked` intercept lifted into `shared/state.ts`; `get_vault_status` SW message + sidebar status indicator closing the last `relicario status` CLI/extension parity gap | | v0.6.0 *(2026-05-30)* | Security audit fixes; device authentication; backup/restore + LastPass import; fullscreen UX Phases 1+2A+2B; v0.5.1 Streams A/B/C (3-column vault layout + bottom-sheet picker + toast system; left-nav settings; Recovery QR end-to-end + setup wizard Style C); 1C-γ (attachments + Document type + device registration + trash + field history); Plan B multi-stream refactor (commands/ split, prompt_or_flag, core/WASM seam); vault-tab management surfaces revamp (settings synced/local split, devices fingerprint, trash purge countdown, field-history polish, item-history-index, `#history/` routing); doc-structure redesign (rename to DESIGN/CRYPTO/docs/FORMATS, scope headers + Next: footers); GPL-3.0-or-later license | | v0.2.0 | Typed-item rewrite (Plans 1A/1B/1C-α/β₁/β₂) | @@ -15,14 +16,19 @@ See `CHANGELOG.md` for tagged-release detail and `STATUS.md` for the per-train c ## Up next -All three 2026-05-04 architecture-review specs are now shipped (CLI restructure = Plan B Cycles 1+2; security polish = Stream A Cycle 1; extension restructure = Plan C Phases 1–6, completed v0.7.0 2026-06-01). The next committed item is: +All three 2026-05-04 architecture-review specs are shipped; enterprise org vault backend is shipped (2026-06-20). Pending items in rough priority order: +- **Org-vault item-type parity** — `org add`/`edit` support for Card, SshKey, Document, Totp (Login/SecureNote/Identity ship today) +- **Extension org parity — read** — org switch + collection-filtered browse in the popup/vault tab (Dev-D, deferred) +- **Extension org parity — write** — `org add`/`edit`/`rm` from the extension - **Phase 4: command palette** — ⌘K global search + action dispatch across the vault tab (no spec yet) ## Medium-term _(promote here once specced)_ +- **Org vault phase 2** — SSO/LDAP federation, read audit log, per-collection subkeys (true cryptographic scope separation per collection), HTTP management plane + ## Long-term / backlog - **Relay server** — encrypted WebSocket relay for multi-device sync without a shared git server diff --git a/STATUS.md b/STATUS.md index 2eb83ab..1585b62 100644 --- a/STATUS.md +++ b/STATUS.md @@ -98,6 +98,30 @@ Plan: `docs/superpowers/plans/2026-05-24-vault-tab-management-surfaces-revamp.md - Item-history-index pane — top-level "items with history" list (`32e1632`) - Sidebar slot wiring + `#history/` route with `#field-history/` legacy normalization (`88d7228`) +### Enterprise org vault — core + server hook + CLI (merged 2026-06-20, `7392795`) + +Spec: `docs/superpowers/specs/2026-06-06-relicario-enterprise-org-vault-design.md`; plan: `docs/superpowers/plans/2026-06-06-enterprise-org-vault.md` + +**relicario-core org module** (`crates/relicario-core/src/org.rs`): `OrgId`, `MemberId`, `OrgRole` (Owner/Admin/Member), `OrgMember`, `OrgMembers`/`OrgCollections`/`OrgMeta`/`OrgManifest`/`OrgManifestEntry` (all `schema_version: 1`); `generate_org_key`; ECIES X25519 key wrap/unwrap (`wrap_org_key` / `unwrap_org_key`) — ed25519→X25519 conversion via `SHA-512(seed)[..32]` + RFC 7748 clamp, ephemeral DH, `SHA-256(dh_shared || ephemeral_pk || recipient_pk)` wrap key, inner cipher delegated to `crate::crypto::encrypt` (XChaCha20-Poly1305, no Argon2id in org path); `OrgManifest::filter_for_member` for collection-scoped manifest views. Vault wrappers: `encrypt_org_manifest` / `decrypt_org_manifest` in `vault.rs`. 5 acceptance tests in `crates/relicario-core/tests/org.rs` incl. wrap/unwrap round-trip, revoke-after-rotation, manifest filter, and an RFC 8032 ed25519→X25519 known-answer vector. + +**relicario-server org hook** (`crates/relicario-server/src/{lib.rs,main.rs}`): pure `classify_path` / `extract_schema_version` in new `lib.rs` target; `verify_org_commit` — commit-signature verification against `members.json` ed25519 keys, path-scoped authorization (protected JSON → owner/admin only; `items//…` → slug in signer's grants), `enforce_owner_only_elevation` (parent-role check; guards against privilege self-escalation), `enforce_schema_monotonicity` (schema_version must not decrease; merge commits rejected; genesis allowed); `generate-org-hook` subcommand emits a wrapper script. New `[lib]` target added to `relicario-server` crate. + +**relicario-cli — all 19 `relicario org` subcommands** (`crates/relicario-cli/src/{org_session.rs,commands/org.rs,device.rs}`): `org_session.rs` provides `UnlockedOrgVault` (org key in `Zeroizing`), collection-scoped `item_path`, fingerprint-based member match, `atomic_write`, `org_git_run` (signed commits — does NOT suppress `commit.gpgsign`). + +Admin/lifecycle commands: `init` (structure + wrap + `configure_git_signing` + signed bootstrap commit), `add-member` / `remove-member` / `set-role` (owner-only escalation guard), `create-collection` / `grant` / `revoke`, `rotate-key` (fresh key + re-wrap all members + re-encrypt every `items//.enc` blob + manifest, concurrent-rotation abort, `Relicario-Action: key-rotate`), `transfer-ownership`, `delete-org`, `status`, `audit` (verified-signer attribution + TAMPERED flag). + +Item CRUD commands (B9–B14): `org add` (`OrgAddKind`: Login/SecureNote/Identity; card/key/document/totp deferred — see below), `org get [--show]`, `org list [--trashed]`, `org edit [--title/--username/…]`, `org rm`, `org restore`, `org purge`. All ops are collection-scoped + grant-enforced; audit trail emits `item-create` / `item-update` / `item-delete` / `item-restore` / `item-purge`. + +**A5 doc-fix** (`enforce_owner_only_elevation` parent-role close, `519e503`) and this living-docs sweep also landed. + +**Tracked follow-ups (deferred, not shipped):** +- `org add` / `org edit` parity for Card, SshKey, Document, Totp item types (Login/SecureNote/Identity only today; `get`/`list` can display all types if present) +- Extension org-vault switch + read parity (Dev-D deferred) +- Extension org write operations +- Phase 2: SSO/LDAP federation, read audit log, per-collection subkeys (true cryptographic scope separation), HTTP management plane + +**Known limitations (by design in phase 1):** shared org master key — reads are not cryptographically scoped per collection (hook scopes writes; client filters manifest); no read audit (git records writes only); `delete-org` is a local tombstone only (hook rejects protected-file deletion on push). + ### Extension restructure — Plan C Phases 3, 4, 6 (merged 2026-05-31 → 06-01, v0.7.0) Spec: `docs/superpowers/specs/2026-05-04-extension-restructure-design.md` @@ -143,6 +167,12 @@ Per the 2026-05-30 post-v0.6.0 audit of the three 2026-05-04 architecture-review - **Security polish** (`2026-05-04-security-polish-design.md`) — *already shipped* as Stream A Cycle 1 (`89090a8`) plus follow-ups (`0c9387f` start.sh fourth window, `229e483` recovery_qr.rs docs). All four phases done. - **Extension restructure** (`2026-05-04-extension-restructure-design.md`, plan `docs/superpowers/plans/2026-05-30-extension-restructure.md`) — ✅ **COMPLETE** (all six phases merged; see the dated landing section above). Phases 1/2/5 merged 2026-05-30; Phases 3/4/6 merged 2026-05-31 → 06-01. Final tree: 423/423 vitest, build:all clean. v0.7.0 versions bumped; tag pending. -Beyond extension restructure, ROADMAP medium-term holds Phase 4 command palette (no spec yet). Long-term: relay server, mobile. +**Enterprise org vault** — ✅ **COMPLETE (backend)** — all 19 CLI subcommands + core + server hook merged `7392795` 2026-06-20. Deferred follow-ups tracked in the landing section above. -See `ROADMAP.md` for the longer arc and `CHANGELOG.md` for tagged-release history (current head: `v0.6.0`; the `v0.7.0` entry covers this extension-restructure completion). +Pending org-vault follow-ups (in rough priority order): +- `org add`/`edit` parity for Card, SshKey, Document, Totp +- Extension org switch + read parity (Dev-D) +- Extension org write operations +- **Phase 4: command palette** — ⌘K global search + action dispatch across the vault tab (no spec yet) + +Long-term: relay server, mobile. See `ROADMAP.md` for the longer arc and `CHANGELOG.md` for tagged-release history (current head: `v0.6.0`; the `v0.7.0` entry covers extension-restructure completion). diff --git a/crates/relicario-cli/ARCHITECTURE.md b/crates/relicario-cli/ARCHITECTURE.md index d1770ff..76f73ca 100644 --- a/crates/relicario-cli/ARCHITECTURE.md +++ b/crates/relicario-cli/ARCHITECTURE.md @@ -71,6 +71,47 @@ under `src/commands/`. Each source file has one job. hatches `RELICARIO_TEST_PASSPHRASE` (`session.rs:42`) and `RELICARIO_IMAGE` (`session.rs:125`) that integration tests use to bypass the TTY. +- **`src/org_session.rs`** — `UnlockedOrgVault`, the org-vault analogue of + `session.rs`. Holds the org master key in `Zeroizing<[u8; 32]>` for one CLI + invocation, recovered by unwrapping `keys/.enc` with the device + ed25519 seed. `open_org_vault` calls `crate::device::current_device_seed()` + directly (`device.rs`) — a duplicate private fn that previously existed in + `org_session.rs` was removed during the A5 sweep (implementations were + identical). Owns the **collection-scoped** `item_path` + (`items//.enc` — the leading slug is what the pre-receive + hook authorizes against, never decrypting), fingerprint-based member matching + (`relicario_core::fingerprint`, tolerant of OpenSSH whitespace/comment + differences), `atomic_write`, and `org_git_run`. Note `org_git_run` runs + **bare git** — unlike `helpers::git_run` it does NOT inject + `commit.gpgsign=false`, because org commits MUST be signed (the hook verifies + every commit's signature); signing config is established by + `configure_git_signing` during `org init`. + +- **`src/commands/org.rs`** — the `relicario org` subcommand surface. Full + 19-subcommand surface is merged and wired via `Commands::Org` in `main.rs`. + + *Admin / lifecycle (12):* `init` (structure + wrap + `configure_git_signing` + + signed bootstrap commit), `add-member` / `remove-member` / `set-role` + (owner-only escalation guard), `create-collection` / `grant` / `revoke`, + `rotate-key` (`run_rotate_key`, `commands/org.rs:332` — fresh key, re-wrap for + all members, re-encrypt every item blob + manifest under the new key, + concurrent-rotation abort), `transfer-ownership`, `delete-org`, `status` / + `audit` (verified-signer attribution + `TAMPERED` flag). + + *Item CRUD (7):* `org add` creates typed items via `OrgAddKind` + (`commands/org.rs:749`) — **Login / SecureNote / Identity only**; Card / + SshKey / Document / Totp creation is a deferred follow-up. `get` / `list` can + display any item type if present. `org get [--show]` masks secrets + unless `--show`; `org list [--trashed]` filters by the caller's collection + grants; `org edit ` is flag-driven (blank flags keep current values); + `org rm` soft-deletes, `org restore` undoes, `org purge` permanently removes + the encrypted blob. All item ops are collection-scoped and grant-enforced. The + audit trail emits `item-create` / `item-update` / `item-delete` / + `item-restore` / `item-purge`. + + Deferred: Card / SshKey / Document / Totp `org add` / `edit` parity; + extension org reads and writes (Dev-D). + - **`src/helpers.rs`** (`helpers.rs:1-101`) — pure, no-state plumbing: `find_vault_dir_from` (`helpers.rs:14-28`) walks up parent directories looking for a `.relicario/` marker; `vault_dir` and `relicario_dir` wrap it diff --git a/crates/relicario-cli/src/org_session.rs b/crates/relicario-cli/src/org_session.rs index 6b3357d..f0c0176 100644 --- a/crates/relicario-cli/src/org_session.rs +++ b/crates/relicario-cli/src/org_session.rs @@ -39,8 +39,14 @@ impl UnlockedOrgVault { } pub fn members_path(&self) -> PathBuf { self.root.join("members.json") } pub fn collections_path(&self) -> PathBuf { self.root.join("collections.json") } + // OrgMeta accessors — part of the UnlockedOrgVault path/loader API surface + // (parallel to members_path/collections_path + load_members), retained for + // completeness. No command consumes org.json yet; surfacing the org + // name/id in `org status` is a tracked follow-up, so allow until then. + #[allow(dead_code)] pub fn org_meta_path(&self) -> PathBuf { self.root.join("org.json") } + #[allow(dead_code)] pub fn load_meta(&self) -> Result { let s = fs::read_to_string(self.org_meta_path()).context("read org.json")?; Ok(serde_json::from_str(&s).context("parse org.json")?) @@ -185,7 +191,7 @@ pub fn open_org_vault(dir_flag: Option<&std::path::Path>) -> Result Result { } /// Recover the active device's ed25519 seed (the 32-byte private scalar source) -/// from its OpenSSH `signing.key`, for ECIES unwrap. -fn current_device_seed() -> Result> { - let name = crate::device::current_device()? - .ok_or_else(|| anyhow::anyhow!("no active device — run `relicario device add` first"))?; - let key_pem = crate::device::load_signing_key(&name)?; - let private = ssh_key::PrivateKey::from_openssh(key_pem.as_str()) - .map_err(|e| anyhow::anyhow!("parse device signing key: {e}"))?; - let ed = private - .key_data() - .ed25519() - .ok_or_else(|| anyhow::anyhow!("device signing key is not ed25519"))?; - // Ed25519PrivateKey derefs to its 32-byte seed. - let seed_bytes: &[u8] = ed.private.as_ref(); - if seed_bytes.len() != 32 { - anyhow::bail!("ed25519 seed has wrong length: {}", seed_bytes.len()); - } - let mut seed = Zeroizing::new([0u8; 32]); - seed.copy_from_slice(seed_bytes); - Ok(seed) -} - pub(crate) fn atomic_write(path: &Path, data: &[u8]) -> Result<()> { let mut tmp = path.as_os_str().to_owned(); tmp.push(".tmp"); diff --git a/crates/relicario-cli/tests/org_init_signing.rs b/crates/relicario-cli/tests/org_init_signing.rs index 01a6926..725250d 100644 --- a/crates/relicario-cli/tests/org_init_signing.rs +++ b/crates/relicario-cli/tests/org_init_signing.rs @@ -3,6 +3,10 @@ use std::path::Path; use std::process::Command; use tempfile::TempDir; +// Base runner kept as the documented counterpart to relicario_with_git_identity +// below (every test in this file needs the git identity, so only the _with_ +// variant is currently called). +#[allow(dead_code)] fn relicario(config_home: &Path, args: &[&str]) -> std::process::Output { Command::new(env!("CARGO_BIN_EXE_relicario")) .env("XDG_CONFIG_HOME", config_home) diff --git a/crates/relicario-core/ARCHITECTURE.md b/crates/relicario-core/ARCHITECTURE.md index dc3c3da..81d762a 100644 --- a/crates/relicario-core/ARCHITECTURE.md +++ b/crates/relicario-core/ARCHITECTURE.md @@ -103,6 +103,26 @@ Pipeline" and "Crate Layout"). auth factor. Owns its own `YChannel`, `EmbedRegion`, 8×8 DCT/IDCT, Quantization Index Modulation, and crop-recovery extractor. No other module imports it; it is consumed only via the public re-export from `lib.rs`. +- **`org.rs`** — Org-vault data model and ECIES key-wrapping layer + (`crates/relicario-core/src/org.rs`). Types: `OrgId` (L15), `MemberId` + (L19; `is_valid` L41 — 16 lowercase hex), `OrgRole` (L54; + `can_manage_members` L61 = Owner | Admin, `can_manage_owners` L64 = Owner + only), `OrgMember` (L72; carries `ed25519_pubkey` in OpenSSH wire format, + `collections` grant list, `role`), `OrgMembers` (L86; `schema_version: 1` + L93; `validate` L104), `CollectionDef` (L123), `OrgCollections` (L131; + `schema_version: 1` L138; `validate` L145 rejects empty / `/` / `.` slugs), + `OrgMeta` (L164; `schema_version: 1` L174), `OrgManifestEntry` (L185; + carries `collection` slug plus id/type/title/tags/modified/trashed\_at), + `OrgManifest` (L199; `schema_version: 1` L206; `filter_for_member` L210 + returns only entries whose collection slug appears in the member's grants). + All four JSON containers carry `schema_version: 1` — distinct from the + personal `Manifest` whose `MANIFEST_SCHEMA_VERSION = 2` (`manifest.rs:12`). + Crypto: `generate_org_key` (L230) → `Zeroizing<[u8;32]>` (256-bit + CSPRNG org master key); `wrap_org_key` (L265) / `unwrap_org_key` (L299) — + ECIES over X25519, described in detail under **Invariants & contracts** + below. `vault.rs` adds `encrypt_org_manifest` / `decrypt_org_manifest` typed + wrappers (JSON-serialize → `crypto::encrypt` under the org key, plaintext in + `Zeroizing`) consistent with the personal-vault pattern. - **`backup.rs`** — `.relbak` v1 container format: `pack_backup` / `unpack_backup` plus the `BackupInput` / `BackupOutput` / `BackupItem` / `BackupAttachment` shapes. Wraps a zstd-compressed JSON envelope of vault @@ -230,6 +250,28 @@ Pipeline" and "Crate Layout"). also used to derive the key for *unlock*, not just create). - **`SymbolCharset::Custom` must be ASCII-only** (`generators.rs:46-52`). Non-ASCII custom charsets are rejected with `RelicarioError::Format`. +- **ECIES wrap-blob layout is fixed** at + `ephemeral_x25519_pk(32) || version(1) || nonce(24) || ciphertext+tag` + (`org.rs:264`). The `version(1)` byte is the same `VERSION_BYTE = 0x02` + emitted by `crypto::encrypt`, which is what occupies that slot — the layout + merely names the regions for clarity. +- **KDF wrap key = `SHA-256(dh_shared || ephemeral_pk || recipient_pk)`** + (`org.rs:278-281`). The concatenation order is identical in `wrap_org_key` + and `unwrap_org_key`; a mismatch in either direction would produce a + different key and fail the AEAD open. The intermediate `kdf_input` buffer is + held in `Zeroizing>`; `org_key`, `wrap_key`, and the decrypted + `plaintext` from unwrap are also held in `Zeroizing`. +- **ed25519 → X25519 conversion** applies `SHA-512(seed)[..32]` then the + RFC 7748 scalar clamp + (`scalar[0] &= 248; scalar[31] &= 127; scalar[31] |= 64`) to derive the + private X25519 scalar (`org.rs:242`); the recipient public key is obtained + via `ed25519_dalek`'s `to_montgomery()`. This lets device ed25519 keys serve + double duty as X25519 recipients without storing a separate DH key. +- **Org crypto bypasses Argon2id.** The ECIES inner cipher delegates to + `crate::crypto::encrypt` / `decrypt` (XChaCha20-Poly1305, random 24-byte + nonce, `VERSION_BYTE = 0x02`) — no AEAD re-implementation. The X25519 KDF + output is used directly as the AEAD key; the Argon2id path in `crypto.rs` + is not invoked for org key wrapping. ## Key flows @@ -315,6 +357,35 @@ when subsequent `decrypt_*` returns `RelicarioError::Decrypt`. call `item.prune_history(&settings.field_history_retention, now_unix())` when they want to enforce the policy. +### Org key wrap / unwrap + +1. **Wrap** (`org.rs:265`): caller supplies a recipient's OpenSSH ed25519 + public key string. + - Parse the OpenSSH wire format via `ssh-key` to recover the raw 32-byte + ed25519 public key bytes; apply `to_montgomery()` (ed25519-dalek) to + obtain the recipient's X25519 public key. + - Generate an ephemeral X25519 keypair from `OsRng`. + - `dh_shared = ephemeral_secret × recipient_x25519_pk` (X25519 DH). + - `wrap_key = SHA-256(dh_shared || ephemeral_pk || recipient_pk)` + (`org.rs:278-281`), intermediates in `Zeroizing`. + - `ct = crate::crypto::encrypt(&wrap_key, &org_key)` — yields the standard + `version(1) || nonce(24) || ciphertext+tag` blob. + - Return `ephemeral_x25519_pk(32) || ct` (`org.rs:264`). +2. **Unwrap** (`org.rs:299`): caller supplies the device ed25519 seed bytes + (from `current_device_seed` in the CLI layer, not from `relicario-core`). + - Derive X25519 private scalar from seed: `SHA-512(seed)[..32]` + RFC 7748 + clamp (`org.rs:242`). + - Slice the first 32 bytes of the blob as `ephemeral_pk`; read recipient's + own X25519 public key via the same `to_montgomery()` path. + - `dh_shared = device_x25519_secret × ephemeral_pk`. + - Reconstruct `wrap_key` identically; `crypto::decrypt` recovers `org_key` + into `Zeroizing`. + +Integration tests: `crates/relicario-core/tests/org.rs` (5 acceptance tests +covering wrap/unwrap round-trip, revoked-after-rotation, and manifest +`filter_for_member`). A pinned RFC 8032 ed25519→X25519 known-answer vector +lives in the `#[cfg(test)]` block inside `org.rs` itself. + ### imgsecret embed 1. Caller passes a JPEG byte slice and a 32-byte secret to diff --git a/docs/CRYPTO.md b/docs/CRYPTO.md index 04bac34..18e6aba 100644 --- a/docs/CRYPTO.md +++ b/docs/CRYPTO.md @@ -123,6 +123,157 @@ master_key ────────►│ XChaCha20 │────── └──────────────────┘ ``` +## Org-key ECIES wrap/unwrap + +Org vaults use a different key-derivation path than personal vaults. There is no +passphrase, no reference JPEG, and no Argon2id involved. Instead, each org has a +single random **org master key** that is wrapped per-member using X25519 ECIES and +stored as an opaque blob in `keys/.enc` inside the org repo. + +### Org master key + +``` +generate_org_key() (org.rs:230) + → OsRng → 256-bit random + → Zeroizing<[u8; 32]> (held in memory; never written in the clear) +``` + +One org key per org. It is re-generated on every `org rotate-key` operation. + +### ed25519 → X25519 conversion + +Each Relicario device holds an ed25519 signing key. To participate in ECIES the +ed25519 key pair must be mapped to X25519: + +``` +Recipient public key (for wrap): + ed25519 VerifyingKey + → .to_montgomery() (birational Montgomery map, ed25519_dalek) + → X25519 PublicKey + +Recipient secret key (for unwrap): + ed25519 seed (32 bytes) + → SHA-512(seed)[..32] (org.rs:241–242) + → RFC 7748 clamp: + scalar[0] &= 248 + scalar[31] &= 127 + scalar[31] |= 64 + → x25519_dalek::StaticSecret +``` + +The RFC 7748 clamp and the `to_montgomery()` birational map are the standard +construction; a pinned RFC 8032 known-answer vector is verified in the unit tests +inside `org.rs`. + +### Wrap flow (one blob per member) + +``` + ┌──────────────────────────────────────┐ + │ wrap_org_key() │ (org.rs:265) + │ │ + org_key ──────────►│ EphemeralSecret::random (OsRng) │ + │ ephemeral_pk = PublicKey::from(eph) │ + │ │ + recipient_pk ─────►│ DH: eph_sk.diffie_hellman(rec_pk) │ + │ → dh_shared (32 bytes) │ + │ │ + │ kdf_input = dh_shared │ + │ ‖ ephemeral_pk (32 B) │ (org.rs:278–281) + │ ‖ recipient_pk (32 B) │ + │ wrap_key = SHA-256(kdf_input) │ + │ (kdf_input in Zeroizing>) │ + │ (wrap_key in Zeroizing<[u8;32]>) │ + │ │ + │ encrypted = crate::crypto::encrypt │ + │ (wrap_key, org_key) │ + │ → version(1) ‖ nonce(24) ‖ ct+tag │ + │ │ + │ output: ephemeral_pk(32) │ (org.rs:264) + │ ‖ version(1) │ + │ ‖ nonce(24) │ + │ ‖ ciphertext + tag │ + └──────────────────────────────────────┘ + │ + ▼ + keys/.enc (in org repo) +``` + +### Unwrap flow + +``` + ┌──────────────────────────────────────┐ + │ unwrap_org_key() │ (org.rs:299) + │ │ + wrapped blob ─────►│ split: ephemeral_pk(32) + rest │ + │ │ + ed25519_seed ─────►│ ed25519_seed_to_x25519_secret() │ + │ → recipient_sk + recipient_pk │ + │ │ + │ DH: recipient_sk.diffie_hellman(eph)│ + │ → dh_shared │ + │ │ + │ kdf_input + SHA-256 → wrap_key │ + │ (same domain-separated KDF as wrap) │ + │ │ + │ plaintext = crate::crypto::decrypt │ + │ (wrap_key, rest) │ + │ → Zeroizing<[u8;32]> org_key │ + └──────────────────────────────────────┘ +``` + +### Key distinction: no Argon2id + +Unlike the personal vault, **org crypto bypasses Argon2id entirely**: + +| | Personal vault | Org vault | +|---|---|---| +| Key origin | Argon2id(passphrase ‖ image_secret, salt) | OsRng → 256-bit random | +| Key transport | Embedded in reference JPEG (stego) | X25519 ECIES wrap blob | +| AEAD primitive | XChaCha20-Poly1305 (`crate::crypto::encrypt`) | Same primitive (delegated) | +| KDF for wrap key | Argon2id | SHA-256(DH ‖ eph_pk ‖ rec_pk) | + +The inner AEAD (`crate::crypto::encrypt` / `decrypt`) is **not re-implemented** in +the org module — it is called directly, so org item blobs share the identical +`version(1) ‖ nonce(24) ‖ ct+tag` wire format (`VERSION_BYTE = 0x02`, +`crates/relicario-core/src/crypto.rs:59`). + +### Zeroize discipline + +All intermediates that carry key material are dropped through `Zeroizing`: + +- `org_key` — `Zeroizing<[u8; 32]>` everywhere it is passed +- `kdf_input` — `Zeroizing>` (org.rs:278) +- `wrap_key` — `Zeroizing<[u8; 32]>` +- decrypt `plaintext` in `unwrap_org_key` — `Zeroizing>` + +### Key rotation and re-encryption + +`org rotate-key` (`crates/relicario-cli/src/commands/org.rs:332`) does more than +generate a fresh org key: + +``` +run_rotate_key() + 1. git pull --rebase (detect concurrent rotation → abort if non-fast-forward) + 2. generate_org_key() → new_org_key + 3. wrap_org_key(new_org_key, member_pk) for every current member + → overwrites keys/.enc + 4. re-encrypt every items//.enc blob under new_org_key + 5. re-encrypt manifest.enc under new_org_key + 6. git add + git commit via org_git_run (signed; Relicario-Action: key-rotate) +``` + +`rotate-key` pulls (`--rebase`) at the start to pick up concurrent changes and +abort on a conflicting concurrent rotation, then commits locally; it does **not** +push. Publishing the rotation to the remote is a separate step (the normal git +sync path), the same way personal-vault mutations commit locally and sync later. + +Re-encryption of every item blob (step 4) is deliberate: a removed member who holds +a local clone of the repo cannot decrypt any item written after the rotation, because +those blobs are sealed under a key they never received. Without re-encryption, all +pre-rotation blobs would remain readable to the former member indefinitely. + +The item-CRUD commands (`org add`/`get`/`list`/`edit`/`rm`/`restore`/`purge`) that read and write these blobs are merged and wired into `main.rs`; each operates under the org master key recovered by `unwrap_org_key`. + ## imgsecret DCT Embedding ``` diff --git a/docs/FORMATS.md b/docs/FORMATS.md index e35a63c..a8387a0 100644 --- a/docs/FORMATS.md +++ b/docs/FORMATS.md @@ -71,6 +71,60 @@ An empty array (`[]`) puts the pre-receive hook in bootstrap mode (all pushes ac Commits by `public_key` at or after `revoked_at` (Unix seconds) are rejected by the pre-receive hook. Commits before `revoked_at` remain valid (they were authorized at the time). +## Org vault repo formats + +The org vault is a **separate git repository** alongside the personal vault. It is not nested inside `.relicario/`. Its layout: + +``` +org.json # OrgMeta (schema_version, org_id, display_name, created_at) +members.json # PUBLIC/unencrypted member directory +collections.json # collection definitions +keys/.enc # org master key wrapped to that member's device key +manifest.enc # OrgManifest (schema_version 1, per-member-filtered) +items//.enc # collection-scoped item blobs +``` + +### `org.json` — OrgMeta + +Unencrypted JSON (`OrgMeta`, `org.rs:164`). `schema_version: 1` (`org.rs:174`). Fields: `schema_version`, `org_id`, `display_name`, `created_at` (Unix seconds). + +### `members.json` — OrgMembers + +Unencrypted JSON array of `OrgMember` records (`org.rs:72`); container type `OrgMembers` carries `schema_version: 1` (`org.rs:93`). Per-member fields: `member_id` (16 lowercase hex chars), `display_name`, `role` (one of `owner | admin | member`), `ed25519_pubkey` (OpenSSH wire string), `collections` (array of granted slug strings), `added_at`, `added_by`. Roles are not secrets — authorization to read this file is not required to verify signatures. + +### `collections.json` — OrgCollections + +Unencrypted JSON; `schema_version: 1` (`org.rs:138`). Contains a list of `CollectionDef` records (`org.rs:123`). Validation (`org.rs:145`) rejects slugs that are empty, contain `/`, or equal `.`. + +### `keys/.enc` — wrapped org master key + +Binary blob; NOT a standard `.enc` blob. Layout (`org.rs:264`): + +``` +┌──────────────────────────┬─────────┬────────┬──────────────────────┐ +│ ephemeral_x25519_pubkey │ version │ nonce │ ciphertext + tag │ +│ 32 bytes │ 1 byte │24 bytes│ N + 16 bytes │ +└──────────────────────────┴─────────┴────────┴──────────────────────┘ +``` + +- The wrapping key is `SHA-256(dh_shared || ephemeral_pubkey || recipient_pubkey)` (`org.rs:278–281`), held in `Zeroizing>`. +- The inner AEAD (`version || nonce || ciphertext+tag`) is produced by `crate::crypto::encrypt` — the same XChaCha20-Poly1305 framing used for personal `.enc` blobs (see **Encrypted blob** above). `VERSION_BYTE = 0x02` applies here too. +- The X25519 private scalar is derived from the device ed25519 seed via `SHA-512(seed)[..32]` with RFC 7748 clamping (`org.rs:242`). Argon2id is **not** involved — the wrapping key is derived entirely from the X25519 DH exchange. + +### `manifest.enc` — OrgManifest + +Encrypted with the org master key using `crypto::encrypt` (standard `.enc` framing). Decrypts to `OrgManifest` JSON (`org.rs:199`); `schema_version: 1` (`org.rs:206`). Each `OrgManifestEntry` (`org.rs:185`) carries: `id`, `type`, `title`, `tags`, `modified`, `trashed_at`, and a `collection` slug field. The `collection` field distinguishes this type from `ManifestEntry` in the personal vault. + +Contrast with the personal vault manifest: `Manifest` uses `MANIFEST_SCHEMA_VERSION = 2` (`manifest.rs:12`) and `ManifestEntry` has no `collection` field. The two types are distinct and do not share a schema. + +### `items//.enc` + +Standard `.enc` blob (see **Encrypted blob** above), encrypted under the org master key. The blob itself does **not** name its collection — the directory path segment carries the slug. This allows the pre-receive hook (`relicario-server`) to authorize a write by path segment without decrypting the blob. + +These blobs are written and read by the `relicario org` item commands (`org add` / `get` / `list` / `edit` / `rm` / `restore` / `purge`), all collection-scoped and grant-enforced. `org add` currently creates Login / SecureNote / Identity items; `get` / `list` display any item type present. + +**TODO (extension follow-up):** extension UI for browsing and editing org vault items. **Deferred:** `org add` / `edit` parity for Card / Key / Document / Totp item types. + ## Item IDs and Field IDs | Kind | Length | Entropy | Source | diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 4899e35..c9b04b4 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -74,6 +74,117 @@ Without device authentication, access control is transport-layer only: Device registration is optional but recommended for shared vaults. +## Org vault security + +An org vault is a separate git repository alongside the personal vault. It +uses ed25519 commit-signing and a server-side pre-receive hook to make +least-privilege access control server-enforced, not advisory. + +### Org device-key authentication + +Every org member registers an ed25519 device key. The key appears in +`members.json` as an OpenSSH public-key string alongside the member's role +and collection grants. Fingerprint matching is done via +`relicario_core::fingerprint`, which normalises the OpenSSH format so that +whitespace and comment differences do not create phantom mismatches. + +Org access requires two things at once: a wrapped key blob (`keys/.enc`) +and the device private key that can unwrap it. There is no org passphrase — +removing a member's blob and rotating the org master key is sufficient to +revoke access (see **Key rotation** below). Device keys are completely +separate from the personal vault's KDF inputs; revoking org access does not +affect the member's personal vault. + +### Pre-receive hook enforcement + +`relicario-server generate-org-hook` (`crates/relicario-server/src/main.rs:511`) +emits a hook script that calls `relicario-server verify-org-commit` for +every pushed commit. Unsigned or structurally invalid commits are rejected +before they land. + +`verify_org_commit` (`main.rs:286`) performs four checks in order: + +1. **Signature verification** — a temporary `allowed_signers` file is + constructed from the current `members.json`; `git verify-commit --raw` + is run and the resulting SHA-256 fingerprint is matched back to a + `members.json` entry. A commit not signed by a *current* member is + rejected outright. + +2. **Path-level write authorisation** — each modified path is classified by + `classify_path` (`crates/relicario-server/src/lib.rs:19`) into + `ProtectedJson` (owner/admin write only), `CollectionItem` (the + `items//…` prefix; write allowed only if the slug appears in the + signer's `collections` grant array), or `Unrestricted`. The write is + authorised if and only if the signer's role and grants satisfy the + classification. Item blobs are authorised by the leading path segment + alone — the ciphertext is never decrypted by the hook. + +3. **Owner-only elevation guard** (`enforce_owner_only_elevation`, + `main.rs:438`) — only a member whose *pre-commit* (parent) role is Owner + may introduce a new member at Owner or Admin level, or promote an + existing member to either. Checking the pre-commit role means an Admin + cannot self-promote in the same commit that writes the escalated + `members.json`; there is no epoch in which the transition is + self-authorised. + +4. **Schema monotonicity** (`enforce_schema_monotonicity`, `main.rs:521`) + — `schema_version` values in org JSON containers may not decrease. + Merge commits are rejected. A genesis commit (no parents) is allowed + only when it is signed by the sole Owner it introduces. + +### Key rotation + +`relicario org rotate-key` generates a fresh 256-bit org master key, +re-wraps it for every current member, and re-encrypts every +`items//.enc` blob and the manifest under the new key in a single +signed commit tagged `Relicario-Action: key-rotate`. A revoked member's +wrapped blob is simply not written during rotation, so they hold a blob that +decrypts to a stale key — they cannot read items encrypted under the new +key. + +### Audit action vocabulary + +The `relicario org audit` command attributes actions to their verified +signer (not to the commit author or trailer value). Each event records two +actors: the **verified** actor resolved from the signing key (authoritative) +and the actor **claimed** by the `Relicario-Actor` trailer (advisory). When the +claimed actor disagrees with the verified signer, the event is flagged +`TAMPERED`. Trailers are advisory metadata; the trustworthy actor is always +the cryptographically verified signer. + +Actions live in two groups: + +- **Membership / collections / lifecycle:** `member-add`, `member-remove`, + `member-role-change`, `collection-create`, `collection-grant`, + `collection-revoke`, `key-rotate`, `org-init`, `ownership-transfer`, + `org-delete`. +- **Item CRUD:** `item-create`, `item-update`, `item-delete` (soft-delete / + trash), `item-restore`, `item-purge` — emitted by the `org add` / `edit` / + `rm` / `restore` / `purge` commands. + +### Honest limitations + +The following are deliberate design boundaries, not oversights: + +- **Shared org master key — reads are not cryptographically scoped per + collection.** The pre-receive hook scopes *writes* by collection path + and the CLI filters the manifest to each member's grants, but a single + org key opens all collection blobs. A member with any grant can, outside + the CLI, decrypt items from collections they are not granted. For true + cryptographic separation, use a separate org vault per access boundary. + Per-collection subkeys are a phase-2 non-goal. + +- **No read audit.** Git records writes only. A member who reads blobs + directly leaves no server-visible trace. + +- **No "hide value."** There is no mechanism to show a member that an item + exists without revealing its field values on decrypt. + +- **`delete-org` is a local tombstone in phase 1.** The schema-monotonicity + check causes the hook to reject protected-file deletion, so an + `org-delete` action cannot be pushed to a hook-protected remote. The + deletion is recorded locally only until a future phase addresses it. + ## Configuration env vars Relicario reads the following environment variables. Each is a trust