merge(docs): A5 living-docs sweep — item-CRUD across FORMATS/CRYPTO/SECURITY/DESIGN/ARCHITECTURE, STATUS shipped, ROADMAP, CHANGELOG; dead_code de-dup

This commit is contained in:
adlee-was-taken
2026-06-20 15:57:38 -04:00
11 changed files with 541 additions and 25 deletions

View File

@@ -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/<member-id>.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/<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. 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 <query> [--show]` masks secrets
unless `--show`; `org list [--trashed]` filters by the caller's collection
grants; `org edit <query>` 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

View File

@@ -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<OrgMeta> {
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<UnlockedOrgV
fs::read(&key_path).with_context(|| format!("read {}", key_path.display()))?;
// Recover the device ed25519 seed and unwrap.
let seed = current_device_seed()?;
let seed = crate::device::current_device_seed()?;
let org_key = relicario_core::unwrap_org_key(&wrapped, &seed)?;
Ok(UnlockedOrgVault { root, org_key })
@@ -202,27 +208,6 @@ fn current_device_fingerprint() -> Result<String> {
}
/// 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<Zeroizing<[u8; 32]>> {
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");

View File

@@ -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)

View File

@@ -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<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
@@ -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