use std::fs;
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use std::time::{SystemTime, UNIX_EPOCH};
use anyhow::{Context, Result, anyhow};
use clap::{Parser, Subcommand};
use ed25519_dalek::{PUBLIC_KEY_LENGTH, VerifyingKey};
use ai_memory::federation::identity::issuer::{
DEFAULT_CREDENTIAL_TTL_SECS, FederationIssuer, IssuerConfig,
};
use ai_memory::identity::keypair;
const VERIFYING_KEY_LEN: usize = PUBLIC_KEY_LENGTH;
const PUB_SUFFIX: &str = ".pub";
#[cfg(unix)]
const SECRET_DIR_MODE: u32 = 0o700;
const CRED_FILE_MODE: u32 = 0o600;
const PUB_FILE_MODE: u32 = 0o644;
#[derive(Parser)]
#[command(
name = "fed_issue",
about = "First-party zero-touch federation issuer (mint-ca / export-bundle / issue)",
long_about = None,
)]
struct Cli {
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
MintCa {
#[arg(long)]
key_dir: PathBuf,
#[arg(long)]
issuer_id: String,
},
GenNode {
#[arg(long)]
key_dir: PathBuf,
#[arg(long)]
agent_id: String,
},
ExportBundle {
#[arg(long)]
key_dir: PathBuf,
#[arg(long)]
issuer_id: String,
#[arg(long)]
bundle_dir: PathBuf,
},
Issue {
#[arg(long)]
key_dir: PathBuf,
#[arg(long)]
issuer_id: String,
#[arg(long)]
trust_domain: String,
#[arg(long)]
subject_agent_id: String,
#[arg(long)]
subject_pub_file: PathBuf,
#[arg(long)]
out: PathBuf,
#[arg(long, default_value_t = DEFAULT_CREDENTIAL_TTL_SECS)]
ttl_secs: i64,
},
VerifyCred {
#[arg(long)]
cred_file: PathBuf,
#[arg(long)]
bundle_dir: PathBuf,
#[arg(long)]
trust_domain: String,
},
}
fn main() -> ExitCode {
let cli = Cli::parse();
match run(cli.command) {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("fed_issue: {e:#}");
ExitCode::FAILURE
}
}
}
fn run(command: Command) -> Result<()> {
match command {
Command::MintCa { key_dir, issuer_id } => {
let pub_b64 = mint_ca(&key_dir, &issuer_id)?;
println!("issuer {issuer_id} ready (pub {pub_b64})");
Ok(())
}
Command::GenNode { key_dir, agent_id } => {
let pub_b64 = gen_node(&key_dir, &agent_id)?;
println!("node {agent_id} ready (pub {pub_b64})");
Ok(())
}
Command::ExportBundle {
key_dir,
issuer_id,
bundle_dir,
} => {
let path = export_bundle(&key_dir, &issuer_id, &bundle_dir)?;
println!("bundle entry written: {}", path.display());
Ok(())
}
Command::Issue {
key_dir,
issuer_id,
trust_domain,
subject_agent_id,
subject_pub_file,
out,
ttl_secs,
} => {
issue_credential(
&key_dir,
&issuer_id,
&trust_domain,
ttl_secs,
&subject_agent_id,
&subject_pub_file,
&out,
)?;
println!(
"credential for {subject_agent_id} (ttl {ttl_secs}s) written: {}",
out.display()
);
Ok(())
}
Command::VerifyCred {
cred_file,
bundle_dir,
trust_domain,
} => {
let subject = verify_cred(&cred_file, &bundle_dir, &trust_domain)?;
println!(
"credential VERIFIED: subject={subject} chains to a trusted issuer in {} (domain={trust_domain})",
bundle_dir.display()
);
Ok(())
}
}
}
fn verify_cred(cred_file: &Path, bundle_dir: &Path, trust_domain: &str) -> Result<String> {
use ai_memory::federation::identity::credential::SignedCredential;
use ai_memory::federation::identity::trust_bundle::TrustBundle;
let raw = std::fs::read_to_string(cred_file)
.with_context(|| format!("reading credential file {}", cred_file.display()))?;
let signed = SignedCredential::from_header_value(raw.trim())
.map_err(|e| anyhow::anyhow!("parsing credential {}: {e}", cred_file.display()))?;
let bundle = TrustBundle::from_dir(bundle_dir)
.with_context(|| format!("loading trust bundle from {}", bundle_dir.display()))?
.with_trust_domain(trust_domain.to_string());
let now = now_unix()?;
let cred = bundle
.verify(&signed, now)
.map_err(|e| anyhow::anyhow!("credential failed CA trust-chain verification: {e}"))?;
Ok(cred.subject_agent_id)
}
fn validate_issuer_id(issuer_id: &str) -> Result<()> {
if issuer_id.is_empty() {
return Err(anyhow!("issuer-id must not be empty"));
}
if issuer_id.contains('/') || issuer_id.contains('\\') {
return Err(anyhow!(
"issuer-id {issuer_id:?} must not contain a path separator — trust \
bundles are non-recursive (the published <issuer-id>.pub must be a \
direct child of the bundle dir, or it is never loaded). Use a \
slash-free id like 'hive-1461-ca'; node subject-agent-ids may still \
be slashed."
));
}
Ok(())
}
fn now_unix() -> Result<i64> {
let secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.context("system clock is before the UNIX epoch")?
.as_secs();
i64::try_from(secs).map_err(|_| anyhow!("UNIX time overflows i64"))
}
fn mint_ca(key_dir: &Path, issuer_id: &str) -> Result<String> {
validate_issuer_id(issuer_id)?;
ensure_secret_dir(key_dir)?;
let priv_path = key_dir.join(format!("{issuer_id}{}", ".priv"));
let keypair = if priv_path.exists() {
keypair::load(issuer_id, key_dir)
.with_context(|| format!("reuse existing issuer key for {issuer_id}"))?
} else {
let kp =
keypair::generate(issuer_id).with_context(|| format!("generate issuer {issuer_id}"))?;
keypair::save(&kp, key_dir).with_context(|| format!("persist issuer {issuer_id}"))?;
kp
};
Ok(keypair.public_base64())
}
fn gen_node(key_dir: &Path, agent_id: &str) -> Result<String> {
ensure_secret_dir(key_dir)?;
let priv_path = key_dir.join(format!("{agent_id}{}", ".priv"));
let keypair = if priv_path.exists() {
keypair::load(agent_id, key_dir)
.with_context(|| format!("reuse existing node key for {agent_id}"))?
} else {
let kp =
keypair::generate(agent_id).with_context(|| format!("generate node {agent_id}"))?;
keypair::save(&kp, key_dir).with_context(|| format!("persist node {agent_id}"))?;
kp
};
Ok(keypair.public_base64())
}
fn export_bundle(key_dir: &Path, issuer_id: &str, bundle_dir: &Path) -> Result<PathBuf> {
validate_issuer_id(issuer_id)?;
let keypair = keypair::load(issuer_id, key_dir)
.with_context(|| format!("load issuer {issuer_id} for bundle export"))?;
ensure_dir(bundle_dir)?;
let dest = bundle_dir.join(format!("{issuer_id}{PUB_SUFFIX}"));
if let Some(parent) = dest.parent() {
ensure_dir(parent)?;
}
write_with_mode(&dest, &keypair.public.to_bytes(), PUB_FILE_MODE)
.with_context(|| format!("write bundle entry {}", dest.display()))?;
Ok(dest)
}
fn issue_credential(
key_dir: &Path,
issuer_id: &str,
trust_domain: &str,
ttl_secs: i64,
subject_agent_id: &str,
subject_pub_file: &Path,
out: &Path,
) -> Result<()> {
validate_issuer_id(issuer_id)?;
let config = IssuerConfig::new(issuer_id, trust_domain).with_ttl_secs(ttl_secs);
let issuer = FederationIssuer::load(config, key_dir)
.map_err(|e| anyhow!("load issuer {issuer_id}: {e}"))?;
let subject_pub = read_verifying_key(subject_pub_file)?;
let signed = issuer
.issue(subject_agent_id, &subject_pub, now_unix()?)
.map_err(|e| anyhow!("mint credential for {subject_agent_id}: {e}"))?;
let header = signed
.to_header_value()
.map_err(|e| anyhow!("encode credential: {e}"))?;
if let Some(parent) = out.parent() {
ensure_dir(parent)?;
}
write_with_mode(out, header.as_bytes(), CRED_FILE_MODE)
.with_context(|| format!("write credential {}", out.display()))?;
Ok(())
}
fn read_verifying_key(path: &Path) -> Result<VerifyingKey> {
let bytes =
fs::read(path).with_context(|| format!("read subject pubkey {}", path.display()))?;
let arr: [u8; VERIFYING_KEY_LEN] = bytes.as_slice().try_into().map_err(|_| {
anyhow!(
"subject pubkey {} is {} bytes, expected {VERIFYING_KEY_LEN}",
path.display(),
bytes.len()
)
})?;
VerifyingKey::from_bytes(&arr).map_err(|e| {
anyhow!(
"subject pubkey {} is not a valid Ed25519 point: {e}",
path.display()
)
})
}
fn ensure_dir(dir: &Path) -> Result<()> {
fs::create_dir_all(dir).with_context(|| format!("create dir {}", dir.display()))
}
fn ensure_secret_dir(dir: &Path) -> Result<()> {
ensure_dir(dir)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(dir, fs::Permissions::from_mode(SECRET_DIR_MODE))
.with_context(|| format!("chmod {SECRET_DIR_MODE:o} {}", dir.display()))?;
}
Ok(())
}
fn write_with_mode(path: &Path, bytes: &[u8], mode: u32) -> Result<()> {
fs::write(path, bytes).with_context(|| format!("write {}", path.display()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(path, fs::Permissions::from_mode(mode))
.with_context(|| format!("chmod {mode:o} {}", path.display()))?;
}
#[cfg(not(unix))]
{
let _ = mode;
}
Ok(())
}
#[cfg(test)]
fn generate_node_pub(key_dir: &Path, agent_id: &str) -> Result<VerifyingKey> {
let kp = keypair::generate(agent_id)?;
keypair::save(&kp, key_dir)?;
Ok(kp.public)
}
#[cfg(test)]
mod tests {
use super::*;
use ai_memory::federation::identity::credential::SignedCredential;
use ai_memory::federation::identity::trust_bundle::TrustBundle;
const ISSUER_ID: &str = "hive-1461-ca";
const TRUST_DOMAIN: &str = "hive.fleet";
const NODE_ID: &str = "hive-1461/nyc3/hive-peer-nyc3-01";
fn tmp() -> tempfile::TempDir {
tempfile::tempdir().expect("tempdir")
}
#[test]
fn mint_ca_is_idempotent() {
let dir = tmp();
let first = mint_ca(dir.path(), ISSUER_ID).expect("first mint");
let second = mint_ca(dir.path(), ISSUER_ID).expect("re-mint reuses");
assert_eq!(first, second, "re-running mint-ca must keep a stable CA");
assert!(dir.path().join(format!("{ISSUER_ID}.priv")).exists());
assert!(dir.path().join(format!("{ISSUER_ID}.pub")).exists());
}
#[test]
fn export_bundle_writes_raw_issuer_key() {
let keys = tmp();
let bundle = tmp();
mint_ca(keys.path(), ISSUER_ID).expect("mint");
let dest = export_bundle(keys.path(), ISSUER_ID, bundle.path()).expect("export");
let bytes = fs::read(&dest).expect("read bundle entry");
assert_eq!(bytes.len(), VERIFYING_KEY_LEN);
let loaded = TrustBundle::from_dir(bundle.path()).expect("load bundle");
assert_eq!(loaded.len(), 1);
}
#[test]
fn issued_credential_verifies_against_the_published_bundle() {
let keys = tmp();
let bundle = tmp();
let nodekeys = tmp();
let out = tmp();
mint_ca(keys.path(), ISSUER_ID).expect("mint");
export_bundle(keys.path(), ISSUER_ID, bundle.path()).expect("export");
let node_pub = generate_node_pub(nodekeys.path(), NODE_ID).expect("node key");
let subject_pub_file = nodekeys.path().join(format!("{NODE_ID}.pub"));
let cred_path = out.path().join("peer-1.cred");
issue_credential(
keys.path(),
ISSUER_ID,
TRUST_DOMAIN,
DEFAULT_CREDENTIAL_TTL_SECS,
NODE_ID,
&subject_pub_file,
&cred_path,
)
.expect("issue");
let loaded = SignedCredential::load_from_path(&cred_path)
.expect("read cred")
.expect("cred present");
let domain_bundle = TrustBundle::from_dir(bundle.path())
.expect("bundle")
.with_trust_domain(TRUST_DOMAIN);
let verified = domain_bundle
.verify(&loaded, now_unix().unwrap())
.expect("trust-bundle verifies the issued credential");
assert_eq!(verified.subject_agent_id, NODE_ID);
assert_eq!(verified.issuer_id, ISSUER_ID);
assert_eq!(verified.trust_domain, TRUST_DOMAIN);
assert_eq!(verified.subject_pubkey, node_pub.to_bytes());
}
#[test]
fn gen_node_is_idempotent_and_persists_slashed_keypair() {
let dir = tmp();
let first = gen_node(dir.path(), NODE_ID).expect("first gen");
let second = gen_node(dir.path(), NODE_ID).expect("re-gen reuses");
assert_eq!(
first, second,
"re-running gen-node must keep a stable node key"
);
assert!(dir.path().join(format!("{NODE_ID}.priv")).exists());
assert!(dir.path().join(format!("{NODE_ID}.pub")).exists());
let loaded = keypair::load(NODE_ID, dir.path()).expect("load node");
assert_eq!(loaded.public_base64(), first);
assert!(
loaded.can_sign(),
"node keypair must carry its private half"
);
}
#[test]
fn mint_ca_rejects_slashed_issuer_id() {
let dir = tmp();
let err = mint_ca(dir.path(), "hive/ca").expect_err("slashed issuer must be refused");
assert!(
err.to_string().contains("path separator"),
"expected the non-recursive-bundle explanation, got: {err}"
);
assert!(
mint_ca(dir.path(), "").is_err(),
"empty issuer must be refused"
);
}
#[test]
fn issue_rejects_wrong_length_subject_pub() {
let keys = tmp();
let bad = tmp();
let out = tmp();
mint_ca(keys.path(), ISSUER_ID).expect("mint");
let bad_pub = bad.path().join("short.pub");
fs::write(&bad_pub, [0u8; 8]).expect("write short");
let err = issue_credential(
keys.path(),
ISSUER_ID,
TRUST_DOMAIN,
DEFAULT_CREDENTIAL_TTL_SECS,
NODE_ID,
&bad_pub,
&out.path().join("x.cred"),
)
.expect_err("short pubkey must fail");
assert!(err.to_string().contains("expected"), "got: {err}");
}
#[test]
fn issue_without_issuer_key_fails() {
let keys = tmp();
let nodekeys = tmp();
let out = tmp();
generate_node_pub(nodekeys.path(), NODE_ID).expect("node key");
let subject_pub_file = nodekeys.path().join(format!("{NODE_ID}.pub"));
let err = issue_credential(
keys.path(),
ISSUER_ID,
TRUST_DOMAIN,
DEFAULT_CREDENTIAL_TTL_SECS,
NODE_ID,
&subject_pub_file,
&out.path().join("x.cred"),
)
.expect_err("missing issuer must fail");
assert!(err.to_string().contains("load issuer"), "got: {err}");
}
#[test]
fn cross_domain_credential_is_rejected_by_scoped_bundle() {
let keys = tmp();
let bundle = tmp();
let nodekeys = tmp();
let out = tmp();
mint_ca(keys.path(), ISSUER_ID).expect("mint");
export_bundle(keys.path(), ISSUER_ID, bundle.path()).expect("export");
generate_node_pub(nodekeys.path(), NODE_ID).expect("node key");
let subject_pub_file = nodekeys.path().join(format!("{NODE_ID}.pub"));
let cred_path = out.path().join("peer.cred");
issue_credential(
keys.path(),
ISSUER_ID,
TRUST_DOMAIN,
DEFAULT_CREDENTIAL_TTL_SECS,
NODE_ID,
&subject_pub_file,
&cred_path,
)
.expect("issue");
let loaded = SignedCredential::load_from_path(&cred_path)
.expect("read")
.expect("present");
let other_domain = TrustBundle::from_dir(bundle.path())
.expect("bundle")
.with_trust_domain("other.fleet");
assert!(
other_domain.verify(&loaded, now_unix().unwrap()).is_err(),
"a bundle scoped to a different domain must reject the credential"
);
}
}