feat(cli/org): org init — structure + wrap + configure_git_signing + signed bootstrap commit
This commit is contained in:
@@ -1,7 +1,96 @@
|
|||||||
//! `relicario org` subcommands for multi-user org vault management.
|
//! `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<()> {
|
use anyhow::{Context, Result};
|
||||||
todo!("org init")
|
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())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -207,6 +207,12 @@ enum Commands {
|
|||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
cmd: RecoveryQrCmd,
|
cmd: RecoveryQrCmd,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/// Multi-user org vault operations.
|
||||||
|
Org {
|
||||||
|
#[command(subcommand)]
|
||||||
|
action: OrgAction,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Subcommand)]
|
#[derive(Subcommand)]
|
||||||
@@ -422,6 +428,19 @@ pub(crate) enum RecoveryQrCmd {
|
|||||||
Unwrap,
|
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<()> {
|
fn main() -> Result<()> {
|
||||||
let cli = Cli::parse();
|
let cli = Cli::parse();
|
||||||
match cli.command {
|
match cli.command {
|
||||||
@@ -456,6 +475,9 @@ fn main() -> Result<()> {
|
|||||||
Commands::Rate { passphrase } => commands::rate::cmd_rate(passphrase),
|
Commands::Rate { passphrase } => commands::rate::cmd_rate(passphrase),
|
||||||
Commands::Device { action } => commands::device::cmd_device(action),
|
Commands::Device { action } => commands::device::cmd_device(action),
|
||||||
Commands::RecoveryQr { cmd } => commands::recovery_qr::cmd_recovery_qr(cmd),
|
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),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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());
|
||||||
|
}
|
||||||
137
crates/relicario-cli/tests/org_init_signing.rs
Normal file
137
crates/relicario-cli/tests/org_init_signing.rs
Normal file
@@ -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 `<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)
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user