Compare commits

...

38 Commits

Author SHA1 Message Date
adlee-was-taken
d8b23d421e refactor(cli): tidy item_build edit helpers (simplify pass)
- edit_secure_note / edit_key now call the module's resolve_secret_multiline
  instead of open-coding the eprintln-hint + read-to-EOF pattern (the helper
  exists precisely to centralize this; build_secure_note/build_key already use it).
- drop redundant fn-local imports: `use zeroize::Zeroizing;` from the five edit_*
  helpers and the re-imported `TotpAlgorithm` from edit_login/build_login
  (all covered by module-level imports; leftover from the verbatim A2/A3 move).
- build_login passes the password_stdin flag through to resolve_secret_line for
  consistency with build_card/build_totp (behavior identical — that branch is
  only reached when password_stdin is true).
- restore #[allow(clippy::too_many_arguments)] on build_totp (8 args; the old
  build_totp_item carried the same allow — signature is frozen for B/C).
2026-06-20 18:14:10 -04:00
adlee-was-taken
6eb1275710 feat(cli): --*-stdin secret flags for personal add (non-interactive secrets) 2026-06-20 17:56:45 -04:00
adlee-was-taken
751e4e9bb1 chore(cli): remove now-dead prompt/prompt_optional helpers
A3 routed personal `add` through the shared item_build builders, which use
prompt_secret / resolve_secret_*; the generic single-line prompt() and
prompt_optional() lost their last callers. read_required_line /
read_optional_line stay (used by prompt_or_flag*).
2026-06-20 17:40:52 -04:00
adlee-was-taken
65e23cfddc refactor(cli): personal add delegates to shared item_build builders 2026-06-20 17:35:18 -04:00
adlee-was-taken
b83643ee0a refactor(cli): move per-type edit helpers into shared item_build module 2026-06-20 17:27:05 -04:00
adlee-was-taken
154b984725 feat(cli): shared item_build module — secret resolution + type parsers 2026-06-20 17:21:43 -04:00
adlee-was-taken
517d52d517 docs(coordination): v0.8.1 PM + Dev-A/B/C/D kickoff prompts
4-stream manual-pane kickoff (no tmux automation): A foundation, B
Card/Key/Totp, C Document+attachments, D server hook. Each dev prompt
mandates a relay polling cadence (read inbox between every subagent;
HOLD/RESCOPE = interrupt) so PM directives are never missed. Gitea/git
merge mechanism; C<->D attachment-path coordination baked in.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L5JvzEse4xUxLZKhofyeCD
2026-06-20 17:10:26 -04:00
adlee-was-taken
3774047298 chore(salvage): snapshot org-vault tail uncommitted work before worktree cleanup
org_audit.rs (B8 verified-signer test) + the two uncommitted org.rs diffs
(item-CRUD B9-B13, status/audit B8) from the wf_22020aea first-run worktrees.
All superseded by v0.8.0 main; also committed on the -r2 branches. Kept so
nothing is lost when the stale worktrees are removed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L5JvzEse4xUxLZKhofyeCD
2026-06-20 16:58:28 -04:00
adlee-was-taken
f27dc72e96 docs(plan): v0.8.1 org item-type parity — 4-stream multi-agent plan
Dev-A shared item_build foundation + personal --*-stdin; Dev-B org
Card/Key/Totp; Dev-C org Document + attachment storage; Dev-D server
hook grant-scoping. TDD tasks with full code; A gates B/C, D independent.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L5JvzEse4xUxLZKhofyeCD
2026-06-20 16:48:46 -04:00
adlee-was-taken
b2f3739673 docs(spec): v0.8.1 org item-type parity (Card/Key/Document/Totp) design
Card/Key/Totp = CLI-only parity via shared item-build module; Document
adds org attachment storage + a relicario-server hook change that
grant-scopes attachment paths (closing the Unrestricted gap). Secrets
via interactive prompts + --*-stdin escape hatches. Four suggested dev
streams (A foundation, B Card/Key/Totp, C Document+attachments, D hook).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L5JvzEse4xUxLZKhofyeCD
2026-06-20 16:38:05 -04:00
adlee-was-taken
50b5c01291 release: v0.8.0 — enterprise org vault
Bump core/cli/wasm 0.7.0 -> 0.8.0; finalize CHANGELOG v0.8.0 header. Git-native multi-user org vaults (core org module + ECIES X25519 wrap, server signature-verifying pre-receive hook, CLI admin + item CRUD); 332/0 workspace tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RXpTHcQzw1n8qjYwZqruzQ
2026-06-20 16:06:16 -04:00
adlee-was-taken
3871da383d merge(docs): A5 living-docs sweep — item-CRUD across FORMATS/CRYPTO/SECURITY/DESIGN/ARCHITECTURE, STATUS shipped, ROADMAP, CHANGELOG; dead_code de-dup 2026-06-20 15:57:38 -04:00
adlee-was-taken
44d61ae7a7 test(cli/org): add grant-denial + secure-note masking regression tests
Cover two authz gaps left by the B9-B14 org item-CRUD work:

1. Grant-DENIAL on the read/mutate-by-query commands. A second member
   added with their own device key but NOT granted `prod` is rejected by
   every one of `org get`, `edit`, `rm`, `restore`, and `purge`, and
   `org get` (with and without --show) leaks no plaintext. Previously
   only `org add` had a denial test. Also asserts the item is untouched
   afterward (owner still reads the original password/username).

2. SecureNote body masking: `org get <note>` prints `********` and not
   the body; `org get <note> --show` reveals it. Mirrors the existing
   Login-password masking assertions in org_items.rs.

New tests/org_authz.rs reuses the multi-member `Dev` harness pattern
from org_lifecycle.rs (one XDG config home + ed25519 device key per
member), so a second member joins with their own keypair.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RXpTHcQzw1n8qjYwZqruzQ
2026-06-20 15:55:25 -04:00
adlee-was-taken
0cd417ded7 docs(org): complete A5 living-docs sweep (item CRUD merged) + dead_code cleanup
Extends the A5 pre-stage now that dev-b's full B-stream (item CRUD + all 19
org subcommands) merged to main (7392795). Living docs:
- FORMATS/CRYPTO/SECURITY/DESIGN: flip the item-CRUD "pending Dev-B" markers to
  shipped; SECURITY audit vocabulary moves item-* actions to live.
- crates/relicario-cli/ARCHITECTURE.md: full 19-subcommand surface (12 admin +
  7 item CRUD), accurate OrgAddKind scope (Login/SecureNote/Identity).
- STATUS.md: enterprise-org-vault landed section (merged 7392795) + tracked
  follow-ups + honest known-limitations; correct spec citation.
- ROADMAP.md: backend-complete row + phase-2 follow-ups.
- CHANGELOG.md: finalize the enterprise-org-vault Unreleased section (item CRUD
  into Added; Card/Key/Document/Totp + extension + phase-2 into Deferred).

Code (PM-directed dead_code fixes): wire device::current_device_seed by removing
the identical duplicate private fn in org_session.rs (de-dup); #[allow(dead_code)]
+ justification on org_session org_meta_path/load_meta (API completeness, no
command consumes org.json yet). Also silence a 3rd pre-existing test-only warning
(unused relicario() helper in tests/org_init_signing.rs).

Honest deferrals kept explicit throughout: Card/Key/Document/Totp org add/edit
parity, extension org switch/read (Dev-D) + writes, phase-2 (SSO/LDAP, read
audit, per-collection subkeys, HTTP plane). Full workspace cargo test green,
zero warnings. All cited code constants pinned file:line.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TJo44YM3UbBjro2fG6NrKy
2026-06-20 15:54:51 -04:00
adlee-was-taken
8bb1d779c4 docs(org): pre-stage A5 living-docs for merged core+server+CLI-admin (item-CRUD/extension TODO)
Pre-stages the A5 living-docs sweep for the already-merged A (relicario-core org
module) + C (relicario-server pre-receive hook) + CLI admin/rotate/status-audit
work, so the final A5 sweep (after Dev-B B9-B14 merges) is fast.

Adds org sections to docs/FORMATS.md (org repo wire formats + wrapped-key blob
layout), docs/CRYPTO.md (ECIES X25519 wrap/unwrap, no-Argon2id contrast, rotate
re-encryption), docs/SECURITY.md (signature-verifying hook, owner-only elevation,
audit vocabulary, honest limitations), DESIGN.md (org-master-key secrets row +
server org mode + deps), core/cli ARCHITECTURE.md (org module + org_session), and
an Unreleased CHANGELOG entry.

B item-CRUD (org add/get/list/edit/rm/restore/purge + main.rs wiring) and extension
parity are left as explicit TODO. STATUS/ROADMAP mark-shipped and
extension/ARCHITECTURE are deferred to the full A5 (track not yet landed; Dev-D
deferred). All cited code constants pinned with file:line per living-docs discipline.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TJo44YM3UbBjro2fG6NrKy
2026-06-20 15:23:27 -04:00
adlee-was-taken
739279515a merge(cli): dev-b B9-B14 — org item CRUD (add/get/list/edit/rm/restore/purge) + wire all 19 OrgCommands. Reviewed: authz+secrets clean; grant-denial regression test follow-up tracked 2026-06-20 15:13:59 -04:00
adlee-was-taken
6123d8b033 feat(cli/org): org rm/restore/purge trash lifecycle (collection-scoped) 2026-06-20 14:39:18 -04:00
adlee-was-taken
057a7defe5 feat(cli/org): org edit — flag-driven field update for login/note/identity 2026-06-20 14:12:46 -04:00
adlee-was-taken
2acd57a4a5 feat(cli/org): org get + list with per-member grant filtering 2026-06-20 14:08:22 -04:00
adlee-was-taken
87b1d166c2 feat(cli/org): org add — collection-scoped typed item create with grant guard 2026-06-20 14:00:21 -04:00
adlee-was-taken
6a16523ee0 feat(cli/org): wire Commands::Org admin subcommands + parse_org_role + transfer-ownership/delete-org 2026-06-20 13:50:11 -04:00
adlee-was-taken
519e503cbd docs(plan,spec): align enforce_owner_only_elevation to shipped parent-role authority
The plan's pre-receive-hook pseudocode judged owner-elevation authority on the
post-change `signer.role` (so a self-promoting Admin reads as Owner in the same
commit and self-authorizes the promotion — the exact escalation the gate exists
to stop). f249395 had fixed only the skip-predicate, leaving this final check
vulnerable. Align the plan's `enforce_owner_only_elevation` to the SHIPPED fix
(relicario-server/src/main.rs, aace6f1): derive `signer_may_manage_owners` from
`signer_parent = parent_role(signer.member_id)` (the signer's PRE-commit role;
None -> reject; genesis allowed) and gate on that, never the post-change role.

The spec was already policy-correct in prose ("a member-role-change granting
owner/admin must be signed by an owner") and did NOT carry the vulnerable
implementation detail; strengthened it with an explicit pre-commit-role note so
the design record pins the property and no one re-derives the vulnerable form.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TJo44YM3UbBjro2fG6NrKy
2026-06-20 13:45:04 -04:00
adlee-was-taken
cdb008c900 merge(cli): dev-b B7 (rotate-key) + B8 (status/audit) — reviewed; rotate re-encrypts all blobs, owner-only, concurrent-rotation abort 2026-06-20 13:40:36 -04:00
adlee-was-taken
053062effd feat(cli/org): status + audit (verified-signer attribution, TAMPERED flag, committer-date framing) 2026-06-20 13:24:35 -04:00
adlee-was-taken
3b6dbbe353 fix(cli/org): rotate-key writes member key blobs atomically (crash-safe) 2026-06-20 13:17:16 -04:00
adlee-was-taken
558da3bd75 feat(cli/org): rotate-key — re-encrypt every item blob + abort on concurrent rotation 2026-06-20 12:58:00 -04:00
adlee-was-taken
9c43f223f5 merge(cli): dev-b org stream B1-B6 — session, init, member/collection admin commands (dormant until B14 wiring) 2026-06-20 12:51:37 -04:00
adlee-was-taken
1c177871a7 feat(cli/org): create-collection, grant, revoke commands 2026-06-20 12:44:32 -04:00
adlee-was-taken
1ad8eb0918 feat(cli/org): add-member (owner-only escalation guard), remove-member, set-role 2026-06-20 12:38:48 -04:00
adlee-was-taken
aace6f132a harden(server): explicit verify-commit success gate + non-member/genesis hook tests
- verify_org_signer now rejects on a non-zero git verify-commit exit instead of
  relying on the stderr fingerprint regex alone (PM hardening note 1).
- org_hook_signed: add commit_signed_by_non_member_is_rejected (exercises the
  signature rejection path) and genesis_bootstrap_with_sole_owner_is_accepted.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 12:36:04 -04:00
adlee-was-taken
dbdb3f6ab0 refactor(cli/org): align org init main.rs wiring to OrgCommands + global --dir (B14-shaped) + assert org-init trailer 2026-06-20 12:33:07 -04:00
adlee-was-taken
7faedf8578 feat(cli/org): org init — structure + wrap + configure_git_signing + signed bootstrap commit 2026-06-20 10:27:08 -04:00
adlee-was-taken
ccb58d8bb5 feat(server): verify-org-commit — signature + path-scoped role/grant auth + owner-only elevation (parent-role authority) + schema monotonicity + generate-org-hook
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 10:21:15 -04:00
adlee-was-taken
570b0ddcd3 feat(cli/org): UnlockedOrgVault session (collection-scoped item_path, fingerprint match, signed org_git_run) 2026-06-20 09:48:15 -04:00
adlee-was-taken
7daedb33e0 feat(cli/org): org commands module stub + pub mod wiring 2026-06-20 09:43:43 -04:00
adlee-was-taken
17df315f0e feat(cli/device): current_device_seed + current_device_pubkey helpers
Read the active device's ed25519 seed/pubkey from
devices/<name>/signing.{key,pub}. Adds ssh-key (0.6) as a CLI dep
(already at 0.6.7 in the workspace lock via relicario-core) and
ed25519-dalek as a dev-dep for the round-trip test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 09:43:43 -04:00
adlee-was-taken
2dd5d79f36 refactor(server): fold in PM review notes on classify_path
- classify_path now Rejects a collection slug containing '.' (mirrors
  OrgCollections::validate, plan L317, and item_path's documented contract,
  plan L990). Unreachable today since git normalizes './' away, but keeps the
  pre-receive hook self-defensive against path traversal.
- Rename test item_write_nested_slug_takes_leading_segment_only ->
  item_write_nested_slug_is_rejected (it asserts Rejected; old name misled).
- Add dotted_slug_is_rejected covering the new guard.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M5brcDrT35r5GaJySXD5ja
2026-06-20 00:04:39 -04:00
adlee-was-taken
675b7836e1 feat(server): lib target + pure org-hook helpers (classify_path, extract_schema_version) + unit tests 2026-06-20 00:04:39 -04:00
46 changed files with 8130 additions and 470 deletions

View File

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

9
Cargo.lock generated
View File

@@ -2156,7 +2156,7 @@ checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
[[package]]
name = "relicario-cli"
version = "0.7.0"
version = "0.8.0"
dependencies = [
"anyhow",
"arboard",
@@ -2166,17 +2166,20 @@ dependencies = [
"clap_complete",
"data-encoding",
"dirs",
"ed25519-dalek",
"hex",
"image",
"predicates",
"qrcode",
"rand",
"regex",
"relicario-core",
"reqwest",
"rpassword",
"rqrr",
"serde",
"serde_json",
"ssh-key",
"tar",
"tempfile",
"url",
@@ -2185,7 +2188,7 @@ dependencies = [
[[package]]
name = "relicario-core"
version = "0.7.0"
version = "0.8.0"
dependencies = [
"argon2",
"base64",
@@ -2232,7 +2235,7 @@ dependencies = [
[[package]]
name = "relicario-wasm"
version = "0.7.0"
version = "0.8.0"
dependencies = [
"base64",
"ed25519-dalek",

View File

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

View File

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

View File

@@ -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 (B9B14): `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).

View File

@@ -37,15 +37,28 @@ under `src/commands/`. Each source file has one job.
`cmd_history`), `edit`, `trash` (rm / restore / purge / trash empty),
`backup` (export / restore), `import` (lastpass), `attach` (attach /
attachments / extract / detach), `generate`, `settings`, `sync`, `status`,
`rate`, `device`, `recovery_qr`. `add` and `edit` each fan out internally to
per-`ItemCore` helpers (`build_<type>_item`, `edit_<type>`) so each
builder/editor reads top-to-bottom and can be tested through the same
integration paths.
`rate`, `device`, `recovery_qr`. `add` and `edit` resolve their non-secret
fields then delegate to the shared `item_build` module's per-`ItemCore`
`build_*` / `edit_*` helpers (see the next bullet), so each builder/editor
reads top-to-bottom and can be tested through the same integration paths.
- **`src/commands/item_build.rs`** — shared per-type item construction and
interactive editing used by BOTH personal (`add.rs`, `edit.rs`) and org
(`org.rs`) handlers, so the two surfaces cannot drift. Contains: secret
resolution (`resolve_secret_line` — reads one line from stdin or falls back
to an interactive masked prompt; `resolve_secret_multiline` — reads stdin to
EOF, printing an optional hint in the interactive case); type parsers
(`parse_card_kind`, `parse_totp_algorithm`); the seven `build_*` builders
(`build_login`, `build_secure_note`, `build_identity`, `build_card`,
`build_key`, `build_document`, `build_totp`); per-type `edit_*` helpers
(`edit_login`, `edit_secure_note`, `edit_card`, `edit_key`, `edit_totp`,
`edit_identity`, `edit_document_message`); and `push_history`.
- **`src/prompt.rs`** — interactive prompt primitives shared across commands:
`prompt`, `prompt_optional`, `prompt_keep`, `prompt_keep_opt`,
`prompt_yesno`, `prompt_secret`. `prompt_secret` honours
`RELICARIO_TEST_ITEM_SECRET` before falling back to `rpassword`.
`prompt_keep`, `prompt_keep_opt`, `prompt_yesno`, `prompt_secret`, and the
flag-or-prompt pair `prompt_or_flag` / `prompt_or_flag_optional`.
`prompt_secret` honours `RELICARIO_TEST_ITEM_SECRET` before falling back to
`rpassword`.
- **`src/parse.rs`** — pure parsers for CLI-typed inputs (e.g. MonthYear
expiries, TOTP `otpauth://` URIs, comma-separated tag lists). No I/O.
@@ -71,6 +84,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
@@ -126,7 +180,7 @@ in code; cite the line if you change it.
works without any setup.
- **Item IDs are minted by core.** The CLI never constructs an `ItemId`
directly; `Item::new` (called inside every `build_*_item`) does it via
directly; `Item::new` (called inside every `item_build::build_*`) does it via
`relicario-core::ids::new_item_id`. `ItemId`s are 8-char hex.
- **Manifest is always saved last.** Within a single command, the order is:
@@ -196,15 +250,23 @@ in code; cite the line if you change it.
### Item add (`cmd_add`, `main.rs:419-456`)
1. Unlock the vault and load the manifest.
2. Match on the `AddKind` variant and dispatch to the matching
`build_<type>_item` helper (`main.rs:423-438`). Seven variants → seven
builders; only `build_document_item` takes `&UnlockedVault` because it
needs `attachment_caps` and writes the encrypted blob alongside the item.
3. The builder returns a fully-populated `Item` (with title, group, tags,
2. Match on the `AddKind` variant: resolve `title` and non-secret fields
(username, URL, holder, expiry, etc.) via `prompt_or_flag` /
`prompt_or_flag_optional`, then delegate to the matching `build_*` builder
in `commands/item_build.rs`. Seven variants → seven builders; only
`build_document` takes `&UnlockedVault` because it needs `attachment_caps`
and writes the encrypted blob alongside the item.
3. Single-line secrets (Login password, Card number/CVV/PIN, TOTP secret)
accept a `--*-stdin` flag that reads one line from stdin instead of
prompting; multiline secrets (SecureNote body, Key material) always read
stdin to EOF — `--body-stdin` / `--material-stdin` suppress the interactive
Ctrl-D hint. Secret-resolution rule: `commands/item_build.rs`
`resolve_secret_line` / `resolve_secret_multiline`.
4. The builder returns a fully-populated `Item` (with title, group, tags,
favorite-flag, primary attachment if any).
4. Common wrap-up: `vault.save_item(&item)`, `manifest.upsert(&item)`,
5. Common wrap-up: `vault.save_item(&item)`, `manifest.upsert(&item)`,
`vault.save_manifest(&manifest)`.
5. Build the path list — `items/<id>.enc`, `manifest.enc`, plus one
6. Build the path list — `items/<id>.enc`, `manifest.enc`, plus one
`attachments/<id>/<aid>.enc` per attachment — and call `commit_paths`
with message `add: <title> (<id>)` (`main.rs:444-452`).
@@ -537,11 +599,12 @@ applies to `relicario-core` unit tests, not these CLI integration tests.
instead. Non-primary attachments on a Document (e.g., a scanned
contract with an addendum) detach normally.
- **Per-type `build_*_item` / `edit_*` helpers exist by design after the
`3f0f5b1` refactor.** Before the refactor, `cmd_add` and `cmd_edit`
carried 217-line `match` arms. The split-out functions are easier to
read, easier to test individually (the existing integration tests still
drive them through the same paths), and easier to grow when a new
- **Per-type `build_*` / `edit_*` helpers exist by design** (extracted in the
`3f0f5b1` refactor, then centralized in `item_build.rs` for v0.8.1 so the
personal and org surfaces share one set). Before the extraction, `cmd_add`
and `cmd_edit` carried 217-line `match` arms. The split-out functions are
easier to read, easier to test individually (the existing integration tests
still drive them through the same paths), and easier to grow when a new
`ItemCore` variant lands. Keep this shape — don't fold them back.
- **Why the CLI shells out to `git`, not libgit2 / gitoxide.** Three

View File

@@ -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"
@@ -30,9 +30,12 @@ image = { version = "0.25", default-features = false, features = ["jpeg", "png"]
rqrr = "0.7"
reqwest = { version = "0.12", features = ["blocking", "json"] }
qrcode = { version = "0.14", features = ["svg"] }
ssh-key = { version = "0.6", features = ["ed25519", "std"] }
regex = "1"
tempfile = "3"
[dev-dependencies]
assert_cmd = "2"
predicates = "3"
tempfile = "3"
serde_json = "1"
ed25519-dalek = "2"

View File

@@ -1,37 +1,76 @@
//! `relicario add <kind>` — create a new item of the given type.
//!
//! `cmd_add` does the common save / manifest upsert / commit dance. The seven
//! per-type `build_*_item` helpers each return a fully-populated `Item`. The
//! `Document` builder is the only one that needs the unlocked vault (for the
//! attachment-cap settings + writing the encrypted blob alongside the item).
//! `cmd_add` resolves `title` / non-secret prompts, then delegates to the
//! shared builders in `commands/item_build.rs`. Group / tags / favorite are
//! set AFTER the build so the builders stay portable to the org vault.
use std::path::PathBuf;
use anyhow::{Context, Result};
use anyhow::Result;
use crate::AddKind;
use crate::parse::{base32_decode_lenient, guess_mime, parse_month_year};
use crate::prompt::{prompt, prompt_optional, prompt_or_flag, prompt_or_flag_optional, prompt_secret};
use crate::commands::item_build as ib;
use crate::prompt::{prompt_or_flag, prompt_or_flag_optional};
pub fn cmd_add(kind: AddKind) -> Result<()> {
let vault = crate::session::UnlockedVault::unlock_interactive()?;
let mut manifest = vault.load_manifest()?;
let item = match kind {
AddKind::Login { title, username, url, password_prompt, password, group, tags, favorite, totp_qr } =>
build_login_item(title, username, url, password_prompt, password, group, tags, favorite, totp_qr)?,
AddKind::SecureNote { title, body_prompt, group, tags } =>
build_secure_note_item(title, body_prompt, group, tags)?,
AddKind::Identity { title, full_name, email, phone, date_of_birth, group, tags } =>
build_identity_item(title, full_name, email, phone, date_of_birth, group, tags)?,
AddKind::Card { title, holder, expiry, kind, group, tags } =>
build_card_item(title, holder, expiry, kind, group, tags)?,
AddKind::Key { title, label, algorithm, group, tags } =>
build_key_item(title, label, algorithm, group, tags)?,
AddKind::Document { title, file, group, tags } =>
build_document_item(&vault, title, file, group, tags)?,
AddKind::Totp { title, issuer, label, secret, period, digits, algorithm, group, tags } =>
build_totp_item(title, issuer, label, secret, period, digits, algorithm, group, tags)?,
AddKind::Login { title, username, url, password_prompt, password, password_stdin, group, tags, favorite, totp_qr } => {
let title = prompt_or_flag(title, "Title", |s| Ok(s.to_string()))?;
let username = prompt_or_flag_optional(username, "Username", |s| Ok(s.to_string()))?;
let url = prompt_or_flag_optional(url, "URL", |s| Ok(s.to_string()))?;
let mut item = ib::build_login(title, username, url, password, password_stdin, password_prompt, totp_qr)?;
item.group = group; item.tags = tags; item.favorite = favorite;
item
}
AddKind::SecureNote { title, body_stdin, group, tags } => {
// Per the v0.8.1 spec's unified secret model, a note body is a
// multiline secret that always reads stdin to EOF. `body_stdin=false`
// means "print the Ctrl-D hint" (interactive default); `true` suppresses
// the hint for non-interactive use.
// Secret-resolution rule: `commands/item_build.rs` `resolve_secret_multiline`.
let title = prompt_or_flag(title, "Title", |s| Ok(s.to_string()))?;
let mut item = ib::build_secure_note(title, None, body_stdin)?;
item.group = group; item.tags = tags;
item
}
AddKind::Identity { title, full_name, email, phone, date_of_birth, group, tags } => {
let title = prompt_or_flag(title, "Title", |s| Ok(s.to_string()))?;
let mut item = ib::build_identity(title, full_name, email, phone, date_of_birth)?;
item.group = group; item.tags = tags;
item
}
AddKind::Card { title, holder, expiry, kind, number_stdin, cvv_stdin, pin_stdin, group, tags } => {
let title = prompt_or_flag(title, "Title", |s| Ok(s.to_string()))?;
let mut item = ib::build_card(title, holder, expiry, &kind, number_stdin, cvv_stdin, pin_stdin)?;
item.group = group; item.tags = tags;
item
}
AddKind::Key { title, label, algorithm, material_stdin, group, tags } => {
// public_key is None for the personal vault: the legacy `prompt_optional`
// for it was unreachable (stdin already at EOF after the key-material read).
// Org `add key` (Dev-B) supplies it via --public-key.
let title = prompt_or_flag(title, "Title", |s| Ok(s.to_string()))?;
let mut item = ib::build_key(title, label, algorithm, None, material_stdin)?;
item.group = group; item.tags = tags;
item
}
AddKind::Document { title, file, group, tags } => {
let title = prompt_or_flag(title, "Title", |s| Ok(s.to_string()))?;
let caps = vault.load_settings()?.attachment_caps;
let (mut item, enc) = ib::build_document(title, file, vault.key(), caps.per_attachment_max_bytes)?;
item.group = group; item.tags = tags;
let att_dir = vault.root().join("attachments").join(item.id.as_str());
std::fs::create_dir_all(&att_dir)?;
std::fs::write(att_dir.join(format!("{}.enc", enc.id.as_str())), &enc.bytes)?;
item
}
AddKind::Totp { title, issuer, label, secret, secret_stdin, period, digits, algorithm, group, tags } => {
let title = prompt_or_flag(title, "Title", |s| Ok(s.to_string()))?;
let mut item = ib::build_totp(title, issuer, label, secret, secret_stdin, period, digits, &algorithm)?;
item.group = group; item.tags = tags;
item
}
};
vault.save_item(&item)?;
@@ -51,263 +90,3 @@ pub fn cmd_add(kind: AddKind) -> Result<()> {
eprintln!("Added: {} (id={})", item.title, item.id.as_str());
Ok(())
}
#[allow(clippy::too_many_arguments)]
fn build_login_item(
title: Option<String>,
username: Option<String>,
url: Option<String>,
password_prompt: bool,
password: Option<String>,
group: Option<String>,
tags: Vec<String>,
favorite: bool,
totp_qr: Option<PathBuf>,
) -> Result<relicario_core::Item> {
use relicario_core::item_types::{LoginCore, TotpAlgorithm, TotpConfig, TotpKind};
use relicario_core::{Item, ItemCore};
use zeroize::Zeroizing;
let title = prompt_or_flag(title, "Title", |s| Ok(s.to_string()))?;
let username = prompt_or_flag_optional(username, "Username", |s| Ok(s.to_string()))?;
let url = prompt_or_flag_optional(url, "URL", |s| Ok(s.to_string()))?;
let parsed_url = match url {
Some(s) => Some(url::Url::parse(&s).with_context(|| format!("invalid URL: {s}"))?),
None => None,
};
let password = if let Some(p) = password {
Some(Zeroizing::new(p))
} else if password_prompt {
Some(Zeroizing::new(prompt_secret("Password: ")?))
} else {
None
};
let totp = if let Some(path) = totp_qr {
let secret_b32 = crate::helpers::decode_totp_qr(&path)?;
let secret_bytes = base32_decode_lenient(&secret_b32)?;
Some(TotpConfig {
secret: Zeroizing::new(secret_bytes),
algorithm: TotpAlgorithm::Sha1,
digits: 6,
period_seconds: 30,
kind: TotpKind::Totp,
})
} else {
None
};
let mut item = Item::new(title, ItemCore::Login(LoginCore {
username, password, url: parsed_url, totp,
}));
item.group = group;
item.tags = tags;
item.favorite = favorite;
Ok(item)
}
fn build_secure_note_item(
title: Option<String>,
body_prompt: bool,
group: Option<String>,
tags: Vec<String>,
) -> Result<relicario_core::Item> {
use relicario_core::item_types::SecureNoteCore;
use relicario_core::{Item, ItemCore};
use zeroize::Zeroizing;
let title = prompt_or_flag(title, "Title", |s| Ok(s.to_string()))?;
let body = if body_prompt {
eprintln!("Enter note body; end with Ctrl-D on a blank line:");
let mut s = String::new();
std::io::Read::read_to_string(&mut std::io::stdin(), &mut s)?;
s
} else {
prompt("Body")?
};
let mut item = Item::new(title, ItemCore::SecureNote(SecureNoteCore {
body: Zeroizing::new(body),
}));
item.group = group;
item.tags = tags;
Ok(item)
}
fn build_identity_item(
title: Option<String>,
full_name: Option<String>,
email: Option<String>,
phone: Option<String>,
date_of_birth: Option<String>,
group: Option<String>,
tags: Vec<String>,
) -> Result<relicario_core::Item> {
use relicario_core::item_types::IdentityCore;
use relicario_core::{Item, ItemCore};
let title = prompt_or_flag(title, "Title", |s| Ok(s.to_string()))?;
let dob = match date_of_birth {
Some(s) => Some(chrono::NaiveDate::parse_from_str(&s, "%Y-%m-%d")
.with_context(|| format!("invalid date {s} (expected YYYY-MM-DD)"))?),
None => None,
};
let mut item = Item::new(title, ItemCore::Identity(IdentityCore {
full_name, address: None, phone, email, date_of_birth: dob,
}));
item.group = group;
item.tags = tags;
Ok(item)
}
fn build_card_item(
title: Option<String>,
holder: Option<String>,
expiry: Option<String>,
kind: String,
group: Option<String>,
tags: Vec<String>,
) -> Result<relicario_core::Item> {
use relicario_core::item_types::{CardCore, CardKind};
use relicario_core::{Item, ItemCore};
use zeroize::Zeroizing;
let title = prompt_or_flag(title, "Title", |s| Ok(s.to_string()))?;
let number = Zeroizing::new(prompt_secret("Card number: ")?);
let cvv = Zeroizing::new(prompt_secret("CVV (blank to skip): ")?);
let cvv = if cvv.is_empty() { None } else { Some(cvv) };
let pin = Zeroizing::new(prompt_secret("PIN (blank to skip): ")?);
let pin = if pin.is_empty() { None } else { Some(pin) };
let parsed_expiry = match expiry {
Some(s) => Some(parse_month_year(&s)?),
None => None,
};
let parsed_kind = match kind.as_str() {
"credit" => CardKind::Credit,
"debit" => CardKind::Debit,
"gift" => CardKind::Gift,
"loyalty" => CardKind::Loyalty,
"other" => CardKind::Other,
other => anyhow::bail!("unknown card kind: {other}"),
};
let mut item = Item::new(title, ItemCore::Card(CardCore {
number: Some(number), holder, expiry: parsed_expiry, cvv, pin, kind: parsed_kind,
}));
item.group = group;
item.tags = tags;
Ok(item)
}
fn build_key_item(
title: Option<String>,
label: Option<String>,
algorithm: Option<String>,
group: Option<String>,
tags: Vec<String>,
) -> Result<relicario_core::Item> {
use relicario_core::item_types::KeyCore;
use relicario_core::{Item, ItemCore};
use zeroize::Zeroizing;
let title = prompt_or_flag(title, "Title", |s| Ok(s.to_string()))?;
eprintln!("Paste key material; end with Ctrl-D on a blank line:");
let mut key_material = String::new();
std::io::Read::read_to_string(&mut std::io::stdin(), &mut key_material)?;
if key_material.trim().is_empty() { anyhow::bail!("key material required"); }
let public_key = prompt_optional("Public key (blank to skip)")?;
let mut item = Item::new(title, ItemCore::Key(KeyCore {
key_material: Zeroizing::new(key_material),
label, public_key, algorithm,
}));
item.group = group;
item.tags = tags;
Ok(item)
}
fn build_document_item(
vault: &crate::session::UnlockedVault,
title: Option<String>,
file: PathBuf,
group: Option<String>,
tags: Vec<String>,
) -> Result<relicario_core::Item> {
use relicario_core::item_types::DocumentCore;
use relicario_core::{encrypt_attachment, AttachmentRef, Item, ItemCore};
use std::fs;
let title = prompt_or_flag(title, "Title", |s| Ok(s.to_string()))?;
let bytes = fs::read(&file)
.with_context(|| format!("failed to read {}", file.display()))?;
let caps = vault.load_settings()?.attachment_caps;
let enc = encrypt_attachment(&bytes, vault.key(), caps.per_attachment_max_bytes)?;
let filename = file.file_name()
.ok_or_else(|| anyhow::anyhow!("file path has no filename: {}", file.display()))?
.to_string_lossy()
.into_owned();
let mime_type = guess_mime(&filename);
let primary_attachment = enc.id.clone();
let mut item = Item::new(title, ItemCore::Document(DocumentCore {
filename: filename.clone(),
mime_type: mime_type.clone(),
primary_attachment: primary_attachment.clone(),
}));
item.group = group;
item.tags = tags;
item.attachments.push(AttachmentRef {
id: primary_attachment.clone(),
filename, mime_type,
size: bytes.len() as u64,
created: item.created,
});
let att_dir = vault.root().join("attachments").join(item.id.as_str());
fs::create_dir_all(&att_dir)?;
fs::write(att_dir.join(format!("{}.enc", primary_attachment.as_str())), &enc.bytes)?;
Ok(item)
}
#[allow(clippy::too_many_arguments)]
fn build_totp_item(
title: Option<String>,
issuer: Option<String>,
label: Option<String>,
secret: Option<String>,
period: u32,
digits: u8,
algorithm: String,
group: Option<String>,
tags: Vec<String>,
) -> Result<relicario_core::Item> {
use relicario_core::item_types::{TotpAlgorithm, TotpConfig, TotpCore, TotpKind};
use relicario_core::{Item, ItemCore};
use zeroize::Zeroizing;
let title = prompt_or_flag(title, "Title", |s| Ok(s.to_string()))?;
let secret_b32 = match secret {
Some(s) => s,
None => prompt_secret("TOTP secret (base32): ")?,
};
let secret_bytes = base32_decode_lenient(&secret_b32)?;
let algo = match algorithm.to_ascii_lowercase().as_str() {
"sha1" => TotpAlgorithm::Sha1,
"sha256" => TotpAlgorithm::Sha256,
"sha512" => TotpAlgorithm::Sha512,
other => anyhow::bail!("unknown algorithm: {other}"),
};
let mut item = Item::new(title, ItemCore::Totp(TotpCore {
config: TotpConfig {
secret: Zeroizing::new(secret_bytes),
algorithm: algo,
digits,
period_seconds: period,
kind: TotpKind::Totp,
},
issuer, label,
}));
item.group = group;
item.tags = tags;
Ok(item)
}

View File

@@ -2,10 +2,9 @@
use std::path::PathBuf;
use anyhow::{Context, Result};
use anyhow::Result;
use crate::parse::base32_decode_lenient;
use crate::prompt::{prompt_keep, prompt_keep_opt, prompt_secret, prompt_yesno};
use crate::prompt::{prompt_keep, prompt_keep_opt};
pub fn cmd_edit(query: String, totp_qr: Option<PathBuf>) -> Result<()> {
use relicario_core::time::now_unix;
@@ -29,13 +28,13 @@ pub fn cmd_edit(query: String, totp_qr: Option<PathBuf>) -> Result<()> {
let history = &mut item.field_history;
match &mut item.core {
ItemCore::Login(l) => edit_login(l, history, totp_qr)?,
ItemCore::SecureNote(n) => edit_secure_note(n, history)?,
ItemCore::Identity(i) => edit_identity(i)?,
ItemCore::Card(c) => edit_card(c, history)?,
ItemCore::Key(k) => edit_key(k, history)?,
ItemCore::Document(_) => edit_document_message(),
ItemCore::Totp(t) => edit_totp(t, history)?,
ItemCore::Login(l) => crate::commands::item_build::edit_login(l, history, totp_qr)?,
ItemCore::SecureNote(n) => crate::commands::item_build::edit_secure_note(n, history)?,
ItemCore::Identity(i) => crate::commands::item_build::edit_identity(i)?,
ItemCore::Card(c) => crate::commands::item_build::edit_card(c, history)?,
ItemCore::Key(k) => crate::commands::item_build::edit_key(k, history)?,
ItemCore::Document(_) => crate::commands::item_build::edit_document_message(),
ItemCore::Totp(t) => crate::commands::item_build::edit_totp(t, history)?,
}
item.modified = now_unix();
@@ -47,125 +46,3 @@ pub fn cmd_edit(query: String, totp_qr: Option<PathBuf>) -> Result<()> {
eprintln!("Updated {}", item.id.as_str());
Ok(())
}
// --- Per-type edit handlers. Each mutates its core slice in place; the ones
// that touch history-tracked fields take the item's field_history map so
// they can record the prior value alongside the change.
type FieldHistory = std::collections::HashMap<
relicario_core::FieldId,
Vec<relicario_core::item::FieldHistoryEntry>,
>;
fn edit_login(
l: &mut relicario_core::item_types::LoginCore,
history: &mut FieldHistory,
totp_qr: Option<PathBuf>,
) -> Result<()> {
use relicario_core::item_types::{TotpAlgorithm, TotpConfig, TotpKind};
use zeroize::Zeroizing;
if let Some(v) = prompt_keep_opt("Username", l.username.as_deref())? { l.username = Some(v); }
if let Some(v) = prompt_keep_opt("URL", l.url.as_ref().map(|u| u.as_str()))? {
l.url = Some(url::Url::parse(&v).with_context(|| format!("invalid URL: {v}"))?);
}
if prompt_yesno("Change password?")? {
let old = l.password.clone();
l.password = Some(Zeroizing::new(prompt_secret("New password: ")?));
if let Some(old_pw) = old {
push_history(history, "login_password", Zeroizing::new(old_pw.as_str().to_string()));
}
}
if let Some(path) = totp_qr {
let secret_b32 = crate::helpers::decode_totp_qr(&path)?;
let secret_bytes = base32_decode_lenient(&secret_b32)?;
l.totp = Some(TotpConfig {
secret: Zeroizing::new(secret_bytes),
algorithm: TotpAlgorithm::Sha1,
digits: 6,
period_seconds: 30,
kind: TotpKind::Totp,
});
eprintln!("TOTP secret set from QR image.");
}
Ok(())
}
fn edit_secure_note(n: &mut relicario_core::item_types::SecureNoteCore, history: &mut FieldHistory) -> Result<()> {
use zeroize::Zeroizing;
if prompt_yesno("Edit body?")? {
let old = n.body.clone();
eprintln!("Enter new body; end with Ctrl-D:");
let mut s = String::new();
std::io::Read::read_to_string(&mut std::io::stdin(), &mut s)?;
n.body = Zeroizing::new(s);
push_history(history, "secure_note_body", Zeroizing::new(old.as_str().to_string()));
}
Ok(())
}
fn edit_identity(i: &mut relicario_core::item_types::IdentityCore) -> Result<()> {
if let Some(v) = prompt_keep_opt("Full name", i.full_name.as_deref())? { i.full_name = Some(v); }
if let Some(v) = prompt_keep_opt("Email", i.email.as_deref())? { i.email = Some(v); }
if let Some(v) = prompt_keep_opt("Phone", i.phone.as_deref())? { i.phone = Some(v); }
Ok(())
}
fn edit_card(c: &mut relicario_core::item_types::CardCore, history: &mut FieldHistory) -> Result<()> {
use zeroize::Zeroizing;
if let Some(v) = prompt_keep_opt("Holder", c.holder.as_deref())? { c.holder = Some(v); }
if prompt_yesno("Change card number?")? {
let old = c.number.clone();
c.number = Some(Zeroizing::new(prompt_secret("New number: ")?));
if let Some(o) = old {
push_history(history, "card_number", Zeroizing::new(o.as_str().to_string()));
}
}
Ok(())
}
fn edit_key(k: &mut relicario_core::item_types::KeyCore, history: &mut FieldHistory) -> Result<()> {
use zeroize::Zeroizing;
if prompt_yesno("Replace key material?")? {
eprintln!("Paste new key material; end with Ctrl-D:");
let mut s = String::new();
std::io::Read::read_to_string(&mut std::io::stdin(), &mut s)?;
let old = k.key_material.clone();
k.key_material = Zeroizing::new(s);
push_history(history, "key_material", Zeroizing::new(old.as_str().to_string()));
}
Ok(())
}
fn edit_document_message() {
eprintln!("Document items: use `relicario attach` / `relicario extract` instead.");
}
fn edit_totp(t: &mut relicario_core::item_types::TotpCore, history: &mut FieldHistory) -> Result<()> {
use zeroize::Zeroizing;
if let Some(v) = prompt_keep_opt("Issuer", t.issuer.as_deref())? { t.issuer = Some(v); }
if let Some(v) = prompt_keep_opt("Label", t.label.as_deref())? { t.label = Some(v); }
if prompt_yesno("Change TOTP secret?")? {
let old_b32 = data_encoding::BASE32.encode(&t.config.secret);
let new_b32 = prompt_secret("New TOTP secret (base32): ")?;
let new_bytes = base32_decode_lenient(&new_b32)?;
t.config.secret = Zeroizing::new(new_bytes);
push_history(history, "totp_secret", Zeroizing::new(old_b32));
}
Ok(())
}
fn push_history(
history: &mut std::collections::HashMap<relicario_core::FieldId, Vec<relicario_core::item::FieldHistoryEntry>>,
synthetic_key: &str,
old_value: zeroize::Zeroizing<String>,
) {
use relicario_core::item::FieldHistoryEntry;
use relicario_core::time::now_unix;
// Synthetic FieldId for core-level fields — stable per-item (prefixed so
// custom-field UUIDs can't collide).
let fid = relicario_core::FieldId(format!("core:{synthetic_key}"));
history.entry(fid).or_default().push(FieldHistoryEntry {
value: old_value,
replaced_at: now_unix(),
});
}

View File

@@ -0,0 +1,318 @@
//! Shared per-type item construction + interactive editing for both the
//! personal vault (`commands/add.rs`, `commands/edit.rs`) and the org vault
//! (`commands/org.rs`). Centralizing it keeps the two surfaces from drifting.
use std::collections::HashMap;
use std::path::PathBuf;
use anyhow::{Context, Result};
use zeroize::Zeroizing;
use relicario_core::item::FieldHistoryEntry;
use relicario_core::item_types::{CardKind, TotpAlgorithm};
use relicario_core::time::now_unix;
use relicario_core::{EncryptedAttachment, FieldId, Item, ItemCore};
use crate::parse::base32_decode_lenient;
use crate::prompt::{prompt_keep_opt, prompt_secret, prompt_yesno};
pub(crate) type FieldHistory = HashMap<FieldId, Vec<FieldHistoryEntry>>;
/// Resolve a single-line secret: from stdin when `from_stdin`, else an
/// interactive masked prompt (which honours `RELICARIO_TEST_ITEM_SECRET`).
pub(crate) fn resolve_secret_line(from_stdin: bool, label: &str) -> Result<String> {
if from_stdin {
let mut s = String::new();
std::io::stdin().read_line(&mut s)?;
Ok(s.trim_end_matches(['\n', '\r']).to_string())
} else {
crate::prompt::prompt_secret(&format!("{label}: "))
}
}
/// Resolve a multiline secret (key material, note body). Both paths read stdin
/// to EOF; the interactive path first prints `hint` to stderr.
pub(crate) fn resolve_secret_multiline(from_stdin: bool, hint: &str) -> Result<String> {
if !from_stdin {
eprintln!("{hint}");
}
let mut s = String::new();
std::io::Read::read_to_string(&mut std::io::stdin(), &mut s)?;
Ok(s)
}
pub(crate) fn parse_card_kind(s: &str) -> Result<CardKind> {
Ok(match s {
"credit" => CardKind::Credit,
"debit" => CardKind::Debit,
"gift" => CardKind::Gift,
"loyalty" => CardKind::Loyalty,
"other" => CardKind::Other,
other => anyhow::bail!("unknown card kind: {other}"),
})
}
pub(crate) fn parse_totp_algorithm(s: &str) -> Result<TotpAlgorithm> {
Ok(match s.to_ascii_lowercase().as_str() {
"sha1" => TotpAlgorithm::Sha1,
"sha256" => TotpAlgorithm::Sha256,
"sha512" => TotpAlgorithm::Sha512,
other => anyhow::bail!("unknown algorithm: {other}"),
})
}
// --- Per-type interactive edit helpers (moved from commands/edit.rs). Each
// mutates its core slice in place; history-tracked variants take the
// item's field_history map so they can record the prior value.
pub(crate) fn edit_login(
l: &mut relicario_core::item_types::LoginCore,
history: &mut FieldHistory,
totp_qr: Option<PathBuf>,
) -> Result<()> {
use relicario_core::item_types::{TotpConfig, TotpKind};
if let Some(v) = prompt_keep_opt("Username", l.username.as_deref())? { l.username = Some(v); }
if let Some(v) = prompt_keep_opt("URL", l.url.as_ref().map(|u| u.as_str()))? {
l.url = Some(url::Url::parse(&v).with_context(|| format!("invalid URL: {v}"))?);
}
if prompt_yesno("Change password?")? {
let old = l.password.clone();
l.password = Some(Zeroizing::new(prompt_secret("New password: ")?));
if let Some(old_pw) = old {
push_history(history, "login_password", Zeroizing::new(old_pw.as_str().to_string()));
}
}
if let Some(path) = totp_qr {
let secret_b32 = crate::helpers::decode_totp_qr(&path)?;
let secret_bytes = base32_decode_lenient(&secret_b32)?;
l.totp = Some(TotpConfig {
secret: Zeroizing::new(secret_bytes),
algorithm: TotpAlgorithm::Sha1,
digits: 6,
period_seconds: 30,
kind: TotpKind::Totp,
});
eprintln!("TOTP secret set from QR image.");
}
Ok(())
}
pub(crate) fn edit_secure_note(n: &mut relicario_core::item_types::SecureNoteCore, history: &mut FieldHistory) -> Result<()> {
if prompt_yesno("Edit body?")? {
let old = n.body.clone();
let s = resolve_secret_multiline(false, "Enter new body; end with Ctrl-D:")?;
n.body = Zeroizing::new(s);
push_history(history, "secure_note_body", Zeroizing::new(old.as_str().to_string()));
}
Ok(())
}
pub(crate) fn edit_identity(i: &mut relicario_core::item_types::IdentityCore) -> Result<()> {
if let Some(v) = prompt_keep_opt("Full name", i.full_name.as_deref())? { i.full_name = Some(v); }
if let Some(v) = prompt_keep_opt("Email", i.email.as_deref())? { i.email = Some(v); }
if let Some(v) = prompt_keep_opt("Phone", i.phone.as_deref())? { i.phone = Some(v); }
Ok(())
}
pub(crate) fn edit_card(c: &mut relicario_core::item_types::CardCore, history: &mut FieldHistory) -> Result<()> {
if let Some(v) = prompt_keep_opt("Holder", c.holder.as_deref())? { c.holder = Some(v); }
if prompt_yesno("Change card number?")? {
let old = c.number.clone();
c.number = Some(Zeroizing::new(prompt_secret("New number: ")?));
if let Some(o) = old {
push_history(history, "card_number", Zeroizing::new(o.as_str().to_string()));
}
}
Ok(())
}
pub(crate) fn edit_key(k: &mut relicario_core::item_types::KeyCore, history: &mut FieldHistory) -> Result<()> {
if prompt_yesno("Replace key material?")? {
let s = resolve_secret_multiline(false, "Paste new key material; end with Ctrl-D:")?;
let old = k.key_material.clone();
k.key_material = Zeroizing::new(s);
push_history(history, "key_material", Zeroizing::new(old.as_str().to_string()));
}
Ok(())
}
pub(crate) fn edit_document_message() {
eprintln!("Document items: use `relicario attach` / `relicario extract` instead.");
}
pub(crate) fn edit_totp(t: &mut relicario_core::item_types::TotpCore, history: &mut FieldHistory) -> Result<()> {
if let Some(v) = prompt_keep_opt("Issuer", t.issuer.as_deref())? { t.issuer = Some(v); }
if let Some(v) = prompt_keep_opt("Label", t.label.as_deref())? { t.label = Some(v); }
if prompt_yesno("Change TOTP secret?")? {
let old_b32 = data_encoding::BASE32.encode(&t.config.secret);
let new_b32 = prompt_secret("New TOTP secret (base32): ")?;
let new_bytes = base32_decode_lenient(&new_b32)?;
t.config.secret = Zeroizing::new(new_bytes);
push_history(history, "totp_secret", Zeroizing::new(old_b32));
}
Ok(())
}
pub(crate) fn build_login(
title: String, username: Option<String>, url: Option<String>,
password: Option<String>, password_stdin: bool, password_prompt: bool,
totp_qr: Option<PathBuf>,
) -> Result<Item> {
use relicario_core::item_types::{LoginCore, TotpConfig, TotpKind};
let parsed_url = match url {
Some(s) => Some(url::Url::parse(&s).with_context(|| format!("invalid URL: {s}"))?),
None => None,
};
let password = if let Some(p) = password {
Some(Zeroizing::new(p))
} else if password_stdin {
Some(Zeroizing::new(resolve_secret_line(password_stdin, "Password")?))
} else if password_prompt {
Some(Zeroizing::new(crate::prompt::prompt_secret("Password: ")?))
} else {
None
};
let totp = if let Some(path) = totp_qr {
let secret_b32 = crate::helpers::decode_totp_qr(&path)?;
let secret_bytes = base32_decode_lenient(&secret_b32)?;
Some(TotpConfig {
secret: Zeroizing::new(secret_bytes), algorithm: TotpAlgorithm::Sha1,
digits: 6, period_seconds: 30, kind: TotpKind::Totp,
})
} else { None };
Ok(Item::new(title, ItemCore::Login(LoginCore { username, password, url: parsed_url, totp })))
}
pub(crate) fn build_secure_note(title: String, body: Option<String>, body_stdin: bool) -> Result<Item> {
use relicario_core::item_types::SecureNoteCore;
let body = match body {
Some(b) => b,
None => resolve_secret_multiline(body_stdin, "Enter note body; end with Ctrl-D on a blank line:")?,
};
Ok(Item::new(title, ItemCore::SecureNote(SecureNoteCore { body: Zeroizing::new(body) })))
}
pub(crate) fn build_identity(
title: String, full_name: Option<String>, email: Option<String>,
phone: Option<String>, date_of_birth: Option<String>,
) -> Result<Item> {
use relicario_core::item_types::IdentityCore;
let dob = match date_of_birth {
Some(s) => Some(chrono::NaiveDate::parse_from_str(&s, "%Y-%m-%d")
.with_context(|| format!("invalid date {s} (expected YYYY-MM-DD)"))?),
None => None,
};
Ok(Item::new(title, ItemCore::Identity(IdentityCore {
full_name, address: None, phone, email, date_of_birth: dob,
})))
}
pub(crate) fn build_card(
title: String, holder: Option<String>, expiry: Option<String>, kind: &str,
number_stdin: bool, cvv_stdin: bool, pin_stdin: bool,
) -> Result<Item> {
use relicario_core::item_types::CardCore;
let number = Zeroizing::new(resolve_secret_line(number_stdin, "Card number")?);
let cvv = resolve_secret_line(cvv_stdin, "CVV (blank to skip)")?;
let cvv = if cvv.is_empty() { None } else { Some(Zeroizing::new(cvv)) };
let pin = resolve_secret_line(pin_stdin, "PIN (blank to skip)")?;
let pin = if pin.is_empty() { None } else { Some(Zeroizing::new(pin)) };
let parsed_expiry = match expiry { Some(s) => Some(crate::parse::parse_month_year(&s)?), None => None };
Ok(Item::new(title, ItemCore::Card(CardCore {
number: Some(number), holder, expiry: parsed_expiry, cvv, pin, kind: parse_card_kind(kind)?,
})))
}
pub(crate) fn build_key(
title: String, label: Option<String>, algorithm: Option<String>,
public_key: Option<String>, material_stdin: bool,
) -> Result<Item> {
use relicario_core::item_types::KeyCore;
let key_material = resolve_secret_multiline(material_stdin, "Paste key material; end with Ctrl-D on a blank line:")?;
if key_material.trim().is_empty() { anyhow::bail!("key material required"); }
Ok(Item::new(title, ItemCore::Key(KeyCore {
key_material: Zeroizing::new(key_material), label, public_key, algorithm,
})))
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn build_totp(
title: String, issuer: Option<String>, label: Option<String>,
secret: Option<String>, secret_stdin: bool, period: u32, digits: u8, algorithm: &str,
) -> Result<Item> {
use relicario_core::item_types::{TotpConfig, TotpCore, TotpKind};
let secret_b32 = match secret {
Some(s) => s,
None => resolve_secret_line(secret_stdin, "TOTP secret (base32)")?,
};
let secret_bytes = base32_decode_lenient(&secret_b32)?;
Ok(Item::new(title, ItemCore::Totp(TotpCore {
config: TotpConfig {
secret: Zeroizing::new(secret_bytes), algorithm: parse_totp_algorithm(algorithm)?,
digits, period_seconds: period, kind: TotpKind::Totp,
},
issuer, label,
})))
}
pub(crate) fn build_document(
title: String, file: PathBuf, key: &Zeroizing<[u8; 32]>, max_bytes: u64,
) -> Result<(Item, EncryptedAttachment)> {
use relicario_core::item_types::DocumentCore;
use relicario_core::{encrypt_attachment, AttachmentRef};
let bytes = std::fs::read(&file).with_context(|| format!("failed to read {}", file.display()))?;
let enc = encrypt_attachment(&bytes, key, max_bytes)?;
let filename = file.file_name()
.ok_or_else(|| anyhow::anyhow!("file path has no filename: {}", file.display()))?
.to_string_lossy().into_owned();
let mime_type = crate::parse::guess_mime(&filename);
let primary_attachment = enc.id.clone();
let mut item = Item::new(title, ItemCore::Document(DocumentCore {
filename: filename.clone(), mime_type: mime_type.clone(), primary_attachment: primary_attachment.clone(),
}));
item.attachments.push(AttachmentRef {
id: primary_attachment, filename, mime_type, size: bytes.len() as u64, created: item.created,
});
Ok((item, enc))
}
pub(crate) fn push_history(
history: &mut FieldHistory,
synthetic_key: &str,
old_value: zeroize::Zeroizing<String>,
) {
// Synthetic FieldId for core-level fields — stable per-item (prefixed so
// custom-field UUIDs can't collide).
let fid = relicario_core::FieldId(format!("core:{synthetic_key}"));
history.entry(fid).or_default().push(FieldHistoryEntry {
value: old_value,
replaced_at: now_unix(),
});
}
#[cfg(test)]
mod tests {
use super::*;
use relicario_core::item_types::{CardKind, TotpAlgorithm};
#[test]
fn card_kind_parses_known_values() {
assert_eq!(parse_card_kind("credit").unwrap(), CardKind::Credit);
assert_eq!(parse_card_kind("loyalty").unwrap(), CardKind::Loyalty);
}
#[test]
fn card_kind_rejects_unknown() {
assert!(parse_card_kind("platinum").is_err());
}
#[test]
fn totp_algorithm_is_case_insensitive() {
assert_eq!(parse_totp_algorithm("SHA256").unwrap(), TotpAlgorithm::Sha256);
}
#[test]
fn totp_algorithm_rejects_unknown() {
assert!(parse_totp_algorithm("md5").is_err());
}
}

View File

@@ -14,6 +14,8 @@ pub mod edit;
pub mod generate;
pub mod get;
pub mod import;
pub mod item_build;
pub mod org;
pub mod init;
pub mod list;
pub mod rate;

File diff suppressed because it is too large Load Diff

View File

@@ -99,6 +99,48 @@ pub fn load_signing_key(name: &str) -> Result<Zeroizing<String>> {
Ok(Zeroizing::new(key))
}
/// Read the active device's ed25519 public key (OpenSSH single-line format,
/// e.g. `ssh-ed25519 AAAA... comment`) from `signing.pub`.
///
/// Errors if no device is selected (`devices/current` missing/empty) — the
/// caller should hint the user to run `relicario device add` first.
pub fn current_device_pubkey() -> Result<String> {
let name = current_device()?
.ok_or_else(|| anyhow::anyhow!("no active device — run `relicario device add` first"))?;
let path = device_dir(&name)?.join("signing.pub");
let pubkey = fs::read_to_string(&path)
.with_context(|| format!("read signing.pub for device '{name}'"))?;
let trimmed = pubkey.trim();
if trimmed.is_empty() {
anyhow::bail!("signing.pub for device '{name}' is empty");
}
Ok(trimmed.to_string())
}
/// Read the active device's 32-byte ed25519 seed from `signing.key`
/// (OpenSSH private-key format).
///
/// The seed is the secret scalar used to sign org commits and to unwrap the
/// org key. It is returned in `Zeroizing` so it is wiped on drop. Errors if no
/// device is selected, the key file is unreadable, or the key is not ed25519.
pub fn current_device_seed() -> Result<Zeroizing<[u8; 32]>> {
let name = current_device()?
.ok_or_else(|| anyhow::anyhow!("no active device — run `relicario device add` first"))?;
// load_signing_key reads signing.key as OpenSSH private-key text.
let pem = load_signing_key(&name)?;
let private = ssh_key::PrivateKey::from_openssh(pem.as_str())
.map_err(|e| anyhow::anyhow!("parse signing.key for device '{name}': {e}"))?;
let keypair = private
.key_data()
.ed25519()
.ok_or_else(|| anyhow::anyhow!("signing.key for device '{name}' is not ed25519"))?;
// Ed25519PrivateKey::as_ref() yields &[u8; 32] (verified: ssh-key 0.6.7
// private/ed25519.rs:42). Copy into a Zeroizing array so the seed is wiped.
let mut seed = Zeroizing::new([0u8; 32]);
seed.copy_from_slice(keypair.private.as_ref());
Ok(seed)
}
/// Load the deploy private key for a device.
#[allow(dead_code)]
pub fn load_deploy_key(name: &str) -> Result<Zeroizing<String>> {
@@ -127,6 +169,53 @@ pub fn delete_device_keys(name: &str) -> Result<()> {
Ok(())
}
#[cfg(test)]
mod seed_helper_tests {
use super::*;
use std::sync::Mutex;
// dirs::config_dir() reads process-wide env; serialize these tests.
static ENV_LOCK: Mutex<()> = Mutex::new(());
#[test]
fn current_device_seed_and_pubkey_round_trip() {
let _guard = ENV_LOCK.lock().unwrap();
let tmp = tempfile::tempdir().unwrap();
let prev_xdg = std::env::var_os("XDG_CONFIG_HOME");
std::env::set_var("XDG_CONFIG_HOME", tmp.path());
// Generate a real ed25519 device keypair (OpenSSH text) via core.
let (private_openssh, public_openssh) =
relicario_core::device::generate_keypair().unwrap();
// Lay out devices/test-dev/{signing.key,signing.pub} + devices/current.
let dir = device_dir("test-dev").unwrap();
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("signing.key"), private_openssh.as_str()).unwrap();
std::fs::write(dir.join("signing.pub"), &public_openssh).unwrap();
set_current_device("test-dev").unwrap();
// pubkey helper returns exactly the stored OpenSSH public line.
let got_pub = current_device_pubkey().unwrap();
assert_eq!(got_pub.trim(), public_openssh.trim());
// seed helper returns the 32-byte ed25519 seed; re-derive the public
// key from it and confirm it matches.
let seed = current_device_seed().unwrap();
let signing = ed25519_dalek::SigningKey::from_bytes(&seed);
let derived = signing.verifying_key();
let parsed_pub = ssh_key::PublicKey::from_openssh(&public_openssh).unwrap();
let parsed_bytes: &[u8] = parsed_pub.key_data().ed25519().unwrap().as_ref();
assert_eq!(derived.as_bytes().as_slice(), parsed_bytes);
// restore env
match prev_xdg {
Some(v) => std::env::set_var("XDG_CONFIG_HOME", v),
None => std::env::remove_var("XDG_CONFIG_HOME"),
}
}
}
/// Configure git in `vault_root` to:
/// - sign commits with the device's signing key (SSH format)
/// - push via SSH using the device's deploy key

View File

@@ -9,6 +9,7 @@ mod helpers;
mod parse;
mod prompt;
mod session;
mod org_session;
use std::path::PathBuf;
@@ -206,6 +207,15 @@ enum Commands {
#[command(subcommand)]
cmd: RecoveryQrCmd,
},
/// Manage a multi-user org vault.
Org {
/// Path to the org vault directory (overrides RELICARIO_ORG_DIR).
#[arg(long, global = true)]
dir: Option<PathBuf>,
#[command(subcommand)]
subcommand: OrgCommands,
},
}
#[derive(Subcommand)]
@@ -217,6 +227,8 @@ pub(crate) enum AddKind {
/// Prompt for password (vs reading from stdin or --password).
#[arg(long)] password_prompt: bool,
#[arg(long)] password: Option<String>,
/// Read the password from stdin (one line) instead of prompting.
#[arg(long)] password_stdin: bool,
#[arg(long)] group: Option<String>,
#[arg(long, value_delimiter = ',')] tags: Vec<String>,
#[arg(long)] favorite: bool,
@@ -225,7 +237,8 @@ pub(crate) enum AddKind {
},
SecureNote {
#[arg(long)] title: Option<String>,
#[arg(long)] body_prompt: bool,
/// Read the note body from stdin (to EOF) instead of printing the Ctrl-D hint.
#[arg(long)] body_stdin: bool,
#[arg(long)] group: Option<String>,
#[arg(long, value_delimiter = ',')] tags: Vec<String>,
},
@@ -243,6 +256,12 @@ pub(crate) enum AddKind {
#[arg(long)] holder: Option<String>,
#[arg(long)] expiry: Option<String>, // MM/YYYY
#[arg(long, default_value = "credit")] kind: String,
/// Read the card number from stdin (one line) instead of prompting.
#[arg(long)] number_stdin: bool,
/// Read the CVV from stdin (one line) instead of prompting.
#[arg(long)] cvv_stdin: bool,
/// Read the PIN from stdin (one line) instead of prompting.
#[arg(long)] pin_stdin: bool,
#[arg(long)] group: Option<String>,
#[arg(long, value_delimiter = ',')] tags: Vec<String>,
},
@@ -250,6 +269,8 @@ pub(crate) enum AddKind {
#[arg(long)] title: Option<String>,
#[arg(long)] label: Option<String>,
#[arg(long)] algorithm: Option<String>,
/// Read the key material from stdin (to EOF) instead of printing the Ctrl-D hint.
#[arg(long)] material_stdin: bool,
#[arg(long)] group: Option<String>,
#[arg(long, value_delimiter = ',')] tags: Vec<String>,
},
@@ -264,6 +285,8 @@ pub(crate) enum AddKind {
#[arg(long)] issuer: Option<String>,
#[arg(long)] label: Option<String>,
#[arg(long)] secret: Option<String>, // base32
/// Read the TOTP secret from stdin (one line) instead of prompting.
#[arg(long)] secret_stdin: bool,
#[arg(long, default_value = "30")] period: u32,
#[arg(long, default_value = "6")] digits: u8,
#[arg(long, default_value = "sha1")] algorithm: String,
@@ -421,6 +444,147 @@ pub(crate) enum RecoveryQrCmd {
Unwrap,
}
#[derive(clap::Subcommand)]
pub(crate) enum OrgCommands {
/// Create a new org vault.
Init {
#[arg(long)]
name: String,
},
/// 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<()> {
let cli = Cli::parse();
match cli.command {
@@ -455,6 +619,117 @@ fn main() -> Result<()> {
Commands::Rate { passphrase } => commands::rate::cmd_rate(passphrase),
Commands::Device { action } => commands::device::cmd_device(action),
Commands::RecoveryQr { cmd } => commands::recovery_qr::cmd_recovery_qr(cmd),
Commands::Org { dir, subcommand } => {
let dir_path = dir.as_deref();
match subcommand {
OrgCommands::Init { name } => {
let d = crate::org_session::org_dir(dir_path)?;
commands::org::run_init(&d, &name)?;
}
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"),
}
}

View File

@@ -0,0 +1,305 @@
//! Unlocked org vault session: holds the org master key for the duration of a
//! CLI invocation.
use std::fs;
use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
use zeroize::Zeroizing;
use relicario_core::{
decrypt_item, decrypt_org_manifest, encrypt_item, encrypt_org_manifest,
Item, ItemId, MemberId, OrgCollections, OrgManifest, OrgMember, OrgMembers, OrgMeta,
};
pub struct UnlockedOrgVault {
pub root: PathBuf,
pub org_key: Zeroizing<[u8; 32]>,
}
impl UnlockedOrgVault {
pub fn root(&self) -> &Path { &self.root }
pub fn key(&self) -> &Zeroizing<[u8; 32]> { &self.org_key }
pub fn manifest_path(&self) -> PathBuf { self.root.join("manifest.enc") }
/// Collection-scoped item path: `items/<collection-slug>/<id>.enc`.
/// The leading slug segment is what the pre-receive hook authorizes against
/// members.json — it never decrypts the blob. The slug must be non-empty and
/// already validated.
pub fn item_path(&self, collection_slug: &str, id: &ItemId) -> PathBuf {
self.root
.join("items")
.join(collection_slug)
.join(format!("{}.enc", id.as_str()))
}
pub fn member_key_path(&self, id: &MemberId) -> PathBuf {
self.root.join("keys").join(format!("{}.enc", id.as_str()))
}
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")?)
}
pub fn load_members(&self) -> Result<OrgMembers> {
let s = fs::read_to_string(self.members_path()).context("read members.json")?;
Ok(serde_json::from_str(&s).context("parse members.json")?)
}
pub fn save_members(&self, members: &OrgMembers) -> Result<()> {
let json = serde_json::to_string_pretty(members)?;
atomic_write(&self.members_path(), json.as_bytes())
}
pub fn load_collections(&self) -> Result<OrgCollections> {
let s = fs::read_to_string(self.collections_path()).context("read collections.json")?;
Ok(serde_json::from_str(&s).context("parse collections.json")?)
}
pub fn save_collections(&self, collections: &OrgCollections) -> Result<()> {
let json = serde_json::to_string_pretty(collections)?;
atomic_write(&self.collections_path(), json.as_bytes())
}
pub fn load_manifest(&self) -> Result<OrgManifest> {
let bytes = fs::read(self.manifest_path()).context("read manifest.enc")?;
Ok(decrypt_org_manifest(&bytes, &self.org_key)?)
}
pub fn save_manifest(&self, manifest: &OrgManifest) -> Result<()> {
let bytes = encrypt_org_manifest(manifest, &self.org_key)?;
atomic_write(&self.manifest_path(), &bytes)
}
/// Encrypt + write an item under its collection directory, creating the
/// directory if needed. Returns the repo-relative path for git staging.
pub fn save_item(&self, collection_slug: &str, item: &Item) -> Result<String> {
let path = self.item_path(collection_slug, &item.id);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("create {}", parent.display()))?;
}
let bytes = encrypt_item(item, &self.org_key)?;
atomic_write(&path, &bytes)?;
Ok(format!("items/{}/{}.enc", collection_slug, item.id.as_str()))
}
/// Read + decrypt an item from its collection directory.
pub fn load_item(&self, collection_slug: &str, id: &ItemId) -> Result<Item> {
let path = self.item_path(collection_slug, id);
let bytes = fs::read(&path)
.with_context(|| format!("read item {}", path.display()))?;
Ok(decrypt_item(&bytes, &self.org_key)?)
}
/// Delete an item blob. Missing file is not an error (partial-write
/// recovery, same as the personal-vault purge path).
pub fn remove_item(&self, collection_slug: &str, id: &ItemId) -> Result<()> {
let path = self.item_path(collection_slug, id);
match fs::remove_file(&path) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(anyhow::Error::from(e)
.context(format!("delete {}", path.display()))),
}
}
/// Bail unless `member` has `slug` in their collection grants. The slug
/// existence check is done separately by the caller against collections.json.
pub fn ensure_grant(member: &OrgMember, slug: &str) -> Result<()> {
if member.collections.iter().any(|c| c == slug) {
Ok(())
} else {
bail!(
"access denied: you do not have a grant for collection `{slug}` — ask an admin to run `relicario org grant`"
)
}
}
/// Load members.json and find the caller's member entry by matching the
/// current device's ed25519 fingerprint against each member's pubkey
/// fingerprint. Fingerprint comparison (not raw OpenSSH-string equality)
/// tolerates comment/whitespace differences in the serialized key.
pub fn current_member(&self) -> Result<relicario_core::OrgMember> {
let device_fp = current_device_fingerprint()?;
let members = self.load_members()?;
members
.members
.into_iter()
.find(|m| {
relicario_core::fingerprint(&m.ed25519_pubkey)
.ok()
.as_deref()
== Some(device_fp.as_str())
})
.ok_or_else(|| {
anyhow::anyhow!(
"your device key is not registered in this org — ask an admin to run `org add-member`"
)
})
}
}
/// Locate the org vault root from RELICARIO_ORG_DIR env var or --dir flag value.
pub fn org_dir(dir_flag: Option<&std::path::Path>) -> Result<PathBuf> {
if let Some(d) = dir_flag {
return Ok(d.to_path_buf());
}
if let Ok(v) = std::env::var("RELICARIO_ORG_DIR") {
return Ok(PathBuf::from(v));
}
bail!("org vault location required: set RELICARIO_ORG_DIR or pass --dir <path>")
}
/// Open an org vault: locate the root, read members.json to find the caller's
/// member entry (by ed25519 fingerprint), then unwrap their keys/<id>.enc to
/// recover the org master key.
pub fn open_org_vault(dir_flag: Option<&std::path::Path>) -> Result<UnlockedOrgVault> {
let root = org_dir(dir_flag)?;
let device_fp = current_device_fingerprint()?;
let members_json = fs::read_to_string(root.join("members.json"))
.context("read members.json — is this an org vault?")?;
let members: OrgMembers = serde_json::from_str(&members_json).context("parse members.json")?;
let member = members
.members
.iter()
.find(|m| {
relicario_core::fingerprint(&m.ed25519_pubkey)
.ok()
.as_deref()
== Some(device_fp.as_str())
})
.ok_or_else(|| anyhow::anyhow!("your device key is not in this org"))?;
// Load this member's wrapped key blob.
let key_path = root
.join("keys")
.join(format!("{}.enc", member.member_id.as_str()));
let wrapped =
fs::read(&key_path).with_context(|| format!("read {}", key_path.display()))?;
// Recover the device ed25519 seed and unwrap.
let seed = crate::device::current_device_seed()?;
let org_key = relicario_core::unwrap_org_key(&wrapped, &seed)?;
Ok(UnlockedOrgVault { root, org_key })
}
/// OpenSSH SHA-256 fingerprint of the active device's signing key.
fn current_device_fingerprint() -> Result<String> {
let name = crate::device::current_device()?
.ok_or_else(|| anyhow::anyhow!("no active device — run `relicario device add` first"))?;
let pub_path = crate::device::device_dir(&name)?.join("signing.pub");
let pubkey = fs::read_to_string(&pub_path)
.with_context(|| format!("read {}", pub_path.display()))?;
Ok(relicario_core::fingerprint(pubkey.trim())?)
}
/// Recover the active device's ed25519 seed (the 32-byte private scalar source)
pub(crate) fn atomic_write(path: &Path, data: &[u8]) -> Result<()> {
let mut tmp = path.as_os_str().to_owned();
tmp.push(".tmp");
let tmp = PathBuf::from(tmp);
fs::write(&tmp, data).with_context(|| format!("write {}", tmp.display()))?;
fs::rename(&tmp, path).with_context(|| format!("rename {}", tmp.display()))?;
Ok(())
}
/// Run `git <args>` in the org repo, capturing output and replaying it on
/// failure. Unlike `crate::helpers::git_run`, this does NOT inject
/// `commit.gpgsign=false` / `core.hooksPath=/dev/null`: org commits MUST be
/// signed (the pre-receive hook verifies every commit's signature), and the
/// repo's signing config is established by `configure_git_signing` during
/// `org init`.
pub(crate) fn org_git_run(root: &Path, args: &[&str], context: &str) -> Result<()> {
let output = std::process::Command::new("git")
.current_dir(root)
.args(args)
.output()
.with_context(|| format!("{context}: failed to spawn git"))?;
if !output.status.success() {
if !output.stdout.is_empty() {
eprint!("{}", String::from_utf8_lossy(&output.stdout));
}
if !output.stderr.is_empty() {
eprint!("{}", String::from_utf8_lossy(&output.stderr));
}
anyhow::bail!("{context}: git failed ({})", output.status);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
use std::fs;
fn make_vault(key: Zeroizing<[u8; 32]>) -> (TempDir, UnlockedOrgVault) {
let dir = TempDir::new().unwrap();
let root = dir.path().to_path_buf();
fs::create_dir_all(root.join("items")).unwrap();
fs::create_dir_all(root.join("keys")).unwrap();
let vault = UnlockedOrgVault { root, org_key: key };
(dir, vault)
}
#[test]
fn unlocked_org_vault_paths() {
let key = Zeroizing::new([0u8; 32]);
let (dir, vault) = make_vault(key);
let root = dir.path().to_path_buf();
assert_eq!(vault.manifest_path(), root.join("manifest.enc"));
assert_eq!(
vault.member_key_path(&MemberId("abc0def1abc0def1".into())),
root.join("keys/abc0def1abc0def1.enc")
);
assert_eq!(
vault.item_path("prod", &relicario_core::ItemId("0123456789abcdef".into())),
root.join("items/prod/0123456789abcdef.enc")
);
}
#[test]
fn save_and_load_manifest() {
let key = Zeroizing::new([0xAAu8; 32]);
let (dir, vault) = make_vault(key);
let _ = dir; // keep alive
let mut m = OrgManifest::new();
m.entries.push(relicario_core::OrgManifestEntry {
id: relicario_core::ItemId::new(),
r#type: relicario_core::ItemType::SecureNote,
title: "test".into(),
tags: vec![],
modified: 0,
trashed_at: None,
collection: "prod".into(),
});
vault.save_manifest(&m).unwrap();
let loaded = vault.load_manifest().unwrap();
assert_eq!(loaded.entries.len(), 1);
}
#[test]
fn save_and_load_members() {
let key = Zeroizing::new([0u8; 32]);
let (dir, vault) = make_vault(key);
let _ = dir;
let members = OrgMembers::new();
vault.save_members(&members).unwrap();
let loaded = vault.load_members().unwrap();
assert_eq!(loaded.schema_version, 1);
}
}

View File

@@ -1,13 +1,12 @@
//! Interactive prompt helpers for the CLI.
//!
//! The `prompt`/`prompt_optional`/`prompt_secret` family reads from stdin /
//! the TTY; the `prompt_keep`/`prompt_keep_opt`/`prompt_yesno` variants are
//! `prompt_secret` reads a masked secret from the TTY (honouring
//! `RELICARIO_TEST_ITEM_SECRET` so integration tests without a TTY can inject
//! secrets); the `prompt_keep`/`prompt_keep_opt`/`prompt_yesno` variants are
//! used by the edit handlers to keep current values when the user hits enter
//! at a blank prompt. `prompt_secret` honours `RELICARIO_TEST_ITEM_SECRET`
//! so integration tests (which don't have a TTY) can inject secrets.
//! `prompt_or_flag` and `prompt_or_flag_optional` thread a CLI-flag value
//! through the same path so command handlers can use one call site whether
//! the value came from the command line or from an interactive prompt.
//! at a blank prompt. `prompt_or_flag` and `prompt_or_flag_optional` thread a
//! CLI-flag value through the same path so command handlers can use one call
//! site whether the value came from the command line or an interactive prompt.
use anyhow::Result;
use std::io::BufRead;
@@ -41,18 +40,6 @@ fn read_optional_line<R: BufRead>(reader: &mut R, label: &str) -> Result<Option<
Ok(if trimmed.is_empty() { None } else { Some(trimmed) })
}
pub(crate) fn prompt(label: &str) -> Result<String> {
let stdin = std::io::stdin();
let mut reader = std::io::BufReader::new(stdin.lock());
read_required_line(&mut reader, label)
}
pub(crate) fn prompt_optional(label: &str) -> Result<Option<String>> {
let stdin = std::io::stdin();
let mut reader = std::io::BufReader::new(stdin.lock());
read_optional_line(&mut reader, label)
}
pub(crate) fn prompt_keep(label: &str, current: &str) -> Result<Option<String>> {
eprint!("{label} [{current}]: ");
std::io::Write::flush(&mut std::io::stderr())?;

View File

@@ -201,3 +201,20 @@ fn generate_random_and_bip39() {
let phrase = String::from_utf8(out.stdout).unwrap();
assert_eq!(phrase.trim().split(' ').count(), 5);
}
#[test]
fn add_card_via_stdin_flags_is_non_interactive() {
let v = TestVault::init();
let out = v.run_with_input(
&["add", "card", "--title", "Visa", "--kind", "credit",
"--number-stdin", "--cvv-stdin", "--pin-stdin"],
&["4111111111111111", "123", "4321"],
);
assert!(out.status.success(), "add card via stdin failed: {}", String::from_utf8_lossy(&out.stderr));
let got = v.run(&["get", "Visa"]);
assert!(got.status.success(), "get Visa failed: {}", String::from_utf8_lossy(&got.stderr));
let stdout = String::from_utf8_lossy(&got.stdout);
assert!(stdout.contains("********"), "card number should be masked without --show: {stdout}");
assert!(!stdout.contains("4111111111111111"), "card number leaked without --show: {stdout}");
}

View File

@@ -0,0 +1,215 @@
//! Authorization regression tests for the `relicario org` item commands.
//!
//! These cover two gaps the B9B14 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}");
}

View File

@@ -0,0 +1,24 @@
use tempfile::TempDir;
fn run(args: &[&str]) -> std::process::Output {
std::process::Command::new(env!("CARGO_BIN_EXE_relicario"))
.args(args)
.output()
.expect("run relicario")
}
#[test]
#[ignore] // requires a device key on disk; run manually or via org_init_signing
fn org_init_creates_expected_files() {
let dir = TempDir::new().unwrap();
let path = dir.path().to_str().unwrap();
// `--dir` is a subcommand-scoped global on `org` (B14), so it must come
// AFTER `org init`, not before it (matches B10's OrgFixture).
let out = run(&["org", "init", "--dir", path, "--name", "Test Org"]);
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
assert!(dir.path().join("org.json").exists());
assert!(dir.path().join("members.json").exists());
assert!(dir.path().join("collections.json").exists());
assert!(dir.path().join("manifest.enc").exists());
assert!(dir.path().join(".git").exists());
}

View File

@@ -0,0 +1,149 @@
use std::fs;
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)
.env("HOME", config_home) // belt-and-suspenders for dirs on all platforms
.args(args)
.output()
.expect("run relicario")
}
/// Like relicario() but also injects the git committer identity so that
/// `git commit` inside `org init` doesn't fail with "Please tell me who you are."
fn relicario_with_git_identity(config_home: &Path, args: &[&str]) -> std::process::Output {
Command::new(env!("CARGO_BIN_EXE_relicario"))
.env("XDG_CONFIG_HOME", config_home)
.env("HOME", config_home)
.env("GIT_AUTHOR_NAME", "Test Device")
.env("GIT_AUTHOR_EMAIL", "test@relicario.test")
.env("GIT_COMMITTER_NAME", "Test Device")
.env("GIT_COMMITTER_EMAIL", "test@relicario.test")
.args(args)
.output()
.expect("run relicario")
}
fn git(repo: &Path, args: &[&str]) -> std::process::Output {
Command::new("git")
.current_dir(repo)
.args(args)
.output()
.expect("run git")
}
/// Lay out device keys directly under `<config_home>/relicario/devices/<name>/`
/// and set `devices/current` — mirrors the B2 seed_helper_tests approach.
/// Returns the OpenSSH public key string so the caller can build an allowed_signers
/// file for `git verify-commit`.
fn seed_device(config_home: &Path, name: &str) -> String {
let (priv_openssh, pub_openssh) =
relicario_core::device::generate_keypair().expect("generate_keypair");
let dev_dir = config_home
.join("relicario")
.join("devices")
.join(name);
fs::create_dir_all(&dev_dir).expect("create device dir");
let signing_key_path = dev_dir.join("signing.key");
fs::write(&signing_key_path, priv_openssh.as_str())
.expect("write signing.key");
// ssh requires 0600 on private key files or it refuses to use them.
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&signing_key_path, fs::Permissions::from_mode(0o600))
.expect("chmod signing.key");
}
fs::write(dev_dir.join("signing.pub"), &pub_openssh)
.expect("write signing.pub");
// Also write stub deploy key files so configure_git_signing doesn't trip on
// a missing deploy.key path (the git config value just points to the file;
// the file itself is never read during org init).
fs::write(dev_dir.join("deploy.key"), "").expect("write stub deploy.key");
fs::write(dev_dir.join("deploy.pub"), "").expect("write stub deploy.pub");
// Set this device as current.
let devices_dir = config_home.join("relicario").join("devices");
fs::write(devices_dir.join("current"), format!("{name}\n"))
.expect("write current");
pub_openssh
}
#[test]
fn org_init_produces_a_signed_initial_commit() {
let cfg = TempDir::new().unwrap();
let org = TempDir::new().unwrap();
// Lay out the device key directly (no `device add` needed — it requires Gitea).
let pub_openssh = seed_device(cfg.path(), "test-dev");
// Initialize the org vault. `--dir` comes AFTER `org init` (B14 global).
// Inject git identity so the commit doesn't fail "Please tell me who you are."
let init = relicario_with_git_identity(
cfg.path(),
&["org", "init", "--dir", org.path().to_str().unwrap(), "--name", "Acme"],
);
assert!(
init.status.success(),
"org init failed:\nstdout: {}\nstderr: {}",
String::from_utf8_lossy(&init.stdout),
String::from_utf8_lossy(&init.stderr)
);
// The org repo must be configured to sign.
let cfg_out = git(org.path(), &["config", "commit.gpgsign"]);
assert_eq!(
String::from_utf8_lossy(&cfg_out.stdout).trim(),
"true",
"org repo must have commit.gpgsign=true"
);
// The HEAD commit object must carry a signature header.
let head = git(org.path(), &["cat-file", "commit", "HEAD"]);
let body = String::from_utf8_lossy(&head.stdout);
assert!(
body.contains("gpgsig "),
"HEAD commit must be signed (no gpgsig header found):\n{body}"
);
// Configure an allowed_signers file so `git verify-commit` can validate the
// SSH signature. The principal must match the committer email injected above.
let allowed_signers_path = cfg.path().join("allowed_signers");
let allowed_line = format!("test@relicario.test {}", pub_openssh.trim());
fs::write(&allowed_signers_path, format!("{allowed_line}\n"))
.expect("write allowed_signers");
git(
org.path(),
&[
"config",
"gpg.ssh.allowedSignersFile",
allowed_signers_path.to_str().unwrap(),
],
);
// Now verify-commit should succeed.
let verify = git(org.path(), &["verify-commit", "HEAD"]);
assert!(
verify.status.success(),
"git verify-commit HEAD failed:\nstdout: {}\nstderr: {}",
String::from_utf8_lossy(&verify.stdout),
String::from_utf8_lossy(&verify.stderr)
);
// The commit body must carry the org-init action trailer.
let log_out = git(org.path(), &["log", "-1", "--format=%B"]);
let commit_body = String::from_utf8_lossy(&log_out.stdout);
assert!(
commit_body.contains("Relicario-Action: org-init"),
"HEAD commit body must contain 'Relicario-Action: org-init' trailer:\n{commit_body}"
);
}

View 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}");
}

View 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));
}

View File

@@ -103,6 +103,26 @@ Pipeline" and "Crate Layout").
auth factor. Owns its own `YChannel`, `EmbedRegion`, 8×8 DCT/IDCT,
Quantization Index Modulation, and crop-recovery extractor. No other module
imports it; it is consumed only via the public re-export from `lib.rs`.
- **`org.rs`** — Org-vault data model and ECIES key-wrapping layer
(`crates/relicario-core/src/org.rs`). Types: `OrgId` (L15), `MemberId`
(L19; `is_valid` L41 — 16 lowercase hex), `OrgRole` (L54;
`can_manage_members` L61 = Owner | Admin, `can_manage_owners` L64 = Owner
only), `OrgMember` (L72; carries `ed25519_pubkey` in OpenSSH wire format,
`collections` grant list, `role`), `OrgMembers` (L86; `schema_version: 1`
L93; `validate` L104), `CollectionDef` (L123), `OrgCollections` (L131;
`schema_version: 1` L138; `validate` L145 rejects empty / `/` / `.` slugs),
`OrgMeta` (L164; `schema_version: 1` L174), `OrgManifestEntry` (L185;
carries `collection` slug plus id/type/title/tags/modified/trashed\_at),
`OrgManifest` (L199; `schema_version: 1` L206; `filter_for_member` L210
returns only entries whose collection slug appears in the member's grants).
All four JSON containers carry `schema_version: 1` — distinct from the
personal `Manifest` whose `MANIFEST_SCHEMA_VERSION = 2` (`manifest.rs:12`).
Crypto: `generate_org_key` (L230) → `Zeroizing<[u8;32]>` (256-bit
CSPRNG org master key); `wrap_org_key` (L265) / `unwrap_org_key` (L299) —
ECIES over X25519, described in detail under **Invariants & contracts**
below. `vault.rs` adds `encrypt_org_manifest` / `decrypt_org_manifest` typed
wrappers (JSON-serialize → `crypto::encrypt` under the org key, plaintext in
`Zeroizing`) consistent with the personal-vault pattern.
- **`backup.rs`** — `.relbak` v1 container format: `pack_backup` /
`unpack_backup` plus the `BackupInput` / `BackupOutput` / `BackupItem` /
`BackupAttachment` shapes. Wraps a zstd-compressed JSON envelope of vault
@@ -230,6 +250,28 @@ Pipeline" and "Crate Layout").
also used to derive the key for *unlock*, not just create).
- **`SymbolCharset::Custom` must be ASCII-only** (`generators.rs:46-52`).
Non-ASCII custom charsets are rejected with `RelicarioError::Format`.
- **ECIES wrap-blob layout is fixed** at
`ephemeral_x25519_pk(32) || version(1) || nonce(24) || ciphertext+tag`
(`org.rs:264`). The `version(1)` byte is the same `VERSION_BYTE = 0x02`
emitted by `crypto::encrypt`, which is what occupies that slot — the layout
merely names the regions for clarity.
- **KDF wrap key = `SHA-256(dh_shared || ephemeral_pk || recipient_pk)`**
(`org.rs:278-281`). The concatenation order is identical in `wrap_org_key`
and `unwrap_org_key`; a mismatch in either direction would produce a
different key and fail the AEAD open. The intermediate `kdf_input` buffer is
held in `Zeroizing<Vec<u8>>`; `org_key`, `wrap_key`, and the decrypted
`plaintext` from unwrap are also held in `Zeroizing`.
- **ed25519 → X25519 conversion** applies `SHA-512(seed)[..32]` then the
RFC 7748 scalar clamp
(`scalar[0] &= 248; scalar[31] &= 127; scalar[31] |= 64`) to derive the
private X25519 scalar (`org.rs:242`); the recipient public key is obtained
via `ed25519_dalek`'s `to_montgomery()`. This lets device ed25519 keys serve
double duty as X25519 recipients without storing a separate DH key.
- **Org crypto bypasses Argon2id.** The ECIES inner cipher delegates to
`crate::crypto::encrypt` / `decrypt` (XChaCha20-Poly1305, random 24-byte
nonce, `VERSION_BYTE = 0x02`) — no AEAD re-implementation. The X25519 KDF
output is used directly as the AEAD key; the Argon2id path in `crypto.rs`
is not invoked for org key wrapping.
## Key flows
@@ -315,6 +357,35 @@ when subsequent `decrypt_*` returns `RelicarioError::Decrypt`.
call `item.prune_history(&settings.field_history_retention, now_unix())`
when they want to enforce the policy.
### Org key wrap / unwrap
1. **Wrap** (`org.rs:265`): caller supplies a recipient's OpenSSH ed25519
public key string.
- Parse the OpenSSH wire format via `ssh-key` to recover the raw 32-byte
ed25519 public key bytes; apply `to_montgomery()` (ed25519-dalek) to
obtain the recipient's X25519 public key.
- Generate an ephemeral X25519 keypair from `OsRng`.
- `dh_shared = ephemeral_secret × recipient_x25519_pk` (X25519 DH).
- `wrap_key = SHA-256(dh_shared || ephemeral_pk || recipient_pk)`
(`org.rs:278-281`), intermediates in `Zeroizing`.
- `ct = crate::crypto::encrypt(&wrap_key, &org_key)` — yields the standard
`version(1) || nonce(24) || ciphertext+tag` blob.
- Return `ephemeral_x25519_pk(32) || ct` (`org.rs:264`).
2. **Unwrap** (`org.rs:299`): caller supplies the device ed25519 seed bytes
(from `current_device_seed` in the CLI layer, not from `relicario-core`).
- Derive X25519 private scalar from seed: `SHA-512(seed)[..32]` + RFC 7748
clamp (`org.rs:242`).
- Slice the first 32 bytes of the blob as `ephemeral_pk`; read recipient's
own X25519 public key via the same `to_montgomery()` path.
- `dh_shared = device_x25519_secret × ephemeral_pk`.
- Reconstruct `wrap_key` identically; `crypto::decrypt` recovers `org_key`
into `Zeroizing`.
Integration tests: `crates/relicario-core/tests/org.rs` (5 acceptance tests
covering wrap/unwrap round-trip, revoked-after-rotation, and manifest
`filter_for_member`). A pinned RFC 8032 ed25519→X25519 known-answer vector
lives in the `#[cfg(test)]` block inside `org.rs` itself.
### imgsecret embed
1. Caller passes a JPEG byte slice and a 32-byte secret to

View File

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

View File

@@ -5,6 +5,14 @@ edition = "2021"
description = "Pre-receive Git hook for relicario password manager"
license = "GPL-3.0-or-later"
[lib]
name = "relicario_server"
path = "src/lib.rs"
[[bin]]
name = "relicario-server"
path = "src/main.rs"
[dependencies]
relicario-core = { path = "../relicario-core" }
anyhow = "1"

View File

@@ -0,0 +1,58 @@
//! Library surface for relicario-server, exposing pure helpers used by the
//! pre-receive hooks so they can be unit-tested.
/// Classification of a single changed path inside an org repo.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PathClass {
/// `members.json`, `collections.json`, `org.json` — only Owner/Admin may write.
Protected,
/// `items/<slug>/<id>.enc` — writer must hold a grant for `<slug>`.
Item { collection: String },
/// `keys/<id>.enc`, `manifest.enc`, `.gitignore`, etc. — gated only by the
/// per-commit signature check (signer must be a current member).
Unrestricted,
/// Structurally invalid path; commit must be rejected.
Rejected(String),
}
/// Classify a repo-relative path. Pure; no I/O.
pub fn classify_path(path: &str) -> PathClass {
match path {
"members.json" | "collections.json" | "org.json" => return PathClass::Protected,
_ => {}
}
if let Some(rest) = path.strip_prefix("items/") {
// Expect exactly: <slug>/<id>.enc → two segments after the prefix.
let segments: Vec<&str> = rest.split('/').collect();
if segments.len() != 2 {
return PathClass::Rejected("items path must be items/<slug>/<id>.enc".to_string());
}
let slug = segments[0];
if slug.is_empty() {
return PathClass::Rejected("empty collection slug in items path".to_string());
}
// Defense-in-depth: mirror `OrgCollections::validate` — a slug containing
// '.' (e.g. a `..`/`.` path-traversal attempt) is structurally invalid.
// git normalizes most `./` away before the hook sees the path, so this is
// unreachable today; it keeps the hook self-defensive regardless.
if slug.contains('.') {
return PathClass::Rejected(format!("invalid collection slug: {:?}", slug));
}
return PathClass::Item { collection: slug.to_string() };
}
PathClass::Unrestricted
}
/// Extract the `schema_version` field from any org JSON document.
/// Returns an error if the field is absent or not a u32.
pub fn extract_schema_version(json: &str) -> Result<u32, String> {
let value: serde_json::Value =
serde_json::from_str(json).map_err(|e| format!("parse json: {e}"))?;
value
.get("schema_version")
.and_then(|v| v.as_u64())
.map(|n| n as u32)
.ok_or_else(|| "missing or non-integer schema_version".to_string())
}

View File

@@ -6,6 +6,8 @@ use std::process::Command;
use anyhow::{Context, Result};
use clap::{Parser, Subcommand};
use relicario_core::device::{DeviceEntry, RevokedEntry};
use relicario_core::org::{OrgCollections, OrgMember, OrgMembers, OrgRole};
use relicario_server::{classify_path, extract_schema_version, PathClass};
#[derive(Parser)]
#[command(name = "relicario-server")]
@@ -23,6 +25,13 @@ enum Commands {
},
/// Generate a pre-receive hook script.
GenerateHook,
/// Verify a commit to an org vault: signature + role/path authorization.
VerifyOrgCommit {
/// The commit SHA to verify.
commit: String,
},
/// Generate an org pre-receive hook script.
GenerateOrgHook,
}
fn main() -> Result<()> {
@@ -31,6 +40,8 @@ fn main() -> Result<()> {
match cli.command {
Commands::VerifyCommit { commit } => verify_commit(&commit),
Commands::GenerateHook => generate_hook(),
Commands::VerifyOrgCommit { commit } => verify_org_commit(&commit),
Commands::GenerateOrgHook => generate_org_hook(),
}
}
@@ -187,3 +198,408 @@ fn git_show(commit: &str, path: &str) -> Result<String> {
Ok(String::from_utf8(output.stdout)?)
}
/// Verify the SSH signature on `commit` against the given org members and return
/// the matching member. On any failure (unsigned, malformed, or unknown signer)
/// this prints REJECT and calls `std::process::exit(1)`; it only returns on success.
fn verify_org_signer(commit: &str, members: &OrgMembers) -> OrgMember {
// Build a temp allowed-signers file from every current member's pubkey.
let tmp = match tempfile::tempdir() {
Ok(t) => t,
Err(e) => {
eprintln!("REJECT: org commit {commit} — cannot create tempdir: {e}");
std::process::exit(1);
}
};
let allowed_path = tmp.path().join("allowed_signers");
let mut allowed_body = String::new();
for m in &members.members {
allowed_body.push_str("relicario ");
allowed_body.push_str(m.ed25519_pubkey.trim());
allowed_body.push('\n');
}
if let Err(e) = fs::write(&allowed_path, &allowed_body) {
eprintln!("REJECT: org commit {commit} — cannot write allowed_signers: {e}");
std::process::exit(1);
}
// Run git verify-commit --raw with the allowed-signers file injected.
let output = match Command::new("git")
.args(["verify-commit", "--raw", commit])
.env("GIT_CONFIG_COUNT", "1")
.env("GIT_CONFIG_KEY_0", "gpg.ssh.allowedSignersFile")
.env("GIT_CONFIG_VALUE_0", allowed_path.as_os_str())
.output()
{
Ok(o) => o,
Err(e) => {
eprintln!("REJECT: org commit {commit} — git verify-commit failed to run: {e}");
std::process::exit(1);
}
};
let stderr = String::from_utf8_lossy(&output.stderr);
// The org hook builds allowed_signers from EVERY current member, so a clean
// `git verify-commit` exit IS the security gate: a non-zero exit means the
// commit was unsigned, tampered, or signed by a non-member. Make that
// property explicit rather than relying on the stderr regex alone (regex
// output is fragile across git versions). The fingerprint parse + member
// mapping below then identifies WHICH member signed.
if !output.status.success() {
eprintln!(
"REJECT: org commit {commit} — signature did not verify against current members \
(git verify-commit exit {}): {}",
output.status.code().unwrap_or(-1),
stderr.trim()
);
std::process::exit(1);
}
// Parse the SHA-256 fingerprint from stderr (same regex as verify_commit).
let re = regex::Regex::new(r"key (SHA256:[A-Za-z0-9+/]+)").expect("static regex");
let signing_fp = match re.captures(&stderr).and_then(|c| c.get(1)) {
Some(m) => m.as_str().to_string(),
None => {
eprintln!(
"REJECT: org commit {commit} — no valid signature found (stderr: {})",
stderr.trim()
);
std::process::exit(1);
}
};
// Map fingerprint → member via relicario_core::fingerprint over each pubkey.
for m in &members.members {
if let Ok(fp) = relicario_core::fingerprint(&m.ed25519_pubkey) {
if fp == signing_fp {
return m.clone();
}
}
}
eprintln!(
"REJECT: org commit {commit} — signer (fingerprint {signing_fp}) is not a current org member"
);
std::process::exit(1);
}
fn verify_org_commit(commit: &str) -> Result<()> {
// Determine parent count from %P (space-separated parent SHAs; empty = root).
let parents_out = Command::new("git")
.args(["show", "-s", "--format=%P", commit])
.output()
.context("git show parents")?;
let parents_line = String::from_utf8_lossy(&parents_out.stdout);
let parents: Vec<&str> = parents_line.split_whitespace().collect();
// Merge commits are rejected. Org repos are linear (CLI uses pull --rebase).
if parents.len() > 1 {
eprintln!(
"REJECT: org commit {commit} — merge commits are not allowed in org vaults \
({} parents); rebase instead",
parents.len()
);
std::process::exit(1);
}
let is_root = parents.is_empty();
// Load members.json AS OF THIS COMMIT so the genesis commit can authorize itself.
let members_json = match git_show(commit, "members.json") {
Ok(s) => s,
Err(_) => {
if is_root {
eprintln!("OK: org commit {commit} (root bootstrap - no members.json yet)");
return Ok(());
}
eprintln!("REJECT: org commit {commit} — members.json missing from non-root commit");
std::process::exit(1);
}
};
let members: OrgMembers =
serde_json::from_str(&members_json).context("parse members.json")?;
if members.members.is_empty() {
if is_root {
eprintln!("OK: org commit {commit} (root bootstrap - empty member list)");
return Ok(());
}
eprintln!("REJECT: org commit {commit} — members.json has no members");
std::process::exit(1);
}
members
.validate()
.map_err(|e| anyhow::anyhow!("members.json invalid: {e}"))?;
// Verify the signature and resolve the signing member (exits on failure).
let signer = verify_org_signer(commit, &members);
// Enumerate changed paths. Root has no parent to diff, so use ls-tree.
let changed_paths: Vec<String> = if is_root {
let out = Command::new("git")
.args(["ls-tree", "-r", "--name-only", commit])
.output()
.context("git ls-tree")?;
String::from_utf8_lossy(&out.stdout)
.lines()
.map(|l| l.trim().to_string())
.filter(|l| !l.is_empty())
.collect()
} else {
let out = Command::new("git")
.args(["diff-tree", "--no-commit-id", "-r", "--name-only", commit])
.output()
.context("git diff-tree")?;
String::from_utf8_lossy(&out.stdout)
.lines()
.map(|l| l.trim().to_string())
.filter(|l| !l.is_empty())
.collect()
};
// Authorize each changed path against the signing member's role/grants.
// collections.json (as of this commit) is loaded lazily on the first item
// path, for the L5 slug-existence check.
let mut collection_slugs: Option<Vec<String>> = None;
for path in &changed_paths {
match classify_path(path) {
PathClass::Rejected(why) => {
eprintln!("REJECT: org commit {commit} — invalid path `{path}`: {why}");
std::process::exit(1);
}
PathClass::Protected => {
if !signer.role.can_manage_members() {
eprintln!(
"REJECT: org commit {commit} — member '{}' (role {:?}) may not write protected file `{path}`",
signer.display_name, signer.role
);
std::process::exit(1);
}
// Privilege-escalation gate: only an Owner may INTRODUCE or
// ELEVATE an owner/admin. An Admin may write members.json but
// must not mint owners/admins server-side (spec §148/158/271).
if path == "members.json" {
enforce_owner_only_elevation(commit, is_root, &members, &signer);
}
}
PathClass::Item { collection } => {
// The signing member must hold an explicit grant for the slug.
if !signer.collections.iter().any(|c| c == &collection) {
eprintln!(
"REJECT: org commit {commit} — member '{}' lacks a grant for collection `{collection}` (path `{path}`)",
signer.display_name
);
std::process::exit(1);
}
// Slug-existence (L5): the collection must exist in
// collections.json AS OF THIS COMMIT. A write into a
// granted-but-deleted (or never-created) collection is rejected.
let known = collection_slugs.get_or_insert_with(|| {
git_show(commit, "collections.json")
.ok()
.and_then(|s| serde_json::from_str::<OrgCollections>(&s).ok())
.map(|c| c.collections.into_iter().map(|d| d.slug).collect::<Vec<_>>())
.unwrap_or_default()
});
if !known.iter().any(|s| s == &collection) {
eprintln!(
"REJECT: org commit {commit} — item write to collection `{collection}` whose slug is absent from collections.json (path `{path}`)"
);
std::process::exit(1);
}
}
PathClass::Unrestricted => {
// keys/<id>.enc, manifest.enc, etc. — signature check already passed.
}
}
}
// Schema-version monotonicity for the three JSON files (Task C2).
enforce_schema_monotonicity(commit, is_root, &changed_paths)?;
eprintln!(
"OK: org commit {commit} verified — signed by '{}' ({:?}), {} path(s) authorized",
signer.display_name,
signer.role,
changed_paths.len()
);
Ok(())
}
/// Reject the commit unless every newly-introduced or elevated owner/admin is
/// authorized. The signer's AUTHORITY is their role in the PARENT state — the role
/// they held BEFORE this commit — NOT the role this commit may grant them. Reading
/// `signer.role` (which is parsed from the post-change members.json) would let an
/// admin self-promote to owner and then pass this very gate with the owner role
/// they are minting — the exact escalation H-C1 exists to stop. We diff the new
/// members.json against the parent's by member_id and require an owner-authority
/// signer for any member that BECOMES owner/admin (new entry, or a role elevated
/// up to owner/admin). On genesis (root) the sole bootstrap owner is allowed.
///
/// `git_show_parent` is defined alongside `enforce_schema_monotonicity` below.
fn enforce_owner_only_elevation(
commit: &str,
is_root: bool,
new_members: &OrgMembers,
signer: &OrgMember,
) {
let is_privileged = |r: OrgRole| matches!(r, OrgRole::Owner | OrgRole::Admin);
// Genesis: the bootstrap commit introduces the sole owner; allow it.
if is_root {
return;
}
// Parent baseline. If members.json did not exist in the parent, every
// privileged member here is "new" and must be owner-signed.
let parent_members: Vec<(String, OrgRole)> = match git_show_parent(commit, "members.json") {
Ok(s) => serde_json::from_str::<OrgMembers>(&s)
.map(|m| {
m.members
.into_iter()
.map(|m| (m.member_id.0, m.role))
.collect()
})
.unwrap_or_default(),
Err(_) => Vec::new(),
};
let parent_role = |id: &str| -> Option<OrgRole> {
parent_members.iter().find(|(mid, _)| mid == id).map(|(_, r)| *r)
};
// The signer's authority = their PARENT role. A member absent from the parent
// (brand new) has no prior authority and cannot mint owners/admins.
let signer_parent = parent_role(signer.member_id.as_str());
let signer_may_manage_owners = signer_parent.map_or(false, |r| r.can_manage_owners());
for m in &new_members.members {
if !is_privileged(m.role) {
continue;
}
// Skip ONLY if the role is unchanged from the parent (a no-op same-role
// entry). Any CHANGE into a privileged role — a new privileged member,
// Member→Admin/Owner, or Admin→Owner — must be owner-signed.
if parent_role(m.member_id.as_str()) == Some(m.role) {
continue;
}
// A new owner/admin, or a member elevated to owner/admin → owner-only,
// judged by the signer's PRE-commit authority.
if !signer_may_manage_owners {
eprintln!(
"REJECT: org commit {commit} — member '{}' (parent role {:?}) may not introduce or \
elevate owner/admin '{}' to {:?}; only an owner may",
signer.display_name, signer_parent, m.display_name, m.role
);
std::process::exit(1);
}
}
}
fn generate_org_hook() -> Result<()> {
print!(
r#"#!/bin/bash
# Relicario org pre-receive hook -- verify signatures + role/path authorization
while read oldrev newrev refname; do
[ "$newrev" = "0000000000000000000000000000000000000000" ] && continue
if [ "$oldrev" = "0000000000000000000000000000000000000000" ]; then
commits=$(git rev-list "$newrev")
else
commits=$(git rev-list "$oldrev..$newrev")
fi
for commit in $commits; do
relicario-server verify-org-commit "$commit" || exit 1
done
done
"#
);
Ok(())
}
/// For each protected JSON file changed in this commit, ensure schema_version did
/// not decrease vs the parent commit, and re-validate collections.json structure.
fn enforce_schema_monotonicity(
commit: &str,
is_root: bool,
changed_paths: &[String],
) -> Result<()> {
const VERSIONED: [&str; 3] = ["members.json", "collections.json", "org.json"];
for file in VERSIONED {
if !changed_paths.iter().any(|p| p == file) {
continue;
}
// A deletion of a protected file is not allowed.
let new_content = match git_show(commit, file) {
Ok(s) => s,
Err(_) => {
eprintln!(
"REJECT: org commit {commit} — protected file `{file}` was deleted; \
org vaults never delete {file}"
);
std::process::exit(1);
}
};
let new_version = match extract_schema_version(&new_content) {
Ok(v) => v,
Err(e) => {
eprintln!("REJECT: org commit {commit} — `{file}` invalid: {e}");
std::process::exit(1);
}
};
// collections.json structural validation.
if file == "collections.json" {
match serde_json::from_str::<relicario_core::org::OrgCollections>(&new_content) {
Ok(c) => {
if let Err(e) = c.validate() {
eprintln!("REJECT: org commit {commit} — collections.json invalid: {e}");
std::process::exit(1);
}
}
Err(e) => {
eprintln!("REJECT: org commit {commit} — collections.json parse error: {e}");
std::process::exit(1);
}
}
}
// On the root commit there is no parent baseline; any starting version is fine.
if is_root {
continue;
}
// Parent version: if the file did not exist in the parent (newly added),
// there is no prior version to regress against — accept.
if let Ok(old_content) = git_show_parent(commit, file) {
let old_version = match extract_schema_version(&old_content) {
Ok(v) => v,
Err(_) => {
continue;
}
};
if new_version < old_version {
eprintln!(
"REJECT: org commit {commit} — `{file}` schema_version decreased \
({old_version} -> {new_version})"
);
std::process::exit(1);
}
}
}
Ok(())
}
/// Read a file from a commit's FIRST PARENT tree: `git show {commit}^:{path}`.
fn git_show_parent(commit: &str, path: &str) -> Result<String> {
let output = Command::new("git")
.args(["show", &format!("{}^:{}", commit, path)])
.output()
.context("git show parent")?;
if !output.status.success() {
anyhow::bail!("git show {}^:{} failed", commit, path);
}
Ok(String::from_utf8(output.stdout)?)
}

View File

@@ -0,0 +1,81 @@
// Integration tests for relicario-server org-hook path classification.
use relicario_server::{classify_path, PathClass};
#[test]
fn protected_files_are_classified_protected() {
assert_eq!(classify_path("members.json"), PathClass::Protected);
assert_eq!(classify_path("collections.json"), PathClass::Protected);
assert_eq!(classify_path("org.json"), PathClass::Protected);
}
#[test]
fn item_write_yields_collection_slug() {
assert_eq!(
classify_path("items/prod/a1b2c3d4e5f6a1b2.enc"),
PathClass::Item { collection: "prod".to_string() }
);
}
#[test]
fn item_write_nested_slug_is_rejected() {
// Slugs cannot contain '/', so a path with extra segments is malformed → Rejected.
assert_eq!(
classify_path("items/prod/sub/x.enc"),
PathClass::Rejected("items path must be items/<slug>/<id>.enc".to_string())
);
}
#[test]
fn key_blobs_and_manifest_are_unrestricted() {
// keys/<id>.enc and manifest.enc are written by org operations; the SIGNATURE
// check (every commit must be signed by a current member) is the gate for them.
assert_eq!(classify_path("keys/a1b2c3d4e5f6a1b2.enc"), PathClass::Unrestricted);
assert_eq!(classify_path("manifest.enc"), PathClass::Unrestricted);
}
#[test]
fn items_without_slug_segment_are_rejected() {
// Flat items/<id>.enc (the OLD, now-removed layout) is no longer valid.
assert_eq!(
classify_path("items/a1b2c3d4e5f6a1b2.enc"),
PathClass::Rejected("items path must be items/<slug>/<id>.enc".to_string())
);
}
#[test]
fn empty_slug_segment_is_rejected() {
assert_eq!(
classify_path("items//x.enc"),
PathClass::Rejected("empty collection slug in items path".to_string())
);
}
#[test]
fn dotted_slug_is_rejected() {
// Defense-in-depth (mirrors OrgCollections::validate): a slug containing '.'
// — e.g. a ".."/"." path-traversal attempt — is rejected.
assert_eq!(
classify_path("items/../x.enc"),
PathClass::Rejected("invalid collection slug: \"..\"".to_string())
);
}
use relicario_server::extract_schema_version;
#[test]
fn extract_schema_version_reads_field() {
let json = r#"{ "schema_version": 3, "members": [] }"#;
assert_eq!(extract_schema_version(json).unwrap(), 3);
}
#[test]
fn extract_schema_version_errors_on_missing_field() {
let json = r#"{ "members": [] }"#;
assert!(extract_schema_version(json).is_err());
}
#[test]
fn extract_schema_version_errors_on_garbage() {
assert!(extract_schema_version("not json").is_err());
}

View File

@@ -0,0 +1,229 @@
//! Integration tests for `relicario-server verify-org-commit` privilege gating.
//!
//! H-C1: only an Owner may introduce or elevate an owner/admin. An Admin who
//! writes members.json must not be able to mint owners/admins.
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use assert_cmd::Command as AssertCommand;
use predicates::prelude::*;
use relicario_core::device::generate_keypair;
use tempfile::TempDir;
fn write_keypair(dir: &Path, name: &str) -> (PathBuf, String) {
let (priv_pem, pub_line) = generate_keypair().expect("generate keypair");
let priv_path = dir.join(format!("{name}.key"));
fs::write(&priv_path, priv_pem.as_str()).unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&priv_path, fs::Permissions::from_mode(0o600)).unwrap();
}
(priv_path, pub_line)
}
fn git(repo: &Path, args: &[&str]) {
let status = Command::new("git").current_dir(repo).args(args).status().unwrap();
assert!(status.success(), "git {args:?} failed");
}
/// members.json content with two members; `member_id`s are fixed 16-hex.
fn members_json(owner_pub: &str, admin_pub: &str, admin_role: &str) -> String {
format!(
r#"{{
"schema_version": 1,
"members": [
{{ "member_id": "1111111111111111", "display_name": "Owner", "role": "owner",
"ed25519_pubkey": "{}", "collections": [], "added_at": 0, "added_by": "1111111111111111" }},
{{ "member_id": "2222222222222222", "display_name": "Admin", "role": "{admin_role}",
"ed25519_pubkey": "{}", "collections": [], "added_at": 0, "added_by": "1111111111111111" }}
]
}}"#,
owner_pub.trim(),
admin_pub.trim()
)
}
/// Stage members.json, sign the commit with `signing_key`, return its SHA.
fn signed_members_commit(
repo: &Path,
signing_key: &Path,
allowed: &Path,
msg: &str,
content: &str,
) -> String {
fs::write(repo.join("members.json"), content).unwrap();
git(repo, &["add", "members.json"]);
let status = Command::new("git")
.current_dir(repo)
.args([
"-c", "gpg.format=ssh",
"-c", &format!("user.signingkey={}", signing_key.display()),
"-c", &format!("gpg.ssh.allowedSignersFile={}", allowed.display()),
"commit", "-S", "-q", "-m", msg,
])
.status()
.unwrap();
assert!(status.success());
let out = Command::new("git").current_dir(repo).args(["rev-parse", "HEAD"]).output().unwrap();
String::from_utf8(out.stdout).unwrap().trim().to_string()
}
/// Set up an org repo whose root commit (signed by the owner) registers an
/// owner + an admin. Returns (repo tmp, owner priv, admin priv, allowed file).
fn bootstrap() -> (TempDir, PathBuf, PathBuf, PathBuf) {
let tmp = TempDir::new().unwrap();
let repo = tmp.path();
git(repo, &["init", "-q", "-b", "main"]);
git(repo, &["config", "user.email", "t@t"]);
git(repo, &["config", "user.name", "t"]);
let (owner_priv, owner_pub) = write_keypair(repo, "owner");
let (admin_priv, admin_pub) = write_keypair(repo, "admin");
let allowed = repo.join("allowed_signers");
fs::write(
&allowed,
format!("relicario {}\nrelicario {}\n", owner_pub.trim(), admin_pub.trim()),
)
.unwrap();
// Genesis: owner registers both members (admin starts as `admin`).
let genesis = members_json(&owner_pub, &admin_pub, "admin");
signed_members_commit(repo, &owner_priv, &allowed, "org-init", &genesis);
// also write org.json + collections.json so later commits are well-formed
fs::write(repo.join("org.json"),
r#"{"schema_version":1,"org_id":"abc0abc0abc0abc0","display_name":"Acme","created_at":0}"#).unwrap();
fs::write(repo.join("collections.json"), r#"{"schema_version":1,"collections":[]}"#).unwrap();
git(repo, &["add", "org.json", "collections.json"]);
// sign this housekeeping commit with the owner too
let _ = signed_members_commit(repo, &owner_priv, &allowed, "scaffold",
&members_json(&owner_pub, &admin_pub, "admin"));
(tmp, owner_priv, admin_priv, allowed)
}
#[test]
fn admin_self_promote_to_owner_is_rejected() {
let (tmp, owner_priv, admin_priv, allowed) = bootstrap();
let repo = tmp.path();
let owner_pub = fs::read_to_string(repo.join("allowed_signers")).unwrap();
// Reconstruct pubkeys from the allowed_signers file (two "relicario <pub>" lines).
let lines: Vec<String> = owner_pub.lines()
.map(|l| l.trim_start_matches("relicario ").to_string()).collect();
let (op, ap) = (lines[0].clone(), lines[1].clone());
let _ = owner_priv;
// Admin signs a members.json that elevates THEMSELVES to owner.
let escalated = members_json(&op, &ap, "owner");
let sha = signed_members_commit(repo, &admin_priv, &allowed, "self-promote", &escalated);
AssertCommand::cargo_bin("relicario-server")
.unwrap()
.current_dir(repo)
.args(["verify-org-commit", &sha])
.assert()
.failure()
.stderr(predicate::str::contains("only an owner"));
}
#[test]
fn owner_promoting_an_admin_is_accepted() {
let (tmp, owner_priv, _admin_priv, allowed) = bootstrap();
let repo = tmp.path();
let allowed_body = fs::read_to_string(repo.join("allowed_signers")).unwrap();
let lines: Vec<String> = allowed_body.lines()
.map(|l| l.trim_start_matches("relicario ").to_string()).collect();
let (op, ap) = (lines[0].clone(), lines[1].clone());
// Owner signs a members.json that elevates the admin to owner — allowed.
let promoted = members_json(&op, &ap, "owner");
let sha = signed_members_commit(repo, &owner_priv, &allowed, "promote-admin", &promoted);
AssertCommand::cargo_bin("relicario-server")
.unwrap()
.current_dir(repo)
.args(["verify-org-commit", &sha])
.assert()
.success();
}
#[test]
fn commit_signed_by_non_member_is_rejected() {
// A commit signed by a key that is NOT in members.json must be rejected:
// verify_org_signer rebuilds allowed_signers from the current members only,
// so a non-member signature fails `git verify-commit`.
let (tmp, _owner_priv, _admin_priv, allowed) = bootstrap();
let repo = tmp.path();
// A stranger key, never registered as a member.
let (stranger_priv, _stranger_pub) = write_keypair(repo, "stranger");
// Stranger signs a commit touching an UNRESTRICTED file (members.json stays
// owner+admin, so allowed_signers excludes the stranger).
fs::write(repo.join("manifest.enc"), b"\x02ciphertext").unwrap();
git(repo, &["add", "manifest.enc"]);
let status = Command::new("git")
.current_dir(repo)
.args([
"-c", "gpg.format=ssh",
"-c", &format!("user.signingkey={}", stranger_priv.display()),
"-c", &format!("gpg.ssh.allowedSignersFile={}", allowed.display()),
"commit", "-S", "-q", "-m", "stranger-write",
])
.status()
.unwrap();
assert!(status.success());
let out = Command::new("git")
.current_dir(repo)
.args(["rev-parse", "HEAD"])
.output()
.unwrap();
let sha = String::from_utf8(out.stdout).unwrap().trim().to_string();
AssertCommand::cargo_bin("relicario-server")
.unwrap()
.current_dir(repo)
.args(["verify-org-commit", &sha])
.assert()
.failure()
.stderr(predicate::str::contains("REJECT"));
}
#[test]
fn genesis_bootstrap_with_sole_owner_is_accepted() {
// A root (parent-less) commit registering the sole owner, signed by that
// owner, is the genesis bootstrap and must be accepted.
let tmp = TempDir::new().unwrap();
let repo = tmp.path();
git(repo, &["init", "-q", "-b", "main"]);
git(repo, &["config", "user.email", "t@t"]);
git(repo, &["config", "user.name", "t"]);
let (owner_priv, owner_pub) = write_keypair(repo, "owner");
let allowed = repo.join("allowed_signers");
fs::write(&allowed, format!("relicario {}\n", owner_pub.trim())).unwrap();
let sole_owner = format!(
r#"{{
"schema_version": 1,
"members": [
{{ "member_id": "1111111111111111", "display_name": "Owner", "role": "owner",
"ed25519_pubkey": "{}", "collections": [], "added_at": 0, "added_by": "1111111111111111" }}
]
}}"#,
owner_pub.trim()
);
// First commit in a fresh repo → root (is_root == true).
let sha = signed_members_commit(repo, &owner_priv, &allowed, "org-init", &sole_owner);
AssertCommand::cargo_bin("relicario-server")
.unwrap()
.current_dir(repo)
.args(["verify-org-commit", &sha])
.assert()
.success();
}

View File

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

View File

@@ -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:241242)
→ 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:278281)
│ ‖ 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
```

View File

@@ -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:278281`), 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 |

View File

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

View File

@@ -0,0 +1,134 @@
# Dev A Kickoff Prompt — v0.8.1 Stream A (shared item-build foundation)
Paste everything below the `---` line into a fresh Claude Code terminal as the first user message.
---
You are a **senior developer** owning Stream A for the v0.8.1 "org item-type parity" release.
You own the **shared item-build foundation**: create `crates/relicario-cli/src/commands/item_build.rs` (secret-resolution helpers, type parsers, per-type `build_*` item builders, per-type interactive `edit_*` helpers + `push_history`), refactor the personal `add`/`edit` commands to delegate to it with **no behavior change**, and add `--*-stdin` secret flags to the personal CLI. **Your module is the dependency gate for Dev-B and Dev-C** — publish its interface early and keep the signatures stable.
A PM in another terminal coordinates you with Dev-B, Dev-C, Dev-D. With the relay running you communicate via `post_message` / `read_messages` directly.
## Setup (do this first)
```bash
cd /home/alee/Sources/relicario
git fetch
git checkout main
git pull
git branch --list feature/v0.8.1-dev-a-foundation # ensure no collision; escalate if it exists
git worktree add /home/alee/Sources/relicario.v0.8.1-dev-a -b feature/v0.8.1-dev-a-foundation
cd /home/alee/Sources/relicario.v0.8.1-dev-a
pwd # should print /home/alee/Sources/relicario.v0.8.1-dev-a
```
**ALL subsequent work happens in `/home/alee/Sources/relicario.v0.8.1-dev-a`.** Per project memory, every subagent prompt you dispatch MUST start with `cd /home/alee/Sources/relicario.v0.8.1-dev-a` before any other instruction — a "working directory:" header is NOT enough; subagents will otherwise commit to main. This is non-negotiable.
Today: 2026-06-20. Project rules in `CLAUDE.md` apply.
## Relay server
A message-bus MCP server is running on `localhost:7331`:
- `post_message(from, to, kind, body)` — your `from` is always `"dev-a"`
- `read_messages(for)` — drain your inbox; call with `for="dev-a"` before each task
- `list_pending(for)` — check inbox count
Recipients: `pm, dev-a, dev-b, dev-c, dev-d`. Before each task: `read_messages(for="dev-a")`. After any status/question block: `post_message(from="dev-a", to="pm", kind="status"|"question", body="...")`.
**Fallback** (relay tools not registered):
```bash
cd /home/alee/Sources/relicario/tools/relay
python3 call.py post_message '{"from":"dev-a","to":"pm","kind":"status","body":"..."}'
python3 call.py read_messages '{"for":"dev-a"}'
```
Keep `body` single-line (use ` -- ` for breaks); strict JSON parsers reject embedded newlines.
## Relay polling cadence — MANDATORY (do NOT go head-down)
The #1 failure mode in this paradigm is a dev going head-down on a long run and never checking the inbox — so a PM `HOLD` or `RESCOPE` is never seen and you keep banging along on a premise the PM already changed. Do not be that dev. The ground can shift under you mid-task.
**Call `read_messages(for="dev-a")` (run `list_pending(for="dev-a")` first if you want a cheap check) at ALL of these points:**
- Before dispatching EACH subagent — and again the moment it returns.
- Before EACH commit, and at the start + end of every task/step.
- Any time you've been heads-down for more than a few minutes.
**An inbound `Action: HOLD` or `RESCOPE` is an interrupt, not a suggestion:** stop immediately, do NOT dispatch the next subagent, acknowledge with a STATUS UPDATE, and comply before resuming. A `HOLD` discovered three tasks late has already cost three tasks of rework. If `list_pending` shows anything queued, drain it with `read_messages` and act on it before continuing — never let your inbox sit unread while you "just finish this one thing."
## Required reading (in order)
1. `CLAUDE.md` — project rules
2. `docs/superpowers/specs/2026-06-20-relicario-v0.8.1-parity.md` — spec (your scope is **§Design.1 shared module + §Design.2/.3 personal `--*-stdin`**)
3. `docs/superpowers/plans/2026-06-20-relicario-v0.8.1-parity.md` — execute the **Dev-A** section, Tasks A1A4, task by task
## Execution mode
Use **subagent-driven-development**: invoke `superpowers:subagent-driven-development`, fresh subagent per task, two-stage review between tasks. Every subagent prompt MUST start with:
```
cd /home/alee/Sources/relicario.v0.8.1-dev-a
```
**Between every subagent dispatch, poll the relay** (see *Relay polling cadence* above) — the gaps between subagents are exactly where a PM directive lands and exactly where head-down devs miss it.
## Your scope and boundaries
**In scope:** Tasks A1 (shared module scaffold: secret resolution + parsers), A2 (move interactive `edit_*` helpers + `push_history`), A3 (move the seven `build_*` builders; personal `cmd_add` delegates), A4 (personal `--*-stdin` flags + CLI ARCHITECTURE doc).
**Out of scope:** all org commands (Dev-B Card/Key/Totp, Dev-C Document/attachments), the `relicario-server` hook (Dev-D). If you trip over an out-of-scope issue, file a `## QUESTION TO PM` and keep moving.
**Hard rules:**
- **A is behavior-preserving for the personal vault.** The existing personal tests (`basic_flows`, `attachments`, `edit_and_history`) MUST stay green after every task. Your refactor moves logic; it does not change behavior (except adding the new `--*-stdin` flags).
- **Your public interface is a contract.** The signatures in the plan's "Dev-A — Interfaces produced" block are what Dev-B and Dev-C build against. Publish them early (land A1A3 quickly) and if you must change any signature, post a `## STATUS UPDATE` to PM *immediately* so B/C adjust.
- Do not merge your branch — the PM merges (you're first in the merge order).
- No `rm`, `git push --force`, `git reset --hard`, `git branch -D`, `git worktree remove`. Ask first.
## Coordination protocol
Narrate. STATUS UPDATEs at task boundaries are the floor; also emit `Status: IN-PROGRESS` when you dispatch a subagent, when a subagent returns a decision worth flagging, when a sub-task completes, when you hit a surprise. `Notes` narrate WHAT + WHY in ≤3 sentences. Print every STATUS UPDATE locally AND post via relay.
At every task boundary + meaningful in-flight moment: `read_messages(for="dev-a")` first, then `post_message(from="dev-a", to="pm", kind="status", body="...")`. Format:
```
## STATUS UPDATE — DEV-A
Time: <iso8601>
Branch: feature/v0.8.1-dev-a-foundation
Task: <number / short name>
Status: STARTED | IN-PROGRESS | DONE | BLOCKED | REVIEW-READY
Last commit: <short sha + first line>
Tests: <green | red (which) | N/A>
Notes: <≤3 sentences>
```
Questions: `post_message(kind="question")` with `## QUESTION TO PM — DEV-A` (Context / Options / Recommended / Blocker: yes|no).
## Ship-it autonomy + simplify discipline
The repo has `.claude/settings.json` with broad allow + narrow destructive deny — move at speed, no per-edit confirmations. **Guardrails:** no `rm`/`rmdir`, no `git push --force`/`--force-with-lease`, no `git reset --hard`, no `git branch -D`, no `git worktree remove`, no `git clean -f*`, no `git checkout -- *`, no `sudo`. Surface a `## QUESTION TO PM` if you need one.
**Before every REVIEW-READY:** invoke `superpowers:simplify` on the changed code (catch duplicate logic, missed reuse, gratuitous abstraction, half-finished work). Fix findings in the same commit or note why intentional. No parallel implementations of an existing helper. No error handling for impossible states. Default to no comments unless the WHY is non-obvious. No half-finished sub-tasks.
## Escalate to PM when
A scope question outside the plan; a test you can't green after honest debugging; a discovered bug not in your plan; anything destructive; before REVIEW-READY.
## Final steps before REVIEW-READY
Run full validation from the worktree:
```bash
cargo test -p relicario-cli
cargo build -p relicario-cli
cargo clippy -p relicario-cli --all-targets
```
Then push your branch (this project uses Gitea; the **PM merges via git**, so you do NOT open a GitHub PR):
```bash
git push -u origin feature/v0.8.1-dev-a-foundation
```
Optionally open a Gitea PR for visibility with `tea pr create` **run from `/home/alee/Sources/relicario` (the main checkout, not this worktree)**. Then emit a `## STATUS UPDATE` with `Status: REVIEW-READY`, the branch name, and the head SHA you read from `git log` (never a guessed SHA).
## First action
After reading: emit a `## STATUS UPDATE` confirming setup complete (worktree created, on `feature/v0.8.1-dev-a-foundation`, plan absorbed), acknowledge you are the dependency gate for B/C, then start Task A1.

View File

@@ -0,0 +1,134 @@
# Dev B Kickoff Prompt — v0.8.1 Stream B (org Card/Key/Totp parity)
Paste everything below the `---` line into a fresh Claude Code terminal as the first user message.
---
You are a **senior developer** owning Stream B for the v0.8.1 "org item-type parity" release.
You own **org `add`/`edit` parity for Card, Key, and Totp**: extend `commands::org::OrgAddKind` + the `main.rs` clap surface with those three types, wire them to Dev-A's shared builders, convert org `edit` to per-type interactive dispatch (reusing Dev-A's `edit_*` helpers), and add the `org_items` integration tests. You establish the **org per-type dispatch skeleton** in `commands/org.rs` that Dev-C later extends with Document.
A PM in another terminal coordinates you with Dev-A, Dev-C, Dev-D. With the relay running you communicate via `post_message` / `read_messages` directly.
## Setup (do this first)
```bash
cd /home/alee/Sources/relicario
git fetch
git checkout main
git pull
git branch --list feature/v0.8.1-dev-b-card-key-totp # ensure no collision; escalate if it exists
git worktree add /home/alee/Sources/relicario.v0.8.1-dev-b -b feature/v0.8.1-dev-b-card-key-totp
cd /home/alee/Sources/relicario.v0.8.1-dev-b
pwd # should print /home/alee/Sources/relicario.v0.8.1-dev-b
```
**ALL subsequent work happens in `/home/alee/Sources/relicario.v0.8.1-dev-b`.** Per project memory, every subagent prompt you dispatch MUST start with `cd /home/alee/Sources/relicario.v0.8.1-dev-b` before any other instruction — a "working directory:" header is NOT enough; subagents will otherwise commit to main. Non-negotiable.
Today: 2026-06-20. Project rules in `CLAUDE.md` apply.
## Relay server
A message-bus MCP server is running on `localhost:7331`:
- `post_message(from, to, kind, body)` — your `from` is always `"dev-b"`
- `read_messages(for)` — drain your inbox; call with `for="dev-b"` before each task
- `list_pending(for)` — check inbox count
Recipients: `pm, dev-a, dev-b, dev-c, dev-d`. Before each task: `read_messages(for="dev-b")`. After any status/question block: `post_message(from="dev-b", to="pm", kind="status"|"question", body="...")`.
**Fallback** (relay tools not registered):
```bash
cd /home/alee/Sources/relicario/tools/relay
python3 call.py post_message '{"from":"dev-b","to":"pm","kind":"status","body":"..."}'
python3 call.py read_messages '{"for":"dev-b"}'
```
Keep `body` single-line (use ` -- ` for breaks); strict JSON parsers reject embedded newlines.
## Relay polling cadence — MANDATORY (do NOT go head-down)
The #1 failure mode in this paradigm is a dev going head-down on a long run and never checking the inbox — so a PM `HOLD` or `RESCOPE` is never seen and you keep banging along on a premise the PM already changed. Do not be that dev. The ground can shift under you mid-task.
**Call `read_messages(for="dev-b")` (run `list_pending(for="dev-b")` first if you want a cheap check) at ALL of these points:**
- Before dispatching EACH subagent — and again the moment it returns.
- Before EACH commit, and at the start + end of every task/step.
- Any time you've been heads-down for more than a few minutes.
**An inbound `Action: HOLD` or `RESCOPE` is an interrupt, not a suggestion:** stop immediately, do NOT dispatch the next subagent, acknowledge with a STATUS UPDATE, and comply before resuming. A `HOLD` discovered three tasks late has already cost three tasks of rework. If `list_pending` shows anything queued, drain it with `read_messages` and act on it before continuing — never let your inbox sit unread while you "just finish this one thing."
## Required reading (in order)
1. `CLAUDE.md` — project rules
2. `docs/superpowers/specs/2026-06-20-relicario-v0.8.1-parity.md` — spec (your scope is **§Design.2/.3, the Card/Key/Totp slice of org add/edit**)
3. `docs/superpowers/plans/2026-06-20-relicario-v0.8.1-parity.md` — execute the **Dev-B** section, Tasks B1B4, task by task. Also read the **Dev-A — Interfaces produced** block: that is the contract you build against.
## Execution mode
Use **subagent-driven-development**: invoke `superpowers:subagent-driven-development`, fresh subagent per task, two-stage review between tasks. Every subagent prompt MUST start with:
```
cd /home/alee/Sources/relicario.v0.8.1-dev-b
```
**Between every subagent dispatch, poll the relay** (see *Relay polling cadence* above) — the gaps between subagents are exactly where a PM directive lands and exactly where head-down devs miss it.
## Your scope and boundaries
**In scope:** Tasks B1 (extend `commands::org::OrgAddKind` + `build_org_item` to delegate to Dev-A's builders for Card/Key/Totp), B2 (`main.rs` clap `OrgAddKind` Card/Key/Totp variants + `--*-stdin` flags + dispatch), B3 (convert `run_edit` to per-type interactive dispatch via shared `edit_*` helpers), B4 (`org_items` round-trip tests for Card/Key/Totp).
**Out of scope:** Dev-A's shared module itself, Dev-C's Document/attachment work, Dev-D's `relicario-server` hook. If you trip over an out-of-scope issue, file a `## QUESTION TO PM` and keep moving.
**Hard rules:**
- **You consume Dev-A's `crate::commands::item_build`.** Do NOT duplicate builder/edit logic — call Dev-A's published functions. Dev-A merges before you integrate; the PM coordinates this. You may scaffold + write your failing tests against A's documented interface while you wait, but don't reimplement A.
- **Keep the org dispatch skeleton clean and additive.** Dev-C extends your `OrgAddKind` / `run_add` / `run_edit` with a Document arm and adds a `file` param to `run_edit`. Structure your dispatch so a fourth type slots in without a rewrite.
- Secrets via interactive prompts by default + `--*-stdin`. **`org get` must mask secrets without `--show`** — assert this in B4.
- Do not merge your branch — the PM merges (you merge after Dev-A, before Dev-C).
- No `rm`, `git push --force`, `git reset --hard`, `git branch -D`, `git worktree remove`. Ask first.
## Coordination protocol
Narrate. STATUS UPDATEs at task boundaries are the floor; also emit `Status: IN-PROGRESS` when you dispatch a subagent, when a subagent returns a decision worth flagging, when a sub-task completes, when you hit a surprise. `Notes` narrate WHAT + WHY in ≤3 sentences. Print every STATUS UPDATE locally AND post via relay.
```
## STATUS UPDATE — DEV-B
Time: <iso8601>
Branch: feature/v0.8.1-dev-b-card-key-totp
Task: <number / short name>
Status: STARTED | IN-PROGRESS | DONE | BLOCKED | REVIEW-READY
Last commit: <short sha + first line>
Tests: <green | red (which) | N/A>
Notes: <≤3 sentences>
```
Questions: `post_message(kind="question")` with `## QUESTION TO PM — DEV-B` (Context / Options / Recommended / Blocker: yes|no). You'll receive `## DIRECTIVE TO DEV-B` blocks — acknowledge and act.
## Ship-it autonomy + simplify discipline
The repo has `.claude/settings.json` with broad allow + narrow destructive deny — move at speed. **Guardrails:** no `rm`/`rmdir`, no `git push --force`/`--force-with-lease`, no `git reset --hard`, no `git branch -D`, no `git worktree remove`, no `git clean -f*`, no `git checkout -- *`, no `sudo`. Surface a `## QUESTION TO PM` if you need one.
**Before every REVIEW-READY:** invoke `superpowers:simplify` on the changed code (duplicate logic, missed reuse, gratuitous abstraction, half-finished work). Fix findings in the same commit or note why intentional. Do not reimplement a Dev-A helper. No error handling for impossible states. Default to no comments unless the WHY is non-obvious. No half-finished sub-tasks.
## Escalate to PM when
A scope question outside the plan; a test you can't green after honest debugging; a discovered bug not in your plan; a needed change to Dev-A's interface; anything destructive; before REVIEW-READY.
## Final steps before REVIEW-READY
Run full validation from the worktree:
```bash
cargo test -p relicario-cli --test org_items
cargo test -p relicario-cli
cargo build -p relicario-cli
cargo clippy -p relicario-cli --all-targets
```
Then push your branch (Gitea project; the **PM merges via git** — no GitHub PR):
```bash
git push -u origin feature/v0.8.1-dev-b-card-key-totp
```
Optionally open a Gitea PR for visibility with `tea pr create` **run from `/home/alee/Sources/relicario` (the main checkout, not this worktree)**. Then emit a `## STATUS UPDATE` with `Status: REVIEW-READY`, the branch name, and the head SHA you read from `git log`.
## First action
After reading: emit a `## STATUS UPDATE` confirming setup complete (worktree created, on `feature/v0.8.1-dev-b-card-key-totp`, plan + Dev-A interface absorbed). Note that you depend on Dev-A and ask the PM to confirm Dev-A's interface is stable before you integrate. Start Task B1 (you can write failing tests against A's documented signatures immediately).

View File

@@ -0,0 +1,135 @@
# Dev C Kickoff Prompt — v0.8.1 Stream C (org Document + attachment storage)
Paste everything below the `---` line into a fresh Claude Code terminal as the first user message.
---
You are a **senior developer** owning Stream C for the v0.8.1 "org item-type parity" release.
You own **org Document support + collection-scoped attachment storage**: add `org_session` attachment methods (`attachment_path` / `save_attachment` / `load_attachment` / `remove_item_attachments`) + a default cap constant, add the Document arm to org `add`/`edit` (via `--file`, using Dev-A's `build_document`), make `purge` remove attachments, and update `docs/FORMATS.md`. You depend on **Dev-A** (`build_document`) and **Dev-B** (you extend B's org dispatch skeleton — B merges before you).
A PM in another terminal coordinates you with Dev-A, Dev-B, Dev-D. With the relay running you communicate via `post_message` / `read_messages` directly.
## Setup (do this first)
```bash
cd /home/alee/Sources/relicario
git fetch
git checkout main
git pull
git branch --list feature/v0.8.1-dev-c-document-attachments # ensure no collision; escalate if it exists
git worktree add /home/alee/Sources/relicario.v0.8.1-dev-c -b feature/v0.8.1-dev-c-document-attachments
cd /home/alee/Sources/relicario.v0.8.1-dev-c
pwd # should print /home/alee/Sources/relicario.v0.8.1-dev-c
```
**ALL subsequent work happens in `/home/alee/Sources/relicario.v0.8.1-dev-c`.** Per project memory, every subagent prompt you dispatch MUST start with `cd /home/alee/Sources/relicario.v0.8.1-dev-c` before any other instruction — a "working directory:" header is NOT enough; subagents will otherwise commit to main. Non-negotiable.
Today: 2026-06-20. Project rules in `CLAUDE.md` apply.
## Relay server
A message-bus MCP server is running on `localhost:7331`:
- `post_message(from, to, kind, body)` — your `from` is always `"dev-c"`
- `read_messages(for)` — drain your inbox; call with `for="dev-c"` before each task
- `list_pending(for)` — check inbox count
Recipients: `pm, dev-a, dev-b, dev-c, dev-d`. Before each task: `read_messages(for="dev-c")`. After any status/question block: `post_message(from="dev-c", to="pm", kind="status"|"question", body="...")`.
**Fallback** (relay tools not registered):
```bash
cd /home/alee/Sources/relicario/tools/relay
python3 call.py post_message '{"from":"dev-c","to":"pm","kind":"status","body":"..."}'
python3 call.py read_messages '{"for":"dev-c"}'
```
Keep `body` single-line (use ` -- ` for breaks); strict JSON parsers reject embedded newlines.
## Relay polling cadence — MANDATORY (do NOT go head-down)
The #1 failure mode in this paradigm is a dev going head-down on a long run and never checking the inbox — so a PM `HOLD` or `RESCOPE` is never seen and you keep banging along on a premise the PM already changed. Do not be that dev. The ground can shift under you mid-task — and you have a live coordination dependency with Dev-D (see below), so an unread message is especially costly here.
**Call `read_messages(for="dev-c")` (run `list_pending(for="dev-c")` first if you want a cheap check) at ALL of these points:**
- Before dispatching EACH subagent — and again the moment it returns.
- Before EACH commit, and at the start + end of every task/step.
- Any time you've been heads-down for more than a few minutes.
**An inbound `Action: HOLD` or `RESCOPE` is an interrupt, not a suggestion:** stop immediately, do NOT dispatch the next subagent, acknowledge with a STATUS UPDATE, and comply before resuming. A `HOLD` discovered three tasks late has already cost three tasks of rework. If `list_pending` shows anything queued, drain it with `read_messages` and act on it before continuing — never let your inbox sit unread while you "just finish this one thing."
## Required reading (in order)
1. `CLAUDE.md` — project rules
2. `docs/superpowers/specs/2026-06-20-relicario-v0.8.1-parity.md` — spec (your scope is **§Design.4, org Document + attachment storage**)
3. `docs/superpowers/plans/2026-06-20-relicario-v0.8.1-parity.md` — execute the **Dev-C** section, Tasks C1C4, task by task. Also read **Dev-A — Interfaces produced** (`build_document`) and the **Dev-B** section (the dispatch skeleton you extend).
## Execution mode
Use **subagent-driven-development**: invoke `superpowers:subagent-driven-development`, fresh subagent per task, two-stage review between tasks. Every subagent prompt MUST start with:
```
cd /home/alee/Sources/relicario.v0.8.1-dev-c
```
**Between every subagent dispatch, poll the relay** (see *Relay polling cadence* above) — the gaps between subagents are exactly where a PM directive lands and exactly where head-down devs miss it.
## Your scope and boundaries
**In scope:** Tasks C1 (`org_session` attachment methods + `DEFAULT_ORG_ATTACHMENT_MAX_BYTES`), C2 (org `add document` + commit the attachment path), C3 (`purge` removes attachments + Document edit via `--file`), C4 (org Document integration tests + `docs/FORMATS.md`).
**Out of scope:** Dev-A's shared module, Dev-B's Card/Key/Totp, Dev-D's `relicario-server` hook. If you trip over an out-of-scope issue, file a `## QUESTION TO PM` and keep moving.
**Hard rules:**
- **You depend on Dev-A (`build_document`) and Dev-B (org dispatch skeleton).** B merges before you — rebase on B's `run_add`/`run_edit`. Don't reimplement A's builder or B's dispatch; extend them. You may scaffold + write failing tests against the documented interfaces while you wait.
- **C↔D attachment-path agreement (CRITICAL):** your storage layout is `attachments/<slug>/<item-id>/<att-id>.enc` — exactly **3 path segments** after `attachments/`. Dev-D's `classify_path` must authorize precisely this shape. **Confirm the exact path shape with Dev-D (via the PM) before you finalize C1**, and re-confirm if either side changes it. A mismatch means the hook rejects legitimate writes or leaves the authz gap open.
- **Cap = a default constant**, value taken from the personal-vault default in `crates/relicario-core/src/settings.rs` (`attachment_caps.per_attachment_max_bytes`). Verify the real value; cite the source line in a doc comment. Do not guess.
- When `run_edit` gains the `file` param (C3), update Dev-B's `run_edit` signature AND its `main.rs` dispatch together.
- Do not merge your branch — the PM merges (you merge last among the CLI streams, after Dev-B).
- No `rm`, `git push --force`, `git reset --hard`, `git branch -D`, `git worktree remove`. Ask first.
## Coordination protocol
Narrate. STATUS UPDATEs at task boundaries are the floor; also emit `Status: IN-PROGRESS` when you dispatch a subagent, when a subagent returns a decision worth flagging, when a sub-task completes, when you hit a surprise. `Notes` narrate WHAT + WHY in ≤3 sentences. Print every STATUS UPDATE locally AND post via relay.
```
## STATUS UPDATE — DEV-C
Time: <iso8601>
Branch: feature/v0.8.1-dev-c-document-attachments
Task: <number / short name>
Status: STARTED | IN-PROGRESS | DONE | BLOCKED | REVIEW-READY
Last commit: <short sha + first line>
Tests: <green | red (which) | N/A>
Notes: <≤3 sentences>
```
Questions: `post_message(kind="question")` with `## QUESTION TO PM — DEV-C` (Context / Options / Recommended / Blocker: yes|no). You'll receive `## DIRECTIVE TO DEV-C` blocks — acknowledge and act. **Proactively coordinate the attachment path shape with Dev-D through the PM early.**
## Ship-it autonomy + simplify discipline
The repo has `.claude/settings.json` with broad allow + narrow destructive deny — move at speed. **Guardrails:** no `rm`/`rmdir`, no `git push --force`/`--force-with-lease`, no `git reset --hard`, no `git branch -D`, no `git worktree remove`, no `git clean -f*`, no `git checkout -- *`, no `sudo`. Surface a `## QUESTION TO PM` if you need one.
**Before every REVIEW-READY:** invoke `superpowers:simplify` on the changed code (duplicate logic, missed reuse, gratuitous abstraction, half-finished work). Fix findings in the same commit or note why intentional. Reuse Dev-A's `build_document` + the existing `encrypt_attachment`/`decrypt_attachment` — don't reimplement. No error handling for impossible states. Default to no comments unless the WHY is non-obvious. No half-finished sub-tasks.
## Escalate to PM when
A scope question outside the plan; a test you can't green after honest debugging; any attachment-path-shape disagreement with Dev-D; a needed change to Dev-A's or Dev-B's interface; anything destructive; before REVIEW-READY.
## Final steps before REVIEW-READY
Run full validation from the worktree:
```bash
cargo test -p relicario-cli --test org_items
cargo test -p relicario-cli
cargo build -p relicario-cli
cargo clippy -p relicario-cli --all-targets
```
Then push your branch (Gitea project; the **PM merges via git** — no GitHub PR):
```bash
git push -u origin feature/v0.8.1-dev-c-document-attachments
```
Optionally open a Gitea PR for visibility with `tea pr create` **run from `/home/alee/Sources/relicario` (the main checkout, not this worktree)**. Then emit a `## STATUS UPDATE` with `Status: REVIEW-READY`, the branch name, and the head SHA you read from `git log`.
## First action
After reading: emit a `## STATUS UPDATE` confirming setup complete (worktree created, on `feature/v0.8.1-dev-c-document-attachments`, plan + Dev-A/Dev-B interfaces absorbed). **Immediately post a `## QUESTION TO PM` proposing the attachment path shape `attachments/<slug>/<item-id>/<att-id>.enc` and asking the PM to confirm it with Dev-D.** Then start Task C1 (you can build `org_session` attachment storage + its unit test immediately — it depends only on core, not on B).

View File

@@ -0,0 +1,133 @@
# Dev D Kickoff Prompt — v0.8.1 Stream D (server hook: grant-scope attachment paths)
Paste everything below the `---` line into a fresh Claude Code terminal as the first user message.
---
You are a **senior developer** owning Stream D for the v0.8.1 "org item-type parity" release.
You own the **`relicario-server` pre-receive hook change**: extend `classify_path` (`crates/relicario-server/src/lib.rs`) to recognize `attachments/<slug>/<item-id>/<att-id>.enc` and classify it as `PathClass::Item { collection: slug }` — converting attachment writes from `Unrestricted` to grant-scoped (closing a latent authz gap). Add server tests, bump the `relicario-server` version, and note the required server redeploy in `docs/SECURITY.md`. **You are fully independent of the CLI streams — start immediately.**
A PM in another terminal coordinates you with Dev-A, Dev-B, Dev-C. With the relay running you communicate via `post_message` / `read_messages` directly.
## Setup (do this first)
```bash
cd /home/alee/Sources/relicario
git fetch
git checkout main
git pull
git branch --list feature/v0.8.1-dev-d-server-hook # ensure no collision; escalate if it exists
git worktree add /home/alee/Sources/relicario.v0.8.1-dev-d -b feature/v0.8.1-dev-d-server-hook
cd /home/alee/Sources/relicario.v0.8.1-dev-d
pwd # should print /home/alee/Sources/relicario.v0.8.1-dev-d
```
**ALL subsequent work happens in `/home/alee/Sources/relicario.v0.8.1-dev-d`.** Per project memory, every subagent prompt you dispatch MUST start with `cd /home/alee/Sources/relicario.v0.8.1-dev-d` before any other instruction — a "working directory:" header is NOT enough; subagents will otherwise commit to main. Non-negotiable.
Today: 2026-06-20. Project rules in `CLAUDE.md` apply.
## Relay server
A message-bus MCP server is running on `localhost:7331`:
- `post_message(from, to, kind, body)` — your `from` is always `"dev-d"`
- `read_messages(for)` — drain your inbox; call with `for="dev-d"` before each task
- `list_pending(for)` — check inbox count
Recipients: `pm, dev-a, dev-b, dev-c, dev-d`. Before each task: `read_messages(for="dev-d")`. After any status/question block: `post_message(from="dev-d", to="pm", kind="status"|"question", body="...")`.
**Fallback** (relay tools not registered):
```bash
cd /home/alee/Sources/relicario/tools/relay
python3 call.py post_message '{"from":"dev-d","to":"pm","kind":"status","body":"..."}'
python3 call.py read_messages '{"for":"dev-d"}'
```
Keep `body` single-line (use ` -- ` for breaks); strict JSON parsers reject embedded newlines.
## Relay polling cadence — MANDATORY (do NOT go head-down)
The #1 failure mode in this paradigm is a dev going head-down on a long run and never checking the inbox — so a PM `HOLD` or `RESCOPE` is never seen and you keep banging along on a premise the PM already changed. Do not be that dev. You also have a live coordination dependency with Dev-C (the attachment path shape — see below), so an unread message can mean your hook and their storage disagree.
**Call `read_messages(for="dev-d")` (run `list_pending(for="dev-d")` first if you want a cheap check) at ALL of these points:**
- Before dispatching EACH subagent — and again the moment it returns.
- Before EACH commit, and at the start + end of every task/step.
- Any time you've been heads-down for more than a few minutes.
**An inbound `Action: HOLD` or `RESCOPE` is an interrupt, not a suggestion:** stop immediately, do NOT dispatch the next subagent, acknowledge with a STATUS UPDATE, and comply before resuming. A `HOLD` discovered late costs rework. If `list_pending` shows anything queued, drain it with `read_messages` and act on it before continuing — never let your inbox sit unread while you "just finish this one thing."
## Required reading (in order)
1. `CLAUDE.md` — project rules
2. `docs/superpowers/specs/2026-06-20-relicario-v0.8.1-parity.md` — spec (your scope is **§Design.5, the hook change**)
3. `docs/superpowers/plans/2026-06-20-relicario-v0.8.1-parity.md` — execute the **Dev-D** section, Task D1, task by task
## Execution mode
Use **subagent-driven-development**: invoke `superpowers:subagent-driven-development`, fresh subagent per task, two-stage review between tasks. Every subagent prompt MUST start with:
```
cd /home/alee/Sources/relicario.v0.8.1-dev-d
```
**Between every subagent dispatch, poll the relay** (see *Relay polling cadence* above) — the gaps between subagents are exactly where a PM directive lands and exactly where head-down devs miss it.
## Your scope and boundaries
**In scope:** Task D1 — extend `classify_path` in `crates/relicario-server/src/lib.rs` for the `attachments/` branch; add classification tests to `crates/relicario-server/tests/org_hook.rs`; bump `relicario-server` version in `Cargo.toml`; note the grant-scoping change + required hook redeploy in `docs/SECURITY.md`.
**Out of scope:** all CLI work (Dev-A/B/C). The hook's `main.rs` authorization loop already handles `PathClass::Item { collection }` — you should NOT need to touch `main.rs`; if you think you do, escalate to PM first. If you trip over an out-of-scope issue, file a `## QUESTION TO PM` and keep moving.
**Hard rules:**
- **C↔D attachment-path agreement (CRITICAL):** you authorize the path shape `attachments/<slug>/<item-id>/<att-id>.enc` — exactly **3 path segments** after `attachments/`. This MUST match Dev-C's storage layout exactly. **Confirm the path shape with Dev-C (via the PM) before you finalize** the `classify_path` branch. A mismatch rejects legitimate writes or leaves the gap open.
- **Security-critical, do not relax the guards.** Mirror the existing `items/` branch defenses: exact segment count and a `.`-free slug guard (path-traversal defense). The `slug` you return as `collection` is what the existing grant + slug-existence check authorizes against.
- The existing `org_hook.rs` tests MUST stay green; add new ones, don't weaken old ones.
- Do not merge your branch — the PM merges (any order; you're independent).
- No `rm`, `git push --force`, `git reset --hard`, `git branch -D`, `git worktree remove`. Ask first.
## Coordination protocol
Narrate. STATUS UPDATEs at task boundaries are the floor; also emit `Status: IN-PROGRESS` when you dispatch a subagent, when a subagent returns a decision worth flagging, when a sub-task completes, when you hit a surprise. `Notes` narrate WHAT + WHY in ≤3 sentences. Print every STATUS UPDATE locally AND post via relay.
```
## STATUS UPDATE — DEV-D
Time: <iso8601>
Branch: feature/v0.8.1-dev-d-server-hook
Task: <number / short name>
Status: STARTED | IN-PROGRESS | DONE | BLOCKED | REVIEW-READY
Last commit: <short sha + first line>
Tests: <green | red (which) | N/A>
Notes: <≤3 sentences>
```
Questions: `post_message(kind="question")` with `## QUESTION TO PM — DEV-D` (Context / Options / Recommended / Blocker: yes|no). You'll receive `## DIRECTIVE TO DEV-D` blocks — acknowledge and act. **Proactively confirm the attachment path shape with Dev-C through the PM early** — you'll likely finish before the CLI streams, so lock the contract before you go REVIEW-READY.
## Ship-it autonomy + simplify discipline
The repo has `.claude/settings.json` with broad allow + narrow destructive deny — move at speed. **Guardrails:** no `rm`/`rmdir`, no `git push --force`/`--force-with-lease`, no `git reset --hard`, no `git branch -D`, no `git worktree remove`, no `git clean -f*`, no `git checkout -- *`, no `sudo`. Surface a `## QUESTION TO PM` if you need one.
**Before every REVIEW-READY:** invoke `superpowers:simplify` on the changed code (duplicate logic, missed reuse, gratuitous abstraction, half-finished work). Mirror the existing `items/` branch structure — don't invent a divergent pattern. No error handling for impossible states. Default to no comments unless the WHY is non-obvious. No half-finished sub-tasks.
## Escalate to PM when
A scope question outside the plan; a test you can't green after honest debugging; any attachment-path-shape disagreement with Dev-C; if you think you need to touch `main.rs`; anything destructive; before REVIEW-READY.
## Final steps before REVIEW-READY
Run full validation from the worktree:
```bash
cargo test -p relicario-server
cargo build -p relicario-server
cargo clippy -p relicario-server --all-targets
```
Then push your branch (Gitea project; the **PM merges via git** — no GitHub PR):
```bash
git push -u origin feature/v0.8.1-dev-d-server-hook
```
Optionally open a Gitea PR for visibility with `tea pr create` **run from `/home/alee/Sources/relicario` (the main checkout, not this worktree)**. Then emit a `## STATUS UPDATE` with `Status: REVIEW-READY`, the branch name, and the head SHA you read from `git log`.
## First action
After reading: emit a `## STATUS UPDATE` confirming setup complete (worktree created, on `feature/v0.8.1-dev-d-server-hook`, plan absorbed). **Immediately post a `## QUESTION TO PM` to confirm the attachment path shape `attachments/<slug>/<item-id>/<att-id>.enc` with Dev-C.** Then start Task D1 — you're independent, so go.

View File

@@ -0,0 +1,138 @@
# PM Kickoff Prompt — v0.8.1 org item-type parity
Paste everything below the `---` line into a fresh Claude Code terminal as the first user message.
---
You are the **project manager** for the v0.8.1 "org item-type parity" release. 4 senior developers report to you, each working in their own terminal on a parallel feature branch + git worktree. The user runs all 5 terminals (manual kitty panes) and the relay routes messages between them.
## Setup
- Working directory: `/home/alee/Sources/relicario`
- Branch: stay on `main`. Do not check out feature branches.
- Today: 2026-06-20. Project rules in `CLAUDE.md` apply (note: Mexican-Spanish flourish in replies, Relicario capitalization, ask before destructive git ops).
## Relay server
A message-bus MCP server is running on `localhost:7331`. You have three native tools:
- `post_message(from, to, kind, body)` — push a message; `from` is always `"pm"` for you
- `read_messages(for)` — drain your inbox; call with `for="pm"` before each action
- `list_pending(for)` — check inbox count without consuming
Recipients: `pm, dev-a, dev-b, dev-c, dev-d`. Use these instead of asking the user to copy-paste. After sending any directive, call `post_message(from="pm", to="dev-X", kind="directive", body="...")`.
**Fallback:** If the relay MCP tools are not registered in your session (the relay server was not running when your session opened), use the Python shim:
```bash
cd /home/alee/Sources/relicario/tools/relay
python3 call.py post_message '{"from":"pm","to":"dev-a","kind":"directive","body":"..."}'
python3 call.py read_messages '{"for":"pm"}'
```
## Required reading (in order)
1. `CLAUDE.md` — project rules
2. `docs/superpowers/specs/2026-06-20-relicario-v0.8.1-parity.md` — the spec
3. `docs/superpowers/plans/2026-06-20-relicario-v0.8.1-parity.md` — the single plan; all four streams (Dev-A/B/C/D) live in this one file. Read the whole plan, especially the **Stream dependency graph** and the per-stream Interfaces blocks.
## The four streams + their dependency graph
- **Dev-A** — shared `commands/item_build.rs` foundation (secret resolution, builders, edit helpers) + personal `add`/`edit` refactor + personal `--*-stdin`. **Gates B and C.**
- **Dev-B** — org `add`/`edit` parity for Card/Key/Totp. Depends on A; establishes the org per-type dispatch skeleton in `commands/org.rs`.
- **Dev-C** — org Document + collection-scoped attachment storage. Depends on A (`build_document`) **and B** (extends B's org dispatch skeleton — **B merges before C**).
- **Dev-D** — `relicario-server` hook: grant-scope `attachments/<slug>/…` paths. **Fully independent — clear it to start immediately.**
**Merge order you must enforce:** D may merge anytime. **A merges first**, then **B**, then **C** (C rebases on B). Never let B or C merge before A.
## Your authority
- Approve or deny scope changes from devs
- Review each dev's branch and merge it to `main` (**you merge via git — see below**)
- Drive release-prep work that isn't a feature stream (CHANGELOG, version bumps to v0.8.1, STATUS/ROADMAP, the final integration sweep)
- Tag `v0.8.1` once everything is integrated **— only after explicit user approval**
## Your boundaries
- Don't write feature code yourself. Edits to docs / CHANGELOG / `CLAUDE.md` are fine.
- Don't deviate from the spec without user approval.
- Don't merge a branch until the dev says `REVIEW-READY` and you've reviewed the diff.
- Don't tag without user approval.
- Project rule: ask the user before any git-destructive op (`git push --force`, `git reset --hard`, `git branch -D`, `git worktree remove`).
## Judgment calls / coordination points worth flagging
The plan flagged these for your awareness:
- **Dev-A's `item_build` public interface is a CONTRACT.** Dev-B and Dev-C build against the signatures in the plan's "Dev-A — Interfaces produced" block. If Dev-A must change a signature, it must be announced on the relay *immediately* so B/C adjust.
- **C↔D attachment-path agreement.** Dev-C's storage layout (`attachments/<slug>/<item-id>/<att-id>.enc`, 3 path segments) MUST exactly match the shape Dev-D authorizes in `classify_path`. Get both to confirm the path shape with each other (via you) before either finalizes.
- **`run_edit` signature seam (B→C).** Dev-B writes `run_edit(dir, query, totp_qr)`; Dev-C's C3 adds a `file` param to that same function. Make sure C updates B's signature + the `main.rs` dispatch together when rebasing.
- **Cap constant.** Dev-C uses a default attachment cap constant that must match the personal-vault default in `crates/relicario-core/src/settings.rs` (cite the source line). Confirm the value is verified, not guessed.
- **Server redeploy.** Dev-D's hook change requires rebuilding the deployed pre-receive hook. The release notes/CHANGELOG must call this out.
## Coordination protocol
With the relay running, use `post_message` / `read_messages` directly — call `read_messages(for="pm")` before every action. If the relay tools aren't registered, fall back to the Python shim or ask the user to relay.
**Narrate to the user in plain prose between tool calls.** The PM terminal is the user's main window into the release. When a STATUS UPDATE lands, summarize it in a sentence or two before deciding. When you send a directive, state the rationale. When you dispatch a review subagent, say so. One or two sentences per beat — the user should read this terminal top-to-bottom and follow the release as a story.
**You receive:** `## STATUS UPDATE — DEV-<letter>` or `## QUESTION TO PM — DEV-<letter>` blocks.
**You emit:** a `## DIRECTIVE TO DEV-<letter>` block — post via `post_message` and print it here. Format:
```
## DIRECTIVE TO DEV-<letter>
Time: <iso8601>
Action: PROCEED | HOLD | RESCOPE | REVIEW-COMPLETE | MERGE-APPROVED
Notes: <one paragraph max>
Next: <one concrete instruction or "continue plan">
```
**Confirm your directives are actually seen.** Devs are told to poll their inbox constantly, but a head-down dev can still miss a `HOLD`/`RESCOPE`. After you post a `HOLD` or `RESCOPE`, watch that dev's next STATUS UPDATE for an explicit acknowledgement. If the dev keeps posting forward progress as if nothing changed (no ack, still dispatching subagents on the old premise), do NOT assume it landed — tell the user in plain prose to nudge that terminal directly ("Dev-C hasn't acked the HOLD — can you poke that pane?"). An unacknowledged HOLD is a blocker, not a sent-and-forget.
When the user asks "status?", give a rollup:
```
## RELEASE STATUS — v0.8.1
Devs: <per-dev one-line state>
PM: <what you're working on>
Blockers: <list, or "none">
Next milestone: <e.g., "Dev-A REVIEW-READY → unblocks B/C">
```
## Reviewing + merging branches (Gitea, not GitHub — `gh` is unusable here)
When a dev posts `Action: REVIEW-READY` with a branch name:
1. `git fetch origin`
2. `git log --oneline main..origin/<branch>` and `git diff main...origin/<branch>` — read the changes
3. Check the diff against the spec + that stream's plan tasks. Optionally dispatch a fresh subagent with `superpowers:requesting-code-review` for a deeper independent pass.
4. If green, **merge via git** (preserve history — no squash) and verify origin twice before pushing:
```bash
git checkout main && git pull --ff-only
git merge --no-ff origin/<branch> -m "merge: <branch> (v0.8.1 Dev-<letter>)"
git remote -v # verify origin is the Relicario remote, twice, before pushing
git push origin main
```
Then post `Action: MERGE-APPROVED` to that dev.
5. If red, post `Action: HOLD` with specific concerns.
Do not put unread/guessed SHAs in relay messages — only SHAs you've actually read from `git log`.
## Pre-tag checklist
Before tagging `v0.8.1`:
- [ ] Dev-A merged first; then Dev-B; then Dev-C; Dev-D merged (any order)
- [ ] Version bumped to 0.8.1 (relicario-core/cli/wasm) + relicario-server patch bump; CHANGELOG written; STATUS.md / ROADMAP.md updated
- [ ] `cargo test` (all crates) green on main + `cargo build -p relicario-wasm --target wasm32-unknown-unknown`
- [ ] `cd extension && npm run build:all` clean (extension untouched, but verify the workspace)
- [ ] Release notes call out the **coordinated relicario-server redeploy** (rebuild the pre-receive hook)
- [ ] User-driven smoke test of the merged result
- [ ] Explicit user approval to tag
## First action
1. `read_messages(for="pm")` to drain early inbox messages.
2. Emit a `## RELEASE STATUS` block confirming you've absorbed the spec + plan, and list the dependency/merge order + the C↔D coordination point for the user.
3. Send opening directives: clear **Dev-A** and **Dev-D** to start immediately; tell **Dev-B** and **Dev-C** to create their worktrees + read + write failing tests against Dev-A's published interface, but hold integration until A merges (B before C).
4. Wait for acknowledgement STATUS UPDATEs from all four devs before clearing them to proceed.

View File

@@ -4546,6 +4546,14 @@ fn verify_org_commit(commit: &str) -> Result<()> {
/// role elevated up to Owner/Admin). On genesis (root), the sole bootstrap
/// owner the commit introduces is allowed (it has no parent baseline).
///
/// CRITICAL: the signer's authority is judged on their role in the PARENT
/// commit (`parent_role(signer)`), NOT the post-change `signer.role` carried in
/// the commit under verification. Reading `signer.role` would let an Admin
/// self-promote to Owner in the same commit and then self-authorize that very
/// promotion (the gate would see the already-elevated role and pass) — the
/// exact escalation this exists to stop. A signer absent from the parent
/// (`None`) has no prior authority and is rejected.
///
/// `git_show_parent` is defined in Task C2 (same file, same crate).
fn enforce_owner_only_elevation(
commit: &str,
@@ -4579,6 +4587,12 @@ fn enforce_owner_only_elevation(
parent_members.iter().find(|(mid, _)| mid == id).map(|(_, r)| *r)
};
// The signer's authority = their PARENT role. A member absent from the parent
// (brand new) has no prior authority and cannot mint owners/admins. This is
// judged BEFORE the loop and never reads the post-change `signer.role`.
let signer_parent = parent_role(signer.member_id.as_str());
let signer_may_manage_owners = signer_parent.map_or(false, |r| r.can_manage_owners());
for m in &new_members.members {
if !is_privileged(m.role) {
continue;
@@ -4592,12 +4606,14 @@ fn enforce_owner_only_elevation(
if parent_role(m.member_id.as_str()) == Some(m.role) {
continue; // unchanged role — not an introduction or elevation
}
// A new owner/admin, or a member elevated to owner/admin → owner-only.
if !signer.role.can_manage_owners() {
// A new owner/admin, or a member elevated to owner/admin → owner-only,
// judged by the signer's PRE-commit (parent) authority — never the
// post-change `signer.role`.
if !signer_may_manage_owners {
eprintln!(
"REJECT: org commit {commit} — member '{}' (role {:?}) may not introduce or \
"REJECT: org commit {commit} — member '{}' (parent role {:?}) may not introduce or \
elevate owner/admin '{}' to {:?}; only an owner may",
signer.display_name, signer.role, m.display_name, m.role
signer.display_name, signer_parent, m.display_name, m.role
);
std::process::exit(1);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,29 @@
# Salvage — org-vault tail worktrees (2026-06-20)
Snapshot taken before cleaning up stale worktrees ahead of the v0.8.1 parity lift.
Everything here is **superseded by what shipped in v0.8.0** (`50b5c01`) and is kept
only so nothing is irrecoverably lost when the source worktrees are removed.
## Provenance
The v0.8.0 org-vault build had a first run (`wf_22020aea-*`, worktrees under
`.claude/worktrees/`) that left work **uncommitted**, and a second run
(`wf_e65cb9c3-*`, branches `feature/org-vault-tail-{itemcrud,statusaudit}-r2`)
that **committed** the same work. Main ultimately landed equivalent functionality
through the canonical v0.8.0 merge, leaving the `-r2` branches unmerged.
| File | Source | What it is | Status in main |
|---|---|---|---|
| `org_audit.f3e-2.rs` | untracked `tests/org_audit.rs` in `wf_22020aea-f3e-2` | B8 integration test: verified-signer attribution + non-member rejection against a real signed repo | **Superseded**`org_lifecycle.rs` + `org_init_signing.rs` cover verified-signer attribution / non-member rejection; `org_lifecycle.rs::audit_format_json_is_valid_and_has_actions` covers the `org audit` command. Also committed (slightly older variant) on `feature/org-vault-tail-statusaudit-r2`. |
| `f3e-1-org.rs.uncommitted.patch` | uncommitted diff in `wf_22020aea-f3e-1` | +884 lines: org item CRUD handlers (B9B13) | **Shipped** — item CRUD merged in v0.8.0; also committed on `feature/org-vault-tail-itemcrud-r2` (`a3f0777`). |
| `f3e-2-statusaudit.uncommitted.patch` | uncommitted diff in `wf_22020aea-f3e-2` | +476 lines: status + audit handlers (B8) | **Shipped** — status/audit merged in v0.8.0; also committed on `feature/org-vault-tail-statusaudit-r2` (`57fe10e`, `b6d6db0`). |
## Why it's safe to remove the source worktrees
- The committed copies live on the `-r2` branches (preserved) and the canonical
functionality is in `main`.
- These three artifacts pin the only *uncommitted* bytes that existed nowhere else.
If a future audit wants the dedicated `org_audit.rs` test back as a distinct
integration file, restore it from `org_audit.f3e-2.rs` and re-verify it compiles
against the current `commands::org` surface before adding it to `tests/`.

View File

@@ -0,0 +1,899 @@
diff --git a/crates/relicario-cli/src/commands/org.rs b/crates/relicario-cli/src/commands/org.rs
index b0f1bf8..3b40610 100644
--- a/crates/relicario-cli/src/commands/org.rs
+++ b/crates/relicario-cli/src/commands/org.rs
@@ -329,6 +329,503 @@ fn resolve_member_id(members: &OrgMembers, prefix: &str) -> Result<MemberId> {
}
}
+// ═══════════ Item CRUD (B9-B13) ═══════════
+//
+// `org add` / `get` / `list` / `edit` / `rm` / `restore` / `purge` for items
+// stored under `items/<collection-slug>/<id>.enc`. Each public `run_org_*`
+// wrapper opens the org vault, resolves the calling member by device key, then
+// delegates the actual work to an inner `*_with` fn that takes an already-opened
+// `UnlockedOrgVault` + the caller's `OrgMember`. The split keeps the CRUD logic
+// testable in-process without device-fingerprint plumbing.
+//
+// Supported builders for `org add`/`org edit`: Login, SecureNote, Identity.
+// Card / Key / Document / Totp parity is deferred (those read secrets via
+// rpassword/stdin); see the follow-up note in the plan after B13.
+
+use relicario_core::{Item, ItemCore};
+
+use crate::org_session::UnlockedOrgVault;
+
+/// Item kinds `org add` supports without interactive prompts. This is the
+/// handler-side enum (no clap attributes, no `collection`/`tags` — those are
+/// threaded separately by B14's dispatch). Deliberately distinct from any
+/// clap-side enum so the handler stays unaware of clap.
+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>,
+ },
+}
+
+/// Build a typed `Item` from a non-interactive `OrgAddKind` plus tags.
+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)
+}
+
+/// Insert-or-replace an `OrgManifestEntry` (keyed by item id), mirroring the
+/// personal-vault `Manifest::upsert`. The collection slug is stored in plaintext
+/// inside the encrypted manifest.
+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);
+ }
+}
+
+/// 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(", "))
+ }
+ }
+}
+
+// ── add ──────────────────────────────────────────────────────────────────────
+
+/// `org add`: create a typed item in a collection the caller holds a grant for.
+pub fn run_org_add(dir: &Path, collection: &str, kind: OrgAddKind, tags: Vec<String>) -> Result<()> {
+ let vault = crate::org_session::open_org_vault(Some(dir))?;
+ let caller = vault.current_member()?;
+ run_org_add_with(&vault, &caller, collection, kind, tags)
+}
+
+fn run_org_add_with(
+ vault: &UnlockedOrgVault,
+ caller: &OrgMember,
+ collection: &str,
+ kind: OrgAddKind,
+ tags: Vec<String>,
+) -> Result<()> {
+ // The 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, then re-encrypt the 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(())
+}
+
+// ── list ─────────────────────────────────────────────────────────────────────
+
+/// `org list`: list items in the caller's granted collections (filtered by
+/// `OrgManifest::filter_for_member`). `trashed` toggles between live + trashed.
+pub fn run_org_list(dir: &Path, trashed: bool) -> Result<()> {
+ let vault = crate::org_session::open_org_vault(Some(dir))?;
+ let caller = vault.current_member()?;
+ run_org_list_with(&vault, &caller, trashed)
+}
+
+fn run_org_list_with(vault: &UnlockedOrgVault, caller: &OrgMember, trashed: bool) -> Result<()> {
+ 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(())
+}
+
+// ── get ──────────────────────────────────────────────────────────────────────
+
+/// `org get`: print one item, masking secrets unless `show`. The query resolves
+/// over the caller-visible manifest only; the resolved collection's grant is
+/// re-checked (defense in depth).
+pub fn run_org_get(dir: &Path, query: &str, show: bool) -> Result<()> {
+ let vault = crate::org_session::open_org_vault(Some(dir))?;
+ let caller = vault.current_member()?;
+ run_org_get_with(&vault, &caller, query, show)
+}
+
+fn run_org_get_with(vault: &UnlockedOrgVault, caller: &OrgMember, query: &str, show: bool) -> Result<()> {
+ use zeroize::Zeroizing;
+
+ let manifest = vault.load_manifest()?;
+ let visible = manifest.filter_for_member(caller);
+
+ let entry = resolve_org_query(&visible, query)?;
+ 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(())
+}
+
+// ── edit ─────────────────────────────────────────────────────────────────────
+
+/// `org edit`: flag-driven field update for login / secure-note / identity.
+/// Blank flags keep their current value. The blob is re-saved in place, the
+/// manifest upserted, and the commit carries `Relicario-Action: item-update`.
+#[allow(clippy::too_many_arguments)]
+pub fn run_org_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<()> {
+ let vault = crate::org_session::open_org_vault(Some(dir))?;
+ let caller = vault.current_member()?;
+ run_org_edit_with(
+ &vault, &caller, query, title, username, url, password, body, email, phone, full_name,
+ )
+}
+
+#[allow(clippy::too_many_arguments)]
+fn run_org_edit_with(
+ vault: &UnlockedOrgVault,
+ caller: &OrgMember,
+ 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::now_unix;
+ use zeroize::Zeroizing;
+
+ 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();
+ 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(())
+}
+
+// ── trash lifecycle: rm / restore / purge ────────────────────────────────────
+
+/// Resolve a query to (collection, item) with grant enforcement. Shared by the
+/// trash-lifecycle commands.
+fn open_org_item(
+ vault: &UnlockedOrgVault,
+ caller: &OrgMember,
+ query: &str,
+) -> Result<(String, 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();
+ UnlockedOrgVault::ensure_grant(caller, &collection)?;
+ let item = vault.load_item(&collection, &id)?;
+ Ok((collection, item))
+}
+
+/// `org rm`: soft-delete (sets `trashed_at`); reversible via `org restore`.
+pub fn run_org_rm(dir: &Path, query: &str) -> Result<()> {
+ let vault = crate::org_session::open_org_vault(Some(dir))?;
+ let caller = vault.current_member()?;
+ run_org_rm_with(&vault, &caller, query)
+}
+
+fn run_org_rm_with(vault: &UnlockedOrgVault, caller: &OrgMember, query: &str) -> Result<()> {
+ 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(())
+}
+
+/// `org restore`: clear `trashed_at`, bringing the item back into the live list.
+pub fn run_org_restore(dir: &Path, query: &str) -> Result<()> {
+ let vault = crate::org_session::open_org_vault(Some(dir))?;
+ let caller = vault.current_member()?;
+ run_org_restore_with(&vault, &caller, query)
+}
+
+fn run_org_restore_with(vault: &UnlockedOrgVault, caller: &OrgMember, query: &str) -> Result<()> {
+ 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(())
+}
+
+/// `org purge`: permanently delete the blob (git rm) and drop the manifest entry.
+pub fn run_org_purge(dir: &Path, query: &str) -> Result<()> {
+ let vault = crate::org_session::open_org_vault(Some(dir))?;
+ let caller = vault.current_member()?;
+ run_org_purge_with(&vault, &caller, query)
+}
+
+fn run_org_purge_with(vault: &UnlockedOrgVault, caller: &OrgMember, query: &str) -> Result<()> {
+ 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(())
+}
+
#[cfg(test)]
mod tests {
use super::*;
@@ -386,3 +883,390 @@ mod tests {
assert!(members.find_by_id(&id).unwrap().collections.contains(&"dev".to_string()));
}
}
+
+// ═══════════ Item CRUD tests (B9-B13) ═══════════
+//
+// `relicario-cli` is a binary-only crate, so integration tests in `tests/`
+// can only drive the compiled binary — and the item subcommands are not wired
+// into `Commands::Org` dispatch yet (that is B14). These in-process unit tests
+// therefore exercise the CRUD logic through the inner `*_with` helpers against a
+// directly-constructed `UnlockedOrgVault` over a real git repo, which needs no
+// device-fingerprint plumbing. The public `run_org_*` wrappers add only the
+// open-vault + resolve-caller preamble, which the `tests/org_*` integration
+// suites in the plan cover once B14 lands the CLI dispatch.
+#[cfg(test)]
+mod crud_tests {
+ use super::*;
+ use relicario_core::{
+ encrypt_org_manifest, CollectionDef, ItemId, MemberId, OrgCollections, OrgManifest,
+ OrgMember, OrgRole,
+ };
+ use std::path::Path;
+ use std::process::Command;
+ use tempfile::TempDir;
+ use zeroize::Zeroizing;
+
+ /// A throwaway org vault: a real (unsigned-commit) git repo with the org
+ /// scaffold written and an `UnlockedOrgVault` holding a known key.
+ struct Fixture {
+ _dir: TempDir,
+ vault: UnlockedOrgVault,
+ }
+
+ fn git(root: &Path, args: &[&str]) {
+ let out = Command::new("git").current_dir(root).args(args).output().unwrap();
+ assert!(out.status.success(), "git {:?} failed: {}", args, String::from_utf8_lossy(&out.stderr));
+ }
+
+ impl Fixture {
+ fn new() -> Self {
+ let dir = TempDir::new().unwrap();
+ let root = dir.path().to_path_buf();
+ std::fs::create_dir_all(root.join("items")).unwrap();
+ std::fs::create_dir_all(root.join("keys")).unwrap();
+
+ let org_key = Zeroizing::new([7u8; 32]);
+
+ // Scaffold the non-encrypted control files.
+ std::fs::write(
+ root.join("collections.json"),
+ serde_json::to_string_pretty(&OrgCollections::new()).unwrap(),
+ )
+ .unwrap();
+ // Empty encrypted manifest.
+ let manifest = OrgManifest::new();
+ std::fs::write(
+ root.join("manifest.enc"),
+ encrypt_org_manifest(&manifest, &org_key).unwrap(),
+ )
+ .unwrap();
+
+ // A real git repo, but with signing disabled so commits succeed
+ // without a device key (signature verification is Dev-C's hook).
+ git(&root, &["init", "-q"]);
+ git(&root, &["config", "user.name", "Test"]);
+ git(&root, &["config", "user.email", "test@relicario.test"]);
+ git(&root, &["config", "commit.gpgsign", "false"]);
+ git(&root, &["add", "."]);
+ git(&root, &["commit", "-q", "-m", "scaffold"]);
+
+ let vault = UnlockedOrgVault { root, org_key };
+ Fixture { _dir: dir, vault }
+ }
+
+ /// Add a collection to collections.json and return a member granted it.
+ fn with_collection(&self, slug: &str) -> OrgMember {
+ let mut collections = self.vault.load_collections().unwrap();
+ collections.collections.push(CollectionDef {
+ slug: slug.to_string(),
+ display_name: slug.to_string(),
+ created_by: MemberId::new(),
+ created_at: 0,
+ });
+ self.vault.save_collections(&collections).unwrap();
+ self.member(vec![slug.to_string()])
+ }
+
+ fn member(&self, collections: Vec<String>) -> OrgMember {
+ OrgMember {
+ member_id: MemberId("0123456789abcdef".into()),
+ display_name: "Alice".into(),
+ role: OrgRole::Owner,
+ ed25519_pubkey: "ssh-ed25519 AAAA fake".into(),
+ collections,
+ added_at: 0,
+ added_by: MemberId("0123456789abcdef".into()),
+ }
+ }
+
+ fn head_body(&self) -> String {
+ let out = Command::new("git")
+ .current_dir(&self.vault.root)
+ .args(["log", "-1", "--format=%B"])
+ .output()
+ .unwrap();
+ String::from_utf8_lossy(&out.stdout).to_string()
+ }
+
+ fn manifest_entry_for<'a>(
+ &self,
+ m: &'a OrgManifest,
+ title: &str,
+ ) -> Option<&'a relicario_core::OrgManifestEntry> {
+ m.entries.iter().find(|e| e.title == title)
+ }
+ }
+
+ fn login(title: &str, user: &str, pw: &str) -> OrgAddKind {
+ OrgAddKind::Login {
+ title: title.into(),
+ username: Some(user.into()),
+ url: Some("https://example.com".into()),
+ password: Some(pw.into()),
+ }
+ }
+
+ // ── B10: add ──────────────────────────────────────────────────────────────
+
+ #[test]
+ fn add_writes_collection_scoped_blob_and_manifest_and_trailers() {
+ let f = Fixture::new();
+ let caller = f.with_collection("prod");
+
+ run_org_add_with(&f.vault, &caller, "prod", login("GitHub", "alice", "hunter2"), vec![])
+ .unwrap();
+
+ // Blob lives under items/prod/, not flat items/.
+ let prod_dir = f.vault.root.join("items").join("prod");
+ let blobs: Vec<_> = std::fs::read_dir(&prod_dir).unwrap().collect();
+ assert_eq!(blobs.len(), 1, "expected exactly one blob under items/prod/");
+ assert!(!f.vault.root.join("items").join("GitHub.enc").exists());
+
+ // Manifest entry recorded with the collection.
+ let manifest = f.vault.load_manifest().unwrap();
+ let entry = f.manifest_entry_for(&manifest, "GitHub").expect("manifest entry");
+ assert_eq!(entry.collection, "prod");
+
+ // Commit trailers.
+ let body = f.head_body();
+ assert!(body.contains("Relicario-Action: item-create"), "body: {body}");
+ assert!(body.contains("Relicario-Collection: prod"), "body: {body}");
+ assert!(body.contains(&format!("Relicario-Item: {}", entry.id.as_str())), "body: {body}");
+ assert!(body.contains("Relicario-Actor: Alice 0123456789abcdef"), "body: {body}");
+ }
+
+ #[test]
+ fn add_secure_note_and_identity_round_trip() {
+ let f = Fixture::new();
+ let caller = f.with_collection("prod");
+
+ run_org_add_with(
+ &f.vault,
+ &caller,
+ "prod",
+ OrgAddKind::SecureNote { title: "Notes".into(), body: "secret-body".into() },
+ vec!["tag1".into()],
+ )
+ .unwrap();
+ run_org_add_with(
+ &f.vault,
+ &caller,
+ "prod",
+ OrgAddKind::Identity {
+ title: "Me".into(),
+ full_name: Some("Alice Anderson".into()),
+ email: Some("a@example.com".into()),
+ phone: None,
+ },
+ vec![],
+ )
+ .unwrap();
+
+ let manifest = f.vault.load_manifest().unwrap();
+ assert_eq!(manifest.entries.len(), 2);
+ let note = f.manifest_entry_for(&manifest, "Notes").unwrap();
+ assert_eq!(note.tags, vec!["tag1".to_string()]);
+ let note_item = f.vault.load_item("prod", &note.id).unwrap();
+ match &note_item.core {
+ ItemCore::SecureNote(n) => assert_eq!(n.body.as_str(), "secret-body"),
+ _ => panic!("expected secure note"),
+ }
+ }
+
+ #[test]
+ fn add_rejects_ungranted_collection() {
+ let f = Fixture::new();
+ // Collection exists, but the caller holds no grant for it.
+ let _ = f.with_collection("secret");
+ let caller = f.member(vec![]); // no grants
+
+ let err = run_org_add_with(&f.vault, &caller, "secret", login("X", "u", "p"), vec![])
+ .unwrap_err();
+ let msg = format!("{err:#}");
+ assert!(msg.contains("access denied") || msg.contains("grant"), "msg: {msg}");
+ }
+
+ #[test]
+ fn add_rejects_unknown_collection() {
+ let f = Fixture::new();
+ let caller = f.member(vec!["ghost".into()]); // grant for a slug that doesn't exist
+
+ let err = run_org_add_with(&f.vault, &caller, "ghost", login("X", "u", "p"), vec![])
+ .unwrap_err();
+ let msg = format!("{err:#}");
+ assert!(msg.contains("does not exist") || msg.contains("ghost"), "msg: {msg}");
+ }
+
+ // ── B11: get + list ───────────────────────────────────────────────────────
+
+ #[test]
+ fn list_filters_to_granted_collections() {
+ let f = Fixture::new();
+ // Two collections exist; caller is granted only `prod`.
+ let _ = f.with_collection("prod");
+ let _ = f.with_collection("secret");
+ let prod_caller = f.member(vec!["prod".into()]);
+ let secret_caller = f.member(vec!["secret".into()]);
+
+ run_org_add_with(&f.vault, &prod_caller, "prod", login("InProd", "u", "p"), vec![]).unwrap();
+ run_org_add_with(&f.vault, &secret_caller, "secret", login("InSecret", "u", "p"), vec![])
+ .unwrap();
+
+ // The prod caller's visible manifest excludes the secret entry.
+ let manifest = f.vault.load_manifest().unwrap();
+ let visible = manifest.filter_for_member(&prod_caller);
+ let titles: Vec<&str> = visible.entries.iter().map(|e| e.title.as_str()).collect();
+ assert!(titles.contains(&"InProd"));
+ assert!(!titles.contains(&"InSecret"), "leaked ungranted entry: {titles:?}");
+
+ // run_org_list_with returns Ok and prints only granted entries.
+ run_org_list_with(&f.vault, &prod_caller, false).unwrap();
+ }
+
+ #[test]
+ fn get_resolves_by_id_and_title_substring() {
+ let f = Fixture::new();
+ let caller = f.with_collection("prod");
+ run_org_add_with(&f.vault, &caller, "prod", login("GitHub", "alice", "hunter2"), vec![])
+ .unwrap();
+
+ let manifest = f.vault.load_manifest().unwrap();
+ let id = manifest.entries[0].id.as_str().to_string();
+
+ // exact id, case-insensitive substring, masked default + --show all OK.
+ run_org_get_with(&f.vault, &caller, &id, false).unwrap();
+ run_org_get_with(&f.vault, &caller, "github", false).unwrap();
+ run_org_get_with(&f.vault, &caller, "GitHub", true).unwrap();
+ }
+
+ #[test]
+ fn get_unknown_query_errors() {
+ let f = Fixture::new();
+ let caller = f.with_collection("prod");
+ run_org_add_with(&f.vault, &caller, "prod", login("GitHub", "alice", "hunter2"), vec![])
+ .unwrap();
+ let err = run_org_get_with(&f.vault, &caller, "nope", false).unwrap_err();
+ assert!(format!("{err:#}").contains("no item matches"));
+ }
+
+ #[test]
+ fn resolve_org_query_reports_ambiguity() {
+ let mut manifest = OrgManifest::new();
+ for title in ["Mail Personal", "Mail Work"] {
+ manifest.entries.push(relicario_core::OrgManifestEntry {
+ id: ItemId::new(),
+ r#type: relicario_core::ItemType::Login,
+ title: title.into(),
+ tags: vec![],
+ modified: 0,
+ trashed_at: None,
+ collection: "prod".into(),
+ });
+ }
+ let err = resolve_org_query(&manifest, "mail").unwrap_err();
+ assert!(format!("{err:#}").contains("ambiguous"), "{err:#}");
+ }
+
+ // ── B12: edit ─────────────────────────────────────────────────────────────
+
+ #[test]
+ fn edit_updates_login_field_and_writes_update_trailer() {
+ let f = Fixture::new();
+ let caller = f.with_collection("prod");
+ run_org_add_with(&f.vault, &caller, "prod", login("Mail", "old", "pw"), vec![]).unwrap();
+
+ run_org_edit_with(
+ &f.vault, &caller, "Mail",
+ None, Some("new-user".into()), None, None, None, None, None, None,
+ )
+ .unwrap();
+
+ // The blob now carries the new username.
+ let manifest = f.vault.load_manifest().unwrap();
+ let entry = f.manifest_entry_for(&manifest, "Mail").unwrap();
+ let item = f.vault.load_item("prod", &entry.id).unwrap();
+ match &item.core {
+ ItemCore::Login(l) => assert_eq!(l.username.as_deref(), Some("new-user")),
+ _ => panic!("expected login"),
+ }
+
+ let body = f.head_body();
+ assert!(body.contains("Relicario-Action: item-update"), "body: {body}");
+ assert!(body.contains("Relicario-Collection: prod"), "body: {body}");
+ }
+
+ #[test]
+ fn edit_can_retitle_and_keeps_unset_fields() {
+ let f = Fixture::new();
+ let caller = f.with_collection("prod");
+ run_org_add_with(&f.vault, &caller, "prod", login("Mail", "old", "pw"), vec![]).unwrap();
+
+ run_org_edit_with(
+ &f.vault, &caller, "Mail",
+ Some("Webmail".into()), None, None, None, None, None, None, None,
+ )
+ .unwrap();
+
+ let manifest = f.vault.load_manifest().unwrap();
+ assert!(f.manifest_entry_for(&manifest, "Webmail").is_some());
+ let entry = f.manifest_entry_for(&manifest, "Webmail").unwrap();
+ let item = f.vault.load_item("prod", &entry.id).unwrap();
+ match &item.core {
+ // username untouched (we passed None), password untouched.
+ ItemCore::Login(l) => assert_eq!(l.username.as_deref(), Some("old")),
+ _ => panic!("expected login"),
+ }
+ }
+
+ // ── B13: rm / restore / purge ─────────────────────────────────────────────
+
+ #[test]
+ fn rm_restore_purge_cycle() {
+ let f = Fixture::new();
+ let caller = f.with_collection("prod");
+ run_org_add_with(
+ &f.vault,
+ &caller,
+ "prod",
+ OrgAddKind::SecureNote { title: "Recovery".into(), body: "codes-here".into() },
+ vec![],
+ )
+ .unwrap();
+
+ // rm → trashed_at set, item drops out of the live list, shows in --trashed.
+ run_org_rm_with(&f.vault, &caller, "Recovery").unwrap();
+ let manifest = f.vault.load_manifest().unwrap();
+ let entry = f.manifest_entry_for(&manifest, "Recovery").unwrap();
+ assert!(entry.trashed_at.is_some(), "rm should set trashed_at");
+ assert!(f.head_body().contains("Relicario-Action: item-delete"));
+
+ // restore → trashed_at cleared.
+ run_org_restore_with(&f.vault, &caller, "Recovery").unwrap();
+ let manifest = f.vault.load_manifest().unwrap();
+ let entry = f.manifest_entry_for(&manifest, "Recovery").unwrap();
+ assert!(entry.trashed_at.is_none(), "restore should clear trashed_at");
+ assert!(f.head_body().contains("Relicario-Action: item-restore"));
+
+ // purge → blob gone from disk, manifest entry dropped, purge trailer.
+ run_org_purge_with(&f.vault, &caller, "Recovery").unwrap();
+ let prod_dir = f.vault.root.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 manifest = f.vault.load_manifest().unwrap();
+ assert!(f.manifest_entry_for(&manifest, "Recovery").is_none(), "manifest entry not dropped");
+ assert!(f.head_body().contains("Relicario-Action: item-purge"));
+ }
+
+ #[test]
+ fn rm_enforces_grant_via_visible_manifest() {
+ let f = Fixture::new();
+ // owner adds into prod
+ let owner = f.with_collection("prod");
+ run_org_add_with(&f.vault, &owner, "prod", login("Secret", "u", "p"), vec![]).unwrap();
+
+ // a caller with no grant cannot even resolve the item (filtered out).
+ let outsider = f.member(vec![]);
+ let err = run_org_rm_with(&f.vault, &outsider, "Secret").unwrap_err();
+ assert!(format!("{err:#}").contains("no item matches"), "{err:#}");
+ }
+}

View File

@@ -0,0 +1,521 @@
diff --git a/Cargo.lock b/Cargo.lock
index ffaf13f..5b9a869 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2172,6 +2172,7 @@ dependencies = [
"predicates",
"qrcode",
"rand",
+ "regex",
"relicario-core",
"reqwest",
"rpassword",
diff --git a/crates/relicario-cli/Cargo.toml b/crates/relicario-cli/Cargo.toml
index db05181..928004c 100644
--- a/crates/relicario-cli/Cargo.toml
+++ b/crates/relicario-cli/Cargo.toml
@@ -31,10 +31,11 @@ rqrr = "0.7"
reqwest = { version = "0.12", features = ["blocking", "json"] }
qrcode = { version = "0.14", features = ["svg"] }
ssh-key = { version = "0.6", features = ["ed25519", "std"] }
+regex = "1"
+tempfile = "3"
[dev-dependencies]
assert_cmd = "2"
predicates = "3"
-tempfile = "3"
serde_json = "1"
ed25519-dalek = "2"
diff --git a/crates/relicario-cli/src/commands/org.rs b/crates/relicario-cli/src/commands/org.rs
index b0f1bf8..799b14b 100644
--- a/crates/relicario-cli/src/commands/org.rs
+++ b/crates/relicario-cli/src/commands/org.rs
@@ -329,6 +329,285 @@ fn resolve_member_id(members: &OrgMembers, prefix: &str) -> Result<MemberId> {
}
}
+// ═══════════ Status / Audit (B8) ═══════════
+
+/// `org status`: print the org's members + collections with no decryption. Reads
+/// the three plaintext metadata files (org.json, members.json, collections.json)
+/// directly — the manifest stays encrypted and is never touched.
+pub fn run_org_status(dir: &Path) -> Result<()> {
+ let root = crate::org_session::org_dir(Some(dir))?;
+
+ let meta: relicario_core::OrgMeta = {
+ let s = fs::read_to_string(root.join("org.json")).context("read org.json")?;
+ serde_json::from_str(&s).context("parse org.json")?
+ };
+ let members: OrgMembers = {
+ let s = fs::read_to_string(root.join("members.json")).context("read members.json")?;
+ serde_json::from_str(&s).context("parse members.json")?
+ };
+ let collections: OrgCollections = {
+ let s = fs::read_to_string(root.join("collections.json"))
+ .context("read collections.json")?;
+ serde_json::from_str(&s).context("parse collections.json")?
+ };
+
+ println!("Org: {} ({})", meta.display_name, meta.org_id.as_str());
+ println!();
+ println!("Members ({}):", members.members.len());
+ for m in &members.members {
+ let colls = if m.collections.is_empty() {
+ "(no collections)".to_string()
+ } else {
+ m.collections.join(", ")
+ };
+ println!(
+ " {:?} {} {} [{}]",
+ m.role,
+ m.member_id.as_str(),
+ m.display_name,
+ colls
+ );
+ }
+ println!();
+ println!("Collections ({}):", collections.collections.len());
+ for c in &collections.collections {
+ println!(" {} — {}", c.slug, c.display_name);
+ }
+ Ok(())
+}
+
+/// One audited org-vault commit, attributed to a VERIFIED git signer.
+#[derive(Debug, serde::Serialize)]
+pub struct AuditEvent {
+ pub commit: String,
+ pub timestamp: String,
+ /// Actor as resolved from the VERIFIED signing key (authoritative).
+ pub actor_name: Option<String>,
+ pub actor_id: Option<String>,
+ /// Actor id as CLAIMED by the commit trailer (advisory; for tamper-checking).
+ pub trailer_actor_id: Option<String>,
+ pub action: Option<String>,
+ pub collection: Option<String>,
+ pub item_id: Option<String>,
+ pub device_id: Option<String>,
+ /// True when the trailer's claimed actor disagrees with the verified signer,
+ /// or when no current member matches the signing key.
+ pub tampered: bool,
+}
+
+/// Parse a commit's `Relicario-*` trailer block into an `AuditEvent`. The actor
+/// id captured here is the trailer's CLAIM (`trailer_actor_id`) — the
+/// authoritative `actor_id` is resolved later from the verified signature.
+fn parse_trailer_block(commit: &str, timestamp: &str, trailers: &str) -> AuditEvent {
+ let mut ev = AuditEvent {
+ commit: commit.to_string(),
+ timestamp: timestamp.to_string(),
+ actor_name: None,
+ actor_id: None,
+ trailer_actor_id: None,
+ action: None,
+ collection: None,
+ item_id: None,
+ device_id: None,
+ tampered: false,
+ };
+ for line in trailers.lines() {
+ let line = line.trim();
+ if let Some(rest) = line.strip_prefix("Relicario-Actor:") {
+ // Contract format: "<name> <member_id>" (member_id is the last token).
+ let rest = rest.trim();
+ if let Some((_name, id)) = rest.rsplit_once(' ') {
+ ev.trailer_actor_id = Some(id.trim().to_string());
+ } else if !rest.is_empty() {
+ ev.trailer_actor_id = Some(rest.to_string());
+ }
+ } else if let Some(v) = line.strip_prefix("Relicario-Action:") {
+ ev.action = Some(v.trim().to_string());
+ } else if let Some(v) = line.strip_prefix("Relicario-Collection:") {
+ ev.collection = Some(v.trim().to_string());
+ } else if let Some(v) = line.strip_prefix("Relicario-Item:") {
+ ev.item_id = Some(v.trim().to_string());
+ } else if let Some(v) = line.strip_prefix("Relicario-Device:") {
+ ev.device_id = Some(v.trim().to_string());
+ }
+ }
+ ev
+}
+
+/// Resolve a commit's SSH signature fingerprint to a current member, mirroring
+/// the pre-receive hook: build an allowed_signers from members.json, inject it
+/// via GIT_CONFIG_*, run `git verify-commit --raw`, parse the SHA256: key from
+/// stderr. Returns None if the commit is unsigned or the signer is not a member.
+fn resolve_signer<'m>(
+ root: &Path,
+ commit: &str,
+ members: &'m relicario_core::OrgMembers,
+) -> Option<&'m relicario_core::OrgMember> {
+ use std::io::Write;
+ let mut tmp = tempfile::NamedTempFile::new().ok()?;
+ for m in &members.members {
+ let _ = writeln!(tmp, "relicario {}", m.ed25519_pubkey.trim());
+ }
+ let allowed_path = tmp.path();
+
+ let output = std::process::Command::new("git")
+ .current_dir(root)
+ .args(["verify-commit", "--raw", commit])
+ .env("GIT_CONFIG_COUNT", "1")
+ .env("GIT_CONFIG_KEY_0", "gpg.ssh.allowedSignersFile")
+ .env("GIT_CONFIG_VALUE_0", allowed_path.as_os_str())
+ .output()
+ .ok()?;
+ let stderr = String::from_utf8_lossy(&output.stderr);
+
+ let re = regex::Regex::new(r"key (SHA256:[A-Za-z0-9+/]+)").ok()?;
+ let fp = re.captures(&stderr)?.get(1)?.as_str().to_string();
+
+ members.members.iter().find(|m| {
+ relicario_core::fingerprint(&m.ed25519_pubkey).ok().as_deref() == Some(fp.as_str())
+ })
+}
+
+/// `org audit`: parse `git log`, resolve each commit's VERIFIED signer to a
+/// member and report THAT as the actor (trailers are advisory), flag
+/// trailer/signer mismatch as `TAMPERED`, and frame records with `%x1e`/`%x1f`
+/// (so multi-line trailer values cannot misalign records) using the committer
+/// date (`%cI`).
+pub fn run_org_audit(
+ dir: &Path,
+ since: Option<&str>,
+ member_filter: Option<&str>,
+ collection_filter: Option<&str>,
+ action_filter: Option<&str>,
+ format: &str,
+) -> Result<()> {
+ // Spec surface is `--format <table|json>` (default table). Accept only those.
+ let json = match format {
+ "json" => true,
+ "table" => false,
+ other => anyhow::bail!("unknown --format `{other}` — use table or json"),
+ };
+ let root = crate::org_session::org_dir(Some(dir))?;
+
+ // members.json — needed to resolve each commit's verified signer to a member.
+ let members: relicario_core::OrgMembers = {
+ let s = fs::read_to_string(root.join("members.json")).context("read members.json")?;
+ serde_json::from_str(&s).context("parse members.json")?
+ };
+
+ // git log framed with a record separator (%x1e, U+001E) PER COMMIT and a
+ // field separator (%x1f, U+001F) between fields, so multi-line trailer
+ // values cannot misalign record boundaries. Committer date (%cI), not
+ // author date: it is what revocation/audit is anchored to.
+ let fmt = "%x1e%H%x1f%cI%x1f%(trailers:only=true,unfold=true)";
+ let mut args: Vec<String> = vec!["log".into(), format!("--format={fmt}")];
+ if let Some(s) = since {
+ args.push(format!("--since={s}"));
+ }
+
+ let output = std::process::Command::new("git")
+ .current_dir(&root)
+ .args(&args)
+ .output()
+ .context("git log")?;
+ let log = String::from_utf8_lossy(&output.stdout);
+
+ let events = parse_audit_log(&root, &log, &members, member_filter, collection_filter, action_filter);
+
+ if json {
+ println!("{}", serde_json::to_string_pretty(&events)?);
+ } else {
+ println!(
+ "{:<44} {:<26} {:<20} {:<18} {}",
+ "COMMIT", "TIMESTAMP", "ACTION", "ACTOR", "FLAG"
+ );
+ for ev in &events {
+ println!(
+ "{:<44} {:<26} {:<20} {:<18} {}",
+ ev.commit,
+ ev.timestamp,
+ ev.action.as_deref().unwrap_or("-"),
+ ev.actor_name.as_deref().unwrap_or("<unverified>"),
+ if ev.tampered { "TAMPERED" } else { "" },
+ );
+ }
+ }
+ Ok(())
+}
+
+/// Frame a raw `git log` body (records split on `%x1e`, fields on `%x1f`) into
+/// attributed `AuditEvent`s. Each commit's VERIFIED signer is resolved via
+/// `resolve_signer` and reported as the authoritative actor; trailer/signer
+/// disagreement (or no matching member) sets the `tampered` flag. Filters apply
+/// to the VERIFIED actor id, not the spoofable trailer. Split out from
+/// `run_org_audit` so it can be unit-tested over a real signed repo.
+fn parse_audit_log(
+ root: &Path,
+ log: &str,
+ members: &relicario_core::OrgMembers,
+ member_filter: Option<&str>,
+ collection_filter: Option<&str>,
+ action_filter: Option<&str>,
+) -> Vec<AuditEvent> {
+ let mut events: Vec<AuditEvent> = Vec::new();
+ for record in log.split('\u{1e}') {
+ let record = record.trim_start_matches('\n');
+ if record.trim().is_empty() {
+ continue;
+ }
+ let mut fields = record.splitn(3, '\u{1f}');
+ let commit = fields.next().unwrap_or("").trim();
+ let ts = fields.next().unwrap_or("").trim();
+ let trailers = fields.next().unwrap_or("");
+ if commit.is_empty() {
+ continue;
+ }
+
+ let mut ev = parse_trailer_block(commit, ts, trailers);
+ if ev.action.is_none() {
+ continue; // not an org commit
+ }
+
+ // Resolve the VERIFIED signer and attribute it as the authoritative actor.
+ match resolve_signer(root, commit, members) {
+ Some(m) => {
+ ev.actor_name = Some(m.display_name.clone());
+ ev.actor_id = Some(m.member_id.as_str().to_string());
+ // Tampered if the trailer claims a different actor than the signer.
+ if let Some(claimed) = ev.trailer_actor_id.as_deref() {
+ if claimed != m.member_id.as_str() {
+ ev.tampered = true;
+ }
+ }
+ }
+ None => {
+ // No current member matched the signature -> cannot trust the
+ // trailer's claimed actor.
+ ev.tampered = true;
+ }
+ }
+
+ if let Some(mid) = member_filter {
+ // Filter on the VERIFIED actor id, not the spoofable trailer.
+ if ev.actor_id.as_deref() != Some(mid) {
+ continue;
+ }
+ }
+ if let Some(col) = collection_filter {
+ if ev.collection.as_deref() != Some(col) {
+ continue;
+ }
+ }
+ if let Some(act) = action_filter {
+ if ev.action.as_deref() != Some(act) {
+ continue;
+ }
+ }
+ events.push(ev);
+ }
+ events
+}
+
#[cfg(test)]
mod tests {
use super::*;
@@ -385,4 +664,201 @@ mod tests {
assert!(!members.find_by_id(&id).unwrap().collections.contains(&"prod".to_string()));
assert!(members.find_by_id(&id).unwrap().collections.contains(&"dev".to_string()));
}
+
+ // ───── Status / Audit (B8) ─────
+
+ #[test]
+ fn parse_trailers_extracts_relicario_fields() {
+ // Contract trailer shape: "Relicario-Actor: <name> <member_id>".
+ let raw = "Relicario-Actor: alice a1b2c3d4e5f6a1b2\nRelicario-Action: item-create\nRelicario-Collection: prod\n";
+ let event = parse_trailer_block("abc123", "2026-06-06T12:00:00+00:00", raw);
+ assert_eq!(event.action.as_deref(), Some("item-create"));
+ assert_eq!(event.collection.as_deref(), Some("prod"));
+ // The verified actor_id is resolved later from the signature, not the trailer;
+ // the trailer only populates trailer_actor_id here.
+ assert_eq!(event.trailer_actor_id.as_deref(), Some("a1b2c3d4e5f6a1b2"));
+ assert_eq!(event.actor_id, None);
+ assert!(!event.tampered);
+ }
+
+ #[test]
+ fn parse_trailers_captures_item_and_device() {
+ let raw = "Relicario-Actor: bob feedfacefeedface\nRelicario-Action: item-update\nRelicario-Item: 0123456789abcdef\nRelicario-Device: laptop\n";
+ let ev = parse_trailer_block("def456", "2026-06-06T13:00:00+00:00", raw);
+ assert_eq!(ev.action.as_deref(), Some("item-update"));
+ assert_eq!(ev.item_id.as_deref(), Some("0123456789abcdef"));
+ assert_eq!(ev.device_id.as_deref(), Some("laptop"));
+ assert_eq!(ev.trailer_actor_id.as_deref(), Some("feedfacefeedface"));
+ }
+
+ #[test]
+ fn parse_trailers_single_token_actor_falls_back_to_whole_value() {
+ // No space => the whole value is treated as the member id.
+ let raw = "Relicario-Actor: lonelytoken00000\nRelicario-Action: org-init\n";
+ let ev = parse_trailer_block("c0ffee", "2026-06-06T14:00:00+00:00", raw);
+ assert_eq!(ev.trailer_actor_id.as_deref(), Some("lonelytoken00000"));
+ assert_eq!(ev.action.as_deref(), Some("org-init"));
+ }
+
+ #[test]
+ fn parse_trailers_non_org_commit_has_no_action() {
+ // A commit with no Relicario-* trailers parses to an event with no action,
+ // which run_org_audit skips.
+ let ev = parse_trailer_block("beef", "2026-06-06T15:00:00+00:00", "");
+ assert!(ev.action.is_none());
+ }
+}
+
+#[cfg(test)]
+mod audit_log_tests {
+ //! Record-framing + filter tests for `parse_audit_log` against a synthetic
+ //! `git log` body (no real repo / signatures needed: members.json is empty so
+ //! `resolve_signer` always returns None and every org commit is flagged
+ //! TAMPERED — which is exactly the "signer is not a current member" path).
+ use super::*;
+ use relicario_core::OrgMembers;
+
+ /// Build one framed record: leading %x1e, then commit %x1f ts %x1f trailers.
+ fn record(commit: &str, ts: &str, trailers: &str) -> String {
+ format!("\u{1e}{commit}\u{1f}{ts}\u{1f}{trailers}")
+ }
+
+ #[test]
+ fn parse_audit_log_frames_records_and_flags_unverified() {
+ let members = OrgMembers::new(); // no members => no signer can resolve
+ let log = format!(
+ "{}{}",
+ record(
+ "1111111111111111111111111111111111111111",
+ "2026-06-06T12:00:00+00:00",
+ "Relicario-Actor: alice a1b2c3d4e5f6a1b2\nRelicario-Action: item-create\nRelicario-Collection: prod\n",
+ ),
+ record(
+ "2222222222222222222222222222222222222222",
+ "2026-06-06T13:00:00+00:00",
+ "Relicario-Actor: bob feedfacefeedface\nRelicario-Action: item-update\nRelicario-Collection: dev\n",
+ ),
+ );
+ // root path is unused once resolve_signer short-circuits on empty members,
+ // but verify-commit will run; point it at a tempdir to be safe.
+ let tmp = tempfile::tempdir().unwrap();
+ let events = parse_audit_log(tmp.path(), &log, &members, None, None, None);
+ assert_eq!(events.len(), 2);
+ // Leading %x1e produced an empty leading split element that was filtered.
+ assert_eq!(events[0].commit, "1111111111111111111111111111111111111111");
+ assert_eq!(events[0].action.as_deref(), Some("item-create"));
+ assert_eq!(events[0].collection.as_deref(), Some("prod"));
+ // No member matched the (absent) signature => TAMPERED, no verified actor.
+ assert!(events[0].tampered);
+ assert_eq!(events[0].actor_name, None);
+ assert_eq!(events[0].actor_id, None);
+ // Trailer claim is preserved for forensic comparison.
+ assert_eq!(events[0].trailer_actor_id.as_deref(), Some("a1b2c3d4e5f6a1b2"));
+ }
+
+ #[test]
+ fn parse_audit_log_skips_non_org_commits() {
+ let members = OrgMembers::new();
+ let log = format!(
+ "{}{}",
+ // A non-org commit: no Relicario-Action trailer.
+ record("3333", "2026-06-06T10:00:00+00:00", "Some-Other: trailer\n"),
+ record(
+ "4444",
+ "2026-06-06T11:00:00+00:00",
+ "Relicario-Action: org-init\nRelicario-Actor: alice a1b2c3d4e5f6a1b2\n",
+ ),
+ );
+ let tmp = tempfile::tempdir().unwrap();
+ let events = parse_audit_log(tmp.path(), &log, &members, None, None, None);
+ assert_eq!(events.len(), 1);
+ assert_eq!(events[0].commit, "4444");
+ assert_eq!(events[0].action.as_deref(), Some("org-init"));
+ }
+
+ #[test]
+ fn parse_audit_log_multiline_trailer_value_does_not_misalign() {
+ // A multi-line trailer value must not break record framing: only %x1e
+ // ends a record, not a newline inside the trailer block.
+ let members = OrgMembers::new();
+ let log = format!(
+ "{}{}",
+ record(
+ "5555",
+ "2026-06-06T09:00:00+00:00",
+ "Relicario-Action: item-create\nRelicario-Actor: carol cafecafecafecafe\nRelicario-Collection: prod\n",
+ ),
+ record(
+ "6666",
+ "2026-06-06T09:30:00+00:00",
+ "Relicario-Action: item-delete\nRelicario-Actor: dave deaddeaddeaddead\nRelicario-Collection: dev\n",
+ ),
+ );
+ let tmp = tempfile::tempdir().unwrap();
+ let events = parse_audit_log(tmp.path(), &log, &members, None, None, None);
+ assert_eq!(events.len(), 2);
+ assert_eq!(events[0].commit, "5555");
+ assert_eq!(events[1].commit, "6666");
+ assert_eq!(events[1].action.as_deref(), Some("item-delete"));
+ }
+
+ #[test]
+ fn parse_audit_log_collection_and_action_filters_apply() {
+ let members = OrgMembers::new();
+ let log = format!(
+ "{}{}{}",
+ record(
+ "7777",
+ "2026-06-06T08:00:00+00:00",
+ "Relicario-Action: item-create\nRelicario-Collection: prod\nRelicario-Actor: a aaaa000000000000\n",
+ ),
+ record(
+ "8888",
+ "2026-06-06T08:10:00+00:00",
+ "Relicario-Action: item-update\nRelicario-Collection: prod\nRelicario-Actor: a aaaa000000000000\n",
+ ),
+ record(
+ "9999",
+ "2026-06-06T08:20:00+00:00",
+ "Relicario-Action: item-create\nRelicario-Collection: dev\nRelicario-Actor: a aaaa000000000000\n",
+ ),
+ );
+ let tmp = tempfile::tempdir().unwrap();
+
+ // Collection filter: only prod commits survive.
+ let prod = parse_audit_log(tmp.path(), &log, &members, None, Some("prod"), None);
+ assert_eq!(prod.len(), 2);
+ assert!(prod.iter().all(|e| e.collection.as_deref() == Some("prod")));
+
+ // Action filter: only item-create commits survive.
+ let creates = parse_audit_log(tmp.path(), &log, &members, None, None, Some("item-create"));
+ assert_eq!(creates.len(), 2);
+ assert!(creates.iter().all(|e| e.action.as_deref() == Some("item-create")));
+
+ // Combined: item-create AND prod => just commit 7777.
+ let combined =
+ parse_audit_log(tmp.path(), &log, &members, None, Some("prod"), Some("item-create"));
+ assert_eq!(combined.len(), 1);
+ assert_eq!(combined[0].commit, "7777");
+ }
+
+ #[test]
+ fn parse_audit_log_member_filter_uses_verified_actor_not_trailer() {
+ // With no resolvable signer, actor_id is None, so a member filter naming
+ // the TRAILER's claimed id must NOT match — the filter is on the verified
+ // actor, which is the whole point of TAMPERED attribution.
+ let members = OrgMembers::new();
+ let log = record(
+ "aaaa",
+ "2026-06-06T07:00:00+00:00",
+ "Relicario-Action: item-create\nRelicario-Actor: mallory deadbeefdeadbeef\n",
+ );
+ let tmp = tempfile::tempdir().unwrap();
+ let filtered =
+ parse_audit_log(tmp.path(), &log, &members, Some("deadbeefdeadbeef"), None, None);
+ assert!(
+ filtered.is_empty(),
+ "member filter must match the verified actor id, never the spoofable trailer"
+ );
+ }
}

View File

@@ -0,0 +1,156 @@
//! B8 `org audit` verified-signer attribution — integration coverage.
//!
//! The audit logic (`resolve_signer`, `parse_audit_log`, `run_org_audit`) lives
//! in the bin crate's private `commands::org` module and the CLI dispatch is not
//! wired until B14, so we cannot drive `org audit` through the binary yet. What
//! we CAN do is build a real signed org vault via `org init` and assert that the
//! exact verification mechanism `resolve_signer` uses — a temp `allowed_signers`
//! prefixed `relicario `, injected via `GIT_CONFIG_*`, then
//! `git verify-commit --raw`, then the `key (SHA256:...)` regex over stderr —
//! resolves the genesis commit's signature to the seeded member's fingerprint.
//!
//! This pins the security-critical half of B8 (attribute to the VERIFIED signer,
//! mirroring the pre-receive hook) against a genuine SSH signature rather than
//! the synthetic-log unit tests, which only cover the "no member matched ->
//! TAMPERED" fallback.
use std::fs;
use std::io::Write;
use std::path::Path;
use std::process::Command;
use tempfile::{NamedTempFile, TempDir};
fn relicario_with_git_identity(config_home: &Path, args: &[&str]) -> std::process::Output {
Command::new(env!("CARGO_BIN_EXE_relicario"))
.env("XDG_CONFIG_HOME", config_home)
.env("HOME", config_home)
.env("GIT_AUTHOR_NAME", "Test Device")
.env("GIT_AUTHOR_EMAIL", "test@relicario.test")
.env("GIT_COMMITTER_NAME", "Test Device")
.env("GIT_COMMITTER_EMAIL", "test@relicario.test")
.args(args)
.output()
.expect("run relicario")
}
/// Lay out a device keypair under `<config_home>/relicario/devices/<name>/` and
/// mark it current. Mirrors `org_init_signing::seed_device`. Returns the OpenSSH
/// public key string.
fn seed_device(config_home: &Path, name: &str) -> String {
let (priv_openssh, pub_openssh) =
relicario_core::device::generate_keypair().expect("generate_keypair");
let dev_dir = config_home.join("relicario").join("devices").join(name);
fs::create_dir_all(&dev_dir).expect("create device dir");
let signing_key_path = dev_dir.join("signing.key");
fs::write(&signing_key_path, priv_openssh.as_str()).expect("write signing.key");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&signing_key_path, fs::Permissions::from_mode(0o600))
.expect("chmod signing.key");
}
fs::write(dev_dir.join("signing.pub"), &pub_openssh).expect("write signing.pub");
fs::write(dev_dir.join("deploy.key"), "").expect("write stub deploy.key");
fs::write(dev_dir.join("deploy.pub"), "").expect("write stub deploy.pub");
let devices_dir = config_home.join("relicario").join("devices");
fs::write(devices_dir.join("current"), format!("{name}\n")).expect("write current");
pub_openssh
}
/// Replicate `commands::org::resolve_signer`'s verification: build an
/// allowed_signers file from the given pubkeys (prefixed `relicario `), inject it
/// via GIT_CONFIG_*, run `git verify-commit --raw`, and parse the SHA256 key
/// fingerprint from stderr.
fn resolve_signer_fp(org_root: &Path, commit: &str, pubkeys: &[&str]) -> Option<String> {
let mut tmp = NamedTempFile::new().ok()?;
for pk in pubkeys {
writeln!(tmp, "relicario {}", pk.trim()).ok()?;
}
let allowed_path = tmp.path();
let output = Command::new("git")
.current_dir(org_root)
.args(["verify-commit", "--raw", commit])
.env("GIT_CONFIG_COUNT", "1")
.env("GIT_CONFIG_KEY_0", "gpg.ssh.allowedSignersFile")
.env("GIT_CONFIG_VALUE_0", allowed_path.as_os_str())
.output()
.ok()?;
// The clean exit IS the gate (matches the hook): a non-member signature fails.
if !output.status.success() {
return None;
}
let stderr = String::from_utf8_lossy(&output.stderr);
let re = regex::Regex::new(r"key (SHA256:[A-Za-z0-9+/]+)").ok()?;
Some(re.captures(&stderr)?.get(1)?.as_str().to_string())
}
#[test]
fn audit_resolves_genesis_commit_to_the_signing_member() {
let cfg = TempDir::new().unwrap();
let org = TempDir::new().unwrap();
let pub_openssh = seed_device(cfg.path(), "test-dev");
let init = relicario_with_git_identity(
cfg.path(),
&["org", "init", "--dir", org.path().to_str().unwrap(), "--name", "Acme"],
);
assert!(
init.status.success(),
"org init failed:\nstdout: {}\nstderr: {}",
String::from_utf8_lossy(&init.stdout),
String::from_utf8_lossy(&init.stderr)
);
// The signing member's pubkey is recorded in members.json. resolve_signer
// builds allowed_signers from exactly that set.
let members_json =
fs::read_to_string(org.path().join("members.json")).expect("read members.json");
let members: relicario_core::OrgMembers =
serde_json::from_str(&members_json).expect("parse members.json");
assert_eq!(members.members.len(), 1, "init seeds exactly one owner member");
let owner = &members.members[0];
// The genesis commit must resolve to the owner's fingerprint.
let signing_fp = resolve_signer_fp(org.path(), "HEAD", &[owner.ed25519_pubkey.as_str()])
.expect("genesis commit signature must verify against the member set");
let expected = relicario_core::fingerprint(&owner.ed25519_pubkey).expect("fingerprint owner");
assert_eq!(
signing_fp, expected,
"verified signer fingerprint must equal the owner member's fingerprint"
);
// The seeded pubkey and the members.json pubkey are the same key.
assert_eq!(owner.ed25519_pubkey.trim(), pub_openssh.trim());
}
#[test]
fn audit_rejects_signature_from_a_non_member_key() {
// A commit signed by the owner must NOT resolve when the allowed_signers set
// contains only some OTHER (non-member) key — this is the TAMPERED path:
// "signer is not a current member".
let cfg = TempDir::new().unwrap();
let org = TempDir::new().unwrap();
let _owner_pub = seed_device(cfg.path(), "test-dev");
let init = relicario_with_git_identity(
cfg.path(),
&["org", "init", "--dir", org.path().to_str().unwrap(), "--name", "Acme"],
);
assert!(init.status.success(), "org init failed");
// A stranger keypair that never signed anything in this repo.
let (_stranger_priv, stranger_pub) =
relicario_core::device::generate_keypair().expect("generate stranger keypair");
let resolved = resolve_signer_fp(org.path(), "HEAD", &[stranger_pub.as_str()]);
assert!(
resolved.is_none(),
"a commit signed by the owner must not verify against a stranger-only signer set"
);
}

View File

@@ -271,7 +271,7 @@ Parses `git log` (record separator `%x1e`, field separator `%x1f` to survive mul
1. **Verifies the signature** by building a temporary `allowed_signers` from `members.json` ed25519 keys, injecting `gpg.ssh.allowedSignersFile` via `GIT_CONFIG_*`, running `git verify-commit --raw`, and parsing the `SHA256:` fingerprint from stderr — the same mechanism the existing `verify-commit` uses. A commit with no good signature, or whose signer is not a current member, is rejected. (Bare `git %GF` is **not** used — it returns empty without an allowed-signers file.)
2. **Authorizes the change** by inspecting `git diff-tree` paths:
- `members.json` / `collections.json` / `org.json` → signer must be owner/admin; a `member-role-change` granting owner/admin must be signed by an owner.
- `members.json` / `collections.json` / `org.json` → signer must be owner/admin; a `member-role-change` granting owner/admin must be signed by an owner. The signer's authority here is judged on their role in the **parent** commit (their pre-change role), never the post-change role carried in the commit under verification — otherwise an Admin could self-promote to Owner in one commit and have the gate read the already-elevated role and self-authorize. A signer absent from the parent has no prior authority and is rejected. (Genesis is the sole exception — see §4 below.)
- `items/<slug>/<id>.enc``<slug>` must be in the signing member's grants.
3. **Validates schema**`schema_version` must not decrease for any of the three JSON files (compared against `{commit}^:<file>`), and `members.json`/`collections.json` must pass `validate()`.
4. **Handles genesis and merges** — the root commit (no parent) is the org-init genesis: it is allowed if signed by the sole owner it introduces. Merge commits are rejected (org history is linear) to avoid first-parent-only diff blind spots.

View File

@@ -0,0 +1,107 @@
# Relicario v0.8.1 — Org Vault Item-Type Parity (Design Spec)
**Date:** 2026-06-20
**Status:** Approved (design) — implementation plan to follow
**Predecessor:** `docs/superpowers/specs/2026-06-06-relicario-enterprise-org-vault-design.md` (org vault shipped v0.8.0, `50b5c01`)
**Tracked-from:** the org-vault plan's deferral note — `docs/superpowers/plans/2026-06-06-enterprise-org-vault.md:3839` and the v0.8.1 follow-up list at `:6132`.
## Goal
Bring `relicario org add` and `relicario org edit` to **full item-type parity** with the personal vault. Today the org surface supports only **Login, SecureNote, Identity**; this milestone adds **Card, Key, Document, Totp**.
Secrets are entered via **interactive prompts by default**, with **`--*-stdin` escape hatches** for non-interactive scripting and the acceptance tests — matching the personal vault's secret-input philosophy while staying fully testable.
Document support additionally requires org-side **attachment storage** and a **`relicario-server` pre-receive hook change** that grant-scopes attachment write paths (closing a latent authorization gap).
## Background — current state (verified)
| Surface | Coverage today | Mechanism |
|---|---|---|
| Personal `add` (`commands/add.rs`) | All 7 types | per-type `build_*_item`; flags + `prompt_secret`/stdin |
| Personal `edit` (`commands/edit.rs`) | All 7 types | interactive per-type, "blank to keep", **field history** (synthetic `core:<field>` FieldIds) |
| Org `add` (`commands/org.rs::build_org_item`) | Login / SecureNote / Identity | plain value flags only (incl. `--password`) |
| Org `edit` (`commands/org.rs::run_edit`) | Login / SecureNote / Identity | flat `Option<String>` flag args |
- **Org storage** is `items/<slug>/<id>.enc` only (`org_session.rs::item_path`). There is **no attachment support and no settings/caps** on the org side.
- **Hook** (`crates/relicario-server/src/lib.rs::classify_path`) classifies paths as `Protected` (members/collections/org.json), `Item { collection }` (`items/<slug>/<id>.enc` — grant-authorized), `Rejected`, or **`Unrestricted`** (everything else — gated only by the per-commit member-signature check). An `attachments/...` path therefore currently falls through to **`Unrestricted`**: any member could push attachment blobs regardless of collection grants. Document parity must close this.
Why the four types were deferred: their personal builders read secrets interactively (`prompt_secret` / multiline stdin), so they had no non-interactive path the org acceptance tests could drive. Document additionally has no org storage target.
## Design
### Approach
**Shared builder/edit module + parity on both surfaces.** Extract the per-type item construction, secret-resolution, and interactive-edit logic into one CLI module that *both* the personal commands and the org commands call. This eliminates the existing personal↔org builder duplication and prevents the two surfaces from drifting again. `--*-stdin` is added to **both** surfaces (true parity), not org-only.
Rejected alternatives: (B) duplicate org-specific builders in `org.rs` — smaller blast radius but locks in two diverging builder sets, which is exactly the drift this milestone is paying down; (C) push builders into `relicario-core` — overkill, since prompt/stdin/storage logic does not belong in the bytes-in/bytes-out core.
### 1. Shared item-build module — `crates/relicario-cli/src/commands/item_build.rs`
- **`SecretSource` resolution**: a helper that resolves a secret field in priority order — explicit flag value → `--*-stdin` (read a single line, or multiline-to-EOF for key material / note body) → interactive `prompt_secret`/`prompt`. If a required secret has no flag, no stdin flag, and the process is non-interactive (no TTY), it errors clearly rather than hanging.
- **Builders** `build_login`, `build_secure_note`, `build_identity`, `build_card`, `build_key`, `build_totp` → return a fully-populated `Item` (no storage side effects).
- **`build_document`** → returns `(Item, EncryptedAttachment)` so each caller writes the encrypted blob with *its own* master key and *its own* path layout (personal: `vault.root()/attachments/<item-id>/…`; org: `attachments/<slug>/<item-id>/…`).
- **Shared per-type interactive edit helpers** — mutate a `&mut ItemCore` slice in place and record field history via the existing synthetic-`FieldId` scheme (`commands/edit.rs::push_history`), reused by both personal and org edit.
- Personal `add.rs` / `edit.rs` are refactored to call these helpers with **no behavior change** (existing personal tests stay green), then gain `--*-stdin` flags.
### 2. CLI surface — `org add`
Extend `OrgAddKind` (in `main.rs`) with `card` / `key` / `document` / `totp` subcommands mirroring the personal `AddKind` flags, plus the org-required `--collection` and the secret-stdin flags:
- `org add card --collection <s> --title <t> [--holder <h>] [--expiry YYYY-MM] [--kind credit|debit|gift|loyalty|other] [--number-stdin] [--cvv-stdin] [--pin-stdin]` — secrets prompted when a TTY is present and no `--*-stdin` flag is set.
- `org add key --collection <s> --title <t> [--label <l>] [--algorithm <a>] [--public-key <p>] [--material-stdin]`
- `org add totp --collection <s> --title <t> [--issuer <i>] [--label <l>] [--period 30] [--digits 6] [--algorithm sha1] [--secret <b32> | --secret-stdin]`
- `org add document --collection <s> --title <t> --file <path>` — no secret; file bytes encrypted with the org key and written to the collection-scoped attachment path.
Retrofit `org add login` to accept `--password` / `--password-stdin` (+ prompt fallback) so the existing type matches the new convention. All paths flow through the shared builders and are committed via the existing signed `org_git_run` path with the same `Relicario-*` trailers as today's `run_add`.
### 3. CLI surface — `org edit`
Restructure `run_edit` to dispatch per item type (mirroring personal `edit`): interactive "blank to keep" by default, with flag / `--*-stdin` overrides for scripts and tests. Field history is recorded with the same synthetic-key scheme as personal edit. Document edit accepts an optional `--file` that re-encrypts and replaces the primary attachment (re-points `DocumentCore.primary_attachment` + `AttachmentRef`, stages old + new paths). Grant + collection-existence checks are unchanged.
### 4. Org attachment storage + cap
- **Layout:** `attachments/<slug>/<item-id>/<att-id>.enc` — collection-scoped, mirroring `items/<slug>/<id>.enc`.
- **`org_session` methods:** `attachment_path`, `save_attachment`, `load_attachment`, `remove_item_attachments` (purge removes an item's attachment directory).
- **Cap:** a **default constant** in the CLI org path (mirroring the personal-vault `attachment_caps` default; the spec/code cites the source line per the code-constant-pinning rule). Per-org configurable caps are out of scope for v0.8.1.
### 5. Hook change — `relicario-server`
- Extend `classify_path` (`lib.rs`) to recognize `attachments/<slug>/<item-id>/<att-id>.enc` and classify it as `PathClass::Item { collection: slug }` — reusing the existing grant + slug-existence authorization for items. Apply the same defenses as the `items/` branch: exact segment count and a `.`-free slug guard (path-traversal defense).
- This converts attachment writes from `Unrestricted` to grant-scoped, closing the gap.
- **Version bump** for `relicario-server`; the release notes must call out a **coordinated server redeploy** (the deployed pre-receive hook must be rebuilt) — Document writes to a not-yet-upgraded server still succeed but remain `Unrestricted` until the hook is updated.
### 6. Tests (acceptance)
- `crates/relicario-cli/tests/org_items.rs`: non-interactive add → get → edit → rm round-trips for **Card, Key, Totp, Document** driven through the `--*-stdin` flags; secret masking verified in `org get` without `--show`; a grant-denied attachment-write case.
- `crates/relicario-server` lib tests: `classify_path("attachments/eng/<id>/<att>.enc") == Item { collection: "eng" }`; rejection cases for malformed attachment paths.
- Existing personal `add`/`edit` tests stay green after the shared-module refactor (behavior-preserving).
- Green across all crates (`cargo test`).
### 7. Living-docs updates (per CLAUDE.md discipline)
- `docs/FORMATS.md` — org attachment path layout + the default cap constant (cite source line).
- `crates/relicario-cli/ARCHITECTURE.md` — the shared `item_build` module + per-type org `add`/`edit`.
- `docs/SECURITY.md` — attachment writes are now grant-scoped (closing the `Unrestricted` gap).
- `STATUS.md` / `ROADMAP.md` / `CHANGELOG.md` — on release; mark org item-type parity landed, move Document/attachment + hook change to shipped.
- Extension docs untouched — extension org **writes** remain deferred (Plan B-2).
## Out of scope (v0.8.1)
- Extension org **writes** (`Plan B-2`).
- Per-collection subkeys, read audit, SSO/SAML/LDAP, HTTP management plane (phase 2).
- Per-org **configurable** attachment cap (a default constant ships now).
## Suggested execution decomposition (for the plan)
Four parallel dev streams; Dev-A is the dependency gate for B and C, Dev-D is fully independent:
| Stream | Scope | Depends on |
|---|---|---|
| **Dev-A** | Shared `item_build` module (SecretSource, builders, shared edit helpers); refactor personal `add`/`edit`; add `--*-stdin` to personal CLI | — (foundation) |
| **Dev-B** | Org `add`/`edit` parity for **Card / Key / Totp**; secret-stdin flags; field history; `org_items` tests | Dev-A module interface |
| **Dev-C** | Org **Document** + attachment storage (`org_session` methods, default cap, doc add/edit via `--file`); Document tests | Dev-A (`build_document`) |
| **Dev-D** | `relicario-server` hook: `classify_path` attachment grant-scoping; server tests; version bump | — (independent) |
## Open questions
None blocking. The cap value and the exact `--*-stdin` flag spellings are finalized in the plan against the personal-vault source.