Compare commits

...

22 Commits

Author SHA1 Message Date
adlee-was-taken
2fa4d6824c release: v0.8.1 — org item-type parity + collection-scoped attachments 2026-06-20 22:24:22 -04:00
adlee-was-taken
783e3493f0 merge: feature/extension-cli-parity-gap-analysis (v0.8.1) — extension<->CLI parity gap analysis (forward plan referenced by STATUS/ROADMAP) 2026-06-20 22:21:05 -04:00
adlee-was-taken
4cca9b465c merge: feature/v0.8.1-status-roadmap (v0.8.1 wrap-up) — mark org item-type parity + attachments shipped; reference parity-gap analysis 2026-06-20 22:14:36 -04:00
adlee-was-taken
5be3043ab5 merge: feature/v0.8.1-cleanup-item-build (v0.8.1 wrap-up) — shared encrypt_document_file helper (F1 DRY + F2 zeroize plaintext) 2026-06-20 22:07:38 -04:00
adlee-was-taken
cf89bf8ca4 merge: feature/v0.8.1-cli-arch-doc (v0.8.1 wrap-up) — document org item-type parity + attachment storage in cli ARCHITECTURE 2026-06-20 22:02:13 -04:00
adlee-was-taken
a91ceea0ed refactor(cli): shared encrypt_document_file helper (DRY org/personal Document build; zeroize source plaintext) 2026-06-20 22:02:07 -04:00
adlee-was-taken
415d8ed9ef docs(cli): document v0.8.1 org item-type parity surface in ARCHITECTURE.md
- org.rs bullet: full Card/Key/Document/Totp org add/edit parity via the
  shared item_build builders + edit helpers; interactive per-type edit;
  --*-stdin secret convention; purge removes attachments. Replaces the stale
  'Login/SecureNote/Identity only' + flag-driven-edit + deferred text.
- org_session.rs bullet: collection-scoped attachment storage (attachment_path/
  save/load/remove + DEFAULT_ORG_ATTACHMENT_MAX_BYTES).
- main.rs bullet: OrgCommands + OrgAddKind clap surface.

Source-line citations pinned per the code-constant-pinning discipline.
2026-06-20 22:00:29 -04:00
adlee-was-taken
b54aaea239 docs(status): v0.8.1 org item-type parity landed — update STATUS + ROADMAP
Mark v0.8.1 shipped (all four streams merged on 4c0a289, verified against
source): org add/edit parity for all 7 item types (Card/Key/Totp + Document),
collection-scoped attachment storage, and the grant-scoped attachment-write
pre-receive hook. Move org item-type parity from deferred to shipped; relabel
the org-vault row as v0.8.0; reference the new extension-cli parity gap analysis
as the forward plan for deferred extension org read/write. Scope: STATUS.md +
ROADMAP.md only (CHANGELOG + version bumps owned by PM).
2026-06-20 21:59:47 -04:00
adlee-was-taken
4c0a289acb merge: feature/v0.8.1-dev-c-document-attachments (v0.8.1 Dev-C) — org Document + collection-scoped attachment storage + edit/purge 2026-06-20 21:53:21 -04:00
adlee-was-taken
03559f81ea test(cli/org): org document add/edit/purge round-trips + attachment staging + grant denial 2026-06-20 21:35:24 -04:00
adlee-was-taken
fe8eeb97c9 fix(cli/org): reject --file on non-Document org edit (fail fast) 2026-06-20 21:28:52 -04:00
adlee-was-taken
8ec616be5d feat(cli/org): org document edit via --file + purge removes attachments 2026-06-20 21:23:46 -04:00
adlee-was-taken
bd323d8b1b feat(cli/org): org add document with collection-scoped attachment 2026-06-20 21:13:26 -04:00
adlee-was-taken
db0ab1d82e docs(formats): org collection-scoped attachment layout + default cap
Document the attachments/<slug>/<item-id>/<att-id>.enc layout (exactly 3
segments, slug-authorized by the pre-receive hook, never decrypted
server-side) and DEFAULT_ORG_ATTACHMENT_MAX_BYTES = 10 MiB, citing
org_session.rs:24 and the mirrored personal default settings.rs:116.
2026-06-20 21:08:23 -04:00
adlee-was-taken
68c6da4d67 chore(cli/org): silence dead_code on not-yet-consumed attachment API 2026-06-20 21:08:23 -04:00
adlee-was-taken
bccd113f55 feat(cli/org): collection-scoped attachment storage + default cap 2026-06-20 21:08:23 -04:00
adlee-was-taken
6e73c5e6a1 merge: feature/v0.8.1-dev-b-card-key-totp (v0.8.1 Dev-B) — org add/edit parity for Card/Key/Totp via shared item_build + interactive org edit 2026-06-20 21:07:22 -04:00
adlee-was-taken
e76d7167d6 test(cli/org): grant enforcement + body/secret-stdin + key-edit coverage
Closes the minor coverage gaps from the final adversarial review:
- org add card/key/totp reject ungranted + unknown collections (pins the
  grant gate on the new write paths, which runs before any secret prompt)
- secure-note --body-stdin masks body; totp --secret-stdin round-trips
  (completes the --*-stdin matrix for the org surface)
- key-material edit accept-branch round-trip, verified via get --show
2026-06-20 20:58:26 -04:00
adlee-was-taken
04ad98973a test(cli/org): adapt grant-denial edit case to interactive org edit
B3 dropped the flat --username/--url/... flags from `org edit`, so the
ungranted-member denial test must drive the bare interactive form. The
ungranted member is now rejected at manifest lookup (filter_for_member +
resolve_org_query) before any prompt is read.
2026-06-20 20:49:12 -04:00
adlee-was-taken
290bc4e2d0 feat(cli/org): interactive per-type org edit via shared edit helpers 2026-06-20 20:43:03 -04:00
adlee-was-taken
82feb49ab4 feat(cli/org): org add parity for Card/Key/Totp via shared builders 2026-06-20 18:31:29 -04:00
adlee-was-taken
07862b8d44 test(cli/org): failing Card/Key/Totp org add round-trips (B4, pre-A-integration)
Adds run_stdin + create_collection_and_grant fixture helpers and three
acceptance tests for org add card/key/totp. Red until B1/B2 wire the
subcommands (currently: unrecognized subcommand). Asserts org get masks
card number + key material without --show. Edit round-trips land with B3.
2026-06-20 18:26:11 -04:00
15 changed files with 806 additions and 150 deletions

View File

@@ -1,5 +1,56 @@
# Changelog # Changelog
## v0.8.1 — 2026-06-20 — org item-type parity + collection-scoped attachments
Brings `relicario org add` / `relicario org edit` to **full item-type parity** with the
personal vault: the org surface now supports **all 7 item types** (previously Login /
SecureNote / Identity only), adds collection-scoped attachment storage for Document
items, and grant-scopes attachment write paths in the pre-receive hook — closing a latent
authorization gap. Secrets are entered via interactive prompts by default, with `--*-stdin`
escape hatches for non-interactive scripting. Tracked under
`docs/superpowers/plans/2026-06-20-relicario-v0.8.1-parity.md`.
> **⚠️ Coordinated server redeploy required.** The `relicario-server` pre-receive hook
> (now `0.1.1`) must be rebuilt and redeployed for attachment writes to be grant-scoped in
> production. Until the updated hook is installed, `attachments/…` pushes remain
> `Unrestricted` (gated only by the per-commit member-signature check).
### Added
- **Shared `item_build` CLI module** (`crates/relicario-cli/src/commands/item_build.rs`):
centralizes per-type secret resolution, item builders (`build_*`), and interactive edit
helpers (`edit_*`) consumed by **both** the personal and org command surfaces, eliminating
the prior personal↔org builder duplication.
- **Org `add` / `edit` parity for Card, Key, TOTP, and Document** — `relicario org add` now
creates all 7 item types; `relicario org edit` is interactive per-type ("blank to keep",
field-history capture) instead of flat flags.
- **`--*-stdin` secret flags** on personal and org `add` for non-interactive entry of
passwords, card number/CVV/PIN, key material, TOTP secrets, and note bodies.
- **Collection-scoped org attachment storage** (`crates/relicario-cli/src/org_session.rs`):
attachments stored at `attachments/<slug>/<item-id>/<att-id>.enc` with a default
per-attachment cap (10 MiB, mirroring the personal default at
`crates/relicario-core/src/settings.rs`). `org add document --file`, `org edit --file`
(replace), and `org purge` (removes the item's attachment directory) round-trip with
git-status-clean staging.
### Security
- **Grant-scoped attachment writes** (`relicario-server` `0.1.1`): `classify_path` now
recognizes `attachments/<slug>/<item-id>/<att-id>.enc` (exactly 3 path segments, `.`-free
slug guard) as `Item { collection }`, bringing attachment writes under the same grant +
slug-existence check as `items/` blobs. Previously such paths fell through to
`Unrestricted`. The Document source plaintext is read into a `Zeroizing` buffer and wiped
after encryption. See `docs/SECURITY.md`.
### Changed
- Personal `add secure-note` `--body-prompt` flag renamed to `--body-stdin` (unified
multiline-secret model).
### Docs
- Updated cli `ARCHITECTURE.md`, `docs/FORMATS.md` (org attachment layout + cap citation),
`docs/SECURITY.md`, `STATUS.md`, and `ROADMAP.md`. New
`docs/superpowers/specs/2026-06-20-extension-cli-parity-gap-analysis.md` is the forward
plan for extension↔CLI parity (org read/write plus a cluster of personal-side extension
gaps). End-user `user_docs/` guide lands as a fast-follow.
## v0.8.0 — 2026-06-20 — enterprise org vault ## v0.8.0 — 2026-06-20 — enterprise org vault
Git-native multi-user **org vaults**: a separate org git repository alongside each Git-native multi-user **org vaults**: a separate org git repository alongside each

6
Cargo.lock generated
View File

@@ -2156,7 +2156,7 @@ checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
[[package]] [[package]]
name = "relicario-cli" name = "relicario-cli"
version = "0.8.0" version = "0.8.1"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"arboard", "arboard",
@@ -2188,7 +2188,7 @@ dependencies = [
[[package]] [[package]]
name = "relicario-core" name = "relicario-core"
version = "0.8.0" version = "0.8.1"
dependencies = [ dependencies = [
"argon2", "argon2",
"base64", "base64",
@@ -2235,7 +2235,7 @@ dependencies = [
[[package]] [[package]]
name = "relicario-wasm" name = "relicario-wasm"
version = "0.8.0" version = "0.8.1"
dependencies = [ dependencies = [
"base64", "base64",
"ed25519-dalek", "ed25519-dalek",

View File

@@ -7,7 +7,8 @@
| Version | Highlights | | 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.8.1** *(2026-06-20, tag pending PM)* | **Org item-type parity + collection-scoped attachments + grant-scoped hook** (`4c0a289`, four parallel streams): `relicario org add`/`edit` now cover **all 7 item types** — Card/Key/Totp (Dev-B `6e73c5e`) and Document (Dev-C `4c0a289`) on the shared `item_build` foundation (Dev-A `b09e0ce`); org attachments stored collection-scoped at `attachments/<slug>/<item-id>/<att-id>.enc` with a default cap (Dev-C); `relicario-server` `classify_path` grant-scopes those attachment writes (Dev-D `db4e05a`, server `0.1.1`**requires pre-receive hook redeploy**). **Still deferred:** extension org read/write (forward plan: `docs/superpowers/specs/2026-06-20-extension-cli-parity-gap-analysis.md`); org phase 2. |
| v0.8.0 *(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). Org item-type parity for Card/Key/Document/Totp shipped subsequently in v0.8.1; extension org parity + phase 2 (SSO/LDAP, read audit, per-collection subkeys, HTTP plane) remain deferred. |
| 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.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.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-α/β₁/β₂) | | v0.2.0 | Typed-item rewrite (Plans 1A/1B/1C-α/β₁/β₂) |
@@ -16,11 +17,11 @@ See `CHANGELOG.md` for tagged-release detail and `STATUS.md` for the per-train c
## Up next ## Up next
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: All three 2026-05-04 architecture-review specs are shipped; the enterprise org vault backend (v0.8.0) and org item-type parity + collection-scoped attachments (v0.8.1) are shipped. Forward plan for extension parity: `docs/superpowers/specs/2026-06-20-extension-cli-parity-gap-analysis.md`. 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 — 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 - **Extension org parity — write** — `org add`/`edit`/`rm` from the extension (Plan B-2; the CLI side reached all-7-type org write in v0.8.1, so this is unblocked CLI-side)
- **Personal-side extension gaps** — favorites UI, group/tag/filter editing across all type forms, attachment-remove router wire + per-item purge UI, autofill registrable-domain matching (per the parity gap analysis)
- **Phase 4: command palette** — ⌘K global search + action dispatch across the vault tab (no spec yet) - **Phase 4: command palette** — ⌘K global search + action dispatch across the vault tab (no spec yet)
## Medium-term ## Medium-term

View File

@@ -5,10 +5,23 @@
## Version ## Version
**Last release tagged:** v0.6.0 — rolled up Phase 2B, v0.5.1 Streams A/B/C, 1C-γ, Plan B refactor (Cycles 1+2), management-surfaces revamp, and the doc-structure redesign into one tag. **Last release tagged:** v0.6.0 — rolled up Phase 2B, v0.5.1 Streams A/B/C, 1C-γ, Plan B refactor (Cycles 1+2), management-surfaces revamp, and the doc-structure redesign into one tag.
**Active track:** **extension restructure (Plan C) — COMPLETE.** All six phases merged. Phases 1, 2, 5 merged 2026-05-30; Phases 3, 4, 6 merged 2026-05-31/06-01 via three parallel worktree streams (Dev-A/B/C under PM coordination). Versions bumped to v0.7.0; tag pending. **Active track:** **v0.8.1 — org item-type parity — COMPLETE (on `main` `4c0a289`; tag pending PM).** All four parallel streams merged: shared item-build foundation + personal add/edit refactor (Dev-A, `b09e0ce`); org add/edit parity for Card/Key/Totp (Dev-B, `6e73c5e`); org Document + collection-scoped attachment storage (Dev-C, `4c0a289`); grant-scoped attachment write-path hook (Dev-D, `db4e05a`). See the v0.8.1 landing section below.
## What landed on main since the v0.5.0 version bump ## What landed on main since the v0.5.0 version bump
### v0.8.1 — org item-type parity + collection-scoped attachments + grant-scoped hook (merged 2026-06-20, `4c0a289`)
Spec: `docs/superpowers/specs/2026-06-20-relicario-v0.8.1-parity.md`; plan: `docs/superpowers/plans/2026-06-20-relicario-v0.8.1-parity.md`. Four parallel streams under PM coordination (relay-bus):
- **Dev-A — shared item-build foundation** (merge `b09e0ce`): `commands/item_build.rs` (shared secret-resolution, type parsers, per-type `build_*`/`edit_*` helpers, `push_history`); personal `add`/`edit` refactored onto it; personal `--*-stdin` flags for non-interactive scripting/tests.
- **Dev-B — org Card/Key/Totp parity** (merge `6e73c5e`): `OrgAddKind` gains Card/Key/Totp; `org edit` becomes per-type interactive dispatch (the old "login/secure-note/identity only" bail is gone).
- **Dev-C — org Document + collection-scoped attachments** (merge `4c0a289`): `OrgAddKind::Document`; `org_session.rs` attachment storage (`attachment_path`/`save_attachment`/`load_attachment`/`remove_item_attachments`) writing `attachments/<slug>/<item-id>/<att-id>.enc`; default org attachment cap; `org add document --file` + `org edit --file`; purge removes the item's attachment dir.
- **Dev-D — grant-scoped attachment hook** (merge `db4e05a`): `relicario-server` `classify_path` recognizes `attachments/<slug>/<item-id>/<att-id>.enc` (3 segments, slug-only `.`-free guard) as `Item { collection }`, converting attachment writes from `Unrestricted` to grant-scoped — closing a latent authz gap. Bumped `relicario-server` to 0.1.1; `docs/SECURITY.md` documents the required pre-receive hook redeploy.
Result: `relicario org add`/`edit` now reach **all 7 item types** (Login, Secure Note, Identity, Card, Key, TOTP, Document); org attachments are collection-scoped on disk and grant-enforced at the hook. The C↔D path contract held in the merge — Dev-C's `save_attachment` emitter (`attachments/{slug}/{item}/{att}.enc`) exactly matches Dev-D's `classify_path` authorization. **Deploy note:** the pre-receive hook must be rebuilt on the server for attachment writes to be grant-scoped in production.
**Still deferred — forward plan in `docs/superpowers/specs/2026-06-20-extension-cli-parity-gap-analysis.md`:** extension org **read** (Dev-D) and **write** (Plan B-2) — the extension has no org concept yet; org phase-2 (SSO/LDAP, read audit, per-collection subkeys, HTTP plane). That parity gap analysis is the authoritative forward plan for extension↔CLI parity (org read/write plus a cluster of personal-side extension gaps: favorites UI, group/tag/filter editing, attachment-remove router wire, per-item purge).
### Phase 2B — polish foundation + form layout (merged 2026-05-02, `5da1e52`) ### Phase 2B — polish foundation + form layout (merged 2026-05-02, `5da1e52`)
Spec: `docs/superpowers/specs/2026-05-02-phase-2b-form-layout-design.md` Spec: `docs/superpowers/specs/2026-05-02-phase-2b-form-layout-design.md`
@@ -114,10 +127,10 @@ Item CRUD commands (B9B14): `org add` (`OrgAddKind`: Login/SecureNote/Identit
**A5 doc-fix** (`enforce_owner_only_elevation` parent-role close, `519e503`) and this living-docs sweep also landed. **A5 doc-fix** (`enforce_owner_only_elevation` parent-role close, `519e503`) and this living-docs sweep also landed.
**Tracked follow-ups (deferred, not shipped):** **Tracked follow-ups:**
- `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) - `org add` / `org edit` parity for Card, Key, Document, Totp — ✅ **SHIPPED v0.8.1** (`4c0a289`; all 7 item types now supported)
- Extension org-vault switch + read parity (Dev-D deferred) - Extension org-vault switch + read parity (Dev-D) — still deferred; forward plan in the parity gap analysis
- Extension org write operations - Extension org write operations — still deferred (Plan B-2)
- Phase 2: SSO/LDAP federation, read audit log, per-collection subkeys (true cryptographic scope separation), HTTP management plane - 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). **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).
@@ -169,10 +182,10 @@ Per the 2026-05-30 post-v0.6.0 audit of the three 2026-05-04 architecture-review
**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. **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.
Pending org-vault follow-ups (in rough priority order): Pending follow-ups (in rough priority order; **forward plan:** `docs/superpowers/specs/2026-06-20-extension-cli-parity-gap-analysis.md`):
- `org add`/`edit` parity for Card, SshKey, Document, Totp - **Extension org parity — read** (Dev-D): org context switch + collection-filtered browse in the popup/vault tab
- Extension org switch + read parity (Dev-D) - **Extension org parity — write** (Plan B-2): `org add`/`edit`/`rm` from the extension — blocked behind extension org-read landing (and now unblocked on the CLI side, which reached all-7-type org write in v0.8.1)
- Extension org write operations - **Personal-side extension gaps** (from the parity analysis): favorites UI, group/tag editing on all type forms, popup type/tag filters, attachment-remove router wire + per-item purge UI, autofill registrable-domain matching
- **Phase 4: command palette** — ⌘K global search + action dispatch across the vault tab (no spec yet) - **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). Long-term: relay server, mobile. See `ROADMAP.md` for the longer arc and `CHANGELOG.md` for tagged-release history (the `v0.8.1` CHANGELOG entry + version bump are owned by the PM in this lift).

View File

@@ -24,7 +24,10 @@ under `src/commands/`. Each source file has one job.
- **`src/main.rs`** (`main.rs:1-492`) — clap surface and the flat dispatcher. - **`src/main.rs`** (`main.rs:1-492`) — clap surface and the flat dispatcher.
Owns the top-level `Cli` / `Commands` enum and every subcommand enum Owns the top-level `Cli` / `Commands` enum and every subcommand enum
(`AddKind`, `TrashAction`, `SettingsAction`, `BackupAction`, `ImportAction`, (`AddKind`, `TrashAction`, `SettingsAction`, `BackupAction`, `ImportAction`,
`DeviceAction`, `RecoveryQrCmd`). `main()` is a single `match` that `DeviceAction`, `RecoveryQrCmd`), plus the org clap surface `OrgCommands`
(`main.rs:448`) and `OrgAddKind` (`main.rs:556`) — the latter's Card / Key /
Document / Totp variants carry `--collection` and the `--*-stdin` secret flags.
`main()` is a single `match` that
delegates each variant to `commands::<verb>::cmd_<verb>(...)`. Also owns the delegates each variant to `commands::<verb>::cmd_<verb>(...)`. Also owns the
three test-only env-var hooks (`test_passphrase_override`, three test-only env-var hooks (`test_passphrase_override`,
`test_item_secret_override`, `test_backup_passphrase_override`) — each is `test_item_secret_override`, `test_backup_passphrase_override`) — each is
@@ -94,7 +97,14 @@ under `src/commands/`. Each source file has one job.
(`items/<collection-slug>/<id>.enc` — the leading slug is what the pre-receive (`items/<collection-slug>/<id>.enc` — the leading slug is what the pre-receive
hook authorizes against, never decrypting), fingerprint-based member matching hook authorizes against, never decrypting), fingerprint-based member matching
(`relicario_core::fingerprint`, tolerant of OpenSSH whitespace/comment (`relicario_core::fingerprint`, tolerant of OpenSSH whitespace/comment
differences), `atomic_write`, and `org_git_run`. Note `org_git_run` runs differences), `atomic_write`, and `org_git_run`. As of v0.8.1 it also owns
**collection-scoped attachment storage**`attachment_path` /
`save_attachment` / `load_attachment` / `remove_item_attachments`
(`org_session.rs:125-157`) at layout
`attachments/<collection-slug>/<item-id>/<att-id>.enc` (the same leading slug
the pre-receive hook authorizes against as for `item_path`), capped
per-attachment by `DEFAULT_ORG_ATTACHMENT_MAX_BYTES` (10 MiB,
`org_session.rs:20`). Note `org_git_run` runs
**bare git** — unlike `helpers::git_run` it does NOT inject **bare git** — unlike `helpers::git_run` it does NOT inject
`commit.gpgsign=false`, because org commits MUST be signed (the hook verifies `commit.gpgsign=false`, because org commits MUST be signed (the hook verifies
every commit's signature); signing config is established by every commit's signature); signing config is established by
@@ -111,19 +121,38 @@ under `src/commands/`. Each source file has one job.
concurrent-rotation abort), `transfer-ownership`, `delete-org`, `status` / concurrent-rotation abort), `transfer-ownership`, `delete-org`, `status` /
`audit` (verified-signer attribution + `TAMPERED` flag). `audit` (verified-signer attribution + `TAMPERED` flag).
*Item CRUD (7):* `org add` creates typed items via `OrgAddKind` *Item CRUD (7):* full item-type parity with the personal vault (v0.8.1).
(`commands/org.rs:749`) — **Login / SecureNote / Identity only**; Card / `org add` creates **all seven types** (Login / SecureNote / Identity / Card /
SshKey / Document / Totp creation is a deferred follow-up. `get` / `list` can Key / Document / Totp) via `OrgAddKind` (`commands/org.rs:751`); each arm
display any item type if present. `org get <query> [--show]` masks secrets delegates to the shared `item_build::build_*` builders through `build_org_item`
unless `--show`; `org list [--trashed]` filters by the caller's collection (`commands/org.rs:799`), and `run_add` (`commands/org.rs:823`) sets tags
grants; `org edit <query>` is flag-driven (blank flags keep current values); post-build. Document is special-cased in `run_add` (`commands/org.rs:839`): its
`org rm` soft-deletes, `org restore` undoes, `org purge` permanently removes builder also yields an `EncryptedAttachment` that is written via
the encrypted blob. All item ops are collection-scoped and grant-enforced. The `save_attachment` and git-staged before the signed commit. Single-line secrets
audit trail emits `item-create` / `item-update` / `item-delete` / (card number/CVV/PIN, TOTP secret, login password) accept a `--*-stdin` flag;
`item-restore` / `item-purge`. multiline secrets (Key material, SecureNote body) read stdin to EOF — the same
`resolve_secret_line` / `resolve_secret_multiline` convention as personal `add`
(`commands/item_build.rs`).
Deferred: Card / SshKey / Document / Totp `org add` / `edit` parity; `org edit <query>` (`run_edit`, `commands/org.rs:1004`) is **interactive
extension org reads and writes (Dev-D). per-type** as of v0.8.1 (it was flag-driven before): it prompts Title, then
dispatches on `&mut item.core` to the shared `item_build::edit_*` helpers
("blank keeps current", field-history capture via `push_history`), mirroring
personal `cmd_edit`. `--totp-qr` sets a Login TOTP from a QR image; `--file`
replaces a Document's primary attachment (`commands/org.rs:1039`, rejected for
non-Document items at `commands/org.rs:1018`). The edit commit carries
`Relicario-Action: item-update`.
`org get <query> [--show]` masks every secret unless `--show`; `org list
[--trashed]` filters by the caller's collection grants; `org rm` soft-deletes,
`org restore` undoes, `org purge` (`run_purge`, `commands/org.rs:1164`)
permanently removes the encrypted blob **and** the item's attachment directory
(`remove_item_attachments`, `commands/org.rs:1173`). All item ops are
collection-scoped and grant-enforced (`filter_for_member` over the manifest +
`ensure_grant` before any load/mutate). The audit trail emits `item-create` /
`item-update` / `item-delete` / `item-restore` / `item-purge`.
Deferred: extension org reads and writes (Plan B-2 / phase 2).
- **`src/helpers.rs`** (`helpers.rs:1-101`) — pure, no-state plumbing: - **`src/helpers.rs`** (`helpers.rs:1-101`) — pure, no-state plumbing:
`find_vault_dir_from` (`helpers.rs:14-28`) walks up parent directories `find_vault_dir_from` (`helpers.rs:14-28`) walks up parent directories

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "relicario-cli" name = "relicario-cli"
version = "0.8.0" version = "0.8.1"
edition = "2021" edition = "2021"
description = "CLI for relicario password manager" description = "CLI for relicario password manager"
license = "GPL-3.0-or-later" license = "GPL-3.0-or-later"

View File

@@ -3,7 +3,7 @@
//! (`commands/org.rs`). Centralizing it keeps the two surfaces from drifting. //! (`commands/org.rs`). Centralizing it keeps the two surfaces from drifting.
use std::collections::HashMap; use std::collections::HashMap;
use std::path::PathBuf; use std::path::{Path, PathBuf};
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use zeroize::Zeroizing; use zeroize::Zeroizing;
@@ -255,23 +255,39 @@ pub(crate) fn build_totp(
}))) })))
} }
/// Read a file and encrypt it as an attachment under `key`, deriving its display
/// metadata. The plaintext is held in a `Zeroizing` buffer so it is wiped after
/// encryption. Returns the encrypted blob plus (filename, mime_type, size).
pub(crate) fn encrypt_document_file(
path: &Path,
key: &Zeroizing<[u8; 32]>,
max_bytes: u64,
) -> Result<(EncryptedAttachment, String, String, u64)> {
use relicario_core::encrypt_attachment;
let bytes = Zeroizing::new(
std::fs::read(path).with_context(|| format!("failed to read {}", path.display()))?,
);
let enc = encrypt_attachment(&bytes, key, max_bytes)?;
let filename = path
.file_name()
.ok_or_else(|| anyhow::anyhow!("file path has no filename: {}", path.display()))?
.to_string_lossy()
.into_owned();
let mime_type = crate::parse::guess_mime(&filename);
Ok((enc, filename, mime_type, bytes.len() as u64))
}
pub(crate) fn build_document( pub(crate) fn build_document(
title: String, file: PathBuf, key: &Zeroizing<[u8; 32]>, max_bytes: u64, title: String, file: PathBuf, key: &Zeroizing<[u8; 32]>, max_bytes: u64,
) -> Result<(Item, EncryptedAttachment)> { ) -> Result<(Item, EncryptedAttachment)> {
use relicario_core::item_types::DocumentCore; use relicario_core::item_types::DocumentCore;
use relicario_core::{encrypt_attachment, AttachmentRef}; use relicario_core::AttachmentRef;
let bytes = std::fs::read(&file).with_context(|| format!("failed to read {}", file.display()))?; let (enc, filename, mime_type, size) = encrypt_document_file(&file, key, max_bytes)?;
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 { let mut item = Item::new(title, ItemCore::Document(DocumentCore {
filename: filename.clone(), mime_type: mime_type.clone(), primary_attachment: primary_attachment.clone(), filename: filename.clone(), mime_type: mime_type.clone(), primary_attachment: enc.id.clone(),
})); }));
item.attachments.push(AttachmentRef { item.attachments.push(AttachmentRef {
id: primary_attachment, filename, mime_type, size: bytes.len() as u64, created: item.created, id: enc.id.clone(), filename, mime_type, size, created: item.created,
}); });
Ok((item, enc)) Ok((item, enc))
} }

View File

@@ -6,12 +6,13 @@ use std::path::Path;
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use relicario_core::{ use relicario_core::{
generate_org_key, wrap_org_key, generate_org_key, wrap_org_key,
CollectionDef, Item, ItemCore, MemberId, OrgCollections, OrgManifest, OrgMembers, OrgMeta, CollectionDef, Item, MemberId, OrgCollections, OrgManifest, OrgMembers, OrgMeta,
OrgRole, OrgMember, OrgRole, OrgMember,
encrypt_org_manifest, encrypt_org_manifest,
}; };
use crate::org_session::atomic_write; use crate::org_session::atomic_write;
use crate::commands::item_build as ib;
pub fn run_init(dir: &Path, name: &str) -> Result<()> { pub fn run_init(dir: &Path, name: &str) -> Result<()> {
// Create directory structure // Create directory structure
@@ -745,17 +746,20 @@ pub fn run_audit(
Ok(()) Ok(())
} }
/// Item kinds `org add` supports without interactive prompts. /// Item kinds `org add` supports. Secrets resolve via `--*-stdin` flags or an
/// interactive prompt inside the shared `item_build` builders.
pub enum OrgAddKind { pub enum OrgAddKind {
Login { Login {
title: String, title: String,
username: Option<String>, username: Option<String>,
url: Option<String>, url: Option<String>,
password: Option<String>, password: Option<String>,
password_stdin: bool,
}, },
SecureNote { SecureNote {
title: String, title: String,
body: String, body: Option<String>,
body_stdin: bool,
}, },
Identity { Identity {
title: String, title: String,
@@ -763,43 +767,57 @@ pub enum OrgAddKind {
email: Option<String>, email: Option<String>,
phone: Option<String>, phone: Option<String>,
}, },
Card {
title: String,
holder: Option<String>,
expiry: Option<String>,
kind: String,
number_stdin: bool,
cvv_stdin: bool,
pin_stdin: bool,
},
Key {
title: String,
label: Option<String>,
algorithm: Option<String>,
public_key: Option<String>,
material_stdin: bool,
},
Totp {
title: String,
issuer: Option<String>,
label: Option<String>,
secret: Option<String>,
secret_stdin: bool,
period: u32,
digits: u8,
algorithm: String,
},
Document { title: String, file: std::path::PathBuf },
} }
fn build_org_item(kind: OrgAddKind, tags: Vec<String>) -> Result<Item> { fn build_org_item(kind: OrgAddKind) -> Result<Item> {
use relicario_core::item_types::{IdentityCore, LoginCore, SecureNoteCore}; match kind {
use zeroize::Zeroizing; OrgAddKind::Login { title, username, url, password, password_stdin } => {
ib::build_login(title, username, url, password, password_stdin, false, None)
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 } => { OrgAddKind::SecureNote { title, body, body_stdin } => {
Item::new(title, ItemCore::SecureNote(SecureNoteCore { ib::build_secure_note(title, body, body_stdin)
body: Zeroizing::new(body),
}))
} }
OrgAddKind::Identity { title, full_name, email, phone } => { OrgAddKind::Identity { title, full_name, email, phone } => {
Item::new(title, ItemCore::Identity(IdentityCore { ib::build_identity(title, full_name, email, phone, None)
full_name,
address: None,
phone,
email,
date_of_birth: None,
}))
} }
}; OrgAddKind::Card { title, holder, expiry, kind, number_stdin, cvv_stdin, pin_stdin } => {
item.tags = tags; ib::build_card(title, holder, expiry, &kind, number_stdin, cvv_stdin, pin_stdin)
Ok(item) }
OrgAddKind::Key { title, label, algorithm, public_key, material_stdin } => {
ib::build_key(title, label, algorithm, public_key, material_stdin)
}
OrgAddKind::Totp { title, issuer, label, secret, secret_stdin, period, digits, algorithm } => {
ib::build_totp(title, issuer, label, secret, secret_stdin, period, digits, &algorithm)
}
OrgAddKind::Document { .. } => unreachable!("Document handled in run_add before build_org_item"),
}
} }
pub fn run_add(dir: &Path, collection: &str, kind: OrgAddKind, tags: Vec<String>) -> Result<()> { pub fn run_add(dir: &Path, collection: &str, kind: OrgAddKind, tags: Vec<String>) -> Result<()> {
@@ -816,7 +834,17 @@ pub fn run_add(dir: &Path, collection: &str, kind: OrgAddKind, tags: Vec<String>
// …and the caller must hold a grant for it. // …and the caller must hold a grant for it.
UnlockedOrgVault::ensure_grant(&caller, collection)?; UnlockedOrgVault::ensure_grant(&caller, collection)?;
let item = build_org_item(kind, tags)?; // Build the item; Document additionally yields an encrypted attachment to persist.
let (mut item, attachment_rel): (relicario_core::Item, Option<String>) = match kind {
OrgAddKind::Document { title, file } => {
let (item, enc) = ib::build_document(
title, file, vault.key(), crate::org_session::DEFAULT_ORG_ATTACHMENT_MAX_BYTES)?;
let rel = vault.save_attachment(collection, &item.id, &enc)?;
(item, Some(rel))
}
other => (build_org_item(other)?, None),
};
item.tags = tags;
let item_rel = vault.save_item(collection, &item)?; let item_rel = vault.save_item(collection, &item)?;
// Upsert the manifest entry (collection slug stored plaintext inside the // Upsert the manifest entry (collection slug stored plaintext inside the
@@ -837,11 +865,11 @@ pub fn run_add(dir: &Path, collection: &str, kind: OrgAddKind, tags: Vec<String>
collection, collection,
item.id.as_str() item.id.as_str()
); );
crate::org_session::org_git_run( let mut add_args: Vec<&str> = vec!["add", &item_rel, "manifest.enc"];
&vault.root, if let Some(ref rel) = attachment_rel {
&["add", &item_rel, "manifest.enc"], add_args.insert(1, rel);
"org add: git add", }
)?; crate::org_session::org_git_run(&vault.root, &add_args, "org add: git add")?;
crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "org add: git commit")?; 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); println!("Added {} ({}) to `{}`", item.title, item.id.as_str(), collection);
@@ -973,21 +1001,9 @@ fn resolve_org_query<'a>(
} }
} }
pub fn run_edit( pub fn run_edit(dir: &Path, query: &str, totp_qr: Option<std::path::PathBuf>, file: Option<std::path::PathBuf>) -> Result<()> {
dir: &Path,
query: &str,
title: Option<String>,
username: Option<String>,
url: Option<String>,
password: Option<String>,
body: Option<String>,
email: Option<String>,
phone: Option<String>,
full_name: Option<String>,
) -> Result<()> {
use relicario_core::time::now_unix; use relicario_core::time::now_unix;
use relicario_core::ItemCore; use relicario_core::ItemCore;
use zeroize::Zeroizing;
let vault = crate::org_session::open_org_vault(Some(dir))?; let vault = crate::org_session::open_org_vault(Some(dir))?;
let caller = vault.current_member()?; let caller = vault.current_member()?;
@@ -999,31 +1015,56 @@ pub fn run_edit(
crate::org_session::UnlockedOrgVault::ensure_grant(&caller, &collection)?; crate::org_session::UnlockedOrgVault::ensure_grant(&caller, &collection)?;
let mut item = vault.load_item(&collection, &id)?; let mut item = vault.load_item(&collection, &id)?;
if file.is_some() && !matches!(item.core, ItemCore::Document(_)) {
anyhow::bail!("--file is only valid when editing a Document item");
}
eprintln!(
"Editing: {} ({}) — leave a prompt blank to keep the current value.",
item.title,
item.id.as_str()
);
if let Some(v) = crate::prompt::prompt_keep("Title", &item.title)? {
item.title = v;
}
if let Some(t) = title { item.title = t; } let history = &mut item.field_history;
let mut doc_attachment_rel: Option<String> = None;
let mut new_doc_attachments: Option<Vec<relicario_core::AttachmentRef>> = None;
match &mut item.core { match &mut item.core {
ItemCore::Login(l) => { ItemCore::Login(l) => ib::edit_login(l, history, totp_qr)?,
if let Some(u) = username { l.username = Some(u); } ItemCore::SecureNote(n) => ib::edit_secure_note(n, history)?,
if let Some(u) = url { ItemCore::Identity(i) => ib::edit_identity(i)?,
l.url = Some(url::Url::parse(&u).with_context(|| format!("invalid URL: {u}"))?); ItemCore::Card(c) => ib::edit_card(c, history)?,
ItemCore::Key(k) => ib::edit_key(k, history)?,
ItemCore::Document(d) => {
if let Some(path) = &file {
let (enc, filename, mime_type, size) = ib::encrypt_document_file(
path, vault.key(), crate::org_session::DEFAULT_ORG_ATTACHMENT_MAX_BYTES)?;
vault.remove_item_attachments(&collection, &id)?;
let rel = vault.save_attachment(&collection, &id, &enc)?;
d.filename = filename.clone();
d.mime_type = mime_type.clone();
d.primary_attachment = enc.id.clone();
new_doc_attachments = Some(vec![relicario_core::AttachmentRef {
id: enc.id,
filename,
mime_type,
size,
created: now_unix(),
}]);
doc_attachment_rel = Some(rel);
} else {
ib::edit_document_message();
} }
if let Some(p) = password { l.password = Some(Zeroizing::new(p)); }
} }
ItemCore::SecureNote(n) => { ItemCore::Totp(t) => ib::edit_totp(t, history)?,
if let Some(b) = body { n.body = Zeroizing::new(b); } }
} if let Some(atts) = new_doc_attachments {
ItemCore::Identity(i) => { item.attachments = atts;
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(); item.modified = now_unix();
let item_rel = vault.save_item(&collection, &item)?; let item_rel = vault.save_item(&collection, &item)?;
let mut manifest = vault.load_manifest()?; let mut manifest = vault.load_manifest()?;
upsert_org_entry(&mut manifest, &item, &collection); upsert_org_entry(&mut manifest, &item, &collection);
vault.save_manifest(&manifest)?; vault.save_manifest(&manifest)?;
@@ -1035,12 +1076,20 @@ pub fn run_edit(
); );
let commit_msg = format!( let commit_msg = format!(
"{subject}\n\nRelicario-Actor: {} {}\nRelicario-Action: item-update\nRelicario-Collection: {}\nRelicario-Item: {}", "{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() 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")?; let mut add_args: Vec<&str> = vec!["add", &item_rel, "manifest.enc"];
let att_dir_rel;
if doc_attachment_rel.is_some() {
att_dir_rel = format!("attachments/{}/{}", collection, id.as_str());
add_args.push(&att_dir_rel);
}
crate::org_session::org_git_run(&vault.root, &add_args, "org edit: git add")?;
crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "org edit: git commit")?; crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "org edit: git commit")?;
println!("Updated {} ({}) in `{}`", item.title, item.id.as_str(), collection);
println!("Updated {}", item.id.as_str());
Ok(()) Ok(())
} }
@@ -1114,12 +1163,14 @@ pub fn run_purge(dir: &Path, query: &str) -> Result<()> {
// Remove the blob from disk, drop the manifest entry, stage with git rm. // Remove the blob from disk, drop the manifest entry, stage with git rm.
vault.remove_item(&collection, &id)?; vault.remove_item(&collection, &id)?;
vault.remove_item_attachments(&collection, &id)?;
let mut manifest = vault.load_manifest()?; let mut manifest = vault.load_manifest()?;
manifest.entries.retain(|e| e.id != id); manifest.entries.retain(|e| e.id != id);
vault.save_manifest(&manifest)?; vault.save_manifest(&manifest)?;
let item_rel = format!("items/{}/{}.enc", collection, id.as_str()); let item_rel = format!("items/{}/{}.enc", collection, id.as_str());
crate::helpers::git_rm(&vault.root, &[item_rel], "org purge: git rm")?; let att_dir_rel = format!("attachments/{}/{}", collection, id.as_str());
crate::helpers::git_rm(&vault.root, &[item_rel, att_dir_rel], "org purge: git rm")?;
crate::org_session::org_git_run(&vault.root, &["add", "manifest.enc"], "org purge: git add manifest")?; crate::org_session::org_git_run(&vault.root, &["add", "manifest.enc"], "org purge: git add manifest")?;
let commit_msg = format!( let commit_msg = format!(

View File

@@ -535,18 +535,14 @@ pub(crate) enum OrgCommands {
List { List {
#[arg(long)] trashed: bool, #[arg(long)] trashed: bool,
}, },
/// Edit an org item's fields (flag-driven; blank flags keep current values). /// Edit an org item interactively (per-type prompts; blank keeps current).
Edit { Edit {
/// Item id or case-insensitive title substring. /// Item id or case-insensitive title substring.
query: String, query: String,
#[arg(long)] title: Option<String>, /// Replace the login TOTP secret from a QR image.
#[arg(long)] username: Option<String>, #[arg(long)] totp_qr: Option<std::path::PathBuf>,
#[arg(long)] url: Option<String>, /// Replace a Document item's attachment file.
#[arg(long)] password: Option<String>, #[arg(long)] file: Option<std::path::PathBuf>,
#[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`). /// Soft-delete an org item (reversible via `org restore`).
Rm { query: String }, Rm { query: String },
@@ -566,13 +562,15 @@ pub(crate) enum OrgAddKind {
#[arg(long)] url: Option<String>, #[arg(long)] url: Option<String>,
#[arg(long)] password: Option<String>, #[arg(long)] password: Option<String>,
#[arg(long, value_delimiter = ',')] tags: Vec<String>, #[arg(long, value_delimiter = ',')] tags: Vec<String>,
#[arg(long)] password_stdin: bool,
}, },
/// A secure note. /// A secure note.
SecureNote { SecureNote {
#[arg(long)] collection: String, #[arg(long)] collection: String,
#[arg(long)] title: String, #[arg(long)] title: String,
#[arg(long)] body: String, #[arg(long)] body: Option<String>,
#[arg(long, value_delimiter = ',')] tags: Vec<String>, #[arg(long, value_delimiter = ',')] tags: Vec<String>,
#[arg(long)] body_stdin: bool,
}, },
/// An identity record. /// An identity record.
Identity { Identity {
@@ -583,6 +581,48 @@ pub(crate) enum OrgAddKind {
#[arg(long)] phone: Option<String>, #[arg(long)] phone: Option<String>,
#[arg(long, value_delimiter = ',')] tags: Vec<String>, #[arg(long, value_delimiter = ',')] tags: Vec<String>,
}, },
/// A payment card (number / cvv / pin entered via --*-stdin or prompt).
Card {
#[arg(long)] collection: String,
#[arg(long)] title: String,
#[arg(long)] holder: Option<String>,
#[arg(long)] expiry: Option<String>,
#[arg(long, default_value = "credit")] kind: String,
#[arg(long, value_delimiter = ',')] tags: Vec<String>,
#[arg(long)] number_stdin: bool,
#[arg(long)] cvv_stdin: bool,
#[arg(long)] pin_stdin: bool,
},
/// A key / credential blob (material entered via --material-stdin or prompt).
Key {
#[arg(long)] collection: String,
#[arg(long)] title: String,
#[arg(long)] label: Option<String>,
#[arg(long)] algorithm: Option<String>,
#[arg(long)] public_key: Option<String>,
#[arg(long, value_delimiter = ',')] tags: Vec<String>,
#[arg(long)] material_stdin: bool,
},
/// A TOTP authenticator (base32 secret via --secret or --secret-stdin).
Totp {
#[arg(long)] collection: String,
#[arg(long)] title: String,
#[arg(long)] issuer: Option<String>,
#[arg(long)] label: Option<String>,
#[arg(long)] secret: Option<String>,
#[arg(long, default_value_t = 30)] period: u32,
#[arg(long, default_value_t = 6)] digits: u8,
#[arg(long, default_value = "sha1")] algorithm: String,
#[arg(long, value_delimiter = ',')] tags: Vec<String>,
#[arg(long)] secret_stdin: bool,
},
/// A document (file payload encrypted into a collection-scoped attachment).
Document {
#[arg(long)] collection: String,
#[arg(long)] title: String,
#[arg(long)] file: std::path::PathBuf,
#[arg(long, value_delimiter = ',')] tags: Vec<String>,
},
} }
fn main() -> Result<()> { fn main() -> Result<()> {
@@ -676,14 +716,14 @@ fn main() -> Result<()> {
OrgCommands::Add { kind } => { OrgCommands::Add { kind } => {
let d = crate::org_session::org_dir(dir_path)?; let d = crate::org_session::org_dir(dir_path)?;
let (collection, add_kind, tags) = match kind { let (collection, add_kind, tags) = match kind {
OrgAddKind::Login { collection, title, username, url, password, tags } => ( OrgAddKind::Login { collection, title, username, url, password, tags, password_stdin } => (
collection, collection,
commands::org::OrgAddKind::Login { title, username, url, password }, commands::org::OrgAddKind::Login { title, username, url, password, password_stdin },
tags, tags,
), ),
OrgAddKind::SecureNote { collection, title, body, tags } => ( OrgAddKind::SecureNote { collection, title, body, tags, body_stdin } => (
collection, collection,
commands::org::OrgAddKind::SecureNote { title, body }, commands::org::OrgAddKind::SecureNote { title, body, body_stdin },
tags, tags,
), ),
OrgAddKind::Identity { collection, title, full_name, email, phone, tags } => ( OrgAddKind::Identity { collection, title, full_name, email, phone, tags } => (
@@ -691,6 +731,26 @@ fn main() -> Result<()> {
commands::org::OrgAddKind::Identity { title, full_name, email, phone }, commands::org::OrgAddKind::Identity { title, full_name, email, phone },
tags, tags,
), ),
OrgAddKind::Card { collection, title, holder, expiry, kind, tags, number_stdin, cvv_stdin, pin_stdin } => (
collection,
commands::org::OrgAddKind::Card { title, holder, expiry, kind, number_stdin, cvv_stdin, pin_stdin },
tags,
),
OrgAddKind::Key { collection, title, label, algorithm, public_key, tags, material_stdin } => (
collection,
commands::org::OrgAddKind::Key { title, label, algorithm, public_key, material_stdin },
tags,
),
OrgAddKind::Totp { collection, title, issuer, label, secret, period, digits, algorithm, tags, secret_stdin } => (
collection,
commands::org::OrgAddKind::Totp { title, issuer, label, secret, secret_stdin, period, digits, algorithm },
tags,
),
OrgAddKind::Document { collection, title, file, tags } => (
collection,
commands::org::OrgAddKind::Document { title, file },
tags,
),
}; };
commands::org::run_add(&d, &collection, add_kind, tags)?; commands::org::run_add(&d, &collection, add_kind, tags)?;
} }
@@ -702,9 +762,9 @@ fn main() -> Result<()> {
let d = crate::org_session::org_dir(dir_path)?; let d = crate::org_session::org_dir(dir_path)?;
commands::org::run_list(&d, trashed)?; commands::org::run_list(&d, trashed)?;
} }
OrgCommands::Edit { query, title, username, url, password, body, email, phone, full_name } => { OrgCommands::Edit { query, totp_qr, file } => {
let d = crate::org_session::org_dir(dir_path)?; let d = crate::org_session::org_dir(dir_path)?;
commands::org::run_edit(&d, &query, title, username, url, password, body, email, phone, full_name)?; commands::org::run_edit(&d, &query, totp_qr, file)?;
} }
OrgCommands::Rm { query } => { OrgCommands::Rm { query } => {
let d = crate::org_session::org_dir(dir_path)?; let d = crate::org_session::org_dir(dir_path)?;

View File

@@ -9,9 +9,16 @@ use zeroize::Zeroizing;
use relicario_core::{ use relicario_core::{
decrypt_item, decrypt_org_manifest, encrypt_item, encrypt_org_manifest, decrypt_item, decrypt_org_manifest, encrypt_item, encrypt_org_manifest,
Item, ItemId, MemberId, OrgCollections, OrgManifest, OrgMember, OrgMembers, OrgMeta, AttachmentId, EncryptedAttachment, Item, ItemId, MemberId, OrgCollections, OrgManifest,
OrgMember, OrgMembers, OrgMeta,
}; };
/// Default per-attachment cap for org vaults. Org vaults have no settings.enc,
/// so this mirrors the personal-vault default
/// `AttachmentCaps::per_attachment_max_bytes` at
/// crates/relicario-core/src/settings.rs:116.
pub const DEFAULT_ORG_ATTACHMENT_MAX_BYTES: u64 = 10 * 1024 * 1024;
pub struct UnlockedOrgVault { pub struct UnlockedOrgVault {
pub root: PathBuf, pub root: PathBuf,
pub org_key: Zeroizing<[u8; 32]>, pub org_key: Zeroizing<[u8; 32]>,
@@ -115,6 +122,40 @@ impl UnlockedOrgVault {
} }
} }
pub fn attachment_path(&self, collection_slug: &str, item_id: &ItemId, att_id: &AttachmentId) -> PathBuf {
self.root.join("attachments").join(collection_slug)
.join(item_id.as_str()).join(format!("{}.enc", att_id.as_str()))
}
/// Encrypt-already-done blob: persist it and return the repo-relative path for git staging.
pub fn save_attachment(&self, collection_slug: &str, item_id: &ItemId, enc: &EncryptedAttachment) -> Result<String> {
let path = self.attachment_path(collection_slug, item_id, &enc.id);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
}
atomic_write(&path, &enc.bytes)?;
Ok(format!("attachments/{}/{}/{}.enc", collection_slug, item_id.as_str(), enc.id.as_str()))
}
// Retained for a future `org document read/extract` command (mirrors `org_meta_path` convention).
#[allow(dead_code)]
pub fn load_attachment(&self, collection_slug: &str, item_id: &ItemId, att_id: &AttachmentId) -> Result<Zeroizing<Vec<u8>>> {
let path = self.attachment_path(collection_slug, item_id, att_id);
let bytes = fs::read(&path).with_context(|| format!("read attachment {}", path.display()))?;
Ok(relicario_core::decrypt_attachment(&bytes, &self.org_key)?)
}
/// Remove an item's whole attachment directory. Missing dir is NOT an error
/// (mirrors `remove_item`'s NotFound-tolerant behavior, for partial-write recovery).
pub fn remove_item_attachments(&self, collection_slug: &str, item_id: &ItemId) -> Result<()> {
let dir = self.root.join("attachments").join(collection_slug).join(item_id.as_str());
match fs::remove_dir_all(&dir) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(anyhow::Error::from(e).context(format!("remove {}", dir.display()))),
}
}
/// Bail unless `member` has `slug` in their collection grants. The slug /// Bail unless `member` has `slug` in their collection grants. The slug
/// existence check is done separately by the caller against collections.json. /// existence check is done separately by the caller against collections.json.
pub fn ensure_grant(member: &OrgMember, slug: &str) -> Result<()> { pub fn ensure_grant(member: &OrgMember, slug: &str) -> Result<()> {
@@ -292,6 +333,22 @@ mod tests {
assert_eq!(loaded.entries.len(), 1); assert_eq!(loaded.entries.len(), 1);
} }
#[test]
fn attachment_round_trip_collection_scoped() {
use relicario_core::encrypt_attachment;
let key = Zeroizing::new([7u8; 32]);
let (dir, vault) = make_vault(key);
let _ = dir; // keep tempdir alive
let item_id = ItemId::new();
let enc = encrypt_attachment(b"hello world", &vault.org_key, DEFAULT_ORG_ATTACHMENT_MAX_BYTES).unwrap();
let rel = vault.save_attachment("eng", &item_id, &enc).unwrap();
assert_eq!(rel, format!("attachments/eng/{}/{}.enc", item_id.as_str(), enc.id.as_str()));
let got = vault.load_attachment("eng", &item_id, &enc.id).unwrap();
assert_eq!(got.as_slice(), b"hello world");
vault.remove_item_attachments("eng", &item_id).unwrap();
assert!(vault.load_attachment("eng", &item_id, &enc.id).is_err());
}
#[test] #[test]
fn save_and_load_members() { fn save_and_load_members() {
let key = Zeroizing::new([0u8; 32]); let key = Zeroizing::new([0u8; 32]);

View File

@@ -152,7 +152,9 @@ fn org_get_edit_rm_restore_purge_reject_ungranted_member() {
); );
for (label, args) in [ for (label, args) in [
("edit", vec!["org", "edit", "GitHub", "--username", "evil"]), // `org edit` is now interactive (no flat flags); the ungranted member is
// rejected at manifest lookup, before any prompt is read.
("edit", vec!["org", "edit", "GitHub"]),
("rm", vec!["org", "rm", "GitHub"]), ("rm", vec!["org", "rm", "GitHub"]),
("restore", vec!["org", "restore", "GitHub"]), ("restore", vec!["org", "restore", "GitHub"]),
("purge", vec!["org", "purge", "GitHub"]), ("purge", vec!["org", "purge", "GitHub"]),
@@ -170,13 +172,12 @@ fn org_get_edit_rm_restore_purge_reject_ungranted_member() {
} }
// The item is untouched: the owner can still read the original password and // 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. // username the ungranted member's get/edit/rm/restore/purge were all denied.
let owner_get = owner_dev.run(vault, &["org", "get", "GitHub", "--show"]); let owner_get = owner_dev.run(vault, &["org", "get", "GitHub", "--show"]);
let owner_out = String::from_utf8_lossy(&owner_get.stdout).to_string(); 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_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("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("alice"), "ungranted member must not have modified the item: {owner_out}");
assert!(!owner_out.contains("evil"), "ungranted edit leaked through: {owner_out}");
} }
#[test] #[test]

View File

@@ -67,6 +67,39 @@ impl OrgFixture {
let v: serde_json::Value = serde_json::from_str(&s).unwrap(); let v: serde_json::Value = serde_json::from_str(&s).unwrap();
v["members"][0]["member_id"].as_str().unwrap().to_string() v["members"][0]["member_id"].as_str().unwrap().to_string()
} }
/// Like `run`, but pipes `stdin_data` into the child's stdin — used to drive
/// `--*-stdin` secret flags and the interactive edit prompts. `wait_with_output`
/// closes stdin for us, so multiline secrets (read-to-EOF) terminate cleanly.
fn run_stdin(&self, args: &[&str], stdin_data: &str) -> std::process::Output {
use std::io::Write as _;
let mut child = Command::cargo_bin("relicario")
.unwrap()
.env("XDG_CONFIG_HOME", &self.xdg)
.env("RELICARIO_ORG_DIR", self.vault.path())
.args(args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
child.stdin.as_mut().unwrap().write_all(stdin_data.as_bytes()).unwrap();
child.wait_with_output().unwrap()
}
/// Create collection `slug` and grant the owner access to it — the common
/// setup the item-type round-trip tests share.
fn create_collection_and_grant(&self, slug: &str) {
let owner = self.owner_member_id();
assert!(
self.run(&["org", "create-collection", slug, "--name", slug]).status.success(),
"create-collection {slug} failed",
);
assert!(
self.run(&["org", "grant", &owner, slug]).status.success(),
"grant {slug} failed",
);
}
} }
#[test] #[test]
@@ -151,21 +184,17 @@ fn org_add_rejects_unknown_collection() {
#[test] #[test]
fn org_edit_updates_fields_and_commits_update_trailer() { fn org_edit_updates_fields_and_commits_update_trailer() {
let f = OrgFixture::new(); let f = OrgFixture::new();
let owner = f.owner_member_id(); f.create_collection_and_grant("prod");
assert!(f.run(&["org", "create-collection", "prod", "--name", "Production"]).status.success());
assert!(f.run(&["org", "grant", &owner, "prod"]).status.success());
assert!(f.run(&[ assert!(f.run(&[
"org", "add", "login", "--collection", "prod", "org", "add", "login", "--collection", "prod",
"--title", "Mail", "--username", "old", "--password", "pw", "--title", "Mail", "--username", "old", "--password", "pw",
]).status.success()); ]).status.success());
// Edit the username. // org edit is now interactive per-type: keep title, set username=new-user,
let out = f.run(&[ // keep URL, decline password change.
"org", "edit", "Mail", "--username", "new-user", let out = f.run_stdin(&["org", "edit", "Mail"], "\nnew-user\n\nn\n");
]);
assert!(out.status.success(), "org edit: {}", String::from_utf8_lossy(&out.stderr)); 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 out = f.run(&["org", "get", "Mail", "--show"]);
let stdout = String::from_utf8_lossy(&out.stdout).to_string(); let stdout = String::from_utf8_lossy(&out.stdout).to_string();
assert!(stdout.contains("new-user"), "edit did not take: {stdout}"); assert!(stdout.contains("new-user"), "edit did not take: {stdout}");
@@ -215,3 +244,344 @@ fn org_rm_restore_purge_cycle() {
let body = String::from_utf8_lossy(&log.stdout).to_string(); let body = String::from_utf8_lossy(&log.stdout).to_string();
assert!(body.contains("Relicario-Action: item-purge"), "missing purge trailer: {body}"); assert!(body.contains("Relicario-Action: item-purge"), "missing purge trailer: {body}");
} }
// --- v0.8.1 org item-type parity: Card / Key / Totp -------------------------
// These drive the new `org add <card|key|totp>` subcommands. Secrets enter via
// `--*-stdin` (read from piped stdin) or, for Totp, the `--secret` flag. `org get`
// must mask every secret unless `--show` is passed — asserted below.
#[test]
fn org_add_card_via_stdin_then_get_masks_secret() {
let f = OrgFixture::new();
f.create_collection_and_grant("eng");
// build_card reads number, then cvv, then pin — one line each, in that order.
let out = f.run_stdin(
&[
"org", "add", "card", "--collection", "eng", "--title", "Corp Visa",
"--kind", "credit", "--number-stdin", "--cvv-stdin", "--pin-stdin",
],
"4111111111111111\n123\n4321\n",
);
assert!(out.status.success(), "add card: {}", String::from_utf8_lossy(&out.stderr));
// get masks the card number by default.
let got = f.run(&["org", "get", "Corp Visa"]);
let stdout = String::from_utf8_lossy(&got.stdout).to_string();
assert!(stdout.contains("Corp Visa"), "title missing: {stdout}");
assert!(stdout.contains("********"), "card number must be masked without --show: {stdout}");
assert!(!stdout.contains("4111111111111111"), "secret leaked without --show: {stdout}");
// --show reveals it.
let shown = f.run(&["org", "get", "Corp Visa", "--show"]);
let shown = String::from_utf8_lossy(&shown.stdout).to_string();
assert!(shown.contains("4111111111111111"), "number not revealed with --show: {shown}");
}
#[test]
fn org_add_key_via_stdin_then_get_masks_material() {
let f = OrgFixture::new();
f.create_collection_and_grant("eng");
// build_key reads key material from stdin to EOF (multiline secret).
let out = f.run_stdin(
&[
"org", "add", "key", "--collection", "eng", "--title", "Deploy Key",
"--label", "ci", "--algorithm", "ed25519", "--material-stdin",
],
"-----BEGIN OPENSSH PRIVATE KEY-----\nAAAAsecretmaterial\n-----END OPENSSH PRIVATE KEY-----\n",
);
assert!(out.status.success(), "add key: {}", String::from_utf8_lossy(&out.stderr));
let got = f.run(&["org", "get", "Deploy Key"]);
let stdout = String::from_utf8_lossy(&got.stdout).to_string();
assert!(stdout.contains("Label: ci"), "label missing: {stdout}");
assert!(stdout.contains("********"), "key material must be masked without --show: {stdout}");
assert!(!stdout.contains("secretmaterial"), "key material leaked without --show: {stdout}");
}
#[test]
fn org_add_totp_with_secret_flag_round_trips() {
let f = OrgFixture::new();
f.create_collection_and_grant("eng");
// Totp accepts the base32 secret via --secret (no stdin needed).
let out = f.run(&[
"org", "add", "totp", "--collection", "eng", "--title", "AWS root",
"--issuer", "AWS", "--secret", "JBSWY3DPEHPK3PXP",
]);
assert!(out.status.success(), "add totp: {}", String::from_utf8_lossy(&out.stderr));
let got = f.run(&["org", "get", "AWS root"]);
let stdout = String::from_utf8_lossy(&got.stdout).to_string();
assert!(stdout.contains("AWS root"), "title missing: {stdout}");
assert!(stdout.contains("Issuer: AWS"), "issuer missing: {stdout}");
}
#[test]
fn org_edit_card_interactive_changes_holder() {
let f = OrgFixture::new();
f.create_collection_and_grant("eng");
let out = f.run_stdin(
&[
"org", "add", "card", "--collection", "eng", "--title", "Corp Visa",
"--kind", "credit", "--number-stdin", "--cvv-stdin", "--pin-stdin",
],
"4111111111111111\n123\n4321\n",
);
assert!(out.status.success(), "add card: {}", String::from_utf8_lossy(&out.stderr));
// Interactive edit: keep title, set holder, decline number change.
let out = f.run_stdin(&["org", "edit", "Corp Visa"], "\nJane Q. Public\nn\n");
assert!(out.status.success(), "org edit card: {}", String::from_utf8_lossy(&out.stderr));
let got = f.run(&["org", "get", "Corp Visa"]);
let stdout = String::from_utf8_lossy(&got.stdout).to_string();
assert!(stdout.contains("Holder: Jane Q. Public"), "holder edit did not take: {stdout}");
assert!(stdout.contains("********"), "number must stay masked after declining change: {stdout}");
assert!(!stdout.contains("4111111111111111"), "number leaked without --show: {stdout}");
}
#[test]
fn org_edit_totp_interactive_changes_issuer() {
let f = OrgFixture::new();
f.create_collection_and_grant("eng");
assert!(f.run(&[
"org", "add", "totp", "--collection", "eng", "--title", "AWS root",
"--issuer", "AWS", "--secret", "JBSWY3DPEHPK3PXP",
]).status.success());
// Interactive edit: keep title, set issuer=GitHub, keep label, decline secret change.
let out = f.run_stdin(&["org", "edit", "AWS root"], "\nGitHub\n\nn\n");
assert!(out.status.success(), "org edit totp: {}", String::from_utf8_lossy(&out.stderr));
let got = f.run(&["org", "get", "AWS root"]);
assert!(String::from_utf8_lossy(&got.stdout).contains("Issuer: GitHub"), "issuer edit did not take");
}
// --- grant enforcement + remaining --*-stdin paths for the new types ---------
#[test]
fn org_add_card_key_totp_reject_ungranted_and_unknown_collection() {
let f = OrgFixture::new();
// `secret` exists but is NOT granted to the owner.
assert!(f.run(&["org", "create-collection", "secret", "--name", "secret"]).status.success());
// ensure_grant runs before any secret prompt in run_add, so these need no
// stdin — each new type must be rejected for a collection it lacks a grant for.
for args in [
vec!["org", "add", "card", "--collection", "secret", "--title", "X", "--kind", "credit"],
vec!["org", "add", "key", "--collection", "secret", "--title", "X"],
vec!["org", "add", "totp", "--collection", "secret", "--title", "X", "--secret", "JBSWY3DPEHPK3PXP"],
] {
let out = f.run(&args);
assert!(!out.status.success(), "ungranted add must fail: {args:?}");
let err = String::from_utf8_lossy(&out.stderr).to_string();
assert!(err.contains("access denied") || err.contains("grant"),
"expected grant denial for {args:?}: {err}");
}
// …and rejected for a nonexistent collection.
for args in [
vec!["org", "add", "card", "--collection", "ghost", "--title", "X", "--kind", "credit"],
vec!["org", "add", "key", "--collection", "ghost", "--title", "X"],
vec!["org", "add", "totp", "--collection", "ghost", "--title", "X", "--secret", "JBSWY3DPEHPK3PXP"],
] {
let out = f.run(&args);
assert!(!out.status.success(), "unknown-collection add must fail: {args:?}");
let err = String::from_utf8_lossy(&out.stderr).to_string();
assert!(err.contains("does not exist") || err.contains("ghost"),
"expected unknown-collection error for {args:?}: {err}");
}
}
#[test]
fn org_add_secure_note_via_body_stdin_masks_body() {
let f = OrgFixture::new();
f.create_collection_and_grant("eng");
// build_secure_note(body_stdin=true) reads the body from stdin to EOF.
let out = f.run_stdin(
&["org", "add", "secure-note", "--collection", "eng", "--title", "Runbook", "--body-stdin"],
"line one\nsuper-secret-line\n",
);
assert!(out.status.success(), "add note: {}", String::from_utf8_lossy(&out.stderr));
let got = f.run(&["org", "get", "Runbook"]);
let stdout = String::from_utf8_lossy(&got.stdout).to_string();
assert!(stdout.contains("********"), "note body must be masked without --show: {stdout}");
assert!(!stdout.contains("super-secret-line"), "note body leaked without --show: {stdout}");
let shown = f.run(&["org", "get", "Runbook", "--show"]);
assert!(String::from_utf8_lossy(&shown.stdout).contains("super-secret-line"), "body not revealed with --show");
}
#[test]
fn org_add_totp_via_secret_stdin_round_trips() {
let f = OrgFixture::new();
f.create_collection_and_grant("eng");
// build_totp(secret_stdin=true) reads one base32 line from stdin.
let out = f.run_stdin(
&["org", "add", "totp", "--collection", "eng", "--title", "VPN", "--issuer", "Corp", "--secret-stdin"],
"JBSWY3DPEHPK3PXP\n",
);
assert!(out.status.success(), "add totp: {}", String::from_utf8_lossy(&out.stderr));
let got = f.run(&["org", "get", "VPN"]);
assert!(String::from_utf8_lossy(&got.stdout).contains("Issuer: Corp"), "issuer missing");
}
#[test]
fn org_edit_key_replaces_material_and_reveals_with_show() {
let f = OrgFixture::new();
f.create_collection_and_grant("eng");
let out = f.run_stdin(
&["org", "add", "key", "--collection", "eng", "--title", "Signing Key",
"--label", "ci", "--material-stdin"],
"OLD-MATERIAL-aaaa\n",
);
assert!(out.status.success(), "add key: {}", String::from_utf8_lossy(&out.stderr));
// Interactive edit: keep title, ACCEPT "Replace key material?" -> new material
// read from stdin to EOF (edit_key). Exercises the accept branch + history push.
let out = f.run_stdin(&["org", "edit", "Signing Key"], "\ny\nNEW-MATERIAL-bbbb\n");
assert!(out.status.success(), "org edit key: {}", String::from_utf8_lossy(&out.stderr));
let masked = f.run(&["org", "get", "Signing Key"]);
let masked = String::from_utf8_lossy(&masked.stdout).to_string();
assert!(masked.contains("********"), "material must be masked without --show: {masked}");
assert!(!masked.contains("NEW-MATERIAL"), "material leaked without --show: {masked}");
let shown = f.run(&["org", "get", "Signing Key", "--show"]);
let shown = String::from_utf8_lossy(&shown.stdout).to_string();
assert!(shown.contains("NEW-MATERIAL-bbbb"), "replaced material not revealed with --show: {shown}");
assert!(!shown.contains("OLD-MATERIAL"), "old material still present after replace: {shown}");
}
// --- v0.8.1 org Document tests -----------------------------------------------
/// `git status --porcelain` output for the org repo (trimmed). Empty-of-`attachments/`
/// proves every attachment add/remove was staged into the signed commit.
fn git_porcelain(repo: &str) -> String {
let out = std::process::Command::new("git")
.args(["-C", repo, "status", "--porcelain"])
.output()
.unwrap();
String::from_utf8_lossy(&out.stdout).trim().to_string()
}
#[test]
fn org_add_document_stores_collection_scoped_attachment() {
let f = OrgFixture::new();
f.create_collection_and_grant("eng");
let srcdir = TempDir::new().unwrap();
let src = srcdir.path().join("note.txt");
std::fs::write(&src, b"secret memo").unwrap();
let out = f.run(&["org", "add", "document", "--collection", "eng",
"--title", "Q3 Memo", "--file", src.to_str().unwrap()]);
assert!(out.status.success(), "add doc: {}", String::from_utf8_lossy(&out.stderr));
// Encrypted blob at attachments/eng/<item-id>/<att-id>.enc (3 segments).
let att_eng = f.vault_path().join("attachments").join("eng");
assert!(att_eng.exists(), "attachment dir missing");
let item_dirs: Vec<_> = std::fs::read_dir(&att_eng).unwrap().map(|e| e.unwrap().path()).collect();
assert_eq!(item_dirs.len(), 1, "expected exactly one item attachment dir");
let blobs: Vec<_> = std::fs::read_dir(&item_dirs[0]).unwrap().map(|e| e.unwrap().path()).collect();
assert_eq!(blobs.len(), 1, "expected exactly one attachment blob");
assert_eq!(blobs[0].extension().and_then(|e| e.to_str()), Some("enc"), "blob must be .enc");
let got = f.run(&["org", "get", "Q3 Memo"]);
let stdout = String::from_utf8_lossy(&got.stdout);
assert!(stdout.contains("Filename: note.txt"), "get missing filename: {stdout}");
// Staging proof: nothing attachment-related left uncommitted.
assert!(!git_porcelain(f.vault_str()).contains("attachments/"), "unstaged attachment after add");
}
#[test]
fn org_purge_document_removes_attachment_dir() {
let f = OrgFixture::new();
f.create_collection_and_grant("eng");
let srcdir = TempDir::new().unwrap();
let src = srcdir.path().join("d.bin");
std::fs::write(&src, b"bytes").unwrap();
assert!(f.run(&["org", "add", "document", "--collection", "eng",
"--title", "Doc", "--file", src.to_str().unwrap()]).status.success());
let att_eng = f.vault_path().join("attachments").join("eng");
assert!(std::fs::read_dir(&att_eng).unwrap().next().is_some(), "attachment must exist after add");
assert!(f.run(&["org", "rm", "Doc"]).status.success(), "rm");
let out = f.run(&["org", "purge", "Doc"]);
assert!(out.status.success(), "purge: {}", String::from_utf8_lossy(&out.stderr));
let empty = !att_eng.exists() || std::fs::read_dir(&att_eng).unwrap().next().is_none();
assert!(empty, "attachment dir should be gone after purge");
assert!(!git_porcelain(f.vault_str()).contains("attachments/"), "unstaged attachment removal after purge");
}
#[test]
fn org_edit_document_replaces_attachment_and_stages_cleanly() {
let f = OrgFixture::new();
f.create_collection_and_grant("eng");
let srcdir = TempDir::new().unwrap();
let a = srcdir.path().join("a.txt");
std::fs::write(&a, b"version A").unwrap();
assert!(f.run(&["org", "add", "document", "--collection", "eng",
"--title", "Spec", "--file", a.to_str().unwrap()]).status.success());
let b = srcdir.path().join("b.md");
std::fs::write(&b, b"version B has different content").unwrap();
let out = f.run(&["org", "edit", "Spec", "--file", b.to_str().unwrap()]);
assert!(out.status.success(), "edit --file: {}", String::from_utf8_lossy(&out.stderr));
let got = String::from_utf8_lossy(&f.run(&["org", "get", "Spec"]).stdout).to_string();
assert!(got.contains("Filename: b.md"), "get should show new filename: {got}");
assert!(!got.contains("a.txt"), "old filename should be gone: {got}");
// Old blob replaced, not accumulated: exactly one blob remains.
let att_eng = f.vault_path().join("attachments").join("eng");
let item_dirs: Vec<_> = std::fs::read_dir(&att_eng).unwrap().map(|e| e.unwrap().path()).collect();
assert_eq!(item_dirs.len(), 1, "one item attachment dir");
let blobs = std::fs::read_dir(&item_dirs[0]).unwrap().count();
assert_eq!(blobs, 1, "old blob must be replaced, not accumulated");
// The key staging proof: no orphaned old blob / unstaged new blob.
assert!(!git_porcelain(f.vault_str()).contains("attachments/"),
"edit-replace left attachment changes unstaged (incomplete git add)");
}
#[test]
fn org_edit_file_on_non_document_is_rejected() {
let f = OrgFixture::new();
f.create_collection_and_grant("eng");
assert!(f.run(&["org", "add", "login", "--collection", "eng",
"--title", "Site", "--password", "p"]).status.success());
let srcdir = TempDir::new().unwrap();
let x = srcdir.path().join("x.txt");
std::fs::write(&x, b"nope").unwrap();
let out = f.run(&["org", "edit", "Site", "--file", x.to_str().unwrap()]);
assert!(!out.status.success(), "--file on a Login must be rejected");
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(stderr.contains("--file is only valid"), "unexpected error: {stderr}");
}
#[test]
fn org_add_document_into_ungranted_collection_is_denied() {
let f = OrgFixture::new();
// Collection exists but the owner is NOT granted.
assert!(f.run(&["org", "create-collection", "secret", "--name", "Secret"]).status.success(),
"create-collection");
let srcdir = TempDir::new().unwrap();
let src = srcdir.path().join("f.txt");
std::fs::write(&src, b"data").unwrap();
let out = f.run(&["org", "add", "document", "--collection", "secret",
"--title", "X", "--file", src.to_str().unwrap()]);
assert!(!out.status.success(), "ungranted document add must fail");
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(stderr.contains("access denied") || stderr.contains("grant"), "unexpected error: {stderr}");
// Grant is enforced before any attachment is written.
assert!(!f.vault_path().join("attachments").join("secret").exists(),
"no attachment dir should exist on a denied add");
}

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "relicario-core" name = "relicario-core"
version = "0.8.0" version = "0.8.1"
edition = "2021" edition = "2021"
description = "Core library for relicario password manager" description = "Core library for relicario password manager"
license = "GPL-3.0-or-later" license = "GPL-3.0-or-later"

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "relicario-wasm" name = "relicario-wasm"
version = "0.8.0" version = "0.8.1"
edition = "2021" edition = "2021"
description = "WASM bindings for relicario password manager" description = "WASM bindings for relicario password manager"
license = "GPL-3.0-or-later" license = "GPL-3.0-or-later"

View File

@@ -82,6 +82,7 @@ collections.json # collection definitions
keys/<member-id>.enc # org master key wrapped to that member's device key keys/<member-id>.enc # org master key wrapped to that member's device key
manifest.enc # OrgManifest (schema_version 1, per-member-filtered) manifest.enc # OrgManifest (schema_version 1, per-member-filtered)
items/<collection-slug>/<item-id>.enc # collection-scoped item blobs items/<collection-slug>/<item-id>.enc # collection-scoped item blobs
attachments/<collection-slug>/<item-id>/<att-id>.enc # Document attachment blobs (collection-scoped)
``` ```
### `org.json` — OrgMeta ### `org.json` — OrgMeta
@@ -123,7 +124,13 @@ Standard `.enc` blob (see **Encrypted blob** above), encrypted under the org mas
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. 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. ### `attachments/<collection-slug>/<item-id>/<att-id>.enc`
Standard `.enc` blob (see **Encrypted blob** above), encrypted under the org master key — the encrypted file payload of a Document item. As with item blobs, the blob does **not** name its collection; the leading `<collection-slug>` path segment carries it, so the pre-receive hook (`relicario-server`, `classify_path`) authorizes the write by slug without decrypting — reusing the same grant + slug-existence check as the `items/` branch. The path is **exactly three segments** after `attachments/` (`<collection-slug>/<item-id>/<att-id>.enc`); the hook rejects any other shape (segment-count and `.`-free slug guards). `<att-id>` is the content-addressed `AttachmentId` (see **Item IDs and Field IDs** below).
Per-attachment size is capped at `DEFAULT_ORG_ATTACHMENT_MAX_BYTES = 10 * 1024 * 1024` (10 MiB) (`org_session.rs:24`), mirroring the personal-vault default `AttachmentCaps::per_attachment_max_bytes` (`settings.rs:116`). Org vaults have no `settings.enc`, so this cap is a fixed default rather than per-org configurable. Blobs are persisted / read / removed by `UnlockedOrgVault::save_attachment` / `load_attachment` / `remove_item_attachments` (`org_session.rs:137`, `:147`, `:156`). The storage primitives back the org **Document** item type; the `org add document` / Document-edit commands that produce these blobs land in v0.8.1 (see the item-type-parity note below).
**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 (landing in v0.8.1; Document file payloads use the attachment layout above).
## Item IDs and Field IDs ## Item IDs and Field IDs