Compare commits
30 Commits
v0.7.0
...
feature/or
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
519e503cbd | ||
|
|
cdb008c900 | ||
|
|
053062effd | ||
|
|
3b6dbbe353 | ||
|
|
558da3bd75 | ||
|
|
9c43f223f5 | ||
|
|
1c177871a7 | ||
|
|
1ad8eb0918 | ||
|
|
aace6f132a | ||
|
|
dbdb3f6ab0 | ||
|
|
7faedf8578 | ||
|
|
ccb58d8bb5 | ||
|
|
570b0ddcd3 | ||
|
|
7daedb33e0 | ||
|
|
17df315f0e | ||
|
|
2dd5d79f36 | ||
|
|
675b7836e1 | ||
|
|
743a46f3d5 | ||
|
|
409ddce049 | ||
|
|
631608e6e5 | ||
|
|
ca4936cf95 | ||
|
|
da4dc44f80 | ||
|
|
f249395644 | ||
|
|
b655024320 | ||
|
|
8c19e3cfda | ||
|
|
21ed8d83b8 | ||
|
|
ac6756e698 | ||
|
|
2543ed30f6 | ||
|
|
2a6f6f1307 | ||
|
|
108965ec84 |
@@ -187,10 +187,26 @@ function devRole(letter) {
|
||||
|
||||
// ── Routing ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const action = (args && args.action) || 'develop'
|
||||
const mode = (args && args.mode) || 'single'
|
||||
const release = args && args.release
|
||||
const context = args && args.context
|
||||
// Support both object args {action, mode, release} and space-separated string
|
||||
// "action mode release-label" (e.g. "develop multi enterprise-org-vault").
|
||||
let _args = args
|
||||
if (typeof args === 'string') {
|
||||
const parts = args.trim().split(/\s+/)
|
||||
// "develop multi enterprise-org-vault" → 3 parts
|
||||
// "develop enterprise-org-vault" → 2 parts (mode defaults to single)
|
||||
if (parts.length >= 3) {
|
||||
_args = { action: parts[0], mode: parts[1], release: parts.slice(2).join(' ') }
|
||||
} else if (parts.length === 2) {
|
||||
_args = { action: parts[0], mode: 'single', release: parts[1] }
|
||||
} else {
|
||||
_args = { action: parts[0] || 'develop' }
|
||||
}
|
||||
}
|
||||
|
||||
const action = (_args && _args.action) || 'develop'
|
||||
const mode = (_args && _args.mode) || 'single'
|
||||
const release = _args && _args.release
|
||||
const context = _args && _args.context
|
||||
|
||||
// ── ACTION: preflight ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
16
Cargo.lock
generated
16
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",
|
||||
@@ -2209,6 +2212,7 @@ dependencies = [
|
||||
"thiserror 2.0.18",
|
||||
"unicode-normalization",
|
||||
"url",
|
||||
"x25519-dalek",
|
||||
"zeroize",
|
||||
"zstd",
|
||||
"zxcvbn",
|
||||
@@ -3709,6 +3713,18 @@ version = "0.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd"
|
||||
|
||||
[[package]]
|
||||
name = "x25519-dalek"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277"
|
||||
dependencies = [
|
||||
"curve25519-dalek",
|
||||
"rand_core",
|
||||
"serde",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yoke"
|
||||
version = "0.8.2"
|
||||
|
||||
@@ -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}"
|
||||
);
|
||||
}
|
||||
@@ -16,6 +16,7 @@ sha2 = "0.10"
|
||||
sha1 = "0.10"
|
||||
hmac = "0.12"
|
||||
ed25519-dalek = { version = "2", features = ["rand_core"] }
|
||||
x25519-dalek = { version = "2", features = ["static_secrets"] }
|
||||
ssh-key = { version = "0.6", features = ["ed25519", "std"] }
|
||||
image = { version = "0.25", default-features = false, features = ["jpeg"] }
|
||||
|
||||
|
||||
@@ -78,8 +78,8 @@ pub use generators::{generate_passphrase, generate_password, rate_passphrase, va
|
||||
|
||||
pub mod vault;
|
||||
pub use vault::{
|
||||
decrypt_item, decrypt_manifest, decrypt_settings,
|
||||
encrypt_item, encrypt_manifest, encrypt_settings,
|
||||
decrypt_item, decrypt_manifest, decrypt_org_manifest, decrypt_settings,
|
||||
encrypt_item, encrypt_manifest, encrypt_org_manifest, encrypt_settings,
|
||||
};
|
||||
|
||||
pub mod imgsecret;
|
||||
@@ -93,6 +93,13 @@ pub use import_lastpass::{parse_lastpass_csv, ImportWarning};
|
||||
pub mod device;
|
||||
pub use device::{fingerprint, DeviceEntry, RevokedEntry, generate_keypair, sign, verify};
|
||||
|
||||
pub mod org;
|
||||
pub use org::{
|
||||
generate_org_key, unwrap_org_key, wrap_org_key,
|
||||
CollectionDef, MemberId, OrgCollections, OrgId, OrgManifest,
|
||||
OrgManifestEntry, OrgMember, OrgMembers, OrgMeta, OrgRole,
|
||||
};
|
||||
|
||||
pub mod tar_safe;
|
||||
pub use tar_safe::{safe_unpack_git_archive, DEFAULT_MAX_UNCOMPRESSED};
|
||||
|
||||
|
||||
494
crates/relicario-core/src/org.rs
Normal file
494
crates/relicario-core/src/org.rs
Normal file
@@ -0,0 +1,494 @@
|
||||
//! Org vault types, crypto, and schema for multi-user self-hosted deployments.
|
||||
|
||||
use rand::{rngs::OsRng, RngCore};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
use crate::error::{RelicarioError, Result};
|
||||
use crate::ids::ItemId;
|
||||
use crate::item_types::ItemType;
|
||||
|
||||
// ── IDs ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct OrgId(pub String);
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct MemberId(pub String);
|
||||
|
||||
impl OrgId {
|
||||
pub fn new() -> Self {
|
||||
let mut bytes = [0u8; 8];
|
||||
OsRng.fill_bytes(&mut bytes);
|
||||
Self(hex::encode(bytes))
|
||||
}
|
||||
pub fn as_str(&self) -> &str { &self.0 }
|
||||
}
|
||||
|
||||
impl Default for OrgId {
|
||||
fn default() -> Self { Self::new() }
|
||||
}
|
||||
|
||||
impl MemberId {
|
||||
pub fn new() -> Self {
|
||||
let mut bytes = [0u8; 8];
|
||||
OsRng.fill_bytes(&mut bytes);
|
||||
Self(hex::encode(bytes))
|
||||
}
|
||||
pub fn as_str(&self) -> &str { &self.0 }
|
||||
pub fn is_valid(&self) -> bool {
|
||||
self.0.len() == 16 && self.0.chars().all(|c| c.is_ascii_hexdigit())
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for MemberId {
|
||||
fn default() -> Self { Self::new() }
|
||||
}
|
||||
|
||||
// ── Roles ────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum OrgRole {
|
||||
Owner,
|
||||
Admin,
|
||||
Member,
|
||||
}
|
||||
|
||||
impl OrgRole {
|
||||
pub fn can_manage_members(&self) -> bool {
|
||||
matches!(self, OrgRole::Owner | OrgRole::Admin)
|
||||
}
|
||||
pub fn can_manage_owners(&self) -> bool {
|
||||
matches!(self, OrgRole::Owner)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Members ──────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OrgMember {
|
||||
pub member_id: MemberId,
|
||||
pub display_name: String,
|
||||
pub role: OrgRole,
|
||||
/// SSH public key string (openssh format: "ssh-ed25519 AAAA...")
|
||||
pub ed25519_pubkey: String,
|
||||
/// Collection slugs this member can access.
|
||||
#[serde(default)]
|
||||
pub collections: Vec<String>,
|
||||
pub added_at: i64,
|
||||
pub added_by: MemberId,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OrgMembers {
|
||||
pub schema_version: u32,
|
||||
pub members: Vec<OrgMember>,
|
||||
}
|
||||
|
||||
impl OrgMembers {
|
||||
pub fn new() -> Self {
|
||||
Self { schema_version: 1, members: Vec::new() }
|
||||
}
|
||||
|
||||
pub fn find_by_id(&self, id: &MemberId) -> Option<&OrgMember> {
|
||||
self.members.iter().find(|m| &m.member_id == id)
|
||||
}
|
||||
|
||||
pub fn find_by_id_mut(&mut self, id: &MemberId) -> Option<&mut OrgMember> {
|
||||
self.members.iter_mut().find(|m| &m.member_id == id)
|
||||
}
|
||||
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
for m in &self.members {
|
||||
if !m.member_id.is_valid() {
|
||||
return Err(RelicarioError::Format(
|
||||
format!("invalid member_id: {}", m.member_id.0)
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for OrgMembers {
|
||||
fn default() -> Self { Self::new() }
|
||||
}
|
||||
|
||||
// ── Collections ───────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CollectionDef {
|
||||
pub slug: String,
|
||||
pub display_name: String,
|
||||
pub created_by: MemberId,
|
||||
pub created_at: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OrgCollections {
|
||||
pub schema_version: u32,
|
||||
pub collections: Vec<CollectionDef>,
|
||||
}
|
||||
|
||||
impl OrgCollections {
|
||||
pub fn new() -> Self {
|
||||
Self { schema_version: 1, collections: Vec::new() }
|
||||
}
|
||||
|
||||
pub fn contains_slug(&self, slug: &str) -> bool {
|
||||
self.collections.iter().any(|c| c.slug == slug)
|
||||
}
|
||||
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
for c in &self.collections {
|
||||
if c.slug.is_empty() || c.slug.contains('/') || c.slug.contains('.') {
|
||||
return Err(RelicarioError::Format(
|
||||
format!("invalid collection slug: {:?}", c.slug)
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for OrgCollections {
|
||||
fn default() -> Self { Self::new() }
|
||||
}
|
||||
|
||||
// ── Org meta ─────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OrgMeta {
|
||||
pub schema_version: u32,
|
||||
pub org_id: OrgId,
|
||||
pub display_name: String,
|
||||
pub created_at: i64,
|
||||
}
|
||||
|
||||
impl OrgMeta {
|
||||
pub fn new(display_name: String) -> Self {
|
||||
Self {
|
||||
schema_version: 1,
|
||||
org_id: OrgId::new(),
|
||||
display_name,
|
||||
created_at: crate::time::now_unix(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Org manifest ─────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OrgManifestEntry {
|
||||
pub id: ItemId,
|
||||
pub r#type: ItemType,
|
||||
pub title: String,
|
||||
#[serde(default)]
|
||||
pub tags: Vec<String>,
|
||||
pub modified: i64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub trashed_at: Option<i64>,
|
||||
/// Collection this item belongs to.
|
||||
pub collection: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OrgManifest {
|
||||
pub schema_version: u32,
|
||||
pub entries: Vec<OrgManifestEntry>,
|
||||
}
|
||||
|
||||
impl OrgManifest {
|
||||
pub fn new() -> Self {
|
||||
Self { schema_version: 1, entries: Vec::new() }
|
||||
}
|
||||
|
||||
/// Return only entries whose collection is in `member.collections`.
|
||||
pub fn filter_for_member(&self, member: &OrgMember) -> Self {
|
||||
let granted: std::collections::HashSet<&str> =
|
||||
member.collections.iter().map(|s| s.as_str()).collect();
|
||||
Self {
|
||||
schema_version: self.schema_version,
|
||||
entries: self.entries.iter()
|
||||
.filter(|e| granted.contains(e.collection.as_str()))
|
||||
.cloned()
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for OrgManifest {
|
||||
fn default() -> Self { Self::new() }
|
||||
}
|
||||
|
||||
// ── Key wrap / unwrap (ECIES: X25519 + XChaCha20-Poly1305) ───────────────────
|
||||
|
||||
/// Generate a random 256-bit org master key.
|
||||
pub fn generate_org_key() -> Zeroizing<[u8; 32]> {
|
||||
let mut key = Zeroizing::new([0u8; 32]);
|
||||
OsRng.fill_bytes(key.as_mut());
|
||||
key
|
||||
}
|
||||
|
||||
/// Derive an X25519 static secret from an ed25519 seed (standard RFC 7748 path).
|
||||
fn ed25519_seed_to_x25519_secret(seed: &[u8; 32]) -> x25519_dalek::StaticSecret {
|
||||
use sha2::{Digest, Sha512};
|
||||
let h = Sha512::digest(seed.as_ref());
|
||||
let mut scalar = [0u8; 32];
|
||||
scalar.copy_from_slice(&h[..32]);
|
||||
// RFC 7748 clamping
|
||||
scalar[0] &= 248;
|
||||
scalar[31] &= 127;
|
||||
scalar[31] |= 64;
|
||||
x25519_dalek::StaticSecret::from(scalar)
|
||||
}
|
||||
|
||||
/// Parse an OpenSSH ed25519 public key string and return its X25519 form.
|
||||
fn openssh_ed25519_to_x25519_pk(openssh: &str) -> Result<x25519_dalek::PublicKey> {
|
||||
use ssh_key::PublicKey;
|
||||
let pk = PublicKey::from_openssh(openssh.trim())
|
||||
.map_err(|e| RelicarioError::Format(format!("bad SSH pubkey: {e}")))?;
|
||||
let ed_bytes = pk.key_data().ed25519()
|
||||
.ok_or_else(|| RelicarioError::Format("expected ed25519 key".into()))?
|
||||
.0;
|
||||
let verifying = ed25519_dalek::VerifyingKey::from_bytes(&ed_bytes)
|
||||
.map_err(|e| RelicarioError::Format(format!("bad ed25519 pubkey: {e}")))?;
|
||||
Ok(x25519_dalek::PublicKey::from(verifying.to_montgomery().to_bytes()))
|
||||
}
|
||||
|
||||
/// Wrap `org_key` for a recipient identified by their OpenSSH ed25519 public key.
|
||||
///
|
||||
/// Output layout: `ephemeral_x25519_pk(32) || version(1) || nonce(24) || ciphertext+tag`
|
||||
pub fn wrap_org_key(org_key: &Zeroizing<[u8; 32]>, recipient_openssh_pubkey: &str) -> Result<Vec<u8>> {
|
||||
use sha2::{Digest, Sha256};
|
||||
use x25519_dalek::EphemeralSecret;
|
||||
|
||||
let recipient_pk = openssh_ed25519_to_x25519_pk(recipient_openssh_pubkey)?;
|
||||
|
||||
let ephemeral_sk = EphemeralSecret::random_from_rng(OsRng);
|
||||
let ephemeral_pk = x25519_dalek::PublicKey::from(&ephemeral_sk);
|
||||
|
||||
let shared = ephemeral_sk.diffie_hellman(&recipient_pk);
|
||||
|
||||
// Domain-separated KDF. All intermediates carrying the DH secret are held in
|
||||
// Zeroizing so they are wiped on drop (H6).
|
||||
let mut kdf_input: Zeroizing<Vec<u8>> = Zeroizing::new(Vec::with_capacity(32 + 32 + 32));
|
||||
kdf_input.extend_from_slice(shared.as_bytes());
|
||||
kdf_input.extend_from_slice(ephemeral_pk.as_bytes());
|
||||
kdf_input.extend_from_slice(recipient_pk.as_bytes());
|
||||
|
||||
// Copy the digest straight into a Zeroizing array. The GenericArray returned
|
||||
// by Sha256::digest is not Zeroize (generic-array's impl is feature-gated and
|
||||
// not enabled here), so we move the bytes into an owned [u8; 32] whose own
|
||||
// Zeroize impl wipes them on drop.
|
||||
let mut wrap_key: Zeroizing<[u8; 32]> = Zeroizing::new([0u8; 32]);
|
||||
wrap_key.copy_from_slice(&Sha256::digest(kdf_input.as_slice()));
|
||||
|
||||
let encrypted = crate::crypto::encrypt(&wrap_key, org_key.as_ref())?;
|
||||
|
||||
let mut out = Vec::with_capacity(32 + encrypted.len());
|
||||
out.extend_from_slice(ephemeral_pk.as_bytes());
|
||||
out.extend_from_slice(&encrypted);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Unwrap a key blob produced by `wrap_org_key` using the recipient's ed25519 seed.
|
||||
pub fn unwrap_org_key(wrapped: &[u8], ed25519_seed: &Zeroizing<[u8; 32]>) -> Result<Zeroizing<[u8; 32]>> {
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
// Minimum: 32 (ephemeral_pk) + 41 (version+nonce+tag for 32-byte plaintext)
|
||||
if wrapped.len() < 32 + 41 {
|
||||
return Err(RelicarioError::Format("wrapped key blob too short".into()));
|
||||
}
|
||||
|
||||
let mut eph_bytes = [0u8; 32];
|
||||
eph_bytes.copy_from_slice(&wrapped[..32]);
|
||||
let ephemeral_pk = x25519_dalek::PublicKey::from(eph_bytes);
|
||||
let encrypted = &wrapped[32..];
|
||||
|
||||
let recipient_sk = ed25519_seed_to_x25519_secret(ed25519_seed);
|
||||
let recipient_pk = x25519_dalek::PublicKey::from(&recipient_sk);
|
||||
|
||||
let shared = recipient_sk.diffie_hellman(&ephemeral_pk);
|
||||
|
||||
let mut kdf_input: Zeroizing<Vec<u8>> = Zeroizing::new(Vec::with_capacity(32 + 32 + 32));
|
||||
kdf_input.extend_from_slice(shared.as_bytes());
|
||||
kdf_input.extend_from_slice(ephemeral_pk.as_bytes());
|
||||
kdf_input.extend_from_slice(recipient_pk.as_bytes());
|
||||
|
||||
let mut wrap_key: Zeroizing<[u8; 32]> = Zeroizing::new([0u8; 32]);
|
||||
wrap_key.copy_from_slice(&Sha256::digest(kdf_input.as_slice()));
|
||||
|
||||
let plaintext = Zeroizing::new(crate::crypto::decrypt(&wrap_key, encrypted)?);
|
||||
if plaintext.len() != 32 {
|
||||
return Err(RelicarioError::Format(
|
||||
format!("unwrapped key has wrong length: {}", plaintext.len())
|
||||
));
|
||||
}
|
||||
|
||||
let mut key = Zeroizing::new([0u8; 32]);
|
||||
key.copy_from_slice(&plaintext);
|
||||
Ok(key)
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn member_id_is_16_hex_chars() {
|
||||
let id = MemberId::new();
|
||||
assert_eq!(id.0.len(), 16);
|
||||
assert!(id.0.chars().all(|c| c.is_ascii_hexdigit()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn member_ids_are_unique() {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for _ in 0..1_000 {
|
||||
assert!(seen.insert(MemberId::new().0));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn org_id_is_16_hex_chars() {
|
||||
let id = OrgId::new();
|
||||
assert_eq!(id.0.len(), 16);
|
||||
assert!(id.0.chars().all(|c| c.is_ascii_hexdigit()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn org_role_can_manage_members() {
|
||||
assert!(OrgRole::Owner.can_manage_members());
|
||||
assert!(OrgRole::Admin.can_manage_members());
|
||||
assert!(!OrgRole::Member.can_manage_members());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collection_slug_validation_rejects_slash() {
|
||||
let mut c = OrgCollections::new();
|
||||
c.collections.push(CollectionDef {
|
||||
slug: "bad/slug".into(),
|
||||
display_name: "Bad".into(),
|
||||
created_by: MemberId::new(),
|
||||
created_at: 0,
|
||||
});
|
||||
assert!(c.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_for_member_restricts_collections() {
|
||||
let mut manifest = OrgManifest::new();
|
||||
manifest.entries.push(OrgManifestEntry {
|
||||
id: ItemId::new(),
|
||||
r#type: crate::item_types::ItemType::SecureNote,
|
||||
title: "A".into(),
|
||||
tags: vec![],
|
||||
modified: 0,
|
||||
trashed_at: None,
|
||||
collection: "prod".into(),
|
||||
});
|
||||
manifest.entries.push(OrgManifestEntry {
|
||||
id: ItemId::new(),
|
||||
r#type: crate::item_types::ItemType::SecureNote,
|
||||
title: "B".into(),
|
||||
tags: vec![],
|
||||
modified: 0,
|
||||
trashed_at: None,
|
||||
collection: "dev".into(),
|
||||
});
|
||||
|
||||
let member = OrgMember {
|
||||
member_id: MemberId::new(),
|
||||
display_name: "Alice".into(),
|
||||
role: OrgRole::Member,
|
||||
ed25519_pubkey: String::new(),
|
||||
collections: vec!["prod".into()],
|
||||
added_at: 0,
|
||||
added_by: MemberId::new(),
|
||||
};
|
||||
|
||||
let filtered = manifest.filter_for_member(&member);
|
||||
assert_eq!(filtered.entries.len(), 1);
|
||||
assert_eq!(filtered.entries[0].collection, "prod");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generate_org_key_is_32_bytes() {
|
||||
let key = generate_org_key();
|
||||
assert_eq!(key.len(), 32);
|
||||
}
|
||||
|
||||
/// Pinned RFC 8032 known-answer vector for the ed25519→X25519 map. The seed
|
||||
/// and expected X25519 public key are from ed25519-dalek's own reference
|
||||
/// test (`tests/x25519.rs`, section 7.1 vector A). The expected value is a
|
||||
/// HARD-CODED LITERAL — NOT recomputed by the production code path — so a
|
||||
/// correlated cross-crate-version regression in the birational map (where
|
||||
/// both our derivation and a naive re-derivation would drift together) is
|
||||
/// still caught. If this test ever fails after a dep bump, the wrap/unwrap
|
||||
/// keyspace changed and every existing `keys/<id>.enc` blob is invalidated.
|
||||
#[test]
|
||||
fn ed25519_to_x25519_pinned_rfc8032_vector() {
|
||||
let seed: [u8; 32] =
|
||||
hex::decode("9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60")
|
||||
.unwrap()
|
||||
.try_into()
|
||||
.unwrap();
|
||||
// Derive the X25519 *public* key the same way wrap/unwrap derives the
|
||||
// recipient's static secret from a seed.
|
||||
let secret = ed25519_seed_to_x25519_secret(&seed);
|
||||
let public = x25519_dalek::PublicKey::from(&secret);
|
||||
assert_eq!(
|
||||
hex::encode(public.as_bytes()),
|
||||
"d85e07ec22b0ad881537c2f44d662d1a143cf830c57aca4305d85c7a90f6b62e",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrap_unwrap_round_trip() {
|
||||
// Generate an ed25519 keypair to act as the member's device key
|
||||
use ed25519_dalek::SigningKey;
|
||||
let mut seed = [0u8; 32];
|
||||
OsRng.fill_bytes(&mut seed);
|
||||
let signing_key = SigningKey::from_bytes(&seed);
|
||||
let pubkey_openssh = ssh_key::PrivateKey::from(
|
||||
ssh_key::private::Ed25519Keypair::from(&signing_key),
|
||||
)
|
||||
.public_key()
|
||||
.to_openssh()
|
||||
.expect("openssh");
|
||||
|
||||
let org_key = generate_org_key();
|
||||
let wrapped = wrap_org_key(&org_key, &pubkey_openssh).expect("wrap");
|
||||
let seed_zeroizing = Zeroizing::new(seed);
|
||||
let unwrapped = unwrap_org_key(&wrapped, &seed_zeroizing).expect("unwrap");
|
||||
|
||||
assert_eq!(*org_key, *unwrapped);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unwrap_with_wrong_seed_fails() {
|
||||
use ed25519_dalek::SigningKey;
|
||||
let mut seed = [0u8; 32];
|
||||
OsRng.fill_bytes(&mut seed);
|
||||
let signing_key = SigningKey::from_bytes(&seed);
|
||||
let pubkey_openssh = ssh_key::PrivateKey::from(
|
||||
ssh_key::private::Ed25519Keypair::from(&signing_key),
|
||||
)
|
||||
.public_key()
|
||||
.to_openssh()
|
||||
.expect("openssh");
|
||||
|
||||
let org_key = generate_org_key();
|
||||
let wrapped = wrap_org_key(&org_key, &pubkey_openssh).expect("wrap");
|
||||
|
||||
let wrong_seed = Zeroizing::new([0xFFu8; 32]);
|
||||
let result = unwrap_org_key(&wrapped, &wrong_seed);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ use crate::crypto::{decrypt, encrypt};
|
||||
use crate::error::Result;
|
||||
use crate::item::Item;
|
||||
use crate::manifest::Manifest;
|
||||
use crate::org::OrgManifest;
|
||||
use crate::settings::VaultSettings;
|
||||
|
||||
pub fn encrypt_item(item: &Item, master_key: &Zeroizing<[u8; 32]>) -> Result<Vec<u8>> {
|
||||
@@ -52,6 +53,19 @@ pub fn decrypt_settings(encrypted: &[u8], master_key: &Zeroizing<[u8; 32]>) -> R
|
||||
Ok(settings)
|
||||
}
|
||||
|
||||
pub fn encrypt_org_manifest(manifest: &OrgManifest, org_key: &Zeroizing<[u8; 32]>) -> Result<Vec<u8>> {
|
||||
let json = serde_json::to_vec(manifest)?;
|
||||
let plaintext = Zeroizing::new(json);
|
||||
encrypt(org_key, plaintext.as_slice())
|
||||
}
|
||||
|
||||
pub fn decrypt_org_manifest(encrypted: &[u8], org_key: &Zeroizing<[u8; 32]>) -> Result<OrgManifest> {
|
||||
let plaintext = decrypt(org_key, encrypted)?;
|
||||
let plaintext = Zeroizing::new(plaintext);
|
||||
let manifest: OrgManifest = serde_json::from_slice(&plaintext)?;
|
||||
Ok(manifest)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -87,4 +101,27 @@ mod tests {
|
||||
assert_eq!(decoded.attachment_caps.per_attachment_max_bytes,
|
||||
s.attachment_caps.per_attachment_max_bytes);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn org_manifest_round_trip() {
|
||||
use crate::org::{OrgManifest, OrgManifestEntry};
|
||||
use crate::ids::ItemId;
|
||||
use crate::item_types::ItemType;
|
||||
|
||||
let mut m = OrgManifest::new();
|
||||
m.entries.push(OrgManifestEntry {
|
||||
id: ItemId::new(),
|
||||
r#type: ItemType::SecureNote,
|
||||
title: "test".into(),
|
||||
tags: vec![],
|
||||
modified: 0,
|
||||
trashed_at: None,
|
||||
collection: "prod".into(),
|
||||
});
|
||||
let key = key();
|
||||
let bytes = encrypt_org_manifest(&m, &key).unwrap();
|
||||
let decoded = decrypt_org_manifest(&bytes, &key).unwrap();
|
||||
assert_eq!(decoded.entries.len(), 1);
|
||||
assert_eq!(decoded.entries[0].collection, "prod");
|
||||
}
|
||||
}
|
||||
|
||||
120
crates/relicario-core/tests/org.rs
Normal file
120
crates/relicario-core/tests/org.rs
Normal file
@@ -0,0 +1,120 @@
|
||||
use relicario_core::{
|
||||
generate_org_key, wrap_org_key, unwrap_org_key,
|
||||
encrypt_org_manifest, decrypt_org_manifest,
|
||||
OrgManifest, OrgManifestEntry, OrgMember, OrgMembers, OrgRole,
|
||||
MemberId, ItemId,
|
||||
};
|
||||
use relicario_core::item_types::ItemType;
|
||||
use rand::rngs::OsRng;
|
||||
use rand::RngCore;
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
fn make_member_keypair() -> (Zeroizing<[u8; 32]>, String) {
|
||||
let mut seed = [0u8; 32];
|
||||
OsRng.fill_bytes(&mut seed);
|
||||
let signing_key = ed25519_dalek::SigningKey::from_bytes(&seed);
|
||||
let pubkey_openssh = ssh_key::PrivateKey::from(
|
||||
ssh_key::private::Ed25519Keypair::from(&signing_key),
|
||||
)
|
||||
.public_key()
|
||||
.to_openssh()
|
||||
.expect("openssh");
|
||||
(Zeroizing::new(seed), pubkey_openssh)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn org_key_wrap_unwrap_round_trip() {
|
||||
let (seed, pubkey) = make_member_keypair();
|
||||
let org_key = generate_org_key();
|
||||
let wrapped = wrap_org_key(&org_key, &pubkey).expect("wrap");
|
||||
let unwrapped = unwrap_org_key(&wrapped, &seed).expect("unwrap");
|
||||
assert_eq!(*org_key, *unwrapped);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn revoked_member_cannot_decrypt_after_rotation() {
|
||||
// Alice and Bob both get access
|
||||
let (alice_seed, alice_pubkey) = make_member_keypair();
|
||||
let (_bob_seed, bob_pubkey) = make_member_keypair();
|
||||
|
||||
let org_key = generate_org_key();
|
||||
let _alice_wrapped = wrap_org_key(&org_key, &alice_pubkey).expect("wrap alice");
|
||||
let _bob_wrapped = wrap_org_key(&org_key, &bob_pubkey).expect("wrap bob");
|
||||
|
||||
// Rotate: new key, only Bob gets re-wrapped
|
||||
let new_org_key = generate_org_key();
|
||||
let new_bob_wrapped = wrap_org_key(&new_org_key, &bob_pubkey).expect("wrap bob new");
|
||||
|
||||
// Alice tries to use old org_key — she can still decrypt old items,
|
||||
// but new_bob_wrapped was encrypted with new_org_key, not org_key.
|
||||
// Verify: unwrapping new_bob_wrapped with Alice's seed fails.
|
||||
let result = unwrap_org_key(&new_bob_wrapped, &alice_seed);
|
||||
assert!(result.is_err(), "Alice should not be able to unwrap Bob's new key blob");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn org_manifest_filter_restricts_to_granted_collections() {
|
||||
let mut manifest = OrgManifest::new();
|
||||
for (title, collection) in &[("A", "prod"), ("B", "dev"), ("C", "prod")] {
|
||||
manifest.entries.push(OrgManifestEntry {
|
||||
id: ItemId::new(),
|
||||
r#type: ItemType::SecureNote,
|
||||
title: title.to_string(),
|
||||
tags: vec![],
|
||||
modified: 0,
|
||||
trashed_at: None,
|
||||
collection: collection.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
let member = OrgMember {
|
||||
member_id: MemberId::new(),
|
||||
display_name: "Alice".into(),
|
||||
role: OrgRole::Member,
|
||||
ed25519_pubkey: String::new(),
|
||||
collections: vec!["prod".into()],
|
||||
added_at: 0,
|
||||
added_by: MemberId::new(),
|
||||
};
|
||||
|
||||
let filtered = manifest.filter_for_member(&member);
|
||||
assert_eq!(filtered.entries.len(), 2);
|
||||
assert!(filtered.entries.iter().all(|e| e.collection == "prod"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn org_manifest_encrypt_decrypt_round_trip() {
|
||||
let key = generate_org_key();
|
||||
let mut manifest = OrgManifest::new();
|
||||
manifest.entries.push(OrgManifestEntry {
|
||||
id: ItemId::new(),
|
||||
r#type: ItemType::Login,
|
||||
title: "GitHub".into(),
|
||||
tags: vec!["work".into()],
|
||||
modified: 1748000000,
|
||||
trashed_at: None,
|
||||
collection: "eng-tools".into(),
|
||||
});
|
||||
|
||||
let encrypted = encrypt_org_manifest(&manifest, &key).expect("encrypt");
|
||||
let decrypted = decrypt_org_manifest(&encrypted, &key).expect("decrypt");
|
||||
|
||||
assert_eq!(decrypted.entries.len(), 1);
|
||||
assert_eq!(decrypted.entries[0].title, "GitHub");
|
||||
assert_eq!(decrypted.entries[0].collection, "eng-tools");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn members_validation_rejects_invalid_id() {
|
||||
let mut members = OrgMembers::new();
|
||||
members.members.push(OrgMember {
|
||||
member_id: MemberId("not-hex-lol!!".to_string()),
|
||||
display_name: "Bad".into(),
|
||||
role: OrgRole::Member,
|
||||
ed25519_pubkey: String::new(),
|
||||
collections: vec![],
|
||||
added_at: 0,
|
||||
added_by: MemberId::new(),
|
||||
});
|
||||
assert!(members.validate().is_err());
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
# Dev-C ARCHITECTURE.md slice — Plan C Phase 6 (`get_vault_status` + sidebar status indicator)
|
||||
|
||||
Ready-to-fold additions for `extension/ARCHITECTURE.md`, scoped to Dev-C's Phase 6 work only.
|
||||
Phase 3 (`create_vault`/`attach_vault`, setup-SW migration) and Phase 4 (the `vault.ts` →
|
||||
`vault-shell`/`vault-sidebar`/`vault-list`/`vault-drawer`/`vault-form-wrapper` split) doc updates
|
||||
are Dev-A's / Dev-B's slices — not included here.
|
||||
|
||||
Merged to origin/main as `397cc78` (Merge Plan C Phase 6). Local source ref: `675452a`.
|
||||
|
||||
---
|
||||
|
||||
## 1. SW message-protocol row — `get_vault_status` (read-only, popup-only)
|
||||
|
||||
**Where:** the `router/popup-only.ts` bullet in the service-worker module map (around line 270),
|
||||
and/or wherever the read-only popup messages are enumerated.
|
||||
|
||||
**Add:**
|
||||
|
||||
> - `get_vault_status` (popup-only, read-only) — returns the cached sync summary
|
||||
> `{ ahead, behind, lastSyncAt, pendingItems }` with **no network call**. `ahead`/`behind`/
|
||||
> `lastSyncAt` are read straight off `state.gitHost` (populated by the `sync` handler, which
|
||||
> records `lastSyncAt = Math.floor(Date.now()/1000)` — unix **seconds** — after a successful
|
||||
> manifest fetch). `pendingItems` is a live count of active (non-trashed) manifest entries via
|
||||
> `vault.listItems(manifest).length`. `ahead`/`behind` are structurally always `0` in the
|
||||
> extension (it writes straight to the host via the Contents REST API; there is no local commit
|
||||
> graph) and exist for parity with `relicario status`. Handler: `vault.handleGetVaultStatus(state)`
|
||||
> — synchronous; its `Pick<GitHost,'lastSyncAt'|'ahead'|'behind'>`-typed param both breaks the
|
||||
> `PopupState` import cycle and structurally forbids it from making a network call.
|
||||
|
||||
## 2. `git-host.ts` cache fields
|
||||
|
||||
**Where:** the `git-host.ts` bullet in the SW module map (around line 299, listing the interface methods).
|
||||
|
||||
**Amend** the interface description to note the cached sync metadata:
|
||||
|
||||
> The `GitHost` interface also carries cached sync metadata —
|
||||
> `lastSyncAt: number | null` (unix seconds), `ahead: number`, `behind: number` — initialized to
|
||||
> `null`/`0`/`0` in both `GiteaHost` and `GitHubHost`. The cache rides the gitHost lifecycle: it is
|
||||
> created on unlock and cleared whenever `state.gitHost` is nulled — on session-timer expiry
|
||||
> (`index.ts`) **and** on the explicit `lock` message handler (`popup-only.ts`), which now nulls
|
||||
> `state.gitHost` symmetrically so a lock→unlock cycle can't surface a stale `lastSyncAt`.
|
||||
|
||||
## 3. Sidebar status-indicator UI flow
|
||||
|
||||
**Where:** the `src/vault/` module map (around line 184). Add a `vault-status.ts` entry and a note on
|
||||
the `vault-sidebar.ts` footer wiring. (If Dev-B's Phase 4 slice has already added the `vault-sidebar.ts`
|
||||
entry, fold the status note into it rather than duplicating.)
|
||||
|
||||
**Add:**
|
||||
|
||||
> - `vault-status.ts` — sidebar-footer sync indicator renderer. `renderStatusIndicator(el, status)`
|
||||
> is pure DOM: it renders, by priority, `N pending` / `N ahead` / `N behind`, falling back to
|
||||
> `in sync`, plus a `last sync <relativeTime>` / `never synced` line. Reuses `shared/glyphs.ts`
|
||||
> (`GLYPH_PENDING`/`AHEAD`/`BEHIND`/`SYNCED`) and `shared/relative-time.ts`. `VaultStatus` is an
|
||||
> alias of `GetVaultStatusResponse['data']`, so the renderer's input shape is single-sourced from
|
||||
> the message contract and can't drift from the SW handler.
|
||||
> - **Status-indicator flow** (in the `vault-sidebar.ts` entry): the footer holds a
|
||||
> `#vault-status-slot` plus a manual `↻` refresh button (`GLYPH_REFRESH`). `wireSidebar` calls
|
||||
> `refreshStatus()` once on mount and again on the button's click — sending `get_vault_status` via
|
||||
> `ctx.sendMessage` and rendering the result into the slot. There is **no timer polling**: the
|
||||
> indicator only refreshes on mount + explicit button press, matching the spec's
|
||||
> no-network-without-user-intent discipline (sync is user-initiated).
|
||||
|
||||
## 4. Living-docs note
|
||||
|
||||
This closes the last `relicario status` CLI/extension parity gap (called out in the extension
|
||||
restructure spec, `docs/superpowers/specs/2026-05-04-extension-restructure-design.md`). `STATUS.md`
|
||||
should move the extension-restructure line to shipped as part of the Task 7.1 pass.
|
||||
6194
docs/superpowers/plans/2026-06-06-enterprise-org-vault.md
Normal file
6194
docs/superpowers/plans/2026-06-06-enterprise-org-vault.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,360 @@
|
||||
# Relicario Enterprise Org Vault — Design Spec
|
||||
|
||||
**Scope:** Multi-user organizational vault for security-conscious self-hosting shops. Covers the git-native org model, the per-member key-wrapping scheme, collection-scoped item storage, role-based access control, org item CRUD, the signature-verifying pre-receive hook, the audit trail, and extension parity. Does not cover SSO/SAML, live SIEM streaming, or the HTTP management plane (deferred to a later server-tier spec).
|
||||
|
||||
**Next:** `docs/superpowers/specs/2026-05-02-relay-server-design.md` (relay server — future phase 2 management plane)
|
||||
|
||||
> **Revision note (2026-06-19):** This spec was revised after an adversarial multi-agent review of the first draft + its implementation plan. The review confirmed the cryptographic wrap/unwrap scheme is correct but found that the original access-control design was unenforceable (flat item paths the hook could not authorize), the hook never actually verified signatures, the audit actor was read from spoofable commit trailers, and there was no item CRUD. This revision corrects all of those at the design level. See `docs/superpowers/plans/2026-06-06-enterprise-org-vault.md` for the implementation.
|
||||
|
||||
---
|
||||
|
||||
## Target Audience
|
||||
|
||||
Security-focused organizations that self-host their entire stack: infosec shops, security consultancies, law firms handling privileged client data, small financial firms. Key requirements:
|
||||
|
||||
- Full air-gap capability — no mandatory internet connectivity
|
||||
- Cryptographically provenance-linked, **tamper-evident** audit trail
|
||||
- Personal vaults remain isolated from org vault (separate cryptographic domains)
|
||||
- Least-privilege blast-radius limiting via collections, **server-enforced** (not advisory)
|
||||
- Member offboarding with clean key revocation that protects past secrets
|
||||
- Deployable without an IdP, SSO provider, or cloud dependency
|
||||
|
||||
---
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
An org vault is a second git repository alongside each member's personal vault. Personal and org vaults are cryptographically isolated — the personal vault's two-factor KDF (passphrase + image → Argon2id → master key) is completely untouched by org operations.
|
||||
|
||||
```
|
||||
Personal: ~/.config/relicario/personal/ → personal git repo (passphrase + image → Argon2id → master key)
|
||||
Org: ~/.config/relicario/acme-org/ → org git repo (org master key, wrapped per-member)
|
||||
```
|
||||
|
||||
**Two cryptographic domains, one CLI and one extension.**
|
||||
|
||||
The org vault uses a random 256-bit **org master key** to encrypt all org items and the org manifest. Each authorized member receives a copy of the org master key wrapped (ECIES: X25519 + XChaCha20-Poly1305) to their existing ed25519 device public key — converted to X25519 for the Diffie-Hellman step. To open the org vault, a member uses their device private key to unwrap their copy of the org master key, then decrypts items exactly as today.
|
||||
|
||||
**Two enforcement boundaries, working together:**
|
||||
|
||||
1. **Cryptographic** — only holders of a wrapped key can decrypt the org master key, and only the org master key can decrypt items. Revocation + key rotation re-encrypts everything under a fresh key.
|
||||
2. **Git pre-receive hook** — every commit is signature-verified against `members.json`, and writes are authorized by role (for management files) or by **collection path segment** (for item files). This is what makes least-privilege real rather than advisory, and what makes the audit trail tamper-evident.
|
||||
|
||||
**Key security properties:**
|
||||
|
||||
- Member departure = delete their `keys/<member-id>.enc`, then `rotate-key` re-wraps the org key for remaining members **and re-encrypts every item blob**. A removed member who kept the old key and a clone can decrypt nothing written or rotated after their removal.
|
||||
- Every write to the org repo is a **signed** git commit; the hook rejects unsigned commits and commits from non-members. The git log is the audit log, and its actor attribution comes from the **verified signing key**, not from spoofable commit-message text.
|
||||
- Fully air-gapped: the org repo is just git, push/pull over SSH.
|
||||
- A compromised org master key does not expose personal vault items.
|
||||
|
||||
**Phase 2 (not in this spec):** live SIEM streaming, SSO/SAML, LDAP/IdP member sync, HTTP management plane via the `relicario-server` relay skeleton, server-mediated read audit, and "hide value" (autofill without revealing plaintext).
|
||||
|
||||
---
|
||||
|
||||
## Data Model
|
||||
|
||||
The org repo has a defined on-disk schema. The pre-receive hook rejects pushes that violate it.
|
||||
|
||||
```
|
||||
acme-org/
|
||||
├── org.json # org identity: name, org_id, created_at, schema_version
|
||||
├── members.json # user directory (unencrypted — roles are not secrets)
|
||||
├── collections.json # collection definitions
|
||||
├── keys/
|
||||
│ └── <member-id>.enc # org master key wrapped to each member's X25519 public key
|
||||
├── manifest.enc # encrypted org manifest (item index + collection membership)
|
||||
└── items/
|
||||
└── <collection-slug>/
|
||||
└── <item-id>.enc # encrypted item, stored UNDER its collection directory
|
||||
```
|
||||
|
||||
**Collection-scoped item storage is load-bearing.** Items live under `items/<collection-slug>/<item-id>.enc`, not in a flat `items/` directory. The leading path segment is the collection slug, in cleartext, so the pre-receive hook can authorize a write by comparing the path's collection against the signing member's grants — *without* decrypting anything. (The original flat layout made this impossible: the item→collection mapping existed only inside the encrypted manifest the server cannot read.)
|
||||
|
||||
### `org.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_version": 1,
|
||||
"org_id": "<16-char hex>",
|
||||
"display_name": "Acme Security",
|
||||
"created_at": 1748000000
|
||||
}
|
||||
```
|
||||
|
||||
### `members.json`
|
||||
|
||||
Public and unencrypted — readable without the org master key. Roles are not secrets; the key material is in `keys/`.
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_version": 1,
|
||||
"members": [
|
||||
{
|
||||
"member_id": "<16-char hex>",
|
||||
"display_name": "Alice",
|
||||
"role": "owner",
|
||||
"ed25519_pubkey": "ssh-ed25519 AAAA... ",
|
||||
"collections": ["prod-infra", "shared-tools"],
|
||||
"added_at": 1748000000,
|
||||
"added_by": "<member-id>"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`role` is one of `owner`, `admin`, `member`. `collections` is the list of collection slugs this member is granted. `member_id` is a 16-char lowercase hex string generated from 64 bits of `OsRng` entropy — the same convention as `ItemId`/`FieldId` in `relicario-core/src/ids.rs`. `ed25519_pubkey` is the member's device public key in OpenSSH format; the hook canonicalizes it to a SHA-256 fingerprint (via `relicario_core::fingerprint`) for matching, so whitespace/comment differences do not lock a member out.
|
||||
|
||||
### `collections.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_version": 1,
|
||||
"collections": [
|
||||
{
|
||||
"slug": "prod-infra",
|
||||
"display_name": "Production Infrastructure",
|
||||
"created_by": "<member-id>",
|
||||
"created_at": 1748000000
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Slugs are validated: non-empty, no `/`, no `.` (so they are safe single path segments).
|
||||
|
||||
### `keys/<member-id>.enc`
|
||||
|
||||
The org master key (32 bytes) encrypted with ECIES to the member's device key. Wrapped-blob layout: `ephemeral_x25519_pubkey(32) || version(1) || nonce(24) || ciphertext+tag`. The wrapping key is `SHA-256(dh_shared || ephemeral_pubkey || recipient_pubkey)`; all secret intermediates (shared secret, derived wrap key) are held in `Zeroizing`. The ed25519→X25519 conversion (SHA-512(seed)[:32] + RFC 7748 clamp for the scalar; birational Montgomery map for the point) is the standard one; its correctness was verified against ed25519-dalek's own reference test vector.
|
||||
|
||||
### `manifest.enc`
|
||||
|
||||
Encrypted with the org master key. Same shape as the personal vault manifest but each entry carries a `collection` slug. The manifest is the authoritative item index; item blobs carry no metadata.
|
||||
|
||||
### `items/<collection-slug>/<item-id>.enc`
|
||||
|
||||
Identical `.enc` format to personal vault items (XChaCha20-Poly1305, random 24-byte nonce, org master key used directly — no Argon2id). Item IDs follow the 16-char hex convention. The blob does not name its collection; the directory path does.
|
||||
|
||||
---
|
||||
|
||||
## Access Control
|
||||
|
||||
### Roles
|
||||
|
||||
| Role | Permissions |
|
||||
|---|---|
|
||||
| **Owner** | All operations. Add/remove admins and owners. Create/delete collections. Rotate org key. Transfer ownership. Delete org. |
|
||||
| **Admin** | Add/remove **members** (not owners/admins). Create/delete collections. Grant/revoke collection access. Read all collections. |
|
||||
| **Member** | Read/write items in granted collections only. Cannot see or write items in other collections. |
|
||||
|
||||
Role gating is enforced both client-side (the CLI refuses) and server-side (the hook rejects). An admin cannot mint an owner or admin — only an owner can.
|
||||
|
||||
### Collection Access
|
||||
|
||||
Grants are stored in the member's `collections` array in `members.json`. No separate ACL file. An admin edits the member record and commits; the hook validates the committing member's role.
|
||||
|
||||
### Enforcement Layers
|
||||
|
||||
1. **Manifest filtering (read)** — the CLI and extension filter the decrypted manifest to entries whose `collection` is in the authenticated member's grant list. Members never see items for collections they are not granted.
|
||||
|
||||
2. **Pre-receive hook (write)** — for `items/<slug>/<id>.enc`, the hook requires `<slug>` to be in the signing member's grants. For `members.json` / `collections.json` / `org.json`, it requires owner/admin role. Every commit must additionally carry a **valid signature** from a current member. This makes both confidentiality *and integrity* of collections server-enforced.
|
||||
|
||||
### Known Limitations (honest)
|
||||
|
||||
- **Shared org master key — reads are not cryptographically scoped per collection.** Every member holds the *same* org master key (wrapped to their device key). The hook scopes *writes* by collection path and the client filters the *manifest* on read, but the cryptography itself does not partition reads: a member who obtains the raw ciphertext of an item in a collection they were not granted can still decrypt it, because the one org key opens everything. Collection grants are therefore an access-control boundary (enforced by the hook on write and by manifest filtering + optional git-host directory read-ACLs on fetch), not a cryptographic one. For *cryptographic* separation, put the sensitive material in a **separate org vault**. Per-collection subkeys are an explicit non-goal for this phase.
|
||||
- **No read audit.** Git commits record writes, not reads. A member decrypting an item without writing leaves no git trace. Read audit needs a server that mediates fetch — phase 2.
|
||||
- **No "hide value."** Autofill-without-revealing requires per-item subkeys or a mediating relay — phase 2.
|
||||
|
||||
---
|
||||
|
||||
## Org Items (CRUD)
|
||||
|
||||
The org vault stores secrets via `relicario org` item commands that mirror the personal-vault item model (`Item`, `ItemCore`, the typed builders) but operate on the org repo and enforce collection grants.
|
||||
|
||||
```
|
||||
relicario org add --collection <slug> <type> [type-specific flags]
|
||||
relicario org get --collection <slug> <query> [--show] [--copy]
|
||||
relicario org list [--collection <slug>] [--type <t>]
|
||||
relicario org edit --collection <slug> <query>
|
||||
relicario org rm | restore | purge --collection <slug> <query>
|
||||
```
|
||||
|
||||
Every item operation:
|
||||
|
||||
1. Requires the caller's `current_member()` to have `<slug>` in their grants, and `<slug>` to exist in `collections.json`.
|
||||
2. Reads/writes `items/<slug>/<id>.enc` with the org master key.
|
||||
3. Upserts/removes the `OrgManifestEntry` (with `collection = <slug>`) and re-encrypts `manifest.enc`.
|
||||
4. Commits with the structured trailer block, emitting the matching `item-*` action.
|
||||
|
||||
`get`/`list` apply manifest filtering so a member only sees their granted collections; secret fields are masked unless `--show`. Trash uses `trashed_at` like the personal vault.
|
||||
|
||||
---
|
||||
|
||||
## Admin Operations
|
||||
|
||||
All org management uses `relicario org <subcommand>`. Command bodies live in `commands/org.rs` as `run_<verb>`.
|
||||
|
||||
```
|
||||
relicario org init --name "Acme Security" # create org repo, generate org key, add caller as owner, configure signing
|
||||
relicario org add-member --key <openssh-pubkey> --name Alice --role member
|
||||
relicario org remove-member <member-id> # delete key blob; prompts to run rotate-key
|
||||
relicario org set-role <member-id> admin|member
|
||||
relicario org create-collection <slug> --name "..."
|
||||
relicario org grant <member-id> <slug>
|
||||
relicario org revoke <member-id> <slug>
|
||||
relicario org rotate-key # new org key: re-wrap for members AND re-encrypt all items + manifest
|
||||
relicario org transfer-ownership <member-id> # owner → another member (owner only; caller demoted to admin unless --keep-owner)
|
||||
relicario org delete-org # owner only; explicit confirmation; LOCAL tombstone only (see caveat below)
|
||||
relicario org status # members, roles, collections — no decryption
|
||||
relicario org audit [--since ..] [--member ..] [--collection ..] [--action ..] [--format json]
|
||||
```
|
||||
|
||||
> **`delete-org` caveat (phase 1):** the pre-receive hook rejects deletion of the protected JSON files (`members.json` / `collections.json` / `org.json`) as part of schema-monotonicity enforcement. Therefore phase-1 `delete-org` is a **local tombstone only** — it removes the org files in the working tree and records a delete commit locally, but that commit **cannot be pushed to a hook-protected remote**. Pushing org teardown to a protected remote (a hook-side "owner may delete" exception) is a tracked phase-2 follow-up. `transfer-ownership` is fully hook-compatible (it only mutates `members.json` roles, owner-signed).
|
||||
|
||||
### Onboarding Flow
|
||||
|
||||
1. Alice runs `relicario device add`, exports her ed25519 public key (`signing.pub`).
|
||||
2. Alice sends her public key to an admin out-of-band (Signal, email, printed QR — Relicario does not mediate key exchange).
|
||||
3. Admin runs `org add-member --key <pubkey> --name Alice`. (An admin may add only `member` role; promoting to admin/owner requires an owner.)
|
||||
4. Alice pulls the org repo. She can now open the org vault.
|
||||
|
||||
### Offboarding Flow
|
||||
|
||||
1. Admin runs `org remove-member <id>` (deletes the key blob, updates `members.json`).
|
||||
2. Admin runs `org rotate-key` — generates a new org key, re-wraps it for remaining members, and **re-encrypts every item blob and the manifest** under the new key.
|
||||
3. The former member, even with the old key and a clone, can decrypt nothing post-rotation.
|
||||
|
||||
### Signing
|
||||
|
||||
`org init` calls `configure_git_signing(org_root, device_name)` so the org repo signs commits with the device's ed25519 key. All org writes are signed; the hook rejects anything else.
|
||||
|
||||
### Extension — Org Context
|
||||
|
||||
The vault tab gains a top-level org switcher (Personal + each configured org). Switching loads the selected org's manifest through the service worker. The SW holds the unwrapped org master key in a `Zeroizing` session handle — identical to the personal master key. The org master key is **never** written to `localStorage`, `IndexedDB`, or any persistent browser storage. If the git remote is unreachable, the org context is read-only with an "org offline — writes disabled" indicator. Phase-1 extension scope is: org switching, browsing/reading org items (grant-filtered), and the parity acceptance tests; full in-extension org item editing may be a tracked follow-up if it balloons.
|
||||
|
||||
---
|
||||
|
||||
## Audit Trail
|
||||
|
||||
### Git Log as Tamper-Evident Audit Record
|
||||
|
||||
Every write is a signed git commit carrying structured trailers:
|
||||
|
||||
```
|
||||
add item to prod-infra collection
|
||||
|
||||
Relicario-Actor: alice <a1b2c3d4e5f6a1b2>
|
||||
Relicario-Action: item-create
|
||||
Relicario-Collection: prod-infra
|
||||
Relicario-Item: 9f8e7d6c5b4a3f2e
|
||||
```
|
||||
|
||||
**Trailers are advisory, not authoritative.** A malicious committer can write any trailer text. The trustworthy actor identity is the **verified signing key**: `relicario org audit` resolves each commit's signature fingerprint to a `members.json` entry and reports that as the actor. Where the trailer's claimed actor disagrees with the verified signer, the commit is flagged `TAMPERED`. Timestamps use the committer date (`%cI`).
|
||||
|
||||
### Action Vocabulary
|
||||
|
||||
| `Relicario-Action` | Trigger |
|
||||
|---|---|
|
||||
| `item-create` / `item-update` / `item-delete` / `item-restore` / `item-purge` | org item add / edit / trash / restore / purge |
|
||||
| `member-add` / `member-remove` / `member-role-change` | member management |
|
||||
| `collection-create` / `collection-grant` / `collection-revoke` | collection management |
|
||||
| `key-rotate` | org key rotation |
|
||||
| `org-init` / `ownership-transfer` / `org-delete` | org lifecycle |
|
||||
|
||||
### `relicario org audit`
|
||||
|
||||
Parses `git log` (record separator `%x1e`, field separator `%x1f` to survive multi-line trailer values), resolves signer→member, applies `--since/--member/--collection/--action` filters, and emits a table or, with `--format json`, a JSON array ready for `… | <siem-ingest>` via cron. Each event includes the verified actor, action, collection, item, commit, committer timestamp, and a `tampered` flag.
|
||||
|
||||
---
|
||||
|
||||
## Pre-Receive Hook (`relicario-server verify-org-commit`)
|
||||
|
||||
`relicario-server` gains an org mode. For each pushed commit it:
|
||||
|
||||
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. 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.
|
||||
|
||||
`relicario-server generate-org-hook` emits the wrapper script that runs `verify-org-commit` per pushed commit.
|
||||
|
||||
---
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Key Rotation Race
|
||||
|
||||
`rotate-key` does `git pull --rebase` first. If the pull surfaces a non-fast-forward / conflict (a concurrent rotation), it aborts with `"Concurrent key rotation detected — pull and re-run org rotate-key."` A missing remote (local-only org) is distinguished and does not abort.
|
||||
|
||||
### Org Repo Schema Invalid
|
||||
|
||||
If `members.json`/`collections.json` fail validation on pull, the CLI refuses to open the org vault with a clear error. No silent degradation.
|
||||
|
||||
### Member Device Key Lost
|
||||
|
||||
If a member loses their device key before a backup device was added, an owner re-wraps the org key to a replacement device key the member generates. No master key escrow is needed — owners hold the org key and can always re-grant.
|
||||
|
||||
### Extension Offline
|
||||
|
||||
If the git remote is unreachable, the extension serves read-only from the last-pulled state and blocks writes with an indicator. Identical to personal vault offline behavior.
|
||||
|
||||
---
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Unit Tests (`relicario-core`)
|
||||
|
||||
- Org key wrap/unwrap round-trip (ed25519→X25519 + XChaCha20-Poly1305), including a pinned RFC 8032 known-answer vector so a future crate-version regression in the birational map is caught.
|
||||
- Manifest filtering by collection grant list.
|
||||
- `members.json` / `collections.json` schema validation (valid + invalid).
|
||||
- Secret intermediates are `Zeroizing` (compile-level).
|
||||
|
||||
### Integration Tests (`relicario-cli`)
|
||||
|
||||
- Full lifecycle against a local bare git repo: `org init → add-member → create-collection → grant → org add (item write) → audit` — verifying the item lands at `items/<slug>/<id>.enc` and the audit attributes the verified signer.
|
||||
- `remove-member → rotate-key` → former member cannot decrypt a re-encrypted item; remaining member can.
|
||||
- Grant enforcement: a member without a collection grant is refused `org add/get` for it.
|
||||
- `org audit --format json` is valid JSON matching the action vocabulary; a forged-trailer commit is flagged `TAMPERED`.
|
||||
- Concurrent `rotate-key` race aborts with the spec error string.
|
||||
|
||||
### Hook Tests (`relicario-server`)
|
||||
|
||||
- Unsigned commit rejected; commit signed by a non-member rejected.
|
||||
- Item write to an ungranted collection path rejected; to a granted one accepted.
|
||||
- Protected-file write by a member (non-admin) rejected.
|
||||
- `schema_version` decrease rejected. Genesis commit accepted; merge commit rejected.
|
||||
|
||||
### Extension Tests (vitest)
|
||||
|
||||
- SW org context switching replaces the personal manifest cleanly (no cross-contamination).
|
||||
- Org master key lives only in the Zeroizing session — never in `localStorage`/`IndexedDB`.
|
||||
- Offline read-only mode triggers on a git network error.
|
||||
|
||||
Org crypto bypasses Argon2id (key wrapping is X25519-based), so the fast-Argon2id test-params convention is irrelevant to org tests; standard params apply only where shared fixtures touch the personal path.
|
||||
|
||||
---
|
||||
|
||||
## Living-Docs Impact
|
||||
|
||||
This feature introduces new on-disk formats, a new crypto path, and a new dependency, so the following docs must be updated as the work lands (per CLAUDE.md living-docs discipline):
|
||||
|
||||
- `docs/FORMATS.md` — the four org JSON files, the `keys/<id>.enc` wrapped-blob layout, and `items/<slug>/<id>.enc`.
|
||||
- `docs/CRYPTO.md` — the ECIES org-key wrap/unwrap path and key-rotation re-encryption.
|
||||
- `DESIGN.md` — org-master-key row in the secrets map; the `x25519-dalek` dependency; relicario-server org mode.
|
||||
- `docs/SECURITY.md` — org device-key auth, the signature-verifying hook, and the honest limitations above.
|
||||
- `crates/relicario-core/ARCHITECTURE.md` and `crates/relicario-cli/ARCHITECTURE.md` — the new `org` modules.
|
||||
- `STATUS.md` / `ROADMAP.md` — the org-vault track and any tracked follow-ups (e.g. full extension org editing, SSO/LDAP).
|
||||
|
||||
---
|
||||
|
||||
## Phase Boundary
|
||||
|
||||
This spec covers phase 1 (git-native org, CLI + extension parity). Phase 2 adds:
|
||||
|
||||
- HTTP management plane via `relicario-server` (relay skeleton → org API)
|
||||
- Live audit event streaming to SIEM (webhooks, not cron-poll)
|
||||
- SSO/SAML assertion validation + LDAP/IdP member sync
|
||||
- Server-mediated read audit
|
||||
- "Hide value" autofill (per-item subkeys or server-mediated relay)
|
||||
- Per-collection cryptographic isolation (subkeys — explicit non-goal for phase 1)
|
||||
- Pushable `delete-org` org teardown (a hook-side "owner may delete protected files" exception); phase-1 `delete-org` is a local tombstone only
|
||||
3
tools/relay/.gitignore
vendored
Normal file
3
tools/relay/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
# Runtime message archive written by queue.ts post() — local relay traffic,
|
||||
# not source. Regenerated each session; never committed.
|
||||
relay-log.jsonl
|
||||
49
tools/relay/pm
Executable file
49
tools/relay/pm
Executable file
@@ -0,0 +1,49 @@
|
||||
#!/usr/bin/env bash
|
||||
# PM relay helper — absolute-path wrapper around call.py so it can be invoked
|
||||
# from ANY working directory with no `cd` and no JSON-quoting by hand.
|
||||
#
|
||||
# Usage:
|
||||
# tools/relay/pm read # drain PM inbox
|
||||
# tools/relay/pm pending # pending counts for all roles
|
||||
# tools/relay/pm send <to> <kind> <body> # post_message from pm
|
||||
# e.g. tools/relay/pm send dev-c directive "## DIRECTIVE ... "
|
||||
#
|
||||
# Always works regardless of cwd because it resolves call.py by absolute path.
|
||||
set -euo pipefail
|
||||
|
||||
RELAY_DIR="/home/alee/Sources/relicario/tools/relay"
|
||||
CALL="python3 $RELAY_DIR/call.py"
|
||||
|
||||
cmd="${1:-}"
|
||||
case "$cmd" in
|
||||
read)
|
||||
$CALL read_messages '{"for":"pm"}'
|
||||
;;
|
||||
pending)
|
||||
for r in dev-a dev-b dev-c pm; do
|
||||
printf '%s: ' "$r"
|
||||
$CALL list_pending "{\"for\":\"$r\"}"
|
||||
echo
|
||||
done
|
||||
;;
|
||||
send)
|
||||
to="${2:?usage: pm send <to> <kind> <body>}"
|
||||
kind="${3:?usage: pm send <to> <kind> <body>}"
|
||||
body="${4:?usage: pm send <to> <kind> <body>}"
|
||||
# Build JSON with python to handle escaping of the body safely.
|
||||
python3 - "$to" "$kind" "$body" <<'PY'
|
||||
import json, sys, urllib.request
|
||||
to, kind, body = sys.argv[1], sys.argv[2], sys.argv[3]
|
||||
payload = {"from": "pm", "to": to, "kind": kind, "body": body}
|
||||
import subprocess
|
||||
print(subprocess.run(
|
||||
["python3", "/home/alee/Sources/relicario/tools/relay/call.py",
|
||||
"post_message", json.dumps(payload)],
|
||||
capture_output=True, text=True).stdout, end="")
|
||||
PY
|
||||
;;
|
||||
*)
|
||||
echo "usage: pm {read|pending|send <to> <kind> <body>}" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
@@ -1,4 +1,13 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { appendFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
// Append-only archive of every posted message. The in-memory queues are
|
||||
// consume-once (read() drains the inbox) and vanish on restart, so this is
|
||||
// the only durable, full-body record of relay traffic. One JSON object per
|
||||
// line; never truncated.
|
||||
const LOG_PATH = join(dirname(fileURLToPath(import.meta.url)), "relay-log.jsonl");
|
||||
|
||||
export type Role = "pm" | "dev-a" | "dev-b" | "dev-c" | "dev-d" | "dev-e" | "dev-f";
|
||||
export type MessageKind = "status" | "question" | "directive" | "free";
|
||||
@@ -39,6 +48,11 @@ export class RelayQueue {
|
||||
ts: new Date().toISOString(),
|
||||
};
|
||||
this.queues.get(to)!.push(msg);
|
||||
try {
|
||||
appendFileSync(LOG_PATH, JSON.stringify(msg) + "\n");
|
||||
} catch {
|
||||
// Logging is best-effort; never let a disk error drop a message.
|
||||
}
|
||||
return msg;
|
||||
}
|
||||
|
||||
|
||||
@@ -86,8 +86,8 @@ function handleToolCall(name: string, args: Record<string, string>) {
|
||||
const kind = args.kind as "status" | "question" | "directive" | "free";
|
||||
const msg = queue.post(args.from, args.to, kind, args.body);
|
||||
const ts = new Date(msg.ts).toTimeString().slice(0, 8);
|
||||
const preview = args.body.slice(0, 60).replace(/\n/g, " ");
|
||||
const ellipsis = args.body.length > 60 ? "..." : "";
|
||||
const preview = args.body.slice(0, 120).replace(/\n/g, " ");
|
||||
const ellipsis = args.body.length > 120 ? "..." : "";
|
||||
process.stdout.write(`[${ts}] ${args.from} → ${args.to} [${kind}] "${preview}${ellipsis}"\n`);
|
||||
return { content: [{ type: "text" as const, text: JSON.stringify({ id: msg.id }) }] };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user