Skip to main content

auths_cli/commands/
emergency.rs

1//! Emergency response commands for incident handling.
2//!
3//! Commands:
4//! - `auths emergency` - Interactive emergency response flow
5//! - `auths emergency revoke-device` - Revoke a compromised device
6//! - `auths emergency rotate-now` - Force key rotation
7//! - `auths emergency freeze` - Freeze all operations
8//! - `auths emergency report` - Generate incident report
9
10use crate::ux::format::{Output, is_json_mode};
11use anyhow::{Context, Result, anyhow};
12use clap::{Parser, Subcommand};
13use dialoguer::{Confirm, Input, Select};
14use serde::{Deserialize, Serialize};
15use std::io::IsTerminal;
16use std::path::PathBuf;
17use std::sync::Arc;
18
19/// Emergency incident response commands.
20#[derive(Parser, Debug, Clone)]
21#[command(name = "emergency", about = "Emergency incident response commands")]
22pub struct EmergencyCommand {
23    #[command(subcommand)]
24    pub command: Option<EmergencySubcommand>,
25}
26
27#[derive(Subcommand, Debug, Clone)]
28pub enum EmergencySubcommand {
29    /// Revoke a compromised device immediately.
30    #[command(name = "revoke-device")]
31    RevokeDevice(RevokeDeviceCommand),
32
33    /// Force immediate key rotation.
34    #[command(name = "rotate-now")]
35    RotateNow(RotateNowCommand),
36
37    /// Freeze all signing operations.
38    Freeze(FreezeCommand),
39
40    /// Unfreeze (cancel an active freeze early).
41    Unfreeze(UnfreezeCommand),
42
43    /// Generate an incident report.
44    Report(ReportCommand),
45}
46
47/// Revoke a compromised device.
48#[derive(Parser, Debug, Clone)]
49pub struct RevokeDeviceCommand {
50    /// Device ID to revoke.
51    #[arg(long)]
52    pub device: Option<String>,
53
54    /// Local alias of the identity's key (used for signing the revocation).
55    #[arg(long)]
56    pub key: Option<String>,
57
58    /// Optional note explaining the revocation.
59    #[arg(long)]
60    pub note: Option<String>,
61
62    /// Skip confirmation prompt.
63    #[arg(long, short = 'y')]
64    pub yes: bool,
65
66    /// Preview actions without making changes.
67    #[arg(long)]
68    pub dry_run: bool,
69
70    /// Path to the Auths repository.
71    #[arg(long)]
72    pub repo: Option<PathBuf>,
73}
74
75/// Force immediate key rotation.
76#[derive(Parser, Debug, Clone)]
77pub struct RotateNowCommand {
78    /// Local alias of the current signing key.
79    #[arg(long)]
80    pub current_alias: Option<String>,
81
82    /// Local alias for the new signing key after rotation.
83    #[arg(long)]
84    pub next_alias: Option<String>,
85
86    /// Skip confirmation prompt (requires typing ROTATE).
87    #[arg(long, short = 'y')]
88    pub yes: bool,
89
90    /// Preview actions without making changes.
91    #[arg(long)]
92    pub dry_run: bool,
93
94    /// Reason for rotation.
95    #[arg(long)]
96    pub reason: Option<String>,
97
98    /// Path to the Auths repository.
99    #[arg(long)]
100    pub repo: Option<PathBuf>,
101}
102
103/// Freeze all signing operations.
104#[derive(Parser, Debug, Clone)]
105pub struct FreezeCommand {
106    /// Duration to freeze (e.g., "24h", "7d").
107    #[arg(long, default_value = "24h")]
108    pub duration: String,
109
110    /// Skip confirmation prompt (requires typing identity name).
111    #[arg(long, short = 'y')]
112    pub yes: bool,
113
114    /// Preview actions without making changes.
115    #[arg(long)]
116    pub dry_run: bool,
117
118    /// Path to the Auths repository.
119    #[arg(long)]
120    pub repo: Option<PathBuf>,
121}
122
123/// Cancel an active freeze early.
124#[derive(Parser, Debug, Clone)]
125pub struct UnfreezeCommand {
126    /// Skip confirmation prompt.
127    #[arg(long, short = 'y')]
128    pub yes: bool,
129
130    /// Path to the Auths repository.
131    #[arg(long)]
132    pub repo: Option<PathBuf>,
133}
134
135/// Generate an incident report.
136#[derive(Parser, Debug, Clone)]
137pub struct ReportCommand {
138    /// Include last N events in report.
139    #[arg(long, default_value = "100")]
140    pub events: usize,
141
142    /// Output file path (defaults to stdout).
143    #[arg(long = "output", visible_alias = "file", short = 'o')]
144    pub output_file: Option<PathBuf>,
145
146    /// Path to the Auths repository.
147    #[arg(long)]
148    pub repo: Option<PathBuf>,
149}
150
151/// Incident report output.
152#[derive(Debug, Serialize, Deserialize)]
153pub struct IncidentReport {
154    pub generated_at: String,
155    pub identity_did: Option<String>,
156    pub devices: Vec<DeviceInfo>,
157    pub recent_events: Vec<EventInfo>,
158    pub recommendations: Vec<String>,
159}
160
161#[derive(Debug, Serialize, Deserialize)]
162pub struct DeviceInfo {
163    pub did: String,
164    pub name: Option<String>,
165    pub status: String,
166    pub anchored: bool,
167    pub last_active: Option<String>,
168}
169
170#[derive(Debug, Serialize, Deserialize)]
171pub struct EventInfo {
172    pub timestamp: String,
173    pub event_type: String,
174    pub details: String,
175}
176
177/// Handle the emergency command.
178pub fn handle_emergency(
179    cmd: EmergencyCommand,
180    now: chrono::DateTime<chrono::Utc>,
181    ctx: &crate::config::CliConfig,
182) -> Result<()> {
183    match cmd.command {
184        Some(EmergencySubcommand::RevokeDevice(c)) => handle_revoke_device(c, now, ctx),
185        Some(EmergencySubcommand::RotateNow(c)) => handle_rotate_now(c, now, ctx),
186        Some(EmergencySubcommand::Freeze(c)) => handle_freeze(c, now),
187        Some(EmergencySubcommand::Unfreeze(c)) => handle_unfreeze(c, now),
188        Some(EmergencySubcommand::Report(c)) => handle_report(c, now),
189        None => handle_interactive_flow(ctx),
190    }
191}
192
193/// Handle interactive emergency flow.
194fn handle_interactive_flow(ctx: &crate::config::CliConfig) -> Result<()> {
195    #[allow(clippy::disallowed_methods)]
196    let now = chrono::Utc::now();
197    let out = Output::new();
198
199    if !std::io::stdin().is_terminal() {
200        return Err(anyhow!(
201            "Interactive mode requires a terminal. Use subcommands for non-interactive use."
202        ));
203    }
204
205    out.newline();
206    out.println(&format!(
207        "  {} {}",
208        out.error("🚨"),
209        out.bold("Emergency Response")
210    ));
211    out.newline();
212
213    let options = [
214        "Device lost or stolen",
215        "Key may have been exposed",
216        "Freeze everything immediately",
217        "Generate incident report",
218        "Cancel",
219    ];
220
221    let selection = Select::new()
222        .with_prompt("What happened?")
223        .items(options)
224        .default(0)
225        .interact()?;
226
227    match selection {
228        0 => {
229            // Device lost/stolen
230            out.print_info("Starting device revocation flow...");
231            handle_revoke_device(
232                RevokeDeviceCommand {
233                    device: None,
234                    key: None,
235                    note: None,
236                    yes: false,
237                    dry_run: false,
238                    repo: None,
239                },
240                now,
241                ctx,
242            )
243        }
244        1 => {
245            // Key exposed
246            out.print_info("Starting key rotation flow...");
247            handle_rotate_now(
248                RotateNowCommand {
249                    current_alias: None,
250                    next_alias: None,
251                    yes: false,
252                    dry_run: false,
253                    reason: Some("Potential key exposure".to_string()),
254                    repo: None,
255                },
256                now,
257                ctx,
258            )
259        }
260        2 => {
261            // Freeze everything
262            out.print_warn("Starting freeze flow...");
263            handle_freeze(
264                FreezeCommand {
265                    duration: "24h".to_string(),
266                    yes: false,
267                    dry_run: false,
268                    repo: None,
269                },
270                now,
271            )
272        }
273        3 => {
274            // Generate report
275            handle_report(
276                ReportCommand {
277                    events: 100,
278                    output_file: None,
279                    repo: None,
280                },
281                now,
282            )
283        }
284        _ => {
285            out.println("Cancelled.");
286            Ok(())
287        }
288    }
289}
290
291/// Handle device revocation using the real revocation code path.
292fn handle_revoke_device(
293    cmd: RevokeDeviceCommand,
294    _now: chrono::DateTime<chrono::Utc>,
295    ctx: &crate::config::CliConfig,
296) -> Result<()> {
297    use auths_sdk::keychain::KeyAlias;
298    use auths_sdk::storage_layout::layout;
299
300    let out = Output::new();
301
302    out.print_heading("Device Revocation");
303    out.newline();
304
305    // Get device to revoke
306    let device_did = if let Some(did) = cmd.device {
307        did
308    } else if std::io::stdin().is_terminal() {
309        Input::new()
310            .with_prompt("Enter device ID to revoke")
311            .interact_text()?
312    } else {
313        return Err(anyhow!("--device is required in non-interactive mode"));
314    };
315
316    // Get identity key alias
317    let identity_key_alias = if let Some(alias) = cmd.key {
318        alias
319    } else if std::io::stdin().is_terminal() {
320        Input::new()
321            .with_prompt("Enter identity key alias")
322            .interact_text()?
323    } else {
324        return Err(anyhow!("--key is required in non-interactive mode"));
325    };
326
327    out.println(&format!("Device to revoke: {}", out.info(&device_did)));
328    out.newline();
329
330    if cmd.dry_run {
331        out.print_info("Dry run mode - no changes will be made");
332        out.newline();
333        out.println("Would perform the following actions:");
334        out.println(&format!(
335            "  1. Revoke device authorization for {}",
336            device_did
337        ));
338        out.println("  2. Create signed revocation attestation");
339        out.println("  3. Store revocation in Git repository");
340        return Ok(());
341    }
342
343    // Confirmation
344    if !cmd.yes {
345        let confirm = Confirm::new()
346            .with_prompt(format!("Revoke device {}?", device_did))
347            .default(false)
348            .interact()?;
349
350        if !confirm {
351            out.println("Cancelled.");
352            return Ok(());
353        }
354    }
355
356    let repo_path = layout::resolve_repo_path(cmd.repo)?;
357    let auths_ctx = crate::factories::storage::build_auths_context(
358        &repo_path,
359        &ctx.env_config,
360        Some(Arc::clone(&ctx.passphrase_provider)),
361    )?;
362    let identity_key_alias = KeyAlias::new_unchecked(identity_key_alias);
363
364    out.print_info("Creating signed revocation attestation...");
365    auths_sdk::domains::device::service::revoke_device(
366        &device_did,
367        &identity_key_alias,
368        &auths_ctx,
369        cmd.note,
370        &auths_sdk::ports::SystemClock,
371    )
372    .map_err(anyhow::Error::new)
373    .context("Failed to revoke device")?;
374
375    out.print_success(&format!("Device {} has been revoked", device_did));
376    out.newline();
377    out.println("The device can no longer sign on behalf of your identity.");
378
379    Ok(())
380}
381
382/// Handle emergency key rotation using the real rotation code path.
383fn handle_rotate_now(
384    cmd: RotateNowCommand,
385    _now: chrono::DateTime<chrono::Utc>,
386    ctx: &crate::config::CliConfig,
387) -> Result<()> {
388    use auths_sdk::domains::identity::types::IdentityRotationConfig;
389    use auths_sdk::keychain::KeyAlias;
390    use auths_sdk::storage_layout::layout;
391
392    let out = Output::new();
393
394    out.print_heading("Emergency Key Rotation");
395    out.newline();
396
397    let reason = cmd
398        .reason
399        .unwrap_or_else(|| "Manual emergency rotation".to_string());
400    out.println(&format!("Reason: {}", out.info(&reason)));
401    out.newline();
402
403    // Get key aliases
404    let current_alias = if let Some(alias) = cmd.current_alias {
405        alias
406    } else if std::io::stdin().is_terminal() {
407        Input::new()
408            .with_prompt("Enter current signing key alias")
409            .interact_text()?
410    } else {
411        return Err(anyhow!(
412            "--current-alias is required in non-interactive mode"
413        ));
414    };
415
416    let next_alias = if let Some(alias) = cmd.next_alias {
417        alias
418    } else if std::io::stdin().is_terminal() {
419        Input::new()
420            .with_prompt("Enter alias for the new signing key")
421            .interact_text()?
422    } else {
423        return Err(anyhow!("--next-alias is required in non-interactive mode"));
424    };
425
426    if cmd.dry_run {
427        out.print_info("Dry run mode - no changes will be made");
428        out.newline();
429        out.println("Would perform the following actions:");
430        out.println("  1. Generate new signing key");
431        out.println("  2. Create rotation event in identity log");
432        out.println("  3. Update key alias mappings");
433        return Ok(());
434    }
435
436    // Extra confirmation for rotation — fail closed in a non-interactive environment without
437    // --yes rather than crashing on a TTY prompt or leaving a half-done rotation.
438    if !cmd.yes {
439        out.print_warn("Key rotation is a significant operation.");
440        out.println("All devices will need to re-authorize.");
441        out.newline();
442    }
443    if !confirm_or_refuse(
444        cmd.yes,
445        std::io::stdin().is_terminal(),
446        "key rotation",
447        "ROTATE",
448    )? {
449        out.println("Cancelled - confirmation not matched.");
450        return Ok(());
451    }
452
453    let repo_path = layout::resolve_repo_path(cmd.repo)?;
454    let rotation_config = IdentityRotationConfig {
455        repo_path: repo_path.clone(),
456        identity_key_alias: Some(KeyAlias::new_unchecked(current_alias)),
457        next_key_alias: Some(KeyAlias::new_unchecked(next_alias)),
458    };
459    let auths_ctx = crate::factories::storage::build_auths_context(
460        &repo_path,
461        &ctx.env_config,
462        Some(Arc::clone(&ctx.passphrase_provider)),
463    )?;
464
465    out.print_info("Rotating key...");
466    let rotation_result = auths_sdk::domains::identity::rotation::rotate_identity(
467        rotation_config,
468        &auths_ctx,
469        &auths_sdk::ports::SystemClock,
470    )
471    .map_err(anyhow::Error::new)
472    .context("Key rotation failed")?;
473
474    out.print_success(&format!(
475        "Key rotation complete (new sequence: {})",
476        rotation_result.sequence
477    ));
478    out.newline();
479    out.println("Next steps:");
480    out.println("  1. Re-authorize your devices: auths pair");
481    out.println("  2. Update any CI/CD secrets");
482    out.println("  3. Run `auths doctor` to verify setup");
483
484    Ok(())
485}
486
487/// Whether a confirmation-gated emergency action should prompt, proceed, or be refused —
488/// decided from `--yes` and whether stdin is a terminal. A non-interactive run without `--yes`
489/// is refused so the action fails closed with a clear message rather than crashing on a TTY
490/// prompt or silently proceeding.
491#[derive(Debug, PartialEq, Eq)]
492enum ConfirmationMode {
493    /// Apply the action without prompting (`--yes`).
494    Proceed,
495    /// Prompt for typed confirmation (interactive terminal).
496    Prompt,
497    /// Refuse: non-interactive and no `--yes`.
498    NonInteractive,
499}
500
501/// Decide how to confirm a destructive emergency action. Pure, so it is exhaustively testable.
502fn confirmation_mode(yes: bool, stdin_is_terminal: bool) -> ConfirmationMode {
503    if yes {
504        ConfirmationMode::Proceed
505    } else if stdin_is_terminal {
506        ConfirmationMode::Prompt
507    } else {
508        ConfirmationMode::NonInteractive
509    }
510}
511
512/// Confirm a destructive emergency action, or fail closed. Returns whether to proceed; a
513/// non-interactive environment without `--yes` returns an actionable error (never a cryptic
514/// TTY-prompt crash, never silently proceeding).
515///
516/// Args:
517/// * `yes`: the `--yes` flag (apply headlessly).
518/// * `stdin_is_terminal`: whether stdin is an interactive terminal.
519/// * `action`: the action name for the message (e.g. "freeze", "key rotation").
520/// * `confirm_word`: the word the operator types to confirm interactively.
521///
522/// Usage:
523/// ```ignore
524/// if !confirm_or_refuse(cmd.yes, std::io::stdin().is_terminal(), "freeze", "FREEZE")? {
525///     return Ok(());
526/// }
527/// ```
528fn confirm_or_refuse(
529    yes: bool,
530    stdin_is_terminal: bool,
531    action: &str,
532    confirm_word: &str,
533) -> Result<bool> {
534    match confirmation_mode(yes, stdin_is_terminal) {
535        ConfirmationMode::Proceed => Ok(true),
536        ConfirmationMode::NonInteractive => Err(anyhow!(
537            "Cannot confirm an emergency {action} in a non-interactive environment. Re-run with \
538             --yes to apply it. The {action} was NOT applied."
539        )),
540        ConfirmationMode::Prompt => {
541            let typed: String = dialoguer::Input::new()
542                .with_prompt(format!("Type {confirm_word} to confirm"))
543                .interact_text()?;
544            Ok(typed == confirm_word)
545        }
546    }
547}
548
549/// Handle freeze operation — temporarily disables all signing.
550fn handle_freeze(cmd: FreezeCommand, now: chrono::DateTime<chrono::Utc>) -> Result<()> {
551    use auths_sdk::freeze::{FreezeState, load_active_freeze, parse_duration, store_freeze};
552    use auths_sdk::storage_layout::layout;
553
554    let out = Output::new();
555
556    out.print_heading("Identity Freeze");
557    out.newline();
558
559    // Parse duration
560    let duration = parse_duration(&cmd.duration)?;
561    let frozen_at = now;
562    let frozen_until = frozen_at + duration;
563
564    out.println(&format!(
565        "Duration: {} (until {})",
566        out.info(&cmd.duration),
567        out.info(&frozen_until.format("%Y-%m-%d %H:%M UTC").to_string())
568    ));
569    out.newline();
570
571    // Resolve repository
572    let repo_path = layout::resolve_repo_path(cmd.repo)?;
573
574    // Check for existing freeze
575    if let Some(existing) = load_active_freeze(&repo_path, now)? {
576        let existing_until = existing.frozen_until;
577        if frozen_until > existing_until {
578            out.print_warn(&format!(
579                "Existing freeze active until {}. Will extend to {}.",
580                existing_until.format("%Y-%m-%d %H:%M UTC"),
581                frozen_until.format("%Y-%m-%d %H:%M UTC"),
582            ));
583        } else {
584            out.print_warn(&format!(
585                "Existing freeze already active until {} (longer than requested).",
586                existing_until.format("%Y-%m-%d %H:%M UTC"),
587            ));
588            out.println("Use a longer duration to extend, or unfreeze first.");
589            return Ok(());
590        }
591        out.newline();
592    }
593
594    if cmd.dry_run {
595        out.print_info("Dry run mode - no changes will be made");
596        out.newline();
597        out.println("Would perform the following actions:");
598        out.println(&format!(
599            "  1. Freeze all signing operations for {}",
600            cmd.duration
601        ));
602        out.println(&format!(
603            "  2. Write freeze state to {}",
604            repo_path.join("freeze.json").display()
605        ));
606        out.println("  3. auths-sign will refuse to sign until freeze expires");
607        return Ok(());
608    }
609
610    // Confirmation — fail closed in a non-interactive environment without --yes, rather than
611    // crashing on a TTY prompt and leaving signing silently enabled.
612    if !confirm_or_refuse(cmd.yes, std::io::stdin().is_terminal(), "freeze", "FREEZE")? {
613        out.println("Cancelled - confirmation not matched.");
614        return Ok(());
615    }
616
617    let state = FreezeState {
618        frozen_at,
619        frozen_until,
620        reason: Some(format!("Emergency freeze for {}", cmd.duration)),
621    };
622
623    store_freeze(&repo_path, &state)?;
624
625    out.print_success(&format!(
626        "Identity frozen until {}",
627        frozen_until.format("%Y-%m-%d %H:%M UTC")
628    ));
629    out.newline();
630    out.println("All signing operations are disabled.");
631    out.println(&format!(
632        "Freeze expires in: {}",
633        out.info(&state.expires_description(now))
634    ));
635    out.newline();
636    out.print_warn(
637        "This freeze is local to this machine — it does not travel to other verifiers, so a \
638         leaked key still verifies elsewhere. To revoke a compromised identity everywhere, \
639         rotate (recover) or abandon it.",
640    );
641    out.newline();
642    out.println("To unfreeze early:");
643    out.println(&format!("  {}", out.dim("auths emergency unfreeze")));
644
645    Ok(())
646}
647
648/// Handle unfreeze — cancel an active freeze early.
649fn handle_unfreeze(cmd: UnfreezeCommand, now: chrono::DateTime<chrono::Utc>) -> Result<()> {
650    use auths_sdk::freeze::{load_active_freeze, remove_freeze};
651    use auths_sdk::storage_layout::layout;
652
653    let out = Output::new();
654
655    let repo_path = layout::resolve_repo_path(cmd.repo)?;
656
657    match load_active_freeze(&repo_path, now)? {
658        Some(state) => {
659            out.println(&format!(
660                "Active freeze until {}",
661                out.info(&state.frozen_until.format("%Y-%m-%d %H:%M UTC").to_string())
662            ));
663            out.newline();
664
665            if !cmd.yes {
666                let confirm = Confirm::new()
667                    .with_prompt("Remove freeze and restore signing?")
668                    .default(false)
669                    .interact()?;
670
671                if !confirm {
672                    out.println("Cancelled.");
673                    return Ok(());
674                }
675            }
676
677            remove_freeze(&repo_path)?;
678            out.print_success("Freeze removed. Signing operations are restored.");
679        }
680        None => {
681            out.print_info("No active freeze found.");
682        }
683    }
684
685    Ok(())
686}
687
688/// Handle incident report generation.
689fn handle_report(cmd: ReportCommand, now: chrono::DateTime<chrono::Utc>) -> Result<()> {
690    use auths_sdk::identity::ManagedIdentity;
691    use auths_sdk::ports::IdentityStorage;
692    use auths_sdk::storage::{RegistryAttestationStorage, RegistryIdentityStorage};
693    use auths_sdk::storage_layout::layout;
694
695    let out = Output::new();
696
697    let repo_path = layout::resolve_repo_path(cmd.repo.clone())?;
698
699    // Load real identity
700    let identity_storage = RegistryIdentityStorage::new(repo_path.clone());
701    let identity_did = match identity_storage.load_identity() {
702        Ok(ManagedIdentity { controller_did, .. }) => Some(controller_did),
703        Err(_) => None,
704    };
705
706    // Load real device attestations + anchor status
707    let attestation_storage = RegistryAttestationStorage::new(repo_path.clone());
708    let enriched = attestation_storage.load_all_enriched().unwrap_or_default();
709    let all_attestations: Vec<_> = enriched.iter().map(|e| e.attestation.clone()).collect();
710
711    // Build device list from attestations (deduplicate by subject DID)
712    let mut seen_devices = std::collections::HashSet::new();
713    let mut devices = Vec::new();
714    for e_att in &enriched {
715        let att = &e_att.attestation;
716        let did_str = att.subject.to_string();
717        if seen_devices.insert(did_str.clone()) {
718            let status = if att.is_revoked() {
719                "revoked"
720            } else if att.expires_at.is_some_and(|exp| exp <= now) {
721                "expired"
722            } else {
723                "active"
724            };
725            devices.push(DeviceInfo {
726                did: did_str,
727                name: att.note.clone(),
728                status: status.to_string(),
729                anchored: e_att.anchor == auths_keri::AnchorStatus::Anchored,
730                last_active: att.timestamp.map(|t| t.to_rfc3339()),
731            });
732        }
733    }
734
735    // Build recent events from attestation history (most recent first, capped)
736    let mut events: Vec<&auths_verifier::core::Attestation> = all_attestations.iter().collect();
737    events.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
738    let recent_events: Vec<EventInfo> = events
739        .iter()
740        .take(cmd.events)
741        .map(|att| {
742            let event_type = if att.is_revoked() {
743                "device_revocation"
744            } else {
745                "device_authorization"
746            };
747            EventInfo {
748                timestamp: att.timestamp.map(|t| t.to_rfc3339()).unwrap_or_default(),
749                event_type: event_type.to_string(),
750                details: format!("{} for {}", event_type, att.subject),
751            }
752        })
753        .collect();
754
755    // Generate recommendations based on actual state
756    let mut recommendations = Vec::new();
757    let active_count = devices.iter().filter(|d| d.status == "active").count();
758    let revoked_count = devices.iter().filter(|d| d.status == "revoked").count();
759    let expired_count = devices.iter().filter(|d| d.status == "expired").count();
760
761    if active_count > 0 {
762        recommendations.push(format!(
763            "Review all {} active device authorizations",
764            active_count
765        ));
766    }
767    if expired_count > 0 {
768        recommendations.push(format!(
769            "Clean up {} expired device authorizations",
770            expired_count
771        ));
772    }
773    if revoked_count > 0 {
774        recommendations.push(format!(
775            "{} device(s) already revoked — verify these were intentional",
776            revoked_count
777        ));
778    }
779    recommendations.push("Check for any unexpected signing activity".to_string());
780
781    let report = IncidentReport {
782        generated_at: now.to_rfc3339(),
783        identity_did: identity_did.map(|d| d.to_string()),
784        devices,
785        recent_events,
786        recommendations,
787    };
788
789    if is_json_mode() {
790        let json = serde_json::to_string_pretty(&report)?;
791        if let Some(output_path) = &cmd.output_file {
792            std::fs::write(output_path, &json)
793                .with_context(|| format!("Failed to write report to {:?}", output_path))?;
794            out.print_success(&format!("Report saved to {}", output_path.display()));
795        } else {
796            println!("{}", json);
797        }
798        return Ok(());
799    }
800
801    // Text output
802    out.print_heading("Incident Report");
803    out.newline();
804
805    out.println(&format!("Generated: {}", out.info(&report.generated_at)));
806    if let Some(did) = &report.identity_did {
807        out.println(&format!("Identity: {}", out.info(did)));
808    }
809    out.newline();
810
811    out.print_heading("  Devices");
812    for device in &report.devices {
813        let status_icon = if device.status == "active" {
814            out.success("●")
815        } else {
816            out.error("○")
817        };
818        out.println(&format!(
819            "    {} {} ({}) - {}",
820            status_icon,
821            device.did,
822            device.name.as_deref().unwrap_or("unnamed"),
823            device.status
824        ));
825    }
826    out.newline();
827
828    out.print_heading("  Recent Events");
829    for event in &report.recent_events {
830        out.println(&format!(
831            "    {} [{}] {}",
832            out.dim(&event.timestamp[..19]),
833            event.event_type,
834            event.details
835        ));
836    }
837    out.newline();
838
839    out.print_heading("  Recommendations");
840    for (i, rec) in report.recommendations.iter().enumerate() {
841        out.println(&format!("    {}. {}", i + 1, rec));
842    }
843
844    if let Some(output_path) = &cmd.output_file {
845        let json = serde_json::to_string_pretty(&report)?;
846        std::fs::write(output_path, json)
847            .with_context(|| format!("Failed to write report to {:?}", output_path))?;
848        out.newline();
849        out.print_success(&format!("Report also saved to {}", output_path.display()));
850    }
851
852    Ok(())
853}
854
855use crate::commands::executable::ExecutableCommand;
856use crate::config::CliConfig;
857
858impl ExecutableCommand for EmergencyCommand {
859    #[allow(clippy::disallowed_methods)]
860    fn execute(&self, ctx: &CliConfig) -> Result<()> {
861        handle_emergency(self.clone(), chrono::Utc::now(), ctx)
862    }
863}
864
865#[cfg(test)]
866#[allow(clippy::disallowed_methods)]
867mod tests {
868    use super::*;
869
870    #[test]
871    fn test_incident_report_serialization() {
872        let report = IncidentReport {
873            generated_at: "2024-01-15T10:30:00Z".to_string(),
874            identity_did: Some("did:keri:ETest".to_string()),
875            devices: vec![],
876            recent_events: vec![],
877            recommendations: vec!["Test recommendation".to_string()],
878        };
879
880        let json = serde_json::to_string(&report).unwrap();
881        assert!(json.contains("did:keri:ETest"));
882        assert!(json.contains("Test recommendation"));
883    }
884
885    #[test]
886    fn test_device_info_serialization() {
887        let device = DeviceInfo {
888            did: "did:key:z6MkTest".to_string(),
889            name: Some("Test Device".to_string()),
890            status: "active".to_string(),
891            anchored: true,
892            last_active: None,
893        };
894
895        let json = serde_json::to_string(&device).unwrap();
896        assert!(json.contains("did:key:z6MkTest"));
897        assert!(json.contains("Test Device"));
898    }
899
900    #[test]
901    fn test_freeze_dry_run() {
902        let dir = tempfile::TempDir::new().unwrap();
903        let result = handle_freeze(
904            FreezeCommand {
905                duration: "24h".to_string(),
906                yes: true,
907                dry_run: true,
908                repo: Some(dir.path().to_path_buf()),
909            },
910            chrono::Utc::now(),
911        );
912
913        assert!(result.is_ok());
914        // Dry run should NOT create the freeze file
915        assert!(!dir.path().join("freeze.json").exists());
916    }
917
918    #[test]
919    fn test_freeze_creates_freeze_file() {
920        let dir = tempfile::TempDir::new().unwrap();
921        let result = handle_freeze(
922            FreezeCommand {
923                duration: "1h".to_string(),
924                yes: true,
925                dry_run: false,
926                repo: Some(dir.path().to_path_buf()),
927            },
928            chrono::Utc::now(),
929        );
930
931        assert!(result.is_ok());
932        assert!(dir.path().join("freeze.json").exists());
933
934        // Verify the freeze is active
935        let state = auths_sdk::freeze::load_active_freeze(dir.path(), chrono::Utc::now()).unwrap();
936        assert!(state.is_some());
937    }
938
939    #[test]
940    fn test_freeze_invalid_duration() {
941        let dir = tempfile::TempDir::new().unwrap();
942        let result = handle_freeze(
943            FreezeCommand {
944                duration: "invalid".to_string(),
945                yes: true,
946                dry_run: false,
947                repo: Some(dir.path().to_path_buf()),
948            },
949            chrono::Utc::now(),
950        );
951
952        assert!(result.is_err());
953        let err_msg = result.unwrap_err().to_string();
954        assert!(
955            err_msg.contains("Invalid") || err_msg.contains("duration"),
956            "Expected duration parse error, got: {}",
957            err_msg
958        );
959    }
960
961    #[test]
962    fn test_unfreeze_removes_freeze() {
963        let dir = tempfile::TempDir::new().unwrap();
964
965        // Create a freeze
966        handle_freeze(
967            FreezeCommand {
968                duration: "24h".to_string(),
969                yes: true,
970                dry_run: false,
971                repo: Some(dir.path().to_path_buf()),
972            },
973            chrono::Utc::now(),
974        )
975        .unwrap();
976        assert!(dir.path().join("freeze.json").exists());
977
978        // Unfreeze
979        handle_unfreeze(
980            UnfreezeCommand {
981                yes: true,
982                repo: Some(dir.path().to_path_buf()),
983            },
984            chrono::Utc::now(),
985        )
986        .unwrap();
987        assert!(!dir.path().join("freeze.json").exists());
988    }
989
990    #[test]
991    fn confirmation_fails_closed_without_a_terminal_and_without_yes() {
992        // --yes always proceeds — the headless path for runbooks and automation.
993        assert_eq!(confirmation_mode(true, false), ConfirmationMode::Proceed);
994        assert_eq!(confirmation_mode(true, true), ConfirmationMode::Proceed);
995        // Interactive without --yes prompts for typed confirmation.
996        assert_eq!(confirmation_mode(false, true), ConfirmationMode::Prompt);
997        // Non-interactive without --yes is refused (fail closed): never a cryptic TTY crash,
998        // and never a silent no-op that leaves the action half-done.
999        assert_eq!(
1000            confirmation_mode(false, false),
1001            ConfirmationMode::NonInteractive
1002        );
1003    }
1004
1005    #[test]
1006    fn confirm_or_refuse_fails_closed_with_an_actionable_message() {
1007        // --yes proceeds headlessly for every destructive action (freeze, rotation).
1008        assert!(confirm_or_refuse(true, false, "key rotation", "ROTATE").unwrap());
1009        // Non-interactive without --yes refuses with an actionable error that names --yes and
1010        // the action that was NOT applied — the same fail-closed gate for every action.
1011        let err = confirm_or_refuse(false, false, "key rotation", "ROTATE")
1012            .expect_err("non-interactive without --yes must fail closed");
1013        assert!(
1014            err.to_string().contains("--yes"),
1015            "must point to --yes: {err}"
1016        );
1017        assert!(
1018            err.to_string().contains("rotation"),
1019            "must name the refused action: {err}"
1020        );
1021    }
1022
1023    #[test]
1024    fn handle_freeze_refuses_non_interactive_without_yes() {
1025        // Tests run headless (no TTY), so a freeze without --yes must fail closed:
1026        // return an actionable error pointing to --yes and write no freeze file —
1027        // never crash cryptically, never silently leave signing enabled.
1028        let dir = tempfile::TempDir::new().unwrap();
1029        let result = handle_freeze(
1030            FreezeCommand {
1031                duration: "24h".to_string(),
1032                yes: false,
1033                dry_run: false,
1034                repo: Some(dir.path().to_path_buf()),
1035            },
1036            chrono::Utc::now(),
1037        );
1038        let err = result.expect_err("freeze without --yes in a non-interactive env must fail");
1039        assert!(
1040            err.to_string().contains("--yes"),
1041            "the error must direct the operator to --yes, got: {err}"
1042        );
1043        assert!(
1044            !dir.path().join("freeze.json").exists(),
1045            "no freeze must be written"
1046        );
1047    }
1048}