Compare commits
38 Commits
feature/en
...
feature/v0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d8b23d421e | ||
|
|
6eb1275710 | ||
|
|
751e4e9bb1 | ||
|
|
65e23cfddc | ||
|
|
b83643ee0a | ||
|
|
154b984725 | ||
|
|
517d52d517 | ||
|
|
3774047298 | ||
|
|
f27dc72e96 | ||
|
|
b2f3739673 | ||
|
|
50b5c01291 | ||
|
|
3871da383d | ||
|
|
44d61ae7a7 | ||
|
|
0cd417ded7 | ||
|
|
8bb1d779c4 | ||
|
|
739279515a | ||
|
|
6123d8b033 | ||
|
|
057a7defe5 | ||
|
|
2acd57a4a5 | ||
|
|
87b1d166c2 | ||
|
|
6a16523ee0 | ||
|
|
519e503cbd | ||
|
|
cdb008c900 | ||
|
|
053062effd | ||
|
|
3b6dbbe353 | ||
|
|
558da3bd75 | ||
|
|
9c43f223f5 | ||
|
|
1c177871a7 | ||
|
|
1ad8eb0918 | ||
|
|
aace6f132a | ||
|
|
dbdb3f6ab0 | ||
|
|
7faedf8578 | ||
|
|
ccb58d8bb5 | ||
|
|
570b0ddcd3 | ||
|
|
7daedb33e0 | ||
|
|
17df315f0e | ||
|
|
2dd5d79f36 | ||
|
|
675b7836e1 |
49
CHANGELOG.md
49
CHANGELOG.md
@@ -1,5 +1,54 @@
|
||||
# Changelog
|
||||
|
||||
## v0.8.0 — 2026-06-20 — enterprise org vault
|
||||
|
||||
Git-native multi-user **org vaults**: a separate org git repository alongside each
|
||||
member's personal vault, with a 256-bit org master key ECIES-wrapped per member to
|
||||
their ed25519 device key, collection-scoped item storage, role-based access, and a
|
||||
signature-verifying pre-receive hook that makes least-privilege enforcement
|
||||
server-side. Tracked under `docs/superpowers/plans/2026-06-06-enterprise-org-vault.md`.
|
||||
|
||||
### Added
|
||||
- **relicario-core `org` module** (`crates/relicario-core/src/org.rs`): org types
|
||||
(`OrgId`, `MemberId`, `OrgRole`, `OrgMember`/`OrgMembers`, `CollectionDef`/
|
||||
`OrgCollections`, `OrgMeta`, `OrgManifest`/`OrgManifestEntry`) and ECIES X25519
|
||||
key wrap/unwrap (`generate_org_key`, `wrap_org_key`, `unwrap_org_key`) — ed25519→
|
||||
X25519 via RFC 7748 clamp, domain-separated `SHA-256(dh || eph_pk || rcpt_pk)` KDF,
|
||||
XChaCha20-Poly1305 inner cipher, all key material in `Zeroizing`. Adds
|
||||
`encrypt_org_manifest` / `decrypt_org_manifest` vault wrappers. New dependencies:
|
||||
`x25519-dalek 2` (`static_secrets`) in core, `ssh-key 0.6` in core and CLI.
|
||||
- **relicario-server org mode**: `verify-org-commit` (commit-signature verification
|
||||
against `members.json` ed25519 keys, path-scoped role/grant authorization,
|
||||
owner-only elevation judged on the signer's pre-commit role, schema-version
|
||||
monotonicity) and `generate-org-hook`; new `[lib]` target (`classify_path`,
|
||||
`extract_schema_version`). Audit trail on every push carries verified-signer
|
||||
attribution; commits whose signer cannot be matched are flagged `TAMPERED`.
|
||||
- **relicario-cli org admin commands**: `org init`, `add-member` / `remove-member` /
|
||||
`set-role` (owner-only escalation guard), `create-collection` / `grant` / `revoke`,
|
||||
`rotate-key` (re-encrypts every item blob + manifest under a fresh org key),
|
||||
`transfer-ownership`, `delete-org` (local tombstone; hook blocks pushing a
|
||||
protected-file deletion), `status` / `audit`. Org commits are signed
|
||||
(`org_git_run` preserves signing).
|
||||
- **relicario-cli org item CRUD**: `org add` (Login, SecureNote, Identity — each
|
||||
collection-scoped and grant-enforced), `org get <query> [--show]` (secrets masked
|
||||
by default; renders Login/SecureNote/Identity/Card/Document/Totp), `org list
|
||||
[--trashed]` (manifest filtered to your collection grants), `org edit <query>`
|
||||
(flag-driven field updates for login/note/identity fields), `org rm` / `org restore`
|
||||
/ `org purge` (soft-delete lifecycle). Audit actions emitted: `item-create`,
|
||||
`item-update`, `item-delete`, `item-restore`, `item-purge`.
|
||||
|
||||
### Deferred
|
||||
- `org add` / `org edit` parity for Card, SshKey, Document, and Totp item types
|
||||
(only Login, SecureNote, Identity supported today; `org get` and `org list` can
|
||||
display all types already present in the vault).
|
||||
- Extension org switch + read-only browse parity (Dev-D follow-up).
|
||||
- Extension org writes.
|
||||
- Phase-2 features: SSO/LDAP provisioning, read audit trail, per-collection subkeys
|
||||
(the current shared org master key scopes *writes* via the hook and *read access*
|
||||
via manifest filtering, but does not cryptographically isolate collections from one
|
||||
another — a member who obtains the org key can decrypt any blob), HTTP management
|
||||
plane.
|
||||
|
||||
## v0.7.0 — 2026-06-01
|
||||
|
||||
Completes the extension restructure (Plan C) begun under v0.6.0. Phases
|
||||
|
||||
9
Cargo.lock
generated
9
Cargo.lock
generated
@@ -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",
|
||||
|
||||
14
DESIGN.md
14
DESIGN.md
@@ -147,11 +147,25 @@ The threat model differs by codebase. This is the per-secret per-codebase reside
|
||||
| Master key | `Zeroizing<[u8;32]>` returned by `derive_master_key` | `UnlockedVault.master_key` for the lifetime of one CLI invocation | WASM-side memory behind an opaque `SessionHandle`; JS never sees the bytes | Never sees it |
|
||||
| Item secret (password, card number, etc.) | `Zeroizing<String>` / `Zeroizing<Vec<u8>>` | Same | Briefly held in WASM during `item_decrypt`; results passed to popup as plaintext for display | Held in DOM (the user is staring at it); cleared when view changes |
|
||||
| Device private key | — | Filesystem under `~/.config/relicario/devices/<name>.key` (mode 0600) | `chrome.storage.local.device_private_key` | — |
|
||||
| Org master key (256-bit, random) | `Zeroizing<[u8;32]>` during `wrap_org_key`/`unwrap_org_key` (never derived from a passphrase) | `UnlockedOrgVault.org_key` for one CLI invocation; recovered by unwrapping `keys/<member-id>.enc` with the device ed25519 seed | TODO (extension follow-up) | Never sees it |
|
||||
|
||||
The org master key is **never escrowed**: each member holds it ECIES-wrapped to their device key (`keys/<member-id>.enc`); an owner can always re-wrap it to a replacement device key, so there is no central key store to compromise. See `docs/CRYPTO.md` (Org-key ECIES wrap/unwrap) and `docs/FORMATS.md` (Org vault repo formats).
|
||||
|
||||
The popup / vault / content surfaces of the extension cannot decrypt an item independently — they all message the SW. Content scripts in particular get back already-prepared payloads (e.g. `{ username, password }`) from `fill_credentials` after the SW resolved everything.
|
||||
|
||||
The CLI keeps its master key in process memory; if the process exits or crashes, the key is gone (Zeroize on drop). There is no CLI session daemon. The `lock` subcommand exists only for UX parity with the extension and is a no-op.
|
||||
|
||||
## Org vault (enterprise, in progress)
|
||||
|
||||
The enterprise org vault is a **second git repository** alongside each member's personal vault, with its own schema (`org.json` / `members.json` / `collections.json` / `keys/<member-id>.enc` / `manifest.enc` / `items/<collection-slug>/<item-id>.enc`). It reuses the same `relicario-core` AEAD; the only new crypto is the per-member ECIES key wrap. Cross-codebase additions:
|
||||
|
||||
- **relicario-core** gains the `org` module (`org.rs`) and the `x25519-dalek = { version = "2", features = ["static_secrets"] }` dependency (`crates/relicario-core/Cargo.toml:19`); `ssh-key` 0.6 is already present (`:20`).
|
||||
- **relicario-cli** gains `org_session.rs` + `commands/org.rs` and the `ssh-key = "0.6"` dependency (`crates/relicario-cli/Cargo.toml:33`).
|
||||
- **relicario-server** gains an **org mode**: a new `[lib]` target (`classify_path`, `extract_schema_version`) plus the `verify-org-commit` and `generate-org-hook` subcommands — a signature-verifying, path-scoped pre-receive hook (see `docs/SECURITY.md`).
|
||||
- **extension** org switch + read parity is a tracked follow-up (Dev-D) — `TODO (extension follow-up)`.
|
||||
|
||||
Status: the backend is complete on `main` — core (A) org module, server hook (C), and the full CLI (all 19 `org` subcommands incl. item CRUD) are merged. Deferred: `org add`/`edit` parity for Card/Key/Document/Totp (Login/SecureNote/Identity ship today), and the extension org switch + read parity (`TODO (extension follow-up)`, Dev-D).
|
||||
|
||||
## Build matrix
|
||||
|
||||
| Target | Tool | Output | When to run |
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
| Version | Highlights |
|
||||
|---|---|
|
||||
| *(untagged, 2026-06-20)* | **Enterprise org vault — backend complete** (`7392795`): relicario-core `org` module (ECIES X25519 key wrap/unwrap, `OrgRole`/`OrgMember`/`OrgManifest` types, `filter_for_member`, `schema_version: 1`); relicario-server org hook (`verify-org-commit`: signature verification, path-scoped authz, `enforce_owner_only_elevation` on parent role, `enforce_schema_monotonicity`, `generate-org-hook`, new `[lib]` target); relicario-cli — all 19 `relicario org` subcommands: init, add-member/remove-member/set-role, create-collection/grant/revoke, rotate-key (re-encrypts all blobs), transfer-ownership, delete-org, status, audit, and item CRUD (add/get/list/edit/rm/restore/purge). **Not yet shipped:** `org add`/`edit` for Card/SshKey/Document/Totp; extension org parity (Dev-D); phase 2 (SSO/LDAP, read audit, per-collection subkeys, HTTP plane). |
|
||||
| v0.7.0 *(2026-06-01)* | Extension restructure (Plan C) complete — Phases 3/4/6 merged via 3 parallel worktree streams under PM coordination: setup wizard crypto migrated into the SW (`create_vault`/`attach_vault`; `setup.ts` 1230→58 LOC + step registry); `vault.ts` split 1037→194 LOC into 5 focused + 2 support modules; `vault_locked` intercept lifted into `shared/state.ts`; `get_vault_status` SW message + sidebar status indicator closing the last `relicario status` CLI/extension parity gap |
|
||||
| v0.6.0 *(2026-05-30)* | Security audit fixes; device authentication; backup/restore + LastPass import; fullscreen UX Phases 1+2A+2B; v0.5.1 Streams A/B/C (3-column vault layout + bottom-sheet picker + toast system; left-nav settings; Recovery QR end-to-end + setup wizard Style C); 1C-γ (attachments + Document type + device registration + trash + field history); Plan B multi-stream refactor (commands/ split, prompt_or_flag, core/WASM seam); vault-tab management surfaces revamp (settings synced/local split, devices fingerprint, trash purge countdown, field-history polish, item-history-index, `#history/<id>` routing); doc-structure redesign (rename to DESIGN/CRYPTO/docs/FORMATS, scope headers + Next: footers); GPL-3.0-or-later license |
|
||||
| v0.2.0 | Typed-item rewrite (Plans 1A/1B/1C-α/β₁/β₂) |
|
||||
@@ -15,14 +16,19 @@ See `CHANGELOG.md` for tagged-release detail and `STATUS.md` for the per-train c
|
||||
|
||||
## Up next
|
||||
|
||||
All three 2026-05-04 architecture-review specs are now shipped (CLI restructure = Plan B Cycles 1+2; security polish = Stream A Cycle 1; extension restructure = Plan C Phases 1–6, completed v0.7.0 2026-06-01). The next committed item is:
|
||||
All three 2026-05-04 architecture-review specs are shipped; enterprise org vault backend is shipped (2026-06-20). Pending items in rough priority order:
|
||||
|
||||
- **Org-vault item-type parity** — `org add`/`edit` support for Card, SshKey, Document, Totp (Login/SecureNote/Identity ship today)
|
||||
- **Extension org parity — read** — org switch + collection-filtered browse in the popup/vault tab (Dev-D, deferred)
|
||||
- **Extension org parity — write** — `org add`/`edit`/`rm` from the extension
|
||||
- **Phase 4: command palette** — ⌘K global search + action dispatch across the vault tab (no spec yet)
|
||||
|
||||
## Medium-term
|
||||
|
||||
_(promote here once specced)_
|
||||
|
||||
- **Org vault phase 2** — SSO/LDAP federation, read audit log, per-collection subkeys (true cryptographic scope separation per collection), HTTP management plane
|
||||
|
||||
## Long-term / backlog
|
||||
|
||||
- **Relay server** — encrypted WebSocket relay for multi-device sync without a shared git server
|
||||
|
||||
34
STATUS.md
34
STATUS.md
@@ -98,6 +98,30 @@ Plan: `docs/superpowers/plans/2026-05-24-vault-tab-management-surfaces-revamp.md
|
||||
- Item-history-index pane — top-level "items with history" list (`32e1632`)
|
||||
- Sidebar slot wiring + `#history/<id>` route with `#field-history/<id>` legacy normalization (`88d7228`)
|
||||
|
||||
### Enterprise org vault — core + server hook + CLI (merged 2026-06-20, `7392795`)
|
||||
|
||||
Spec: `docs/superpowers/specs/2026-06-06-relicario-enterprise-org-vault-design.md`; plan: `docs/superpowers/plans/2026-06-06-enterprise-org-vault.md`
|
||||
|
||||
**relicario-core org module** (`crates/relicario-core/src/org.rs`): `OrgId`, `MemberId`, `OrgRole` (Owner/Admin/Member), `OrgMember`, `OrgMembers`/`OrgCollections`/`OrgMeta`/`OrgManifest`/`OrgManifestEntry` (all `schema_version: 1`); `generate_org_key`; ECIES X25519 key wrap/unwrap (`wrap_org_key` / `unwrap_org_key`) — ed25519→X25519 conversion via `SHA-512(seed)[..32]` + RFC 7748 clamp, ephemeral DH, `SHA-256(dh_shared || ephemeral_pk || recipient_pk)` wrap key, inner cipher delegated to `crate::crypto::encrypt` (XChaCha20-Poly1305, no Argon2id in org path); `OrgManifest::filter_for_member` for collection-scoped manifest views. Vault wrappers: `encrypt_org_manifest` / `decrypt_org_manifest` in `vault.rs`. 5 acceptance tests in `crates/relicario-core/tests/org.rs` incl. wrap/unwrap round-trip, revoke-after-rotation, manifest filter, and an RFC 8032 ed25519→X25519 known-answer vector.
|
||||
|
||||
**relicario-server org hook** (`crates/relicario-server/src/{lib.rs,main.rs}`): pure `classify_path` / `extract_schema_version` in new `lib.rs` target; `verify_org_commit` — commit-signature verification against `members.json` ed25519 keys, path-scoped authorization (protected JSON → owner/admin only; `items/<slug>/…` → slug in signer's grants), `enforce_owner_only_elevation` (parent-role check; guards against privilege self-escalation), `enforce_schema_monotonicity` (schema_version must not decrease; merge commits rejected; genesis allowed); `generate-org-hook` subcommand emits a wrapper script. New `[lib]` target added to `relicario-server` crate.
|
||||
|
||||
**relicario-cli — all 19 `relicario org` subcommands** (`crates/relicario-cli/src/{org_session.rs,commands/org.rs,device.rs}`): `org_session.rs` provides `UnlockedOrgVault` (org key in `Zeroizing`), collection-scoped `item_path`, fingerprint-based member match, `atomic_write`, `org_git_run` (signed commits — does NOT suppress `commit.gpgsign`).
|
||||
|
||||
Admin/lifecycle commands: `init` (structure + wrap + `configure_git_signing` + signed bootstrap commit), `add-member` / `remove-member` / `set-role` (owner-only escalation guard), `create-collection` / `grant` / `revoke`, `rotate-key` (fresh key + re-wrap all members + re-encrypt every `items/<slug>/<id>.enc` blob + manifest, concurrent-rotation abort, `Relicario-Action: key-rotate`), `transfer-ownership`, `delete-org`, `status`, `audit` (verified-signer attribution + TAMPERED flag).
|
||||
|
||||
Item CRUD commands (B9–B14): `org add` (`OrgAddKind`: Login/SecureNote/Identity; card/key/document/totp deferred — see below), `org get <query> [--show]`, `org list [--trashed]`, `org edit <query> [--title/--username/…]`, `org rm`, `org restore`, `org purge`. All ops are collection-scoped + grant-enforced; audit trail emits `item-create` / `item-update` / `item-delete` / `item-restore` / `item-purge`.
|
||||
|
||||
**A5 doc-fix** (`enforce_owner_only_elevation` parent-role close, `519e503`) and this living-docs sweep also landed.
|
||||
|
||||
**Tracked follow-ups (deferred, not shipped):**
|
||||
- `org add` / `org edit` parity for Card, SshKey, Document, Totp item types (Login/SecureNote/Identity only today; `get`/`list` can display all types if present)
|
||||
- Extension org-vault switch + read parity (Dev-D deferred)
|
||||
- Extension org write operations
|
||||
- Phase 2: SSO/LDAP federation, read audit log, per-collection subkeys (true cryptographic scope separation), HTTP management plane
|
||||
|
||||
**Known limitations (by design in phase 1):** shared org master key — reads are not cryptographically scoped per collection (hook scopes writes; client filters manifest); no read audit (git records writes only); `delete-org` is a local tombstone only (hook rejects protected-file deletion on push).
|
||||
|
||||
### Extension restructure — Plan C Phases 3, 4, 6 (merged 2026-05-31 → 06-01, v0.7.0)
|
||||
|
||||
Spec: `docs/superpowers/specs/2026-05-04-extension-restructure-design.md`
|
||||
@@ -143,6 +167,12 @@ Per the 2026-05-30 post-v0.6.0 audit of the three 2026-05-04 architecture-review
|
||||
- **Security polish** (`2026-05-04-security-polish-design.md`) — *already shipped* as Stream A Cycle 1 (`89090a8`) plus follow-ups (`0c9387f` start.sh fourth window, `229e483` recovery_qr.rs docs). All four phases done.
|
||||
- **Extension restructure** (`2026-05-04-extension-restructure-design.md`, plan `docs/superpowers/plans/2026-05-30-extension-restructure.md`) — ✅ **COMPLETE** (all six phases merged; see the dated landing section above). Phases 1/2/5 merged 2026-05-30; Phases 3/4/6 merged 2026-05-31 → 06-01. Final tree: 423/423 vitest, build:all clean. v0.7.0 versions bumped; tag pending.
|
||||
|
||||
Beyond extension restructure, ROADMAP medium-term holds Phase 4 command palette (no spec yet). Long-term: relay server, mobile.
|
||||
**Enterprise org vault** — ✅ **COMPLETE (backend)** — all 19 CLI subcommands + core + server hook merged `7392795` 2026-06-20. Deferred follow-ups tracked in the landing section above.
|
||||
|
||||
See `ROADMAP.md` for the longer arc and `CHANGELOG.md` for tagged-release history (current head: `v0.6.0`; the `v0.7.0` entry covers this extension-restructure completion).
|
||||
Pending org-vault follow-ups (in rough priority order):
|
||||
- `org add`/`edit` parity for Card, SshKey, Document, Totp
|
||||
- Extension org switch + read parity (Dev-D)
|
||||
- Extension org write operations
|
||||
- **Phase 4: command palette** — ⌘K global search + action dispatch across the vault tab (no spec yet)
|
||||
|
||||
Long-term: relay server, mobile. See `ROADMAP.md` for the longer arc and `CHANGELOG.md` for tagged-release history (current head: `v0.6.0`; the `v0.7.0` entry covers extension-restructure completion).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
}
|
||||
|
||||
318
crates/relicario-cli/src/commands/item_build.rs
Normal file
318
crates/relicario-cli/src/commands/item_build.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
1235
crates/relicario-cli/src/commands/org.rs
Normal file
1235
crates/relicario-cli/src/commands/org.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -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"),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
305
crates/relicario-cli/src/org_session.rs
Normal file
305
crates/relicario-cli/src/org_session.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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())?;
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
|
||||
215
crates/relicario-cli/tests/org_authz.rs
Normal file
215
crates/relicario-cli/tests/org_authz.rs
Normal file
@@ -0,0 +1,215 @@
|
||||
//! Authorization regression tests for the `relicario org` item commands.
|
||||
//!
|
||||
//! These cover two gaps the B9–B14 item-CRUD work left open:
|
||||
//! 1. Grant-DENIAL on the read/mutate-by-query commands (`get`, `edit`, `rm`,
|
||||
//! `restore`, `purge`). Only `add` had a denial test before this. An
|
||||
//! ungranted member must be rejected by EVERY one of them, and `get` must
|
||||
//! not leak the item's plaintext.
|
||||
//! 2. SecureNote body masking on `org get`, mirroring the Login-password
|
||||
//! masking already asserted in `org_items.rs`.
|
||||
//!
|
||||
//! The multi-member harness mirrors `org_lifecycle.rs`'s `Dev` pattern: each
|
||||
//! `Dev` is an isolated XDG config home carrying its own ed25519 device key, so
|
||||
//! a second member can be added with their OWN keypair and then attempt commands
|
||||
//! against the shared vault.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
use tempfile::TempDir;
|
||||
|
||||
/// A device home (its own XDG config + ed25519 signing key). One `Dev` is the
|
||||
/// owner; a second `Dev` plays the ungranted member.
|
||||
struct Dev {
|
||||
xdg: PathBuf,
|
||||
_config: TempDir,
|
||||
}
|
||||
|
||||
impl Dev {
|
||||
/// Generate an OpenSSH ed25519 signing key for `name` and mark it current.
|
||||
fn new(name: &str) -> Self {
|
||||
let config = TempDir::new().unwrap();
|
||||
let xdg = config.path().to_path_buf();
|
||||
let devices = xdg.join("relicario").join("devices").join(name);
|
||||
std::fs::create_dir_all(&devices).unwrap();
|
||||
let keyfile = devices.join("signing.key");
|
||||
let st = Command::new("ssh-keygen")
|
||||
.args(["-t", "ed25519", "-N", "", "-C", "relicario-test", "-f"])
|
||||
.arg(&keyfile)
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.status()
|
||||
.expect("ssh-keygen");
|
||||
assert!(st.success(), "ssh-keygen failed");
|
||||
std::fs::rename(devices.join("signing.key.pub"), devices.join("signing.pub")).unwrap();
|
||||
std::fs::write(
|
||||
xdg.join("relicario").join("devices").join("current"),
|
||||
format!("{name}\n"),
|
||||
)
|
||||
.unwrap();
|
||||
Dev { xdg, _config: config }
|
||||
}
|
||||
|
||||
/// The OpenSSH public key string for one of this device's keys.
|
||||
fn pubkey(&self, name: &str) -> String {
|
||||
std::fs::read_to_string(
|
||||
self.xdg.join("relicario").join("devices").join(name).join("signing.pub"),
|
||||
)
|
||||
.unwrap()
|
||||
.trim()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Run `relicario <args>` against `vault` with this device active.
|
||||
fn run(&self, vault: &Path, args: &[&str]) -> std::process::Output {
|
||||
let mut cmd = Command::cargo_bin("relicario").unwrap();
|
||||
cmd.env("XDG_CONFIG_HOME", &self.xdg)
|
||||
.env("RELICARIO_ORG_DIR", vault)
|
||||
.args(args)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
cmd.output().unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
fn owner_member_id(vault: &Path) -> String {
|
||||
let s = std::fs::read_to_string(vault.join("members.json")).unwrap();
|
||||
let v: serde_json::Value = serde_json::from_str(&s).unwrap();
|
||||
v["members"][0]["member_id"].as_str().unwrap().to_string()
|
||||
}
|
||||
|
||||
/// Look up a member's id by display name (used to find a freshly added member).
|
||||
fn member_id_by_name(vault: &Path, name: &str) -> String {
|
||||
let s = std::fs::read_to_string(vault.join("members.json")).unwrap();
|
||||
let v: serde_json::Value = serde_json::from_str(&s).unwrap();
|
||||
v["members"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|m| m["display_name"] == name)
|
||||
.unwrap_or_else(|| panic!("member `{name}` not found in members.json"))
|
||||
["member_id"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
use assert_cmd::cargo::CommandCargoExt as _;
|
||||
|
||||
#[test]
|
||||
fn org_get_edit_rm_restore_purge_reject_ungranted_member() {
|
||||
// Owner inits an org, creates `prod`, grants ONLY the owner, and adds an
|
||||
// item into `prod`.
|
||||
let owner_dev = Dev::new("owner-laptop");
|
||||
let vault_tmp = TempDir::new().unwrap();
|
||||
let vault = vault_tmp.path();
|
||||
|
||||
assert!(owner_dev
|
||||
.run(vault, &["org", "init", "--dir", vault.to_str().unwrap(), "--name", "Acme"])
|
||||
.status
|
||||
.success());
|
||||
let owner = owner_member_id(vault);
|
||||
assert!(owner_dev.run(vault, &["org", "create-collection", "prod", "--name", "Prod"]).status.success());
|
||||
assert!(owner_dev.run(vault, &["org", "grant", &owner, "prod"]).status.success());
|
||||
assert!(owner_dev
|
||||
.run(vault, &[
|
||||
"org", "add", "login", "--collection", "prod",
|
||||
"--title", "GitHub", "--username", "alice", "--password", "hunter2",
|
||||
])
|
||||
.status
|
||||
.success());
|
||||
|
||||
// A SECOND member joins with their OWN device key but is NOT granted `prod`.
|
||||
let other_dev = Dev::new("other-laptop");
|
||||
let other_pub = other_dev.pubkey("other-laptop");
|
||||
assert!(owner_dev
|
||||
.run(vault, &["org", "add-member", "--key", &other_pub, "--name", "Mallory", "--role", "member"])
|
||||
.status
|
||||
.success());
|
||||
// Sanity: the member exists but holds no collection grants.
|
||||
let mallory = member_id_by_name(vault, "Mallory");
|
||||
assert!(!mallory.is_empty());
|
||||
|
||||
// EVERY read/mutate-by-query command must be rejected for the ungranted
|
||||
// member, and `get` must NOT print the plaintext password.
|
||||
let get = other_dev.run(vault, &["org", "get", "GitHub"]);
|
||||
let get_out = String::from_utf8_lossy(&get.stdout).to_string();
|
||||
let get_err = String::from_utf8_lossy(&get.stderr).to_string();
|
||||
assert!(!get.status.success(), "get must be rejected for ungranted member: {get_out}{get_err}");
|
||||
assert!(!get_out.contains("hunter2"), "get leaked plaintext to ungranted member: {get_out}");
|
||||
assert!(!get_out.contains("alice"), "get leaked username to ungranted member: {get_out}");
|
||||
assert!(
|
||||
get_err.contains("no item matches") || get_err.contains("access denied"),
|
||||
"get error should be denial / not-found: {get_err}"
|
||||
);
|
||||
|
||||
// get --show must ALSO be denied and reveal nothing.
|
||||
let get_show = other_dev.run(vault, &["org", "get", "GitHub", "--show"]);
|
||||
assert!(!get_show.status.success(), "get --show must be rejected for ungranted member");
|
||||
assert!(
|
||||
!String::from_utf8_lossy(&get_show.stdout).contains("hunter2"),
|
||||
"get --show leaked plaintext to ungranted member"
|
||||
);
|
||||
|
||||
for (label, args) in [
|
||||
("edit", vec!["org", "edit", "GitHub", "--username", "evil"]),
|
||||
("rm", vec!["org", "rm", "GitHub"]),
|
||||
("restore", vec!["org", "restore", "GitHub"]),
|
||||
("purge", vec!["org", "purge", "GitHub"]),
|
||||
] {
|
||||
let out = other_dev.run(vault, &args);
|
||||
let stderr = String::from_utf8_lossy(&out.stderr).to_string();
|
||||
assert!(
|
||||
!out.status.success(),
|
||||
"`org {label}` must be rejected for ungranted member; stderr: {stderr}"
|
||||
);
|
||||
assert!(
|
||||
stderr.contains("no item matches") || stderr.contains("access denied"),
|
||||
"`org {label}` error should be denial / not-found: {stderr}"
|
||||
);
|
||||
}
|
||||
|
||||
// The item is untouched: the owner can still read the original password and
|
||||
// the username was NOT changed to the ungranted member's "evil" attempt.
|
||||
let owner_get = owner_dev.run(vault, &["org", "get", "GitHub", "--show"]);
|
||||
let owner_out = String::from_utf8_lossy(&owner_get.stdout).to_string();
|
||||
assert!(owner_get.status.success(), "owner should still read the item");
|
||||
assert!(owner_out.contains("hunter2"), "owner read must still show original password: {owner_out}");
|
||||
assert!(owner_out.contains("alice"), "edit by ungranted member must not have changed username: {owner_out}");
|
||||
assert!(!owner_out.contains("evil"), "ungranted edit leaked through: {owner_out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn org_get_masks_secure_note_body_until_show() {
|
||||
let owner_dev = Dev::new("owner-laptop");
|
||||
let vault_tmp = TempDir::new().unwrap();
|
||||
let vault = vault_tmp.path();
|
||||
|
||||
assert!(owner_dev
|
||||
.run(vault, &["org", "init", "--dir", vault.to_str().unwrap(), "--name", "Acme"])
|
||||
.status
|
||||
.success());
|
||||
let owner = owner_member_id(vault);
|
||||
assert!(owner_dev.run(vault, &["org", "create-collection", "prod", "--name", "Prod"]).status.success());
|
||||
assert!(owner_dev.run(vault, &["org", "grant", &owner, "prod"]).status.success());
|
||||
assert!(owner_dev
|
||||
.run(vault, &[
|
||||
"org", "add", "secure-note", "--collection", "prod",
|
||||
"--title", "Recovery", "--body", "super-secret-body",
|
||||
])
|
||||
.status
|
||||
.success());
|
||||
|
||||
// Default get masks the body and never prints the plaintext.
|
||||
let masked = owner_dev.run(vault, &["org", "get", "Recovery"]);
|
||||
assert!(masked.status.success(), "get: {}", String::from_utf8_lossy(&masked.stderr));
|
||||
let masked_out = String::from_utf8_lossy(&masked.stdout).to_string();
|
||||
assert!(masked_out.contains("********"), "expected masked body: {masked_out}");
|
||||
assert!(!masked_out.contains("super-secret-body"), "masked get leaked the body: {masked_out}");
|
||||
|
||||
// get --show reveals the body.
|
||||
let shown = owner_dev.run(vault, &["org", "get", "Recovery", "--show"]);
|
||||
assert!(shown.status.success(), "get --show: {}", String::from_utf8_lossy(&shown.stderr));
|
||||
let shown_out = String::from_utf8_lossy(&shown.stdout).to_string();
|
||||
assert!(shown_out.contains("super-secret-body"), "expected plaintext body with --show: {shown_out}");
|
||||
}
|
||||
24
crates/relicario-cli/tests/org_init.rs
Normal file
24
crates/relicario-cli/tests/org_init.rs
Normal 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());
|
||||
}
|
||||
149
crates/relicario-cli/tests/org_init_signing.rs
Normal file
149
crates/relicario-cli/tests/org_init_signing.rs
Normal 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}"
|
||||
);
|
||||
}
|
||||
217
crates/relicario-cli/tests/org_items.rs
Normal file
217
crates/relicario-cli/tests/org_items.rs
Normal file
@@ -0,0 +1,217 @@
|
||||
use assert_cmd::cargo::CommandCargoExt as _;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
use tempfile::TempDir;
|
||||
|
||||
/// A throwaway org vault with a device signing key wired via XDG_CONFIG_HOME.
|
||||
struct OrgFixture {
|
||||
_config: TempDir,
|
||||
vault: TempDir,
|
||||
xdg: PathBuf,
|
||||
}
|
||||
|
||||
impl OrgFixture {
|
||||
/// Generate an ed25519 signing key in OpenSSH format using ssh-keygen and
|
||||
/// register it as the current device, then `org init`.
|
||||
fn new() -> Self {
|
||||
let config = TempDir::new().unwrap();
|
||||
let xdg = config.path().to_path_buf();
|
||||
let devices = xdg.join("relicario").join("devices").join("laptop");
|
||||
std::fs::create_dir_all(&devices).unwrap();
|
||||
|
||||
// Generate an OpenSSH ed25519 keypair without a passphrase.
|
||||
let keyfile = devices.join("signing.key");
|
||||
let status = Command::new("ssh-keygen")
|
||||
.args(["-t", "ed25519", "-N", "", "-C", "relicario-test", "-f"])
|
||||
.arg(&keyfile)
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.status()
|
||||
.expect("ssh-keygen");
|
||||
assert!(status.success(), "ssh-keygen failed");
|
||||
// ssh-keygen writes signing.key + signing.key.pub; rename the .pub to signing.pub.
|
||||
std::fs::rename(devices.join("signing.key.pub"), devices.join("signing.pub")).unwrap();
|
||||
// Mark this device current.
|
||||
std::fs::write(
|
||||
xdg.join("relicario").join("devices").join("current"),
|
||||
"laptop\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let vault = TempDir::new().unwrap();
|
||||
let f = OrgFixture { _config: config, vault, xdg };
|
||||
|
||||
let out = f.run(&["org", "init", "--dir", f.vault_str(), "--name", "Acme"]);
|
||||
assert!(out.status.success(), "org init failed: {}", String::from_utf8_lossy(&out.stderr));
|
||||
f
|
||||
}
|
||||
|
||||
fn vault_path(&self) -> &Path { self.vault.path() }
|
||||
fn vault_str(&self) -> &str { self.vault.path().to_str().unwrap() }
|
||||
|
||||
fn run(&self, args: &[&str]) -> std::process::Output {
|
||||
let mut cmd = Command::cargo_bin("relicario").unwrap();
|
||||
cmd.env("XDG_CONFIG_HOME", &self.xdg)
|
||||
.env("RELICARIO_ORG_DIR", self.vault.path())
|
||||
.args(args)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
cmd.output().unwrap()
|
||||
}
|
||||
|
||||
/// Owner member id printed by `org init`/`org status`. We read it from
|
||||
/// members.json directly to avoid parsing stdout.
|
||||
fn owner_member_id(&self) -> String {
|
||||
let s = std::fs::read_to_string(self.vault.path().join("members.json")).unwrap();
|
||||
let v: serde_json::Value = serde_json::from_str(&s).unwrap();
|
||||
v["members"][0]["member_id"].as_str().unwrap().to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn org_add_get_list_round_trip() {
|
||||
let f = OrgFixture::new();
|
||||
let owner = f.owner_member_id();
|
||||
|
||||
// Create a collection and grant the owner access to it.
|
||||
let out = f.run(&["org", "create-collection", "prod", "--name", "Production"]);
|
||||
assert!(out.status.success(), "create-collection: {}", String::from_utf8_lossy(&out.stderr));
|
||||
let out = f.run(&["org", "grant", &owner, "prod"]);
|
||||
assert!(out.status.success(), "grant: {}", String::from_utf8_lossy(&out.stderr));
|
||||
|
||||
// Add a login into the prod collection.
|
||||
let out = f.run(&[
|
||||
"org", "add", "login", "--collection", "prod",
|
||||
"--title", "GitHub", "--username", "alice",
|
||||
"--url", "https://github.com", "--password", "hunter2",
|
||||
]);
|
||||
assert!(out.status.success(), "org add: {}", String::from_utf8_lossy(&out.stderr));
|
||||
|
||||
// The blob must live under items/prod/, NOT flat items/.
|
||||
let prod_dir = f.vault_path().join("items").join("prod");
|
||||
let blobs: Vec<_> = std::fs::read_dir(&prod_dir).unwrap().collect();
|
||||
assert_eq!(blobs.len(), 1, "expected one blob under items/prod/");
|
||||
|
||||
// list shows it.
|
||||
let out = f.run(&["org", "list"]);
|
||||
let stdout = String::from_utf8_lossy(&out.stdout).to_string();
|
||||
assert!(stdout.contains("GitHub"), "list missing GitHub: {stdout}");
|
||||
|
||||
// get masks by default.
|
||||
let out = f.run(&["org", "get", "GitHub"]);
|
||||
let stdout = String::from_utf8_lossy(&out.stdout).to_string();
|
||||
assert!(stdout.contains("********"), "expected masked secret: {stdout}");
|
||||
assert!(!stdout.contains("hunter2"), "leaked plaintext: {stdout}");
|
||||
|
||||
// get --show reveals.
|
||||
let out = f.run(&["org", "get", "GitHub", "--show"]);
|
||||
let stdout = String::from_utf8_lossy(&out.stdout).to_string();
|
||||
assert!(stdout.contains("hunter2"), "expected plaintext with --show: {stdout}");
|
||||
|
||||
// The commit trailer records the action + collection + item.
|
||||
let log = Command::new("git")
|
||||
.args(["-C", f.vault_str(), "log", "-1", "--format=%B"])
|
||||
.output()
|
||||
.unwrap();
|
||||
let body = String::from_utf8_lossy(&log.stdout).to_string();
|
||||
assert!(body.contains("Relicario-Action: item-create"), "missing action trailer: {body}");
|
||||
assert!(body.contains("Relicario-Collection: prod"), "missing collection trailer: {body}");
|
||||
assert!(body.contains("Relicario-Item: "), "missing item trailer: {body}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn org_add_rejects_ungranted_collection() {
|
||||
let f = OrgFixture::new();
|
||||
// Create the collection but do NOT grant the owner.
|
||||
let out = f.run(&["org", "create-collection", "secret", "--name", "Secret"]);
|
||||
assert!(out.status.success(), "create-collection: {}", String::from_utf8_lossy(&out.stderr));
|
||||
|
||||
let out = f.run(&[
|
||||
"org", "add", "login", "--collection", "secret",
|
||||
"--title", "X", "--username", "u", "--password", "p",
|
||||
]);
|
||||
assert!(!out.status.success(), "add into ungranted collection must fail");
|
||||
let stderr = String::from_utf8_lossy(&out.stderr).to_string();
|
||||
assert!(stderr.contains("access denied") || stderr.contains("grant"), "unexpected error: {stderr}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn org_add_rejects_unknown_collection() {
|
||||
let f = OrgFixture::new();
|
||||
let out = f.run(&[
|
||||
"org", "add", "login", "--collection", "ghost",
|
||||
"--title", "X", "--username", "u", "--password", "p",
|
||||
]);
|
||||
assert!(!out.status.success(), "add into nonexistent collection must fail");
|
||||
let stderr = String::from_utf8_lossy(&out.stderr).to_string();
|
||||
assert!(stderr.contains("does not exist") || stderr.contains("ghost"), "unexpected error: {stderr}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn org_edit_updates_fields_and_commits_update_trailer() {
|
||||
let f = OrgFixture::new();
|
||||
let owner = f.owner_member_id();
|
||||
assert!(f.run(&["org", "create-collection", "prod", "--name", "Production"]).status.success());
|
||||
assert!(f.run(&["org", "grant", &owner, "prod"]).status.success());
|
||||
assert!(f.run(&[
|
||||
"org", "add", "login", "--collection", "prod",
|
||||
"--title", "Mail", "--username", "old", "--password", "pw",
|
||||
]).status.success());
|
||||
|
||||
// Edit the username.
|
||||
let out = f.run(&[
|
||||
"org", "edit", "Mail", "--username", "new-user",
|
||||
]);
|
||||
assert!(out.status.success(), "org edit: {}", String::from_utf8_lossy(&out.stderr));
|
||||
|
||||
// get --show reflects the new username.
|
||||
let out = f.run(&["org", "get", "Mail", "--show"]);
|
||||
let stdout = String::from_utf8_lossy(&out.stdout).to_string();
|
||||
assert!(stdout.contains("new-user"), "edit did not take: {stdout}");
|
||||
|
||||
let log = Command::new("git")
|
||||
.args(["-C", f.vault_str(), "log", "-1", "--format=%B"])
|
||||
.output().unwrap();
|
||||
let body = String::from_utf8_lossy(&log.stdout).to_string();
|
||||
assert!(body.contains("Relicario-Action: item-update"), "missing update trailer: {body}");
|
||||
assert!(body.contains("Relicario-Collection: prod"), "missing collection trailer: {body}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn org_rm_restore_purge_cycle() {
|
||||
let f = OrgFixture::new();
|
||||
let owner = f.owner_member_id();
|
||||
assert!(f.run(&["org", "create-collection", "prod", "--name", "Production"]).status.success());
|
||||
assert!(f.run(&["org", "grant", &owner, "prod"]).status.success());
|
||||
assert!(f.run(&[
|
||||
"org", "add", "secure-note", "--collection", "prod",
|
||||
"--title", "Recovery", "--body", "codes-here",
|
||||
]).status.success());
|
||||
|
||||
// rm → appears only with --trashed.
|
||||
assert!(f.run(&["org", "rm", "Recovery"]).status.success());
|
||||
let listed = String::from_utf8_lossy(&f.run(&["org", "list"]).stdout).to_string();
|
||||
assert!(!listed.contains("Recovery"), "trashed item still in default list: {listed}");
|
||||
let trashed = String::from_utf8_lossy(&f.run(&["org", "list", "--trashed"]).stdout).to_string();
|
||||
assert!(trashed.contains("Recovery"), "trashed item not in --trashed list: {trashed}");
|
||||
|
||||
// restore → back in default list.
|
||||
assert!(f.run(&["org", "restore", "Recovery"]).status.success());
|
||||
let listed = String::from_utf8_lossy(&f.run(&["org", "list"]).stdout).to_string();
|
||||
assert!(listed.contains("Recovery"), "restore did not bring it back: {listed}");
|
||||
|
||||
// purge → blob gone, entry gone, item-purge trailer.
|
||||
assert!(f.run(&["org", "purge", "Recovery"]).status.success());
|
||||
let prod_dir = f.vault_path().join("items").join("prod");
|
||||
let count = std::fs::read_dir(&prod_dir).map(|d| d.count()).unwrap_or(0);
|
||||
assert_eq!(count, 0, "blob not purged from items/prod/");
|
||||
let listed = String::from_utf8_lossy(&f.run(&["org", "list", "--trashed"]).stdout).to_string();
|
||||
assert!(!listed.contains("Recovery"), "purged item still listed: {listed}");
|
||||
|
||||
let log = Command::new("git")
|
||||
.args(["-C", f.vault_str(), "log", "-1", "--format=%B"])
|
||||
.output().unwrap();
|
||||
let body = String::from_utf8_lossy(&log.stdout).to_string();
|
||||
assert!(body.contains("Relicario-Action: item-purge"), "missing purge trailer: {body}");
|
||||
}
|
||||
206
crates/relicario-cli/tests/org_lifecycle.rs
Normal file
206
crates/relicario-cli/tests/org_lifecycle.rs
Normal file
@@ -0,0 +1,206 @@
|
||||
use assert_cmd::cargo::CommandCargoExt as _;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
use tempfile::TempDir;
|
||||
|
||||
/// A device home + an org vault. A second device can be wired for multi-member.
|
||||
struct Dev {
|
||||
xdg: PathBuf,
|
||||
_config: TempDir,
|
||||
}
|
||||
|
||||
impl Dev {
|
||||
fn new(name: &str) -> Self {
|
||||
let config = TempDir::new().unwrap();
|
||||
let xdg = config.path().to_path_buf();
|
||||
let devices = xdg.join("relicario").join("devices").join(name);
|
||||
std::fs::create_dir_all(&devices).unwrap();
|
||||
let keyfile = devices.join("signing.key");
|
||||
let st = Command::new("ssh-keygen")
|
||||
.args(["-t", "ed25519", "-N", "", "-C", "relicario-test", "-f"])
|
||||
.arg(&keyfile)
|
||||
.stdout(Stdio::null()).stderr(Stdio::null())
|
||||
.status().expect("ssh-keygen");
|
||||
assert!(st.success());
|
||||
std::fs::rename(devices.join("signing.key.pub"), devices.join("signing.pub")).unwrap();
|
||||
std::fs::write(xdg.join("relicario").join("devices").join("current"), format!("{name}\n")).unwrap();
|
||||
Dev { xdg, _config: config }
|
||||
}
|
||||
|
||||
fn pubkey(&self, name: &str) -> String {
|
||||
std::fs::read_to_string(
|
||||
self.xdg.join("relicario").join("devices").join(name).join("signing.pub"),
|
||||
).unwrap().trim().to_string()
|
||||
}
|
||||
|
||||
fn run(&self, vault: &Path, args: &[&str]) -> std::process::Output {
|
||||
let mut cmd = Command::cargo_bin("relicario").unwrap();
|
||||
cmd.env("XDG_CONFIG_HOME", &self.xdg)
|
||||
.env("RELICARIO_ORG_DIR", vault)
|
||||
.args(args)
|
||||
.stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped());
|
||||
cmd.output().unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
fn owner_member_id(vault: &Path) -> String {
|
||||
let s = std::fs::read_to_string(vault.join("members.json")).unwrap();
|
||||
let v: serde_json::Value = serde_json::from_str(&s).unwrap();
|
||||
v["members"][0]["member_id"].as_str().unwrap().to_string()
|
||||
}
|
||||
|
||||
/// Set up an org with the owner granted `prod` and one login item in it.
|
||||
fn setup_with_item() -> (Dev, TempDir, String) {
|
||||
let dev = Dev::new("laptop");
|
||||
let vault = TempDir::new().unwrap();
|
||||
let v = vault.path();
|
||||
assert!(dev.run(v, &["org", "init", "--dir", v.to_str().unwrap(), "--name", "Acme"]).status.success());
|
||||
let owner = owner_member_id(v);
|
||||
assert!(dev.run(v, &["org", "create-collection", "prod", "--name", "Prod"]).status.success());
|
||||
assert!(dev.run(v, &["org", "grant", &owner, "prod"]).status.success());
|
||||
assert!(dev.run(v, &[
|
||||
"org", "add", "login", "--collection", "prod",
|
||||
"--title", "GitHub", "--username", "alice", "--password", "hunter2",
|
||||
]).status.success());
|
||||
(dev, vault, owner)
|
||||
}
|
||||
|
||||
// (b) audit --format json parses + has expected actions.
|
||||
#[test]
|
||||
fn audit_format_json_is_valid_and_has_actions() {
|
||||
let (dev, vault, _owner) = setup_with_item();
|
||||
let out = dev.run(vault.path(), &["org", "audit", "--format", "json"]);
|
||||
assert!(out.status.success(), "audit json: {}", String::from_utf8_lossy(&out.stderr));
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
let events: serde_json::Value = serde_json::from_str(&stdout).expect("audit json must parse");
|
||||
let arr = events.as_array().expect("array");
|
||||
let actions: Vec<&str> = arr.iter()
|
||||
.filter_map(|e| e["action"].as_str())
|
||||
.collect();
|
||||
assert!(actions.contains(&"org-init"), "actions: {actions:?}");
|
||||
assert!(actions.contains(&"collection-create"), "actions: {actions:?}");
|
||||
assert!(actions.contains(&"item-create"), "actions: {actions:?}");
|
||||
// Honest signer attribution: none of these should be TAMPERED (signer == trailer).
|
||||
assert!(arr.iter().all(|e| e["tampered"] == serde_json::Value::Bool(false)));
|
||||
}
|
||||
|
||||
// (a) a forged-trailer commit is flagged TAMPERED.
|
||||
#[test]
|
||||
fn forged_trailer_commit_is_flagged_tampered() {
|
||||
let (dev, vault, owner) = setup_with_item();
|
||||
let v = vault.path();
|
||||
|
||||
// Hand-craft a SIGNED commit whose trailer CLAIMS a different actor id than
|
||||
// the real signer. We reuse the org repo's own signing config (set by
|
||||
// `org init`), so the commit verifies — but the trailer lies.
|
||||
std::fs::write(v.join("decoy.txt"), "x").unwrap();
|
||||
let git = |args: &[&str]| {
|
||||
Command::new("git").current_dir(v).args(args)
|
||||
.env("XDG_CONFIG_HOME", &dev.xdg)
|
||||
.output().unwrap()
|
||||
};
|
||||
assert!(git(&["add", "decoy.txt"]).status.success());
|
||||
let forged_msg = format!(
|
||||
"forged\n\nRelicario-Actor: impostor ffffffffffffffff\nRelicario-Action: item-update\nRelicario-Member: {owner}"
|
||||
);
|
||||
// commit -S uses the repo's configured signing key (the real owner key).
|
||||
let c = git(&["commit", "-S", "-m", &forged_msg]);
|
||||
assert!(c.status.success(), "forged commit: {}", String::from_utf8_lossy(&c.stderr));
|
||||
|
||||
let out = dev.run(v, &["org", "audit", "--format", "json"]);
|
||||
let events: serde_json::Value =
|
||||
serde_json::from_str(&String::from_utf8_lossy(&out.stdout)).unwrap();
|
||||
let forged = events.as_array().unwrap().iter()
|
||||
.find(|e| e["action"] == "item-update")
|
||||
.expect("forged item-update event present");
|
||||
// Trailer claims ffff... but the verified signer is the owner → TAMPERED.
|
||||
assert_eq!(forged["tampered"], serde_json::Value::Bool(true));
|
||||
assert_eq!(forged["actor_id"].as_str(), Some(owner.as_str()));
|
||||
}
|
||||
|
||||
// (c) concurrent rotate-key aborts with the exact spec error string.
|
||||
#[test]
|
||||
fn concurrent_rotate_key_aborts_with_spec_string() {
|
||||
let (dev, vault, _owner) = setup_with_item();
|
||||
let origin = TempDir::new().unwrap();
|
||||
let v = vault.path();
|
||||
let git = |args: &[&str]| Command::new("git").current_dir(v).args(args)
|
||||
.env("XDG_CONFIG_HOME", &dev.xdg).output().unwrap();
|
||||
|
||||
// Make a bare origin and push, so a divergent upstream can be simulated.
|
||||
assert!(Command::new("git").args(["init", "--bare", origin.path().to_str().unwrap()])
|
||||
.output().unwrap().status.success());
|
||||
assert!(git(&["remote", "add", "origin", origin.path().to_str().unwrap()]).status.success());
|
||||
assert!(git(&["push", "-u", "origin", "HEAD"]).status.success());
|
||||
|
||||
// Diverge upstream: a second clone commits + pushes, writing to a SHARED file
|
||||
// so that `git pull --rebase` will hit a merge conflict (add/add or edit/edit)
|
||||
// and exit non-zero — which is how run_rotate_key detects a concurrent rotation.
|
||||
let clone2 = TempDir::new().unwrap();
|
||||
assert!(Command::new("git")
|
||||
.args(["clone", origin.path().to_str().unwrap(), clone2.path().to_str().unwrap()])
|
||||
.output().unwrap().status.success());
|
||||
std::fs::write(clone2.path().join("conflict.txt"), "upstream-version").unwrap();
|
||||
for a in [&["add", "conflict.txt"][..], &["-c", "user.email=u@u", "-c", "user.name=u", "commit", "-m", "upstream"][..], &["push", "origin", "HEAD:master"][..], &["push", "origin", "HEAD:main"][..]] {
|
||||
let _ = Command::new("git").current_dir(clone2.path()).args(a).output();
|
||||
}
|
||||
// Local also writes conflict.txt with different content → add/add conflict on pull.
|
||||
std::fs::write(v.join("conflict.txt"), "local-version").unwrap();
|
||||
assert!(git(&["add", "conflict.txt"]).status.success());
|
||||
assert!(git(&["-c", "commit.gpgsign=false", "commit", "-m", "local"]).status.success());
|
||||
|
||||
let out = dev.run(v, &["org", "rotate-key"]);
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(!out.status.success(), "rotate-key should abort on a concurrent rotation");
|
||||
assert!(
|
||||
stderr.contains("Concurrent key rotation detected — pull and re-run org rotate-key."),
|
||||
"missing spec error string: {stderr}"
|
||||
);
|
||||
}
|
||||
|
||||
// (d) remove-member → rotate-key → old clone cannot decrypt; remaining member can.
|
||||
#[test]
|
||||
fn removed_member_clone_cannot_decrypt_after_rotation() {
|
||||
// Owner laptop sets up the org + a second member "bob".
|
||||
let (owner_dev, vault, _owner) = setup_with_item();
|
||||
let v = vault.path();
|
||||
let bob = Dev::new("bob-laptop");
|
||||
let bob_pub = bob.pubkey("bob-laptop");
|
||||
|
||||
// Owner adds Bob and grants him prod.
|
||||
assert!(owner_dev.run(v, &["org", "add-member", "--key", &bob_pub, "--name", "Bob", "--role", "member"]).status.success());
|
||||
let members = std::fs::read_to_string(v.join("members.json")).unwrap();
|
||||
let mv: serde_json::Value = serde_json::from_str(&members).unwrap();
|
||||
let bob_id = mv["members"].as_array().unwrap().iter()
|
||||
.find(|m| m["display_name"] == "Bob").unwrap()["member_id"].as_str().unwrap().to_string();
|
||||
assert!(owner_dev.run(v, &["org", "grant", &bob_id, "prod"]).status.success());
|
||||
|
||||
// Bob clones the vault dir (his device, his key blob is present).
|
||||
// `cp -r /vault /dst/` places contents at `/dst/<vault_basename>/` — use that
|
||||
// sub-path, not the TempDir root, as the vault for Bob's commands.
|
||||
let bob_clone = TempDir::new().unwrap();
|
||||
let vault_basename = v.file_name().unwrap();
|
||||
let cp = Command::new("cp").args(["-r", v.to_str().unwrap(), bob_clone.path().to_str().unwrap()]).output().unwrap();
|
||||
assert!(cp.status.success());
|
||||
let bob_vault = bob_clone.path().join(vault_basename);
|
||||
// Bob can read the item BEFORE removal.
|
||||
let pre = bob.run(&bob_vault, &["org", "get", "GitHub", "--show"]);
|
||||
assert!(String::from_utf8_lossy(&pre.stdout).contains("hunter2"), "bob should read pre-removal");
|
||||
|
||||
// Owner removes Bob and rotates the key in the live vault.
|
||||
assert!(owner_dev.run(v, &["org", "remove-member", &bob_id]).status.success());
|
||||
assert!(owner_dev.run(v, &["org", "rotate-key"]).status.success());
|
||||
|
||||
// Owner (remaining member) can still decrypt in the live vault.
|
||||
let owner_get = owner_dev.run(v, &["org", "get", "GitHub", "--show"]);
|
||||
assert!(String::from_utf8_lossy(&owner_get.stdout).contains("hunter2"), "owner must still read");
|
||||
|
||||
// Copy the rotated item + manifest into Bob's stale clone (simulating a
|
||||
// pull) — his OLD key blob can no longer unwrap the rotated org key.
|
||||
let _ = Command::new("cp").args(["-r",
|
||||
v.join("items").to_str().unwrap(), bob_vault.to_str().unwrap()]).output();
|
||||
let _ = std::fs::copy(v.join("manifest.enc"), bob_vault.join("manifest.enc"));
|
||||
let post = bob.run(&bob_vault, &["org", "get", "GitHub", "--show"]);
|
||||
assert!(!post.status.success() || !String::from_utf8_lossy(&post.stdout).contains("hunter2"),
|
||||
"removed member must NOT decrypt post-rotation: {}", String::from_utf8_lossy(&post.stdout));
|
||||
}
|
||||
@@ -103,6 +103,26 @@ Pipeline" and "Crate Layout").
|
||||
auth factor. Owns its own `YChannel`, `EmbedRegion`, 8×8 DCT/IDCT,
|
||||
Quantization Index Modulation, and crop-recovery extractor. No other module
|
||||
imports it; it is consumed only via the public re-export from `lib.rs`.
|
||||
- **`org.rs`** — Org-vault data model and ECIES key-wrapping layer
|
||||
(`crates/relicario-core/src/org.rs`). Types: `OrgId` (L15), `MemberId`
|
||||
(L19; `is_valid` L41 — 16 lowercase hex), `OrgRole` (L54;
|
||||
`can_manage_members` L61 = Owner | Admin, `can_manage_owners` L64 = Owner
|
||||
only), `OrgMember` (L72; carries `ed25519_pubkey` in OpenSSH wire format,
|
||||
`collections` grant list, `role`), `OrgMembers` (L86; `schema_version: 1`
|
||||
L93; `validate` L104), `CollectionDef` (L123), `OrgCollections` (L131;
|
||||
`schema_version: 1` L138; `validate` L145 rejects empty / `/` / `.` slugs),
|
||||
`OrgMeta` (L164; `schema_version: 1` L174), `OrgManifestEntry` (L185;
|
||||
carries `collection` slug plus id/type/title/tags/modified/trashed\_at),
|
||||
`OrgManifest` (L199; `schema_version: 1` L206; `filter_for_member` L210
|
||||
returns only entries whose collection slug appears in the member's grants).
|
||||
All four JSON containers carry `schema_version: 1` — distinct from the
|
||||
personal `Manifest` whose `MANIFEST_SCHEMA_VERSION = 2` (`manifest.rs:12`).
|
||||
Crypto: `generate_org_key` (L230) → `Zeroizing<[u8;32]>` (256-bit
|
||||
CSPRNG org master key); `wrap_org_key` (L265) / `unwrap_org_key` (L299) —
|
||||
ECIES over X25519, described in detail under **Invariants & contracts**
|
||||
below. `vault.rs` adds `encrypt_org_manifest` / `decrypt_org_manifest` typed
|
||||
wrappers (JSON-serialize → `crypto::encrypt` under the org key, plaintext in
|
||||
`Zeroizing`) consistent with the personal-vault pattern.
|
||||
- **`backup.rs`** — `.relbak` v1 container format: `pack_backup` /
|
||||
`unpack_backup` plus the `BackupInput` / `BackupOutput` / `BackupItem` /
|
||||
`BackupAttachment` shapes. Wraps a zstd-compressed JSON envelope of vault
|
||||
@@ -230,6 +250,28 @@ Pipeline" and "Crate Layout").
|
||||
also used to derive the key for *unlock*, not just create).
|
||||
- **`SymbolCharset::Custom` must be ASCII-only** (`generators.rs:46-52`).
|
||||
Non-ASCII custom charsets are rejected with `RelicarioError::Format`.
|
||||
- **ECIES wrap-blob layout is fixed** at
|
||||
`ephemeral_x25519_pk(32) || version(1) || nonce(24) || ciphertext+tag`
|
||||
(`org.rs:264`). The `version(1)` byte is the same `VERSION_BYTE = 0x02`
|
||||
emitted by `crypto::encrypt`, which is what occupies that slot — the layout
|
||||
merely names the regions for clarity.
|
||||
- **KDF wrap key = `SHA-256(dh_shared || ephemeral_pk || recipient_pk)`**
|
||||
(`org.rs:278-281`). The concatenation order is identical in `wrap_org_key`
|
||||
and `unwrap_org_key`; a mismatch in either direction would produce a
|
||||
different key and fail the AEAD open. The intermediate `kdf_input` buffer is
|
||||
held in `Zeroizing<Vec<u8>>`; `org_key`, `wrap_key`, and the decrypted
|
||||
`plaintext` from unwrap are also held in `Zeroizing`.
|
||||
- **ed25519 → X25519 conversion** applies `SHA-512(seed)[..32]` then the
|
||||
RFC 7748 scalar clamp
|
||||
(`scalar[0] &= 248; scalar[31] &= 127; scalar[31] |= 64`) to derive the
|
||||
private X25519 scalar (`org.rs:242`); the recipient public key is obtained
|
||||
via `ed25519_dalek`'s `to_montgomery()`. This lets device ed25519 keys serve
|
||||
double duty as X25519 recipients without storing a separate DH key.
|
||||
- **Org crypto bypasses Argon2id.** The ECIES inner cipher delegates to
|
||||
`crate::crypto::encrypt` / `decrypt` (XChaCha20-Poly1305, random 24-byte
|
||||
nonce, `VERSION_BYTE = 0x02`) — no AEAD re-implementation. The X25519 KDF
|
||||
output is used directly as the AEAD key; the Argon2id path in `crypto.rs`
|
||||
is not invoked for org key wrapping.
|
||||
|
||||
## Key flows
|
||||
|
||||
@@ -315,6 +357,35 @@ when subsequent `decrypt_*` returns `RelicarioError::Decrypt`.
|
||||
call `item.prune_history(&settings.field_history_retention, now_unix())`
|
||||
when they want to enforce the policy.
|
||||
|
||||
### Org key wrap / unwrap
|
||||
|
||||
1. **Wrap** (`org.rs:265`): caller supplies a recipient's OpenSSH ed25519
|
||||
public key string.
|
||||
- Parse the OpenSSH wire format via `ssh-key` to recover the raw 32-byte
|
||||
ed25519 public key bytes; apply `to_montgomery()` (ed25519-dalek) to
|
||||
obtain the recipient's X25519 public key.
|
||||
- Generate an ephemeral X25519 keypair from `OsRng`.
|
||||
- `dh_shared = ephemeral_secret × recipient_x25519_pk` (X25519 DH).
|
||||
- `wrap_key = SHA-256(dh_shared || ephemeral_pk || recipient_pk)`
|
||||
(`org.rs:278-281`), intermediates in `Zeroizing`.
|
||||
- `ct = crate::crypto::encrypt(&wrap_key, &org_key)` — yields the standard
|
||||
`version(1) || nonce(24) || ciphertext+tag` blob.
|
||||
- Return `ephemeral_x25519_pk(32) || ct` (`org.rs:264`).
|
||||
2. **Unwrap** (`org.rs:299`): caller supplies the device ed25519 seed bytes
|
||||
(from `current_device_seed` in the CLI layer, not from `relicario-core`).
|
||||
- Derive X25519 private scalar from seed: `SHA-512(seed)[..32]` + RFC 7748
|
||||
clamp (`org.rs:242`).
|
||||
- Slice the first 32 bytes of the blob as `ephemeral_pk`; read recipient's
|
||||
own X25519 public key via the same `to_montgomery()` path.
|
||||
- `dh_shared = device_x25519_secret × ephemeral_pk`.
|
||||
- Reconstruct `wrap_key` identically; `crypto::decrypt` recovers `org_key`
|
||||
into `Zeroizing`.
|
||||
|
||||
Integration tests: `crates/relicario-core/tests/org.rs` (5 acceptance tests
|
||||
covering wrap/unwrap round-trip, revoked-after-rotation, and manifest
|
||||
`filter_for_member`). A pinned RFC 8032 ed25519→X25519 known-answer vector
|
||||
lives in the `#[cfg(test)]` block inside `org.rs` itself.
|
||||
|
||||
### imgsecret embed
|
||||
|
||||
1. Caller passes a JPEG byte slice and a 32-byte secret to
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "relicario-core"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
edition = "2021"
|
||||
description = "Core library for relicario password manager"
|
||||
license = "GPL-3.0-or-later"
|
||||
|
||||
@@ -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"
|
||||
|
||||
58
crates/relicario-server/src/lib.rs
Normal file
58
crates/relicario-server/src/lib.rs
Normal 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())
|
||||
}
|
||||
@@ -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)?)
|
||||
}
|
||||
|
||||
81
crates/relicario-server/tests/org_hook.rs
Normal file
81
crates/relicario-server/tests/org_hook.rs
Normal 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());
|
||||
}
|
||||
229
crates/relicario-server/tests/org_hook_signed.rs
Normal file
229
crates/relicario-server/tests/org_hook_signed.rs
Normal 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();
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "relicario-wasm"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
edition = "2021"
|
||||
description = "WASM bindings for relicario password manager"
|
||||
license = "GPL-3.0-or-later"
|
||||
|
||||
151
docs/CRYPTO.md
151
docs/CRYPTO.md
@@ -123,6 +123,157 @@ master_key ────────►│ XChaCha20 │──────
|
||||
└──────────────────┘
|
||||
```
|
||||
|
||||
## Org-key ECIES wrap/unwrap
|
||||
|
||||
Org vaults use a different key-derivation path than personal vaults. There is no
|
||||
passphrase, no reference JPEG, and no Argon2id involved. Instead, each org has a
|
||||
single random **org master key** that is wrapped per-member using X25519 ECIES and
|
||||
stored as an opaque blob in `keys/<member-id>.enc` inside the org repo.
|
||||
|
||||
### Org master key
|
||||
|
||||
```
|
||||
generate_org_key() (org.rs:230)
|
||||
→ OsRng → 256-bit random
|
||||
→ Zeroizing<[u8; 32]> (held in memory; never written in the clear)
|
||||
```
|
||||
|
||||
One org key per org. It is re-generated on every `org rotate-key` operation.
|
||||
|
||||
### ed25519 → X25519 conversion
|
||||
|
||||
Each Relicario device holds an ed25519 signing key. To participate in ECIES the
|
||||
ed25519 key pair must be mapped to X25519:
|
||||
|
||||
```
|
||||
Recipient public key (for wrap):
|
||||
ed25519 VerifyingKey
|
||||
→ .to_montgomery() (birational Montgomery map, ed25519_dalek)
|
||||
→ X25519 PublicKey
|
||||
|
||||
Recipient secret key (for unwrap):
|
||||
ed25519 seed (32 bytes)
|
||||
→ SHA-512(seed)[..32] (org.rs:241–242)
|
||||
→ RFC 7748 clamp:
|
||||
scalar[0] &= 248
|
||||
scalar[31] &= 127
|
||||
scalar[31] |= 64
|
||||
→ x25519_dalek::StaticSecret
|
||||
```
|
||||
|
||||
The RFC 7748 clamp and the `to_montgomery()` birational map are the standard
|
||||
construction; a pinned RFC 8032 known-answer vector is verified in the unit tests
|
||||
inside `org.rs`.
|
||||
|
||||
### Wrap flow (one blob per member)
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────┐
|
||||
│ wrap_org_key() │ (org.rs:265)
|
||||
│ │
|
||||
org_key ──────────►│ EphemeralSecret::random (OsRng) │
|
||||
│ ephemeral_pk = PublicKey::from(eph) │
|
||||
│ │
|
||||
recipient_pk ─────►│ DH: eph_sk.diffie_hellman(rec_pk) │
|
||||
│ → dh_shared (32 bytes) │
|
||||
│ │
|
||||
│ kdf_input = dh_shared │
|
||||
│ ‖ ephemeral_pk (32 B) │ (org.rs:278–281)
|
||||
│ ‖ recipient_pk (32 B) │
|
||||
│ wrap_key = SHA-256(kdf_input) │
|
||||
│ (kdf_input in Zeroizing<Vec<u8>>) │
|
||||
│ (wrap_key in Zeroizing<[u8;32]>) │
|
||||
│ │
|
||||
│ encrypted = crate::crypto::encrypt │
|
||||
│ (wrap_key, org_key) │
|
||||
│ → version(1) ‖ nonce(24) ‖ ct+tag │
|
||||
│ │
|
||||
│ output: ephemeral_pk(32) │ (org.rs:264)
|
||||
│ ‖ version(1) │
|
||||
│ ‖ nonce(24) │
|
||||
│ ‖ ciphertext + tag │
|
||||
└──────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
keys/<member-id>.enc (in org repo)
|
||||
```
|
||||
|
||||
### Unwrap flow
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────┐
|
||||
│ unwrap_org_key() │ (org.rs:299)
|
||||
│ │
|
||||
wrapped blob ─────►│ split: ephemeral_pk(32) + rest │
|
||||
│ │
|
||||
ed25519_seed ─────►│ ed25519_seed_to_x25519_secret() │
|
||||
│ → recipient_sk + recipient_pk │
|
||||
│ │
|
||||
│ DH: recipient_sk.diffie_hellman(eph)│
|
||||
│ → dh_shared │
|
||||
│ │
|
||||
│ kdf_input + SHA-256 → wrap_key │
|
||||
│ (same domain-separated KDF as wrap) │
|
||||
│ │
|
||||
│ plaintext = crate::crypto::decrypt │
|
||||
│ (wrap_key, rest) │
|
||||
│ → Zeroizing<[u8;32]> org_key │
|
||||
└──────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Key distinction: no Argon2id
|
||||
|
||||
Unlike the personal vault, **org crypto bypasses Argon2id entirely**:
|
||||
|
||||
| | Personal vault | Org vault |
|
||||
|---|---|---|
|
||||
| Key origin | Argon2id(passphrase ‖ image_secret, salt) | OsRng → 256-bit random |
|
||||
| Key transport | Embedded in reference JPEG (stego) | X25519 ECIES wrap blob |
|
||||
| AEAD primitive | XChaCha20-Poly1305 (`crate::crypto::encrypt`) | Same primitive (delegated) |
|
||||
| KDF for wrap key | Argon2id | SHA-256(DH ‖ eph_pk ‖ rec_pk) |
|
||||
|
||||
The inner AEAD (`crate::crypto::encrypt` / `decrypt`) is **not re-implemented** in
|
||||
the org module — it is called directly, so org item blobs share the identical
|
||||
`version(1) ‖ nonce(24) ‖ ct+tag` wire format (`VERSION_BYTE = 0x02`,
|
||||
`crates/relicario-core/src/crypto.rs:59`).
|
||||
|
||||
### Zeroize discipline
|
||||
|
||||
All intermediates that carry key material are dropped through `Zeroizing`:
|
||||
|
||||
- `org_key` — `Zeroizing<[u8; 32]>` everywhere it is passed
|
||||
- `kdf_input` — `Zeroizing<Vec<u8>>` (org.rs:278)
|
||||
- `wrap_key` — `Zeroizing<[u8; 32]>`
|
||||
- decrypt `plaintext` in `unwrap_org_key` — `Zeroizing<Vec<u8>>`
|
||||
|
||||
### Key rotation and re-encryption
|
||||
|
||||
`org rotate-key` (`crates/relicario-cli/src/commands/org.rs:332`) does more than
|
||||
generate a fresh org key:
|
||||
|
||||
```
|
||||
run_rotate_key()
|
||||
1. git pull --rebase (detect concurrent rotation → abort if non-fast-forward)
|
||||
2. generate_org_key() → new_org_key
|
||||
3. wrap_org_key(new_org_key, member_pk) for every current member
|
||||
→ overwrites keys/<member-id>.enc
|
||||
4. re-encrypt every items/<slug>/<id>.enc blob under new_org_key
|
||||
5. re-encrypt manifest.enc under new_org_key
|
||||
6. git add + git commit via org_git_run (signed; Relicario-Action: key-rotate)
|
||||
```
|
||||
|
||||
`rotate-key` pulls (`--rebase`) at the start to pick up concurrent changes and
|
||||
abort on a conflicting concurrent rotation, then commits locally; it does **not**
|
||||
push. Publishing the rotation to the remote is a separate step (the normal git
|
||||
sync path), the same way personal-vault mutations commit locally and sync later.
|
||||
|
||||
Re-encryption of every item blob (step 4) is deliberate: a removed member who holds
|
||||
a local clone of the repo cannot decrypt any item written after the rotation, because
|
||||
those blobs are sealed under a key they never received. Without re-encryption, all
|
||||
pre-rotation blobs would remain readable to the former member indefinitely.
|
||||
|
||||
The item-CRUD commands (`org add`/`get`/`list`/`edit`/`rm`/`restore`/`purge`) that read and write these blobs are merged and wired into `main.rs`; each operates under the org master key recovered by `unwrap_org_key`.
|
||||
|
||||
## imgsecret DCT Embedding
|
||||
|
||||
```
|
||||
|
||||
@@ -71,6 +71,60 @@ An empty array (`[]`) puts the pre-receive hook in bootstrap mode (all pushes ac
|
||||
|
||||
Commits by `public_key` at or after `revoked_at` (Unix seconds) are rejected by the pre-receive hook. Commits before `revoked_at` remain valid (they were authorized at the time).
|
||||
|
||||
## Org vault repo formats
|
||||
|
||||
The org vault is a **separate git repository** alongside the personal vault. It is not nested inside `.relicario/`. Its layout:
|
||||
|
||||
```
|
||||
org.json # OrgMeta (schema_version, org_id, display_name, created_at)
|
||||
members.json # PUBLIC/unencrypted member directory
|
||||
collections.json # collection definitions
|
||||
keys/<member-id>.enc # org master key wrapped to that member's device key
|
||||
manifest.enc # OrgManifest (schema_version 1, per-member-filtered)
|
||||
items/<collection-slug>/<item-id>.enc # collection-scoped item blobs
|
||||
```
|
||||
|
||||
### `org.json` — OrgMeta
|
||||
|
||||
Unencrypted JSON (`OrgMeta`, `org.rs:164`). `schema_version: 1` (`org.rs:174`). Fields: `schema_version`, `org_id`, `display_name`, `created_at` (Unix seconds).
|
||||
|
||||
### `members.json` — OrgMembers
|
||||
|
||||
Unencrypted JSON array of `OrgMember` records (`org.rs:72`); container type `OrgMembers` carries `schema_version: 1` (`org.rs:93`). Per-member fields: `member_id` (16 lowercase hex chars), `display_name`, `role` (one of `owner | admin | member`), `ed25519_pubkey` (OpenSSH wire string), `collections` (array of granted slug strings), `added_at`, `added_by`. Roles are not secrets — authorization to read this file is not required to verify signatures.
|
||||
|
||||
### `collections.json` — OrgCollections
|
||||
|
||||
Unencrypted JSON; `schema_version: 1` (`org.rs:138`). Contains a list of `CollectionDef` records (`org.rs:123`). Validation (`org.rs:145`) rejects slugs that are empty, contain `/`, or equal `.`.
|
||||
|
||||
### `keys/<member-id>.enc` — wrapped org master key
|
||||
|
||||
Binary blob; NOT a standard `.enc` blob. Layout (`org.rs:264`):
|
||||
|
||||
```
|
||||
┌──────────────────────────┬─────────┬────────┬──────────────────────┐
|
||||
│ ephemeral_x25519_pubkey │ version │ nonce │ ciphertext + tag │
|
||||
│ 32 bytes │ 1 byte │24 bytes│ N + 16 bytes │
|
||||
└──────────────────────────┴─────────┴────────┴──────────────────────┘
|
||||
```
|
||||
|
||||
- The wrapping key is `SHA-256(dh_shared || ephemeral_pubkey || recipient_pubkey)` (`org.rs:278–281`), held in `Zeroizing<Vec<u8>>`.
|
||||
- The inner AEAD (`version || nonce || ciphertext+tag`) is produced by `crate::crypto::encrypt` — the same XChaCha20-Poly1305 framing used for personal `.enc` blobs (see **Encrypted blob** above). `VERSION_BYTE = 0x02` applies here too.
|
||||
- The X25519 private scalar is derived from the device ed25519 seed via `SHA-512(seed)[..32]` with RFC 7748 clamping (`org.rs:242`). Argon2id is **not** involved — the wrapping key is derived entirely from the X25519 DH exchange.
|
||||
|
||||
### `manifest.enc` — OrgManifest
|
||||
|
||||
Encrypted with the org master key using `crypto::encrypt` (standard `.enc` framing). Decrypts to `OrgManifest` JSON (`org.rs:199`); `schema_version: 1` (`org.rs:206`). Each `OrgManifestEntry` (`org.rs:185`) carries: `id`, `type`, `title`, `tags`, `modified`, `trashed_at`, and a `collection` slug field. The `collection` field distinguishes this type from `ManifestEntry` in the personal vault.
|
||||
|
||||
Contrast with the personal vault manifest: `Manifest` uses `MANIFEST_SCHEMA_VERSION = 2` (`manifest.rs:12`) and `ManifestEntry` has no `collection` field. The two types are distinct and do not share a schema.
|
||||
|
||||
### `items/<collection-slug>/<item-id>.enc`
|
||||
|
||||
Standard `.enc` blob (see **Encrypted blob** above), encrypted under the org master key. The blob itself does **not** name its collection — the directory path segment carries the slug. This allows the pre-receive hook (`relicario-server`) to authorize a write by path segment without decrypting the blob.
|
||||
|
||||
These blobs are written and read by the `relicario org` item commands (`org add` / `get` / `list` / `edit` / `rm` / `restore` / `purge`), all collection-scoped and grant-enforced. `org add` currently creates Login / SecureNote / Identity items; `get` / `list` display any item type present.
|
||||
|
||||
**TODO (extension follow-up):** extension UI for browsing and editing org vault items. **Deferred:** `org add` / `edit` parity for Card / Key / Document / Totp item types.
|
||||
|
||||
## Item IDs and Field IDs
|
||||
|
||||
| Kind | Length | Entropy | Source |
|
||||
|
||||
111
docs/SECURITY.md
111
docs/SECURITY.md
@@ -74,6 +74,117 @@ Without device authentication, access control is transport-layer only:
|
||||
|
||||
Device registration is optional but recommended for shared vaults.
|
||||
|
||||
## Org vault security
|
||||
|
||||
An org vault is a separate git repository alongside the personal vault. It
|
||||
uses ed25519 commit-signing and a server-side pre-receive hook to make
|
||||
least-privilege access control server-enforced, not advisory.
|
||||
|
||||
### Org device-key authentication
|
||||
|
||||
Every org member registers an ed25519 device key. The key appears in
|
||||
`members.json` as an OpenSSH public-key string alongside the member's role
|
||||
and collection grants. Fingerprint matching is done via
|
||||
`relicario_core::fingerprint`, which normalises the OpenSSH format so that
|
||||
whitespace and comment differences do not create phantom mismatches.
|
||||
|
||||
Org access requires two things at once: a wrapped key blob (`keys/<member-id>.enc`)
|
||||
and the device private key that can unwrap it. There is no org passphrase —
|
||||
removing a member's blob and rotating the org master key is sufficient to
|
||||
revoke access (see **Key rotation** below). Device keys are completely
|
||||
separate from the personal vault's KDF inputs; revoking org access does not
|
||||
affect the member's personal vault.
|
||||
|
||||
### Pre-receive hook enforcement
|
||||
|
||||
`relicario-server generate-org-hook` (`crates/relicario-server/src/main.rs:511`)
|
||||
emits a hook script that calls `relicario-server verify-org-commit` for
|
||||
every pushed commit. Unsigned or structurally invalid commits are rejected
|
||||
before they land.
|
||||
|
||||
`verify_org_commit` (`main.rs:286`) performs four checks in order:
|
||||
|
||||
1. **Signature verification** — a temporary `allowed_signers` file is
|
||||
constructed from the current `members.json`; `git verify-commit --raw`
|
||||
is run and the resulting SHA-256 fingerprint is matched back to a
|
||||
`members.json` entry. A commit not signed by a *current* member is
|
||||
rejected outright.
|
||||
|
||||
2. **Path-level write authorisation** — each modified path is classified by
|
||||
`classify_path` (`crates/relicario-server/src/lib.rs:19`) into
|
||||
`ProtectedJson` (owner/admin write only), `CollectionItem` (the
|
||||
`items/<slug>/…` prefix; write allowed only if the slug appears in the
|
||||
signer's `collections` grant array), or `Unrestricted`. The write is
|
||||
authorised if and only if the signer's role and grants satisfy the
|
||||
classification. Item blobs are authorised by the leading path segment
|
||||
alone — the ciphertext is never decrypted by the hook.
|
||||
|
||||
3. **Owner-only elevation guard** (`enforce_owner_only_elevation`,
|
||||
`main.rs:438`) — only a member whose *pre-commit* (parent) role is Owner
|
||||
may introduce a new member at Owner or Admin level, or promote an
|
||||
existing member to either. Checking the pre-commit role means an Admin
|
||||
cannot self-promote in the same commit that writes the escalated
|
||||
`members.json`; there is no epoch in which the transition is
|
||||
self-authorised.
|
||||
|
||||
4. **Schema monotonicity** (`enforce_schema_monotonicity`, `main.rs:521`)
|
||||
— `schema_version` values in org JSON containers may not decrease.
|
||||
Merge commits are rejected. A genesis commit (no parents) is allowed
|
||||
only when it is signed by the sole Owner it introduces.
|
||||
|
||||
### Key rotation
|
||||
|
||||
`relicario org rotate-key` generates a fresh 256-bit org master key,
|
||||
re-wraps it for every current member, and re-encrypts every
|
||||
`items/<slug>/<id>.enc` blob and the manifest under the new key in a single
|
||||
signed commit tagged `Relicario-Action: key-rotate`. A revoked member's
|
||||
wrapped blob is simply not written during rotation, so they hold a blob that
|
||||
decrypts to a stale key — they cannot read items encrypted under the new
|
||||
key.
|
||||
|
||||
### Audit action vocabulary
|
||||
|
||||
The `relicario org audit` command attributes actions to their verified
|
||||
signer (not to the commit author or trailer value). Each event records two
|
||||
actors: the **verified** actor resolved from the signing key (authoritative)
|
||||
and the actor **claimed** by the `Relicario-Actor` trailer (advisory). When the
|
||||
claimed actor disagrees with the verified signer, the event is flagged
|
||||
`TAMPERED`. Trailers are advisory metadata; the trustworthy actor is always
|
||||
the cryptographically verified signer.
|
||||
|
||||
Actions live in two groups:
|
||||
|
||||
- **Membership / collections / lifecycle:** `member-add`, `member-remove`,
|
||||
`member-role-change`, `collection-create`, `collection-grant`,
|
||||
`collection-revoke`, `key-rotate`, `org-init`, `ownership-transfer`,
|
||||
`org-delete`.
|
||||
- **Item CRUD:** `item-create`, `item-update`, `item-delete` (soft-delete /
|
||||
trash), `item-restore`, `item-purge` — emitted by the `org add` / `edit` /
|
||||
`rm` / `restore` / `purge` commands.
|
||||
|
||||
### Honest limitations
|
||||
|
||||
The following are deliberate design boundaries, not oversights:
|
||||
|
||||
- **Shared org master key — reads are not cryptographically scoped per
|
||||
collection.** The pre-receive hook scopes *writes* by collection path
|
||||
and the CLI filters the manifest to each member's grants, but a single
|
||||
org key opens all collection blobs. A member with any grant can, outside
|
||||
the CLI, decrypt items from collections they are not granted. For true
|
||||
cryptographic separation, use a separate org vault per access boundary.
|
||||
Per-collection subkeys are a phase-2 non-goal.
|
||||
|
||||
- **No read audit.** Git records writes only. A member who reads blobs
|
||||
directly leaves no server-visible trace.
|
||||
|
||||
- **No "hide value."** There is no mechanism to show a member that an item
|
||||
exists without revealing its field values on decrypt.
|
||||
|
||||
- **`delete-org` is a local tombstone in phase 1.** The schema-monotonicity
|
||||
check causes the hook to reject protected-file deletion, so an
|
||||
`org-delete` action cannot be pushed to a hook-protected remote. The
|
||||
deletion is recorded locally only until a future phase addresses it.
|
||||
|
||||
## Configuration env vars
|
||||
|
||||
Relicario reads the following environment variables. Each is a trust
|
||||
|
||||
134
docs/superpowers/coordination/v0.8.1-dev-a-prompt.md
Normal file
134
docs/superpowers/coordination/v0.8.1-dev-a-prompt.md
Normal 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 A1–A4, 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 A1–A3 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.
|
||||
134
docs/superpowers/coordination/v0.8.1-dev-b-prompt.md
Normal file
134
docs/superpowers/coordination/v0.8.1-dev-b-prompt.md
Normal 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 B1–B4, 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).
|
||||
135
docs/superpowers/coordination/v0.8.1-dev-c-prompt.md
Normal file
135
docs/superpowers/coordination/v0.8.1-dev-c-prompt.md
Normal 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 C1–C4, 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).
|
||||
133
docs/superpowers/coordination/v0.8.1-dev-d-prompt.md
Normal file
133
docs/superpowers/coordination/v0.8.1-dev-d-prompt.md
Normal 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.
|
||||
138
docs/superpowers/coordination/v0.8.1-pm-prompt.md
Normal file
138
docs/superpowers/coordination/v0.8.1-pm-prompt.md
Normal 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.
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
1216
docs/superpowers/plans/2026-06-20-relicario-v0.8.1-parity.md
Normal file
1216
docs/superpowers/plans/2026-06-20-relicario-v0.8.1-parity.md
Normal file
File diff suppressed because it is too large
Load Diff
29
docs/superpowers/salvage/2026-06-20-org-vault-tail/README.md
Normal file
29
docs/superpowers/salvage/2026-06-20-org-vault-tail/README.md
Normal 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 (B9–B13) | **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/`.
|
||||
@@ -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", ¬e.id).unwrap();
|
||||
+ match ¬e_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:#}");
|
||||
+ }
|
||||
+}
|
||||
@@ -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"
|
||||
+ );
|
||||
+ }
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
107
docs/superpowers/specs/2026-06-20-relicario-v0.8.1-parity.md
Normal file
107
docs/superpowers/specs/2026-06-20-relicario-v0.8.1-parity.md
Normal 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.
|
||||
Reference in New Issue
Block a user