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:
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user