docs(org): pre-stage A5 living-docs for merged core+server+CLI-admin (item-CRUD/extension TODO)
Pre-stages the A5 living-docs sweep for the already-merged A (relicario-core org module) + C (relicario-server pre-receive hook) + CLI admin/rotate/status-audit work, so the final A5 sweep (after Dev-B B9-B14 merges) is fast. Adds org sections to docs/FORMATS.md (org repo wire formats + wrapped-key blob layout), docs/CRYPTO.md (ECIES X25519 wrap/unwrap, no-Argon2id contrast, rotate re-encryption), docs/SECURITY.md (signature-verifying hook, owner-only elevation, audit vocabulary, honest limitations), DESIGN.md (org-master-key secrets row + server org mode + deps), core/cli ARCHITECTURE.md (org module + org_session), and an Unreleased CHANGELOG entry. B item-CRUD (org add/get/list/edit/rm/restore/purge + main.rs wiring) and extension parity are left as explicit TODO. STATUS/ROADMAP mark-shipped and extension/ARCHITECTURE are deferred to the full A5 (track not yet landed; Dev-D deferred). All cited code constants pinned with file:line per living-docs discipline. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TJo44YM3UbBjro2fG6NrKy
This commit is contained in:
34
CHANGELOG.md
34
CHANGELOG.md
@@ -1,5 +1,39 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## Unreleased — enterprise org vault (in progress)
|
||||||
|
|
||||||
|
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 server-enforced.
|
||||||
|
Tracked under `docs/superpowers/plans/2026-06-06-enterprise-org-vault.md`. Entries
|
||||||
|
below cover the **already-merged** core (A) + server (C) + CLI admin work; item CRUD
|
||||||
|
and extension parity land subsequently.
|
||||||
|
|
||||||
|
### 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 dependency
|
||||||
|
`x25519-dalek 2` (`static_secrets`).
|
||||||
|
- **relicario-server org mode**: `verify-org-commit` (signature verification against
|
||||||
|
`members.json`, 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`).
|
||||||
|
- **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 key),
|
||||||
|
`status` / `audit` (verified-signer attribution + `TAMPERED` flag). Org commits are
|
||||||
|
signed (`org_git_run` preserves signing). New `ssh-key` dependency in the CLI.
|
||||||
|
|
||||||
|
### TODO (pending merge)
|
||||||
|
- CLI item CRUD: `org add` / `get` / `list` / `edit` / `rm` / `restore` / `purge`,
|
||||||
|
and the final `Commands::Org` wiring in `main.rs` (Dev-B B9–B14).
|
||||||
|
- Extension org switch + read-only browse parity (Dev-D follow-up).
|
||||||
|
|
||||||
## v0.7.0 — 2026-06-01
|
## v0.7.0 — 2026-06-01
|
||||||
|
|
||||||
Completes the extension restructure (Plan C) begun under v0.6.0. Phases
|
Completes the extension restructure (Plan C) begun under v0.6.0. Phases
|
||||||
|
|||||||
14
DESIGN.md
14
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 |
|
| 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<String>` / `Zeroizing<Vec<u8>>` | 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 |
|
| Item secret (password, card number, etc.) | `Zeroizing<String>` / `Zeroizing<Vec<u8>>` | 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/<name>.key` (mode 0600) | `chrome.storage.local.device_private_key` | — |
|
| Device private key | — | Filesystem under `~/.config/relicario/devices/<name>.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/<member-id>.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/<member-id>.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 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.
|
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/<member-id>.enc` / `manifest.enc` / `items/<collection-slug>/<item-id>.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: core (A) + server hook (C) merged; CLI admin/rotate/status-audit merged; CLI item-CRUD + the final command wiring are `TODO (pending Dev-B B9–B14)`.
|
||||||
|
|
||||||
## Build matrix
|
## Build matrix
|
||||||
|
|
||||||
| Target | Tool | Output | When to run |
|
| Target | Tool | Output | When to run |
|
||||||
|
|||||||
@@ -71,6 +71,30 @@ under `src/commands/`. Each source file has one job.
|
|||||||
hatches `RELICARIO_TEST_PASSPHRASE` (`session.rs:42`) and `RELICARIO_IMAGE`
|
hatches `RELICARIO_TEST_PASSPHRASE` (`session.rs:42`) and `RELICARIO_IMAGE`
|
||||||
(`session.rs:125`) that integration tests use to bypass the TTY.
|
(`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/<member-id>.enc` with the device
|
||||||
|
ed25519 seed (`relicario_core::unwrap_org_key`). Owns the **collection-scoped**
|
||||||
|
`item_path` (`items/<collection-slug>/<id>.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. Merged:
|
||||||
|
`init`, `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), and `status` / `audit` (verified-signer attribution + `TAMPERED`
|
||||||
|
flag). **TODO (pending Dev-B B9–B14):** the item-CRUD commands (`org add` /
|
||||||
|
`get` / `list` / `edit` / `rm` / `restore` / `purge`) and the final
|
||||||
|
`Commands::Org` wiring in `main.rs`. `device.rs` gains
|
||||||
|
`current_device_seed` / `current_device_pubkey` helpers for the ECIES unwrap.
|
||||||
|
|
||||||
- **`src/helpers.rs`** (`helpers.rs:1-101`) — pure, no-state plumbing:
|
- **`src/helpers.rs`** (`helpers.rs:1-101`) — pure, no-state plumbing:
|
||||||
`find_vault_dir_from` (`helpers.rs:14-28`) walks up parent directories
|
`find_vault_dir_from` (`helpers.rs:14-28`) walks up parent directories
|
||||||
looking for a `.relicario/` marker; `vault_dir` and `relicario_dir` wrap it
|
looking for a `.relicario/` marker; `vault_dir` and `relicario_dir` wrap it
|
||||||
|
|||||||
@@ -103,6 +103,26 @@ Pipeline" and "Crate Layout").
|
|||||||
auth factor. Owns its own `YChannel`, `EmbedRegion`, 8×8 DCT/IDCT,
|
auth factor. Owns its own `YChannel`, `EmbedRegion`, 8×8 DCT/IDCT,
|
||||||
Quantization Index Modulation, and crop-recovery extractor. No other module
|
Quantization Index Modulation, and crop-recovery extractor. No other module
|
||||||
imports it; it is consumed only via the public re-export from `lib.rs`.
|
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` /
|
- **`backup.rs`** — `.relbak` v1 container format: `pack_backup` /
|
||||||
`unpack_backup` plus the `BackupInput` / `BackupOutput` / `BackupItem` /
|
`unpack_backup` plus the `BackupInput` / `BackupOutput` / `BackupItem` /
|
||||||
`BackupAttachment` shapes. Wraps a zstd-compressed JSON envelope of vault
|
`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).
|
also used to derive the key for *unlock*, not just create).
|
||||||
- **`SymbolCharset::Custom` must be ASCII-only** (`generators.rs:46-52`).
|
- **`SymbolCharset::Custom` must be ASCII-only** (`generators.rs:46-52`).
|
||||||
Non-ASCII custom charsets are rejected with `RelicarioError::Format`.
|
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<Vec<u8>>`; `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
|
## Key flows
|
||||||
|
|
||||||
@@ -315,6 +357,35 @@ when subsequent `decrypt_*` returns `RelicarioError::Decrypt`.
|
|||||||
call `item.prune_history(&settings.field_history_retention, now_unix())`
|
call `item.prune_history(&settings.field_history_retention, now_unix())`
|
||||||
when they want to enforce the policy.
|
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
|
### imgsecret embed
|
||||||
|
|
||||||
1. Caller passes a JPEG byte slice and a 32-byte secret to
|
1. Caller passes a JPEG byte slice and a 32-byte secret to
|
||||||
|
|||||||
151
docs/CRYPTO.md
151
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/<member-id>.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<Vec<u8>>) │
|
||||||
|
│ (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/<member-id>.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<Vec<u8>>` (org.rs:278)
|
||||||
|
- `wrap_key` — `Zeroizing<[u8; 32]>`
|
||||||
|
- decrypt `plaintext` in `unwrap_org_key` — `Zeroizing<Vec<u8>>`
|
||||||
|
|
||||||
|
### 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/<member-id>.enc
|
||||||
|
4. re-encrypt every items/<slug>/<id>.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.
|
||||||
|
|
||||||
|
> **TODO (pending Dev-B B9–B14):** item-CRUD commands (`org add`/`get`/`list`/`edit`/`rm`/`restore`/`purge`) and the final `Commands::Org` wiring in `main.rs` are not yet merged.
|
||||||
|
|
||||||
## imgsecret DCT Embedding
|
## imgsecret DCT Embedding
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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).
|
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/<member-id>.enc # org master key wrapped to that member's device key
|
||||||
|
manifest.enc # OrgManifest (schema_version 1, per-member-filtered)
|
||||||
|
items/<collection-slug>/<item-id>.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/<member-id>.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<Vec<u8>>`.
|
||||||
|
- 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/<collection-slug>/<item-id>.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.
|
||||||
|
|
||||||
|
**TODO (pending Dev-B B9–B14):** CLI commands for creating, reading, editing, and deleting org items (`org add` / `get` / `list` / `edit` / `rm` / `restore` / `purge`) are not yet wired in `main.rs`.
|
||||||
|
|
||||||
|
**TODO (extension follow-up):** extension UI for browsing and editing org vault items.
|
||||||
|
|
||||||
## Item IDs and Field IDs
|
## Item IDs and Field IDs
|
||||||
|
|
||||||
| Kind | Length | Entropy | Source |
|
| Kind | Length | Entropy | Source |
|
||||||
|
|||||||
111
docs/SECURITY.md
111
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.
|
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/<member-id>.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/<slug>/…` 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/<slug>/<id>.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:
|
||||||
|
|
||||||
|
- **Live (merged A + C streams):** `member-add`, `member-remove`,
|
||||||
|
`member-role-change`, `collection-create`, `collection-grant`,
|
||||||
|
`collection-revoke`, `key-rotate`, `org-init`, `ownership-transfer`,
|
||||||
|
`org-delete`.
|
||||||
|
- **TODO (pending Dev-B B9–B13):** `item-create`, `item-update`,
|
||||||
|
`item-delete`, `item-restore`, `item-purge` — the emitter code lands with
|
||||||
|
the item-CRUD command stream.
|
||||||
|
|
||||||
|
### 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
|
## Configuration env vars
|
||||||
|
|
||||||
Relicario reads the following environment variables. Each is a trust
|
Relicario reads the following environment variables. Each is a trust
|
||||||
|
|||||||
Reference in New Issue
Block a user