use anyhow::Result;
use clap::{Args, Subcommand};
use kanade_shared::signing;
const DEFAULT_MAX_AGE_MINS: u64 = 60;
#[derive(Args, Debug)]
pub struct CommandKeyArgs {
#[command(subcommand)]
pub command: CommandKeyCmd,
}
#[derive(Subcommand, Debug)]
pub enum CommandKeyCmd {
BreakGlass(BreakGlassArgs),
}
#[derive(Args, Debug)]
pub struct BreakGlassArgs {
#[arg(long)]
pub kid: Option<String>,
#[arg(long, default_value_t = DEFAULT_MAX_AGE_MINS)]
pub max_age_mins: u64,
}
pub fn execute(args: CommandKeyArgs) -> Result<()> {
match args.command {
CommandKeyCmd::BreakGlass(a) => break_glass(a),
}
}
fn resolve_kid(requested: Option<&str>, stamp: &str) -> Result<String, String> {
let kid = requested
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_owned)
.unwrap_or_else(|| format!("break-glass-{stamp}"));
if kid.trim().is_empty() {
return Err("the key id is empty".into());
}
Ok(kid)
}
fn max_age(mins: u64) -> std::time::Duration {
std::time::Duration::from_secs(mins * 60)
}
fn break_glass(args: BreakGlassArgs) -> Result<()> {
if args.max_age_mins == 0 {
anyhow::bail!(
"--max-age-mins 0 would reject every signature the instant it is made. \
Pick a window a human can act inside."
);
}
let kid = resolve_kid(
args.kid.as_deref(),
&chrono::Utc::now().format("%Y%m%d-%H%M").to_string(),
)
.map_err(|e| anyhow::anyhow!(e))?;
let key = signing::generate_keypair().map_err(|e| anyhow::anyhow!(e))?;
let max_age = max_age(args.max_age_mins);
let entry =
signing::break_glass_keyring_entry(&kid, &key.verifying_key(), "break-glass", max_age);
println!("Break-glass signing key minted. It is NOT stored anywhere.");
println!();
println!("kid: {kid}");
println!("max age: {} minutes", args.max_age_mins);
println!();
println!("PRIVATE KEY — shown once. Move it to an offline medium or a password manager now:");
println!(" {}", signing::encode_secret(&key));
println!();
println!("Add this entry to every agent's HKLM\\SOFTWARE\\kanade\\agent\\CommandKeys array,");
println!("alongside the backend's entry (the array is REPLACED, not merged — include both):");
println!("{}", serde_json::to_string_pretty(&entry)?);
println!();
println!(
"Distribute it in the SAME pass as the backend key, not a later one. A machine that is \
offline when break-glass is added keeps a backend-only ring, and at stage 3 it cannot be \
given the key afterwards — the command carrying it would be rejected by the very gap it \
is meant to fix."
);
println!();
println!(
"To use it during an incident, set both halves and run `kanade run` as usual:\n \
{}=<the private key above>\n {}={kid}",
super::run::ENV_BREAK_GLASS_KEY,
super::run::ENV_BREAK_GLASS_KID
);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_kid_defaults_to_a_timestamp_including_the_time_of_day() {
assert_eq!(
resolve_kid(None, "20260730-1432").unwrap(),
"break-glass-20260730-1432"
);
}
#[test]
fn the_default_window_reaches_the_keyring_entry_as_an_hour() {
let key = signing::generate_keypair().unwrap();
let entry = signing::break_glass_keyring_entry(
"bg",
&key.verifying_key(),
"break-glass",
max_age(DEFAULT_MAX_AGE_MINS),
);
assert_eq!(entry["max_age_secs"], 3600);
}
#[test]
fn an_explicit_kid_wins_and_is_trimmed() {
assert_eq!(
resolve_kid(Some(" bg-1 "), "20260730-1432").unwrap(),
"bg-1"
);
}
#[test]
fn a_blank_explicit_kid_falls_back_rather_than_signing_under_nothing() {
assert_eq!(
resolve_kid(Some(" "), "20260730-1432").unwrap(),
"break-glass-20260730-1432"
);
}
}