Compare commits
11 Commits
feature/or
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
50b5c01291 | ||
|
|
3871da383d | ||
|
|
44d61ae7a7 | ||
|
|
0cd417ded7 | ||
|
|
8bb1d779c4 | ||
|
|
739279515a | ||
|
|
6123d8b033 | ||
|
|
057a7defe5 | ||
|
|
2acd57a4a5 | ||
|
|
87b1d166c2 | ||
|
|
6a16523ee0 |
49
CHANGELOG.md
49
CHANGELOG.md
@@ -1,5 +1,54 @@
|
||||
# Changelog
|
||||
|
||||
## v0.8.0 — 2026-06-20 — 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 <query> [--show]` (secrets masked
|
||||
by default; renders Login/SecureNote/Identity/Card/Document/Totp), `org list
|
||||
[--trashed]` (manifest filtered to your collection grants), `org edit <query>`
|
||||
(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
|
||||
|
||||
6
Cargo.lock
generated
6
Cargo.lock
generated
@@ -2156,7 +2156,7 @@ checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
|
||||
|
||||
[[package]]
|
||||
name = "relicario-cli"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arboard",
|
||||
@@ -2188,7 +2188,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "relicario-core"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"argon2",
|
||||
"base64",
|
||||
@@ -2235,7 +2235,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "relicario-wasm"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"ed25519-dalek",
|
||||
|
||||
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 |
|
||||
| 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` | — |
|
||||
| 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 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: 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 |
|
||||
|
||||
@@ -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/<id>` 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
|
||||
|
||||
34
STATUS.md
34
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/<id>` route with `#field-history/<id>` 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>/…` → 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/<slug>/<id>.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 <query> [--show]`, `org list [--trashed]`, `org edit <query> [--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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "relicario-cli"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
edition = "2021"
|
||||
description = "CLI for relicario password manager"
|
||||
license = "GPL-3.0-or-later"
|
||||
|
||||
@@ -6,7 +6,8 @@ use std::path::Path;
|
||||
use anyhow::{Context, Result};
|
||||
use relicario_core::{
|
||||
generate_org_key, wrap_org_key,
|
||||
CollectionDef, MemberId, OrgCollections, OrgManifest, OrgMembers, OrgMeta, OrgRole, OrgMember,
|
||||
CollectionDef, Item, ItemCore, MemberId, OrgCollections, OrgManifest, OrgMembers, OrgMeta,
|
||||
OrgRole, OrgMember,
|
||||
encrypt_org_manifest,
|
||||
};
|
||||
|
||||
@@ -441,6 +442,73 @@ pub fn run_rotate_key(dir: &Path) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn run_transfer_ownership(dir: &Path, member_id_prefix: &str, keep_owner: bool) -> Result<()> {
|
||||
let vault = crate::org_session::open_org_vault(Some(dir))?;
|
||||
let caller = vault.current_member()?;
|
||||
if !caller.role.can_manage_owners() {
|
||||
anyhow::bail!("only an owner can transfer ownership");
|
||||
}
|
||||
let mut members = vault.load_members()?;
|
||||
let target_id = resolve_member_id(&members, member_id_prefix)?;
|
||||
if target_id == caller.member_id {
|
||||
anyhow::bail!("you are already the owner");
|
||||
}
|
||||
// Promote the target to Owner.
|
||||
{
|
||||
let target = members.find_by_id_mut(&target_id)
|
||||
.ok_or_else(|| anyhow::anyhow!("member not found"))?;
|
||||
target.role = OrgRole::Owner;
|
||||
}
|
||||
// Real transfer: also demote the CALLER to Admin, unless --keep-owner was
|
||||
// passed (explicit co-ownership). The spec says "owner → another member",
|
||||
// so demotion is the default.
|
||||
if !keep_owner {
|
||||
if let Some(me) = members.find_by_id_mut(&caller.member_id) {
|
||||
me.role = OrgRole::Admin;
|
||||
}
|
||||
}
|
||||
vault.save_members(&members)?;
|
||||
|
||||
let mode = if keep_owner { "co-ownership (caller kept owner)" } else { "caller demoted to admin" };
|
||||
let commit_msg = format!(
|
||||
"org: transfer ownership to {} ({mode})\n\nRelicario-Actor: {} {}\nRelicario-Action: ownership-transfer\nRelicario-Member: {}",
|
||||
target_id.as_str(), caller.display_name, caller.member_id.as_str(), target_id.as_str()
|
||||
);
|
||||
crate::org_session::org_git_run(&vault.root, &["add", "members.json"], "git add")?;
|
||||
crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "git commit")?;
|
||||
if keep_owner {
|
||||
println!("Ownership shared with {} (you remain an owner).", target_id.as_str());
|
||||
} else {
|
||||
println!("Ownership transferred to {} (you are now an admin).", target_id.as_str());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn run_delete_org(dir: &Path, confirm: bool) -> Result<()> {
|
||||
let vault = crate::org_session::open_org_vault(Some(dir))?;
|
||||
let caller = vault.current_member()?;
|
||||
if !caller.role.can_manage_owners() {
|
||||
anyhow::bail!("only an owner can delete the org");
|
||||
}
|
||||
if !confirm {
|
||||
anyhow::bail!("refusing to delete org without --confirm");
|
||||
}
|
||||
let commit_msg = format!(
|
||||
"org: delete org\n\nRelicario-Actor: {} {}\nRelicario-Action: org-delete",
|
||||
caller.display_name, caller.member_id.as_str()
|
||||
);
|
||||
// Remove org files (the git history is retained as the audit record).
|
||||
for f in ["org.json", "members.json", "collections.json", "manifest.enc"] {
|
||||
let _ = fs::remove_file(vault.root.join(f));
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(vault.root.join("items"));
|
||||
let _ = std::fs::remove_dir_all(vault.root.join("keys"));
|
||||
crate::org_session::org_git_run(&vault.root, &["add", "-A"], "git add")?;
|
||||
crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "git commit")?;
|
||||
println!("Org deleted (git history retained as audit record).");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn run_status(dir: &Path) -> Result<()> {
|
||||
let root = crate::org_session::org_dir(Some(dir))?;
|
||||
|
||||
@@ -677,6 +745,416 @@ pub fn run_audit(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Item kinds `org add` supports without interactive prompts.
|
||||
pub enum OrgAddKind {
|
||||
Login {
|
||||
title: String,
|
||||
username: Option<String>,
|
||||
url: Option<String>,
|
||||
password: Option<String>,
|
||||
},
|
||||
SecureNote {
|
||||
title: String,
|
||||
body: String,
|
||||
},
|
||||
Identity {
|
||||
title: String,
|
||||
full_name: Option<String>,
|
||||
email: Option<String>,
|
||||
phone: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
fn build_org_item(kind: OrgAddKind, tags: Vec<String>) -> Result<Item> {
|
||||
use relicario_core::item_types::{IdentityCore, LoginCore, SecureNoteCore};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
let mut item = match kind {
|
||||
OrgAddKind::Login { title, username, url, password } => {
|
||||
let parsed_url = match url {
|
||||
Some(s) => Some(url::Url::parse(&s).with_context(|| format!("invalid URL: {s}"))?),
|
||||
None => None,
|
||||
};
|
||||
let password = password.map(Zeroizing::new);
|
||||
Item::new(title, ItemCore::Login(LoginCore {
|
||||
username,
|
||||
password,
|
||||
url: parsed_url,
|
||||
totp: None,
|
||||
}))
|
||||
}
|
||||
OrgAddKind::SecureNote { title, body } => {
|
||||
Item::new(title, ItemCore::SecureNote(SecureNoteCore {
|
||||
body: Zeroizing::new(body),
|
||||
}))
|
||||
}
|
||||
OrgAddKind::Identity { title, full_name, email, phone } => {
|
||||
Item::new(title, ItemCore::Identity(IdentityCore {
|
||||
full_name,
|
||||
address: None,
|
||||
phone,
|
||||
email,
|
||||
date_of_birth: None,
|
||||
}))
|
||||
}
|
||||
};
|
||||
item.tags = tags;
|
||||
Ok(item)
|
||||
}
|
||||
|
||||
pub fn run_add(dir: &Path, collection: &str, kind: OrgAddKind, tags: Vec<String>) -> Result<()> {
|
||||
use crate::org_session::UnlockedOrgVault;
|
||||
|
||||
let vault = crate::org_session::open_org_vault(Some(dir))?;
|
||||
let caller = vault.current_member()?;
|
||||
|
||||
// Slug must exist in collections.json…
|
||||
let collections = vault.load_collections()?;
|
||||
if !collections.contains_slug(collection) {
|
||||
anyhow::bail!("collection `{collection}` does not exist — create it with `relicario org create-collection`");
|
||||
}
|
||||
// …and the caller must hold a grant for it.
|
||||
UnlockedOrgVault::ensure_grant(&caller, collection)?;
|
||||
|
||||
let item = build_org_item(kind, tags)?;
|
||||
let item_rel = vault.save_item(collection, &item)?;
|
||||
|
||||
// Upsert the manifest entry (collection slug stored plaintext inside the
|
||||
// encrypted manifest).
|
||||
let mut manifest = vault.load_manifest()?;
|
||||
upsert_org_entry(&mut manifest, &item, collection);
|
||||
vault.save_manifest(&manifest)?;
|
||||
|
||||
let subject = format!(
|
||||
"org add: {} ({})",
|
||||
crate::helpers::sanitize_for_commit(&item.title),
|
||||
item.id.as_str()
|
||||
);
|
||||
let commit_msg = format!(
|
||||
"{subject}\n\nRelicario-Actor: {} {}\nRelicario-Action: item-create\nRelicario-Collection: {}\nRelicario-Item: {}",
|
||||
caller.display_name,
|
||||
caller.member_id.as_str(),
|
||||
collection,
|
||||
item.id.as_str()
|
||||
);
|
||||
crate::org_session::org_git_run(
|
||||
&vault.root,
|
||||
&["add", &item_rel, "manifest.enc"],
|
||||
"org add: git add",
|
||||
)?;
|
||||
crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "org add: git commit")?;
|
||||
|
||||
println!("Added {} ({}) to `{}`", item.title, item.id.as_str(), collection);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn run_list(dir: &Path, trashed: bool) -> Result<()> {
|
||||
let vault = crate::org_session::open_org_vault(Some(dir))?;
|
||||
let caller = vault.current_member()?;
|
||||
let manifest = vault.load_manifest()?;
|
||||
|
||||
// filter_for_member restricts to the caller's granted collections.
|
||||
let visible = manifest.filter_for_member(&caller);
|
||||
|
||||
let mut entries: Vec<_> = visible.entries.iter()
|
||||
.filter(|e| if trashed { e.trashed_at.is_some() } else { e.trashed_at.is_none() })
|
||||
.collect();
|
||||
entries.sort_by(|a, b| a.title.to_lowercase().cmp(&b.title.to_lowercase()));
|
||||
|
||||
if entries.is_empty() {
|
||||
eprintln!("(no items match)");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
println!("{:<16} {:<14} {:<12} TITLE", "ID", "TYPE", "COLLECTION");
|
||||
for e in entries {
|
||||
println!(
|
||||
"{:<16} {:<14} {:<12} {}",
|
||||
e.id.as_str(),
|
||||
format!("{:?}", e.r#type),
|
||||
e.collection,
|
||||
e.title
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn run_get(dir: &Path, query: &str, show: bool) -> Result<()> {
|
||||
use relicario_core::ItemCore;
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
let vault = crate::org_session::open_org_vault(Some(dir))?;
|
||||
let caller = vault.current_member()?;
|
||||
let manifest = vault.load_manifest()?;
|
||||
let visible = manifest.filter_for_member(&caller);
|
||||
|
||||
let entry = resolve_org_query(&visible, query)?;
|
||||
// Double-check the grant for the resolved collection (defense in depth).
|
||||
crate::org_session::UnlockedOrgVault::ensure_grant(&caller, &entry.collection)?;
|
||||
|
||||
let item = vault.load_item(&entry.collection, &entry.id)?;
|
||||
|
||||
println!("ID: {}", item.id.as_str());
|
||||
println!("Title: {}", item.title);
|
||||
println!("Type: {:?}", item.r#type);
|
||||
println!("Collection: {}", entry.collection);
|
||||
if !item.tags.is_empty() { println!("Tags: {}", item.tags.join(", ")); }
|
||||
println!("Modified: {}", crate::helpers::iso8601(item.modified));
|
||||
if let Some(t) = item.trashed_at { println!("Trashed: {}", crate::helpers::iso8601(t)); }
|
||||
println!();
|
||||
|
||||
let primary_secret: Option<Zeroizing<String>> = match &item.core {
|
||||
ItemCore::Login(l) => {
|
||||
if let Some(u) = &l.username { println!("Username: {u}"); }
|
||||
if let Some(u) = &l.url { println!("URL: {u}"); }
|
||||
l.password.clone()
|
||||
}
|
||||
ItemCore::SecureNote(n) => {
|
||||
if show { println!("Body:\n{}", n.body.as_str()); }
|
||||
else { println!("Body: ********"); }
|
||||
None
|
||||
}
|
||||
ItemCore::Identity(i) => {
|
||||
if let Some(v) = &i.full_name { println!("Name: {v}"); }
|
||||
if let Some(v) = &i.email { println!("Email: {v}"); }
|
||||
if let Some(v) = &i.phone { println!("Phone: {v}"); }
|
||||
None
|
||||
}
|
||||
ItemCore::Card(c) => {
|
||||
if let Some(h) = &c.holder { println!("Holder: {h}"); }
|
||||
c.number.clone()
|
||||
}
|
||||
ItemCore::Key(k) => {
|
||||
if let Some(l) = &k.label { println!("Label: {l}"); }
|
||||
Some(k.key_material.clone())
|
||||
}
|
||||
ItemCore::Document(d) => {
|
||||
println!("Filename: {}", d.filename);
|
||||
println!("MIME: {}", d.mime_type);
|
||||
None
|
||||
}
|
||||
ItemCore::Totp(t) => {
|
||||
if let Some(i) = &t.issuer { println!("Issuer: {i}"); }
|
||||
if let Some(l) = &t.label { println!("Label: {l}"); }
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(secret) = primary_secret {
|
||||
if show {
|
||||
println!("Secret: {}", secret.as_str());
|
||||
} else {
|
||||
println!("Secret: ******** (use --show to reveal)");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resolve a query (exact id, else case-insensitive title substring) against an
|
||||
/// already-grant-filtered manifest.
|
||||
fn resolve_org_query<'a>(
|
||||
manifest: &'a relicario_core::OrgManifest,
|
||||
query: &str,
|
||||
) -> Result<&'a relicario_core::OrgManifestEntry> {
|
||||
if let Some(entry) = manifest.entries.iter().find(|e| e.id.as_str() == query) {
|
||||
return Ok(entry);
|
||||
}
|
||||
let needle = query.to_lowercase();
|
||||
let hits: Vec<&relicario_core::OrgManifestEntry> = manifest.entries.iter()
|
||||
.filter(|e| e.title.to_lowercase().contains(&needle))
|
||||
.collect();
|
||||
match hits.len() {
|
||||
0 => anyhow::bail!("no item matches `{query}`"),
|
||||
1 => Ok(hits[0]),
|
||||
_ => {
|
||||
let titles: Vec<&str> = hits.iter().map(|e| e.title.as_str()).collect();
|
||||
anyhow::bail!("ambiguous — {} matches: {}", hits.len(), titles.join(", "))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run_edit(
|
||||
dir: &Path,
|
||||
query: &str,
|
||||
title: Option<String>,
|
||||
username: Option<String>,
|
||||
url: Option<String>,
|
||||
password: Option<String>,
|
||||
body: Option<String>,
|
||||
email: Option<String>,
|
||||
phone: Option<String>,
|
||||
full_name: Option<String>,
|
||||
) -> Result<()> {
|
||||
use relicario_core::time::now_unix;
|
||||
use relicario_core::ItemCore;
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
let vault = crate::org_session::open_org_vault(Some(dir))?;
|
||||
let caller = vault.current_member()?;
|
||||
let manifest = vault.load_manifest()?;
|
||||
let visible = manifest.filter_for_member(&caller);
|
||||
let entry = resolve_org_query(&visible, query)?;
|
||||
let collection = entry.collection.clone();
|
||||
let id = entry.id.clone();
|
||||
crate::org_session::UnlockedOrgVault::ensure_grant(&caller, &collection)?;
|
||||
|
||||
let mut item = vault.load_item(&collection, &id)?;
|
||||
|
||||
if let Some(t) = title { item.title = t; }
|
||||
|
||||
match &mut item.core {
|
||||
ItemCore::Login(l) => {
|
||||
if let Some(u) = username { l.username = Some(u); }
|
||||
if let Some(u) = url {
|
||||
l.url = Some(url::Url::parse(&u).with_context(|| format!("invalid URL: {u}"))?);
|
||||
}
|
||||
if let Some(p) = password { l.password = Some(Zeroizing::new(p)); }
|
||||
}
|
||||
ItemCore::SecureNote(n) => {
|
||||
if let Some(b) = body { n.body = Zeroizing::new(b); }
|
||||
}
|
||||
ItemCore::Identity(i) => {
|
||||
if let Some(v) = full_name { i.full_name = Some(v); }
|
||||
if let Some(v) = email { i.email = Some(v); }
|
||||
if let Some(v) = phone { i.phone = Some(v); }
|
||||
}
|
||||
_ => anyhow::bail!("org edit currently supports login, secure-note, and identity items"),
|
||||
}
|
||||
|
||||
item.modified = now_unix();
|
||||
let item_rel = vault.save_item(&collection, &item)?;
|
||||
|
||||
let mut manifest = vault.load_manifest()?;
|
||||
upsert_org_entry(&mut manifest, &item, &collection);
|
||||
vault.save_manifest(&manifest)?;
|
||||
|
||||
let subject = format!(
|
||||
"org edit: {} ({})",
|
||||
crate::helpers::sanitize_for_commit(&item.title),
|
||||
item.id.as_str()
|
||||
);
|
||||
let commit_msg = format!(
|
||||
"{subject}\n\nRelicario-Actor: {} {}\nRelicario-Action: item-update\nRelicario-Collection: {}\nRelicario-Item: {}",
|
||||
caller.display_name, caller.member_id.as_str(), collection, item.id.as_str()
|
||||
);
|
||||
crate::org_session::org_git_run(&vault.root, &["add", &item_rel, "manifest.enc"], "org edit: git add")?;
|
||||
crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "org edit: git commit")?;
|
||||
|
||||
println!("Updated {}", item.id.as_str());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resolve a query to (collection, item) with grant enforcement. Used by the
|
||||
/// trash-lifecycle commands.
|
||||
fn open_org_item(
|
||||
vault: &crate::org_session::UnlockedOrgVault,
|
||||
caller: &relicario_core::OrgMember,
|
||||
query: &str,
|
||||
) -> Result<(String, relicario_core::Item)> {
|
||||
let manifest = vault.load_manifest()?;
|
||||
let visible = manifest.filter_for_member(caller);
|
||||
let entry = resolve_org_query(&visible, query)?;
|
||||
let collection = entry.collection.clone();
|
||||
let id = entry.id.clone();
|
||||
crate::org_session::UnlockedOrgVault::ensure_grant(caller, &collection)?;
|
||||
let item = vault.load_item(&collection, &id)?;
|
||||
Ok((collection, item))
|
||||
}
|
||||
|
||||
pub fn run_rm(dir: &Path, query: &str) -> Result<()> {
|
||||
let vault = crate::org_session::open_org_vault(Some(dir))?;
|
||||
let caller = vault.current_member()?;
|
||||
let (collection, mut item) = open_org_item(&vault, &caller, query)?;
|
||||
|
||||
item.soft_delete();
|
||||
let item_rel = vault.save_item(&collection, &item)?;
|
||||
let mut manifest = vault.load_manifest()?;
|
||||
upsert_org_entry(&mut manifest, &item, &collection);
|
||||
vault.save_manifest(&manifest)?;
|
||||
|
||||
let commit_msg = format!(
|
||||
"org trash: {} ({})\n\nRelicario-Actor: {} {}\nRelicario-Action: item-delete\nRelicario-Collection: {}\nRelicario-Item: {}",
|
||||
crate::helpers::sanitize_for_commit(&item.title), item.id.as_str(),
|
||||
caller.display_name, caller.member_id.as_str(), collection, item.id.as_str()
|
||||
);
|
||||
crate::org_session::org_git_run(&vault.root, &["add", &item_rel, "manifest.enc"], "org rm: git add")?;
|
||||
crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "org rm: git commit")?;
|
||||
println!("Moved to trash: {}", item.title);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn run_restore(dir: &Path, query: &str) -> Result<()> {
|
||||
let vault = crate::org_session::open_org_vault(Some(dir))?;
|
||||
let caller = vault.current_member()?;
|
||||
let (collection, mut item) = open_org_item(&vault, &caller, query)?;
|
||||
|
||||
item.restore();
|
||||
let item_rel = vault.save_item(&collection, &item)?;
|
||||
let mut manifest = vault.load_manifest()?;
|
||||
upsert_org_entry(&mut manifest, &item, &collection);
|
||||
vault.save_manifest(&manifest)?;
|
||||
|
||||
let commit_msg = format!(
|
||||
"org restore: {} ({})\n\nRelicario-Actor: {} {}\nRelicario-Action: item-restore\nRelicario-Collection: {}\nRelicario-Item: {}",
|
||||
crate::helpers::sanitize_for_commit(&item.title), item.id.as_str(),
|
||||
caller.display_name, caller.member_id.as_str(), collection, item.id.as_str()
|
||||
);
|
||||
crate::org_session::org_git_run(&vault.root, &["add", &item_rel, "manifest.enc"], "org restore: git add")?;
|
||||
crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "org restore: git commit")?;
|
||||
println!("Restored: {}", item.title);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn run_purge(dir: &Path, query: &str) -> Result<()> {
|
||||
let vault = crate::org_session::open_org_vault(Some(dir))?;
|
||||
let caller = vault.current_member()?;
|
||||
let (collection, item) = open_org_item(&vault, &caller, query)?;
|
||||
let title = item.title.clone();
|
||||
let id = item.id.clone();
|
||||
|
||||
// Remove the blob from disk, drop the manifest entry, stage with git rm.
|
||||
vault.remove_item(&collection, &id)?;
|
||||
let mut manifest = vault.load_manifest()?;
|
||||
manifest.entries.retain(|e| e.id != id);
|
||||
vault.save_manifest(&manifest)?;
|
||||
|
||||
let item_rel = format!("items/{}/{}.enc", collection, id.as_str());
|
||||
crate::helpers::git_rm(&vault.root, &[item_rel], "org purge: git rm")?;
|
||||
crate::org_session::org_git_run(&vault.root, &["add", "manifest.enc"], "org purge: git add manifest")?;
|
||||
|
||||
let commit_msg = format!(
|
||||
"org purge: {} ({})\n\nRelicario-Actor: {} {}\nRelicario-Action: item-purge\nRelicario-Collection: {}\nRelicario-Item: {}",
|
||||
crate::helpers::sanitize_for_commit(&title), id.as_str(),
|
||||
caller.display_name, caller.member_id.as_str(), collection, id.as_str()
|
||||
);
|
||||
crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "org purge: git commit")?;
|
||||
println!("Purged: {title}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Insert-or-replace an `OrgManifestEntry` mirroring the personal-vault
|
||||
/// `Manifest::upsert`. Keyed by item id.
|
||||
fn upsert_org_entry(
|
||||
manifest: &mut relicario_core::OrgManifest,
|
||||
item: &Item,
|
||||
collection: &str,
|
||||
) {
|
||||
let entry = relicario_core::OrgManifestEntry {
|
||||
id: item.id.clone(),
|
||||
r#type: item.r#type,
|
||||
title: item.title.clone(),
|
||||
tags: item.tags.clone(),
|
||||
modified: item.modified,
|
||||
trashed_at: item.trashed_at,
|
||||
collection: collection.to_string(),
|
||||
};
|
||||
if let Some(slot) = manifest.entries.iter_mut().find(|e| e.id == item.id) {
|
||||
*slot = entry;
|
||||
} else {
|
||||
manifest.entries.push(entry);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -438,7 +438,138 @@ pub(crate) enum OrgCommands {
|
||||
#[arg(long)]
|
||||
name: String,
|
||||
},
|
||||
// Admin + item subcommands are added by later tasks (B10-B14).
|
||||
/// Add a member to the org.
|
||||
AddMember {
|
||||
/// OpenSSH ed25519 public key of the new member.
|
||||
#[arg(long)]
|
||||
key: String,
|
||||
/// Display name.
|
||||
#[arg(long)]
|
||||
name: String,
|
||||
/// Role: owner, admin, or member.
|
||||
#[arg(long, default_value = "member")]
|
||||
role: String,
|
||||
},
|
||||
/// Remove a member from the org.
|
||||
RemoveMember {
|
||||
/// Member ID prefix.
|
||||
member_id: String,
|
||||
},
|
||||
/// Change a member's role.
|
||||
SetRole {
|
||||
member_id: String,
|
||||
role: String,
|
||||
},
|
||||
/// Create a collection.
|
||||
CreateCollection {
|
||||
slug: String,
|
||||
#[arg(long)]
|
||||
name: String,
|
||||
},
|
||||
/// Grant a member access to a collection.
|
||||
Grant {
|
||||
member_id: String,
|
||||
collection: String,
|
||||
},
|
||||
/// Revoke a member's access to a collection.
|
||||
Revoke {
|
||||
member_id: String,
|
||||
collection: String,
|
||||
},
|
||||
/// Rotate the org master key (run after removing a member).
|
||||
RotateKey,
|
||||
/// Transfer ownership to another member (owner only). By default the caller
|
||||
/// is demoted to admin; pass --keep-owner for explicit co-ownership.
|
||||
TransferOwnership {
|
||||
member_id: String,
|
||||
/// Keep the caller as an owner too (co-ownership) instead of demoting.
|
||||
#[arg(long)]
|
||||
keep_owner: bool,
|
||||
},
|
||||
/// Delete the org (owner only; requires --confirm).
|
||||
DeleteOrg {
|
||||
#[arg(long)]
|
||||
confirm: bool,
|
||||
},
|
||||
/// Show org members and collections.
|
||||
Status,
|
||||
/// Query the org audit log.
|
||||
Audit {
|
||||
#[arg(long)]
|
||||
since: Option<String>,
|
||||
#[arg(long)]
|
||||
member: Option<String>,
|
||||
#[arg(long)]
|
||||
collection: Option<String>,
|
||||
#[arg(long)]
|
||||
action: Option<String>,
|
||||
/// Output format: `table` (default) or `json`.
|
||||
#[arg(long, default_value = "table")]
|
||||
format: String,
|
||||
},
|
||||
/// Add an item to a collection in the org vault.
|
||||
Add {
|
||||
#[command(subcommand)]
|
||||
kind: OrgAddKind,
|
||||
},
|
||||
/// Print an org item (secrets masked unless --show).
|
||||
Get {
|
||||
/// Item id or case-insensitive title substring.
|
||||
query: String,
|
||||
#[arg(long)] show: bool,
|
||||
},
|
||||
/// List org items visible to you (filtered by your collection grants).
|
||||
List {
|
||||
#[arg(long)] trashed: bool,
|
||||
},
|
||||
/// Edit an org item's fields (flag-driven; blank flags keep current values).
|
||||
Edit {
|
||||
/// Item id or case-insensitive title substring.
|
||||
query: String,
|
||||
#[arg(long)] title: Option<String>,
|
||||
#[arg(long)] username: Option<String>,
|
||||
#[arg(long)] url: Option<String>,
|
||||
#[arg(long)] password: Option<String>,
|
||||
#[arg(long)] body: Option<String>,
|
||||
#[arg(long)] email: Option<String>,
|
||||
#[arg(long)] phone: Option<String>,
|
||||
#[arg(long)] full_name: Option<String>,
|
||||
},
|
||||
/// Soft-delete an org item (reversible via `org restore`).
|
||||
Rm { query: String },
|
||||
/// Restore a soft-deleted org item.
|
||||
Restore { query: String },
|
||||
/// Permanently purge an org item (deletes the encrypted blob).
|
||||
Purge { query: String },
|
||||
}
|
||||
|
||||
#[derive(clap::Subcommand)]
|
||||
pub(crate) enum OrgAddKind {
|
||||
/// A login (username / url / password).
|
||||
Login {
|
||||
#[arg(long)] collection: String,
|
||||
#[arg(long)] title: String,
|
||||
#[arg(long)] username: Option<String>,
|
||||
#[arg(long)] url: Option<String>,
|
||||
#[arg(long)] password: Option<String>,
|
||||
#[arg(long, value_delimiter = ',')] tags: Vec<String>,
|
||||
},
|
||||
/// A secure note.
|
||||
SecureNote {
|
||||
#[arg(long)] collection: String,
|
||||
#[arg(long)] title: String,
|
||||
#[arg(long)] body: String,
|
||||
#[arg(long, value_delimiter = ',')] tags: Vec<String>,
|
||||
},
|
||||
/// An identity record.
|
||||
Identity {
|
||||
#[arg(long)] collection: String,
|
||||
#[arg(long)] title: String,
|
||||
#[arg(long)] full_name: Option<String>,
|
||||
#[arg(long)] email: Option<String>,
|
||||
#[arg(long)] phone: Option<String>,
|
||||
#[arg(long, value_delimiter = ',')] tags: Vec<String>,
|
||||
},
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
@@ -481,13 +612,114 @@ fn main() -> Result<()> {
|
||||
OrgCommands::Init { name } => {
|
||||
let d = crate::org_session::org_dir(dir_path)?;
|
||||
commands::org::run_init(&d, &name)?;
|
||||
Ok(())
|
||||
}
|
||||
OrgCommands::AddMember { key, name, role } => {
|
||||
let d = crate::org_session::org_dir(dir_path)?;
|
||||
let role = parse_org_role(&role)?;
|
||||
commands::org::run_add_member(&d, &key, &name, role)?;
|
||||
}
|
||||
OrgCommands::RemoveMember { member_id } => {
|
||||
let d = crate::org_session::org_dir(dir_path)?;
|
||||
commands::org::run_remove_member(&d, &member_id)?;
|
||||
}
|
||||
OrgCommands::SetRole { member_id, role } => {
|
||||
let d = crate::org_session::org_dir(dir_path)?;
|
||||
let role = parse_org_role(&role)?;
|
||||
commands::org::run_set_role(&d, &member_id, role)?;
|
||||
}
|
||||
OrgCommands::CreateCollection { slug, name } => {
|
||||
let d = crate::org_session::org_dir(dir_path)?;
|
||||
commands::org::run_create_collection(&d, &slug, &name)?;
|
||||
}
|
||||
OrgCommands::Grant { member_id, collection } => {
|
||||
let d = crate::org_session::org_dir(dir_path)?;
|
||||
commands::org::run_grant(&d, &member_id, &collection)?;
|
||||
}
|
||||
OrgCommands::Revoke { member_id, collection } => {
|
||||
let d = crate::org_session::org_dir(dir_path)?;
|
||||
commands::org::run_revoke(&d, &member_id, &collection)?;
|
||||
}
|
||||
OrgCommands::RotateKey => {
|
||||
let d = crate::org_session::org_dir(dir_path)?;
|
||||
commands::org::run_rotate_key(&d)?;
|
||||
}
|
||||
OrgCommands::TransferOwnership { member_id, keep_owner } => {
|
||||
let d = crate::org_session::org_dir(dir_path)?;
|
||||
commands::org::run_transfer_ownership(&d, &member_id, keep_owner)?;
|
||||
}
|
||||
OrgCommands::DeleteOrg { confirm } => {
|
||||
let d = crate::org_session::org_dir(dir_path)?;
|
||||
commands::org::run_delete_org(&d, confirm)?;
|
||||
}
|
||||
OrgCommands::Status => {
|
||||
let d = crate::org_session::org_dir(dir_path)?;
|
||||
commands::org::run_status(&d)?;
|
||||
}
|
||||
OrgCommands::Audit { since, member, collection, action, format } => {
|
||||
let d = crate::org_session::org_dir(dir_path)?;
|
||||
commands::org::run_audit(&d, since.as_deref(), member.as_deref(),
|
||||
collection.as_deref(), action.as_deref(), &format)?;
|
||||
}
|
||||
OrgCommands::Add { kind } => {
|
||||
let d = crate::org_session::org_dir(dir_path)?;
|
||||
let (collection, add_kind, tags) = match kind {
|
||||
OrgAddKind::Login { collection, title, username, url, password, tags } => (
|
||||
collection,
|
||||
commands::org::OrgAddKind::Login { title, username, url, password },
|
||||
tags,
|
||||
),
|
||||
OrgAddKind::SecureNote { collection, title, body, tags } => (
|
||||
collection,
|
||||
commands::org::OrgAddKind::SecureNote { title, body },
|
||||
tags,
|
||||
),
|
||||
OrgAddKind::Identity { collection, title, full_name, email, phone, tags } => (
|
||||
collection,
|
||||
commands::org::OrgAddKind::Identity { title, full_name, email, phone },
|
||||
tags,
|
||||
),
|
||||
};
|
||||
commands::org::run_add(&d, &collection, add_kind, tags)?;
|
||||
}
|
||||
OrgCommands::Get { query, show } => {
|
||||
let d = crate::org_session::org_dir(dir_path)?;
|
||||
commands::org::run_get(&d, &query, show)?;
|
||||
}
|
||||
OrgCommands::List { trashed } => {
|
||||
let d = crate::org_session::org_dir(dir_path)?;
|
||||
commands::org::run_list(&d, trashed)?;
|
||||
}
|
||||
OrgCommands::Edit { query, title, username, url, password, body, email, phone, full_name } => {
|
||||
let d = crate::org_session::org_dir(dir_path)?;
|
||||
commands::org::run_edit(&d, &query, title, username, url, password, body, email, phone, full_name)?;
|
||||
}
|
||||
OrgCommands::Rm { query } => {
|
||||
let d = crate::org_session::org_dir(dir_path)?;
|
||||
commands::org::run_rm(&d, &query)?;
|
||||
}
|
||||
OrgCommands::Restore { query } => {
|
||||
let d = crate::org_session::org_dir(dir_path)?;
|
||||
commands::org::run_restore(&d, &query)?;
|
||||
}
|
||||
OrgCommands::Purge { query } => {
|
||||
let d = crate::org_session::org_dir(dir_path)?;
|
||||
commands::org::run_purge(&d, &query)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_org_role(s: &str) -> anyhow::Result<relicario_core::OrgRole> {
|
||||
match s {
|
||||
"owner" => Ok(relicario_core::OrgRole::Owner),
|
||||
"admin" => Ok(relicario_core::OrgRole::Admin),
|
||||
"member" => Ok(relicario_core::OrgRole::Member),
|
||||
other => anyhow::bail!("unknown role `{other}` — use owner, admin, or member"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Check for test passphrase override (debug builds only; stripped from release).
|
||||
#[cfg(debug_assertions)]
|
||||
pub(crate) fn test_passphrase_override() -> Option<String> {
|
||||
|
||||
@@ -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");
|
||||
|
||||
215
crates/relicario-cli/tests/org_authz.rs
Normal file
215
crates/relicario-cli/tests/org_authz.rs
Normal file
@@ -0,0 +1,215 @@
|
||||
//! Authorization regression tests for the `relicario org` item commands.
|
||||
//!
|
||||
//! These cover two gaps the B9–B14 item-CRUD work left open:
|
||||
//! 1. Grant-DENIAL on the read/mutate-by-query commands (`get`, `edit`, `rm`,
|
||||
//! `restore`, `purge`). Only `add` had a denial test before this. An
|
||||
//! ungranted member must be rejected by EVERY one of them, and `get` must
|
||||
//! not leak the item's plaintext.
|
||||
//! 2. SecureNote body masking on `org get`, mirroring the Login-password
|
||||
//! masking already asserted in `org_items.rs`.
|
||||
//!
|
||||
//! The multi-member harness mirrors `org_lifecycle.rs`'s `Dev` pattern: each
|
||||
//! `Dev` is an isolated XDG config home carrying its own ed25519 device key, so
|
||||
//! a second member can be added with their OWN keypair and then attempt commands
|
||||
//! against the shared vault.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
use tempfile::TempDir;
|
||||
|
||||
/// A device home (its own XDG config + ed25519 signing key). One `Dev` is the
|
||||
/// owner; a second `Dev` plays the ungranted member.
|
||||
struct Dev {
|
||||
xdg: PathBuf,
|
||||
_config: TempDir,
|
||||
}
|
||||
|
||||
impl Dev {
|
||||
/// Generate an OpenSSH ed25519 signing key for `name` and mark it current.
|
||||
fn new(name: &str) -> Self {
|
||||
let config = TempDir::new().unwrap();
|
||||
let xdg = config.path().to_path_buf();
|
||||
let devices = xdg.join("relicario").join("devices").join(name);
|
||||
std::fs::create_dir_all(&devices).unwrap();
|
||||
let keyfile = devices.join("signing.key");
|
||||
let st = Command::new("ssh-keygen")
|
||||
.args(["-t", "ed25519", "-N", "", "-C", "relicario-test", "-f"])
|
||||
.arg(&keyfile)
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.status()
|
||||
.expect("ssh-keygen");
|
||||
assert!(st.success(), "ssh-keygen failed");
|
||||
std::fs::rename(devices.join("signing.key.pub"), devices.join("signing.pub")).unwrap();
|
||||
std::fs::write(
|
||||
xdg.join("relicario").join("devices").join("current"),
|
||||
format!("{name}\n"),
|
||||
)
|
||||
.unwrap();
|
||||
Dev { xdg, _config: config }
|
||||
}
|
||||
|
||||
/// The OpenSSH public key string for one of this device's keys.
|
||||
fn pubkey(&self, name: &str) -> String {
|
||||
std::fs::read_to_string(
|
||||
self.xdg.join("relicario").join("devices").join(name).join("signing.pub"),
|
||||
)
|
||||
.unwrap()
|
||||
.trim()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Run `relicario <args>` against `vault` with this device active.
|
||||
fn run(&self, vault: &Path, args: &[&str]) -> std::process::Output {
|
||||
let mut cmd = Command::cargo_bin("relicario").unwrap();
|
||||
cmd.env("XDG_CONFIG_HOME", &self.xdg)
|
||||
.env("RELICARIO_ORG_DIR", vault)
|
||||
.args(args)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
cmd.output().unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
fn owner_member_id(vault: &Path) -> String {
|
||||
let s = std::fs::read_to_string(vault.join("members.json")).unwrap();
|
||||
let v: serde_json::Value = serde_json::from_str(&s).unwrap();
|
||||
v["members"][0]["member_id"].as_str().unwrap().to_string()
|
||||
}
|
||||
|
||||
/// Look up a member's id by display name (used to find a freshly added member).
|
||||
fn member_id_by_name(vault: &Path, name: &str) -> String {
|
||||
let s = std::fs::read_to_string(vault.join("members.json")).unwrap();
|
||||
let v: serde_json::Value = serde_json::from_str(&s).unwrap();
|
||||
v["members"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|m| m["display_name"] == name)
|
||||
.unwrap_or_else(|| panic!("member `{name}` not found in members.json"))
|
||||
["member_id"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
use assert_cmd::cargo::CommandCargoExt as _;
|
||||
|
||||
#[test]
|
||||
fn org_get_edit_rm_restore_purge_reject_ungranted_member() {
|
||||
// Owner inits an org, creates `prod`, grants ONLY the owner, and adds an
|
||||
// item into `prod`.
|
||||
let owner_dev = Dev::new("owner-laptop");
|
||||
let vault_tmp = TempDir::new().unwrap();
|
||||
let vault = vault_tmp.path();
|
||||
|
||||
assert!(owner_dev
|
||||
.run(vault, &["org", "init", "--dir", vault.to_str().unwrap(), "--name", "Acme"])
|
||||
.status
|
||||
.success());
|
||||
let owner = owner_member_id(vault);
|
||||
assert!(owner_dev.run(vault, &["org", "create-collection", "prod", "--name", "Prod"]).status.success());
|
||||
assert!(owner_dev.run(vault, &["org", "grant", &owner, "prod"]).status.success());
|
||||
assert!(owner_dev
|
||||
.run(vault, &[
|
||||
"org", "add", "login", "--collection", "prod",
|
||||
"--title", "GitHub", "--username", "alice", "--password", "hunter2",
|
||||
])
|
||||
.status
|
||||
.success());
|
||||
|
||||
// A SECOND member joins with their OWN device key but is NOT granted `prod`.
|
||||
let other_dev = Dev::new("other-laptop");
|
||||
let other_pub = other_dev.pubkey("other-laptop");
|
||||
assert!(owner_dev
|
||||
.run(vault, &["org", "add-member", "--key", &other_pub, "--name", "Mallory", "--role", "member"])
|
||||
.status
|
||||
.success());
|
||||
// Sanity: the member exists but holds no collection grants.
|
||||
let mallory = member_id_by_name(vault, "Mallory");
|
||||
assert!(!mallory.is_empty());
|
||||
|
||||
// EVERY read/mutate-by-query command must be rejected for the ungranted
|
||||
// member, and `get` must NOT print the plaintext password.
|
||||
let get = other_dev.run(vault, &["org", "get", "GitHub"]);
|
||||
let get_out = String::from_utf8_lossy(&get.stdout).to_string();
|
||||
let get_err = String::from_utf8_lossy(&get.stderr).to_string();
|
||||
assert!(!get.status.success(), "get must be rejected for ungranted member: {get_out}{get_err}");
|
||||
assert!(!get_out.contains("hunter2"), "get leaked plaintext to ungranted member: {get_out}");
|
||||
assert!(!get_out.contains("alice"), "get leaked username to ungranted member: {get_out}");
|
||||
assert!(
|
||||
get_err.contains("no item matches") || get_err.contains("access denied"),
|
||||
"get error should be denial / not-found: {get_err}"
|
||||
);
|
||||
|
||||
// get --show must ALSO be denied and reveal nothing.
|
||||
let get_show = other_dev.run(vault, &["org", "get", "GitHub", "--show"]);
|
||||
assert!(!get_show.status.success(), "get --show must be rejected for ungranted member");
|
||||
assert!(
|
||||
!String::from_utf8_lossy(&get_show.stdout).contains("hunter2"),
|
||||
"get --show leaked plaintext to ungranted member"
|
||||
);
|
||||
|
||||
for (label, args) in [
|
||||
("edit", vec!["org", "edit", "GitHub", "--username", "evil"]),
|
||||
("rm", vec!["org", "rm", "GitHub"]),
|
||||
("restore", vec!["org", "restore", "GitHub"]),
|
||||
("purge", vec!["org", "purge", "GitHub"]),
|
||||
] {
|
||||
let out = other_dev.run(vault, &args);
|
||||
let stderr = String::from_utf8_lossy(&out.stderr).to_string();
|
||||
assert!(
|
||||
!out.status.success(),
|
||||
"`org {label}` must be rejected for ungranted member; stderr: {stderr}"
|
||||
);
|
||||
assert!(
|
||||
stderr.contains("no item matches") || stderr.contains("access denied"),
|
||||
"`org {label}` error should be denial / not-found: {stderr}"
|
||||
);
|
||||
}
|
||||
|
||||
// The item is untouched: the owner can still read the original password and
|
||||
// the username was NOT changed to the ungranted member's "evil" attempt.
|
||||
let owner_get = owner_dev.run(vault, &["org", "get", "GitHub", "--show"]);
|
||||
let owner_out = String::from_utf8_lossy(&owner_get.stdout).to_string();
|
||||
assert!(owner_get.status.success(), "owner should still read the item");
|
||||
assert!(owner_out.contains("hunter2"), "owner read must still show original password: {owner_out}");
|
||||
assert!(owner_out.contains("alice"), "edit by ungranted member must not have changed username: {owner_out}");
|
||||
assert!(!owner_out.contains("evil"), "ungranted edit leaked through: {owner_out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn org_get_masks_secure_note_body_until_show() {
|
||||
let owner_dev = Dev::new("owner-laptop");
|
||||
let vault_tmp = TempDir::new().unwrap();
|
||||
let vault = vault_tmp.path();
|
||||
|
||||
assert!(owner_dev
|
||||
.run(vault, &["org", "init", "--dir", vault.to_str().unwrap(), "--name", "Acme"])
|
||||
.status
|
||||
.success());
|
||||
let owner = owner_member_id(vault);
|
||||
assert!(owner_dev.run(vault, &["org", "create-collection", "prod", "--name", "Prod"]).status.success());
|
||||
assert!(owner_dev.run(vault, &["org", "grant", &owner, "prod"]).status.success());
|
||||
assert!(owner_dev
|
||||
.run(vault, &[
|
||||
"org", "add", "secure-note", "--collection", "prod",
|
||||
"--title", "Recovery", "--body", "super-secret-body",
|
||||
])
|
||||
.status
|
||||
.success());
|
||||
|
||||
// Default get masks the body and never prints the plaintext.
|
||||
let masked = owner_dev.run(vault, &["org", "get", "Recovery"]);
|
||||
assert!(masked.status.success(), "get: {}", String::from_utf8_lossy(&masked.stderr));
|
||||
let masked_out = String::from_utf8_lossy(&masked.stdout).to_string();
|
||||
assert!(masked_out.contains("********"), "expected masked body: {masked_out}");
|
||||
assert!(!masked_out.contains("super-secret-body"), "masked get leaked the body: {masked_out}");
|
||||
|
||||
// get --show reveals the body.
|
||||
let shown = owner_dev.run(vault, &["org", "get", "Recovery", "--show"]);
|
||||
assert!(shown.status.success(), "get --show: {}", String::from_utf8_lossy(&shown.stderr));
|
||||
let shown_out = String::from_utf8_lossy(&shown.stdout).to_string();
|
||||
assert!(shown_out.contains("super-secret-body"), "expected plaintext body with --show: {shown_out}");
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
217
crates/relicario-cli/tests/org_items.rs
Normal file
217
crates/relicario-cli/tests/org_items.rs
Normal file
@@ -0,0 +1,217 @@
|
||||
use assert_cmd::cargo::CommandCargoExt as _;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
use tempfile::TempDir;
|
||||
|
||||
/// A throwaway org vault with a device signing key wired via XDG_CONFIG_HOME.
|
||||
struct OrgFixture {
|
||||
_config: TempDir,
|
||||
vault: TempDir,
|
||||
xdg: PathBuf,
|
||||
}
|
||||
|
||||
impl OrgFixture {
|
||||
/// Generate an ed25519 signing key in OpenSSH format using ssh-keygen and
|
||||
/// register it as the current device, then `org init`.
|
||||
fn new() -> Self {
|
||||
let config = TempDir::new().unwrap();
|
||||
let xdg = config.path().to_path_buf();
|
||||
let devices = xdg.join("relicario").join("devices").join("laptop");
|
||||
std::fs::create_dir_all(&devices).unwrap();
|
||||
|
||||
// Generate an OpenSSH ed25519 keypair without a passphrase.
|
||||
let keyfile = devices.join("signing.key");
|
||||
let status = Command::new("ssh-keygen")
|
||||
.args(["-t", "ed25519", "-N", "", "-C", "relicario-test", "-f"])
|
||||
.arg(&keyfile)
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.status()
|
||||
.expect("ssh-keygen");
|
||||
assert!(status.success(), "ssh-keygen failed");
|
||||
// ssh-keygen writes signing.key + signing.key.pub; rename the .pub to signing.pub.
|
||||
std::fs::rename(devices.join("signing.key.pub"), devices.join("signing.pub")).unwrap();
|
||||
// Mark this device current.
|
||||
std::fs::write(
|
||||
xdg.join("relicario").join("devices").join("current"),
|
||||
"laptop\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let vault = TempDir::new().unwrap();
|
||||
let f = OrgFixture { _config: config, vault, xdg };
|
||||
|
||||
let out = f.run(&["org", "init", "--dir", f.vault_str(), "--name", "Acme"]);
|
||||
assert!(out.status.success(), "org init failed: {}", String::from_utf8_lossy(&out.stderr));
|
||||
f
|
||||
}
|
||||
|
||||
fn vault_path(&self) -> &Path { self.vault.path() }
|
||||
fn vault_str(&self) -> &str { self.vault.path().to_str().unwrap() }
|
||||
|
||||
fn run(&self, args: &[&str]) -> std::process::Output {
|
||||
let mut cmd = Command::cargo_bin("relicario").unwrap();
|
||||
cmd.env("XDG_CONFIG_HOME", &self.xdg)
|
||||
.env("RELICARIO_ORG_DIR", self.vault.path())
|
||||
.args(args)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
cmd.output().unwrap()
|
||||
}
|
||||
|
||||
/// Owner member id printed by `org init`/`org status`. We read it from
|
||||
/// members.json directly to avoid parsing stdout.
|
||||
fn owner_member_id(&self) -> String {
|
||||
let s = std::fs::read_to_string(self.vault.path().join("members.json")).unwrap();
|
||||
let v: serde_json::Value = serde_json::from_str(&s).unwrap();
|
||||
v["members"][0]["member_id"].as_str().unwrap().to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn org_add_get_list_round_trip() {
|
||||
let f = OrgFixture::new();
|
||||
let owner = f.owner_member_id();
|
||||
|
||||
// Create a collection and grant the owner access to it.
|
||||
let out = f.run(&["org", "create-collection", "prod", "--name", "Production"]);
|
||||
assert!(out.status.success(), "create-collection: {}", String::from_utf8_lossy(&out.stderr));
|
||||
let out = f.run(&["org", "grant", &owner, "prod"]);
|
||||
assert!(out.status.success(), "grant: {}", String::from_utf8_lossy(&out.stderr));
|
||||
|
||||
// Add a login into the prod collection.
|
||||
let out = f.run(&[
|
||||
"org", "add", "login", "--collection", "prod",
|
||||
"--title", "GitHub", "--username", "alice",
|
||||
"--url", "https://github.com", "--password", "hunter2",
|
||||
]);
|
||||
assert!(out.status.success(), "org add: {}", String::from_utf8_lossy(&out.stderr));
|
||||
|
||||
// The blob must live under items/prod/, NOT flat items/.
|
||||
let prod_dir = f.vault_path().join("items").join("prod");
|
||||
let blobs: Vec<_> = std::fs::read_dir(&prod_dir).unwrap().collect();
|
||||
assert_eq!(blobs.len(), 1, "expected one blob under items/prod/");
|
||||
|
||||
// list shows it.
|
||||
let out = f.run(&["org", "list"]);
|
||||
let stdout = String::from_utf8_lossy(&out.stdout).to_string();
|
||||
assert!(stdout.contains("GitHub"), "list missing GitHub: {stdout}");
|
||||
|
||||
// get masks by default.
|
||||
let out = f.run(&["org", "get", "GitHub"]);
|
||||
let stdout = String::from_utf8_lossy(&out.stdout).to_string();
|
||||
assert!(stdout.contains("********"), "expected masked secret: {stdout}");
|
||||
assert!(!stdout.contains("hunter2"), "leaked plaintext: {stdout}");
|
||||
|
||||
// get --show reveals.
|
||||
let out = f.run(&["org", "get", "GitHub", "--show"]);
|
||||
let stdout = String::from_utf8_lossy(&out.stdout).to_string();
|
||||
assert!(stdout.contains("hunter2"), "expected plaintext with --show: {stdout}");
|
||||
|
||||
// The commit trailer records the action + collection + item.
|
||||
let log = Command::new("git")
|
||||
.args(["-C", f.vault_str(), "log", "-1", "--format=%B"])
|
||||
.output()
|
||||
.unwrap();
|
||||
let body = String::from_utf8_lossy(&log.stdout).to_string();
|
||||
assert!(body.contains("Relicario-Action: item-create"), "missing action trailer: {body}");
|
||||
assert!(body.contains("Relicario-Collection: prod"), "missing collection trailer: {body}");
|
||||
assert!(body.contains("Relicario-Item: "), "missing item trailer: {body}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn org_add_rejects_ungranted_collection() {
|
||||
let f = OrgFixture::new();
|
||||
// Create the collection but do NOT grant the owner.
|
||||
let out = f.run(&["org", "create-collection", "secret", "--name", "Secret"]);
|
||||
assert!(out.status.success(), "create-collection: {}", String::from_utf8_lossy(&out.stderr));
|
||||
|
||||
let out = f.run(&[
|
||||
"org", "add", "login", "--collection", "secret",
|
||||
"--title", "X", "--username", "u", "--password", "p",
|
||||
]);
|
||||
assert!(!out.status.success(), "add into ungranted collection must fail");
|
||||
let stderr = String::from_utf8_lossy(&out.stderr).to_string();
|
||||
assert!(stderr.contains("access denied") || stderr.contains("grant"), "unexpected error: {stderr}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn org_add_rejects_unknown_collection() {
|
||||
let f = OrgFixture::new();
|
||||
let out = f.run(&[
|
||||
"org", "add", "login", "--collection", "ghost",
|
||||
"--title", "X", "--username", "u", "--password", "p",
|
||||
]);
|
||||
assert!(!out.status.success(), "add into nonexistent collection must fail");
|
||||
let stderr = String::from_utf8_lossy(&out.stderr).to_string();
|
||||
assert!(stderr.contains("does not exist") || stderr.contains("ghost"), "unexpected error: {stderr}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn org_edit_updates_fields_and_commits_update_trailer() {
|
||||
let f = OrgFixture::new();
|
||||
let owner = f.owner_member_id();
|
||||
assert!(f.run(&["org", "create-collection", "prod", "--name", "Production"]).status.success());
|
||||
assert!(f.run(&["org", "grant", &owner, "prod"]).status.success());
|
||||
assert!(f.run(&[
|
||||
"org", "add", "login", "--collection", "prod",
|
||||
"--title", "Mail", "--username", "old", "--password", "pw",
|
||||
]).status.success());
|
||||
|
||||
// Edit the username.
|
||||
let out = f.run(&[
|
||||
"org", "edit", "Mail", "--username", "new-user",
|
||||
]);
|
||||
assert!(out.status.success(), "org edit: {}", String::from_utf8_lossy(&out.stderr));
|
||||
|
||||
// get --show reflects the new username.
|
||||
let out = f.run(&["org", "get", "Mail", "--show"]);
|
||||
let stdout = String::from_utf8_lossy(&out.stdout).to_string();
|
||||
assert!(stdout.contains("new-user"), "edit did not take: {stdout}");
|
||||
|
||||
let log = Command::new("git")
|
||||
.args(["-C", f.vault_str(), "log", "-1", "--format=%B"])
|
||||
.output().unwrap();
|
||||
let body = String::from_utf8_lossy(&log.stdout).to_string();
|
||||
assert!(body.contains("Relicario-Action: item-update"), "missing update trailer: {body}");
|
||||
assert!(body.contains("Relicario-Collection: prod"), "missing collection trailer: {body}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn org_rm_restore_purge_cycle() {
|
||||
let f = OrgFixture::new();
|
||||
let owner = f.owner_member_id();
|
||||
assert!(f.run(&["org", "create-collection", "prod", "--name", "Production"]).status.success());
|
||||
assert!(f.run(&["org", "grant", &owner, "prod"]).status.success());
|
||||
assert!(f.run(&[
|
||||
"org", "add", "secure-note", "--collection", "prod",
|
||||
"--title", "Recovery", "--body", "codes-here",
|
||||
]).status.success());
|
||||
|
||||
// rm → appears only with --trashed.
|
||||
assert!(f.run(&["org", "rm", "Recovery"]).status.success());
|
||||
let listed = String::from_utf8_lossy(&f.run(&["org", "list"]).stdout).to_string();
|
||||
assert!(!listed.contains("Recovery"), "trashed item still in default list: {listed}");
|
||||
let trashed = String::from_utf8_lossy(&f.run(&["org", "list", "--trashed"]).stdout).to_string();
|
||||
assert!(trashed.contains("Recovery"), "trashed item not in --trashed list: {trashed}");
|
||||
|
||||
// restore → back in default list.
|
||||
assert!(f.run(&["org", "restore", "Recovery"]).status.success());
|
||||
let listed = String::from_utf8_lossy(&f.run(&["org", "list"]).stdout).to_string();
|
||||
assert!(listed.contains("Recovery"), "restore did not bring it back: {listed}");
|
||||
|
||||
// purge → blob gone, entry gone, item-purge trailer.
|
||||
assert!(f.run(&["org", "purge", "Recovery"]).status.success());
|
||||
let prod_dir = f.vault_path().join("items").join("prod");
|
||||
let count = std::fs::read_dir(&prod_dir).map(|d| d.count()).unwrap_or(0);
|
||||
assert_eq!(count, 0, "blob not purged from items/prod/");
|
||||
let listed = String::from_utf8_lossy(&f.run(&["org", "list", "--trashed"]).stdout).to_string();
|
||||
assert!(!listed.contains("Recovery"), "purged item still listed: {listed}");
|
||||
|
||||
let log = Command::new("git")
|
||||
.args(["-C", f.vault_str(), "log", "-1", "--format=%B"])
|
||||
.output().unwrap();
|
||||
let body = String::from_utf8_lossy(&log.stdout).to_string();
|
||||
assert!(body.contains("Relicario-Action: item-purge"), "missing purge trailer: {body}");
|
||||
}
|
||||
206
crates/relicario-cli/tests/org_lifecycle.rs
Normal file
206
crates/relicario-cli/tests/org_lifecycle.rs
Normal file
@@ -0,0 +1,206 @@
|
||||
use assert_cmd::cargo::CommandCargoExt as _;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
use tempfile::TempDir;
|
||||
|
||||
/// A device home + an org vault. A second device can be wired for multi-member.
|
||||
struct Dev {
|
||||
xdg: PathBuf,
|
||||
_config: TempDir,
|
||||
}
|
||||
|
||||
impl Dev {
|
||||
fn new(name: &str) -> Self {
|
||||
let config = TempDir::new().unwrap();
|
||||
let xdg = config.path().to_path_buf();
|
||||
let devices = xdg.join("relicario").join("devices").join(name);
|
||||
std::fs::create_dir_all(&devices).unwrap();
|
||||
let keyfile = devices.join("signing.key");
|
||||
let st = Command::new("ssh-keygen")
|
||||
.args(["-t", "ed25519", "-N", "", "-C", "relicario-test", "-f"])
|
||||
.arg(&keyfile)
|
||||
.stdout(Stdio::null()).stderr(Stdio::null())
|
||||
.status().expect("ssh-keygen");
|
||||
assert!(st.success());
|
||||
std::fs::rename(devices.join("signing.key.pub"), devices.join("signing.pub")).unwrap();
|
||||
std::fs::write(xdg.join("relicario").join("devices").join("current"), format!("{name}\n")).unwrap();
|
||||
Dev { xdg, _config: config }
|
||||
}
|
||||
|
||||
fn pubkey(&self, name: &str) -> String {
|
||||
std::fs::read_to_string(
|
||||
self.xdg.join("relicario").join("devices").join(name).join("signing.pub"),
|
||||
).unwrap().trim().to_string()
|
||||
}
|
||||
|
||||
fn run(&self, vault: &Path, args: &[&str]) -> std::process::Output {
|
||||
let mut cmd = Command::cargo_bin("relicario").unwrap();
|
||||
cmd.env("XDG_CONFIG_HOME", &self.xdg)
|
||||
.env("RELICARIO_ORG_DIR", vault)
|
||||
.args(args)
|
||||
.stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped());
|
||||
cmd.output().unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
fn owner_member_id(vault: &Path) -> String {
|
||||
let s = std::fs::read_to_string(vault.join("members.json")).unwrap();
|
||||
let v: serde_json::Value = serde_json::from_str(&s).unwrap();
|
||||
v["members"][0]["member_id"].as_str().unwrap().to_string()
|
||||
}
|
||||
|
||||
/// Set up an org with the owner granted `prod` and one login item in it.
|
||||
fn setup_with_item() -> (Dev, TempDir, String) {
|
||||
let dev = Dev::new("laptop");
|
||||
let vault = TempDir::new().unwrap();
|
||||
let v = vault.path();
|
||||
assert!(dev.run(v, &["org", "init", "--dir", v.to_str().unwrap(), "--name", "Acme"]).status.success());
|
||||
let owner = owner_member_id(v);
|
||||
assert!(dev.run(v, &["org", "create-collection", "prod", "--name", "Prod"]).status.success());
|
||||
assert!(dev.run(v, &["org", "grant", &owner, "prod"]).status.success());
|
||||
assert!(dev.run(v, &[
|
||||
"org", "add", "login", "--collection", "prod",
|
||||
"--title", "GitHub", "--username", "alice", "--password", "hunter2",
|
||||
]).status.success());
|
||||
(dev, vault, owner)
|
||||
}
|
||||
|
||||
// (b) audit --format json parses + has expected actions.
|
||||
#[test]
|
||||
fn audit_format_json_is_valid_and_has_actions() {
|
||||
let (dev, vault, _owner) = setup_with_item();
|
||||
let out = dev.run(vault.path(), &["org", "audit", "--format", "json"]);
|
||||
assert!(out.status.success(), "audit json: {}", String::from_utf8_lossy(&out.stderr));
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
let events: serde_json::Value = serde_json::from_str(&stdout).expect("audit json must parse");
|
||||
let arr = events.as_array().expect("array");
|
||||
let actions: Vec<&str> = arr.iter()
|
||||
.filter_map(|e| e["action"].as_str())
|
||||
.collect();
|
||||
assert!(actions.contains(&"org-init"), "actions: {actions:?}");
|
||||
assert!(actions.contains(&"collection-create"), "actions: {actions:?}");
|
||||
assert!(actions.contains(&"item-create"), "actions: {actions:?}");
|
||||
// Honest signer attribution: none of these should be TAMPERED (signer == trailer).
|
||||
assert!(arr.iter().all(|e| e["tampered"] == serde_json::Value::Bool(false)));
|
||||
}
|
||||
|
||||
// (a) a forged-trailer commit is flagged TAMPERED.
|
||||
#[test]
|
||||
fn forged_trailer_commit_is_flagged_tampered() {
|
||||
let (dev, vault, owner) = setup_with_item();
|
||||
let v = vault.path();
|
||||
|
||||
// Hand-craft a SIGNED commit whose trailer CLAIMS a different actor id than
|
||||
// the real signer. We reuse the org repo's own signing config (set by
|
||||
// `org init`), so the commit verifies — but the trailer lies.
|
||||
std::fs::write(v.join("decoy.txt"), "x").unwrap();
|
||||
let git = |args: &[&str]| {
|
||||
Command::new("git").current_dir(v).args(args)
|
||||
.env("XDG_CONFIG_HOME", &dev.xdg)
|
||||
.output().unwrap()
|
||||
};
|
||||
assert!(git(&["add", "decoy.txt"]).status.success());
|
||||
let forged_msg = format!(
|
||||
"forged\n\nRelicario-Actor: impostor ffffffffffffffff\nRelicario-Action: item-update\nRelicario-Member: {owner}"
|
||||
);
|
||||
// commit -S uses the repo's configured signing key (the real owner key).
|
||||
let c = git(&["commit", "-S", "-m", &forged_msg]);
|
||||
assert!(c.status.success(), "forged commit: {}", String::from_utf8_lossy(&c.stderr));
|
||||
|
||||
let out = dev.run(v, &["org", "audit", "--format", "json"]);
|
||||
let events: serde_json::Value =
|
||||
serde_json::from_str(&String::from_utf8_lossy(&out.stdout)).unwrap();
|
||||
let forged = events.as_array().unwrap().iter()
|
||||
.find(|e| e["action"] == "item-update")
|
||||
.expect("forged item-update event present");
|
||||
// Trailer claims ffff... but the verified signer is the owner → TAMPERED.
|
||||
assert_eq!(forged["tampered"], serde_json::Value::Bool(true));
|
||||
assert_eq!(forged["actor_id"].as_str(), Some(owner.as_str()));
|
||||
}
|
||||
|
||||
// (c) concurrent rotate-key aborts with the exact spec error string.
|
||||
#[test]
|
||||
fn concurrent_rotate_key_aborts_with_spec_string() {
|
||||
let (dev, vault, _owner) = setup_with_item();
|
||||
let origin = TempDir::new().unwrap();
|
||||
let v = vault.path();
|
||||
let git = |args: &[&str]| Command::new("git").current_dir(v).args(args)
|
||||
.env("XDG_CONFIG_HOME", &dev.xdg).output().unwrap();
|
||||
|
||||
// Make a bare origin and push, so a divergent upstream can be simulated.
|
||||
assert!(Command::new("git").args(["init", "--bare", origin.path().to_str().unwrap()])
|
||||
.output().unwrap().status.success());
|
||||
assert!(git(&["remote", "add", "origin", origin.path().to_str().unwrap()]).status.success());
|
||||
assert!(git(&["push", "-u", "origin", "HEAD"]).status.success());
|
||||
|
||||
// Diverge upstream: a second clone commits + pushes, writing to a SHARED file
|
||||
// so that `git pull --rebase` will hit a merge conflict (add/add or edit/edit)
|
||||
// and exit non-zero — which is how run_rotate_key detects a concurrent rotation.
|
||||
let clone2 = TempDir::new().unwrap();
|
||||
assert!(Command::new("git")
|
||||
.args(["clone", origin.path().to_str().unwrap(), clone2.path().to_str().unwrap()])
|
||||
.output().unwrap().status.success());
|
||||
std::fs::write(clone2.path().join("conflict.txt"), "upstream-version").unwrap();
|
||||
for a in [&["add", "conflict.txt"][..], &["-c", "user.email=u@u", "-c", "user.name=u", "commit", "-m", "upstream"][..], &["push", "origin", "HEAD:master"][..], &["push", "origin", "HEAD:main"][..]] {
|
||||
let _ = Command::new("git").current_dir(clone2.path()).args(a).output();
|
||||
}
|
||||
// Local also writes conflict.txt with different content → add/add conflict on pull.
|
||||
std::fs::write(v.join("conflict.txt"), "local-version").unwrap();
|
||||
assert!(git(&["add", "conflict.txt"]).status.success());
|
||||
assert!(git(&["-c", "commit.gpgsign=false", "commit", "-m", "local"]).status.success());
|
||||
|
||||
let out = dev.run(v, &["org", "rotate-key"]);
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(!out.status.success(), "rotate-key should abort on a concurrent rotation");
|
||||
assert!(
|
||||
stderr.contains("Concurrent key rotation detected — pull and re-run org rotate-key."),
|
||||
"missing spec error string: {stderr}"
|
||||
);
|
||||
}
|
||||
|
||||
// (d) remove-member → rotate-key → old clone cannot decrypt; remaining member can.
|
||||
#[test]
|
||||
fn removed_member_clone_cannot_decrypt_after_rotation() {
|
||||
// Owner laptop sets up the org + a second member "bob".
|
||||
let (owner_dev, vault, _owner) = setup_with_item();
|
||||
let v = vault.path();
|
||||
let bob = Dev::new("bob-laptop");
|
||||
let bob_pub = bob.pubkey("bob-laptop");
|
||||
|
||||
// Owner adds Bob and grants him prod.
|
||||
assert!(owner_dev.run(v, &["org", "add-member", "--key", &bob_pub, "--name", "Bob", "--role", "member"]).status.success());
|
||||
let members = std::fs::read_to_string(v.join("members.json")).unwrap();
|
||||
let mv: serde_json::Value = serde_json::from_str(&members).unwrap();
|
||||
let bob_id = mv["members"].as_array().unwrap().iter()
|
||||
.find(|m| m["display_name"] == "Bob").unwrap()["member_id"].as_str().unwrap().to_string();
|
||||
assert!(owner_dev.run(v, &["org", "grant", &bob_id, "prod"]).status.success());
|
||||
|
||||
// Bob clones the vault dir (his device, his key blob is present).
|
||||
// `cp -r /vault /dst/` places contents at `/dst/<vault_basename>/` — use that
|
||||
// sub-path, not the TempDir root, as the vault for Bob's commands.
|
||||
let bob_clone = TempDir::new().unwrap();
|
||||
let vault_basename = v.file_name().unwrap();
|
||||
let cp = Command::new("cp").args(["-r", v.to_str().unwrap(), bob_clone.path().to_str().unwrap()]).output().unwrap();
|
||||
assert!(cp.status.success());
|
||||
let bob_vault = bob_clone.path().join(vault_basename);
|
||||
// Bob can read the item BEFORE removal.
|
||||
let pre = bob.run(&bob_vault, &["org", "get", "GitHub", "--show"]);
|
||||
assert!(String::from_utf8_lossy(&pre.stdout).contains("hunter2"), "bob should read pre-removal");
|
||||
|
||||
// Owner removes Bob and rotates the key in the live vault.
|
||||
assert!(owner_dev.run(v, &["org", "remove-member", &bob_id]).status.success());
|
||||
assert!(owner_dev.run(v, &["org", "rotate-key"]).status.success());
|
||||
|
||||
// Owner (remaining member) can still decrypt in the live vault.
|
||||
let owner_get = owner_dev.run(v, &["org", "get", "GitHub", "--show"]);
|
||||
assert!(String::from_utf8_lossy(&owner_get.stdout).contains("hunter2"), "owner must still read");
|
||||
|
||||
// Copy the rotated item + manifest into Bob's stale clone (simulating a
|
||||
// pull) — his OLD key blob can no longer unwrap the rotated org key.
|
||||
let _ = Command::new("cp").args(["-r",
|
||||
v.join("items").to_str().unwrap(), bob_vault.to_str().unwrap()]).output();
|
||||
let _ = std::fs::copy(v.join("manifest.enc"), bob_vault.join("manifest.enc"));
|
||||
let post = bob.run(&bob_vault, &["org", "get", "GitHub", "--show"]);
|
||||
assert!(!post.status.success() || !String::from_utf8_lossy(&post.stdout).contains("hunter2"),
|
||||
"removed member must NOT decrypt post-rotation: {}", String::from_utf8_lossy(&post.stdout));
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "relicario-core"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
edition = "2021"
|
||||
description = "Core library for relicario password manager"
|
||||
license = "GPL-3.0-or-later"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "relicario-wasm"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
edition = "2021"
|
||||
description = "WASM bindings for relicario password manager"
|
||||
license = "GPL-3.0-or-later"
|
||||
|
||||
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.
|
||||
|
||||
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
|
||||
|
||||
```
|
||||
|
||||
@@ -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/<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.
|
||||
|
||||
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 |
|
||||
|
||||
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.
|
||||
|
||||
## 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:
|
||||
|
||||
- **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
|
||||
|
||||
Reference in New Issue
Block a user