Skip to main content

ssh_cli/vps/
secrets_cmd.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2// G-COMP: secrets command dispatcher extracted from vps/mod (SRP; line budget).
3#![forbid(unsafe_code)]
4//! One-shot `secrets status|init|reencrypt` local crypto workload.
5
6use super::config_io::{load, resolve_config_path, save};
7use crate::cli::{OutputFormat, SecretsAction};
8use crate::errors::SshCliError;
9use anyhow::Result;
10use std::path::{Path, PathBuf};
11
12/// Dispatcher one-shot de `secrets status|init|reencrypt`.
13/// Secrets primary-key status/init/reencrypt.
14///
15/// Workload: **local crypto + disk** (not multi-host SSH). Sequential justified:
16/// single key file / single config rewrite; Mutex in secrets layer.
17pub async fn run_secrets_command(
18    action: SecretsAction,
19    config_override: Option<PathBuf>,
20    format: OutputFormat,
21) -> Result<()> {
22    // Keep secrets.key aligned with --config-dir (owned copy for process static).
23    crate::secrets::set_config_dir(config_override.clone());
24    match action {
25        SecretsAction::Status { json } => {
26            let seg = crate::secrets::secrets_status()?;
27            let use_json = json || format == OutputFormat::Json;
28            if use_json {
29                let v = serde_json::json!({
30                    "encryption_active": seg.encryption_active,
31                    "key_source": seg.source.as_str(),
32                    "key_file": seg.key_file_path.display().to_string(),
33                    "plaintext_opt_out": seg.plaintext_opt_out,
34                    "at_rest": if seg.encryption_active { "encrypted" } else { "plaintext" },
35                });
36                crate::output::print_json_value(&v)?;
37            } else {
38                let at_rest = if seg.encryption_active {
39                    "encrypted"
40                } else {
41                    "plaintext"
42                };
43                crate::output::print_success_fmt(format_args!(
44                    "at-rest: {at_rest} | source: {} | key_file: {} | plaintext_opt_out: {}",
45                    seg.source.as_str(),
46                    seg.key_file_path.display(),
47                    seg.plaintext_opt_out
48                ));
49            }
50            Ok(())
51        }
52        SecretsAction::Init {
53            keyring,
54            force,
55            json,
56        } => {
57            // GAP-AUD-SEC-001: rotating the primary key without re-encrypting hosts
58            // permanently loses at-rest secrets. Load/decrypt BEFORE overwriting the key,
59            // then save under the new key after rotation.
60            let path = resolve_config_path(config_override.as_deref())?;
61            let hosts_to_reencrypt = if force && path.is_file() {
62                Some(load(&path)?)
63            } else {
64                None
65            };
66
67            // C2: `--force` rotates the primary key, which invalidates every stored
68            // secret that is not re-encrypted in the same run. The plan states how
69            // many hosts that is, because "0" and "12" call for very different
70            // amounts of care before pressing enter.
71            if crate::cli::dry_run_stop(
72                "secrets-init",
73                &[
74                    ("force", serde_json::json!(force)),
75                    ("keyring", serde_json::json!(keyring)),
76                    (
77                        "hosts_to_reencrypt",
78                        serde_json::json!(hosts_to_reencrypt.as_ref().map_or(0, |f| f.hosts.len())),
79                    ),
80                    ("config_path", serde_json::json!(path.display().to_string())),
81                ],
82            )? {
83                return Ok(());
84            }
85
86            let seg = crate::secrets::init_primary_key(keyring, force)?;
87            let mut reencrypted_hosts = 0usize;
88
89            if let Some(file) = hosts_to_reencrypt {
90                reencrypted_hosts = file.hosts.len();
91                save(&path, &file).map_err(|e| {
92                    SshCliError::Config(format!(
93                        "primary key was rotated but re-encrypting config failed: {e}; \
94                         restore secrets.key.bak if present and re-run `secrets reencrypt`"
95                    ))
96                })?;
97            }
98
99            let use_json = json || format == OutputFormat::Json;
100            crate::output::emit_success(
101                "secrets-init",
102                serde_json::json!({
103                    "key_source": seg.source.as_str(),
104                    "key_file": seg.key_file_path.display().to_string(),
105                    "reencrypted_hosts": reencrypted_hosts,
106                    "force": force,
107                }),
108                &crate::i18n::t(crate::i18n::Message::PrimaryKeyReady {
109                    source: seg.source.as_str().to_string(),
110                    key_file: seg.key_file_path.display().to_string(),
111                }),
112                use_json,
113            )?;
114            Ok(())
115        }
116        SecretsAction::Reencrypt { json } => {
117            let path = resolve_config_path(config_override.as_deref())?;
118            // C2: rewrites every secret in `config.toml` under the current key.
119            if crate::cli::dry_run_stop(
120                "secrets-reencrypt",
121                &[
122                    ("config_path", serde_json::json!(path.display().to_string())),
123                    (
124                        "hosts",
125                        serde_json::json!(if path.is_file() {
126                            load(&path)?.hosts.len()
127                        } else {
128                            0
129                        }),
130                    ),
131                ],
132            )? {
133                return Ok(());
134            }
135            run_reencrypt(&path, json || format == OutputFormat::Json)?;
136            Ok(())
137        }
138    }
139}
140
141/// Reloads and rewrites config, re-encrypting secrets with the current key.
142/// Re-encrypt all secrets in `config.toml` with the current primary key.
143///
144/// Workload: **local AEAD** over one file. Sequential justified: single atomic
145/// save; host count is small vs coordination overhead.
146fn run_reencrypt(path: &Path, json: bool) -> Result<()> {
147    let (key, _source) = crate::secrets::ensure_key_for_write()?;
148    if key.is_none() {
149        return Err(SshCliError::InvalidArgument(
150            "no primary-key; run `ssh-cli secrets init` or pass --allow-plaintext-secrets"
151                .to_string(),
152        )
153        .into());
154    }
155    if let Some(mut k) = key {
156        use zeroize::Zeroize;
157        k.zeroize();
158    }
159    let file = load(path)?;
160    let hosts = file.hosts.len();
161    save(path, &file)?;
162    crate::output::emit_success(
163        "secrets-reencrypt",
164        serde_json::json!({ "hosts": hosts }),
165        &crate::i18n::t(crate::i18n::Message::ReencryptCompleted { hosts }),
166        json,
167    )?;
168    Ok(())
169}
170
171/// Side-effect metadata when `secrets.key` was auto-created in this process.
172///
173/// G-E2E-04: callers **fold** this into the primary success event (one JSON
174/// document per one-shot). Do **not** emit a second stdout root.
175#[derive(Debug, Clone)]
176pub(crate) struct AutoKeyMeta {
177    /// Absolute path to the primary-key file.
178    pub key_file: String,
179    /// Always `xdg_file` for auto-create.
180    pub key_source: &'static str,
181}
182
183/// Consumes the auto-key-created flag without writing to stdout (G-E2E-04).
184#[must_use]
185pub(crate) fn take_auto_key_meta() -> Option<AutoKeyMeta> {
186    if crate::secrets::take_auto_key_created() {
187        let path = crate::secrets::secrets_key_path().unwrap_or_default();
188        Some(AutoKeyMeta {
189            key_file: path.display().to_string(),
190            key_source: "xdg_file",
191        })
192    } else {
193        None
194    }
195}