From 7faedf857802ab4c9dc6f039e18bf12780a2683f Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sat, 20 Jun 2026 10:27:08 -0400 Subject: [PATCH] =?UTF-8?q?feat(cli/org):=20org=20init=20=E2=80=94=20struc?= =?UTF-8?q?ture=20+=20wrap=20+=20configure=5Fgit=5Fsigning=20+=20signed=20?= =?UTF-8?q?bootstrap=20commit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/relicario-cli/src/commands/org.rs | 95 +++++++++++- crates/relicario-cli/src/main.rs | 22 +++ crates/relicario-cli/tests/org_init.rs | 24 +++ .../relicario-cli/tests/org_init_signing.rs | 137 ++++++++++++++++++ 4 files changed, 275 insertions(+), 3 deletions(-) create mode 100644 crates/relicario-cli/tests/org_init.rs create mode 100644 crates/relicario-cli/tests/org_init_signing.rs diff --git a/crates/relicario-cli/src/commands/org.rs b/crates/relicario-cli/src/commands/org.rs index 37dd8ee..e2bc03a 100644 --- a/crates/relicario-cli/src/commands/org.rs +++ b/crates/relicario-cli/src/commands/org.rs @@ -1,7 +1,96 @@ //! `relicario org` subcommands for multi-user org vault management. -use anyhow::Result; +use std::fs; +use std::path::Path; -pub fn run_init(_dir: &std::path::Path, _name: &str) -> Result<()> { - todo!("org init") +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()) } diff --git a/crates/relicario-cli/src/main.rs b/crates/relicario-cli/src/main.rs index 4d2eb7a..2a9f80c 100644 --- a/crates/relicario-cli/src/main.rs +++ b/crates/relicario-cli/src/main.rs @@ -207,6 +207,12 @@ enum Commands { #[command(subcommand)] cmd: RecoveryQrCmd, }, + + /// Multi-user org vault operations. + Org { + #[command(subcommand)] + action: OrgAction, + }, } #[derive(Subcommand)] @@ -422,6 +428,19 @@ pub(crate) enum RecoveryQrCmd { Unwrap, } +#[derive(clap::Subcommand)] +pub(crate) enum OrgAction { + /// Initialize a new org vault (creates dir structure, git repo, signed commit). + Init { + /// Directory to create the org vault in. + #[arg(long)] + dir: PathBuf, + /// Human-readable display name for the org. + #[arg(long)] + name: String, + }, +} + fn main() -> Result<()> { let cli = Cli::parse(); match cli.command { @@ -456,6 +475,9 @@ 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 { action } => match action { + OrgAction::Init { dir, name } => commands::org::run_init(&dir, &name), + }, } } diff --git a/crates/relicario-cli/tests/org_init.rs b/crates/relicario-cli/tests/org_init.rs new file mode 100644 index 0000000..a2d708b --- /dev/null +++ b/crates/relicario-cli/tests/org_init.rs @@ -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()); +} diff --git a/crates/relicario-cli/tests/org_init_signing.rs b/crates/relicario-cli/tests/org_init_signing.rs new file mode 100644 index 0000000..981d623 --- /dev/null +++ b/crates/relicario-cli/tests/org_init_signing.rs @@ -0,0 +1,137 @@ +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 `/relicario/devices//` +/// 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) + ); +}