Files
relicario/docs/superpowers/plans/2026-06-06-enterprise-org-vault.md
adlee-was-taken 519e503cbd docs(plan,spec): align enforce_owner_only_elevation to shipped parent-role authority
The plan's pre-receive-hook pseudocode judged owner-elevation authority on the
post-change `signer.role` (so a self-promoting Admin reads as Owner in the same
commit and self-authorizes the promotion — the exact escalation the gate exists
to stop). f249395 had fixed only the skip-predicate, leaving this final check
vulnerable. Align the plan's `enforce_owner_only_elevation` to the SHIPPED fix
(relicario-server/src/main.rs, aace6f1): derive `signer_may_manage_owners` from
`signer_parent = parent_role(signer.member_id)` (the signer's PRE-commit role;
None -> reject; genesis allowed) and gate on that, never the post-change role.

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TJo44YM3UbBjro2fG6NrKy
2026-06-20 13:45:04 -04:00

6195 lines
246 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Enterprise Org Vault Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Implement git-native multi-user org vaults for security-conscious self-hosting shops — per-user repos + a shared org repo, X25519-wrapped org master key per member, **collection-scoped item storage**, role-based access, full **org item CRUD** (`add`/`get`/`list`/`edit`/`rm`/`restore`/`purge`), a **signature-verifying** pre-receive hook with path-scoped write authorization, a tamper-evident structured-git-trailer audit trail attributed to the **verified signer**, and **extension parity** (org switcher + read-only browse + the spec's vitest acceptance tests).
**Architecture:** The org repo is a separate git repository with a defined schema (`org.json`, `members.json`, `collections.json`, `keys/<member-id>.enc`, `manifest.enc`, and **collection-scoped** items at `items/<collection-slug>/<item-id>.enc`). Each member holds a copy of the 256-bit org master key wrapped (ECIES/X25519 + XChaCha20-Poly1305) to their existing ed25519 device key. Two enforcement boundaries work together: **(1) cryptographic** — only a wrapped-key holder can unwrap the org master key, and rotation re-encrypts every item blob under a fresh key; **(2) the git pre-receive hook** — every commit is **signature-verified** against `members.json` (signer resolved by ed25519 fingerprint), and writes are authorized by role (for management files) or by **collection path segment** (for item files). Item storage is collection-scoped precisely so the hook can authorize an item write by its leading path segment *without decrypting anything*. All org commits are **signed**; `org init` configures git signing and org commits route through a non-hardened `org_git_run` (the standard `helpers::git_run` force-disables signing). Both the CLI and the extension consume the same `relicario-core::org` module; the extension ships org switch + read in this phase (writes are a tracked follow-up).
**Tech Stack:** Rust, `x25519-dalek 2` with `features = ["static_secrets"]` (new, in `relicario-core`), `ed25519-dalek 2`, `sha2`, `chacha20poly1305 0.10`, `ssh-key 0.6` (new, in `relicario-cli` **and** `relicario-wasm`), `regex`, `tempfile`, `serde_json`, `clap`, `anyhow`, `zeroize`; WASM bindings (`relicario-wasm`, `base64`) + extension TypeScript with **vitest** for the parity acceptance tests.
**Multi-stream assignment for PM:**
| Stream | Tasks | Scope | Depends on |
|---|---|---|---|
| **Dev-A** | A1A4 (+ **A5** docs, final) | `relicario-core` org module: deps, types, crypto, vault wrappers, integration tests; then the living-docs sweep | A5 depends on all streams merged |
| **Dev-B** | B1B14 | `relicario-cli`: session, device helpers, admin commands, item CRUD, wiring | **A2A3 merged first** (imports `relicario_core::org`); B9B13 depend on B1; B14 depends on the command fns existing |
| **Dev-C** | C1C2 | `relicario-server` pre-receive hook: signature + path-scoped authz, schema monotonicity | **A2A3 merged first** (imports `relicario_core::org`) |
| **Dev-D** | D1D4 | Extension parity: WASM org bindings, SW org session/handlers, vault-tab switcher, vitest tests | **A2A3 merged first** (bindings call `relicario_core::{unwrap_org_key, decrypt_org_manifest, decrypt_item}`); independent of Dev-B/Dev-C |
> **Hard dependency note:** Dev-B, Dev-C, and Dev-D all import `relicario_core::org` types/functions, so **Tasks A2A3 must be MERGED before those streams begin**. Within Dev-B, the item-CRUD tasks **B9B13 depend on B1** (the collection-scoped `item_path` + session helpers), and **B14 depends on every `run_*` command fn existing** (it wires them into `main.rs`). A5 is the final living-docs task and runs after all code streams merge.
---
## File Map
| Action | Path | Responsibility |
|---|---|---|
| Modify | `crates/relicario-core/Cargo.toml` | Add `x25519-dalek = { version = "2", features = ["static_secrets"] }` |
| Create | `crates/relicario-core/src/org.rs` | Org types + crypto (IDs, members, collections, key wrap/unwrap; KDF intermediates in `Zeroizing`) |
| Modify | `crates/relicario-core/src/vault.rs` | Add `encrypt_org_manifest` / `decrypt_org_manifest` |
| Modify | `crates/relicario-core/src/lib.rs` | `pub mod org` + re-exports |
| Create | `crates/relicario-core/tests/org.rs` | Integration tests for org crypto |
| Modify | `crates/relicario-cli/Cargo.toml` | Add `ssh-key = "0.6"`; ensure `regex = "1"` + `tempfile = "3"` in `[dependencies]`; `ed25519-dalek` dev-dep |
| Create | `crates/relicario-cli/src/org_session.rs` | `UnlockedOrgVault` session type (collection-scoped `item_path`, item I/O helpers, fingerprint member match, signed `org_git_run`) |
| Modify | `crates/relicario-cli/src/device.rs` | `current_device_seed` / `current_device_pubkey` helpers |
| Create | `crates/relicario-cli/src/commands/org.rs` | All `relicario org` subcommands — admin **and** item commands |
| Modify | `crates/relicario-cli/src/commands/mod.rs` | `pub mod org` |
| Modify | `crates/relicario-cli/src/main.rs` | `Commands::Org` arm + `OrgCommands` (admin + item subcommands) |
| Modify | `crates/relicario-server/Cargo.toml` | Ensure `tempfile` in `[dependencies]`; add `[lib]` + `[[bin]]` |
| Create | `crates/relicario-server/src/lib.rs` | Pure path-classification + schema-version helpers (`classify_path`, `PathClass`, `extract_schema_version`) |
| Modify | `crates/relicario-server/src/main.rs` | `verify-org-commit` (signature + path/role authz + schema monotonicity) + `generate-org-hook` |
| Create | `crates/relicario-server/tests/org_hook.rs` | Hook path-classification + schema-version tests |
| Modify | `crates/relicario-wasm/src/lib.rs` | `org_open_with_registered_device` / `org_manifest_decrypt` / `org_item_decrypt` bindings |
| Modify | `crates/relicario-wasm/src/device.rs` | `unwrap_org_key` accessor — unwraps the org key using the in-memory `DEVICE_STATE` seed (never crosses to JS) |
| Modify | `crates/relicario-wasm/Cargo.toml` | `base64` (if absent) |
| Regenerate | `extension/wasm/relicario_wasm.{js,d.ts}`, `relicario_wasm_bg.wasm` | wasm-pack output consumed by the extension |
| Create | `extension/src/service-worker/org.ts` | SW org session + read ops |
| Modify | `extension/src/service-worker/index.ts` | Wire `org.setWasm`; clear org session on expiry |
| Modify | `extension/src/service-worker/router/popup-only.ts` | 4 org handler arms |
| Modify | `extension/src/shared/messages.ts` | Org message types + responses + capability-set entries |
| Create | `extension/src/vault/vault-org-switcher.ts` | Vault-tab org context switcher + read-only banner |
| Modify | `extension/src/vault/vault-sidebar.ts`, `vault-context.ts`, `vault.ts`, `vault.css` | Mount switcher; `activeOrgId` state; styling |
| Create | `extension/src/service-worker/__tests__/org.test.ts` | 3 spec-mandated vitest tests |
| Modify | living docs (see **A5**) | FORMATS, CRYPTO, DESIGN, SECURITY, core/cli/ext ARCHITECTURE, STATUS, ROADMAP |
---
## Dev-A — relicario-core org module
## [Dev-A] Task A1: Add x25519-dalek and stub org module
**Files:**
- Modify: `crates/relicario-core/Cargo.toml`
- Create: `crates/relicario-core/src/org.rs` (stub)
- Modify: `crates/relicario-core/src/lib.rs`
- [ ] **Step 1: Add x25519-dalek dependency**
In `crates/relicario-core/Cargo.toml`, add after the `ed25519-dalek` line:
```toml
x25519-dalek = { version = "2", features = ["static_secrets"] }
```
- [ ] **Step 2: Create org.rs stub**
Create `crates/relicario-core/src/org.rs` with just a module-level comment:
```rust
//! Org vault types, crypto, and schema for multi-user self-hosted deployments.
```
- [ ] **Step 3: Wire into lib.rs**
In `crates/relicario-core/src/lib.rs`, add after the `device` module block. Wire the `pub mod org;` line only, no `pub use` yet (the re-exports would fail until Task A2 defines the symbols):
```rust
pub mod org;
```
Then add the `pub use` items in Task A2 once the symbols exist.
- [ ] **Step 4: Verify it compiles**
```bash
cargo check -p relicario-core
```
Expected: compiles (stub is empty; only `pub mod org;` is wired).
- [ ] **Step 5: Verify the WASM target still builds**
`x25519-dalek` lands in the core that `relicario-wasm` compiles, so build the WASM target now to catch any feature-unification regression early:
```bash
cargo build -p relicario-wasm --target wasm32-unknown-unknown 2>&1 | tail -10
```
Expected: clean build.
- [ ] **Step 6: Commit**
```bash
git add crates/relicario-core/Cargo.toml crates/relicario-core/src/org.rs crates/relicario-core/src/lib.rs
git commit -m "feat(core/org): add x25519-dalek dep + stub org module"
```
---
## [Dev-A] Task A2: Org types — IDs, members, collections, org meta, ECIES wrap/unwrap
**Files:**
- Modify: `crates/relicario-core/src/org.rs`
- Modify: `crates/relicario-core/src/lib.rs`
This task implements all org types plus the ECIES key-wrap/unwrap. Two corrections from the adversarial review are folded in: **H6** (all KDF intermediates carrying the DH secret are held in `Zeroizing`) and **H7** (the test keypair helper uses `PrivateKey::from(Ed25519Keypair::from(&signing_key))``ssh-key` 0.6.7 has no `From<SigningKey> for PrivateKey`).
- [ ] **Step 1: Write failing test for MemberId format**
```rust
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn member_id_is_16_hex_chars() {
let id = MemberId::new();
assert_eq!(id.0.len(), 16);
assert!(id.0.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn member_ids_are_unique() {
let mut seen = std::collections::HashSet::new();
for _ in 0..1_000 {
assert!(seen.insert(MemberId::new().0));
}
}
#[test]
fn org_id_is_16_hex_chars() {
let id = OrgId::new();
assert_eq!(id.0.len(), 16);
}
}
```
Add this at the bottom of `org.rs`, run:
```bash
cargo test -p relicario-core org::tests::member_id_is_16_hex_chars 2>&1 | tail -5
```
Expected: FAIL — `MemberId` not defined.
- [ ] **Step 2: Implement all org types + ECIES wrap/unwrap (with H6 Zeroizing folded in)**
Replace the stub `org.rs` with:
```rust
//! Org vault types, crypto, and schema for multi-user self-hosted deployments.
use rand::{rngs::OsRng, RngCore};
use serde::{Deserialize, Serialize};
use zeroize::Zeroizing;
use crate::error::{RelicarioError, Result};
use crate::ids::ItemId;
use crate::item_types::ItemType;
// ── IDs ──────────────────────────────────────────────────────────────────────
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct OrgId(pub String);
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct MemberId(pub String);
impl OrgId {
pub fn new() -> Self {
let mut bytes = [0u8; 8];
OsRng.fill_bytes(&mut bytes);
Self(hex::encode(bytes))
}
pub fn as_str(&self) -> &str { &self.0 }
}
impl Default for OrgId {
fn default() -> Self { Self::new() }
}
impl MemberId {
pub fn new() -> Self {
let mut bytes = [0u8; 8];
OsRng.fill_bytes(&mut bytes);
Self(hex::encode(bytes))
}
pub fn as_str(&self) -> &str { &self.0 }
pub fn is_valid(&self) -> bool {
self.0.len() == 16 && self.0.chars().all(|c| c.is_ascii_hexdigit())
}
}
impl Default for MemberId {
fn default() -> Self { Self::new() }
}
// ── Roles ────────────────────────────────────────────────────────────────────
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum OrgRole {
Owner,
Admin,
Member,
}
impl OrgRole {
pub fn can_manage_members(&self) -> bool {
matches!(self, OrgRole::Owner | OrgRole::Admin)
}
pub fn can_manage_owners(&self) -> bool {
matches!(self, OrgRole::Owner)
}
}
// ── Members ──────────────────────────────────────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OrgMember {
pub member_id: MemberId,
pub display_name: String,
pub role: OrgRole,
/// SSH public key string (openssh format: "ssh-ed25519 AAAA...")
pub ed25519_pubkey: String,
/// Collection slugs this member can access.
#[serde(default)]
pub collections: Vec<String>,
pub added_at: i64,
pub added_by: MemberId,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OrgMembers {
pub schema_version: u32,
pub members: Vec<OrgMember>,
}
impl OrgMembers {
pub fn new() -> Self {
Self { schema_version: 1, members: Vec::new() }
}
pub fn find_by_id(&self, id: &MemberId) -> Option<&OrgMember> {
self.members.iter().find(|m| &m.member_id == id)
}
pub fn find_by_id_mut(&mut self, id: &MemberId) -> Option<&mut OrgMember> {
self.members.iter_mut().find(|m| &m.member_id == id)
}
pub fn validate(&self) -> Result<()> {
for m in &self.members {
if !m.member_id.is_valid() {
return Err(RelicarioError::Format(
format!("invalid member_id: {}", m.member_id.0)
));
}
}
Ok(())
}
}
impl Default for OrgMembers {
fn default() -> Self { Self::new() }
}
// ── Collections ───────────────────────────────────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CollectionDef {
pub slug: String,
pub display_name: String,
pub created_by: MemberId,
pub created_at: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OrgCollections {
pub schema_version: u32,
pub collections: Vec<CollectionDef>,
}
impl OrgCollections {
pub fn new() -> Self {
Self { schema_version: 1, collections: Vec::new() }
}
pub fn contains_slug(&self, slug: &str) -> bool {
self.collections.iter().any(|c| c.slug == slug)
}
pub fn validate(&self) -> Result<()> {
for c in &self.collections {
if c.slug.is_empty() || c.slug.contains('/') || c.slug.contains('.') {
return Err(RelicarioError::Format(
format!("invalid collection slug: {:?}", c.slug)
));
}
}
Ok(())
}
}
impl Default for OrgCollections {
fn default() -> Self { Self::new() }
}
// ── Org meta ─────────────────────────────────────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OrgMeta {
pub schema_version: u32,
pub org_id: OrgId,
pub display_name: String,
pub created_at: i64,
}
impl OrgMeta {
pub fn new(display_name: String) -> Self {
Self {
schema_version: 1,
org_id: OrgId::new(),
display_name,
created_at: crate::time::now_unix(),
}
}
}
// ── Org manifest ─────────────────────────────────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OrgManifestEntry {
pub id: ItemId,
pub r#type: ItemType,
pub title: String,
#[serde(default)]
pub tags: Vec<String>,
pub modified: i64,
#[serde(skip_serializing_if = "Option::is_none")]
pub trashed_at: Option<i64>,
/// Collection this item belongs to.
pub collection: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OrgManifest {
pub schema_version: u32,
pub entries: Vec<OrgManifestEntry>,
}
impl OrgManifest {
pub fn new() -> Self {
Self { schema_version: 1, entries: Vec::new() }
}
/// Return only entries whose collection is in `member.collections`.
pub fn filter_for_member(&self, member: &OrgMember) -> Self {
let granted: std::collections::HashSet<&str> =
member.collections.iter().map(|s| s.as_str()).collect();
Self {
schema_version: self.schema_version,
entries: self.entries.iter()
.filter(|e| granted.contains(e.collection.as_str()))
.cloned()
.collect(),
}
}
}
impl Default for OrgManifest {
fn default() -> Self { Self::new() }
}
// ── Key wrap / unwrap (ECIES: X25519 + XChaCha20-Poly1305) ───────────────────
/// Generate a random 256-bit org master key.
pub fn generate_org_key() -> Zeroizing<[u8; 32]> {
let mut key = Zeroizing::new([0u8; 32]);
OsRng.fill_bytes(key.as_mut());
key
}
/// Derive an X25519 static secret from an ed25519 seed (standard RFC 7748 path).
fn ed25519_seed_to_x25519_secret(seed: &[u8; 32]) -> x25519_dalek::StaticSecret {
use sha2::{Digest, Sha512};
let h = Sha512::digest(seed.as_ref());
let mut scalar = [0u8; 32];
scalar.copy_from_slice(&h[..32]);
// RFC 7748 clamping
scalar[0] &= 248;
scalar[31] &= 127;
scalar[31] |= 64;
x25519_dalek::StaticSecret::from(scalar)
}
/// Parse an OpenSSH ed25519 public key string and return its X25519 form.
fn openssh_ed25519_to_x25519_pk(openssh: &str) -> Result<x25519_dalek::PublicKey> {
use ssh_key::PublicKey;
let pk = PublicKey::from_openssh(openssh.trim())
.map_err(|e| RelicarioError::Format(format!("bad SSH pubkey: {e}")))?;
let ed_bytes = pk.key_data().ed25519()
.ok_or_else(|| RelicarioError::Format("expected ed25519 key".into()))?
.0;
let verifying = ed25519_dalek::VerifyingKey::from_bytes(&ed_bytes)
.map_err(|e| RelicarioError::Format(format!("bad ed25519 pubkey: {e}")))?;
Ok(x25519_dalek::PublicKey::from(verifying.to_montgomery().to_bytes()))
}
/// Wrap `org_key` for a recipient identified by their OpenSSH ed25519 public key.
///
/// Output layout: `ephemeral_x25519_pk(32) || version(1) || nonce(24) || ciphertext+tag`
pub fn wrap_org_key(org_key: &Zeroizing<[u8; 32]>, recipient_openssh_pubkey: &str) -> Result<Vec<u8>> {
use sha2::{Digest, Sha256};
use x25519_dalek::EphemeralSecret;
let recipient_pk = openssh_ed25519_to_x25519_pk(recipient_openssh_pubkey)?;
let ephemeral_sk = EphemeralSecret::random_from_rng(OsRng);
let ephemeral_pk = x25519_dalek::PublicKey::from(&ephemeral_sk);
let shared = ephemeral_sk.diffie_hellman(&recipient_pk);
// Domain-separated KDF. All intermediates carrying the DH secret are held in
// Zeroizing so they are wiped on drop (H6).
let mut kdf_input: Zeroizing<Vec<u8>> = Zeroizing::new(Vec::with_capacity(32 + 32 + 32));
kdf_input.extend_from_slice(shared.as_bytes());
kdf_input.extend_from_slice(ephemeral_pk.as_bytes());
kdf_input.extend_from_slice(recipient_pk.as_bytes());
// Copy the digest straight into a Zeroizing array. The GenericArray returned
// by Sha256::digest is not Zeroize (generic-array's impl is feature-gated and
// not enabled here), so we move the bytes into an owned [u8; 32] whose own
// Zeroize impl wipes them on drop.
let mut wrap_key: Zeroizing<[u8; 32]> = Zeroizing::new([0u8; 32]);
wrap_key.copy_from_slice(&Sha256::digest(kdf_input.as_slice()));
let encrypted = crate::crypto::encrypt(&wrap_key, org_key.as_ref())?;
let mut out = Vec::with_capacity(32 + encrypted.len());
out.extend_from_slice(ephemeral_pk.as_bytes());
out.extend_from_slice(&encrypted);
Ok(out)
}
/// Unwrap a key blob produced by `wrap_org_key` using the recipient's ed25519 seed.
pub fn unwrap_org_key(wrapped: &[u8], ed25519_seed: &Zeroizing<[u8; 32]>) -> Result<Zeroizing<[u8; 32]>> {
use sha2::{Digest, Sha256};
// Minimum: 32 (ephemeral_pk) + 41 (version+nonce+tag for 32-byte plaintext)
if wrapped.len() < 32 + 41 {
return Err(RelicarioError::Format("wrapped key blob too short".into()));
}
let ephemeral_pk = x25519_dalek::PublicKey::from(
<[u8; 32]>::try_from(&wrapped[..32]).unwrap()
);
let encrypted = &wrapped[32..];
let recipient_sk = ed25519_seed_to_x25519_secret(ed25519_seed.as_ref());
let recipient_pk = x25519_dalek::PublicKey::from(&recipient_sk);
let shared = recipient_sk.diffie_hellman(&ephemeral_pk);
let mut kdf_input: Zeroizing<Vec<u8>> = Zeroizing::new(Vec::with_capacity(32 + 32 + 32));
kdf_input.extend_from_slice(shared.as_bytes());
kdf_input.extend_from_slice(ephemeral_pk.as_bytes());
kdf_input.extend_from_slice(recipient_pk.as_bytes());
let mut wrap_key: Zeroizing<[u8; 32]> = Zeroizing::new([0u8; 32]);
wrap_key.copy_from_slice(&Sha256::digest(kdf_input.as_slice()));
let plaintext = Zeroizing::new(crate::crypto::decrypt(&wrap_key, encrypted)?);
if plaintext.len() != 32 {
return Err(RelicarioError::Format(
format!("unwrapped key has wrong length: {}", plaintext.len())
));
}
let mut key = Zeroizing::new([0u8; 32]);
key.copy_from_slice(&plaintext);
Ok(key)
}
// ── Tests ─────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn member_id_is_16_hex_chars() {
let id = MemberId::new();
assert_eq!(id.0.len(), 16);
assert!(id.0.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn member_ids_are_unique() {
let mut seen = std::collections::HashSet::new();
for _ in 0..1_000 {
assert!(seen.insert(MemberId::new().0));
}
}
#[test]
fn org_id_is_16_hex_chars() {
let id = OrgId::new();
assert_eq!(id.0.len(), 16);
assert!(id.0.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn org_role_can_manage_members() {
assert!(OrgRole::Owner.can_manage_members());
assert!(OrgRole::Admin.can_manage_members());
assert!(!OrgRole::Member.can_manage_members());
}
#[test]
fn collection_slug_validation_rejects_slash() {
let mut c = OrgCollections::new();
c.collections.push(CollectionDef {
slug: "bad/slug".into(),
display_name: "Bad".into(),
created_by: MemberId::new(),
created_at: 0,
});
assert!(c.validate().is_err());
}
#[test]
fn filter_for_member_restricts_collections() {
let mut manifest = OrgManifest::new();
manifest.entries.push(OrgManifestEntry {
id: ItemId::new(),
r#type: crate::item_types::ItemType::SecureNote,
title: "A".into(),
tags: vec![],
modified: 0,
trashed_at: None,
collection: "prod".into(),
});
manifest.entries.push(OrgManifestEntry {
id: ItemId::new(),
r#type: crate::item_types::ItemType::SecureNote,
title: "B".into(),
tags: vec![],
modified: 0,
trashed_at: None,
collection: "dev".into(),
});
let member = OrgMember {
member_id: MemberId::new(),
display_name: "Alice".into(),
role: OrgRole::Member,
ed25519_pubkey: String::new(),
collections: vec!["prod".into()],
added_at: 0,
added_by: MemberId::new(),
};
let filtered = manifest.filter_for_member(&member);
assert_eq!(filtered.entries.len(), 1);
assert_eq!(filtered.entries[0].collection, "prod");
}
#[test]
fn generate_org_key_is_32_bytes() {
let key = generate_org_key();
assert_eq!(key.len(), 32);
}
/// Pinned RFC 8032 known-answer vector for the ed25519→X25519 map. The seed
/// and expected X25519 public key are from ed25519-dalek's own reference
/// test (`tests/x25519.rs`, section 7.1 vector A). The expected value is a
/// HARD-CODED LITERAL — NOT recomputed by the production code path — so a
/// correlated cross-crate-version regression in the birational map (where
/// both our derivation and a naive re-derivation would drift together) is
/// still caught. If this test ever fails after a dep bump, the wrap/unwrap
/// keyspace changed and every existing `keys/<id>.enc` blob is invalidated.
#[test]
fn ed25519_to_x25519_pinned_rfc8032_vector() {
let seed: [u8; 32] =
hex::decode("9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60")
.unwrap()
.try_into()
.unwrap();
// Derive the X25519 *public* key the same way wrap/unwrap derives the
// recipient's static secret from a seed.
let secret = ed25519_seed_to_x25519_secret(&seed);
let public = x25519_dalek::PublicKey::from(&secret);
assert_eq!(
hex::encode(public.as_bytes()),
"d85e07ec22b0ad881537c2f44d662d1a143cf830c57aca4305d85c7a90f6b62e",
);
}
#[test]
fn wrap_unwrap_round_trip() {
// Generate an ed25519 keypair to act as the member's device key
use ed25519_dalek::SigningKey;
let mut seed = [0u8; 32];
OsRng.fill_bytes(&mut seed);
let signing_key = SigningKey::from_bytes(&seed);
let pubkey_openssh = ssh_key::PrivateKey::from(
ssh_key::private::Ed25519Keypair::from(&signing_key),
)
.public_key()
.to_openssh()
.expect("openssh");
let org_key = generate_org_key();
let wrapped = wrap_org_key(&org_key, &pubkey_openssh).expect("wrap");
let seed_zeroizing = Zeroizing::new(seed);
let unwrapped = unwrap_org_key(&wrapped, &seed_zeroizing).expect("unwrap");
assert_eq!(*org_key, *unwrapped);
}
#[test]
fn unwrap_with_wrong_seed_fails() {
use ed25519_dalek::SigningKey;
let mut seed = [0u8; 32];
OsRng.fill_bytes(&mut seed);
let signing_key = SigningKey::from_bytes(&seed);
let pubkey_openssh = ssh_key::PrivateKey::from(
ssh_key::private::Ed25519Keypair::from(&signing_key),
)
.public_key()
.to_openssh()
.expect("openssh");
let org_key = generate_org_key();
let wrapped = wrap_org_key(&org_key, &pubkey_openssh).expect("wrap");
let wrong_seed = Zeroizing::new([0xFFu8; 32]);
let result = unwrap_org_key(&wrapped, &wrong_seed);
assert!(result.is_err());
}
}
```
- [ ] **Step 3: Run tests**
```bash
cargo test -p relicario-core org:: 2>&1 | tail -20
```
Expected: all org tests pass.
- [ ] **Step 4: Update lib.rs re-exports**
Replace the `pub mod org;` stub line in `crates/relicario-core/src/lib.rs` with:
```rust
pub mod org;
pub use org::{
generate_org_key, unwrap_org_key, wrap_org_key,
CollectionDef, MemberId, OrgCollections, OrgId, OrgManifest,
OrgManifestEntry, OrgMember, OrgMembers, OrgMeta, OrgRole,
};
```
```bash
cargo check -p relicario-core
```
Expected: clean compile.
- [ ] **Step 5: Commit**
```bash
git add crates/relicario-core/src/org.rs crates/relicario-core/src/lib.rs
git commit -m "feat(core/org): org types, manifest, and X25519 key wrap/unwrap (Zeroizing KDF)"
```
---
## [Dev-A] Task A3: Org manifest vault wrappers
**Files:**
- Modify: `crates/relicario-core/src/vault.rs`
- Modify: `crates/relicario-core/src/lib.rs`
- [ ] **Step 1: Write failing tests**
Add to `crates/relicario-core/src/vault.rs` tests block:
```rust
#[test]
fn org_manifest_round_trip() {
use crate::org::{OrgManifest, OrgManifestEntry, MemberId};
use crate::ids::ItemId;
use crate::item_types::ItemType;
let mut m = OrgManifest::new();
m.entries.push(OrgManifestEntry {
id: ItemId::new(),
r#type: ItemType::SecureNote,
title: "test".into(),
tags: vec![],
modified: 0,
trashed_at: None,
collection: "prod".into(),
});
let key = key();
let bytes = encrypt_org_manifest(&m, &key).unwrap();
let decoded = decrypt_org_manifest(&bytes, &key).unwrap();
assert_eq!(decoded.entries.len(), 1);
assert_eq!(decoded.entries[0].collection, "prod");
}
```
```bash
cargo test -p relicario-core vault::tests::org_manifest_round_trip 2>&1 | tail -5
```
Expected: FAIL — `encrypt_org_manifest` not defined.
- [ ] **Step 2: Add org manifest wrappers to vault.rs**
Add to `crates/relicario-core/src/vault.rs` (after the existing `decrypt_settings` function):
```rust
use crate::org::OrgManifest;
pub fn encrypt_org_manifest(manifest: &OrgManifest, org_key: &Zeroizing<[u8; 32]>) -> Result<Vec<u8>> {
let json = serde_json::to_vec(manifest)?;
let plaintext = Zeroizing::new(json);
encrypt(org_key, plaintext.as_slice())
}
pub fn decrypt_org_manifest(encrypted: &[u8], org_key: &Zeroizing<[u8; 32]>) -> Result<OrgManifest> {
let plaintext = decrypt(org_key, encrypted)?;
let plaintext = Zeroizing::new(plaintext);
let manifest: OrgManifest = serde_json::from_slice(&plaintext)?;
Ok(manifest)
}
```
Also add the re-exports in `lib.rs` vault pub use block:
```rust
pub use vault::{
decrypt_item, decrypt_manifest, decrypt_org_manifest, decrypt_settings,
encrypt_item, encrypt_manifest, encrypt_org_manifest, encrypt_settings,
};
```
- [ ] **Step 3: Run tests**
```bash
cargo test -p relicario-core vault::tests::org_manifest_round_trip
cargo test -p relicario-core
```
Expected: all tests pass.
- [ ] **Step 4: Commit**
```bash
git add crates/relicario-core/src/vault.rs crates/relicario-core/src/lib.rs
git commit -m "feat(core/org): encrypt/decrypt_org_manifest vault wrappers"
```
---
## [Dev-A] Task A4: Full org lifecycle integration test (core)
**Files:**
- Create: `crates/relicario-core/tests/org.rs`
This test exercises the complete core-level org flow without requiring a device key on disk. The keypair helper uses the **H7 fix** (`PrivateKey::from(Ed25519Keypair::from(&signing_key))`).
> **Note:** The old plan's "add ed25519-dalek and ssh-key as dev-dependencies" step is **removed** — these crates are already in `relicario-core`'s `[dependencies]`, so integration tests (which compile against the crate) already have them available. No `[dev-dependencies]` edit is required.
- [ ] **Step 1: Write the integration test**
Create `crates/relicario-core/tests/org.rs`:
```rust
use relicario_core::{
generate_org_key, wrap_org_key, unwrap_org_key,
encrypt_org_manifest, decrypt_org_manifest,
OrgManifest, OrgManifestEntry, OrgMember, OrgMembers, OrgRole,
MemberId, ItemId,
};
use relicario_core::item_types::ItemType;
use rand::rngs::OsRng;
use rand::RngCore;
use zeroize::Zeroizing;
fn make_member_keypair() -> (Zeroizing<[u8; 32]>, String) {
let mut seed = [0u8; 32];
OsRng.fill_bytes(&mut seed);
let signing_key = ed25519_dalek::SigningKey::from_bytes(&seed);
let pubkey_openssh = ssh_key::PrivateKey::from(
ssh_key::private::Ed25519Keypair::from(&signing_key),
)
.public_key()
.to_openssh()
.expect("openssh");
(Zeroizing::new(seed), pubkey_openssh)
}
#[test]
fn org_key_wrap_unwrap_round_trip() {
let (seed, pubkey) = make_member_keypair();
let org_key = generate_org_key();
let wrapped = wrap_org_key(&org_key, &pubkey).expect("wrap");
let unwrapped = unwrap_org_key(&wrapped, &seed).expect("unwrap");
assert_eq!(*org_key, *unwrapped);
}
#[test]
fn revoked_member_cannot_decrypt_after_rotation() {
// Alice and Bob both get access
let (alice_seed, alice_pubkey) = make_member_keypair();
let (_bob_seed, bob_pubkey) = make_member_keypair();
let org_key = generate_org_key();
let _alice_wrapped = wrap_org_key(&org_key, &alice_pubkey).expect("wrap alice");
let _bob_wrapped = wrap_org_key(&org_key, &bob_pubkey).expect("wrap bob");
// Rotate: new key, only Bob gets re-wrapped
let new_org_key = generate_org_key();
let new_bob_wrapped = wrap_org_key(&new_org_key, &bob_pubkey).expect("wrap bob new");
// Alice tries to use old org_key — she can still decrypt old items,
// but new_bob_wrapped was encrypted with new_org_key, not org_key.
// Verify: unwrapping new_bob_wrapped with Alice's seed fails.
let result = unwrap_org_key(&new_bob_wrapped, &alice_seed);
assert!(result.is_err(), "Alice should not be able to unwrap Bob's new key blob");
}
#[test]
fn org_manifest_filter_restricts_to_granted_collections() {
let mut manifest = OrgManifest::new();
for (title, collection) in &[("A", "prod"), ("B", "dev"), ("C", "prod")] {
manifest.entries.push(OrgManifestEntry {
id: ItemId::new(),
r#type: ItemType::SecureNote,
title: title.to_string(),
tags: vec![],
modified: 0,
trashed_at: None,
collection: collection.to_string(),
});
}
let member = OrgMember {
member_id: MemberId::new(),
display_name: "Alice".into(),
role: OrgRole::Member,
ed25519_pubkey: String::new(),
collections: vec!["prod".into()],
added_at: 0,
added_by: MemberId::new(),
};
let filtered = manifest.filter_for_member(&member);
assert_eq!(filtered.entries.len(), 2);
assert!(filtered.entries.iter().all(|e| e.collection == "prod"));
}
#[test]
fn org_manifest_encrypt_decrypt_round_trip() {
let key = generate_org_key();
let mut manifest = OrgManifest::new();
manifest.entries.push(OrgManifestEntry {
id: ItemId::new(),
r#type: ItemType::Login,
title: "GitHub".into(),
tags: vec!["work".into()],
modified: 1748000000,
trashed_at: None,
collection: "eng-tools".into(),
});
let encrypted = encrypt_org_manifest(&manifest, &key).expect("encrypt");
let decrypted = decrypt_org_manifest(&encrypted, &key).expect("decrypt");
assert_eq!(decrypted.entries.len(), 1);
assert_eq!(decrypted.entries[0].title, "GitHub");
assert_eq!(decrypted.entries[0].collection, "eng-tools");
}
#[test]
fn members_validation_rejects_invalid_id() {
let mut members = OrgMembers::new();
members.members.push(OrgMember {
member_id: MemberId("not-hex-lol!!".to_string()),
display_name: "Bad".into(),
role: OrgRole::Member,
ed25519_pubkey: String::new(),
collections: vec![],
added_at: 0,
added_by: MemberId::new(),
});
assert!(members.validate().is_err());
}
```
- [ ] **Step 2: Run all org integration tests**
```bash
cargo test -p relicario-core --test org 2>&1 | tail -20
cargo test 2>&1 | tail -20
```
Expected: all 5 tests pass; full suite green.
- [ ] **Step 3: Final commit**
```bash
git add crates/relicario-core/tests/org.rs
git commit -m "test(core/org): full org lifecycle integration tests"
```
---
## Dev-B — relicario-cli (session, device, admin commands, item CRUD, wiring)
> **Stream prerequisite:** Tasks A2A3 must be merged before Dev-B begins (every command imports `relicario_core::org` types/functions).
## [Dev-B] Task B1: UnlockedOrgVault session type (collection-scoped item_path + fingerprint member match + signed org_git_run)
**Files:**
- Create: `crates/relicario-cli/src/org_session.rs`
- Modify: `crates/relicario-cli/src/main.rs`
`UnlockedOrgVault` holds the org master key for one CLI invocation. Two corrections from the review are baked in from the start: (1) `item_path` is **collection-scoped**`items/<collection-slug>/<id>.enc` — so the hook can authorize item writes by leading path segment without decrypting; (2) the caller is matched to `members.json` by **ed25519 fingerprint** (via `relicario_core::fingerprint`), not brittle raw-OpenSSH-string equality. `org_git_run` runs **bare git** (NOT the hardened `helpers::git_run`, which force-disables signing) so org commits are signed.
- [ ] **Step 1: Implement UnlockedOrgVault**
Create `crates/relicario-cli/src/org_session.rs`:
```rust
//! 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") }
pub fn org_meta_path(&self) -> PathBuf { self.root.join("org.json") }
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 = 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)
/// from its OpenSSH `signing.key`, for ECIES unwrap.
fn current_device_seed() -> Result<Zeroizing<[u8; 32]>> {
let name = crate::device::current_device()?
.ok_or_else(|| anyhow::anyhow!("no active device — run `relicario device add` first"))?;
let key_pem = crate::device::load_signing_key(&name)?;
let private = ssh_key::PrivateKey::from_openssh(key_pem.as_str())
.map_err(|e| anyhow::anyhow!("parse device signing key: {e}"))?;
let ed = private
.key_data()
.ed25519()
.ok_or_else(|| anyhow::anyhow!("device signing key is not ed25519"))?;
// Ed25519PrivateKey derefs to its 32-byte seed.
let seed_bytes: &[u8] = ed.private.as_ref();
if seed_bytes.len() != 32 {
anyhow::bail!("ed25519 seed has wrong length: {}", seed_bytes.len());
}
let mut seed = Zeroizing::new([0u8; 32]);
seed.copy_from_slice(seed_bytes);
Ok(seed)
}
pub(crate) fn atomic_write(path: &Path, data: &[u8]) -> Result<()> {
let mut tmp = path.as_os_str().to_owned();
tmp.push(".tmp");
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);
}
}
```
> **Note:** `current_device()`, `device_dir(name)`, and `load_signing_key(name)` are real accessors in `crates/relicario-cli/src/device.rs` (device.rs:32, :27, :95). `relicario_core::fingerprint` is re-exported at core `lib.rs:94`. The `ItemId(..)`/`MemberId(..)` tuple constructors in the tests are valid because both are `pub` tuple structs. If `item_path`/`save_item`/`load_item`/`remove_item`/`ensure_grant` warn as unused at this point, that is fine — B9B13 consume them in the same PR series; do NOT add `#[allow(dead_code)]`.
- [ ] **Step 2: Wire into main.rs module declarations**
In `crates/relicario-cli/src/main.rs`, add after the existing `mod session;` line:
```rust
mod org_session;
```
- [ ] **Step 3: Run tests**
```bash
cargo test -p relicario-cli org_session 2>&1 | tail -20
```
Expected: all org_session tests pass.
- [ ] **Step 4: Commit**
```bash
git add crates/relicario-cli/src/org_session.rs crates/relicario-cli/src/main.rs
git commit -m "feat(cli/org): UnlockedOrgVault session (collection-scoped item_path, fingerprint match, signed org_git_run)"
```
---
## [Dev-B] Task B2: Device seed/pubkey helpers + `ssh-key` CLI dep
**Files:**
- Modify: `crates/relicario-cli/Cargo.toml`
- Modify: `crates/relicario-cli/src/device.rs`
> **Context (verified against the real codebase):** Device keys live under `~/.config/relicario/devices/<name>/signing.key` (OpenSSH private) + `signing.pub` (OpenSSH public single line). The active device name is in `~/.config/relicario/devices/current`. There is **no** `~/.config/relicario/device.key` and **no** `RELICARIO_DEVICE_KEY` env var. Use the real accessors already in `crates/relicario-cli/src/device.rs`: `current_device() -> Result<Option<String>>` (device.rs:32), `device_dir(name) -> Result<PathBuf>` (device.rs:27), `load_signing_key(name) -> Result<Zeroizing<String>>` (device.rs:95, returns the OpenSSH private PEM text). The seed-extraction path is verified against `ssh-key` 0.6.7: `PrivateKey::from_openssh(&pem)` → `.key_data()` → `.ed25519()` (`Option<&Ed25519Keypair>`) → `.private` (`Ed25519PrivateKey`) → `.as_ref()` (`&[u8; 32]`). The core crate already uses this exact chain in `relicario-core/src/device.rs:64-69`.
- [ ] **Step 1: Add `ssh-key` to the CLI crate's dependencies**
`ssh-key` is **not** currently a dependency of `relicario-cli` (verified: no `ssh-key` line in `crates/relicario-cli/Cargo.toml`). It *is* already in the workspace lock at version **0.6.7** (pulled in by `relicario-core`). In `crates/relicario-cli/Cargo.toml`, under `[dependencies]`, add the line after the `qrcode` entry:
```toml
ssh-key = { version = "0.6", features = ["ed25519", "std"] }
```
The full `[dependencies]` tail should read:
```toml
reqwest = { version = "0.12", features = ["blocking", "json"] }
qrcode = { version = "0.14", features = ["svg"] }
ssh-key = { version = "0.6", features = ["ed25519", "std"] }
```
> **Why these features:** `ed25519` enables the `Ed25519Keypair`/`Ed25519PrivateKey` accessors and the `key_data().ed25519()` path; `std` enables `PrivateKey::from_openssh` / `PublicKey::to_openssh`. This matches the feature set `relicario-core` already relies on, so resolution stays at 0.6.7 — no new lockfile entry, no version bump.
- [ ] **Step 2: Confirm the dependency resolves without changing the lock version**
```bash
cargo tree -p relicario-cli -i ssh-key 2>&1 | head -5
grep -A1 'name = "ssh-key"' Cargo.lock | head -3
```
Expected: `ssh-key v0.6.7` appears for `relicario-cli`, and `Cargo.lock` still pins `version = "0.6.7"` (no second copy of `ssh-key` added).
- [ ] **Step 3: Add a failing unit test for the round-trip**
Append this test module to the end of `crates/relicario-cli/src/device.rs`. It generates a keypair with `relicario_core::device::generate_keypair()`, writes it to a temp device dir, points `current` at it, and asserts the seed we extract re-derives the same OpenSSH public key.
> **Note:** This test mutates `XDG_CONFIG_HOME` to redirect `dirs::config_dir()`; it serializes via a `Mutex` and restores the env afterwards. Requires the `tempfile` dev-dependency (already present in `[dev-dependencies]`).
```rust
#[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"),
}
}
}
```
> **New dev-dependency note:** This test references `ed25519_dalek::SigningKey`. `ed25519-dalek` is **not** a dev-dependency of `relicario-cli`. Add it to `crates/relicario-cli/Cargo.toml` under `[dev-dependencies]` (already in the workspace lock at the version `relicario-core` uses, so no new lock entry):
>
> ```toml
> ed25519-dalek = "2"
> ```
Run it — it must **fail to compile** now (the two helper fns don't exist yet):
```bash
cargo test -p relicario-cli --lib seed_helper_tests 2>&1 | tail -20
```
Expected: compile error `cannot find function 'current_device_seed'` (and `current_device_pubkey`).
- [ ] **Step 4: Implement the two helpers**
Add these two public functions to `crates/relicario-cli/src/device.rs`, immediately after `load_signing_key` (after device.rs:100). Do **not** add a `device_key_path()`.
```rust
/// 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)
}
```
> **Import note:** `fs`, `Context`, `Result`, and `Zeroizing` are already imported at the top of `device.rs` (lines 1317). `ssh_key` is referenced by fully-qualified path. `anyhow::anyhow!` / `anyhow::bail!` are used fully-qualified to match the existing style.
> **Note on `current_device_seed` overlap:** `org_session.rs` (Task B1) defines its own private `current_device_seed`/`current_device_fingerprint` for the unwrap path. These device.rs helpers are the *public* device-module surface (also used by Task B4's signing test and by any future caller). They are not the same functions; B1's are module-private to `org_session`. Both reading the same on-disk key is intentional and correct.
- [ ] **Step 5: Verify the test goes green + regression**
```bash
cargo test -p relicario-cli --lib seed_helper_tests 2>&1 | tail -20
cargo check -p relicario-cli 2>&1 | tail -5
cargo test -p relicario-cli --lib 2>&1 | tail -15
```
Expected: `current_device_seed_and_pubkey_round_trip ... ok`; clean check; all lib tests pass.
- [ ] **Step 6: Commit**
```bash
git add crates/relicario-cli/Cargo.toml Cargo.lock crates/relicario-cli/src/device.rs
git commit -m "feat(cli/device): current_device_seed + current_device_pubkey helpers
Read the active device's ed25519 seed/pubkey from
devices/<name>/signing.{key,pub}. Adds ssh-key (0.6) as a CLI dep
(already at 0.6.7 in the workspace lock via relicario-core) and
ed25519-dalek as a dev-dep for the round-trip test.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## [Dev-B] Task B3: Org commands module stub + `pub mod org` wiring
**Files:**
- Create: `crates/relicario-cli/src/commands/org.rs` (stub)
- Modify: `crates/relicario-cli/src/commands/mod.rs`
- [ ] **Step 1: Check existing module layout**
```bash
grep -n "pub mod" crates/relicario-cli/src/commands/mod.rs | head -30
```
- [ ] **Step 2: Create org commands stub**
Create `crates/relicario-cli/src/commands/org.rs`:
```rust
//! `relicario org` subcommands for multi-user org vault management.
use anyhow::Result;
pub fn run_init(_dir: &std::path::Path, _name: &str) -> Result<()> {
todo!("org init")
}
```
Add to `crates/relicario-cli/src/commands/mod.rs`:
```rust
pub mod org;
```
- [ ] **Step 3: Verify compile**
```bash
cargo check -p relicario-cli
```
Expected: clean (`todo!` is fine at compile time).
- [ ] **Step 4: Commit**
```bash
git add crates/relicario-cli/src/commands/org.rs crates/relicario-cli/src/commands/mod.rs
git commit -m "feat(cli/org): org commands module stub + pub mod wiring"
```
---
## [Dev-B] Task B4: `org init` (with git signing)
**Files:**
- Modify: `crates/relicario-cli/src/commands/org.rs`
- Create: `crates/relicario-cli/tests/org_init.rs`
- Create: `crates/relicario-cli/tests/org_init_signing.rs`
`org init` creates the org directory structure, generates the org master key, wraps it to the caller's device key, writes all initial files, runs `git init`, **configures git signing** (`configure_git_signing`), and makes a **signed** bootstrap commit via `org_git_run`.
> **Why signing is load-bearing:** `crate::helpers::git_run` (helpers.rs:73) flows through `git_command` (helpers.rs:46), which hard-codes `-c commit.gpgsign=false` (helpers.rs:51). Any commit made through `git_run` is **unsigned** — and the pre-receive hook (Dev-C) rejects/cannot-attribute unsigned commits. So `org init` must (1) call `configure_git_signing(dir, &device_name)` (device.rs:133) to set `gpg.format=ssh`, `user.signingkey=<signing.key>`, `commit.gpgsign=true`, and (2) make the bootstrap commit via `org_git_run` (Task B1), which does NOT disable signing. This task folds the org-init structure/wrap logic and the signing wiring into one — there is **no** separate standalone signing task.
- [ ] **Step 1: Write the plain integration test**
Create `crates/relicario-cli/tests/org_init.rs`:
```rust
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());
}
```
- [ ] **Step 2: Write the failing signing integration test**
Create `crates/relicario-cli/tests/org_init_signing.rs`:
```rust
use std::path::Path;
use std::process::Command;
use tempfile::TempDir;
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")
}
fn git(repo: &Path, args: &[&str]) -> std::process::Output {
Command::new("git")
.current_dir(repo)
.args(args)
.output()
.expect("run git")
}
#[test]
fn org_init_produces_a_signed_initial_commit() {
let cfg = TempDir::new().unwrap();
let org = TempDir::new().unwrap();
// Register a device so current_device_pubkey()/configure_git_signing work.
let add = relicario(cfg.path(), &["device", "add", "--name", "test-dev", "--local"]);
assert!(
add.status.success(),
"device add failed: {}",
String::from_utf8_lossy(&add.stderr)
);
// Initialize the org vault. `--dir` is a subcommand-scoped global on `org`
// (B14), so it comes AFTER `org init` (matches B10's OrgFixture).
let init = relicario(
cfg.path(),
&["org", "init", "--dir", org.path().to_str().unwrap(), "--name", "Acme"],
);
assert!(
init.status.success(),
"org init failed: {}",
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}"
);
// And it must actually verify against the configured signer.
let verify = git(org.path(), &["verify-commit", "HEAD"]);
assert!(
verify.status.success(),
"git verify-commit HEAD failed: {}",
String::from_utf8_lossy(&verify.stderr)
);
}
```
> **Test-harness assumption:** `relicario device add --name <n> --local` creates the device keys under `$XDG_CONFIG_HOME/relicario/devices/<n>/` and sets `current` without contacting Gitea. If the real `device add` surface differs (verify against `main.rs` `DeviceAction` and `commands::device`), adjust the flags — but the assertions (signed HEAD, `commit.gpgsign=true`, `verify-commit` success) are load-bearing and must stay. If `device add` cannot run offline in CI, lay out `devices/<n>/signing.{key,pub}` + `current` directly using `relicario_core::device::generate_keypair()` (same layout as Task B2 Step 3).
```bash
cargo test -p relicario-cli --test org_init_signing 2>&1 | tail -25
```
Expected red: `commit.gpgsign` empty or `gpgsig ` header absent (init not implemented / committed via unsigned path).
- [ ] **Step 3: Implement `run_init` (structure + wrap + signing + signed commit)**
Replace `run_init` stub in `commands/org.rs`:
```rust
use std::fs;
use std::path::Path;
use anyhow::{Context, Result};
use relicario_core::{
generate_org_key, wrap_org_key,
CollectionDef, MemberId, OrgCollections, OrgManifest, OrgMembers, OrgMeta, OrgRole, OrgMember,
encrypt_org_manifest,
};
use crate::org_session::atomic_write;
pub fn run_init(dir: &Path, name: &str) -> Result<()> {
// Create directory structure
fs::create_dir_all(dir.join("items")).context("create items/")?;
fs::create_dir_all(dir.join("keys")).context("create keys/")?;
// Get caller's device info
let device_pubkey = crate::device::current_device_pubkey()
.context("read device key — run `relicario device add` first")?;
// Generate org master key
let org_key = generate_org_key();
// Wrap org key to caller's device key
let wrapped = wrap_org_key(&org_key, &device_pubkey)
.context("wrap org key to device key")?;
// Create initial members.json with caller as owner
let caller_id = MemberId::new();
let now = relicario_core::now_unix();
let member = OrgMember {
member_id: caller_id.clone(),
display_name: whoami(),
role: OrgRole::Owner,
ed25519_pubkey: device_pubkey,
collections: vec![],
added_at: now,
added_by: caller_id.clone(),
};
let mut members = OrgMembers::new();
members.members.push(member);
// Write wrapped key
let key_path = dir.join("keys").join(format!("{}.enc", caller_id.as_str()));
fs::write(&key_path, &wrapped).context("write caller key blob")?;
// Write org.json
let meta = OrgMeta::new(name.to_string());
let meta_json = serde_json::to_string_pretty(&meta)?;
atomic_write(&dir.join("org.json"), meta_json.as_bytes())?;
// Write members.json
let members_json = serde_json::to_string_pretty(&members)?;
atomic_write(&dir.join("members.json"), members_json.as_bytes())?;
// Write collections.json (empty)
let collections = OrgCollections::new();
let coll_json = serde_json::to_string_pretty(&collections)?;
atomic_write(&dir.join("collections.json"), coll_json.as_bytes())?;
// Write empty manifest.enc
let manifest = OrgManifest::new();
let manifest_bytes = encrypt_org_manifest(&manifest, &org_key)?;
atomic_write(&dir.join("manifest.enc"), &manifest_bytes)?;
// git init, then configure THIS repo to sign commits with the active device
// key. Org commits must be signed; the pre-receive hook verifies every one.
crate::helpers::git_run(dir, &["init"], "git init")?;
let device_name = crate::device::current_device()?
.ok_or_else(|| anyhow::anyhow!("no active device — run `relicario device add` first"))?;
crate::device::configure_git_signing(dir, &device_name)
.context("configure org repo signing")?;
// Stage everything and make the signed bootstrap commit via org_git_run
// (which does NOT disable signing, unlike helpers::git_run).
crate::org_session::org_git_run(dir, &["add", "."], "git add")?;
let commit_msg = format!(
"init: org vault \"{name}\"\n\nRelicario-Actor: {} {}\nRelicario-Action: org-init",
members.members[0].display_name,
caller_id.as_str()
);
crate::org_session::org_git_run(dir, &["commit", "-m", &commit_msg], "git commit")?;
println!("Org vault initialized at {}", dir.display());
println!("Your member ID: {}", caller_id.as_str());
Ok(())
}
fn whoami() -> String {
std::env::var("USER")
.or_else(|_| std::env::var("USERNAME"))
.unwrap_or_else(|_| "unknown".into())
}
```
> **Verified:** `configure_git_signing(vault_root: &Path, name: &str) -> Result<()>` (device.rs:133). The trailer uses `Relicario-Actor: <name> <member_id>` (space-separated, no angle brackets) per the canonical contract — the hook ignores the trailer for actor attribution (it uses the verified signer), but keeping it contract-shaped matters for the audit `TAMPERED` cross-check.
- [ ] **Step 4: Verify both tests + build**
```bash
cargo test -p relicario-cli --test org_init_signing 2>&1 | tail -25
cargo build -p relicario-cli 2>&1 | tail -10
```
Expected: `org_init_produces_a_signed_initial_commit ... ok`; clean build.
- [ ] **Step 5: Commit**
```bash
git add crates/relicario-cli/src/commands/org.rs crates/relicario-cli/tests/org_init.rs crates/relicario-cli/tests/org_init_signing.rs
git commit -m "feat(cli/org): org init — structure + wrap + configure_git_signing + signed bootstrap commit"
```
---
## [Dev-B] Task B5: `org add-member` / `remove-member` / `set-role` (with owner-only role-gating)
**Files:**
- Modify: `crates/relicario-cli/src/commands/org.rs`
All three commands share the open-vault → edit members.json → signed commit pattern. **Role-gating correction:** `add-member` only checked `can_manage_members()` and then trusted the caller-supplied `role` unconditionally — an admin could mint a new owner. The fix adds an owner-only gate so non-owners cannot create owner/admin members (spec line 148/273).
- [ ] **Step 1: Write failing unit test**
Add to `commands/org.rs`:
```rust
#[cfg(test)]
mod tests {
use super::*;
use relicario_core::{MemberId, OrgMembers, OrgRole, OrgMember};
fn alice() -> OrgMember {
OrgMember {
member_id: MemberId::new(),
display_name: "Alice".into(),
role: OrgRole::Member,
ed25519_pubkey: "ssh-ed25519 AAAA fake".into(),
collections: vec![],
added_at: 0,
added_by: MemberId::new(),
}
}
#[test]
fn set_role_changes_role() {
let mut members = OrgMembers::new();
let a = alice();
let id = a.member_id.clone();
members.members.push(a);
if let Some(m) = members.find_by_id_mut(&id) {
m.role = OrgRole::Admin;
}
assert_eq!(members.find_by_id(&id).unwrap().role, OrgRole::Admin);
}
}
```
```bash
cargo test -p relicario-cli commands::org::tests 2>&1 | tail -5
```
Expected: PASS (pure logic test).
- [ ] **Step 2: Implement add-member (with owner-only escalation guard)**
Add to `commands/org.rs`:
```rust
pub fn run_add_member(
dir: &Path,
pubkey: &str,
name: &str,
role: OrgRole,
) -> Result<()> {
let vault = crate::org_session::open_org_vault(Some(dir))?;
let caller = vault.current_member()?;
if !caller.role.can_manage_members() {
anyhow::bail!("only owners and admins can add members");
}
// Privilege-escalation guard: only an owner may create an owner or admin.
if matches!(role, OrgRole::Owner | OrgRole::Admin) && !caller.role.can_manage_owners() {
anyhow::bail!("only owners can add members with the owner or admin role");
}
let mut members = vault.load_members()?;
// Check pubkey not already present
if members.members.iter().any(|m| m.ed25519_pubkey.trim() == pubkey.trim()) {
anyhow::bail!("this public key is already registered in the org");
}
let new_id = MemberId::new();
let now = relicario_core::now_unix();
let wrapped = wrap_org_key(vault.key(), pubkey)
.context("wrap org key to new member's key")?;
fs::write(vault.member_key_path(&new_id), &wrapped)
.context("write member key blob")?;
members.members.push(OrgMember {
member_id: new_id.clone(),
display_name: name.to_string(),
role,
ed25519_pubkey: pubkey.trim().to_string(),
collections: vec![],
added_at: now,
added_by: caller.member_id.clone(),
});
vault.save_members(&members)?;
let commit_msg = format!(
"org: add member \"{name}\"\n\nRelicario-Actor: {} {}\nRelicario-Action: member-add\nRelicario-Member: {}",
caller.display_name, caller.member_id.as_str(), new_id.as_str()
);
crate::org_session::org_git_run(
&vault.root,
&["add", "members.json", &format!("keys/{}.enc", new_id.as_str())],
"git add",
)?;
crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "git commit")?;
println!("Added {} ({})", name, new_id.as_str());
Ok(())
}
```
> `role: OrgRole` is the existing parameter; `OrgRole` derives `Copy`, so `matches!(role, ...)` does not move it.
- [ ] **Step 3: Implement remove-member**
```rust
pub fn run_remove_member(dir: &Path, member_id_prefix: &str) -> Result<()> {
let vault = crate::org_session::open_org_vault(Some(dir))?;
let caller = vault.current_member()?;
if !caller.role.can_manage_members() {
anyhow::bail!("only owners and admins can remove members");
}
let mut members = vault.load_members()?;
let target_id = resolve_member_id(&members, member_id_prefix)?;
let target = members.find_by_id(&target_id).unwrap();
if target.role == OrgRole::Owner && !caller.role.can_manage_owners() {
anyhow::bail!("only owners can remove other owners");
}
let target_name = target.display_name.clone();
// Delete key blob
let key_path = vault.member_key_path(&target_id);
if key_path.exists() { fs::remove_file(&key_path).context("delete key blob")?; }
members.members.retain(|m| m.member_id != target_id);
vault.save_members(&members)?;
let commit_msg = format!(
"org: remove member \"{target_name}\"\n\nRelicario-Actor: {} {}\nRelicario-Action: member-remove\nRelicario-Member: {}",
caller.display_name, caller.member_id.as_str(), target_id.as_str()
);
crate::org_session::org_git_run(
&vault.root,
&["add", "members.json", &format!("keys/{}.enc", target_id.as_str())],
"git add",
)?;
crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "git commit")?;
eprintln!("⚠ Run `relicario org rotate-key --dir {}` to complete revocation.", vault.root.display());
println!("Removed {}", target_name);
Ok(())
}
```
- [ ] **Step 4: Implement set-role**
```rust
pub fn run_set_role(dir: &Path, member_id_prefix: &str, role: OrgRole) -> Result<()> {
let vault = crate::org_session::open_org_vault(Some(dir))?;
let caller = vault.current_member()?;
let mut members = vault.load_members()?;
let target_id = resolve_member_id(&members, member_id_prefix)?;
if matches!(role, OrgRole::Admin | OrgRole::Owner) && !caller.role.can_manage_owners() {
anyhow::bail!("only owners can promote to admin or owner");
}
if !caller.role.can_manage_members() {
anyhow::bail!("only owners and admins can change roles");
}
let target = members.find_by_id_mut(&target_id)
.ok_or_else(|| anyhow::anyhow!("member not found"))?;
let old_role = target.role;
target.role = role;
vault.save_members(&members)?;
let commit_msg = format!(
"org: set role {}{:?}\n\nRelicario-Actor: {} {}\nRelicario-Action: member-role-change\nRelicario-Member: {}",
target_id.as_str(), role,
caller.display_name, caller.member_id.as_str(),
target_id.as_str()
);
crate::org_session::org_git_run(&vault.root, &["add", "members.json"], "git add")?;
crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "git commit")?;
println!("Changed role {:?}{:?}", old_role, role);
Ok(())
}
/// Resolve a member_id prefix (or full ID) to a MemberId.
fn resolve_member_id(members: &OrgMembers, prefix: &str) -> Result<MemberId> {
let hits: Vec<_> = members.members.iter()
.filter(|m| m.member_id.as_str().starts_with(prefix))
.collect();
match hits.len() {
0 => anyhow::bail!("no member matches `{prefix}`"),
1 => Ok(hits[0].member_id.clone()),
_ => anyhow::bail!("ambiguous prefix `{prefix}` — {} matches", hits.len()),
}
}
```
- [ ] **Step 5: Compile + commit**
```bash
cargo build -p relicario-cli 2>&1 | tail -10
git add crates/relicario-cli/src/commands/org.rs
git commit -m "feat(cli/org): add-member (owner-only escalation guard), remove-member, set-role"
```
---
## [Dev-B] Task B6: `org create-collection` / `grant` / `revoke`
**Files:**
- Modify: `crates/relicario-cli/src/commands/org.rs`
- [ ] **Step 1: Write failing tests**
Add to the `tests` block in `commands/org.rs`:
```rust
#[test]
fn grant_adds_slug_to_member_collections() {
let mut members = OrgMembers::new();
let a = alice();
let id = a.member_id.clone();
members.members.push(a);
let m = members.find_by_id_mut(&id).unwrap();
if !m.collections.contains(&"prod".to_string()) {
m.collections.push("prod".to_string());
}
assert!(members.find_by_id(&id).unwrap().collections.contains(&"prod".to_string()));
}
#[test]
fn revoke_removes_slug_from_member_collections() {
let mut members = OrgMembers::new();
let mut a = alice();
a.collections = vec!["prod".into(), "dev".into()];
let id = a.member_id.clone();
members.members.push(a);
let m = members.find_by_id_mut(&id).unwrap();
m.collections.retain(|s| s != "prod");
assert!(!members.find_by_id(&id).unwrap().collections.contains(&"prod".to_string()));
assert!(members.find_by_id(&id).unwrap().collections.contains(&"dev".to_string()));
}
```
```bash
cargo test -p relicario-cli commands::org::tests 2>&1 | tail -5
```
Expected: all pass.
- [ ] **Step 2: Implement create-collection**
```rust
pub fn run_create_collection(dir: &Path, slug: &str, display_name: &str) -> Result<()> {
let vault = crate::org_session::open_org_vault(Some(dir))?;
let caller = vault.current_member()?;
if !caller.role.can_manage_members() {
anyhow::bail!("only owners and admins can create collections");
}
let mut collections = vault.load_collections()?;
if collections.contains_slug(slug) {
anyhow::bail!("collection `{slug}` already exists");
}
if slug.is_empty() || slug.contains('/') || slug.contains('.') {
anyhow::bail!("invalid slug `{slug}` — no slashes or dots, no empty string");
}
collections.collections.push(CollectionDef {
slug: slug.to_string(),
display_name: display_name.to_string(),
created_by: caller.member_id.clone(),
created_at: relicario_core::now_unix(),
});
vault.save_collections(&collections)?;
let commit_msg = format!(
"org: create collection \"{slug}\"\n\nRelicario-Actor: {} {}\nRelicario-Action: collection-create\nRelicario-Collection: {slug}",
caller.display_name, caller.member_id.as_str()
);
crate::org_session::org_git_run(&vault.root, &["add", "collections.json"], "git add")?;
crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "git commit")?;
println!("Created collection `{slug}`");
Ok(())
}
```
- [ ] **Step 3: Implement grant**
```rust
pub fn run_grant(dir: &Path, member_id_prefix: &str, slug: &str) -> Result<()> {
let vault = crate::org_session::open_org_vault(Some(dir))?;
let caller = vault.current_member()?;
if !caller.role.can_manage_members() {
anyhow::bail!("only owners and admins can grant collection access");
}
let collections = vault.load_collections()?;
if !collections.contains_slug(slug) {
anyhow::bail!("collection `{slug}` does not exist — create it first");
}
let mut members = vault.load_members()?;
let target_id = resolve_member_id(&members, member_id_prefix)?;
let target = members.find_by_id_mut(&target_id).unwrap();
if target.collections.contains(&slug.to_string()) {
anyhow::bail!("member already has access to `{slug}`");
}
target.collections.push(slug.to_string());
vault.save_members(&members)?;
let commit_msg = format!(
"org: grant {slug} to {}\n\nRelicario-Actor: {} {}\nRelicario-Action: collection-grant\nRelicario-Collection: {slug}\nRelicario-Member: {}",
target_id.as_str(), caller.display_name, caller.member_id.as_str(), target_id.as_str()
);
crate::org_session::org_git_run(&vault.root, &["add", "members.json"], "git add")?;
crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "git commit")?;
println!("Granted `{slug}` to {}", target_id.as_str());
Ok(())
}
```
- [ ] **Step 4: Implement revoke**
```rust
pub fn run_revoke(dir: &Path, member_id_prefix: &str, slug: &str) -> Result<()> {
let vault = crate::org_session::open_org_vault(Some(dir))?;
let caller = vault.current_member()?;
if !caller.role.can_manage_members() {
anyhow::bail!("only owners and admins can revoke collection access");
}
let mut members = vault.load_members()?;
let target_id = resolve_member_id(&members, member_id_prefix)?;
let target = members.find_by_id_mut(&target_id).unwrap();
if !target.collections.contains(&slug.to_string()) {
anyhow::bail!("member does not have access to `{slug}`");
}
target.collections.retain(|s| s != slug);
vault.save_members(&members)?;
let commit_msg = format!(
"org: revoke {slug} from {}\n\nRelicario-Actor: {} {}\nRelicario-Action: collection-revoke\nRelicario-Collection: {slug}\nRelicario-Member: {}",
target_id.as_str(), caller.display_name, caller.member_id.as_str(), target_id.as_str()
);
crate::org_session::org_git_run(&vault.root, &["add", "members.json"], "git add")?;
crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "git commit")?;
println!("Revoked `{slug}` from {}", target_id.as_str());
Ok(())
}
```
- [ ] **Step 5: Compile + commit**
```bash
cargo build -p relicario-cli 2>&1 | tail -5
git add crates/relicario-cli/src/commands/org.rs
git commit -m "feat(cli/org): create-collection, grant, revoke commands"
```
---
## [Dev-B] Task B7: `org rotate-key` (re-encrypt every item blob + race abort)
**Files:**
- Modify: `crates/relicario-cli/src/commands/org.rs`
`rotate-key` generates a new org master key, re-wraps it for all current members, **re-encrypts every item blob AND the manifest** under the new key, and commits. Two corrections from the review: **(H9)** the old code re-encrypted only `manifest.enc`, leaving every `items/<slug>/<id>.enc` under the *old* key — a removed member with a clone could still decrypt all pre-rotation items; we now walk and re-encrypt every blob. **(race)** the old pull-failure handler swallowed *all* errors (including a genuine non-fast-forward / concurrent rotation) and proceeded; we now abort on a real conflict with the exact spec string while still distinguishing a missing remote.
- [ ] **Step 1: Write failing test**
Add to tests block:
```rust
#[test]
fn new_key_differs_from_old_key() {
let k1 = relicario_core::generate_org_key();
let k2 = relicario_core::generate_org_key();
assert_ne!(*k1, *k2);
}
```
```bash
cargo test -p relicario-cli commands::org::tests::new_key_differs 2>&1 | tail -5
```
Expected: PASS.
- [ ] **Step 2: Implement rotate-key**
```rust
pub fn run_rotate_key(dir: &Path) -> Result<()> {
// Pull latest state first to detect a concurrent rotation. We must
// distinguish three outcomes:
// * success -> proceed
// * no upstream / no remote -> local-only org, proceed
// * non-fast-forward / conflict -> concurrent rotation, ABORT
let pull = std::process::Command::new("git")
.current_dir(dir)
.args(["pull", "--rebase"])
.output()
.context("spawn git pull --rebase")?;
if !pull.status.success() {
let stderr = String::from_utf8_lossy(&pull.stderr);
let no_upstream = stderr.contains("no tracking information")
|| stderr.contains("There is no tracking information")
|| stderr.contains("does not appear to be a git repository")
|| stderr.contains("Could not read from remote repository")
|| stderr.contains("No remote repository specified");
if no_upstream {
eprintln!("Note: no upstream configured; proceeding with local state.");
} else {
// Best-effort: leave the working tree clean for the retry.
let _ = std::process::Command::new("git")
.current_dir(dir)
.args(["rebase", "--abort"])
.output();
anyhow::bail!(
"Concurrent key rotation detected — pull and re-run org rotate-key."
);
}
}
let vault = crate::org_session::open_org_vault(Some(dir))?;
let caller = vault.current_member()?;
if !caller.role.can_manage_owners() {
anyhow::bail!("only owners can rotate the org master key");
}
let members = vault.load_members()?;
let new_org_key = relicario_core::generate_org_key();
// Re-wrap the org key for every current member.
let mut staged_paths: Vec<String> = Vec::new();
for member in &members.members {
let wrapped = wrap_org_key(&new_org_key, &member.ed25519_pubkey)
.with_context(|| format!("wrap key for {}", member.display_name))?;
let key_path = vault.member_key_path(&member.member_id);
fs::write(&key_path, &wrapped)
.with_context(|| format!("write key for {}", member.display_name))?;
staged_paths.push(format!("keys/{}.enc", member.member_id.as_str()));
}
// Re-encrypt EVERY item blob under the new key. Items live collection-scoped
// at items/<collection-slug>/<id>.enc. Decrypt with the old key (held in the
// open vault session) and re-encrypt with the new one, in place. Without this
// a removed member who kept the old key + a clone could still decrypt every
// pre-rotation item.
let items_root = vault.root().join("items");
if items_root.is_dir() {
for slug_entry in fs::read_dir(&items_root).context("read items/")? {
let slug_entry = slug_entry.context("read items/ entry")?;
let slug_dir = slug_entry.path();
if !slug_dir.is_dir() {
continue;
}
let slug = slug_entry.file_name().to_string_lossy().to_string();
for item_entry in fs::read_dir(&slug_dir)
.with_context(|| format!("read items/{slug}/"))?
{
let item_entry = item_entry.context("read item entry")?;
let item_path = item_entry.path();
if item_path.extension().and_then(|e| e.to_str()) != Some("enc") {
continue;
}
let old_bytes = fs::read(&item_path)
.with_context(|| format!("read {}", item_path.display()))?;
let item = relicario_core::decrypt_item(&old_bytes, vault.key())
.with_context(|| format!("decrypt {}", item_path.display()))?;
let new_bytes = relicario_core::encrypt_item(&item, &new_org_key)
.with_context(|| format!("re-encrypt {}", item_path.display()))?;
crate::org_session::atomic_write(&item_path, &new_bytes)?;
let file_name = item_entry.file_name().to_string_lossy().to_string();
staged_paths.push(format!("items/{slug}/{file_name}"));
}
}
}
// Re-encrypt the manifest with the new key.
let manifest = vault.load_manifest()?;
let new_manifest_bytes = relicario_core::encrypt_org_manifest(&manifest, &new_org_key)?;
crate::org_session::atomic_write(&vault.manifest_path(), &new_manifest_bytes)?;
staged_paths.push("manifest.enc".to_string());
// Commit
let mut add_args = vec!["add"];
let path_refs: Vec<&str> = staged_paths.iter().map(|s| s.as_str()).collect();
add_args.extend_from_slice(&path_refs);
crate::org_session::org_git_run(&vault.root, &add_args, "git add")?;
let commit_msg = format!(
"org: rotate org master key\n\nRelicario-Actor: {} {}\nRelicario-Action: key-rotate",
caller.display_name, caller.member_id.as_str()
);
crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "git commit")?;
println!(
"Key rotated. {} member key(s) re-wrapped; all item blobs + manifest re-encrypted.",
members.members.len()
);
Ok(())
}
```
> **Verified:** `decrypt_item(&[u8], &Zeroizing<[u8;32]>) -> Result<Item>` and `encrypt_item(&Item, &Zeroizing<[u8;32]>) -> Result<Vec<u8>>` (re-exported at core lib.rs:8182). `vault.key()` returns the OLD key; `vault.root()` returns `&PathBuf`. The `git add` step stages `staged_paths`, now including every `items/<slug>/<file>.enc`.
- [ ] **Step 3: Build + commit**
```bash
cargo build -p relicario-cli 2>&1 | tail -5
git add crates/relicario-cli/src/commands/org.rs
git commit -m "feat(cli/org): rotate-key — re-encrypt every item blob + abort on concurrent rotation"
```
---
## [Dev-B] Task B8: `org status` + `org audit` (verified-signer attribution + TAMPERED flag)
**Files:**
- Modify: `crates/relicario-cli/src/commands/org.rs`
- Modify: `crates/relicario-cli/Cargo.toml` (ensure `regex` + `tempfile` in `[dependencies]`)
`status` prints members + collections with no decryption. `audit` parses `git log`, **resolves each commit's verified signer** to a member and reports *that* as the actor (trailers are advisory), flags trailer/signer mismatch as `TAMPERED`, and frames records with `%x1e`/`%x1f` (so multi-line trailer values cannot misalign records) using the **committer** date (`%cI`).
> **Dependency note:** `run_audit`/`resolve_signer` need `regex` and `tempfile` available to `relicario-cli` at runtime (not just dev). Add to `crates/relicario-cli/Cargo.toml` `[dependencies]` if absent: `regex = "1"` and `tempfile = "3"` (both already in the workspace lock via relicario-server).
- [ ] **Step 1: Implement status**
```rust
pub fn run_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)?
};
let members: OrgMembers = {
let s = fs::read_to_string(root.join("members.json")).context("read members.json")?;
serde_json::from_str(&s)?
};
let collections: OrgCollections = {
let s = fs::read_to_string(root.join("collections.json")).context("read collections.json")?;
serde_json::from_str(&s)?
};
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(())
}
```
- [ ] **Step 2: Write failing test for audit trailer parsing**
Add to tests block:
```rust
#[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);
}
```
```bash
cargo test -p relicario-cli commands::org::tests::parse_trailers 2>&1 | tail -5
```
Expected: FAIL — `parse_trailer_block` not defined.
- [ ] **Step 3: Implement audit (struct, trailer parser, signer resolver, run_audit)**
```rust
#[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,
}
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())
})
}
pub fn run_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 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);
}
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(())
}
```
> **Verified:** mirrors `relicario-server`'s `verify_commit` exactly (temp allowed_signers prefixed `relicario `, `GIT_CONFIG_COUNT/KEY_0/VALUE_0 = gpg.ssh.allowedSignersFile`, `git verify-commit --raw`, regex `key (SHA256:[A-Za-z0-9+/]+)` over stderr, fingerprint match via `relicario_core::fingerprint`). git 2.54.0 supports `%(trailers:only=true,unfold=true)` and `%x1e`/`%x1f`; `%x1e` prefixes each record so `split('\u{1e}')` yields a leading empty element (filtered by the empty-record guard). `%cI` emits strict ISO 8601 with offset.
- [ ] **Step 4: Run the trailer test + build**
```bash
cargo test -p relicario-cli commands::org::tests::parse_trailers 2>&1 | tail -5
cargo build -p relicario-cli 2>&1 | tail -5
```
Expected: PASS; clean build.
- [ ] **Step 5: Commit**
```bash
git add crates/relicario-cli/src/commands/org.rs crates/relicario-cli/Cargo.toml Cargo.lock
git commit -m "feat(cli/org): status + audit (verified-signer attribution, TAMPERED flag, committer-date framing)"
```
---
## [Dev-B] Task B9: Confirm collection-scoped item helpers on `UnlockedOrgVault`
**Files:**
- Verify only: `crates/relicario-cli/src/org_session.rs`
> **Reconciliation note:** The collection-scoped `item_path(collection_slug, id)` and the item I/O helpers (`save_item`, `load_item`, `remove_item`, `ensure_grant`) were already defined in **Task B1** to avoid a duplicate `item_path` definition. This task is the checkpoint that those helpers exist with the exact signatures the item commands (B10B13) consume. It adds **no new `item_path` definition** — there is exactly ONE, in B1.
- [ ] **Step 1: Verify the helper surface exists**
```bash
grep -n "pub fn item_path\|pub fn save_item\|pub fn load_item\|pub fn remove_item\|pub fn ensure_grant" crates/relicario-cli/src/org_session.rs
```
Expected: each appears exactly once. `item_path(&self, collection_slug: &str, id: &ItemId)`, `save_item(&self, collection_slug, &Item) -> Result<String>`, `load_item(&self, collection_slug, &ItemId) -> Result<Item>`, `remove_item(&self, collection_slug, &ItemId) -> Result<()>`, `ensure_grant(member: &OrgMember, slug: &str) -> Result<()>`.
```bash
grep -rn "item_path" crates/relicario-cli/src/ | grep -v "collection_slug"
```
Expected: the only non-`collection_slug` matches are the personal-vault `session.rs` (a different type — leave it alone) and the B1 doc-comment lines. No flat `items/<id>.enc` call sites.
- [ ] **Step 2: Compile check**
```bash
cargo build -p relicario-cli 2>&1 | tail -15
```
Expected: clean build (unused-helper warnings are fine — B10B13 consume them in the same PR series; do NOT add `#[allow(dead_code)]`).
> No commit for this task — it is a verification gate. If a helper is missing, fix it in B1's file and amend B1's commit (or add a follow-up commit) before proceeding.
---
## [Dev-B] Task B10: `org add` — create a typed item in a collection
**Files:**
- Modify: `crates/relicario-cli/src/commands/org.rs`
- Modify: `crates/relicario-cli/src/main.rs`
- Create: `crates/relicario-cli/tests/org_items.rs`
`run_add` mirrors the personal-vault `cmd_add`: build a typed `Item`, write the encrypted blob (collection-scoped), upsert the manifest entry, re-encrypt the manifest, commit with structured trailers. The caller's grant is enforced and the collection must exist. Supports Login, SecureNote, and Identity (the three non-interactive builders); Card/Key/Document/Totp parity is deferred (see the follow-up note after B13).
- [ ] **Step 1: Write the failing integration test**
Create `crates/relicario-cli/tests/org_items.rs`. The harness seeds a device signing key under a tempdir `XDG_CONFIG_HOME`, runs `org init` to create the owner + wrap the org key, then drives `org add` / `org get` / `org list`.
```rust
mod common;
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}");
}
```
```bash
cargo test -p relicario-cli --test org_items 2>&1 | tail -20
```
Expected: FAIL to compile (`org add` subcommand + `run_add` not defined yet).
> **Note:** This test requires `ssh-keygen` on `PATH`. `mod common;` reuses `tests/common/mod.rs` for `Command::cargo_bin`-style ergonomics via `assert_cmd` (already a dev-dependency via `basic_flows.rs`).
- [ ] **Step 2: Implement `run_add` and the three item builders in `commands/org.rs`**
Append after the existing command fns:
```rust
use relicario_core::{Item, ItemCore, ItemId};
/// Item kinds `org add` supports without interactive prompts.
pub enum OrgAddKind {
Login {
title: String,
username: Option<String>,
url: Option<String>,
password: Option<String>,
},
SecureNote {
title: String,
body: String,
},
Identity {
title: String,
full_name: Option<String>,
email: Option<String>,
phone: Option<String>,
},
}
fn build_org_item(kind: OrgAddKind, tags: Vec<String>) -> Result<Item> {
use relicario_core::item_types::{IdentityCore, LoginCore, SecureNoteCore};
use zeroize::Zeroizing;
let mut item = match kind {
OrgAddKind::Login { title, username, url, password } => {
let parsed_url = match url {
Some(s) => Some(url::Url::parse(&s).with_context(|| format!("invalid URL: {s}"))?),
None => None,
};
let password = password.map(Zeroizing::new);
Item::new(title, ItemCore::Login(LoginCore {
username,
password,
url: parsed_url,
totp: None,
}))
}
OrgAddKind::SecureNote { title, body } => {
Item::new(title, ItemCore::SecureNote(SecureNoteCore {
body: Zeroizing::new(body),
}))
}
OrgAddKind::Identity { title, full_name, email, phone } => {
Item::new(title, ItemCore::Identity(IdentityCore {
full_name,
address: None,
phone,
email,
date_of_birth: None,
}))
}
};
item.tags = tags;
Ok(item)
}
pub fn run_add(dir: &Path, collection: &str, kind: OrgAddKind, tags: Vec<String>) -> Result<()> {
use crate::org_session::UnlockedOrgVault;
let vault = crate::org_session::open_org_vault(Some(dir))?;
let caller = vault.current_member()?;
// Slug must exist in collections.json…
let collections = vault.load_collections()?;
if !collections.contains_slug(collection) {
anyhow::bail!("collection `{collection}` does not exist — create it with `relicario org create-collection`");
}
// …and the caller must hold a grant for it.
UnlockedOrgVault::ensure_grant(&caller, collection)?;
let item = build_org_item(kind, tags)?;
let item_rel = vault.save_item(collection, &item)?;
// Upsert the manifest entry (collection slug stored plaintext inside the
// encrypted manifest).
let mut manifest = vault.load_manifest()?;
upsert_org_entry(&mut manifest, &item, collection);
vault.save_manifest(&manifest)?;
let subject = format!(
"org add: {} ({})",
crate::helpers::sanitize_for_commit(&item.title),
item.id.as_str()
);
let commit_msg = format!(
"{subject}\n\nRelicario-Actor: {} {}\nRelicario-Action: item-create\nRelicario-Collection: {}\nRelicario-Item: {}",
caller.display_name,
caller.member_id.as_str(),
collection,
item.id.as_str()
);
crate::org_session::org_git_run(
&vault.root,
&["add", &item_rel, "manifest.enc"],
"org add: git add",
)?;
crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "org add: git commit")?;
println!("Added {} ({}) to `{}`", item.title, item.id.as_str(), collection);
Ok(())
}
/// Insert-or-replace an `OrgManifestEntry` mirroring the personal-vault
/// `Manifest::upsert`. Keyed by item id.
fn upsert_org_entry(
manifest: &mut relicario_core::OrgManifest,
item: &Item,
collection: &str,
) {
let entry = relicario_core::OrgManifestEntry {
id: item.id.clone(),
r#type: item.r#type,
title: item.title.clone(),
tags: item.tags.clone(),
modified: item.modified,
trashed_at: item.trashed_at,
collection: collection.to_string(),
};
if let Some(slot) = manifest.entries.iter_mut().find(|e| e.id == item.id) {
*slot = entry;
} else {
manifest.entries.push(entry);
}
}
```
> **Note:** Keep exactly one set of imports for `Item, ItemCore, ItemId`. If an earlier task already imported them at module scope, drop the `use relicario_core::{Item, ItemCore, ItemId};` line here. Do not import `encrypt_org_manifest` — `vault.save_manifest` already wraps it.
- [ ] **Step 3: Add the `Add` variant to `OrgCommands` and dispatch (extends B14)**
In `crates/relicario-cli/src/main.rs`, add to the `OrgCommands` enum:
```rust
/// Add an item to a collection in the org vault.
Add {
#[command(subcommand)]
kind: OrgAddKind,
},
```
Add a top-level clap subcommand enum next to `OrgCommands`:
```rust
#[derive(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>,
},
}
```
In the `Commands::Org { dir, subcommand }` dispatch block, add an arm to the inner `match subcommand`:
```rust
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)?;
}
```
> **Note:** The clap-side `OrgAddKind` (in `main.rs`, with `collection`/`tags` per-variant) and the handler-side `commands::org::OrgAddKind` (no `collection`/`tags`) are deliberately two distinct enums so the handler stays unaware of clap. Do not merge them.
- [ ] **Step 4: Run the guard tests**
```bash
cargo test -p relicario-cli --test org_items org_add_rejects 2>&1 | tail -20
```
Expected: both guard tests PASS. (The full `org_add_get_list_round_trip` passes once B11 lands `get`/`list`.)
- [ ] **Step 5: Commit**
```bash
git add crates/relicario-cli/src/commands/org.rs crates/relicario-cli/src/main.rs crates/relicario-cli/tests/org_items.rs
git commit -m "feat(cli/org): org add — collection-scoped typed item create with grant guard"
```
---
## [Dev-B] Task B11: `org get` + `org list`
**Files:**
- Modify: `crates/relicario-cli/src/commands/org.rs`
- Modify: `crates/relicario-cli/src/main.rs`
`run_get` mirrors `commands/get.rs` masking; `run_list` uses `OrgManifest::filter_for_member` so a member only ever sees entries in collections they hold a grant for. Both enforce that the caller has a grant for the target item's collection.
- [ ] **Step 1: Implement `run_list`**
Append to `commands/org.rs`:
```rust
pub fn run_list(dir: &Path, trashed: bool) -> Result<()> {
let vault = crate::org_session::open_org_vault(Some(dir))?;
let caller = vault.current_member()?;
let manifest = vault.load_manifest()?;
// filter_for_member restricts to the caller's granted collections.
let visible = manifest.filter_for_member(&caller);
let mut entries: Vec<_> = visible.entries.iter()
.filter(|e| if trashed { e.trashed_at.is_some() } else { e.trashed_at.is_none() })
.collect();
entries.sort_by(|a, b| a.title.to_lowercase().cmp(&b.title.to_lowercase()));
if entries.is_empty() {
eprintln!("(no items match)");
return Ok(());
}
println!("{:<16} {:<14} {:<12} TITLE", "ID", "TYPE", "COLLECTION");
for e in entries {
println!(
"{:<16} {:<14} {:<12} {}",
e.id.as_str(),
format!("{:?}", e.r#type),
e.collection,
e.title
);
}
Ok(())
}
```
- [ ] **Step 2: Implement `run_get` (query resolution + masking)**
Append to `commands/org.rs`. The query resolver runs over the caller-visible manifest only:
```rust
pub fn run_get(dir: &Path, query: &str, show: bool) -> Result<()> {
use relicario_core::ItemCore;
use zeroize::Zeroizing;
let vault = crate::org_session::open_org_vault(Some(dir))?;
let caller = vault.current_member()?;
let manifest = vault.load_manifest()?;
let visible = manifest.filter_for_member(&caller);
let entry = resolve_org_query(&visible, query)?;
// Double-check the grant for the resolved collection (defense in depth).
crate::org_session::UnlockedOrgVault::ensure_grant(&caller, &entry.collection)?;
let item = vault.load_item(&entry.collection, &entry.id)?;
println!("ID: {}", item.id.as_str());
println!("Title: {}", item.title);
println!("Type: {:?}", item.r#type);
println!("Collection: {}", entry.collection);
if !item.tags.is_empty() { println!("Tags: {}", item.tags.join(", ")); }
println!("Modified: {}", crate::helpers::iso8601(item.modified));
if let Some(t) = item.trashed_at { println!("Trashed: {}", crate::helpers::iso8601(t)); }
println!();
let primary_secret: Option<Zeroizing<String>> = match &item.core {
ItemCore::Login(l) => {
if let Some(u) = &l.username { println!("Username: {u}"); }
if let Some(u) = &l.url { println!("URL: {u}"); }
l.password.clone()
}
ItemCore::SecureNote(n) => {
if show { println!("Body:\n{}", n.body.as_str()); }
else { println!("Body: ********"); }
None
}
ItemCore::Identity(i) => {
if let Some(v) = &i.full_name { println!("Name: {v}"); }
if let Some(v) = &i.email { println!("Email: {v}"); }
if let Some(v) = &i.phone { println!("Phone: {v}"); }
None
}
ItemCore::Card(c) => {
if let Some(h) = &c.holder { println!("Holder: {h}"); }
c.number.clone()
}
ItemCore::Key(k) => {
if let Some(l) = &k.label { println!("Label: {l}"); }
Some(k.key_material.clone())
}
ItemCore::Document(d) => {
println!("Filename: {}", d.filename);
println!("MIME: {}", d.mime_type);
None
}
ItemCore::Totp(t) => {
if let Some(i) = &t.issuer { println!("Issuer: {i}"); }
if let Some(l) = &t.label { println!("Label: {l}"); }
None
}
};
if let Some(secret) = primary_secret {
if show {
println!("Secret: {}", secret.as_str());
} else {
println!("Secret: ******** (use --show to reveal)");
}
}
Ok(())
}
/// Resolve a query (exact id, else case-insensitive title substring) against an
/// already-grant-filtered manifest.
fn resolve_org_query<'a>(
manifest: &'a relicario_core::OrgManifest,
query: &str,
) -> Result<&'a relicario_core::OrgManifestEntry> {
if let Some(entry) = manifest.entries.iter().find(|e| e.id.as_str() == query) {
return Ok(entry);
}
let needle = query.to_lowercase();
let hits: Vec<&relicario_core::OrgManifestEntry> = manifest.entries.iter()
.filter(|e| e.title.to_lowercase().contains(&needle))
.collect();
match hits.len() {
0 => anyhow::bail!("no item matches `{query}`"),
1 => Ok(hits[0]),
_ => {
let titles: Vec<&str> = hits.iter().map(|e| e.title.as_str()).collect();
anyhow::bail!("ambiguous — {} matches: {}", hits.len(), titles.join(", "))
}
}
}
```
- [ ] **Step 3: Add `Get` / `List` variants + dispatch (extends B14)**
In `main.rs` `OrgCommands` enum, add:
```rust
/// 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,
},
```
In the `match subcommand` dispatch, add:
```rust
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)?;
}
```
- [ ] **Step 4: Run the full add/get/list integration test**
```bash
cargo test -p relicario-cli --test org_items 2>&1 | tail -30
```
Expected: all three tests in `org_items.rs` now PASS.
- [ ] **Step 5: Commit**
```bash
git add crates/relicario-cli/src/commands/org.rs crates/relicario-cli/src/main.rs
git commit -m "feat(cli/org): org get + list with per-member grant filtering"
```
---
## [Dev-B] Task B12: `org edit`
**Files:**
- Modify: `crates/relicario-cli/src/commands/org.rs`
- Modify: `crates/relicario-cli/src/main.rs`
`run_edit` mirrors `commands/edit.rs` for the three supported types but takes new values from flags. It enforces the caller's grant, re-saves the blob in place under `items/<slug>/`, upserts the manifest, and commits with `Relicario-Action: item-update`.
- [ ] **Step 1: Write a failing integration test (append to `tests/org_items.rs`)**
```rust
#[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}");
}
```
```bash
cargo test -p relicario-cli --test org_items org_edit 2>&1 | tail -20
```
Expected: FAIL — `org edit` not defined.
- [ ] **Step 2: Implement `run_edit`**
Append to `commands/org.rs`:
```rust
pub fn run_edit(
dir: &Path,
query: &str,
title: Option<String>,
username: Option<String>,
url: Option<String>,
password: Option<String>,
body: Option<String>,
email: Option<String>,
phone: Option<String>,
full_name: Option<String>,
) -> Result<()> {
use relicario_core::time::now_unix;
use relicario_core::ItemCore;
use zeroize::Zeroizing;
let vault = crate::org_session::open_org_vault(Some(dir))?;
let caller = vault.current_member()?;
let manifest = vault.load_manifest()?;
let visible = manifest.filter_for_member(&caller);
let entry = resolve_org_query(&visible, query)?;
let collection = entry.collection.clone();
let id = entry.id.clone();
crate::org_session::UnlockedOrgVault::ensure_grant(&caller, &collection)?;
let mut item = vault.load_item(&collection, &id)?;
if let Some(t) = title { item.title = t; }
match &mut item.core {
ItemCore::Login(l) => {
if let Some(u) = username { l.username = Some(u); }
if let Some(u) = url {
l.url = Some(url::Url::parse(&u).with_context(|| format!("invalid URL: {u}"))?);
}
if let Some(p) = password { l.password = Some(Zeroizing::new(p)); }
}
ItemCore::SecureNote(n) => {
if let Some(b) = body { n.body = Zeroizing::new(b); }
}
ItemCore::Identity(i) => {
if let Some(v) = full_name { i.full_name = Some(v); }
if let Some(v) = email { i.email = Some(v); }
if let Some(v) = phone { i.phone = Some(v); }
}
_ => anyhow::bail!("org edit currently supports login, secure-note, and identity items"),
}
item.modified = now_unix();
let item_rel = vault.save_item(&collection, &item)?;
let mut manifest = vault.load_manifest()?;
upsert_org_entry(&mut manifest, &item, &collection);
vault.save_manifest(&manifest)?;
let subject = format!(
"org edit: {} ({})",
crate::helpers::sanitize_for_commit(&item.title),
item.id.as_str()
);
let commit_msg = format!(
"{subject}\n\nRelicario-Actor: {} {}\nRelicario-Action: item-update\nRelicario-Collection: {}\nRelicario-Item: {}",
caller.display_name, caller.member_id.as_str(), collection, item.id.as_str()
);
crate::org_session::org_git_run(&vault.root, &["add", &item_rel, "manifest.enc"], "org edit: git add")?;
crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "org edit: git commit")?;
println!("Updated {}", item.id.as_str());
Ok(())
}
```
- [ ] **Step 3: Add `Edit` variant + dispatch (extends B14)**
In `main.rs` `OrgCommands`:
```rust
/// 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>,
},
```
Dispatch arm:
```rust
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)?;
}
```
- [ ] **Step 4: Run the test**
```bash
cargo test -p relicario-cli --test org_items org_edit 2>&1 | tail -20
```
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add crates/relicario-cli/src/commands/org.rs crates/relicario-cli/src/main.rs
git commit -m "feat(cli/org): org edit — flag-driven field update for login/note/identity"
```
---
## [Dev-B] Task B13: `org rm` / `org restore` / `org purge`
**Files:**
- Modify: `crates/relicario-cli/src/commands/org.rs`
- Modify: `crates/relicario-cli/src/main.rs`
- Create: `crates/relicario-cli/tests/org_lifecycle.rs` (L6 spec-mandated lifecycle tests)
Trash lifecycle mirrors `commands/trash.rs`: `rm` soft-deletes (sets `trashed_at`), `restore` clears it, `purge` permanently `git rm`s the blob and drops the manifest entry. All three enforce the caller's grant.
- [ ] **Step 1: Write a failing integration test (append to `tests/org_items.rs`)**
```rust
#[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}");
}
```
```bash
cargo test -p relicario-cli --test org_items org_rm_restore_purge 2>&1 | tail -20
```
Expected: FAIL — `org rm`/`restore`/`purge` not defined.
- [ ] **Step 2: Implement `run_rm`, `run_restore`, `run_purge`**
Append to `commands/org.rs`:
```rust
/// Resolve a query to (collection, item) with grant enforcement. Used by the
/// trash-lifecycle commands.
fn open_org_item(
vault: &crate::org_session::UnlockedOrgVault,
caller: &relicario_core::OrgMember,
query: &str,
) -> Result<(String, relicario_core::Item)> {
let manifest = vault.load_manifest()?;
let visible = manifest.filter_for_member(caller);
let entry = resolve_org_query(&visible, query)?;
let collection = entry.collection.clone();
let id = entry.id.clone();
crate::org_session::UnlockedOrgVault::ensure_grant(caller, &collection)?;
let item = vault.load_item(&collection, &id)?;
Ok((collection, item))
}
pub fn run_rm(dir: &Path, query: &str) -> Result<()> {
let vault = crate::org_session::open_org_vault(Some(dir))?;
let caller = vault.current_member()?;
let (collection, mut item) = open_org_item(&vault, &caller, query)?;
item.soft_delete();
let item_rel = vault.save_item(&collection, &item)?;
let mut manifest = vault.load_manifest()?;
upsert_org_entry(&mut manifest, &item, &collection);
vault.save_manifest(&manifest)?;
let commit_msg = format!(
"org trash: {} ({})\n\nRelicario-Actor: {} {}\nRelicario-Action: item-delete\nRelicario-Collection: {}\nRelicario-Item: {}",
crate::helpers::sanitize_for_commit(&item.title), item.id.as_str(),
caller.display_name, caller.member_id.as_str(), collection, item.id.as_str()
);
crate::org_session::org_git_run(&vault.root, &["add", &item_rel, "manifest.enc"], "org rm: git add")?;
crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "org rm: git commit")?;
println!("Moved to trash: {}", item.title);
Ok(())
}
pub fn run_restore(dir: &Path, query: &str) -> Result<()> {
let vault = crate::org_session::open_org_vault(Some(dir))?;
let caller = vault.current_member()?;
let (collection, mut item) = open_org_item(&vault, &caller, query)?;
item.restore();
let item_rel = vault.save_item(&collection, &item)?;
let mut manifest = vault.load_manifest()?;
upsert_org_entry(&mut manifest, &item, &collection);
vault.save_manifest(&manifest)?;
let commit_msg = format!(
"org restore: {} ({})\n\nRelicario-Actor: {} {}\nRelicario-Action: item-restore\nRelicario-Collection: {}\nRelicario-Item: {}",
crate::helpers::sanitize_for_commit(&item.title), item.id.as_str(),
caller.display_name, caller.member_id.as_str(), collection, item.id.as_str()
);
crate::org_session::org_git_run(&vault.root, &["add", &item_rel, "manifest.enc"], "org restore: git add")?;
crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "org restore: git commit")?;
println!("Restored: {}", item.title);
Ok(())
}
pub fn run_purge(dir: &Path, query: &str) -> Result<()> {
let vault = crate::org_session::open_org_vault(Some(dir))?;
let caller = vault.current_member()?;
let (collection, item) = open_org_item(&vault, &caller, query)?;
let title = item.title.clone();
let id = item.id.clone();
// Remove the blob from disk, drop the manifest entry, stage with git rm.
vault.remove_item(&collection, &id)?;
let mut manifest = vault.load_manifest()?;
manifest.entries.retain(|e| e.id != id);
vault.save_manifest(&manifest)?;
let item_rel = format!("items/{}/{}.enc", collection, id.as_str());
crate::helpers::git_rm(&vault.root, &[item_rel], "org purge: git rm")?;
crate::org_session::org_git_run(&vault.root, &["add", "manifest.enc"], "org purge: git add manifest")?;
let commit_msg = format!(
"org purge: {} ({})\n\nRelicario-Actor: {} {}\nRelicario-Action: item-purge\nRelicario-Collection: {}\nRelicario-Item: {}",
crate::helpers::sanitize_for_commit(&title), id.as_str(),
caller.display_name, caller.member_id.as_str(), collection, id.as_str()
);
crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "org purge: git commit")?;
println!("Purged: {title}");
Ok(())
}
```
> **Note:** `git_rm` is `pub` in `crate::helpers` (helpers.rs:93, signature `git_rm(repo: &Path, paths: &[String], context: &str)`) and uses `git rm -rf --ignore-unmatch`, so a missing blob is tolerated. The grant guard runs *before* any deletion.
- [ ] **Step 3: Add `Rm` / `Restore` / `Purge` variants + dispatch (extends B14)**
In `main.rs` `OrgCommands`:
```rust
/// 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 },
```
Dispatch arms:
```rust
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)?;
}
```
- [ ] **Step 4: Run the trash test + the full org_items suite + workspace verify**
```bash
cargo test -p relicario-cli --test org_items 2>&1 | tail -30
cargo test 2>&1 | tail -25
```
Expected: all `org_items.rs` tests PASS; green across all crates. No `todo!`/`unimplemented!` left in the org item path.
- [ ] **Step 4b: Author the spec-required lifecycle integration tests (L6)**
The spec's Testing Strategy (CLI section) mandates four scenarios not yet
covered by `org_items.rs`. Create `crates/relicario-cli/tests/org_lifecycle.rs`
with a self-contained two-device fixture (it must be standalone — integration
test files are separate compilation units, so it cannot share `org_items.rs`'s
`OrgFixture`). Tests:
- (a) a **forged-trailer** commit is flagged `TAMPERED` by `org audit`;
- (b) `org audit --format json` is valid JSON with the expected action values;
- (c) a **concurrent `rotate-key`** aborts with the exact spec error string;
- (d) **`remove-member → rotate-key`** → the removed member's old clone cannot
decrypt an item while a remaining member can.
```rust
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 a non-fast-forward.
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("upstream.txt"), "u").unwrap();
for a in [&["add", "upstream.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 makes a commit so the histories truly diverge.
std::fs::write(v.join("local.txt"), "l").unwrap();
assert!(git(&["add", "local.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).
let bob_clone = TempDir::new().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();
// 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));
}
```
```bash
cargo test -p relicario-cli --test org_lifecycle 2>&1 | tail -30
```
Expected: all four lifecycle tests pass. Requires `ssh-keygen` + a signing-capable `git` on PATH.
- [ ] **Step 5: Commit**
```bash
git add crates/relicario-cli/src/commands/org.rs crates/relicario-cli/src/main.rs crates/relicario-cli/tests/org_items.rs crates/relicario-cli/tests/org_lifecycle.rs
git commit -m "feat(cli/org): org rm/restore/purge trash lifecycle (collection-scoped)"
```
> **Follow-up note (not a task):** `org add`/`org edit` cover Login, SecureNote, and Identity (the non-interactive builders). Card / Key / Document / Totp parity is deferred because those builders read secrets via `rpassword`/stdin; wiring them needs `--*-stdin` flags or a test escape hatch and belongs in a follow-up parity task. The CLI/extension-parity philosophy applies to the *extension* of this surface (Dev-D), not to per-type coverage within this CLI-only plan.
---
## [Dev-B] Task B14: Wire `Commands::Org` into `main.rs` (admin + item subcommands + lifecycle stubs)
**Files:**
- Modify: `crates/relicario-cli/src/main.rs`
This is the integration task that defines the `Commands::Org` arm, the `OrgCommands` enum, and the dispatch block. **Depends on every `run_*` command fn existing** (B4B13). The item subcommand variants (`Add`/`Get`/`List`/`Edit`/`Rm`/`Restore`/`Purge`) and their dispatch arms are added by B10B13 *extending* this enum/block; this task lands the admin variants plus the dispatch skeleton, and adds minimal stubs for `transfer-ownership` and `delete-org` (which the spec lists under Admin Operations).
- [ ] **Step 1: Read the current Commands enum**
```bash
grep -n "Subcommand\|enum Commands\|match cli.command" crates/relicario-cli/src/main.rs | head -40
```
- [ ] **Step 2: Add the `Org` arm to the `Commands` enum**
```rust
/// 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,
},
```
- [ ] **Step 3: Add the `OrgCommands` enum (admin variants + lifecycle stubs)**
The item variants (`Add`/`Get`/`List`/`Edit`/`Rm`/`Restore`/`Purge`) are added by B10B13. The admin + lifecycle variants:
```rust
#[derive(Subcommand)]
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,
},
// Item subcommands (Add/Get/List/Edit/Rm/Restore/Purge) are added by
// Tasks B10B13, which extend this enum.
}
```
- [ ] **Step 4: Add the dispatch block + `parse_org_role` helper**
```rust
Commands::Org { dir, subcommand } => {
let dir_path = dir.as_deref();
match subcommand {
OrgCommands::Init { name } => {
// Resolve via org_dir so `org init` honors RELICARIO_ORG_DIR too
// (while still accepting --dir), like every other org arm (L1).
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)?;
}
// Item dispatch arms (Add/Get/List/Edit/Rm/Restore/Purge) added by
// Tasks B10B13.
}
}
```
```rust
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"),
}
}
```
- [ ] **Step 5: Implement `run_transfer_ownership` and `run_delete_org` in `commands/org.rs`**
The spec lists both under Admin Operations (owner-only). `transfer-ownership` is a **real transfer**: it promotes the target to Owner AND demotes the caller to Admin, unless `--keep-owner` is passed (explicit co-ownership, leaving the caller an owner too). `delete-org` requires `--confirm` and removes the working tree's org files (a LOCAL tombstone — see the tracked gap below). Minimal correct implementations:
```rust
pub fn run_transfer_ownership(dir: &Path, member_id_prefix: &str, keep_owner: bool) -> Result<()> {
let vault = crate::org_session::open_org_vault(Some(dir))?;
let caller = vault.current_member()?;
if !caller.role.can_manage_owners() {
anyhow::bail!("only an owner can transfer ownership");
}
let mut members = vault.load_members()?;
let target_id = resolve_member_id(&members, member_id_prefix)?;
if target_id == caller.member_id {
anyhow::bail!("you are already the owner");
}
// Promote the target to Owner.
{
let target = members.find_by_id_mut(&target_id)
.ok_or_else(|| anyhow::anyhow!("member not found"))?;
target.role = OrgRole::Owner;
}
// Real transfer: also demote the CALLER to Admin, unless --keep-owner was
// passed (explicit co-ownership). The spec says "owner → another member",
// so demotion is the default.
if !keep_owner {
if let Some(me) = members.find_by_id_mut(&caller.member_id) {
me.role = OrgRole::Admin;
}
}
vault.save_members(&members)?;
let mode = if keep_owner { "co-ownership (caller kept owner)" } else { "caller demoted to admin" };
let commit_msg = format!(
"org: transfer ownership to {} ({mode})\n\nRelicario-Actor: {} {}\nRelicario-Action: ownership-transfer\nRelicario-Member: {}",
target_id.as_str(), caller.display_name, caller.member_id.as_str(), target_id.as_str()
);
crate::org_session::org_git_run(&vault.root, &["add", "members.json"], "git add")?;
crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "git commit")?;
if keep_owner {
println!("Ownership shared with {} (you remain an owner).", target_id.as_str());
} else {
println!("Ownership transferred to {} (you are now an admin).", target_id.as_str());
}
Ok(())
}
pub fn run_delete_org(dir: &Path, confirm: bool) -> Result<()> {
let vault = crate::org_session::open_org_vault(Some(dir))?;
let caller = vault.current_member()?;
if !caller.role.can_manage_owners() {
anyhow::bail!("only an owner can delete the org");
}
if !confirm {
anyhow::bail!("refusing to delete org without --confirm");
}
let commit_msg = format!(
"org: delete org\n\nRelicario-Actor: {} {}\nRelicario-Action: org-delete",
caller.display_name, caller.member_id.as_str()
);
// Remove org files (the git history is retained as the audit record).
for f in ["org.json", "members.json", "collections.json", "manifest.enc"] {
let _ = fs::remove_file(vault.root.join(f));
}
let _ = std::fs::remove_dir_all(vault.root.join("items"));
let _ = std::fs::remove_dir_all(vault.root.join("keys"));
crate::org_session::org_git_run(&vault.root, &["add", "-A"], "git add")?;
crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "git commit")?;
println!("Org deleted (git history retained as audit record).");
Ok(())
}
```
> **Tracked gap:** `delete-org`'s tombstone-commit semantics (whether the hook should *allow* a protected-file deletion by an owner) is a design corner the pre-receive hook (C1) currently REJECTs (`enforce_schema_monotonicity` forbids deleting protected JSON). This is an acknowledged interaction: for phase 1, `delete-org` is a **local** operation; pushing a delete to a hook-protected remote is out of scope and tracked as a follow-up. `transfer-ownership` is fully hook-compatible (it only mutates `members.json` roles, signed by the owner).
- [ ] **Step 6: Build and test help output**
```bash
cargo build -p relicario-cli 2>&1 | tail -10
./target/debug/relicario org --help
./target/debug/relicario org init --help
```
Expected: clean build; help text for all org subcommands (admin + item).
- [ ] **Step 7: Commit**
```bash
git add crates/relicario-cli/src/main.rs crates/relicario-cli/src/commands/org.rs
git commit -m "feat(cli): wire Commands::Org (admin + item subcommands) + transfer-ownership/delete-org"
```
---
## Dev-C — relicario-server pre-receive hook
> **Stream prerequisite:** Tasks A2A3 must be merged before Dev-C begins (the hook imports `relicario_core::org::{OrgMember, OrgMembers, OrgCollections}`). No new Cargo deps beyond ensuring `tempfile` is a runtime dependency.
>
> **Dependency on the corrected item layout:** `classify_path` assumes `items/<slug>/<id>.enc`. It must match `UnlockedOrgVault::item_path(collection_slug, id)` from Task B1. If item commits ever used a flat `items/<id>.enc`, every item commit would be `Rejected`.
## [Dev-C] Task C1: `verify-org-commit` — signature + path-scoped authorization
**Files:**
- Modify: `crates/relicario-server/Cargo.toml`
- Modify: `crates/relicario-server/src/main.rs`
- Create: `crates/relicario-server/src/lib.rs`
- Create: `crates/relicario-server/tests/org_hook.rs`
The org pre-receive path mirrors the existing `verify_commit` (main.rs:37153): build a temp `allowed_signers` from member ed25519 pubkeys, inject `gpg.ssh.allowedSignersFile` via `GIT_CONFIG_*`, run `git verify-commit --raw`, and parse the `key (SHA256:...)` fingerprint from **stderr**. We do **not** use `%GF` (it is empty without an allowed-signers file configured server-side). The verified signer's fingerprint maps to a current member via `relicario_core::fingerprint`; **that member is the authority** for every authorization check (trailers are advisory and ignored here).
Authorization rules enforced per commit:
1. **Every** commit must carry a GOOD signature whose fingerprint maps to a current member. Unsigned / unknown-signer → REJECT.
2. **Protected paths** (`members.json`, `collections.json`, `org.json`): signer must have `role.can_manage_members()` (Owner or Admin).
3. **Item writes** `items/<slug>/<id>.enc`: the leading path segment `<slug>` must appear in the signing member's `collections` grant list. (Owners/Admins are **not** auto-granted — grants are explicit.) Additionally (**L5**), `<slug>` must exist in `collections.json` as of the commit — a write into a granted-but-deleted collection is rejected.
4. **Owner-only elevation** (`members.json`): only an Owner may introduce a new owner/admin or elevate an existing member to owner/admin. `can_manage_members()` (Owner OR Admin) is sufficient to *write* members.json, but minting/elevating owner/admin requires `can_manage_owners()` (Owner). (**H-C1**)
5. Schema-version monotonicity for the three JSON files (Task C2).
6. Root commit (no parent) → inspect full tree, allow as genesis. Merge commit (>1 parent) → REJECT.
- [ ] **Step 1: Ensure `tempfile` is a runtime dependency**
Confirm `tempfile` is under `[dependencies]` (not only `[dev-dependencies]`) in `crates/relicario-server/Cargo.toml`. If the `[dependencies]` table lacks it, add:
```toml
tempfile = "3"
```
```bash
cargo build -p relicario-server 2>&1 | tail -5
```
Expected: clean build (the existing `verify_commit` already references `tempfile`, so it is almost certainly already present; this is a guard).
- [ ] **Step 2: Write failing unit tests for path-segment parsing**
Create `crates/relicario-server/tests/org_hook.rs`:
```rust
// 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_takes_leading_segment_only() {
// Slugs cannot contain '/', so a 4-segment path 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())
);
}
```
```bash
cargo test -p relicario-server --test org_hook 2>&1 | tail -20
```
Expected: FAIL to compile — `classify_path` / `PathClass` not defined and no library target yet.
- [ ] **Step 3: Add a library target with the pure helpers**
Create `crates/relicario-server/src/lib.rs`:
```rust
//! 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());
}
return PathClass::Item { collection: slug.to_string() };
}
PathClass::Unrestricted
}
```
Wire the lib into `Cargo.toml`. In `crates/relicario-server/Cargo.toml`, add an explicit `[lib]` + `[[bin]]` pair:
```toml
[lib]
name = "relicario_server"
path = "src/lib.rs"
[[bin]]
name = "relicario-server"
path = "src/main.rs"
```
In `crates/relicario-server/src/main.rs`, import the helpers (after the existing `use` block):
```rust
use relicario_server::{classify_path, PathClass};
```
- [ ] **Step 4: Run the path-classification tests**
```bash
cargo test -p relicario-server --test org_hook 2>&1 | tail -20
```
Expected: all six `org_hook` tests pass.
- [ ] **Step 5: Add the `VerifyOrgCommit` / `GenerateOrgHook` subcommands**
In `crates/relicario-server/src/main.rs`, add to the `Commands` enum (after `GenerateHook`):
```rust
/// 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,
```
Add to the `match cli.command` block in `main()`:
```rust
Commands::VerifyOrgCommit { commit } => verify_org_commit(&commit),
Commands::GenerateOrgHook => generate_org_hook(),
```
- [ ] **Step 6: Implement the signer-resolution helper (mirrors `verify_commit`)**
```rust
use relicario_core::org::{OrgCollections, OrgMember, OrgMembers};
/// 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);
// 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);
}
```
- [ ] **Step 7: Implement `verify_org_commit` (parent/merge handling + per-path authorization)**
> `enforce_schema_monotonicity` is defined in Task C2. The two tasks land in the same file and share one commit boundary at the end of C2 — do not run the full build until C2 has added that function.
```rust
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: `can_manage_members()` alone is insufficient — an Admin must NOT
/// be able to mint owners/admins server-side; only an Owner may. We diff the
/// new members.json (already loaded) against the parent's members.json by
/// member_id and flag any member that becomes Owner/Admin (new entry, or a
/// 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,
is_root: bool,
new_members: &OrgMembers,
signer: &OrgMember,
) {
use relicario_core::org::OrgRole;
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. 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;
}
// Privileged now. 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. (A naive "already privileged" skip is WRONG: Admin is
// also privileged, so it would let an Admin be elevated to Owner
// unchecked — the exact escalation this gate exists to stop.)
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,
// 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 '{}' (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);
}
}
}
```
- [ ] **Step 8: Implement `generate_org_hook`**
```rust
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(())
}
```
- [ ] **Step 9: Build (depends on Task C2's `enforce_schema_monotonicity`)**
> Do not run the full build until Task C2 has added `enforce_schema_monotonicity` to the same file. Proceed to C2, then return.
```bash
cargo build -p relicario-server 2>&1 | tail -15
```
- [ ] **Step 10: Commit (combined with Task C2 — see C2 Step 4)**
This task does not commit on its own; the signature/auth code and the schema-monotonicity code land together. Proceed to Task C2.
---
## [Dev-C] Task C2: Schema-version monotonicity + JSON validation
**Files:**
- Modify: `crates/relicario-server/src/main.rs`
- Modify: `crates/relicario-server/src/lib.rs`
- Modify: `crates/relicario-server/tests/org_hook.rs`
- Create: `crates/relicario-server/tests/org_hook_signed.rs` (H-C1 owner-only-elevation signed-commit test)
Every push that touches `members.json`, `collections.json`, or `org.json` must not **decrease** its `schema_version` (compared against `{commit}^:file`). On the root commit any starting version is accepted. We also re-validate `collections.json` here (members is validated in C1).
- [ ] **Step 1: Write failing test for the version-extraction helper**
Append to `crates/relicario-server/tests/org_hook.rs`:
```rust
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());
}
```
```bash
cargo test -p relicario-server --test org_hook 2>&1 | tail -10
```
Expected: FAIL — `extract_schema_version` not defined.
- [ ] **Step 2: Implement `extract_schema_version` in the lib**
Add to `crates/relicario-server/src/lib.rs`:
```rust
/// 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())
}
```
`serde_json` is already in `[dependencies]`, so the lib target picks it up — no Cargo.toml change.
```bash
cargo test -p relicario-server --test org_hook extract_schema_version 2>&1 | tail -10
```
Expected: the three `extract_schema_version` tests pass.
- [ ] **Step 3: Implement `enforce_schema_monotonicity` in main.rs**
```rust
use relicario_server::extract_schema_version;
/// 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)?)
}
```
- [ ] **Step 3b: Add the signed-commit hook integration test (H-C1: owner-only elevation)**
Create `crates/relicario-server/tests/org_hook_signed.rs`. This drives the full
`verify-org-commit` against a real ssh-signed org repo, asserting the
escalation gate: **an admin self-promoting to owner is REJECTED; an owner
promoting an admin is ACCEPTED.** It reuses the signing/git helpers from the
existing `verify_commit.rs` pattern.
```rust
//! 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();
}
```
```bash
cargo test -p relicario-server --test org_hook_signed 2>&1 | tail -20
```
Expected: both tests pass once the full hook (C1 + C2) is built. Requires `git` with ssh-signing support on PATH.
- [ ] **Step 4: Build the whole crate, run all server tests, then commit**
```bash
cargo build -p relicario-server 2>&1 | tail -15
cargo test -p relicario-server 2>&1 | tail -25
```
Expected: clean build; all `org_hook` + `org_hook_signed` tests pass; existing `verify_commit` tests still pass.
```bash
cargo run -p relicario-server -- generate-org-hook | head -20
cargo run -p relicario-server -- verify-org-commit --help 2>&1 | head -10
```
Expected: the org hook script prints with the `verify-org-commit` loop; `--help` shows the subcommand.
```bash
git add crates/relicario-server/Cargo.toml crates/relicario-server/src/lib.rs crates/relicario-server/src/main.rs crates/relicario-server/tests/org_hook.rs crates/relicario-server/tests/org_hook_signed.rs
git commit -m "feat(server): verify-org-commit — signature + path-scoped role/grant auth + owner-only elevation + schema monotonicity"
```
---
## Dev-D — extension (CLI/extension parity)
> **Stream prerequisite:** Tasks A2A3 must be merged before Dev-D begins (the WASM bindings call `relicario_core::{unwrap_org_key, decrypt_org_manifest, decrypt_item}`). Independent of Dev-B/Dev-C.
>
> **Scope:** This cluster ships extension org support as **read + switch only** (list orgs, open an org, browse items, read a single item, switch back to Personal cleanly). Full extension-side org item *creation/editing* is explicitly a tracked follow-up (see the note at the end of Task D4) — org writes require signed, collection-scoped commits and the browser GitHosts write via the Contents REST API with no commit-signing path.
## [Dev-D] Task D1: WASM org bindings (unwrap org key → SessionHandle, org manifest/item decrypt)
**Why this task exists:** The current WASM surface has **no** org functions. `manifest_decrypt`/`item_decrypt` are strictly typed (`Manifest`/`Item`) and keyed by an opaque `SessionHandle` whose master key never crosses into JS. An `OrgManifest` is a different serde schema, so it cannot reuse `manifest_decrypt`. These bindings add the org read path while preserving the "key never leaves WASM" invariant: the unwrapped org key is dropped straight into the existing session table and only a `u32` handle is returned to JS.
**Files:**
- Modify: `crates/relicario-wasm/src/lib.rs`
- Modify: `crates/relicario-wasm/src/device.rs` (add the `unwrap_org_key` accessor reading DEVICE_STATE)
- Modify: `crates/relicario-wasm/Cargo.toml` (add `ssh-key`; `base64` already present)
- Rebuild artifact: `extension/wasm/relicario_wasm.js`, `relicario_wasm_bg.wasm`, `relicario_wasm.d.ts`
- [ ] **Step 1: Confirm the core org API the binding depends on exists**
Dev-A's Tasks A2/A3 must be merged first. Verify:
```bash
cargo doc -p relicario-core --no-deps 2>/dev/null
grep -n "pub fn unwrap_org_key\|pub fn decrypt_org_manifest\|pub fn decrypt_item" \
crates/relicario-core/src/org.rs crates/relicario-core/src/vault.rs
```
Expected: `unwrap_org_key(&[u8], &Zeroizing<[u8;32]>) -> Result<Zeroizing<[u8;32]>>` in `org.rs`; `decrypt_org_manifest(&[u8], &Zeroizing<[u8;32]>) -> Result<OrgManifest>` in `vault.rs`; `decrypt_item(&[u8], &Zeroizing<[u8;32]>) -> Result<Item>` re-exported. If any signature differs, STOP and reconcile.
- [ ] **Step 2: Confirm/add the `base64` and `ssh-key` dependencies**
```bash
grep -n "^base64\|^ssh-key" crates/relicario-wasm/Cargo.toml
```
`base64` is already a dependency of `relicario-wasm` (`base64 = "0.22"`); confirm it matches the workspace lock (`grep -A1 'name = "base64"' Cargo.lock``0.22.1`) and do **not** add a second version.
`ssh-key` is **NOT** currently a dependency of `relicario-wasm` (verified: no `ssh-key` line in `crates/relicario-wasm/Cargo.toml`). The new `org_open_with_registered_device` binding parses the device PEM held in WASM `DEVICE_STATE` via `ssh_key::PrivateKey::from_openssh`, so add under `[dependencies]` in `crates/relicario-wasm/Cargo.toml`:
```toml
ssh-key = { version = "0.6", features = ["ed25519", "std"] }
```
`ssh-key` is already in the workspace lock at **0.6.7** (pulled in by `relicario-core`), and the `ed25519` + `std` feature set matches what core already relies on, so resolution stays at 0.6.7 — no new lockfile entry. Confirm:
```bash
cargo tree -p relicario-wasm -i ssh-key 2>&1 | head -3
grep -A1 'name = "ssh-key"' Cargo.lock | head -3
```
Expected: `ssh-key v0.6.7` for `relicario-wasm`; `Cargo.lock` still pins a single `version = "0.6.7"`.
- [ ] **Step 3: Add a failing wasm-bindgen-test for the round trip**
The org key is unwrapped INSIDE WASM using the device seed held in `DEVICE_STATE`
(the same in-memory state `sign_for_git` reads). The device private key NEVER
crosses to JS, so the test registers a device via `register_device(...)` first,
then drives `org_open_handle(&wrapped)` (which reads `DEVICE_STATE` internally).
Add to the `#[cfg(test)] mod tests` block near the bottom of `crates/relicario-wasm/src/lib.rs`:
```rust
#[test]
fn org_open_unwraps_into_a_handle_and_decrypts_manifest() {
use zeroize::Zeroizing;
// Register a device in DEVICE_STATE — this is the only place the private
// key lives, and org_open reads it from there (never from JS).
device::clear_device();
let (signing_pub, _deploy_pub) = device::register_device("test-dev").unwrap();
// Org key wrapped to that device's public key, and an org manifest
// encrypted under the org key.
let org_key = relicario_core::generate_org_key();
let wrapped = relicario_core::wrap_org_key(&org_key, &signing_pub).unwrap();
let mut manifest = relicario_core::OrgManifest::new();
manifest.entries.push(relicario_core::OrgManifestEntry {
id: relicario_core::ItemId::new(),
r#type: relicario_core::ItemType::SecureNote,
title: "secret".into(),
tags: vec![],
modified: 0,
trashed_at: None,
collection: "prod".into(),
});
let enc_manifest = relicario_core::encrypt_org_manifest(&manifest, &org_key).unwrap();
// Exercise the internal helper the wasm-bindgen export wraps. It unwraps
// using the registered DEVICE_STATE seed — no JS-side private key.
let handle = org_open_handle(&wrapped).expect("org_open_handle");
let out = session::with(handle, |k| {
relicario_core::decrypt_org_manifest(&enc_manifest, k)
})
.unwrap()
.unwrap();
assert_eq!(out.entries.len(), 1);
assert_eq!(out.entries[0].collection, "prod");
session::remove(handle);
device::clear_device();
let _ = Zeroizing::new([0u8; 0]);
}
#[test]
fn org_open_without_registered_device_errors() {
device::clear_device();
let org_key = relicario_core::generate_org_key();
// We need *some* wrapped blob; wrap to a throwaway device, then clear state.
let (pub_key, _d) = device::register_device("throwaway").unwrap();
let wrapped = relicario_core::wrap_org_key(&org_key, &pub_key).unwrap();
device::clear_device();
assert!(org_open_handle(&wrapped).is_err());
}
```
```bash
cargo test -p relicario-wasm org_open 2>&1 | tail -8
```
Expected: FAIL — `org_open_handle` undefined.
- [ ] **Step 4: Add the `unwrap_org_key` accessor to the WASM device module**
The device private key lives ONLY in `DEVICE_STATE` (a `Zeroizing<String>`
OpenSSH PEM) inside WASM linear memory; `sign_for_git` is the existing reader.
Add a sibling that unwraps an org-key blob using that same in-memory seed and
returns the org master key — the seed never leaves WASM. Append to
`crates/relicario-wasm/src/device.rs`:
```rust
use relicario_core::unwrap_org_key as core_unwrap_org_key;
/// Unwrap an org-key blob (`keys/<member-id>.enc`) using the registered
/// device's ed25519 seed, held in DEVICE_STATE. The seed never crosses to JS.
/// Errors if no device has been registered in this session.
pub fn unwrap_org_key(wrapped: &[u8]) -> Result<Zeroizing<[u8; 32]>, String> {
let guard = DEVICE_STATE.lock().unwrap();
let state = guard
.as_ref()
.ok_or_else(|| "no device registered".to_string())?;
// Extract the 32-byte ed25519 seed from the stored OpenSSH private PEM,
// mirroring relicario-core's sign() / current_device_seed parsing chain.
let private = ssh_key::PrivateKey::from_openssh(state.signing_private.as_str())
.map_err(|e| format!("parse device key: {e}"))?;
let ed = private
.key_data()
.ed25519()
.ok_or_else(|| "device key is not ed25519".to_string())?;
let seed_slice: &[u8] = ed.private.as_ref();
if seed_slice.len() != 32 {
return Err("ed25519 seed has wrong length".to_string());
}
let mut seed = Zeroizing::new([0u8; 32]);
seed.copy_from_slice(seed_slice);
core_unwrap_org_key(wrapped, &seed).map_err(|e| e.to_string())
}
```
> **DEVICE_STATE is session-only (explicit prerequisite, not an assumption):**
> `DEVICE_STATE` is in-memory and is populated by `register_device(...)`; it is
> cleared by `clear_device()` and on SW eviction. There is NO persisted device
> private key. Therefore **org-open requires the device to be registered/unlocked
> in the current SW session** — the same lifecycle as a personal-vault unlock. The
> SW must `register_device` (or restore device state) before `org_open` can
> succeed; this is a stated precondition wired in D2, not a silent assumption.
- [ ] **Step 5: Implement the lib bindings**
Add to `crates/relicario-wasm/src/lib.rs`:
```rust
/// Internal: unwrap the org-key blob using the registered device seed held in
/// WASM DEVICE_STATE, then insert the org key into the session table. Returns
/// the raw u32 handle. The device seed and org key never cross to JS.
fn org_open_handle(wrapped_key: &[u8]) -> Result<u32, JsError> {
use zeroize::Zeroizing;
let org_key = device::unwrap_org_key(wrapped_key)
.map_err(|e| JsError::new(&format!("org open: {e}")))?;
// image_secret is unused for org sessions — store zeros.
let handle = session::insert(org_key, Zeroizing::new([0u8; 32]));
Ok(handle)
}
/// Unwrap the caller's wrapped org-key blob using the REGISTERED device key
/// (held in WASM DEVICE_STATE) and return an opaque SessionHandle bound to the
/// org master key. The device private key never crosses to JS, and the org key
/// stays inside WASM linear memory — JS receives only the handle. Requires a
/// device to have been registered in this session (see clear/register lifecycle).
#[wasm_bindgen]
pub fn org_open_with_registered_device(wrapped_key: &[u8]) -> Result<SessionHandle, JsError> {
let handle = org_open_handle(wrapped_key)?;
Ok(SessionHandle(handle))
}
/// Decrypt an org manifest ciphertext under an org SessionHandle.
#[wasm_bindgen]
pub fn org_manifest_decrypt(handle: &SessionHandle, encrypted: &[u8]) -> Result<JsValue, JsError> {
need_key(handle)?;
let out = session::with(handle.0, |k| relicario_core::decrypt_org_manifest(encrypted, k))
.unwrap()
.map_err(|e| JsError::new(&e.to_string()))?;
js_value_for(&out)
}
/// Decrypt an org item ciphertext under an org SessionHandle.
#[wasm_bindgen]
pub fn org_item_decrypt(handle: &SessionHandle, encrypted: &[u8]) -> Result<JsValue, JsError> {
need_key(handle)?;
let out = session::with(handle.0, |k| relicario_core::decrypt_item(encrypted, k))
.unwrap()
.map_err(|e| JsError::new(&e.to_string()))?;
js_value_for(&out)
}
```
> **Notes for the implementer:**
> - `SessionHandle` is a tuple struct `SessionHandle(u32)` and `need_key`, `session::with`, `session::insert`, `js_value_for` already exist in this file. `SessionHandle.0` is accessible because the binding code is in the same module.
> - `lock(&SessionHandle)` already removes the session entry, so the existing JS `wasm.lock(handle)` path zeroes the org key too — no new teardown export needed.
> - `ssh-key` is newly added in Step 2 (it was NOT previously a dependency of `relicario-wasm`). `ed25519_dalek`, `base64`, `zeroize`, `relicario_core` are already dependencies.
- [ ] **Step 6: Run the unit tests**
```bash
cargo test -p relicario-wasm org_open 2>&1 | tail -8
```
Expected: both `org_open_*` tests PASS.
- [ ] **Step 7: Rebuild the WASM artifact the extension consumes**
```bash
cd crates/relicario-wasm && wasm-pack build --target web --out-dir ../../extension/wasm 2>&1 | tail -15
```
Then confirm the new exports landed:
```bash
grep -n "export function org_open_with_registered_device\|export function org_manifest_decrypt\|export function org_item_decrypt" \
extension/wasm/relicario_wasm.d.ts
```
Expected: all three present.
- [ ] **Step 8: Commit**
```bash
git add crates/relicario-wasm/src/lib.rs crates/relicario-wasm/src/device.rs crates/relicario-wasm/Cargo.toml extension/wasm/
git commit -m "feat(wasm/org): org_open_with_registered_device + org_manifest/item_decrypt bindings (seed stays in WASM)"
```
---
## [Dev-D] Task D2: SW org session module + handlers (list_orgs / open_org / get_org_items / get_org_item)
**Files:**
- Create: `extension/src/service-worker/org.ts`
- Modify: `extension/src/service-worker/router/popup-only.ts` (add 4 handler arms)
- Modify: `extension/src/shared/messages.ts` (add 4 message types + responses + capability-set entries)
- Modify: `extension/src/service-worker/index.ts` (clear org session on session-expiry)
**Storage model (matches existing extension conventions):**
- Org configs live in `chrome.storage.local` under key `orgConfigs`: an array `OrgConfigEntry[]`.
- The unwrapped org master key lives **only** as a `SessionHandle` in a module-scope variable in `org.ts`**never** written to `chrome.storage.local` or IndexedDB.
- Offline → read-only is derived, not stored: any `git.readFile` rejection while an org session is open flips an in-memory `readOnly` flag.
- [ ] **Step 1: Add the message types and capability-set entries**
In `extension/src/shared/messages.ts`, add to the `PopupMessage` union (after the `get_vault_status` arm):
```typescript
| { type: 'list_orgs' }
| { type: 'open_org'; orgId: string }
| { type: 'get_org_items'; collection?: string }
| { type: 'get_org_item'; id: ItemId }
```
Add these typed responses near the other `*Response` interfaces:
```typescript
export interface OrgSummary {
orgId: string;
label: string;
}
export interface ListOrgsResponse extends Extract<Response, { ok: true }> {
data: { orgs: OrgSummary[]; activeOrgId: string | null };
}
export interface OpenOrgResponse extends Extract<Response, { ok: true }> {
data: { orgId: string; label: string; readOnly: boolean };
}
export interface GetOrgItemsResponse extends Extract<Response, { ok: true }> {
data: {
items: Array<{
id: ItemId; type: string; title: string; tags: string[];
modified: number; trashed_at?: number; collection: string;
}>;
readOnly: boolean;
};
}
export interface GetOrgItemResponse extends Extract<Response, { ok: true }> {
data: { item: Item; readOnly: boolean };
}
```
Add the four types to `POPUP_ONLY_TYPES`:
```typescript
'create_vault', 'attach_vault', 'get_vault_status',
'list_orgs', 'open_org', 'get_org_items', 'get_org_item',
```
- [ ] **Step 2: Create the SW org session module**
Create `extension/src/service-worker/org.ts`:
```typescript
/// Org-vault session + read operations for the service worker.
///
/// Mirrors session.ts/vault.ts for the personal vault, but for org repos:
/// - Holds at most one unwrapped org SessionHandle in module memory. The org
/// master key lives inside WASM linear memory; JS only ever sees the opaque
/// handle. NEVER persisted.
/// - Org configs live in chrome.storage.local under `orgConfigs` — config only.
/// - Offline is derived: a failed git.readFile flips the active session's
/// readOnly flag.
import type { SessionHandle } from '../../wasm/relicario_wasm';
import type { GitHost } from './git-host';
import { createGitHost } from './git-host';
import type { Item } from '../shared/types';
export interface OrgConfigEntry {
orgId: string;
label: string;
hostType: 'gitea' | 'github';
hostUrl: string;
repoPath: string;
apiToken: string;
/// Path inside the org repo of THIS device's wrapped org-key blob,
/// e.g. "keys/<member-id>.enc".
wrappedKeyPath: string;
}
interface ActiveOrgSession {
orgId: string;
label: string;
handle: SessionHandle;
git: GitHost;
readOnly: boolean;
/// This device's member id (the leading `<member-id>` of `keys/<id>.enc`,
/// i.e. the SAME identity used to select the wrapped-key blob). Used to
/// resolve the active member in members.json for grant-filtering.
memberId: string;
/// Collection slugs this member is granted, resolved from members.json at
/// open time. getOrgItems/getOrgItem filter the manifest to these — the TS
/// equivalent of core `OrgManifest::filter_for_member`.
grants: string[];
}
interface OrgMemberJson {
member_id: string;
display_name: string;
role: string;
ed25519_pubkey: string;
collections?: string[];
}
interface OrgMembersJson { schema_version: number; members: OrgMemberJson[]; }
/// Extract `<member-id>` from a `keys/<member-id>.enc` wrapped-key path.
function memberIdFromKeyPath(wrappedKeyPath: string): string {
const base = wrappedKeyPath.split('/').pop() ?? '';
return base.replace(/\.enc$/, '');
}
// Module-scope, single active org session. NEVER serialized.
let active: ActiveOrgSession | null = null;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let wasm: any = null;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function setWasm(w: any): void { wasm = w; }
function requireWasm(): any {
if (!wasm) throw new Error('WASM module not initialized');
return wasm;
}
export async function loadOrgConfigs(): Promise<OrgConfigEntry[]> {
const r = await chrome.storage.local.get('orgConfigs');
const raw = r.orgConfigs;
return Array.isArray(raw) ? (raw as OrgConfigEntry[]) : [];
}
export function getActiveOrgId(): string | null {
return active ? active.orgId : null;
}
export function isReadOnly(): boolean {
return active ? active.readOnly : false;
}
/// Tear down the active org session: lock (zeroes the org key in WASM) and drop.
export function clearActiveOrg(): void {
if (active) {
try { requireWasm().lock(active.handle); } catch { /* may already be gone */ }
try { (active.handle as unknown as { free?: () => void }).free?.(); } catch { /* idempotent */ }
active = null;
}
}
/// Open (unlock) an org by id: build its GitHost, fetch this device's wrapped
/// key blob, and unwrap into an org SessionHandle via WASM.
///
/// The device private key is NOT read from JS storage — it lives only inside
/// WASM DEVICE_STATE (populated by register_device at unlock time). The binding
/// `org_open_with_registered_device(wrapped)` unwraps the org key using that
/// in-memory seed without ever exposing it. PRECONDITION: a device must be
/// registered in this SW session (same lifecycle as personal-vault unlock);
/// the WASM binding returns a `device_not_registered`-style error otherwise.
export async function openOrg(orgId: string): Promise<{ orgId: string; label: string; readOnly: boolean }> {
const w = requireWasm();
const configs = await loadOrgConfigs();
const cfg = configs.find((c) => c.orgId === orgId);
if (!cfg) throw new Error('org_not_found');
const git = createGitHost(cfg.hostType, cfg.hostUrl, cfg.repoPath, cfg.apiToken);
const readOnly = false;
let wrapped: Uint8Array;
try {
wrapped = await git.readFile(cfg.wrappedKeyPath);
} catch {
throw new Error('org_offline');
}
// Resolve this device's member entry + grants from members.json (public,
// unencrypted). The member id is the SAME identity used to pick the wrapped
// key blob (the `<member-id>` segment of wrappedKeyPath).
const memberId = memberIdFromKeyPath(cfg.wrappedKeyPath);
let grants: string[] = [];
try {
const membersCt = await git.readFile('members.json');
const membersJson = JSON.parse(
new TextDecoder().decode(membersCt),
) as OrgMembersJson;
const me = (membersJson.members ?? []).find((m) => m.member_id === memberId);
grants = me?.collections ?? [];
} catch {
throw new Error('org_offline');
}
// Unwrap inside WASM using the registered device seed (never crosses to JS).
const handle = w.org_open_with_registered_device(wrapped) as SessionHandle;
clearActiveOrg();
active = { orgId: cfg.orgId, label: cfg.label, handle, git, readOnly, memberId, grants };
return { orgId: cfg.orgId, label: cfg.label, readOnly };
}
interface OrgManifestEntryJson {
id: string; type: string; title: string; tags?: string[];
modified: number; trashed_at?: number; collection: string;
}
interface OrgManifestJson { schema_version: number; entries: OrgManifestEntryJson[]; }
/// Fetch + decrypt the active org's manifest, optionally filtered to one collection.
export async function getOrgItems(
collection?: string,
): Promise<{ items: OrgManifestEntryJson[]; readOnly: boolean }> {
const w = requireWasm();
if (!active) throw new Error('org_locked');
let ciphertext: Uint8Array;
try {
ciphertext = await active.git.readFile('manifest.enc');
} catch {
active.readOnly = true;
throw new Error('org_offline');
}
const manifest = w.org_manifest_decrypt(active.handle, ciphertext) as OrgManifestJson;
// Grant-filter FIRST: a member only ever sees entries in their granted
// collections (the TS mirror of core OrgManifest::filter_for_member).
const granted = new Set(active.grants);
const visible = (manifest.entries ?? []).filter((e) => granted.has(e.collection));
const all = visible.filter((e) => e.trashed_at === undefined);
const slug = collection?.toLowerCase();
const items = slug ? all.filter((e) => e.collection.toLowerCase() === slug) : all;
return {
items: items.map((e) => ({ ...e, tags: e.tags ?? [] })),
readOnly: active.readOnly,
};
}
/// Fetch + decrypt a single org item. Items are collection-scoped on disk
/// (items/<slug>/<id>.enc), so we read the manifest entry first to learn the slug.
export async function getOrgItem(id: string): Promise<{ item: Item; readOnly: boolean }> {
const w = requireWasm();
if (!active) throw new Error('org_locked');
let manifestCt: Uint8Array;
try {
manifestCt = await active.git.readFile('manifest.enc');
} catch {
active.readOnly = true;
throw new Error('org_offline');
}
const manifest = w.org_manifest_decrypt(active.handle, manifestCt) as OrgManifestJson;
const entry = manifest.entries.find((e) => e.id === id);
if (!entry) throw new Error('item_not_found');
// Enforce the grant on read: refuse items in collections this member is not
// granted, even if the id is known (mirrors core filter_for_member + the CLI
// ensure_grant defense-in-depth).
if (!active.grants.includes(entry.collection)) throw new Error('item_not_found');
let itemCt: Uint8Array;
try {
itemCt = await active.git.readFile(`items/${entry.collection}/${id}.enc`);
} catch {
active.readOnly = true;
throw new Error('org_offline');
}
const item = w.org_item_decrypt(active.handle, itemCt) as Item;
return { item, readOnly: active.readOnly };
}
/// Build the list-orgs payload from config; the active id reflects the live
/// in-memory session, not anything persisted.
export async function listOrgs(): Promise<{
orgs: Array<{ orgId: string; label: string }>;
activeOrgId: string | null;
}> {
const configs = await loadOrgConfigs();
return {
orgs: configs.map((c) => ({ orgId: c.orgId, label: c.label })),
activeOrgId: getActiveOrgId(),
};
}
```
- [ ] **Step 3: Wire `setWasm(orgModule)` at SW init**
In `extension/src/service-worker/index.ts`, import the org module:
```typescript
import * as org from './org';
```
Inside `initWasm()`, right after `vault.setWasm(wasmBindings);`:
```typescript
org.setWasm(wasmBindings);
```
And in the `sessionTimer.onExpired(...)` callback (after `state.gitHost = null;`), tear the org session down too:
```typescript
org.clearActiveOrg();
```
- [ ] **Step 4: Add the four handler arms**
In `extension/src/service-worker/router/popup-only.ts`, add an import:
```typescript
import * as org from '../org';
```
Add these arms inside the `switch (msg.type)` in `handle(...)`, after the `get_vault_status` arm:
```typescript
case 'list_orgs': {
const data = await org.listOrgs();
return { ok: true, data };
}
case 'open_org': {
try {
const data = await org.openOrg(msg.orgId);
return { ok: true, data };
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : String(err) };
}
}
case 'get_org_items': {
try {
const data = await org.getOrgItems(msg.collection);
return { ok: true, data };
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : String(err) };
}
}
case 'get_org_item': {
try {
const data = await org.getOrgItem(msg.id);
return { ok: true, data };
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : String(err) };
}
}
```
> Recommended: also call `org.clearActiveOrg()` in the existing `lock` arm for symmetric teardown.
- [ ] **Step 5: Type-check the whole extension (NOT bare `tsc --noEmit`)**
```bash
cd extension && npm run build:all 2>&1 | tail -20
```
Expected: clean build. The new `org_open_with_registered_device`/`org_manifest_decrypt`/`org_item_decrypt` resolve against the regenerated `relicario_wasm.d.ts` from Task D1.
- [ ] **Step 6: Commit**
```bash
git add extension/src/service-worker/org.ts extension/src/service-worker/index.ts \
extension/src/service-worker/router/popup-only.ts extension/src/shared/messages.ts
git commit -m "feat(ext/sw): org session module + list_orgs/open_org/get_org_items/get_org_item handlers"
```
---
## [Dev-D] Task D3: Vault-tab org switcher (Personal + each configured org)
**Files:**
- Create: `extension/src/vault/vault-org-switcher.ts`
- Modify: `extension/src/vault/vault-sidebar.ts` (mount the switcher)
- Modify: `extension/src/vault/vault-context.ts`, `vault.ts` (add `activeOrgId` state)
- Modify: `extension/src/vault/vault.css` (switcher + read-only banner styling)
The switcher is a small `<select>` at the top of the vault-tab sidebar: "Personal" plus one option per configured org. Selecting an org sends `open_org`, then `get_org_items`, and renders the org's items. Selecting "Personal" restores the personal manifest. When the active org session is read-only (offline), a banner shows above the list.
- [ ] **Step 1: Implement the switcher**
Create `extension/src/vault/vault-org-switcher.ts`:
```typescript
/// Vault-tab org context switcher: "Personal" + each configured org.
///
/// Switching to an org opens it through the SW (open_org) and replaces the
/// list pane's entries with the org manifest projection. Switching back to
/// Personal restores the personal manifest. The org master key never reaches
/// this module — it stays inside WASM behind the SW.
import { sendMessage } from '../shared/state';
import type { VaultController, VaultEntry } from './vault-context';
import type {
ListOrgsResponse, OpenOrgResponse, GetOrgItemsResponse,
} from '../shared/messages';
const SWITCHER_ID = 'vault-org-switcher';
const BANNER_ID = 'vault-org-readonly-banner';
/// Render the switcher into `host`. Idempotent — clears and rebuilds.
export async function renderOrgSwitcher(ctx: VaultController, host: HTMLElement): Promise<void> {
const resp = await sendMessage({ type: 'list_orgs' });
if (!resp.ok) return;
const { orgs, activeOrgId } = (resp as ListOrgsResponse).data;
// No orgs configured → render nothing (keeps the personal-only UI clean).
if (orgs.length === 0) {
host.querySelector(`#${SWITCHER_ID}`)?.remove();
return;
}
let select = host.querySelector<HTMLSelectElement>(`#${SWITCHER_ID}`);
if (!select) {
select = document.createElement('select');
select.id = SWITCHER_ID;
select.className = 'org-switcher';
select.setAttribute('aria-label', 'Vault context');
host.prepend(select);
select.addEventListener('change', () => { void onSwitch(ctx, select!.value); });
}
const opts = ['<option value="personal">Personal</option>']
.concat(orgs.map((o) =>
`<option value="${o.orgId}">${ctx.state ? escapeOption(o.label) : o.label}</option>`));
select.innerHTML = opts.join('');
select.value = activeOrgId ?? 'personal';
}
function escapeOption(s: string): string {
return s.replace(/[&<>"]/g, (c) =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c] ?? c));
}
/// Handle a switcher change. "personal" restores the personal manifest;
/// any org id opens that org and loads its items into the list pane.
async function onSwitch(ctx: VaultController, value: string): Promise<void> {
if (value === 'personal') {
setReadOnlyBanner(false);
ctx.state.activeOrgId = null;
await ctx.loadManifest(); // re-loads the personal manifest into state.entries
ctx.renderListPane();
return;
}
const opened = await sendMessage({ type: 'open_org', orgId: value });
if (!opened.ok) {
ctx.state.error = opened.error === 'org_offline'
? 'This org is unavailable offline.'
: `Could not open org: ${opened.error}`;
ctx.renderPane();
return;
}
const openData = (opened as OpenOrgResponse).data;
ctx.state.activeOrgId = openData.orgId;
const itemsResp = await sendMessage({ type: 'get_org_items' });
if (!itemsResp.ok) {
if (itemsResp.error === 'org_offline') {
setReadOnlyBanner(true);
} else {
ctx.state.error = `Could not load org items: ${itemsResp.error}`;
ctx.renderPane();
return;
}
} else {
const data = (itemsResp as GetOrgItemsResponse).data;
setReadOnlyBanner(data.readOnly);
// Project org entries into the same [id, entry] tuple shape the list pane
// consumes for the personal manifest. The entry value type is the pinned
// `VaultState.entries` element type (extended with an optional
// `collection?: string` — see the implementer note), so this assigns
// without a cast and the D2→D3 contract is compiler-checked.
const projected: VaultEntry[] = data.items.map((e) => ({
id: e.id, type: e.type, title: e.title, tags: e.tags,
favorite: false, modified: e.modified, trashed_at: e.trashed_at,
// org-specific projection field; optional on VaultEntry, harmless for
// the personal list renderer.
collection: e.collection,
}));
ctx.state.entries = projected.map((entry) => [entry.id, entry]);
}
ctx.state.selectedId = null;
ctx.state.selectedItem = null;
ctx.renderSidebarCategories();
ctx.renderListPane();
}
function setReadOnlyBanner(show: boolean): void {
const existing = document.getElementById(BANNER_ID);
if (!show) { existing?.remove(); return; }
if (existing) return;
const banner = document.createElement('div');
banner.id = BANNER_ID;
banner.className = 'org-readonly-banner';
banner.textContent = 'Read-only — org is unavailable offline.';
const pane = document.getElementById('vault-list') ?? document.body;
pane.prepend(banner);
}
```
> **Implementer notes:**
> - `ctx.loadManifest()`, `ctx.renderListPane()`, `ctx.renderSidebarCategories()`, `ctx.renderPane()`, and `ctx.state` are all on the existing `VaultController`.
> - `VaultState` needs a new optional field `activeOrgId: string | null`. Add it to the `VaultState` interface in `vault-context.ts` and initialize `activeOrgId: null` in `vault.ts`.
> - **Pin the entry element type (L2):** the personal manifest's entry value type must be a named, exported type — `VaultEntry` — exported from `vault-context.ts` and used as `entries: Array<[ItemId, VaultEntry]>` in `VaultState`. Add an optional `collection?: string` field to `VaultEntry`. The org switcher then imports `VaultEntry` and builds `VaultEntry[]` directly, so the D2→D3 projection is compiler-checked end-to-end instead of being silenced by a `as typeof ctx.state.entries` cast. If `VaultEntry` does not yet exist as a distinct type, extract it from the current inline entry-value type in `vault-context.ts` in this task.
- [ ] **Step 2: Mount the switcher in the sidebar**
In `extension/src/vault/vault-sidebar.ts`, import and call from `wireSidebar`:
```typescript
import { renderOrgSwitcher } from './vault-org-switcher';
```
Inside `wireSidebar(ctx)`, after the existing `refreshStatus()` call:
```typescript
const sidebarNav = document.getElementById('vault-sidebar') ?? document.body;
void renderOrgSwitcher(ctx, sidebarNav as HTMLElement);
```
(Verify the actual sidebar container id in `vault.html` — use that element as the mount host.)
- [ ] **Step 3: Add minimal styling**
In `extension/src/vault/vault.css`, append:
```css
.org-switcher {
width: 100%;
margin-bottom: 0.5rem;
background: var(--bg-elev, #1b1b1b);
color: var(--fg, #ddd);
border: 1px solid var(--border, #333);
font: inherit;
padding: 0.25rem;
}
.org-readonly-banner {
background: #4a3a00;
color: #ffd24a;
padding: 0.35rem 0.6rem;
font-size: 0.85em;
border-bottom: 1px solid #6a5400;
}
```
- [ ] **Step 4: Type-check via build:all**
```bash
cd extension && npm run build:all 2>&1 | tail -20
```
Expected: clean.
- [ ] **Step 5: Commit**
```bash
git add extension/src/vault/vault-org-switcher.ts extension/src/vault/vault-sidebar.ts \
extension/src/vault/vault-context.ts extension/src/vault/vault.ts extension/src/vault/vault.css
git commit -m "feat(ext/vault): org context switcher (Personal + configured orgs) with read-only banner"
```
---
## [Dev-D] Task D4: The 3 spec-mandated vitest tests
**Files:**
- Create: `extension/src/service-worker/__tests__/org.test.ts`
- Modify: `extension/ARCHITECTURE.md` (storage table + module map)
These follow the existing vitest style: `vi.mock` the SW's `git-host` module, a `chrome.storage.local` shim, and a fake wasm object — exactly as in `vault.test.ts` and `router.test.ts`.
- [ ] **Step 1: Write the three tests**
Create `extension/src/service-worker/__tests__/org.test.ts`:
```typescript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { GitHost } from '../git-host';
import * as gitHostMod from '../git-host';
// --- Mock git-host: org.ts builds its GitHost via createGitHost ---
function makeHostMock(opts: { offline?: boolean } = {}): GitHost {
const membersJson = JSON.stringify({
schema_version: 1,
members: [{
member_id: 'm0', display_name: 'Me', role: 'member',
ed25519_pubkey: 'ssh-ed25519 AAAA fake', collections: ['prod'],
}],
});
const reader = vi.fn().mockImplementation(async (path: string) => {
if (opts.offline) throw new Error(`network: ${path}`);
if (path === 'keys/m0.enc') return new Uint8Array([0xaa, 0xbb]); // wrapped org key blob
if (path === 'members.json') return new TextEncoder().encode(membersJson); // grant source
if (path === 'manifest.enc') return new Uint8Array([0x01, 0x02]); // org manifest ct
if (path === 'items/prod/itemprod00000001.enc') return new Uint8Array([0x03]);
throw new Error(`404: ${path}`);
});
return {
readFile: reader,
writeFile: vi.fn(),
writeFileCreateOnly: vi.fn(),
deleteFile: vi.fn(),
listDir: vi.fn().mockResolvedValue([]),
lastCommit: vi.fn().mockResolvedValue(null),
putBlob: vi.fn(),
getBlob: vi.fn(),
deleteBlob: vi.fn(),
lastSyncAt: null,
ahead: 0,
behind: 0,
} as unknown as GitHost;
}
let offlineMode = false;
vi.mock('../git-host', async () => {
const actual = await vi.importActual<typeof import('../git-host')>('../git-host');
return {
...actual,
createGitHost: vi.fn(() => makeHostMock({ offline: offlineMode })),
};
});
import * as org from '../org';
// --- chrome.storage.local shim ---
function mockStorage(initial: Record<string, unknown>) {
const store = { ...initial };
// @ts-expect-error test harness
globalThis.chrome = {
storage: {
local: {
get: vi.fn((k: string | string[]) => {
const arr = Array.isArray(k) ? k : [k];
const out: Record<string, unknown> = {};
for (const key of arr) if (key in store) out[key] = store[key];
return Promise.resolve(out);
}),
set: vi.fn((kv: Record<string, unknown>) => { Object.assign(store, kv); return Promise.resolve(); }),
},
},
runtime: { id: 'relicario-test-id', getURL: (p: string) => `chrome-extension://relicario-test-id/${p}` },
};
return store;
}
const ORG_CONFIGS = [{
orgId: 'org1', label: 'Acme', hostType: 'gitea', hostUrl: 'https://g',
repoPath: 'acme/vault', apiToken: 't', wrappedKeyPath: 'keys/m0.enc',
}];
// --- Fake wasm: org_open_with_registered_device returns an opaque handle,
// decrypt fns return JSON ---
const ORG_MANIFEST = {
schema_version: 1,
entries: [
{
id: 'itemprod00000001', type: 'secure_note', title: 'Prod secret',
tags: [], modified: 100, collection: 'prod',
},
// m0 is NOT granted `dev` — this entry must be filtered out on read.
{
id: 'itemdev000000001', type: 'secure_note', title: 'Dev secret',
tags: [], modified: 100, collection: 'dev',
},
],
};
function makeWasm() {
const handle = { __org: true, free: vi.fn() };
return {
_handle: handle,
// Org open unwraps inside WASM using the registered device seed; the SW
// passes only the wrapped-key bytes — no device private key.
org_open_with_registered_device: vi.fn(() => handle),
org_manifest_decrypt: vi.fn(() => ORG_MANIFEST),
org_item_decrypt: vi.fn(() => ({
id: 'itemprod00000001', type: 'secure_note', title: 'Prod secret',
core: { type: 'secure_note', body: 'top secret' }, attachments: [],
tags: [], modified: 100, favorite: false,
})),
lock: vi.fn(),
};
}
describe('SW org context', () => {
let store: Record<string, unknown>;
let wasm: ReturnType<typeof makeWasm>;
beforeEach(() => {
offlineMode = false;
// No device_private_key in storage — the device seed lives only in WASM
// DEVICE_STATE; the SW never persists or reads it from chrome.storage.
store = mockStorage({ orgConfigs: ORG_CONFIGS });
wasm = makeWasm();
org.setWasm(wasm);
vi.mocked(gitHostMod.createGitHost).mockImplementation(() => makeHostMock({ offline: offlineMode }));
});
afterEach(() => {
org.clearActiveOrg();
vi.restoreAllMocks();
});
// 1) Org context switching replaces the personal manifest cleanly.
it('open_org + get_org_items returns only the org manifest entries', async () => {
const opened = await org.openOrg('org1');
expect(opened).toMatchObject({ orgId: 'org1', label: 'Acme', readOnly: false });
// The binding takes ONLY the wrapped-key bytes — no device private key.
expect(wasm.org_open_with_registered_device).toHaveBeenCalledWith(expect.any(Uint8Array));
const { items, readOnly } = await org.getOrgItems();
expect(readOnly).toBe(false);
expect(items).toHaveLength(1);
expect(items[0]).toMatchObject({ id: 'itemprod00000001', collection: 'prod', title: 'Prod secret' });
// Single org item read is collection-scoped: items/<slug>/<id>.enc.
const single = await org.getOrgItem('itemprod00000001');
expect(single.item).toMatchObject({ id: 'itemprod00000001' });
expect(wasm.org_item_decrypt).toHaveBeenCalled();
// Switching context away (back to Personal) tears the org session down.
org.clearActiveOrg();
expect(org.getActiveOrgId()).toBeNull();
});
// 1b) Grant-filtering: a member only sees entries in their granted
// collections. m0 holds `prod` but not `dev`, so the dev entry is hidden,
// and a direct get_org_item for it is refused.
it('filters org items to the member grant list', async () => {
await org.openOrg('org1');
const { items } = await org.getOrgItems();
expect(items).toHaveLength(1);
expect(items.map((i) => i.collection)).toEqual(['prod']);
expect(items.some((i) => i.id === 'itemdev000000001')).toBe(false);
// Even by id, an ungranted item is not readable.
await expect(org.getOrgItem('itemdev000000001')).rejects.toThrow('item_not_found');
});
// 2) Org master key is never persisted.
it('never writes the unwrapped org key to chrome.storage.local', async () => {
await org.openOrg('org1');
await org.getOrgItems();
const setFn = (globalThis as unknown as {
chrome: { storage: { local: { set: ReturnType<typeof vi.fn> } } };
}).chrome.storage.local.set;
expect(setFn).not.toHaveBeenCalled();
const handleReturn = wasm.org_open_with_registered_device.mock.results[0].value;
expect(handleReturn).toBe(wasm._handle);
expect(handleReturn).not.toBeInstanceOf(Uint8Array);
org.clearActiveOrg();
expect(wasm.lock).toHaveBeenCalledWith(wasm._handle);
});
// 3) Offline read triggers read-only.
it('flips read-only / org_offline when the git host is unreachable', async () => {
await org.openOrg('org1');
expect(org.isReadOnly()).toBe(false);
offlineMode = true;
await expect(org.getOrgItems()).rejects.toThrow('org_offline');
expect(org.isReadOnly()).toBe(true);
});
it('open_org itself reports org_offline when the wrapped key cannot be fetched', async () => {
offlineMode = true;
await expect(org.openOrg('org1')).rejects.toThrow('org_offline');
expect(org.getActiveOrgId()).toBeNull();
});
});
```
- [ ] **Step 2: Run the org tests**
```bash
cd extension && npx vitest run src/service-worker/__tests__/org.test.ts 2>&1 | tail -25
```
Expected: all tests pass.
- [ ] **Step 3: Run the full extension test + build verify**
```bash
cd extension && npm test 2>&1 | tail -25
cd extension && npm run build:all 2>&1 | tail -20
```
Expected: full vitest suite green; clean `build:all`.
- [ ] **Step 4: Update extension ARCHITECTURE.md + commit**
Add `orgConfigs` to the `chrome.storage.local` table in `extension/ARCHITECTURE.md` ("array of `OrgConfigEntry` — org repo coordinates + this device's wrapped-key path; **config only, no secrets**"), add `service-worker/org.ts` and `vault/vault-org-switcher.ts` to the module map, and note the invariant: "the unwrapped org master key lives only as a `SessionHandle` in `org.ts` module memory — never persisted, zeroed on lock/timeout via `wasm.lock`."
```bash
git add extension/src/service-worker/__tests__/org.test.ts extension/ARCHITECTURE.md
git commit -m "test(ext/sw): org context switch, no-key-persistence, offline read-only + ARCHITECTURE docs"
```
> **Tracked follow-up (do NOT implement here):** extension-side org *writes* — `add_org_item` / `update_org_item` / `delete_org_item`, collection-scoped writes, manifest re-encrypt, and **signed** commits from the browser. The browser GitHosts write via the Contents REST API with no commit-signing path, while the corrected org design requires every org commit to be signed. File it as "Plan B-2: extension org writes" against the org-vault epic.
---
## Dev-A — docs (final)
## [Dev-A] Task A5: Living-docs update (final, after all code streams merge)
**Files:**
- Modify: `docs/FORMATS.md`, `docs/CRYPTO.md`, `DESIGN.md`, `docs/SECURITY.md`,
`crates/relicario-core/ARCHITECTURE.md`, `crates/relicario-cli/ARCHITECTURE.md`,
`extension/ARCHITECTURE.md`, `STATUS.md`, `ROADMAP.md`
Per the spec's "Living-Docs Impact" section and CLAUDE.md living-docs discipline, this task records the new on-disk formats, the new crypto path, the new dependencies, and the honest limitations. It runs **after** A1A4, all of Dev-B/C/D have merged. Concrete content per doc:
- [ ] **Step 1: `docs/FORMATS.md`** — add the org wire formats:
- The four org JSON files: `org.json`, `members.json` (incl. `ed25519_pubkey` OpenSSH + `collections` grant array + `role`), `collections.json`, with their `schema_version` fields.
- The `keys/<member-id>.enc` wrapped-blob layout: `ephemeral_x25519_pubkey(32) || version(1) || nonce(24) || ciphertext+tag`; note the wrapping key is `SHA-256(dh_shared || ephemeral_pubkey || recipient_pubkey)`.
- **Collection-scoped** item path `items/<collection-slug>/<item-id>.enc` (same `.enc` format as personal items; blob does not name its collection — the directory path does).
- `manifest.enc` org variant (each entry carries a `collection` slug).
- [ ] **Step 2: `docs/CRYPTO.md`** — add the ECIES org-key path:
- ed25519→X25519 conversion (SHA-512(seed)[:32] + RFC 7748 clamp for the scalar; birational Montgomery map for the point), domain-separated KDF, all secret intermediates in `Zeroizing`.
- Org-key wrap/unwrap diagram; note org crypto bypasses Argon2id (X25519-based, key directly used for XChaCha20-Poly1305).
- **Key-rotation re-encryption:** rotate-key generates a fresh org key, re-wraps for remaining members, AND re-encrypts every `items/<slug>/<id>.enc` blob + the manifest under the new key.
- [ ] **Step 3: `DESIGN.md`** — cross-codebase structure:
- Add the org-master-key row to the secrets map (256-bit random, wrapped per-member to device key; never escrowed; owners can always re-grant).
- Add the `x25519-dalek` (core, `features = ["static_secrets"]`) and `ssh-key` (cli **and** wasm) dependencies to the build/dep matrix.
- Note `relicario-server` gains an org mode (`verify-org-commit` / `generate-org-hook`) and the new `[lib]` target.
- [ ] **Step 4: `docs/SECURITY.md`** — threat model + honest limitations:
- Org device-key auth (signer resolved by ed25519 fingerprint).
- The **signature-verifying** pre-receive hook: every org commit must carry a GOOD signature from a current member; writes authorized by role (protected files) or collection path segment (items); **only an owner may introduce or elevate an owner/admin** (admins cannot mint owners/admins server-side); merge commits rejected; schema_version monotonic.
- The **audit action vocabulary** matching the spec's Action Vocabulary table: `item-create` / `item-update` / `item-delete` (trash) / `item-restore` / `item-purge`; `member-add` / `member-remove` / `member-role-change`; `collection-create` / `collection-grant` / `collection-revoke`; `key-rotate`; `org-init` / `ownership-transfer` / `org-delete`. (Trash emits `item-delete`, restore emits `item-restore` — keep docs and the CLI emitters in B13 in lock-step.)
- The honest limitations verbatim from the spec: **shared org master key — reads are not cryptographically scoped per collection** (the hook scopes writes + the client filters the manifest, but one org key opens everything; for cryptographic separation use a separate org vault — per-collection subkeys are a phase-2 non-goal); **no read audit** (git records writes, not reads); **no "hide value."**; **`delete-org` is a LOCAL tombstone in phase 1** — the hook rejects protected-file deletion, so a delete cannot be pushed to a hook-protected remote.
- [ ] **Step 5: `crates/relicario-core/ARCHITECTURE.md`** — add the `org` module:
- Module map entry for `org.rs` (IDs, roles, members, collections, manifest, ECIES wrap/unwrap) + the `encrypt_org_manifest`/`decrypt_org_manifest` vault wrappers.
- Invariant: KDF intermediates carrying the DH secret are held in `Zeroizing`.
- [ ] **Step 6: `crates/relicario-cli/ARCHITECTURE.md`** — add the org command surface:
- `org_session.rs` (`UnlockedOrgVault`, collection-scoped `item_path`, fingerprint member match, signed `org_git_run`).
- `commands/org.rs` (admin + item commands); device `current_device_seed`/`current_device_pubkey` helpers; the `ssh-key`/`regex`/`tempfile` deps.
- Note: org commits are SIGNED (`configure_git_signing` in `org init`; `org_git_run` does not force `commit.gpgsign=false` the way `helpers::git_run` does).
- [ ] **Step 7: `extension/ARCHITECTURE.md`** — confirm the storage table + module map entries added in Task D4 are present (`orgConfigs`, `service-worker/org.ts`, `vault/vault-org-switcher.ts`, the SessionHandle-only invariant). Add a short "org context" subsection describing the switch + read flow and the offline read-only derivation.
- [ ] **Step 8: `STATUS.md` / `ROADMAP.md`**:
- `STATUS.md`: mark the org-vault track landed (CLI admin + item CRUD, signature-verifying hook, extension switch/read parity); list tracked follow-ups: Card/Key/Document/Totp `org add`/`edit` parity, extension org *writes* (Plan B-2), and the phase-2 items (SSO/LDAP, read audit, per-collection subkeys, HTTP management plane).
- `ROADMAP.md`: move the org-vault milestone to shipped and scope the phase-2 follow-ups.
- [ ] **Step 9: Code-constant pinning check**
Per CLAUDE.md discipline rule 2, any code constant cited in these docs (wrapped-blob byte layout, `MANIFEST_SCHEMA_VERSION`, the KDF construction) must cite the source file + line. Grep your new doc text for cited constants and confirm each has a `file:line` reference.
- [ ] **Step 10: Commit**
```bash
git add docs/FORMATS.md docs/CRYPTO.md DESIGN.md docs/SECURITY.md \
crates/relicario-core/ARCHITECTURE.md crates/relicario-cli/ARCHITECTURE.md \
extension/ARCHITECTURE.md STATUS.md ROADMAP.md
git commit -m "docs(org): living-docs sweep — org formats, ECIES crypto, signature-verifying hook, honest limitations"
```
---
## Self-Review
**Spec coverage check** — every spec requirement maps to a task:
| Spec requirement | Covered by task(s) |
|---|---|
| Org master key (256-bit random, wrapped per-member ECIES/X25519) | A2, A4 |
| `org.json` / `members.json` / `collections.json` data model | A2, B4 |
| `keys/<member-id>.enc` wrapped-blob layout | A2, B4, B5 |
| **Collection-scoped item storage** `items/<slug>/<id>.enc` | B1, B9, B10 (write); C1 `classify_path` (authz); D2 (read) |
| `manifest.enc` org variant (entry carries `collection`) | A2, A3 |
| ed25519→X25519 ECIES wrap/unwrap; Zeroizing KDF intermediates (H6) | A2 |
| Roles owner/admin/member; admin cannot mint owner/admin (role-gating) | A2, B5 |
| Collection grants in `members.json` | A2, B6 |
| Manifest filtering per member grants (read) | A2, A4, B11 |
| **Org item CRUD** — add / get / list / edit / rm / restore / purge | B10, B11, B12, B13 |
| `org init` (with **git signing** configured) | B4 |
| `org add-member` / `remove-member` / `set-role` | B5 |
| `org create-collection` / `grant` / `revoke` | B6 |
| `org rotate-key` — re-wrap + **re-encrypt every item blob & manifest** | B7 |
| Rotate-key concurrent-rotation **race abort** (spec error string) | B7 |
| `org transfer-ownership` / `delete-org` | B14 (transfer fully; delete-org local, push tracked gap) |
| `org status` (no decryption) | B8 |
| `org audit`**verified-signer attribution**, **TAMPERED** flag, `%cI` + `%x1e/%x1f` framing, `--format json` | B8 |
| `relicario org` wired into `main.rs` (admin + item subcommands) | B14 |
| Pre-receive hook: **per-commit signature verification** (signer→member by fingerprint) | C1 |
| Pre-receive hook: protected-file role authz (owner/admin) | C1 |
| Pre-receive hook: **item path → collection-grant** authorization | C1 |
| Pre-receive hook: genesis allowed, **merge rejected** | C1 |
| Pre-receive hook: **schema_version monotonicity** + JSON validation | C2 |
| `generate-org-hook` script | C1 |
| Signature-verifying hook uses allowed-signers + `git verify-commit` (NOT `%GF`) | C1 |
| **Extension parity:** WASM org bindings (key never leaves WASM) | D1 |
| **Extension parity:** SW org session + handlers; org key only in SessionHandle | D2 |
| **Extension parity:** vault-tab org switcher + offline read-only banner | D3 |
| **Extension parity:** 3 spec-mandated vitest tests (switch / no-persist / offline) | D4 |
| Offline → read-only (CLI + extension) | B7-adjacent (no-remote distinction), D2/D3 |
| Member device key lost → owner re-grants (no escrow) | B5/B6 (re-add + grant) |
| **Living-docs** sweep (FORMATS/CRYPTO/DESIGN/SECURITY/ARCHITECTUREs/STATUS/ROADMAP) | A5 |
| Honest limitations recorded (shared key, no read audit, no hide-value) | A5 (docs/SECURITY.md) |
| SSO/SAML/LDAP, read audit, per-collection subkeys, HTTP plane | **Phase 2 — out of scope** |
**Placeholder scan:** No TBD/TODO/"similar to Task N"/phantom-task references. The flat-`items/` layout, the `%GF` fingerprint source, the unsigned `git_run` commit path, the `device.key`/`RELICARIO_DEVICE_KEY` storage model, and "items do not need re-encryption" are all removed.
**Type consistency:** `MemberId`, `OrgRole`, `OrgMembers`, `OrgMember`, `OrgManifest`, `OrgManifestEntry`, `CollectionDef`, `OrgCollections`, `OrgMeta` defined in A2, used consistently in B1B14, C1C2. `wrap_org_key`/`unwrap_org_key`/`generate_org_key`/`encrypt_org_manifest`/`decrypt_org_manifest` defined in A2A3, used in B4B13, C2, D1. `UnlockedOrgVault::item_path(collection_slug, id)` has exactly ONE definition (B1), consumed by B7B13 and matched by C1's `classify_path` and D2's read path.