#![forbid(unsafe_code)]
use std::collections::BTreeMap;
use std::fmt::Write as _;
use std::io::Read as _;
use std::net::SocketAddr;
use std::process::ExitCode;
use std::time::{SystemTime, UNIX_EPOCH};
use ed25519_dalek::{Signer, SigningKey};
use getrandom::SysRng;
use rand_core::UnwrapErr;
use yip_membership::cert::{cert_signing_body, rootset_signing_body, Cert, RootSet};
const SECS_PER_DAY: u64 = 86_400;
fn main() -> ExitCode {
let mut args = std::env::args();
let _prog = args.next();
let cmd = args.next();
let rest: Vec<String> = args.collect();
let result = match cmd.as_deref() {
Some("genkey") => cmd_genkey(),
Some("sign-cert") => cmd_sign_cert(&rest),
Some("sign-roots") => cmd_sign_roots(&rest),
Some("dealer-gen") => cmd_dealer_gen(&rest),
Some("serve") => cmd_serve(&rest),
Some("enroll") => cmd_enroll(&rest),
_ => Err(usage()),
};
match result {
Ok(()) => ExitCode::SUCCESS,
Err(msg) => {
eprintln!("{msg}");
ExitCode::from(2)
}
}
}
fn usage() -> String {
"usage: yip-ca <genkey|sign-cert|sign-roots|dealer-gen|serve|enroll> [args]\n\
\n\
yip-ca genkey\n\
yip-ca sign-cert --member <hex32> --member-sign <hex32> --network <hex16> (--days <N> | --secs <N>) [--ca-private <hex>]\n\
yip-ca sign-roots --roots <file> --version <N> [--ca-private <hex>]\n\
yip-ca dealer-gen --threshold <T> --signers <N> --out-dir <DIR>\n\
yip-ca serve --role <coordinator|signer> ...\n\
yip-ca enroll --server <ADDR> --member <hex32> --member-sign <hex32> --network <hex16> [--out <FILE>]\n\
\n\
Exactly one of --days/--secs sets the cert's validity window: --days N\n\
is N*86400 seconds, --secs N is exactly N seconds (overrides --days if\n\
both are given). --secs is meant for minting short-lived certs in\n\
revocation tests.\n\
\n\
If --ca-private is omitted, the CA private key hex is read from stdin."
.to_string()
}
fn cmd_dealer_gen(args: &[String]) -> Result<(), String> {
let flags = parse_flags(args)?;
let threshold: u16 = require(&flags, "threshold")?
.parse()
.map_err(|e| format!("bad threshold: {e}"))?;
let signers: u16 = require(&flags, "signers")?
.parse()
.map_err(|e| format!("bad signers: {e}"))?;
let out_dir = require(&flags, "out-dir")?;
let (pubkey_package, key_packages) =
yip_ca::threshold::dealer_generate(threshold, signers).map_err(|e| e.to_string())?;
std::fs::create_dir_all(out_dir).map_err(|e| format!("failed to create dir {out_dir}: {e}"))?;
let pk_json = serde_json::to_string_pretty(&pubkey_package).map_err(|e| e.to_string())?;
std::fs::write(format!("{out_dir}/pubkey_package.json"), pk_json).map_err(|e| e.to_string())?;
for (i, (_id, kp)) in key_packages.iter().enumerate() {
let kp_json = serde_json::to_string_pretty(kp).map_err(|e| e.to_string())?;
std::fs::write(format!("{out_dir}/signer_{}.json", i + 1), kp_json)
.map_err(|e| e.to_string())?;
}
let root_vk = pubkey_package
.verifying_key()
.serialize()
.map_err(|e| e.to_string())?;
println!("threshold_ca_public={}", hex_encode(&root_vk));
println!("generated {threshold}-of-{signers} keys in {out_dir}");
Ok(())
}
fn cmd_serve(args: &[String]) -> Result<(), String> {
let flags = parse_flags(args)?;
let role = require(&flags, "role")?;
let rt = tokio::runtime::Runtime::new().map_err(|e| e.to_string())?;
match role {
"coordinator" => {
let bind_addr: SocketAddr = require(&flags, "bind")?
.parse()
.map_err(|e| format!("bad --bind: {e}"))?;
let threshold: usize = require(&flags, "threshold")?
.parse()
.map_err(|e| format!("bad --threshold: {e}"))?;
let pubkey_path = require(&flags, "pubkey")?;
let pubkey_str = std::fs::read_to_string(pubkey_path)
.map_err(|e| format!("failed to read {pubkey_path}: {e}"))?;
let pubkey_package: frost_ed25519::keys::PublicKeyPackage =
serde_json::from_str(&pubkey_str)
.map_err(|e| format!("failed to parse {pubkey_path}: {e}"))?;
rt.block_on(async {
let listener = tokio::net::TcpListener::bind(bind_addr)
.await
.map_err(|e| e.to_string())?;
println!("coordinator listening on {bind_addr} (threshold={threshold})");
yip_ca::serve::run_coordinator(listener, threshold, pubkey_package)
.await
.map_err(|e| e.to_string())
})
}
"signer" => {
let coord_addr: SocketAddr = require(&flags, "coordinator")?
.parse()
.map_err(|e| format!("bad --coordinator: {e}"))?;
let key_path = require(&flags, "key")?;
let pubkey_path = require(&flags, "pubkey")?;
let key_str = std::fs::read_to_string(key_path)
.map_err(|e| format!("failed to read {key_path}: {e}"))?;
let pubkey_str = std::fs::read_to_string(pubkey_path)
.map_err(|e| format!("failed to read {pubkey_path}: {e}"))?;
let key_package: frost_ed25519::keys::KeyPackage = serde_json::from_str(&key_str)
.map_err(|e| format!("failed to parse {key_path}: {e}"))?;
let pubkey_package: frost_ed25519::keys::PublicKeyPackage =
serde_json::from_str(&pubkey_str)
.map_err(|e| format!("failed to parse {pubkey_path}: {e}"))?;
let max_ttl_secs = flags
.get("max-ttl")
.and_then(|s| s.parse().ok())
.unwrap_or(86_400 * 30);
let policy = yip_ca::signer_daemon::SignerPolicy {
max_ttl_secs,
..Default::default()
};
rt.block_on(async {
println!("signer connecting to coordinator at {coord_addr}...");
yip_ca::serve::run_signer(coord_addr, key_package, pubkey_package, policy)
.await
.map_err(|e| e.to_string())
})
}
_ => Err(format!(
"unknown role {role:?}, expected 'coordinator' or 'signer'"
)),
}
}
fn cmd_enroll(args: &[String]) -> Result<(), String> {
let flags = parse_flags(args)?;
let server_addr: SocketAddr = require(&flags, "server")?
.parse()
.map_err(|e| format!("bad --server: {e}"))?;
let member_pubkey = fixed32(&hex_decode(require(&flags, "member")?)?, "member")?;
let member_sign_pubkey = fixed32(&hex_decode(require(&flags, "member-sign")?)?, "member-sign")?;
let network_id = fixed16(&hex_decode(require(&flags, "network")?)?, "network")?;
let client_key = match flags.get("client-key") {
Some(hex) => {
let b = fixed32(&hex_decode(hex)?, "client-key")?;
SigningKey::from_bytes(&b)
}
None => SigningKey::generate(&mut UnwrapErr(SysRng)),
};
let credential = yip_ca::rpc::EnrollmentCredential::RenewalCert {
existing_cert_bytes: Vec::new(),
renewal_signature: [0u8; 64],
};
let rt = tokio::runtime::Runtime::new().map_err(|e| e.to_string())?;
let cert = rt.block_on(async {
yip_ca::enroll::enroll_node(
server_addr,
&client_key,
member_pubkey,
member_sign_pubkey,
network_id,
credential,
)
.await
})?;
let mut cert_bytes = Vec::new();
cert.encode(&mut cert_bytes);
if let Some(out_path) = flags.get("out") {
std::fs::write(out_path, &cert_bytes)
.map_err(|e| format!("failed to write cert to {out_path}: {e}"))?;
println!("wrote certificate to {out_path}");
} else {
println!("cert_hex={}", hex_encode(&cert_bytes));
}
Ok(())
}
fn cmd_genkey() -> Result<(), String> {
let signing_key = SigningKey::generate(&mut UnwrapErr(SysRng));
let verifying_key = signing_key.verifying_key();
println!("ca_private={}", hex_encode(&signing_key.to_bytes()));
println!("ca_public={}", hex_encode(verifying_key.as_bytes()));
Ok(())
}
fn cmd_sign_cert(args: &[String]) -> Result<(), String> {
let flags = parse_flags(args)?;
let member_pubkey = fixed32(&hex_decode(require(&flags, "member")?)?, "member")?;
let member_sign_pubkey = fixed32(&hex_decode(require(&flags, "member-sign")?)?, "member-sign")?;
let network_id = fixed16(&hex_decode(require(&flags, "network")?)?, "network")?;
let ca_key = load_ca_private(flags.get("ca-private").map(String::as_str))?;
let not_before = now_secs()?;
let validity = match (flags.get("secs"), flags.get("days")) {
(Some(secs_str), _) => secs_str
.parse::<u64>()
.map_err(|e| format!("bad --secs {secs_str:?}: {e}"))?,
(None, Some(days_str)) => {
let days: u64 = days_str
.parse()
.map_err(|e| format!("bad --days {days_str:?}: {e}"))?;
days.checked_mul(SECS_PER_DAY)
.ok_or_else(|| "--days too large: overflow computing validity window".to_string())?
}
(None, None) => return Err("missing required --days (or --secs)".to_string()),
};
let not_after = not_before
.checked_add(validity)
.ok_or_else(|| "validity window too large: overflow computing not_after".to_string())?;
let mut cert = Cert {
version: 1,
member_pubkey,
member_sign_pubkey,
network_id,
not_before,
not_after,
tags: vec![],
ca_sig: [0u8; 64],
};
let sig = ca_key.sign(&cert_signing_body(&cert));
cert.ca_sig = sig.to_bytes();
let mut buf = Vec::new();
cert.encode(&mut buf);
println!("{}", hex_encode(&buf));
Ok(())
}
fn cmd_sign_roots(args: &[String]) -> Result<(), String> {
let flags = parse_flags(args)?;
let roots_path = require(&flags, "roots")?;
let version_str = require(&flags, "version")?;
let version: u64 = version_str
.parse()
.map_err(|e| format!("bad --version {version_str:?}: {e}"))?;
let ca_key = load_ca_private(flags.get("ca-private").map(String::as_str))?;
let contents = std::fs::read_to_string(roots_path)
.map_err(|e| format!("failed to read roots file {roots_path:?}: {e}"))?;
let roots = parse_roots_file(&contents)?;
let mut rootset = RootSet {
roots,
version,
ca_sig: [0u8; 64],
};
let sig = ca_key.sign(&rootset_signing_body(&rootset));
rootset.ca_sig = sig.to_bytes();
let mut buf = Vec::new();
rootset.encode(&mut buf);
println!("{}", hex_encode(&buf));
Ok(())
}
fn parse_roots_file(contents: &str) -> Result<Vec<([u8; 32], SocketAddr)>, String> {
let mut roots = Vec::new();
for (idx, raw_line) in contents.lines().enumerate() {
let line_no = idx + 1;
let line = raw_line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let mut fields = line.split_whitespace();
let pk_hex = fields
.next()
.ok_or_else(|| format!("roots file line {line_no}: missing pubkey field"))?;
let addr_str = fields
.next()
.ok_or_else(|| format!("roots file line {line_no}: missing address field"))?;
if fields.next().is_some() {
return Err(format!("roots file line {line_no}: too many fields"));
}
let pk = fixed32(&hex_decode(pk_hex)?, "root pubkey")?;
let addr: SocketAddr = addr_str
.parse()
.map_err(|e| format!("roots file line {line_no}: bad address {addr_str:?}: {e}"))?;
roots.push((pk, addr));
}
Ok(roots)
}
fn load_ca_private(arg: Option<&str>) -> Result<SigningKey, String> {
let raw = match arg {
Some(s) => s.to_string(),
None => {
let mut buf = String::new();
std::io::stdin()
.read_to_string(&mut buf)
.map_err(|e| format!("failed to read CA private key from stdin: {e}"))?;
buf.lines()
.next()
.ok_or_else(|| "stdin was empty".to_string())?
.trim()
.to_string()
}
};
let raw = raw.strip_prefix("ca_private=").unwrap_or(&raw);
let bytes = fixed32(&hex_decode(raw)?, "ca-private")?;
Ok(SigningKey::from_bytes(&bytes))
}
fn now_secs() -> Result<u64, String> {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|e| format!("system clock is before the Unix epoch: {e}"))
.map(|d| d.as_secs())
}
fn parse_flags(args: &[String]) -> Result<BTreeMap<String, String>, String> {
let mut map = BTreeMap::new();
let mut i = 0;
while i < args.len() {
let key = &args[i];
let Some(name) = key.strip_prefix("--") else {
return Err(format!(
"unexpected argument {key:?} (flags must start with --)"
));
};
let value = args
.get(i + 1)
.ok_or_else(|| format!("missing value for --{name}"))?;
map.insert(name.to_string(), value.clone());
i += 2;
}
Ok(map)
}
fn require<'a>(flags: &'a BTreeMap<String, String>, key: &str) -> Result<&'a str, String> {
flags
.get(key)
.map(String::as_str)
.ok_or_else(|| format!("missing required --{key}"))
}
fn fixed32(bytes: &[u8], what: &str) -> Result<[u8; 32], String> {
<[u8; 32]>::try_from(bytes).map_err(|_| {
format!(
"--{what} must be 32 bytes (64 hex chars), got {}",
bytes.len()
)
})
}
fn fixed16(bytes: &[u8], what: &str) -> Result<[u8; 16], String> {
<[u8; 16]>::try_from(bytes).map_err(|_| {
format!(
"--{what} must be 16 bytes (32 hex chars), got {}",
bytes.len()
)
})
}
fn hex_encode(bytes: &[u8]) -> String {
let mut s = String::with_capacity(bytes.len() * 2);
for b in bytes {
let _ = write!(s, "{b:02x}");
}
s
}
fn hex_decode(s: &str) -> Result<Vec<u8>, String> {
let s = s.trim();
if !s.len().is_multiple_of(2) {
return Err(format!("odd-length hex string: {s:?}"));
}
let mut out = Vec::with_capacity(s.len() / 2);
let mut chars = s.chars();
while let (Some(a), Some(b)) = (chars.next(), chars.next()) {
let mut byte_str = String::with_capacity(2);
byte_str.push(a);
byte_str.push(b);
let byte = u8::from_str_radix(&byte_str, 16)
.map_err(|e| format!("bad hex byte {byte_str:?} in {s:?}: {e}"))?;
out.push(byte);
}
Ok(out)
}