use crate::ux::format::{Output, is_json_mode};
use anyhow::{Context, Result, anyhow};
use clap::{Parser, Subcommand};
use dialoguer::{Confirm, Input, Select};
use serde::{Deserialize, Serialize};
use std::io::IsTerminal;
use std::path::PathBuf;
use std::sync::Arc;
#[derive(Parser, Debug, Clone)]
#[command(name = "emergency", about = "Emergency incident response commands")]
pub struct EmergencyCommand {
#[command(subcommand)]
pub command: Option<EmergencySubcommand>,
}
#[derive(Subcommand, Debug, Clone)]
pub enum EmergencySubcommand {
#[command(name = "revoke-device")]
RevokeDevice(RevokeDeviceCommand),
#[command(name = "rotate-now")]
RotateNow(RotateNowCommand),
Freeze(FreezeCommand),
Unfreeze(UnfreezeCommand),
Report(ReportCommand),
}
#[derive(Parser, Debug, Clone)]
pub struct RevokeDeviceCommand {
#[arg(long)]
pub device: Option<String>,
#[arg(long)]
pub key: Option<String>,
#[arg(long)]
pub note: Option<String>,
#[arg(long, short = 'y')]
pub yes: bool,
#[arg(long)]
pub dry_run: bool,
#[arg(long)]
pub repo: Option<PathBuf>,
}
#[derive(Parser, Debug, Clone)]
pub struct RotateNowCommand {
#[arg(long)]
pub current_alias: Option<String>,
#[arg(long)]
pub next_alias: Option<String>,
#[arg(long, short = 'y')]
pub yes: bool,
#[arg(long)]
pub dry_run: bool,
#[arg(long)]
pub reason: Option<String>,
#[arg(long)]
pub repo: Option<PathBuf>,
}
#[derive(Parser, Debug, Clone)]
pub struct FreezeCommand {
#[arg(long, default_value = "24h")]
pub duration: String,
#[arg(long, short = 'y')]
pub yes: bool,
#[arg(long)]
pub dry_run: bool,
#[arg(long)]
pub repo: Option<PathBuf>,
}
#[derive(Parser, Debug, Clone)]
pub struct UnfreezeCommand {
#[arg(long, short = 'y')]
pub yes: bool,
#[arg(long)]
pub repo: Option<PathBuf>,
}
#[derive(Parser, Debug, Clone)]
pub struct ReportCommand {
#[arg(long, default_value = "100")]
pub events: usize,
#[arg(long = "output", visible_alias = "file", short = 'o')]
pub output_file: Option<PathBuf>,
#[arg(long)]
pub repo: Option<PathBuf>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct IncidentReport {
pub generated_at: String,
pub identity_did: Option<String>,
pub devices: Vec<DeviceInfo>,
pub recent_events: Vec<EventInfo>,
pub recommendations: Vec<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct DeviceInfo {
pub did: String,
pub name: Option<String>,
pub status: String,
pub anchored: bool,
pub last_active: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct EventInfo {
pub timestamp: String,
pub event_type: String,
pub details: String,
}
pub fn handle_emergency(
cmd: EmergencyCommand,
now: chrono::DateTime<chrono::Utc>,
ctx: &crate::config::CliConfig,
) -> Result<()> {
match cmd.command {
Some(EmergencySubcommand::RevokeDevice(c)) => handle_revoke_device(c, now, ctx),
Some(EmergencySubcommand::RotateNow(c)) => handle_rotate_now(c, now, ctx),
Some(EmergencySubcommand::Freeze(c)) => handle_freeze(c, now),
Some(EmergencySubcommand::Unfreeze(c)) => handle_unfreeze(c, now),
Some(EmergencySubcommand::Report(c)) => handle_report(c, now),
None => handle_interactive_flow(ctx),
}
}
fn handle_interactive_flow(ctx: &crate::config::CliConfig) -> Result<()> {
#[allow(clippy::disallowed_methods)]
let now = chrono::Utc::now();
let out = Output::new();
if !std::io::stdin().is_terminal() {
return Err(anyhow!(
"Interactive mode requires a terminal. Use subcommands for non-interactive use."
));
}
out.newline();
out.println(&format!(
" {} {}",
out.error("🚨"),
out.bold("Emergency Response")
));
out.newline();
let options = [
"Device lost or stolen",
"Key may have been exposed",
"Freeze everything immediately",
"Generate incident report",
"Cancel",
];
let selection = Select::new()
.with_prompt("What happened?")
.items(options)
.default(0)
.interact()?;
match selection {
0 => {
out.print_info("Starting device revocation flow...");
handle_revoke_device(
RevokeDeviceCommand {
device: None,
key: None,
note: None,
yes: false,
dry_run: false,
repo: None,
},
now,
ctx,
)
}
1 => {
out.print_info("Starting key rotation flow...");
handle_rotate_now(
RotateNowCommand {
current_alias: None,
next_alias: None,
yes: false,
dry_run: false,
reason: Some("Potential key exposure".to_string()),
repo: None,
},
now,
ctx,
)
}
2 => {
out.print_warn("Starting freeze flow...");
handle_freeze(
FreezeCommand {
duration: "24h".to_string(),
yes: false,
dry_run: false,
repo: None,
},
now,
)
}
3 => {
handle_report(
ReportCommand {
events: 100,
output_file: None,
repo: None,
},
now,
)
}
_ => {
out.println("Cancelled.");
Ok(())
}
}
}
fn handle_revoke_device(
cmd: RevokeDeviceCommand,
_now: chrono::DateTime<chrono::Utc>,
ctx: &crate::config::CliConfig,
) -> Result<()> {
use auths_sdk::keychain::KeyAlias;
use auths_sdk::storage_layout::layout;
let out = Output::new();
out.print_heading("Device Revocation");
out.newline();
let device_did = if let Some(did) = cmd.device {
did
} else if std::io::stdin().is_terminal() {
Input::new()
.with_prompt("Enter device ID to revoke")
.interact_text()?
} else {
return Err(anyhow!("--device is required in non-interactive mode"));
};
let identity_key_alias = if let Some(alias) = cmd.key {
alias
} else if std::io::stdin().is_terminal() {
Input::new()
.with_prompt("Enter identity key alias")
.interact_text()?
} else {
return Err(anyhow!("--key is required in non-interactive mode"));
};
out.println(&format!("Device to revoke: {}", out.info(&device_did)));
out.newline();
if cmd.dry_run {
out.print_info("Dry run mode - no changes will be made");
out.newline();
out.println("Would perform the following actions:");
out.println(&format!(
" 1. Revoke device authorization for {}",
device_did
));
out.println(" 2. Create signed revocation attestation");
out.println(" 3. Store revocation in Git repository");
return Ok(());
}
if !cmd.yes {
let confirm = Confirm::new()
.with_prompt(format!("Revoke device {}?", device_did))
.default(false)
.interact()?;
if !confirm {
out.println("Cancelled.");
return Ok(());
}
}
let repo_path = layout::resolve_repo_path(cmd.repo)?;
let auths_ctx = crate::factories::storage::build_auths_context(
&repo_path,
&ctx.env_config,
Some(Arc::clone(&ctx.passphrase_provider)),
)?;
let identity_key_alias = KeyAlias::new_unchecked(identity_key_alias);
out.print_info("Creating signed revocation attestation...");
auths_sdk::domains::device::service::revoke_device(
&device_did,
&identity_key_alias,
&auths_ctx,
cmd.note,
&auths_sdk::ports::SystemClock,
)
.map_err(anyhow::Error::new)
.context("Failed to revoke device")?;
out.print_success(&format!("Device {} has been revoked", device_did));
out.newline();
out.println("The device can no longer sign on behalf of your identity.");
Ok(())
}
fn handle_rotate_now(
cmd: RotateNowCommand,
_now: chrono::DateTime<chrono::Utc>,
ctx: &crate::config::CliConfig,
) -> Result<()> {
use auths_sdk::domains::identity::types::IdentityRotationConfig;
use auths_sdk::keychain::KeyAlias;
use auths_sdk::storage_layout::layout;
let out = Output::new();
out.print_heading("Emergency Key Rotation");
out.newline();
let reason = cmd
.reason
.unwrap_or_else(|| "Manual emergency rotation".to_string());
out.println(&format!("Reason: {}", out.info(&reason)));
out.newline();
let current_alias = if let Some(alias) = cmd.current_alias {
alias
} else if std::io::stdin().is_terminal() {
Input::new()
.with_prompt("Enter current signing key alias")
.interact_text()?
} else {
return Err(anyhow!(
"--current-alias is required in non-interactive mode"
));
};
let next_alias = if let Some(alias) = cmd.next_alias {
alias
} else if std::io::stdin().is_terminal() {
Input::new()
.with_prompt("Enter alias for the new signing key")
.interact_text()?
} else {
return Err(anyhow!("--next-alias is required in non-interactive mode"));
};
if cmd.dry_run {
out.print_info("Dry run mode - no changes will be made");
out.newline();
out.println("Would perform the following actions:");
out.println(" 1. Generate new signing key");
out.println(" 2. Create rotation event in identity log");
out.println(" 3. Update key alias mappings");
return Ok(());
}
if !cmd.yes {
out.print_warn("Key rotation is a significant operation.");
out.println("All devices will need to re-authorize.");
out.newline();
let confirmation: String = Input::new()
.with_prompt("Type ROTATE to confirm")
.interact_text()?;
if confirmation != "ROTATE" {
out.println("Cancelled - confirmation not matched.");
return Ok(());
}
}
let repo_path = layout::resolve_repo_path(cmd.repo)?;
let rotation_config = IdentityRotationConfig {
repo_path: repo_path.clone(),
identity_key_alias: Some(KeyAlias::new_unchecked(current_alias)),
next_key_alias: Some(KeyAlias::new_unchecked(next_alias)),
};
let auths_ctx = crate::factories::storage::build_auths_context(
&repo_path,
&ctx.env_config,
Some(Arc::clone(&ctx.passphrase_provider)),
)?;
out.print_info("Rotating key...");
let rotation_result = auths_sdk::domains::identity::rotation::rotate_identity(
rotation_config,
&auths_ctx,
&auths_sdk::ports::SystemClock,
)
.map_err(anyhow::Error::new)
.context("Key rotation failed")?;
out.print_success(&format!(
"Key rotation complete (new sequence: {})",
rotation_result.sequence
));
out.newline();
out.println("Next steps:");
out.println(" 1. Re-authorize your devices: auths pair");
out.println(" 2. Update any CI/CD secrets");
out.println(" 3. Run `auths doctor` to verify setup");
Ok(())
}
fn handle_freeze(cmd: FreezeCommand, now: chrono::DateTime<chrono::Utc>) -> Result<()> {
use auths_sdk::freeze::{FreezeState, load_active_freeze, parse_duration, store_freeze};
use auths_sdk::storage_layout::layout;
let out = Output::new();
out.print_heading("Identity Freeze");
out.newline();
let duration = parse_duration(&cmd.duration)?;
let frozen_at = now;
let frozen_until = frozen_at + duration;
out.println(&format!(
"Duration: {} (until {})",
out.info(&cmd.duration),
out.info(&frozen_until.format("%Y-%m-%d %H:%M UTC").to_string())
));
out.newline();
let repo_path = layout::resolve_repo_path(cmd.repo)?;
if let Some(existing) = load_active_freeze(&repo_path, now)? {
let existing_until = existing.frozen_until;
if frozen_until > existing_until {
out.print_warn(&format!(
"Existing freeze active until {}. Will extend to {}.",
existing_until.format("%Y-%m-%d %H:%M UTC"),
frozen_until.format("%Y-%m-%d %H:%M UTC"),
));
} else {
out.print_warn(&format!(
"Existing freeze already active until {} (longer than requested).",
existing_until.format("%Y-%m-%d %H:%M UTC"),
));
out.println("Use a longer duration to extend, or unfreeze first.");
return Ok(());
}
out.newline();
}
if cmd.dry_run {
out.print_info("Dry run mode - no changes will be made");
out.newline();
out.println("Would perform the following actions:");
out.println(&format!(
" 1. Freeze all signing operations for {}",
cmd.duration
));
out.println(&format!(
" 2. Write freeze state to {}",
repo_path.join("freeze.json").display()
));
out.println(" 3. auths-sign will refuse to sign until freeze expires");
return Ok(());
}
if !cmd.yes {
let confirmation: String = dialoguer::Input::new()
.with_prompt("Type FREEZE to confirm")
.interact_text()?;
if confirmation != "FREEZE" {
out.println("Cancelled - confirmation not matched.");
return Ok(());
}
}
let state = FreezeState {
frozen_at,
frozen_until,
reason: Some(format!("Emergency freeze for {}", cmd.duration)),
};
store_freeze(&repo_path, &state)?;
out.print_success(&format!(
"Identity frozen until {}",
frozen_until.format("%Y-%m-%d %H:%M UTC")
));
out.newline();
out.println("All signing operations are disabled.");
out.println(&format!(
"Freeze expires in: {}",
out.info(&state.expires_description(now))
));
out.newline();
out.println("To unfreeze early:");
out.println(&format!(" {}", out.dim("auths emergency unfreeze")));
Ok(())
}
fn handle_unfreeze(cmd: UnfreezeCommand, now: chrono::DateTime<chrono::Utc>) -> Result<()> {
use auths_sdk::freeze::{load_active_freeze, remove_freeze};
use auths_sdk::storage_layout::layout;
let out = Output::new();
let repo_path = layout::resolve_repo_path(cmd.repo)?;
match load_active_freeze(&repo_path, now)? {
Some(state) => {
out.println(&format!(
"Active freeze until {}",
out.info(&state.frozen_until.format("%Y-%m-%d %H:%M UTC").to_string())
));
out.newline();
if !cmd.yes {
let confirm = Confirm::new()
.with_prompt("Remove freeze and restore signing?")
.default(false)
.interact()?;
if !confirm {
out.println("Cancelled.");
return Ok(());
}
}
remove_freeze(&repo_path)?;
out.print_success("Freeze removed. Signing operations are restored.");
}
None => {
out.print_info("No active freeze found.");
}
}
Ok(())
}
fn handle_report(cmd: ReportCommand, now: chrono::DateTime<chrono::Utc>) -> Result<()> {
use auths_sdk::identity::ManagedIdentity;
use auths_sdk::ports::IdentityStorage;
use auths_sdk::storage::{RegistryAttestationStorage, RegistryIdentityStorage};
use auths_sdk::storage_layout::layout;
let out = Output::new();
let repo_path = layout::resolve_repo_path(cmd.repo.clone())?;
let identity_storage = RegistryIdentityStorage::new(repo_path.clone());
let identity_did = match identity_storage.load_identity() {
Ok(ManagedIdentity { controller_did, .. }) => Some(controller_did),
Err(_) => None,
};
let attestation_storage = RegistryAttestationStorage::new(repo_path.clone());
let enriched = attestation_storage.load_all_enriched().unwrap_or_default();
let all_attestations: Vec<_> = enriched.iter().map(|e| e.attestation.clone()).collect();
let mut seen_devices = std::collections::HashSet::new();
let mut devices = Vec::new();
for e_att in &enriched {
let att = &e_att.attestation;
let did_str = att.subject.to_string();
if seen_devices.insert(did_str.clone()) {
let status = if att.is_revoked() {
"revoked"
} else if att.expires_at.is_some_and(|exp| exp <= now) {
"expired"
} else {
"active"
};
devices.push(DeviceInfo {
did: did_str,
name: att.note.clone(),
status: status.to_string(),
anchored: e_att.anchor == auths_keri::AnchorStatus::Anchored,
last_active: att.timestamp.map(|t| t.to_rfc3339()),
});
}
}
let mut events: Vec<&auths_verifier::core::Attestation> = all_attestations.iter().collect();
events.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
let recent_events: Vec<EventInfo> = events
.iter()
.take(cmd.events)
.map(|att| {
let event_type = if att.is_revoked() {
"device_revocation"
} else {
"device_authorization"
};
EventInfo {
timestamp: att.timestamp.map(|t| t.to_rfc3339()).unwrap_or_default(),
event_type: event_type.to_string(),
details: format!("{} for {}", event_type, att.subject),
}
})
.collect();
let mut recommendations = Vec::new();
let active_count = devices.iter().filter(|d| d.status == "active").count();
let revoked_count = devices.iter().filter(|d| d.status == "revoked").count();
let expired_count = devices.iter().filter(|d| d.status == "expired").count();
if active_count > 0 {
recommendations.push(format!(
"Review all {} active device authorizations",
active_count
));
}
if expired_count > 0 {
recommendations.push(format!(
"Clean up {} expired device authorizations",
expired_count
));
}
if revoked_count > 0 {
recommendations.push(format!(
"{} device(s) already revoked — verify these were intentional",
revoked_count
));
}
recommendations.push("Check for any unexpected signing activity".to_string());
let report = IncidentReport {
generated_at: now.to_rfc3339(),
identity_did: identity_did.map(|d| d.to_string()),
devices,
recent_events,
recommendations,
};
if is_json_mode() {
let json = serde_json::to_string_pretty(&report)?;
if let Some(output_path) = &cmd.output_file {
std::fs::write(output_path, &json)
.with_context(|| format!("Failed to write report to {:?}", output_path))?;
out.print_success(&format!("Report saved to {}", output_path.display()));
} else {
println!("{}", json);
}
return Ok(());
}
out.print_heading("Incident Report");
out.newline();
out.println(&format!("Generated: {}", out.info(&report.generated_at)));
if let Some(did) = &report.identity_did {
out.println(&format!("Identity: {}", out.info(did)));
}
out.newline();
out.print_heading(" Devices");
for device in &report.devices {
let status_icon = if device.status == "active" {
out.success("●")
} else {
out.error("○")
};
out.println(&format!(
" {} {} ({}) - {}",
status_icon,
device.did,
device.name.as_deref().unwrap_or("unnamed"),
device.status
));
}
out.newline();
out.print_heading(" Recent Events");
for event in &report.recent_events {
out.println(&format!(
" {} [{}] {}",
out.dim(&event.timestamp[..19]),
event.event_type,
event.details
));
}
out.newline();
out.print_heading(" Recommendations");
for (i, rec) in report.recommendations.iter().enumerate() {
out.println(&format!(" {}. {}", i + 1, rec));
}
if let Some(output_path) = &cmd.output_file {
let json = serde_json::to_string_pretty(&report)?;
std::fs::write(output_path, json)
.with_context(|| format!("Failed to write report to {:?}", output_path))?;
out.newline();
out.print_success(&format!("Report also saved to {}", output_path.display()));
}
Ok(())
}
use crate::commands::executable::ExecutableCommand;
use crate::config::CliConfig;
impl ExecutableCommand for EmergencyCommand {
#[allow(clippy::disallowed_methods)]
fn execute(&self, ctx: &CliConfig) -> Result<()> {
handle_emergency(self.clone(), chrono::Utc::now(), ctx)
}
}
#[cfg(test)]
#[allow(clippy::disallowed_methods)]
mod tests {
use super::*;
#[test]
fn test_incident_report_serialization() {
let report = IncidentReport {
generated_at: "2024-01-15T10:30:00Z".to_string(),
identity_did: Some("did:keri:ETest".to_string()),
devices: vec![],
recent_events: vec![],
recommendations: vec!["Test recommendation".to_string()],
};
let json = serde_json::to_string(&report).unwrap();
assert!(json.contains("did:keri:ETest"));
assert!(json.contains("Test recommendation"));
}
#[test]
fn test_device_info_serialization() {
let device = DeviceInfo {
did: "did:key:z6MkTest".to_string(),
name: Some("Test Device".to_string()),
status: "active".to_string(),
anchored: true,
last_active: None,
};
let json = serde_json::to_string(&device).unwrap();
assert!(json.contains("did:key:z6MkTest"));
assert!(json.contains("Test Device"));
}
#[test]
fn test_freeze_dry_run() {
let dir = tempfile::TempDir::new().unwrap();
let result = handle_freeze(
FreezeCommand {
duration: "24h".to_string(),
yes: true,
dry_run: true,
repo: Some(dir.path().to_path_buf()),
},
chrono::Utc::now(),
);
assert!(result.is_ok());
assert!(!dir.path().join("freeze.json").exists());
}
#[test]
fn test_freeze_creates_freeze_file() {
let dir = tempfile::TempDir::new().unwrap();
let result = handle_freeze(
FreezeCommand {
duration: "1h".to_string(),
yes: true,
dry_run: false,
repo: Some(dir.path().to_path_buf()),
},
chrono::Utc::now(),
);
assert!(result.is_ok());
assert!(dir.path().join("freeze.json").exists());
let state = auths_sdk::freeze::load_active_freeze(dir.path(), chrono::Utc::now()).unwrap();
assert!(state.is_some());
}
#[test]
fn test_freeze_invalid_duration() {
let dir = tempfile::TempDir::new().unwrap();
let result = handle_freeze(
FreezeCommand {
duration: "invalid".to_string(),
yes: true,
dry_run: false,
repo: Some(dir.path().to_path_buf()),
},
chrono::Utc::now(),
);
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("Invalid") || err_msg.contains("duration"),
"Expected duration parse error, got: {}",
err_msg
);
}
#[test]
fn test_unfreeze_removes_freeze() {
let dir = tempfile::TempDir::new().unwrap();
handle_freeze(
FreezeCommand {
duration: "24h".to_string(),
yes: true,
dry_run: false,
repo: Some(dir.path().to_path_buf()),
},
chrono::Utc::now(),
)
.unwrap();
assert!(dir.path().join("freeze.json").exists());
handle_unfreeze(
UnfreezeCommand {
yes: true,
repo: Some(dir.path().to_path_buf()),
},
chrono::Utc::now(),
)
.unwrap();
assert!(!dir.path().join("freeze.json").exists());
}
}