merge(cli): dev-b org stream B1-B6 — session, init, member/collection admin commands (dormant until B14 wiring)
This commit is contained in:
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -2166,6 +2166,7 @@ dependencies = [
|
||||
"clap_complete",
|
||||
"data-encoding",
|
||||
"dirs",
|
||||
"ed25519-dalek",
|
||||
"hex",
|
||||
"image",
|
||||
"predicates",
|
||||
@@ -2177,6 +2178,7 @@ dependencies = [
|
||||
"rqrr",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"ssh-key",
|
||||
"tar",
|
||||
"tempfile",
|
||||
"url",
|
||||
|
||||
@@ -30,9 +30,11 @@ 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"] }
|
||||
|
||||
[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;
|
||||
|
||||
388
crates/relicario-cli/src/commands/org.rs
Normal file
388
crates/relicario-cli/src/commands/org.rs
Normal file
@@ -0,0 +1,388 @@
|
||||
//! `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()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use relicario_core::{MemberId, OrgMembers, OrgRole, OrgMember};
|
||||
|
||||
fn alice() -> OrgMember {
|
||||
OrgMember {
|
||||
member_id: MemberId::new(),
|
||||
display_name: "Alice".into(),
|
||||
role: OrgRole::Member,
|
||||
ed25519_pubkey: "ssh-ed25519 AAAA fake".into(),
|
||||
collections: vec![],
|
||||
added_at: 0,
|
||||
added_by: MemberId::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_role_changes_role() {
|
||||
let mut members = OrgMembers::new();
|
||||
let a = alice();
|
||||
let id = a.member_id.clone();
|
||||
members.members.push(a);
|
||||
if let Some(m) = members.find_by_id_mut(&id) {
|
||||
m.role = OrgRole::Admin;
|
||||
}
|
||||
assert_eq!(members.find_by_id(&id).unwrap().role, OrgRole::Admin);
|
||||
}
|
||||
|
||||
#[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}"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user