Compare commits
18 Commits
feature/en
...
feature/or
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ed50735e91 | ||
|
|
519e503cbd | ||
|
|
cdb008c900 | ||
|
|
053062effd | ||
|
|
3b6dbbe353 | ||
|
|
558da3bd75 | ||
|
|
9c43f223f5 | ||
|
|
1c177871a7 | ||
|
|
1ad8eb0918 | ||
|
|
aace6f132a | ||
|
|
dbdb3f6ab0 | ||
|
|
7faedf8578 | ||
|
|
ccb58d8bb5 | ||
|
|
570b0ddcd3 | ||
|
|
7daedb33e0 | ||
|
|
17df315f0e | ||
|
|
2dd5d79f36 | ||
|
|
675b7836e1 |
34
CHANGELOG.md
34
CHANGELOG.md
@@ -1,5 +1,39 @@
|
||||
# Changelog
|
||||
|
||||
## Unreleased — enterprise org vault (in progress)
|
||||
|
||||
Git-native multi-user **org vaults**: a separate org git repository alongside each
|
||||
member's personal vault, with a 256-bit org master key ECIES-wrapped per member to
|
||||
their ed25519 device key, collection-scoped item storage, role-based access, and a
|
||||
signature-verifying pre-receive hook that makes least-privilege server-enforced.
|
||||
Tracked under `docs/superpowers/plans/2026-06-06-enterprise-org-vault.md`. Entries
|
||||
below cover the **already-merged** core (A) + server (C) + CLI admin work; item CRUD
|
||||
and extension parity land subsequently.
|
||||
|
||||
### Added
|
||||
- **relicario-core `org` module** (`crates/relicario-core/src/org.rs`): org types
|
||||
(`OrgId`, `MemberId`, `OrgRole`, `OrgMember`/`OrgMembers`, `CollectionDef`/
|
||||
`OrgCollections`, `OrgMeta`, `OrgManifest`/`OrgManifestEntry`) and ECIES X25519
|
||||
key wrap/unwrap (`generate_org_key`, `wrap_org_key`, `unwrap_org_key`) — ed25519→
|
||||
X25519 via RFC 7748 clamp, domain-separated `SHA-256(dh || eph_pk || rcpt_pk)` KDF,
|
||||
XChaCha20-Poly1305 inner cipher, all key material in `Zeroizing`. Adds
|
||||
`encrypt_org_manifest` / `decrypt_org_manifest` vault wrappers. New dependency
|
||||
`x25519-dalek 2` (`static_secrets`).
|
||||
- **relicario-server org mode**: `verify-org-commit` (signature verification against
|
||||
`members.json`, path-scoped role/grant authorization, owner-only elevation judged
|
||||
on the signer's pre-commit role, schema-version monotonicity) and
|
||||
`generate-org-hook`; new `[lib]` target (`classify_path`, `extract_schema_version`).
|
||||
- **relicario-cli org admin commands**: `org init`, `add-member` / `remove-member` /
|
||||
`set-role` (owner-only escalation guard), `create-collection` / `grant` / `revoke`,
|
||||
`rotate-key` (re-encrypts every item blob + manifest under a fresh key),
|
||||
`status` / `audit` (verified-signer attribution + `TAMPERED` flag). Org commits are
|
||||
signed (`org_git_run` preserves signing). New `ssh-key` dependency in the CLI.
|
||||
|
||||
### TODO (pending merge)
|
||||
- CLI item CRUD: `org add` / `get` / `list` / `edit` / `rm` / `restore` / `purge`,
|
||||
and the final `Commands::Org` wiring in `main.rs` (Dev-B B9–B14).
|
||||
- Extension org switch + read-only browse parity (Dev-D follow-up).
|
||||
|
||||
## v0.7.0 — 2026-06-01
|
||||
|
||||
Completes the extension restructure (Plan C) begun under v0.6.0. Phases
|
||||
|
||||
3
Cargo.lock
generated
3
Cargo.lock
generated
@@ -2166,17 +2166,20 @@ dependencies = [
|
||||
"clap_complete",
|
||||
"data-encoding",
|
||||
"dirs",
|
||||
"ed25519-dalek",
|
||||
"hex",
|
||||
"image",
|
||||
"predicates",
|
||||
"qrcode",
|
||||
"rand",
|
||||
"regex",
|
||||
"relicario-core",
|
||||
"reqwest",
|
||||
"rpassword",
|
||||
"rqrr",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"ssh-key",
|
||||
"tar",
|
||||
"tempfile",
|
||||
"url",
|
||||
|
||||
14
DESIGN.md
14
DESIGN.md
@@ -147,11 +147,25 @@ The threat model differs by codebase. This is the per-secret per-codebase reside
|
||||
| Master key | `Zeroizing<[u8;32]>` returned by `derive_master_key` | `UnlockedVault.master_key` for the lifetime of one CLI invocation | WASM-side memory behind an opaque `SessionHandle`; JS never sees the bytes | Never sees it |
|
||||
| Item secret (password, card number, etc.) | `Zeroizing<String>` / `Zeroizing<Vec<u8>>` | Same | Briefly held in WASM during `item_decrypt`; results passed to popup as plaintext for display | Held in DOM (the user is staring at it); cleared when view changes |
|
||||
| Device private key | — | Filesystem under `~/.config/relicario/devices/<name>.key` (mode 0600) | `chrome.storage.local.device_private_key` | — |
|
||||
| Org master key (256-bit, random) | `Zeroizing<[u8;32]>` during `wrap_org_key`/`unwrap_org_key` (never derived from a passphrase) | `UnlockedOrgVault.org_key` for one CLI invocation; recovered by unwrapping `keys/<member-id>.enc` with the device ed25519 seed | TODO (extension follow-up) | Never sees it |
|
||||
|
||||
The org master key is **never escrowed**: each member holds it ECIES-wrapped to their device key (`keys/<member-id>.enc`); an owner can always re-wrap it to a replacement device key, so there is no central key store to compromise. See `docs/CRYPTO.md` (Org-key ECIES wrap/unwrap) and `docs/FORMATS.md` (Org vault repo formats).
|
||||
|
||||
The popup / vault / content surfaces of the extension cannot decrypt an item independently — they all message the SW. Content scripts in particular get back already-prepared payloads (e.g. `{ username, password }`) from `fill_credentials` after the SW resolved everything.
|
||||
|
||||
The CLI keeps its master key in process memory; if the process exits or crashes, the key is gone (Zeroize on drop). There is no CLI session daemon. The `lock` subcommand exists only for UX parity with the extension and is a no-op.
|
||||
|
||||
## Org vault (enterprise, in progress)
|
||||
|
||||
The enterprise org vault is a **second git repository** alongside each member's personal vault, with its own schema (`org.json` / `members.json` / `collections.json` / `keys/<member-id>.enc` / `manifest.enc` / `items/<collection-slug>/<item-id>.enc`). It reuses the same `relicario-core` AEAD; the only new crypto is the per-member ECIES key wrap. Cross-codebase additions:
|
||||
|
||||
- **relicario-core** gains the `org` module (`org.rs`) and the `x25519-dalek = { version = "2", features = ["static_secrets"] }` dependency (`crates/relicario-core/Cargo.toml:19`); `ssh-key` 0.6 is already present (`:20`).
|
||||
- **relicario-cli** gains `org_session.rs` + `commands/org.rs` and the `ssh-key = "0.6"` dependency (`crates/relicario-cli/Cargo.toml:33`).
|
||||
- **relicario-server** gains an **org mode**: a new `[lib]` target (`classify_path`, `extract_schema_version`) plus the `verify-org-commit` and `generate-org-hook` subcommands — a signature-verifying, path-scoped pre-receive hook (see `docs/SECURITY.md`).
|
||||
- **extension** org switch + read parity is a tracked follow-up (Dev-D) — `TODO (extension follow-up)`.
|
||||
|
||||
Status: core (A) + server hook (C) merged; CLI admin/rotate/status-audit merged; CLI item-CRUD + the final command wiring are `TODO (pending Dev-B B9–B14)`.
|
||||
|
||||
## Build matrix
|
||||
|
||||
| Target | Tool | Output | When to run |
|
||||
|
||||
@@ -71,6 +71,30 @@ under `src/commands/`. Each source file has one job.
|
||||
hatches `RELICARIO_TEST_PASSPHRASE` (`session.rs:42`) and `RELICARIO_IMAGE`
|
||||
(`session.rs:125`) that integration tests use to bypass the TTY.
|
||||
|
||||
- **`src/org_session.rs`** — `UnlockedOrgVault`, the org-vault analogue of
|
||||
`session.rs`. Holds the org master key in `Zeroizing<[u8; 32]>` for one CLI
|
||||
invocation, recovered by unwrapping `keys/<member-id>.enc` with the device
|
||||
ed25519 seed (`relicario_core::unwrap_org_key`). Owns the **collection-scoped**
|
||||
`item_path` (`items/<collection-slug>/<id>.enc` — the leading slug is what the
|
||||
pre-receive hook authorizes against, never decrypting), fingerprint-based
|
||||
member matching (`relicario_core::fingerprint`, tolerant of OpenSSH
|
||||
whitespace/comment differences), `atomic_write`, and `org_git_run`. Note
|
||||
`org_git_run` runs **bare git** — unlike `helpers::git_run` it does NOT inject
|
||||
`commit.gpgsign=false`, because org commits MUST be signed (the hook verifies
|
||||
every commit's signature); signing config is established by
|
||||
`configure_git_signing` during `org init`.
|
||||
|
||||
- **`src/commands/org.rs`** — the `relicario org` subcommand surface. Merged:
|
||||
`init`, `add-member` / `remove-member` / `set-role` (owner-only escalation
|
||||
guard), `create-collection` / `grant` / `revoke`, `rotate-key`
|
||||
(`run_rotate_key`, `commands/org.rs:332` — fresh key, re-wrap for all members,
|
||||
re-encrypt every item blob + manifest under the new key, concurrent-rotation
|
||||
abort), and `status` / `audit` (verified-signer attribution + `TAMPERED`
|
||||
flag). **TODO (pending Dev-B B9–B14):** the item-CRUD commands (`org add` /
|
||||
`get` / `list` / `edit` / `rm` / `restore` / `purge`) and the final
|
||||
`Commands::Org` wiring in `main.rs`. `device.rs` gains
|
||||
`current_device_seed` / `current_device_pubkey` helpers for the ECIES unwrap.
|
||||
|
||||
- **`src/helpers.rs`** (`helpers.rs:1-101`) — pure, no-state plumbing:
|
||||
`find_vault_dir_from` (`helpers.rs:14-28`) walks up parent directories
|
||||
looking for a `.relicario/` marker; `vault_dir` and `relicario_dir` wrap it
|
||||
|
||||
@@ -30,9 +30,12 @@ image = { version = "0.25", default-features = false, features = ["jpeg", "png"]
|
||||
rqrr = "0.7"
|
||||
reqwest = { version = "0.12", features = ["blocking", "json"] }
|
||||
qrcode = { version = "0.14", features = ["svg"] }
|
||||
ssh-key = { version = "0.6", features = ["ed25519", "std"] }
|
||||
regex = "1"
|
||||
tempfile = "3"
|
||||
|
||||
[dev-dependencies]
|
||||
assert_cmd = "2"
|
||||
predicates = "3"
|
||||
tempfile = "3"
|
||||
serde_json = "1"
|
||||
ed25519-dalek = "2"
|
||||
|
||||
@@ -14,6 +14,7 @@ pub mod edit;
|
||||
pub mod generate;
|
||||
pub mod get;
|
||||
pub mod import;
|
||||
pub mod org;
|
||||
pub mod init;
|
||||
pub mod list;
|
||||
pub mod rate;
|
||||
|
||||
757
crates/relicario-cli/src/commands/org.rs
Normal file
757
crates/relicario-cli/src/commands/org.rs
Normal file
@@ -0,0 +1,757 @@
|
||||
//! `relicario org` subcommands for multi-user org vault management.
|
||||
|
||||
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())
|
||||
}
|
||||
|
||||
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(())
|
||||
}
|
||||
|
||||
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(())
|
||||
}
|
||||
|
||||
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(())
|
||||
}
|
||||
|
||||
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(())
|
||||
}
|
||||
|
||||
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(())
|
||||
}
|
||||
|
||||
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(())
|
||||
}
|
||||
|
||||
/// 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()),
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
crate::org_session::atomic_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(())
|
||||
}
|
||||
|
||||
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(())
|
||||
}
|
||||
|
||||
#[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(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use relicario_core::{MemberId, OrgMembers, OrgRole, OrgMember};
|
||||
|
||||
#[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);
|
||||
}
|
||||
|
||||
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 new_key_differs_from_old_key() {
|
||||
let k1 = relicario_core::generate_org_key();
|
||||
let k2 = relicario_core::generate_org_key();
|
||||
assert_ne!(*k1, *k2);
|
||||
}
|
||||
|
||||
#[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);
|
||||
}
|
||||
|
||||
#[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()));
|
||||
}
|
||||
}
|
||||
@@ -99,6 +99,48 @@ pub fn load_signing_key(name: &str) -> Result<Zeroizing<String>> {
|
||||
Ok(Zeroizing::new(key))
|
||||
}
|
||||
|
||||
/// Read the active device's ed25519 public key (OpenSSH single-line format,
|
||||
/// e.g. `ssh-ed25519 AAAA... comment`) from `signing.pub`.
|
||||
///
|
||||
/// Errors if no device is selected (`devices/current` missing/empty) — the
|
||||
/// caller should hint the user to run `relicario device add` first.
|
||||
pub fn current_device_pubkey() -> Result<String> {
|
||||
let name = current_device()?
|
||||
.ok_or_else(|| anyhow::anyhow!("no active device — run `relicario device add` first"))?;
|
||||
let path = device_dir(&name)?.join("signing.pub");
|
||||
let pubkey = fs::read_to_string(&path)
|
||||
.with_context(|| format!("read signing.pub for device '{name}'"))?;
|
||||
let trimmed = pubkey.trim();
|
||||
if trimmed.is_empty() {
|
||||
anyhow::bail!("signing.pub for device '{name}' is empty");
|
||||
}
|
||||
Ok(trimmed.to_string())
|
||||
}
|
||||
|
||||
/// Read the active device's 32-byte ed25519 seed from `signing.key`
|
||||
/// (OpenSSH private-key format).
|
||||
///
|
||||
/// The seed is the secret scalar used to sign org commits and to unwrap the
|
||||
/// org key. It is returned in `Zeroizing` so it is wiped on drop. Errors if no
|
||||
/// device is selected, the key file is unreadable, or the key is not ed25519.
|
||||
pub fn current_device_seed() -> Result<Zeroizing<[u8; 32]>> {
|
||||
let name = current_device()?
|
||||
.ok_or_else(|| anyhow::anyhow!("no active device — run `relicario device add` first"))?;
|
||||
// load_signing_key reads signing.key as OpenSSH private-key text.
|
||||
let pem = load_signing_key(&name)?;
|
||||
let private = ssh_key::PrivateKey::from_openssh(pem.as_str())
|
||||
.map_err(|e| anyhow::anyhow!("parse signing.key for device '{name}': {e}"))?;
|
||||
let keypair = private
|
||||
.key_data()
|
||||
.ed25519()
|
||||
.ok_or_else(|| anyhow::anyhow!("signing.key for device '{name}' is not ed25519"))?;
|
||||
// Ed25519PrivateKey::as_ref() yields &[u8; 32] (verified: ssh-key 0.6.7
|
||||
// private/ed25519.rs:42). Copy into a Zeroizing array so the seed is wiped.
|
||||
let mut seed = Zeroizing::new([0u8; 32]);
|
||||
seed.copy_from_slice(keypair.private.as_ref());
|
||||
Ok(seed)
|
||||
}
|
||||
|
||||
/// Load the deploy private key for a device.
|
||||
#[allow(dead_code)]
|
||||
pub fn load_deploy_key(name: &str) -> Result<Zeroizing<String>> {
|
||||
@@ -127,6 +169,53 @@ pub fn delete_device_keys(name: &str) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod seed_helper_tests {
|
||||
use super::*;
|
||||
use std::sync::Mutex;
|
||||
|
||||
// dirs::config_dir() reads process-wide env; serialize these tests.
|
||||
static ENV_LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
#[test]
|
||||
fn current_device_seed_and_pubkey_round_trip() {
|
||||
let _guard = ENV_LOCK.lock().unwrap();
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let prev_xdg = std::env::var_os("XDG_CONFIG_HOME");
|
||||
std::env::set_var("XDG_CONFIG_HOME", tmp.path());
|
||||
|
||||
// Generate a real ed25519 device keypair (OpenSSH text) via core.
|
||||
let (private_openssh, public_openssh) =
|
||||
relicario_core::device::generate_keypair().unwrap();
|
||||
|
||||
// Lay out devices/test-dev/{signing.key,signing.pub} + devices/current.
|
||||
let dir = device_dir("test-dev").unwrap();
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("signing.key"), private_openssh.as_str()).unwrap();
|
||||
std::fs::write(dir.join("signing.pub"), &public_openssh).unwrap();
|
||||
set_current_device("test-dev").unwrap();
|
||||
|
||||
// pubkey helper returns exactly the stored OpenSSH public line.
|
||||
let got_pub = current_device_pubkey().unwrap();
|
||||
assert_eq!(got_pub.trim(), public_openssh.trim());
|
||||
|
||||
// seed helper returns the 32-byte ed25519 seed; re-derive the public
|
||||
// key from it and confirm it matches.
|
||||
let seed = current_device_seed().unwrap();
|
||||
let signing = ed25519_dalek::SigningKey::from_bytes(&seed);
|
||||
let derived = signing.verifying_key();
|
||||
let parsed_pub = ssh_key::PublicKey::from_openssh(&public_openssh).unwrap();
|
||||
let parsed_bytes: &[u8] = parsed_pub.key_data().ed25519().unwrap().as_ref();
|
||||
assert_eq!(derived.as_bytes().as_slice(), parsed_bytes);
|
||||
|
||||
// restore env
|
||||
match prev_xdg {
|
||||
Some(v) => std::env::set_var("XDG_CONFIG_HOME", v),
|
||||
None => std::env::remove_var("XDG_CONFIG_HOME"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Configure git in `vault_root` to:
|
||||
/// - sign commits with the device's signing key (SSH format)
|
||||
/// - push via SSH using the device's deploy key
|
||||
|
||||
@@ -9,6 +9,7 @@ mod helpers;
|
||||
mod parse;
|
||||
mod prompt;
|
||||
mod session;
|
||||
mod org_session;
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
@@ -206,6 +207,15 @@ enum Commands {
|
||||
#[command(subcommand)]
|
||||
cmd: RecoveryQrCmd,
|
||||
},
|
||||
|
||||
/// Manage a multi-user org vault.
|
||||
Org {
|
||||
/// Path to the org vault directory (overrides RELICARIO_ORG_DIR).
|
||||
#[arg(long, global = true)]
|
||||
dir: Option<PathBuf>,
|
||||
#[command(subcommand)]
|
||||
subcommand: OrgCommands,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
@@ -421,6 +431,16 @@ pub(crate) enum RecoveryQrCmd {
|
||||
Unwrap,
|
||||
}
|
||||
|
||||
#[derive(clap::Subcommand)]
|
||||
pub(crate) enum OrgCommands {
|
||||
/// Create a new org vault.
|
||||
Init {
|
||||
#[arg(long)]
|
||||
name: String,
|
||||
},
|
||||
// Admin + item subcommands are added by later tasks (B10-B14).
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let cli = Cli::parse();
|
||||
match cli.command {
|
||||
@@ -455,6 +475,16 @@ fn main() -> Result<()> {
|
||||
Commands::Rate { passphrase } => commands::rate::cmd_rate(passphrase),
|
||||
Commands::Device { action } => commands::device::cmd_device(action),
|
||||
Commands::RecoveryQr { cmd } => commands::recovery_qr::cmd_recovery_qr(cmd),
|
||||
Commands::Org { dir, subcommand } => {
|
||||
let dir_path = dir.as_deref();
|
||||
match subcommand {
|
||||
OrgCommands::Init { name } => {
|
||||
let d = crate::org_session::org_dir(dir_path)?;
|
||||
commands::org::run_init(&d, &name)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
320
crates/relicario-cli/src/org_session.rs
Normal file
320
crates/relicario-cli/src/org_session.rs
Normal file
@@ -0,0 +1,320 @@
|
||||
//! 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);
|
||||
}
|
||||
}
|
||||
24
crates/relicario-cli/tests/org_init.rs
Normal file
24
crates/relicario-cli/tests/org_init.rs
Normal file
@@ -0,0 +1,24 @@
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn run(args: &[&str]) -> std::process::Output {
|
||||
std::process::Command::new(env!("CARGO_BIN_EXE_relicario"))
|
||||
.args(args)
|
||||
.output()
|
||||
.expect("run relicario")
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore] // requires a device key on disk; run manually or via org_init_signing
|
||||
fn org_init_creates_expected_files() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let path = dir.path().to_str().unwrap();
|
||||
// `--dir` is a subcommand-scoped global on `org` (B14), so it must come
|
||||
// AFTER `org init`, not before it (matches B10's OrgFixture).
|
||||
let out = run(&["org", "init", "--dir", path, "--name", "Test Org"]);
|
||||
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
|
||||
assert!(dir.path().join("org.json").exists());
|
||||
assert!(dir.path().join("members.json").exists());
|
||||
assert!(dir.path().join("collections.json").exists());
|
||||
assert!(dir.path().join("manifest.enc").exists());
|
||||
assert!(dir.path().join(".git").exists());
|
||||
}
|
||||
145
crates/relicario-cli/tests/org_init_signing.rs
Normal file
145
crates/relicario-cli/tests/org_init_signing.rs
Normal file
@@ -0,0 +1,145 @@
|
||||
use std::fs;
|
||||
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")
|
||||
}
|
||||
|
||||
/// Like relicario() but also injects the git committer identity so that
|
||||
/// `git commit` inside `org init` doesn't fail with "Please tell me who you are."
|
||||
fn relicario_with_git_identity(config_home: &Path, args: &[&str]) -> std::process::Output {
|
||||
Command::new(env!("CARGO_BIN_EXE_relicario"))
|
||||
.env("XDG_CONFIG_HOME", config_home)
|
||||
.env("HOME", config_home)
|
||||
.env("GIT_AUTHOR_NAME", "Test Device")
|
||||
.env("GIT_AUTHOR_EMAIL", "test@relicario.test")
|
||||
.env("GIT_COMMITTER_NAME", "Test Device")
|
||||
.env("GIT_COMMITTER_EMAIL", "test@relicario.test")
|
||||
.args(args)
|
||||
.output()
|
||||
.expect("run relicario")
|
||||
}
|
||||
|
||||
fn git(repo: &Path, args: &[&str]) -> std::process::Output {
|
||||
Command::new("git")
|
||||
.current_dir(repo)
|
||||
.args(args)
|
||||
.output()
|
||||
.expect("run git")
|
||||
}
|
||||
|
||||
/// Lay out device keys directly under `<config_home>/relicario/devices/<name>/`
|
||||
/// and set `devices/current` — mirrors the B2 seed_helper_tests approach.
|
||||
/// Returns the OpenSSH public key string so the caller can build an allowed_signers
|
||||
/// file for `git verify-commit`.
|
||||
fn seed_device(config_home: &Path, name: &str) -> String {
|
||||
let (priv_openssh, pub_openssh) =
|
||||
relicario_core::device::generate_keypair().expect("generate_keypair");
|
||||
|
||||
let dev_dir = config_home
|
||||
.join("relicario")
|
||||
.join("devices")
|
||||
.join(name);
|
||||
fs::create_dir_all(&dev_dir).expect("create device dir");
|
||||
let signing_key_path = dev_dir.join("signing.key");
|
||||
fs::write(&signing_key_path, priv_openssh.as_str())
|
||||
.expect("write signing.key");
|
||||
// ssh requires 0600 on private key files or it refuses to use them.
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
fs::set_permissions(&signing_key_path, fs::Permissions::from_mode(0o600))
|
||||
.expect("chmod signing.key");
|
||||
}
|
||||
fs::write(dev_dir.join("signing.pub"), &pub_openssh)
|
||||
.expect("write signing.pub");
|
||||
// Also write stub deploy key files so configure_git_signing doesn't trip on
|
||||
// a missing deploy.key path (the git config value just points to the file;
|
||||
// the file itself is never read during org init).
|
||||
fs::write(dev_dir.join("deploy.key"), "").expect("write stub deploy.key");
|
||||
fs::write(dev_dir.join("deploy.pub"), "").expect("write stub deploy.pub");
|
||||
|
||||
// Set this device as current.
|
||||
let devices_dir = config_home.join("relicario").join("devices");
|
||||
fs::write(devices_dir.join("current"), format!("{name}\n"))
|
||||
.expect("write current");
|
||||
|
||||
pub_openssh
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn org_init_produces_a_signed_initial_commit() {
|
||||
let cfg = TempDir::new().unwrap();
|
||||
let org = TempDir::new().unwrap();
|
||||
|
||||
// Lay out the device key directly (no `device add` needed — it requires Gitea).
|
||||
let pub_openssh = seed_device(cfg.path(), "test-dev");
|
||||
|
||||
// Initialize the org vault. `--dir` comes AFTER `org init` (B14 global).
|
||||
// Inject git identity so the commit doesn't fail "Please tell me who you are."
|
||||
let init = relicario_with_git_identity(
|
||||
cfg.path(),
|
||||
&["org", "init", "--dir", org.path().to_str().unwrap(), "--name", "Acme"],
|
||||
);
|
||||
assert!(
|
||||
init.status.success(),
|
||||
"org init failed:\nstdout: {}\nstderr: {}",
|
||||
String::from_utf8_lossy(&init.stdout),
|
||||
String::from_utf8_lossy(&init.stderr)
|
||||
);
|
||||
|
||||
// The org repo must be configured to sign.
|
||||
let cfg_out = git(org.path(), &["config", "commit.gpgsign"]);
|
||||
assert_eq!(
|
||||
String::from_utf8_lossy(&cfg_out.stdout).trim(),
|
||||
"true",
|
||||
"org repo must have commit.gpgsign=true"
|
||||
);
|
||||
|
||||
// The HEAD commit object must carry a signature header.
|
||||
let head = git(org.path(), &["cat-file", "commit", "HEAD"]);
|
||||
let body = String::from_utf8_lossy(&head.stdout);
|
||||
assert!(
|
||||
body.contains("gpgsig "),
|
||||
"HEAD commit must be signed (no gpgsig header found):\n{body}"
|
||||
);
|
||||
|
||||
// Configure an allowed_signers file so `git verify-commit` can validate the
|
||||
// SSH signature. The principal must match the committer email injected above.
|
||||
let allowed_signers_path = cfg.path().join("allowed_signers");
|
||||
let allowed_line = format!("test@relicario.test {}", pub_openssh.trim());
|
||||
fs::write(&allowed_signers_path, format!("{allowed_line}\n"))
|
||||
.expect("write allowed_signers");
|
||||
git(
|
||||
org.path(),
|
||||
&[
|
||||
"config",
|
||||
"gpg.ssh.allowedSignersFile",
|
||||
allowed_signers_path.to_str().unwrap(),
|
||||
],
|
||||
);
|
||||
|
||||
// Now verify-commit should succeed.
|
||||
let verify = git(org.path(), &["verify-commit", "HEAD"]);
|
||||
assert!(
|
||||
verify.status.success(),
|
||||
"git verify-commit HEAD failed:\nstdout: {}\nstderr: {}",
|
||||
String::from_utf8_lossy(&verify.stdout),
|
||||
String::from_utf8_lossy(&verify.stderr)
|
||||
);
|
||||
|
||||
// The commit body must carry the org-init action trailer.
|
||||
let log_out = git(org.path(), &["log", "-1", "--format=%B"]);
|
||||
let commit_body = String::from_utf8_lossy(&log_out.stdout);
|
||||
assert!(
|
||||
commit_body.contains("Relicario-Action: org-init"),
|
||||
"HEAD commit body must contain 'Relicario-Action: org-init' trailer:\n{commit_body}"
|
||||
);
|
||||
}
|
||||
@@ -103,6 +103,26 @@ Pipeline" and "Crate Layout").
|
||||
auth factor. Owns its own `YChannel`, `EmbedRegion`, 8×8 DCT/IDCT,
|
||||
Quantization Index Modulation, and crop-recovery extractor. No other module
|
||||
imports it; it is consumed only via the public re-export from `lib.rs`.
|
||||
- **`org.rs`** — Org-vault data model and ECIES key-wrapping layer
|
||||
(`crates/relicario-core/src/org.rs`). Types: `OrgId` (L15), `MemberId`
|
||||
(L19; `is_valid` L41 — 16 lowercase hex), `OrgRole` (L54;
|
||||
`can_manage_members` L61 = Owner | Admin, `can_manage_owners` L64 = Owner
|
||||
only), `OrgMember` (L72; carries `ed25519_pubkey` in OpenSSH wire format,
|
||||
`collections` grant list, `role`), `OrgMembers` (L86; `schema_version: 1`
|
||||
L93; `validate` L104), `CollectionDef` (L123), `OrgCollections` (L131;
|
||||
`schema_version: 1` L138; `validate` L145 rejects empty / `/` / `.` slugs),
|
||||
`OrgMeta` (L164; `schema_version: 1` L174), `OrgManifestEntry` (L185;
|
||||
carries `collection` slug plus id/type/title/tags/modified/trashed\_at),
|
||||
`OrgManifest` (L199; `schema_version: 1` L206; `filter_for_member` L210
|
||||
returns only entries whose collection slug appears in the member's grants).
|
||||
All four JSON containers carry `schema_version: 1` — distinct from the
|
||||
personal `Manifest` whose `MANIFEST_SCHEMA_VERSION = 2` (`manifest.rs:12`).
|
||||
Crypto: `generate_org_key` (L230) → `Zeroizing<[u8;32]>` (256-bit
|
||||
CSPRNG org master key); `wrap_org_key` (L265) / `unwrap_org_key` (L299) —
|
||||
ECIES over X25519, described in detail under **Invariants & contracts**
|
||||
below. `vault.rs` adds `encrypt_org_manifest` / `decrypt_org_manifest` typed
|
||||
wrappers (JSON-serialize → `crypto::encrypt` under the org key, plaintext in
|
||||
`Zeroizing`) consistent with the personal-vault pattern.
|
||||
- **`backup.rs`** — `.relbak` v1 container format: `pack_backup` /
|
||||
`unpack_backup` plus the `BackupInput` / `BackupOutput` / `BackupItem` /
|
||||
`BackupAttachment` shapes. Wraps a zstd-compressed JSON envelope of vault
|
||||
@@ -230,6 +250,28 @@ Pipeline" and "Crate Layout").
|
||||
also used to derive the key for *unlock*, not just create).
|
||||
- **`SymbolCharset::Custom` must be ASCII-only** (`generators.rs:46-52`).
|
||||
Non-ASCII custom charsets are rejected with `RelicarioError::Format`.
|
||||
- **ECIES wrap-blob layout is fixed** at
|
||||
`ephemeral_x25519_pk(32) || version(1) || nonce(24) || ciphertext+tag`
|
||||
(`org.rs:264`). The `version(1)` byte is the same `VERSION_BYTE = 0x02`
|
||||
emitted by `crypto::encrypt`, which is what occupies that slot — the layout
|
||||
merely names the regions for clarity.
|
||||
- **KDF wrap key = `SHA-256(dh_shared || ephemeral_pk || recipient_pk)`**
|
||||
(`org.rs:278-281`). The concatenation order is identical in `wrap_org_key`
|
||||
and `unwrap_org_key`; a mismatch in either direction would produce a
|
||||
different key and fail the AEAD open. The intermediate `kdf_input` buffer is
|
||||
held in `Zeroizing<Vec<u8>>`; `org_key`, `wrap_key`, and the decrypted
|
||||
`plaintext` from unwrap are also held in `Zeroizing`.
|
||||
- **ed25519 → X25519 conversion** applies `SHA-512(seed)[..32]` then the
|
||||
RFC 7748 scalar clamp
|
||||
(`scalar[0] &= 248; scalar[31] &= 127; scalar[31] |= 64`) to derive the
|
||||
private X25519 scalar (`org.rs:242`); the recipient public key is obtained
|
||||
via `ed25519_dalek`'s `to_montgomery()`. This lets device ed25519 keys serve
|
||||
double duty as X25519 recipients without storing a separate DH key.
|
||||
- **Org crypto bypasses Argon2id.** The ECIES inner cipher delegates to
|
||||
`crate::crypto::encrypt` / `decrypt` (XChaCha20-Poly1305, random 24-byte
|
||||
nonce, `VERSION_BYTE = 0x02`) — no AEAD re-implementation. The X25519 KDF
|
||||
output is used directly as the AEAD key; the Argon2id path in `crypto.rs`
|
||||
is not invoked for org key wrapping.
|
||||
|
||||
## Key flows
|
||||
|
||||
@@ -315,6 +357,35 @@ when subsequent `decrypt_*` returns `RelicarioError::Decrypt`.
|
||||
call `item.prune_history(&settings.field_history_retention, now_unix())`
|
||||
when they want to enforce the policy.
|
||||
|
||||
### Org key wrap / unwrap
|
||||
|
||||
1. **Wrap** (`org.rs:265`): caller supplies a recipient's OpenSSH ed25519
|
||||
public key string.
|
||||
- Parse the OpenSSH wire format via `ssh-key` to recover the raw 32-byte
|
||||
ed25519 public key bytes; apply `to_montgomery()` (ed25519-dalek) to
|
||||
obtain the recipient's X25519 public key.
|
||||
- Generate an ephemeral X25519 keypair from `OsRng`.
|
||||
- `dh_shared = ephemeral_secret × recipient_x25519_pk` (X25519 DH).
|
||||
- `wrap_key = SHA-256(dh_shared || ephemeral_pk || recipient_pk)`
|
||||
(`org.rs:278-281`), intermediates in `Zeroizing`.
|
||||
- `ct = crate::crypto::encrypt(&wrap_key, &org_key)` — yields the standard
|
||||
`version(1) || nonce(24) || ciphertext+tag` blob.
|
||||
- Return `ephemeral_x25519_pk(32) || ct` (`org.rs:264`).
|
||||
2. **Unwrap** (`org.rs:299`): caller supplies the device ed25519 seed bytes
|
||||
(from `current_device_seed` in the CLI layer, not from `relicario-core`).
|
||||
- Derive X25519 private scalar from seed: `SHA-512(seed)[..32]` + RFC 7748
|
||||
clamp (`org.rs:242`).
|
||||
- Slice the first 32 bytes of the blob as `ephemeral_pk`; read recipient's
|
||||
own X25519 public key via the same `to_montgomery()` path.
|
||||
- `dh_shared = device_x25519_secret × ephemeral_pk`.
|
||||
- Reconstruct `wrap_key` identically; `crypto::decrypt` recovers `org_key`
|
||||
into `Zeroizing`.
|
||||
|
||||
Integration tests: `crates/relicario-core/tests/org.rs` (5 acceptance tests
|
||||
covering wrap/unwrap round-trip, revoked-after-rotation, and manifest
|
||||
`filter_for_member`). A pinned RFC 8032 ed25519→X25519 known-answer vector
|
||||
lives in the `#[cfg(test)]` block inside `org.rs` itself.
|
||||
|
||||
### imgsecret embed
|
||||
|
||||
1. Caller passes a JPEG byte slice and a 32-byte secret to
|
||||
|
||||
@@ -5,6 +5,14 @@ edition = "2021"
|
||||
description = "Pre-receive Git hook for relicario password manager"
|
||||
license = "GPL-3.0-or-later"
|
||||
|
||||
[lib]
|
||||
name = "relicario_server"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "relicario-server"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
relicario-core = { path = "../relicario-core" }
|
||||
anyhow = "1"
|
||||
|
||||
58
crates/relicario-server/src/lib.rs
Normal file
58
crates/relicario-server/src/lib.rs
Normal file
@@ -0,0 +1,58 @@
|
||||
//! Library surface for relicario-server, exposing pure helpers used by the
|
||||
//! pre-receive hooks so they can be unit-tested.
|
||||
|
||||
/// Classification of a single changed path inside an org repo.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum PathClass {
|
||||
/// `members.json`, `collections.json`, `org.json` — only Owner/Admin may write.
|
||||
Protected,
|
||||
/// `items/<slug>/<id>.enc` — writer must hold a grant for `<slug>`.
|
||||
Item { collection: String },
|
||||
/// `keys/<id>.enc`, `manifest.enc`, `.gitignore`, etc. — gated only by the
|
||||
/// per-commit signature check (signer must be a current member).
|
||||
Unrestricted,
|
||||
/// Structurally invalid path; commit must be rejected.
|
||||
Rejected(String),
|
||||
}
|
||||
|
||||
/// Classify a repo-relative path. Pure; no I/O.
|
||||
pub fn classify_path(path: &str) -> PathClass {
|
||||
match path {
|
||||
"members.json" | "collections.json" | "org.json" => return PathClass::Protected,
|
||||
_ => {}
|
||||
}
|
||||
|
||||
if let Some(rest) = path.strip_prefix("items/") {
|
||||
// Expect exactly: <slug>/<id>.enc → two segments after the prefix.
|
||||
let segments: Vec<&str> = rest.split('/').collect();
|
||||
if segments.len() != 2 {
|
||||
return PathClass::Rejected("items path must be items/<slug>/<id>.enc".to_string());
|
||||
}
|
||||
let slug = segments[0];
|
||||
if slug.is_empty() {
|
||||
return PathClass::Rejected("empty collection slug in items path".to_string());
|
||||
}
|
||||
// Defense-in-depth: mirror `OrgCollections::validate` — a slug containing
|
||||
// '.' (e.g. a `..`/`.` path-traversal attempt) is structurally invalid.
|
||||
// git normalizes most `./` away before the hook sees the path, so this is
|
||||
// unreachable today; it keeps the hook self-defensive regardless.
|
||||
if slug.contains('.') {
|
||||
return PathClass::Rejected(format!("invalid collection slug: {:?}", slug));
|
||||
}
|
||||
return PathClass::Item { collection: slug.to_string() };
|
||||
}
|
||||
|
||||
PathClass::Unrestricted
|
||||
}
|
||||
|
||||
/// Extract the `schema_version` field from any org JSON document.
|
||||
/// Returns an error if the field is absent or not a u32.
|
||||
pub fn extract_schema_version(json: &str) -> Result<u32, String> {
|
||||
let value: serde_json::Value =
|
||||
serde_json::from_str(json).map_err(|e| format!("parse json: {e}"))?;
|
||||
value
|
||||
.get("schema_version")
|
||||
.and_then(|v| v.as_u64())
|
||||
.map(|n| n as u32)
|
||||
.ok_or_else(|| "missing or non-integer schema_version".to_string())
|
||||
}
|
||||
@@ -6,6 +6,8 @@ use std::process::Command;
|
||||
use anyhow::{Context, Result};
|
||||
use clap::{Parser, Subcommand};
|
||||
use relicario_core::device::{DeviceEntry, RevokedEntry};
|
||||
use relicario_core::org::{OrgCollections, OrgMember, OrgMembers, OrgRole};
|
||||
use relicario_server::{classify_path, extract_schema_version, PathClass};
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "relicario-server")]
|
||||
@@ -23,6 +25,13 @@ enum Commands {
|
||||
},
|
||||
/// Generate a pre-receive hook script.
|
||||
GenerateHook,
|
||||
/// Verify a commit to an org vault: signature + role/path authorization.
|
||||
VerifyOrgCommit {
|
||||
/// The commit SHA to verify.
|
||||
commit: String,
|
||||
},
|
||||
/// Generate an org pre-receive hook script.
|
||||
GenerateOrgHook,
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
@@ -31,6 +40,8 @@ fn main() -> Result<()> {
|
||||
match cli.command {
|
||||
Commands::VerifyCommit { commit } => verify_commit(&commit),
|
||||
Commands::GenerateHook => generate_hook(),
|
||||
Commands::VerifyOrgCommit { commit } => verify_org_commit(&commit),
|
||||
Commands::GenerateOrgHook => generate_org_hook(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -187,3 +198,408 @@ fn git_show(commit: &str, path: &str) -> Result<String> {
|
||||
|
||||
Ok(String::from_utf8(output.stdout)?)
|
||||
}
|
||||
|
||||
/// Verify the SSH signature on `commit` against the given org members and return
|
||||
/// the matching member. On any failure (unsigned, malformed, or unknown signer)
|
||||
/// this prints REJECT and calls `std::process::exit(1)`; it only returns on success.
|
||||
fn verify_org_signer(commit: &str, members: &OrgMembers) -> OrgMember {
|
||||
// Build a temp allowed-signers file from every current member's pubkey.
|
||||
let tmp = match tempfile::tempdir() {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
eprintln!("REJECT: org commit {commit} — cannot create tempdir: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
let allowed_path = tmp.path().join("allowed_signers");
|
||||
let mut allowed_body = String::new();
|
||||
for m in &members.members {
|
||||
allowed_body.push_str("relicario ");
|
||||
allowed_body.push_str(m.ed25519_pubkey.trim());
|
||||
allowed_body.push('\n');
|
||||
}
|
||||
if let Err(e) = fs::write(&allowed_path, &allowed_body) {
|
||||
eprintln!("REJECT: org commit {commit} — cannot write allowed_signers: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
// Run git verify-commit --raw with the allowed-signers file injected.
|
||||
let output = match Command::new("git")
|
||||
.args(["verify-commit", "--raw", commit])
|
||||
.env("GIT_CONFIG_COUNT", "1")
|
||||
.env("GIT_CONFIG_KEY_0", "gpg.ssh.allowedSignersFile")
|
||||
.env("GIT_CONFIG_VALUE_0", allowed_path.as_os_str())
|
||||
.output()
|
||||
{
|
||||
Ok(o) => o,
|
||||
Err(e) => {
|
||||
eprintln!("REJECT: org commit {commit} — git verify-commit failed to run: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
|
||||
// The org hook builds allowed_signers from EVERY current member, so a clean
|
||||
// `git verify-commit` exit IS the security gate: a non-zero exit means the
|
||||
// commit was unsigned, tampered, or signed by a non-member. Make that
|
||||
// property explicit rather than relying on the stderr regex alone (regex
|
||||
// output is fragile across git versions). The fingerprint parse + member
|
||||
// mapping below then identifies WHICH member signed.
|
||||
if !output.status.success() {
|
||||
eprintln!(
|
||||
"REJECT: org commit {commit} — signature did not verify against current members \
|
||||
(git verify-commit exit {}): {}",
|
||||
output.status.code().unwrap_or(-1),
|
||||
stderr.trim()
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
// Parse the SHA-256 fingerprint from stderr (same regex as verify_commit).
|
||||
let re = regex::Regex::new(r"key (SHA256:[A-Za-z0-9+/]+)").expect("static regex");
|
||||
let signing_fp = match re.captures(&stderr).and_then(|c| c.get(1)) {
|
||||
Some(m) => m.as_str().to_string(),
|
||||
None => {
|
||||
eprintln!(
|
||||
"REJECT: org commit {commit} — no valid signature found (stderr: {})",
|
||||
stderr.trim()
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
// Map fingerprint → member via relicario_core::fingerprint over each pubkey.
|
||||
for m in &members.members {
|
||||
if let Ok(fp) = relicario_core::fingerprint(&m.ed25519_pubkey) {
|
||||
if fp == signing_fp {
|
||||
return m.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
eprintln!(
|
||||
"REJECT: org commit {commit} — signer (fingerprint {signing_fp}) is not a current org member"
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
fn verify_org_commit(commit: &str) -> Result<()> {
|
||||
// Determine parent count from %P (space-separated parent SHAs; empty = root).
|
||||
let parents_out = Command::new("git")
|
||||
.args(["show", "-s", "--format=%P", commit])
|
||||
.output()
|
||||
.context("git show parents")?;
|
||||
let parents_line = String::from_utf8_lossy(&parents_out.stdout);
|
||||
let parents: Vec<&str> = parents_line.split_whitespace().collect();
|
||||
|
||||
// Merge commits are rejected. Org repos are linear (CLI uses pull --rebase).
|
||||
if parents.len() > 1 {
|
||||
eprintln!(
|
||||
"REJECT: org commit {commit} — merge commits are not allowed in org vaults \
|
||||
({} parents); rebase instead",
|
||||
parents.len()
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
let is_root = parents.is_empty();
|
||||
|
||||
// Load members.json AS OF THIS COMMIT so the genesis commit can authorize itself.
|
||||
let members_json = match git_show(commit, "members.json") {
|
||||
Ok(s) => s,
|
||||
Err(_) => {
|
||||
if is_root {
|
||||
eprintln!("OK: org commit {commit} (root bootstrap - no members.json yet)");
|
||||
return Ok(());
|
||||
}
|
||||
eprintln!("REJECT: org commit {commit} — members.json missing from non-root commit");
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
let members: OrgMembers =
|
||||
serde_json::from_str(&members_json).context("parse members.json")?;
|
||||
if members.members.is_empty() {
|
||||
if is_root {
|
||||
eprintln!("OK: org commit {commit} (root bootstrap - empty member list)");
|
||||
return Ok(());
|
||||
}
|
||||
eprintln!("REJECT: org commit {commit} — members.json has no members");
|
||||
std::process::exit(1);
|
||||
}
|
||||
members
|
||||
.validate()
|
||||
.map_err(|e| anyhow::anyhow!("members.json invalid: {e}"))?;
|
||||
|
||||
// Verify the signature and resolve the signing member (exits on failure).
|
||||
let signer = verify_org_signer(commit, &members);
|
||||
|
||||
// Enumerate changed paths. Root has no parent to diff, so use ls-tree.
|
||||
let changed_paths: Vec<String> = if is_root {
|
||||
let out = Command::new("git")
|
||||
.args(["ls-tree", "-r", "--name-only", commit])
|
||||
.output()
|
||||
.context("git ls-tree")?;
|
||||
String::from_utf8_lossy(&out.stdout)
|
||||
.lines()
|
||||
.map(|l| l.trim().to_string())
|
||||
.filter(|l| !l.is_empty())
|
||||
.collect()
|
||||
} else {
|
||||
let out = Command::new("git")
|
||||
.args(["diff-tree", "--no-commit-id", "-r", "--name-only", commit])
|
||||
.output()
|
||||
.context("git diff-tree")?;
|
||||
String::from_utf8_lossy(&out.stdout)
|
||||
.lines()
|
||||
.map(|l| l.trim().to_string())
|
||||
.filter(|l| !l.is_empty())
|
||||
.collect()
|
||||
};
|
||||
|
||||
// Authorize each changed path against the signing member's role/grants.
|
||||
// collections.json (as of this commit) is loaded lazily on the first item
|
||||
// path, for the L5 slug-existence check.
|
||||
let mut collection_slugs: Option<Vec<String>> = None;
|
||||
for path in &changed_paths {
|
||||
match classify_path(path) {
|
||||
PathClass::Rejected(why) => {
|
||||
eprintln!("REJECT: org commit {commit} — invalid path `{path}`: {why}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
PathClass::Protected => {
|
||||
if !signer.role.can_manage_members() {
|
||||
eprintln!(
|
||||
"REJECT: org commit {commit} — member '{}' (role {:?}) may not write protected file `{path}`",
|
||||
signer.display_name, signer.role
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
// Privilege-escalation gate: only an Owner may INTRODUCE or
|
||||
// ELEVATE an owner/admin. An Admin may write members.json but
|
||||
// must not mint owners/admins server-side (spec §148/158/271).
|
||||
if path == "members.json" {
|
||||
enforce_owner_only_elevation(commit, is_root, &members, &signer);
|
||||
}
|
||||
}
|
||||
PathClass::Item { collection } => {
|
||||
// The signing member must hold an explicit grant for the slug.
|
||||
if !signer.collections.iter().any(|c| c == &collection) {
|
||||
eprintln!(
|
||||
"REJECT: org commit {commit} — member '{}' lacks a grant for collection `{collection}` (path `{path}`)",
|
||||
signer.display_name
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
// Slug-existence (L5): the collection must exist in
|
||||
// collections.json AS OF THIS COMMIT. A write into a
|
||||
// granted-but-deleted (or never-created) collection is rejected.
|
||||
let known = collection_slugs.get_or_insert_with(|| {
|
||||
git_show(commit, "collections.json")
|
||||
.ok()
|
||||
.and_then(|s| serde_json::from_str::<OrgCollections>(&s).ok())
|
||||
.map(|c| c.collections.into_iter().map(|d| d.slug).collect::<Vec<_>>())
|
||||
.unwrap_or_default()
|
||||
});
|
||||
if !known.iter().any(|s| s == &collection) {
|
||||
eprintln!(
|
||||
"REJECT: org commit {commit} — item write to collection `{collection}` whose slug is absent from collections.json (path `{path}`)"
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
PathClass::Unrestricted => {
|
||||
// keys/<id>.enc, manifest.enc, etc. — signature check already passed.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Schema-version monotonicity for the three JSON files (Task C2).
|
||||
enforce_schema_monotonicity(commit, is_root, &changed_paths)?;
|
||||
|
||||
eprintln!(
|
||||
"OK: org commit {commit} verified — signed by '{}' ({:?}), {} path(s) authorized",
|
||||
signer.display_name,
|
||||
signer.role,
|
||||
changed_paths.len()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Reject the commit unless every newly-introduced or elevated owner/admin is
|
||||
/// authorized. The signer's AUTHORITY is their role in the PARENT state — the role
|
||||
/// they held BEFORE this commit — NOT the role this commit may grant them. Reading
|
||||
/// `signer.role` (which is parsed from the post-change members.json) would let an
|
||||
/// admin self-promote to owner and then pass this very gate with the owner role
|
||||
/// they are minting — the exact escalation H-C1 exists to stop. We diff the new
|
||||
/// members.json against the parent's by member_id and require an owner-authority
|
||||
/// signer for any member that BECOMES owner/admin (new entry, or a role elevated
|
||||
/// up to owner/admin). On genesis (root) the sole bootstrap owner is allowed.
|
||||
///
|
||||
/// `git_show_parent` is defined alongside `enforce_schema_monotonicity` below.
|
||||
fn enforce_owner_only_elevation(
|
||||
commit: &str,
|
||||
is_root: bool,
|
||||
new_members: &OrgMembers,
|
||||
signer: &OrgMember,
|
||||
) {
|
||||
let is_privileged = |r: OrgRole| matches!(r, OrgRole::Owner | OrgRole::Admin);
|
||||
|
||||
// Genesis: the bootstrap commit introduces the sole owner; allow it.
|
||||
if is_root {
|
||||
return;
|
||||
}
|
||||
|
||||
// Parent baseline. If members.json did not exist in the parent, every
|
||||
// privileged member here is "new" and must be owner-signed.
|
||||
let parent_members: Vec<(String, OrgRole)> = match git_show_parent(commit, "members.json") {
|
||||
Ok(s) => serde_json::from_str::<OrgMembers>(&s)
|
||||
.map(|m| {
|
||||
m.members
|
||||
.into_iter()
|
||||
.map(|m| (m.member_id.0, m.role))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
Err(_) => Vec::new(),
|
||||
};
|
||||
let parent_role = |id: &str| -> Option<OrgRole> {
|
||||
parent_members.iter().find(|(mid, _)| mid == id).map(|(_, r)| *r)
|
||||
};
|
||||
|
||||
// The signer's authority = their PARENT role. A member absent from the parent
|
||||
// (brand new) has no prior authority and cannot mint owners/admins.
|
||||
let signer_parent = parent_role(signer.member_id.as_str());
|
||||
let signer_may_manage_owners = signer_parent.map_or(false, |r| r.can_manage_owners());
|
||||
|
||||
for m in &new_members.members {
|
||||
if !is_privileged(m.role) {
|
||||
continue;
|
||||
}
|
||||
// Skip ONLY if the role is unchanged from the parent (a no-op same-role
|
||||
// entry). Any CHANGE into a privileged role — a new privileged member,
|
||||
// Member→Admin/Owner, or Admin→Owner — must be owner-signed.
|
||||
if parent_role(m.member_id.as_str()) == Some(m.role) {
|
||||
continue;
|
||||
}
|
||||
// A new owner/admin, or a member elevated to owner/admin → owner-only,
|
||||
// judged by the signer's PRE-commit authority.
|
||||
if !signer_may_manage_owners {
|
||||
eprintln!(
|
||||
"REJECT: org commit {commit} — member '{}' (parent role {:?}) may not introduce or \
|
||||
elevate owner/admin '{}' to {:?}; only an owner may",
|
||||
signer.display_name, signer_parent, m.display_name, m.role
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn generate_org_hook() -> Result<()> {
|
||||
print!(
|
||||
r#"#!/bin/bash
|
||||
# Relicario org pre-receive hook -- verify signatures + role/path authorization
|
||||
|
||||
while read oldrev newrev refname; do
|
||||
[ "$newrev" = "0000000000000000000000000000000000000000" ] && continue
|
||||
|
||||
if [ "$oldrev" = "0000000000000000000000000000000000000000" ]; then
|
||||
commits=$(git rev-list "$newrev")
|
||||
else
|
||||
commits=$(git rev-list "$oldrev..$newrev")
|
||||
fi
|
||||
|
||||
for commit in $commits; do
|
||||
relicario-server verify-org-commit "$commit" || exit 1
|
||||
done
|
||||
done
|
||||
"#
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// For each protected JSON file changed in this commit, ensure schema_version did
|
||||
/// not decrease vs the parent commit, and re-validate collections.json structure.
|
||||
fn enforce_schema_monotonicity(
|
||||
commit: &str,
|
||||
is_root: bool,
|
||||
changed_paths: &[String],
|
||||
) -> Result<()> {
|
||||
const VERSIONED: [&str; 3] = ["members.json", "collections.json", "org.json"];
|
||||
|
||||
for file in VERSIONED {
|
||||
if !changed_paths.iter().any(|p| p == file) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// A deletion of a protected file is not allowed.
|
||||
let new_content = match git_show(commit, file) {
|
||||
Ok(s) => s,
|
||||
Err(_) => {
|
||||
eprintln!(
|
||||
"REJECT: org commit {commit} — protected file `{file}` was deleted; \
|
||||
org vaults never delete {file}"
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
let new_version = match extract_schema_version(&new_content) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
eprintln!("REJECT: org commit {commit} — `{file}` invalid: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
// collections.json structural validation.
|
||||
if file == "collections.json" {
|
||||
match serde_json::from_str::<relicario_core::org::OrgCollections>(&new_content) {
|
||||
Ok(c) => {
|
||||
if let Err(e) = c.validate() {
|
||||
eprintln!("REJECT: org commit {commit} — collections.json invalid: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("REJECT: org commit {commit} — collections.json parse error: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// On the root commit there is no parent baseline; any starting version is fine.
|
||||
if is_root {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Parent version: if the file did not exist in the parent (newly added),
|
||||
// there is no prior version to regress against — accept.
|
||||
if let Ok(old_content) = git_show_parent(commit, file) {
|
||||
let old_version = match extract_schema_version(&old_content) {
|
||||
Ok(v) => v,
|
||||
Err(_) => {
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if new_version < old_version {
|
||||
eprintln!(
|
||||
"REJECT: org commit {commit} — `{file}` schema_version decreased \
|
||||
({old_version} -> {new_version})"
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read a file from a commit's FIRST PARENT tree: `git show {commit}^:{path}`.
|
||||
fn git_show_parent(commit: &str, path: &str) -> Result<String> {
|
||||
let output = Command::new("git")
|
||||
.args(["show", &format!("{}^:{}", commit, path)])
|
||||
.output()
|
||||
.context("git show parent")?;
|
||||
if !output.status.success() {
|
||||
anyhow::bail!("git show {}^:{} failed", commit, path);
|
||||
}
|
||||
Ok(String::from_utf8(output.stdout)?)
|
||||
}
|
||||
|
||||
81
crates/relicario-server/tests/org_hook.rs
Normal file
81
crates/relicario-server/tests/org_hook.rs
Normal file
@@ -0,0 +1,81 @@
|
||||
// Integration tests for relicario-server org-hook path classification.
|
||||
|
||||
use relicario_server::{classify_path, PathClass};
|
||||
|
||||
#[test]
|
||||
fn protected_files_are_classified_protected() {
|
||||
assert_eq!(classify_path("members.json"), PathClass::Protected);
|
||||
assert_eq!(classify_path("collections.json"), PathClass::Protected);
|
||||
assert_eq!(classify_path("org.json"), PathClass::Protected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn item_write_yields_collection_slug() {
|
||||
assert_eq!(
|
||||
classify_path("items/prod/a1b2c3d4e5f6a1b2.enc"),
|
||||
PathClass::Item { collection: "prod".to_string() }
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn item_write_nested_slug_is_rejected() {
|
||||
// Slugs cannot contain '/', so a path with extra segments is malformed → Rejected.
|
||||
assert_eq!(
|
||||
classify_path("items/prod/sub/x.enc"),
|
||||
PathClass::Rejected("items path must be items/<slug>/<id>.enc".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn key_blobs_and_manifest_are_unrestricted() {
|
||||
// keys/<id>.enc and manifest.enc are written by org operations; the SIGNATURE
|
||||
// check (every commit must be signed by a current member) is the gate for them.
|
||||
assert_eq!(classify_path("keys/a1b2c3d4e5f6a1b2.enc"), PathClass::Unrestricted);
|
||||
assert_eq!(classify_path("manifest.enc"), PathClass::Unrestricted);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn items_without_slug_segment_are_rejected() {
|
||||
// Flat items/<id>.enc (the OLD, now-removed layout) is no longer valid.
|
||||
assert_eq!(
|
||||
classify_path("items/a1b2c3d4e5f6a1b2.enc"),
|
||||
PathClass::Rejected("items path must be items/<slug>/<id>.enc".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_slug_segment_is_rejected() {
|
||||
assert_eq!(
|
||||
classify_path("items//x.enc"),
|
||||
PathClass::Rejected("empty collection slug in items path".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dotted_slug_is_rejected() {
|
||||
// Defense-in-depth (mirrors OrgCollections::validate): a slug containing '.'
|
||||
// — e.g. a ".."/"." path-traversal attempt — is rejected.
|
||||
assert_eq!(
|
||||
classify_path("items/../x.enc"),
|
||||
PathClass::Rejected("invalid collection slug: \"..\"".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
use relicario_server::extract_schema_version;
|
||||
|
||||
#[test]
|
||||
fn extract_schema_version_reads_field() {
|
||||
let json = r#"{ "schema_version": 3, "members": [] }"#;
|
||||
assert_eq!(extract_schema_version(json).unwrap(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_schema_version_errors_on_missing_field() {
|
||||
let json = r#"{ "members": [] }"#;
|
||||
assert!(extract_schema_version(json).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_schema_version_errors_on_garbage() {
|
||||
assert!(extract_schema_version("not json").is_err());
|
||||
}
|
||||
229
crates/relicario-server/tests/org_hook_signed.rs
Normal file
229
crates/relicario-server/tests/org_hook_signed.rs
Normal file
@@ -0,0 +1,229 @@
|
||||
//! Integration tests for `relicario-server verify-org-commit` privilege gating.
|
||||
//!
|
||||
//! H-C1: only an Owner may introduce or elevate an owner/admin. An Admin who
|
||||
//! writes members.json must not be able to mint owners/admins.
|
||||
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
use assert_cmd::Command as AssertCommand;
|
||||
use predicates::prelude::*;
|
||||
use relicario_core::device::generate_keypair;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn write_keypair(dir: &Path, name: &str) -> (PathBuf, String) {
|
||||
let (priv_pem, pub_line) = generate_keypair().expect("generate keypair");
|
||||
let priv_path = dir.join(format!("{name}.key"));
|
||||
fs::write(&priv_path, priv_pem.as_str()).unwrap();
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
fs::set_permissions(&priv_path, fs::Permissions::from_mode(0o600)).unwrap();
|
||||
}
|
||||
(priv_path, pub_line)
|
||||
}
|
||||
|
||||
fn git(repo: &Path, args: &[&str]) {
|
||||
let status = Command::new("git").current_dir(repo).args(args).status().unwrap();
|
||||
assert!(status.success(), "git {args:?} failed");
|
||||
}
|
||||
|
||||
/// members.json content with two members; `member_id`s are fixed 16-hex.
|
||||
fn members_json(owner_pub: &str, admin_pub: &str, admin_role: &str) -> String {
|
||||
format!(
|
||||
r#"{{
|
||||
"schema_version": 1,
|
||||
"members": [
|
||||
{{ "member_id": "1111111111111111", "display_name": "Owner", "role": "owner",
|
||||
"ed25519_pubkey": "{}", "collections": [], "added_at": 0, "added_by": "1111111111111111" }},
|
||||
{{ "member_id": "2222222222222222", "display_name": "Admin", "role": "{admin_role}",
|
||||
"ed25519_pubkey": "{}", "collections": [], "added_at": 0, "added_by": "1111111111111111" }}
|
||||
]
|
||||
}}"#,
|
||||
owner_pub.trim(),
|
||||
admin_pub.trim()
|
||||
)
|
||||
}
|
||||
|
||||
/// Stage members.json, sign the commit with `signing_key`, return its SHA.
|
||||
fn signed_members_commit(
|
||||
repo: &Path,
|
||||
signing_key: &Path,
|
||||
allowed: &Path,
|
||||
msg: &str,
|
||||
content: &str,
|
||||
) -> String {
|
||||
fs::write(repo.join("members.json"), content).unwrap();
|
||||
git(repo, &["add", "members.json"]);
|
||||
let status = Command::new("git")
|
||||
.current_dir(repo)
|
||||
.args([
|
||||
"-c", "gpg.format=ssh",
|
||||
"-c", &format!("user.signingkey={}", signing_key.display()),
|
||||
"-c", &format!("gpg.ssh.allowedSignersFile={}", allowed.display()),
|
||||
"commit", "-S", "-q", "-m", msg,
|
||||
])
|
||||
.status()
|
||||
.unwrap();
|
||||
assert!(status.success());
|
||||
let out = Command::new("git").current_dir(repo).args(["rev-parse", "HEAD"]).output().unwrap();
|
||||
String::from_utf8(out.stdout).unwrap().trim().to_string()
|
||||
}
|
||||
|
||||
/// Set up an org repo whose root commit (signed by the owner) registers an
|
||||
/// owner + an admin. Returns (repo tmp, owner priv, admin priv, allowed file).
|
||||
fn bootstrap() -> (TempDir, PathBuf, PathBuf, PathBuf) {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let repo = tmp.path();
|
||||
git(repo, &["init", "-q", "-b", "main"]);
|
||||
git(repo, &["config", "user.email", "t@t"]);
|
||||
git(repo, &["config", "user.name", "t"]);
|
||||
|
||||
let (owner_priv, owner_pub) = write_keypair(repo, "owner");
|
||||
let (admin_priv, admin_pub) = write_keypair(repo, "admin");
|
||||
|
||||
let allowed = repo.join("allowed_signers");
|
||||
fs::write(
|
||||
&allowed,
|
||||
format!("relicario {}\nrelicario {}\n", owner_pub.trim(), admin_pub.trim()),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Genesis: owner registers both members (admin starts as `admin`).
|
||||
let genesis = members_json(&owner_pub, &admin_pub, "admin");
|
||||
signed_members_commit(repo, &owner_priv, &allowed, "org-init", &genesis);
|
||||
|
||||
// also write org.json + collections.json so later commits are well-formed
|
||||
fs::write(repo.join("org.json"),
|
||||
r#"{"schema_version":1,"org_id":"abc0abc0abc0abc0","display_name":"Acme","created_at":0}"#).unwrap();
|
||||
fs::write(repo.join("collections.json"), r#"{"schema_version":1,"collections":[]}"#).unwrap();
|
||||
git(repo, &["add", "org.json", "collections.json"]);
|
||||
// sign this housekeeping commit with the owner too
|
||||
let _ = signed_members_commit(repo, &owner_priv, &allowed, "scaffold",
|
||||
&members_json(&owner_pub, &admin_pub, "admin"));
|
||||
|
||||
(tmp, owner_priv, admin_priv, allowed)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn admin_self_promote_to_owner_is_rejected() {
|
||||
let (tmp, owner_priv, admin_priv, allowed) = bootstrap();
|
||||
let repo = tmp.path();
|
||||
let owner_pub = fs::read_to_string(repo.join("allowed_signers")).unwrap();
|
||||
// Reconstruct pubkeys from the allowed_signers file (two "relicario <pub>" lines).
|
||||
let lines: Vec<String> = owner_pub.lines()
|
||||
.map(|l| l.trim_start_matches("relicario ").to_string()).collect();
|
||||
let (op, ap) = (lines[0].clone(), lines[1].clone());
|
||||
let _ = owner_priv;
|
||||
|
||||
// Admin signs a members.json that elevates THEMSELVES to owner.
|
||||
let escalated = members_json(&op, &ap, "owner");
|
||||
let sha = signed_members_commit(repo, &admin_priv, &allowed, "self-promote", &escalated);
|
||||
|
||||
AssertCommand::cargo_bin("relicario-server")
|
||||
.unwrap()
|
||||
.current_dir(repo)
|
||||
.args(["verify-org-commit", &sha])
|
||||
.assert()
|
||||
.failure()
|
||||
.stderr(predicate::str::contains("only an owner"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn owner_promoting_an_admin_is_accepted() {
|
||||
let (tmp, owner_priv, _admin_priv, allowed) = bootstrap();
|
||||
let repo = tmp.path();
|
||||
let allowed_body = fs::read_to_string(repo.join("allowed_signers")).unwrap();
|
||||
let lines: Vec<String> = allowed_body.lines()
|
||||
.map(|l| l.trim_start_matches("relicario ").to_string()).collect();
|
||||
let (op, ap) = (lines[0].clone(), lines[1].clone());
|
||||
|
||||
// Owner signs a members.json that elevates the admin to owner — allowed.
|
||||
let promoted = members_json(&op, &ap, "owner");
|
||||
let sha = signed_members_commit(repo, &owner_priv, &allowed, "promote-admin", &promoted);
|
||||
|
||||
AssertCommand::cargo_bin("relicario-server")
|
||||
.unwrap()
|
||||
.current_dir(repo)
|
||||
.args(["verify-org-commit", &sha])
|
||||
.assert()
|
||||
.success();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commit_signed_by_non_member_is_rejected() {
|
||||
// A commit signed by a key that is NOT in members.json must be rejected:
|
||||
// verify_org_signer rebuilds allowed_signers from the current members only,
|
||||
// so a non-member signature fails `git verify-commit`.
|
||||
let (tmp, _owner_priv, _admin_priv, allowed) = bootstrap();
|
||||
let repo = tmp.path();
|
||||
|
||||
// A stranger key, never registered as a member.
|
||||
let (stranger_priv, _stranger_pub) = write_keypair(repo, "stranger");
|
||||
|
||||
// Stranger signs a commit touching an UNRESTRICTED file (members.json stays
|
||||
// owner+admin, so allowed_signers excludes the stranger).
|
||||
fs::write(repo.join("manifest.enc"), b"\x02ciphertext").unwrap();
|
||||
git(repo, &["add", "manifest.enc"]);
|
||||
let status = Command::new("git")
|
||||
.current_dir(repo)
|
||||
.args([
|
||||
"-c", "gpg.format=ssh",
|
||||
"-c", &format!("user.signingkey={}", stranger_priv.display()),
|
||||
"-c", &format!("gpg.ssh.allowedSignersFile={}", allowed.display()),
|
||||
"commit", "-S", "-q", "-m", "stranger-write",
|
||||
])
|
||||
.status()
|
||||
.unwrap();
|
||||
assert!(status.success());
|
||||
let out = Command::new("git")
|
||||
.current_dir(repo)
|
||||
.args(["rev-parse", "HEAD"])
|
||||
.output()
|
||||
.unwrap();
|
||||
let sha = String::from_utf8(out.stdout).unwrap().trim().to_string();
|
||||
|
||||
AssertCommand::cargo_bin("relicario-server")
|
||||
.unwrap()
|
||||
.current_dir(repo)
|
||||
.args(["verify-org-commit", &sha])
|
||||
.assert()
|
||||
.failure()
|
||||
.stderr(predicate::str::contains("REJECT"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn genesis_bootstrap_with_sole_owner_is_accepted() {
|
||||
// A root (parent-less) commit registering the sole owner, signed by that
|
||||
// owner, is the genesis bootstrap and must be accepted.
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let repo = tmp.path();
|
||||
git(repo, &["init", "-q", "-b", "main"]);
|
||||
git(repo, &["config", "user.email", "t@t"]);
|
||||
git(repo, &["config", "user.name", "t"]);
|
||||
|
||||
let (owner_priv, owner_pub) = write_keypair(repo, "owner");
|
||||
let allowed = repo.join("allowed_signers");
|
||||
fs::write(&allowed, format!("relicario {}\n", owner_pub.trim())).unwrap();
|
||||
|
||||
let sole_owner = format!(
|
||||
r#"{{
|
||||
"schema_version": 1,
|
||||
"members": [
|
||||
{{ "member_id": "1111111111111111", "display_name": "Owner", "role": "owner",
|
||||
"ed25519_pubkey": "{}", "collections": [], "added_at": 0, "added_by": "1111111111111111" }}
|
||||
]
|
||||
}}"#,
|
||||
owner_pub.trim()
|
||||
);
|
||||
// First commit in a fresh repo → root (is_root == true).
|
||||
let sha = signed_members_commit(repo, &owner_priv, &allowed, "org-init", &sole_owner);
|
||||
|
||||
AssertCommand::cargo_bin("relicario-server")
|
||||
.unwrap()
|
||||
.current_dir(repo)
|
||||
.args(["verify-org-commit", &sha])
|
||||
.assert()
|
||||
.success();
|
||||
}
|
||||
151
docs/CRYPTO.md
151
docs/CRYPTO.md
@@ -123,6 +123,157 @@ master_key ────────►│ XChaCha20 │──────
|
||||
└──────────────────┘
|
||||
```
|
||||
|
||||
## Org-key ECIES wrap/unwrap
|
||||
|
||||
Org vaults use a different key-derivation path than personal vaults. There is no
|
||||
passphrase, no reference JPEG, and no Argon2id involved. Instead, each org has a
|
||||
single random **org master key** that is wrapped per-member using X25519 ECIES and
|
||||
stored as an opaque blob in `keys/<member-id>.enc` inside the org repo.
|
||||
|
||||
### Org master key
|
||||
|
||||
```
|
||||
generate_org_key() (org.rs:230)
|
||||
→ OsRng → 256-bit random
|
||||
→ Zeroizing<[u8; 32]> (held in memory; never written in the clear)
|
||||
```
|
||||
|
||||
One org key per org. It is re-generated on every `org rotate-key` operation.
|
||||
|
||||
### ed25519 → X25519 conversion
|
||||
|
||||
Each Relicario device holds an ed25519 signing key. To participate in ECIES the
|
||||
ed25519 key pair must be mapped to X25519:
|
||||
|
||||
```
|
||||
Recipient public key (for wrap):
|
||||
ed25519 VerifyingKey
|
||||
→ .to_montgomery() (birational Montgomery map, ed25519_dalek)
|
||||
→ X25519 PublicKey
|
||||
|
||||
Recipient secret key (for unwrap):
|
||||
ed25519 seed (32 bytes)
|
||||
→ SHA-512(seed)[..32] (org.rs:241–242)
|
||||
→ RFC 7748 clamp:
|
||||
scalar[0] &= 248
|
||||
scalar[31] &= 127
|
||||
scalar[31] |= 64
|
||||
→ x25519_dalek::StaticSecret
|
||||
```
|
||||
|
||||
The RFC 7748 clamp and the `to_montgomery()` birational map are the standard
|
||||
construction; a pinned RFC 8032 known-answer vector is verified in the unit tests
|
||||
inside `org.rs`.
|
||||
|
||||
### Wrap flow (one blob per member)
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────┐
|
||||
│ wrap_org_key() │ (org.rs:265)
|
||||
│ │
|
||||
org_key ──────────►│ EphemeralSecret::random (OsRng) │
|
||||
│ ephemeral_pk = PublicKey::from(eph) │
|
||||
│ │
|
||||
recipient_pk ─────►│ DH: eph_sk.diffie_hellman(rec_pk) │
|
||||
│ → dh_shared (32 bytes) │
|
||||
│ │
|
||||
│ kdf_input = dh_shared │
|
||||
│ ‖ ephemeral_pk (32 B) │ (org.rs:278–281)
|
||||
│ ‖ recipient_pk (32 B) │
|
||||
│ wrap_key = SHA-256(kdf_input) │
|
||||
│ (kdf_input in Zeroizing<Vec<u8>>) │
|
||||
│ (wrap_key in Zeroizing<[u8;32]>) │
|
||||
│ │
|
||||
│ encrypted = crate::crypto::encrypt │
|
||||
│ (wrap_key, org_key) │
|
||||
│ → version(1) ‖ nonce(24) ‖ ct+tag │
|
||||
│ │
|
||||
│ output: ephemeral_pk(32) │ (org.rs:264)
|
||||
│ ‖ version(1) │
|
||||
│ ‖ nonce(24) │
|
||||
│ ‖ ciphertext + tag │
|
||||
└──────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
keys/<member-id>.enc (in org repo)
|
||||
```
|
||||
|
||||
### Unwrap flow
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────┐
|
||||
│ unwrap_org_key() │ (org.rs:299)
|
||||
│ │
|
||||
wrapped blob ─────►│ split: ephemeral_pk(32) + rest │
|
||||
│ │
|
||||
ed25519_seed ─────►│ ed25519_seed_to_x25519_secret() │
|
||||
│ → recipient_sk + recipient_pk │
|
||||
│ │
|
||||
│ DH: recipient_sk.diffie_hellman(eph)│
|
||||
│ → dh_shared │
|
||||
│ │
|
||||
│ kdf_input + SHA-256 → wrap_key │
|
||||
│ (same domain-separated KDF as wrap) │
|
||||
│ │
|
||||
│ plaintext = crate::crypto::decrypt │
|
||||
│ (wrap_key, rest) │
|
||||
│ → Zeroizing<[u8;32]> org_key │
|
||||
└──────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Key distinction: no Argon2id
|
||||
|
||||
Unlike the personal vault, **org crypto bypasses Argon2id entirely**:
|
||||
|
||||
| | Personal vault | Org vault |
|
||||
|---|---|---|
|
||||
| Key origin | Argon2id(passphrase ‖ image_secret, salt) | OsRng → 256-bit random |
|
||||
| Key transport | Embedded in reference JPEG (stego) | X25519 ECIES wrap blob |
|
||||
| AEAD primitive | XChaCha20-Poly1305 (`crate::crypto::encrypt`) | Same primitive (delegated) |
|
||||
| KDF for wrap key | Argon2id | SHA-256(DH ‖ eph_pk ‖ rec_pk) |
|
||||
|
||||
The inner AEAD (`crate::crypto::encrypt` / `decrypt`) is **not re-implemented** in
|
||||
the org module — it is called directly, so org item blobs share the identical
|
||||
`version(1) ‖ nonce(24) ‖ ct+tag` wire format (`VERSION_BYTE = 0x02`,
|
||||
`crates/relicario-core/src/crypto.rs:59`).
|
||||
|
||||
### Zeroize discipline
|
||||
|
||||
All intermediates that carry key material are dropped through `Zeroizing`:
|
||||
|
||||
- `org_key` — `Zeroizing<[u8; 32]>` everywhere it is passed
|
||||
- `kdf_input` — `Zeroizing<Vec<u8>>` (org.rs:278)
|
||||
- `wrap_key` — `Zeroizing<[u8; 32]>`
|
||||
- decrypt `plaintext` in `unwrap_org_key` — `Zeroizing<Vec<u8>>`
|
||||
|
||||
### Key rotation and re-encryption
|
||||
|
||||
`org rotate-key` (`crates/relicario-cli/src/commands/org.rs:332`) does more than
|
||||
generate a fresh org key:
|
||||
|
||||
```
|
||||
run_rotate_key()
|
||||
1. git pull --rebase (detect concurrent rotation → abort if non-fast-forward)
|
||||
2. generate_org_key() → new_org_key
|
||||
3. wrap_org_key(new_org_key, member_pk) for every current member
|
||||
→ overwrites keys/<member-id>.enc
|
||||
4. re-encrypt every items/<slug>/<id>.enc blob under new_org_key
|
||||
5. re-encrypt manifest.enc under new_org_key
|
||||
6. git add + git commit via org_git_run (signed; Relicario-Action: key-rotate)
|
||||
```
|
||||
|
||||
`rotate-key` pulls (`--rebase`) at the start to pick up concurrent changes and
|
||||
abort on a conflicting concurrent rotation, then commits locally; it does **not**
|
||||
push. Publishing the rotation to the remote is a separate step (the normal git
|
||||
sync path), the same way personal-vault mutations commit locally and sync later.
|
||||
|
||||
Re-encryption of every item blob (step 4) is deliberate: a removed member who holds
|
||||
a local clone of the repo cannot decrypt any item written after the rotation, because
|
||||
those blobs are sealed under a key they never received. Without re-encryption, all
|
||||
pre-rotation blobs would remain readable to the former member indefinitely.
|
||||
|
||||
> **TODO (pending Dev-B B9–B14):** item-CRUD commands (`org add`/`get`/`list`/`edit`/`rm`/`restore`/`purge`) and the final `Commands::Org` wiring in `main.rs` are not yet merged.
|
||||
|
||||
## imgsecret DCT Embedding
|
||||
|
||||
```
|
||||
|
||||
@@ -71,6 +71,60 @@ An empty array (`[]`) puts the pre-receive hook in bootstrap mode (all pushes ac
|
||||
|
||||
Commits by `public_key` at or after `revoked_at` (Unix seconds) are rejected by the pre-receive hook. Commits before `revoked_at` remain valid (they were authorized at the time).
|
||||
|
||||
## Org vault repo formats
|
||||
|
||||
The org vault is a **separate git repository** alongside the personal vault. It is not nested inside `.relicario/`. Its layout:
|
||||
|
||||
```
|
||||
org.json # OrgMeta (schema_version, org_id, display_name, created_at)
|
||||
members.json # PUBLIC/unencrypted member directory
|
||||
collections.json # collection definitions
|
||||
keys/<member-id>.enc # org master key wrapped to that member's device key
|
||||
manifest.enc # OrgManifest (schema_version 1, per-member-filtered)
|
||||
items/<collection-slug>/<item-id>.enc # collection-scoped item blobs
|
||||
```
|
||||
|
||||
### `org.json` — OrgMeta
|
||||
|
||||
Unencrypted JSON (`OrgMeta`, `org.rs:164`). `schema_version: 1` (`org.rs:174`). Fields: `schema_version`, `org_id`, `display_name`, `created_at` (Unix seconds).
|
||||
|
||||
### `members.json` — OrgMembers
|
||||
|
||||
Unencrypted JSON array of `OrgMember` records (`org.rs:72`); container type `OrgMembers` carries `schema_version: 1` (`org.rs:93`). Per-member fields: `member_id` (16 lowercase hex chars), `display_name`, `role` (one of `owner | admin | member`), `ed25519_pubkey` (OpenSSH wire string), `collections` (array of granted slug strings), `added_at`, `added_by`. Roles are not secrets — authorization to read this file is not required to verify signatures.
|
||||
|
||||
### `collections.json` — OrgCollections
|
||||
|
||||
Unencrypted JSON; `schema_version: 1` (`org.rs:138`). Contains a list of `CollectionDef` records (`org.rs:123`). Validation (`org.rs:145`) rejects slugs that are empty, contain `/`, or equal `.`.
|
||||
|
||||
### `keys/<member-id>.enc` — wrapped org master key
|
||||
|
||||
Binary blob; NOT a standard `.enc` blob. Layout (`org.rs:264`):
|
||||
|
||||
```
|
||||
┌──────────────────────────┬─────────┬────────┬──────────────────────┐
|
||||
│ ephemeral_x25519_pubkey │ version │ nonce │ ciphertext + tag │
|
||||
│ 32 bytes │ 1 byte │24 bytes│ N + 16 bytes │
|
||||
└──────────────────────────┴─────────┴────────┴──────────────────────┘
|
||||
```
|
||||
|
||||
- The wrapping key is `SHA-256(dh_shared || ephemeral_pubkey || recipient_pubkey)` (`org.rs:278–281`), held in `Zeroizing<Vec<u8>>`.
|
||||
- The inner AEAD (`version || nonce || ciphertext+tag`) is produced by `crate::crypto::encrypt` — the same XChaCha20-Poly1305 framing used for personal `.enc` blobs (see **Encrypted blob** above). `VERSION_BYTE = 0x02` applies here too.
|
||||
- The X25519 private scalar is derived from the device ed25519 seed via `SHA-512(seed)[..32]` with RFC 7748 clamping (`org.rs:242`). Argon2id is **not** involved — the wrapping key is derived entirely from the X25519 DH exchange.
|
||||
|
||||
### `manifest.enc` — OrgManifest
|
||||
|
||||
Encrypted with the org master key using `crypto::encrypt` (standard `.enc` framing). Decrypts to `OrgManifest` JSON (`org.rs:199`); `schema_version: 1` (`org.rs:206`). Each `OrgManifestEntry` (`org.rs:185`) carries: `id`, `type`, `title`, `tags`, `modified`, `trashed_at`, and a `collection` slug field. The `collection` field distinguishes this type from `ManifestEntry` in the personal vault.
|
||||
|
||||
Contrast with the personal vault manifest: `Manifest` uses `MANIFEST_SCHEMA_VERSION = 2` (`manifest.rs:12`) and `ManifestEntry` has no `collection` field. The two types are distinct and do not share a schema.
|
||||
|
||||
### `items/<collection-slug>/<item-id>.enc`
|
||||
|
||||
Standard `.enc` blob (see **Encrypted blob** above), encrypted under the org master key. The blob itself does **not** name its collection — the directory path segment carries the slug. This allows the pre-receive hook (`relicario-server`) to authorize a write by path segment without decrypting the blob.
|
||||
|
||||
**TODO (pending Dev-B B9–B14):** CLI commands for creating, reading, editing, and deleting org items (`org add` / `get` / `list` / `edit` / `rm` / `restore` / `purge`) are not yet wired in `main.rs`.
|
||||
|
||||
**TODO (extension follow-up):** extension UI for browsing and editing org vault items.
|
||||
|
||||
## Item IDs and Field IDs
|
||||
|
||||
| Kind | Length | Entropy | Source |
|
||||
|
||||
111
docs/SECURITY.md
111
docs/SECURITY.md
@@ -74,6 +74,117 @@ Without device authentication, access control is transport-layer only:
|
||||
|
||||
Device registration is optional but recommended for shared vaults.
|
||||
|
||||
## Org vault security
|
||||
|
||||
An org vault is a separate git repository alongside the personal vault. It
|
||||
uses ed25519 commit-signing and a server-side pre-receive hook to make
|
||||
least-privilege access control server-enforced, not advisory.
|
||||
|
||||
### Org device-key authentication
|
||||
|
||||
Every org member registers an ed25519 device key. The key appears in
|
||||
`members.json` as an OpenSSH public-key string alongside the member's role
|
||||
and collection grants. Fingerprint matching is done via
|
||||
`relicario_core::fingerprint`, which normalises the OpenSSH format so that
|
||||
whitespace and comment differences do not create phantom mismatches.
|
||||
|
||||
Org access requires two things at once: a wrapped key blob (`keys/<member-id>.enc`)
|
||||
and the device private key that can unwrap it. There is no org passphrase —
|
||||
removing a member's blob and rotating the org master key is sufficient to
|
||||
revoke access (see **Key rotation** below). Device keys are completely
|
||||
separate from the personal vault's KDF inputs; revoking org access does not
|
||||
affect the member's personal vault.
|
||||
|
||||
### Pre-receive hook enforcement
|
||||
|
||||
`relicario-server generate-org-hook` (`crates/relicario-server/src/main.rs:511`)
|
||||
emits a hook script that calls `relicario-server verify-org-commit` for
|
||||
every pushed commit. Unsigned or structurally invalid commits are rejected
|
||||
before they land.
|
||||
|
||||
`verify_org_commit` (`main.rs:286`) performs four checks in order:
|
||||
|
||||
1. **Signature verification** — a temporary `allowed_signers` file is
|
||||
constructed from the current `members.json`; `git verify-commit --raw`
|
||||
is run and the resulting SHA-256 fingerprint is matched back to a
|
||||
`members.json` entry. A commit not signed by a *current* member is
|
||||
rejected outright.
|
||||
|
||||
2. **Path-level write authorisation** — each modified path is classified by
|
||||
`classify_path` (`crates/relicario-server/src/lib.rs:19`) into
|
||||
`ProtectedJson` (owner/admin write only), `CollectionItem` (the
|
||||
`items/<slug>/…` prefix; write allowed only if the slug appears in the
|
||||
signer's `collections` grant array), or `Unrestricted`. The write is
|
||||
authorised if and only if the signer's role and grants satisfy the
|
||||
classification. Item blobs are authorised by the leading path segment
|
||||
alone — the ciphertext is never decrypted by the hook.
|
||||
|
||||
3. **Owner-only elevation guard** (`enforce_owner_only_elevation`,
|
||||
`main.rs:438`) — only a member whose *pre-commit* (parent) role is Owner
|
||||
may introduce a new member at Owner or Admin level, or promote an
|
||||
existing member to either. Checking the pre-commit role means an Admin
|
||||
cannot self-promote in the same commit that writes the escalated
|
||||
`members.json`; there is no epoch in which the transition is
|
||||
self-authorised.
|
||||
|
||||
4. **Schema monotonicity** (`enforce_schema_monotonicity`, `main.rs:521`)
|
||||
— `schema_version` values in org JSON containers may not decrease.
|
||||
Merge commits are rejected. A genesis commit (no parents) is allowed
|
||||
only when it is signed by the sole Owner it introduces.
|
||||
|
||||
### Key rotation
|
||||
|
||||
`relicario org rotate-key` generates a fresh 256-bit org master key,
|
||||
re-wraps it for every current member, and re-encrypts every
|
||||
`items/<slug>/<id>.enc` blob and the manifest under the new key in a single
|
||||
signed commit tagged `Relicario-Action: key-rotate`. A revoked member's
|
||||
wrapped blob is simply not written during rotation, so they hold a blob that
|
||||
decrypts to a stale key — they cannot read items encrypted under the new
|
||||
key.
|
||||
|
||||
### Audit action vocabulary
|
||||
|
||||
The `relicario org audit` command attributes actions to their verified
|
||||
signer (not to the commit author or trailer value). Each event records two
|
||||
actors: the **verified** actor resolved from the signing key (authoritative)
|
||||
and the actor **claimed** by the `Relicario-Actor` trailer (advisory). When the
|
||||
claimed actor disagrees with the verified signer, the event is flagged
|
||||
`TAMPERED`. Trailers are advisory metadata; the trustworthy actor is always
|
||||
the cryptographically verified signer.
|
||||
|
||||
Actions live in two groups:
|
||||
|
||||
- **Live (merged A + C streams):** `member-add`, `member-remove`,
|
||||
`member-role-change`, `collection-create`, `collection-grant`,
|
||||
`collection-revoke`, `key-rotate`, `org-init`, `ownership-transfer`,
|
||||
`org-delete`.
|
||||
- **TODO (pending Dev-B B9–B13):** `item-create`, `item-update`,
|
||||
`item-delete`, `item-restore`, `item-purge` — the emitter code lands with
|
||||
the item-CRUD command stream.
|
||||
|
||||
### Honest limitations
|
||||
|
||||
The following are deliberate design boundaries, not oversights:
|
||||
|
||||
- **Shared org master key — reads are not cryptographically scoped per
|
||||
collection.** The pre-receive hook scopes *writes* by collection path
|
||||
and the CLI filters the manifest to each member's grants, but a single
|
||||
org key opens all collection blobs. A member with any grant can, outside
|
||||
the CLI, decrypt items from collections they are not granted. For true
|
||||
cryptographic separation, use a separate org vault per access boundary.
|
||||
Per-collection subkeys are a phase-2 non-goal.
|
||||
|
||||
- **No read audit.** Git records writes only. A member who reads blobs
|
||||
directly leaves no server-visible trace.
|
||||
|
||||
- **No "hide value."** There is no mechanism to show a member that an item
|
||||
exists without revealing its field values on decrypt.
|
||||
|
||||
- **`delete-org` is a local tombstone in phase 1.** The schema-monotonicity
|
||||
check causes the hook to reject protected-file deletion, so an
|
||||
`org-delete` action cannot be pushed to a hook-protected remote. The
|
||||
deletion is recorded locally only until a future phase addresses it.
|
||||
|
||||
## Configuration env vars
|
||||
|
||||
Relicario reads the following environment variables. Each is a trust
|
||||
|
||||
@@ -4546,6 +4546,14 @@ fn verify_org_commit(commit: &str) -> Result<()> {
|
||||
/// role elevated up to Owner/Admin). On genesis (root), the sole bootstrap
|
||||
/// owner the commit introduces is allowed (it has no parent baseline).
|
||||
///
|
||||
/// CRITICAL: the signer's authority is judged on their role in the PARENT
|
||||
/// commit (`parent_role(signer)`), NOT the post-change `signer.role` carried in
|
||||
/// the commit under verification. Reading `signer.role` would let an Admin
|
||||
/// self-promote to Owner in the same commit and then self-authorize that very
|
||||
/// promotion (the gate would see the already-elevated role and pass) — the
|
||||
/// exact escalation this exists to stop. A signer absent from the parent
|
||||
/// (`None`) has no prior authority and is rejected.
|
||||
///
|
||||
/// `git_show_parent` is defined in Task C2 (same file, same crate).
|
||||
fn enforce_owner_only_elevation(
|
||||
commit: &str,
|
||||
@@ -4579,6 +4587,12 @@ fn enforce_owner_only_elevation(
|
||||
parent_members.iter().find(|(mid, _)| mid == id).map(|(_, r)| *r)
|
||||
};
|
||||
|
||||
// The signer's authority = their PARENT role. A member absent from the parent
|
||||
// (brand new) has no prior authority and cannot mint owners/admins. This is
|
||||
// judged BEFORE the loop and never reads the post-change `signer.role`.
|
||||
let signer_parent = parent_role(signer.member_id.as_str());
|
||||
let signer_may_manage_owners = signer_parent.map_or(false, |r| r.can_manage_owners());
|
||||
|
||||
for m in &new_members.members {
|
||||
if !is_privileged(m.role) {
|
||||
continue;
|
||||
@@ -4592,12 +4606,14 @@ fn enforce_owner_only_elevation(
|
||||
if parent_role(m.member_id.as_str()) == Some(m.role) {
|
||||
continue; // unchanged role — not an introduction or elevation
|
||||
}
|
||||
// A new owner/admin, or a member elevated to owner/admin → owner-only.
|
||||
if !signer.role.can_manage_owners() {
|
||||
// A new owner/admin, or a member elevated to owner/admin → owner-only,
|
||||
// judged by the signer's PRE-commit (parent) authority — never the
|
||||
// post-change `signer.role`.
|
||||
if !signer_may_manage_owners {
|
||||
eprintln!(
|
||||
"REJECT: org commit {commit} — member '{}' (role {:?}) may not introduce or \
|
||||
"REJECT: org commit {commit} — member '{}' (parent role {:?}) may not introduce or \
|
||||
elevate owner/admin '{}' to {:?}; only an owner may",
|
||||
signer.display_name, signer.role, m.display_name, m.role
|
||||
signer.display_name, signer_parent, m.display_name, m.role
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
@@ -271,7 +271,7 @@ Parses `git log` (record separator `%x1e`, field separator `%x1f` to survive mul
|
||||
|
||||
1. **Verifies the signature** by building a temporary `allowed_signers` from `members.json` ed25519 keys, injecting `gpg.ssh.allowedSignersFile` via `GIT_CONFIG_*`, running `git verify-commit --raw`, and parsing the `SHA256:` fingerprint from stderr — the same mechanism the existing `verify-commit` uses. A commit with no good signature, or whose signer is not a current member, is rejected. (Bare `git %GF` is **not** used — it returns empty without an allowed-signers file.)
|
||||
2. **Authorizes the change** by inspecting `git diff-tree` paths:
|
||||
- `members.json` / `collections.json` / `org.json` → signer must be owner/admin; a `member-role-change` granting owner/admin must be signed by an owner.
|
||||
- `members.json` / `collections.json` / `org.json` → signer must be owner/admin; a `member-role-change` granting owner/admin must be signed by an owner. The signer's authority here is judged on their role in the **parent** commit (their pre-change role), never the post-change role carried in the commit under verification — otherwise an Admin could self-promote to Owner in one commit and have the gate read the already-elevated role and self-authorize. A signer absent from the parent has no prior authority and is rejected. (Genesis is the sole exception — see §4 below.)
|
||||
- `items/<slug>/<id>.enc` → `<slug>` must be in the signing member's grants.
|
||||
3. **Validates schema** — `schema_version` must not decrease for any of the three JSON files (compared against `{commit}^:<file>`), and `members.json`/`collections.json` must pass `validate()`.
|
||||
4. **Handles genesis and merges** — the root commit (no parent) is the org-init genesis: it is allowed if signed by the sole owner it introduces. Merge commits are rejected (org history is linear) to avoid first-parent-only diff blind spots.
|
||||
|
||||
Reference in New Issue
Block a user