Skip to main content

cardanowall_cli/
secret.rs

1//! The single shared secret + config resolution layer used by every command.
2//!
3//! Two distinct precedence chains live here, so no command re-implements either:
4//!
5//! ## High-secrets (`--seed`, `--secret-key`)
6//!
7//! These decode to private key material. The resolution order is:
8//!
9//! 1. `--<name>-file <path>`  — read the file, trim trailing whitespace.
10//! 2. `--<name>-stdin` (or the literal value `-`) — read all of stdin, trim the
11//!    trailing newline. Only one stdin reader may run per process.
12//! 3. the raw `--<name> <hex>` argv flag — explicit, so it wins over env, but it
13//!    is the documented-INSECURE path (shell history / `ps` / CI logs).
14//! 4. the env var (`CARDANOWALL_SEED` / `CARDANOWALL_RECIPIENT_KEY`).
15//! 5. an interactive hidden prompt — ONLY when the secret is required AND stdin
16//!    is a TTY. The prompt text goes to stderr; the typed bytes never echo.
17//! 6. otherwise: a CLI input error (exit `4`).
18//!
19//! A high-secret is NEVER a required argv flag — automation supplies it through a
20//! file, stdin, or the environment; humans get the hidden prompt. The decoded hex
21//! buffer is zeroized after the bytes are produced.
22//!
23//! ## Non-secret gateway config (`--base-url`, `--api-key`)
24//!
25//! The order is `explicit flag > env > active gateway profile > built-in default
26//! (data gateways only) > error`. The profile lookup is resolved by the caller
27//! (it already holds the parsed config); this module only sequences the chain.
28
29use std::io::{IsTerminal, Read};
30
31use zeroize::Zeroize;
32
33use crate::util::{hex_to_bytes, CliError};
34
35/// Which high-secret is being resolved — drives the env var, the flag names in
36/// error messages, and the interactive prompt text.
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum SecretKind {
39    /// The 32-byte master identity seed.
40    Seed,
41    /// The X25519 recipient secret key (recipient-sealed decryption).
42    RecipientKey,
43}
44
45impl SecretKind {
46    /// The canonical env var that supplies this secret on every command.
47    #[must_use]
48    pub fn env_var(self) -> &'static str {
49        match self {
50            SecretKind::Seed => "CARDANOWALL_SEED",
51            SecretKind::RecipientKey => "CARDANOWALL_RECIPIENT_KEY",
52        }
53    }
54
55    /// The base flag name (without the leading dashes), e.g. `seed`.
56    #[must_use]
57    pub fn flag(self) -> &'static str {
58        match self {
59            SecretKind::Seed => "seed",
60            SecretKind::RecipientKey => "secret-key",
61        }
62    }
63
64    /// The interactive hidden-prompt line written to stderr.
65    fn prompt(self) -> &'static str {
66        match self {
67            SecretKind::Seed => "Enter 32-byte identity seed (hex): ",
68            SecretKind::RecipientKey => "Enter X25519 recipient secret key (hex): ",
69        }
70    }
71}
72
73/// The argv inputs for one high-secret, as collected by clap. The four sources
74/// are mutually-exclusive in practice but resolved by documented precedence here
75/// rather than rejected, so a power user mixing `--seed-file` with an env var
76/// still gets deterministic behaviour.
77#[derive(Debug, Clone, Default)]
78pub struct SecretArgs {
79    /// `--<name> <hex>` — the raw, documented-insecure argv value.
80    pub value: Option<String>,
81    /// `--<name>-file <path>`.
82    pub file: Option<String>,
83    /// `--<name>-stdin` — read the secret from stdin.
84    pub stdin: bool,
85}
86
87impl SecretArgs {
88    /// Whether the user supplied any source on argv (file/stdin/value).
89    #[must_use]
90    pub fn any_present(&self) -> bool {
91        self.file.is_some() || self.stdin || self.value.as_deref().is_some_and(|v| !v.is_empty())
92    }
93}
94
95/// A terminal probe + reader surface, injected so tests never touch a real TTY.
96///
97/// Production wiring uses [`SystemSecretEnv`]; tests supply a fake that reports a
98/// non-terminal stdin and canned stdin/file/env reads.
99pub trait SecretEnv {
100    /// Read an environment variable.
101    fn var(&self, key: &str) -> Option<String>;
102    /// Read the whole of stdin to a `String`.
103    fn read_stdin(&self) -> Result<String, CliError>;
104    /// Read a file to a `String`.
105    fn read_file(&self, path: &str) -> Result<String, CliError>;
106    /// Whether stdin is a TTY (gates the interactive prompt).
107    fn stdin_is_terminal(&self) -> bool;
108    /// Prompt on stderr and read a line WITHOUT echo (the hidden prompt).
109    fn prompt_hidden(&self, prompt: &str) -> Result<String, CliError>;
110}
111
112/// The production secret environment: real env, real stdin, real `rpassword`.
113pub struct SystemSecretEnv;
114
115impl SecretEnv for SystemSecretEnv {
116    fn var(&self, key: &str) -> Option<String> {
117        std::env::var(key).ok().filter(|v| !v.is_empty())
118    }
119
120    fn read_stdin(&self) -> Result<String, CliError> {
121        let mut buf = String::new();
122        std::io::stdin()
123            .read_to_string(&mut buf)
124            .map_err(|e| CliError::network(format!("cannot read stdin: {e}")))?;
125        Ok(buf)
126    }
127
128    fn read_file(&self, path: &str) -> Result<String, CliError> {
129        std::fs::read_to_string(path)
130            .map_err(|e| CliError::input(format!("cannot read secret file {path}: {e}")))
131    }
132
133    fn stdin_is_terminal(&self) -> bool {
134        std::io::stdin().is_terminal()
135    }
136
137    fn prompt_hidden(&self, prompt: &str) -> Result<String, CliError> {
138        // rpassword writes the prompt to the controlling terminal and reads the
139        // line with echo disabled, so the secret never lands in scrollback.
140        rpassword::prompt_password(prompt)
141            .map_err(|e| CliError::input(format!("cannot read hidden prompt: {e}")))
142    }
143}
144
145/// Trim a secret read from a file or stdin: drop a single trailing newline (and
146/// any other surrounding whitespace) so a `printf '%s\n' hex > seed` round-trips.
147fn trim_secret(raw: &str) -> String {
148    raw.trim().to_string()
149}
150
151/// Resolve a high-secret to its raw bytes, length-checked to `expected_len`.
152///
153/// `required` decides whether a missing secret triggers the interactive prompt
154/// (TTY only) or a hard error. `cmd` and `kind` shape the error/prompt text.
155///
156/// The intermediate hex string is zeroized before returning.
157///
158/// # Errors
159///
160/// Returns [`CliError`] (exit `4`) for a malformed value, a wrong byte length, a
161/// missing required secret on a non-TTY, or a file/stdin read failure.
162pub fn resolve_secret_bytes(
163    kind: SecretKind,
164    args: &SecretArgs,
165    expected_len: usize,
166    required: bool,
167    cmd: &str,
168    env: &dyn SecretEnv,
169) -> Result<Option<Vec<u8>>, CliError> {
170    let mut hex = match resolve_secret_hex(kind, args, required, cmd, env)? {
171        Some(hex) => hex,
172        None => return Ok(None),
173    };
174    let result = decode_and_check(kind, &hex, expected_len, cmd);
175    hex.zeroize();
176    result.map(Some)
177}
178
179/// The hex-string half of the resolution chain (no decode), so callers that want
180/// the raw string (e.g. comma-list recipient keys) can post-process it.
181fn resolve_secret_hex(
182    kind: SecretKind,
183    args: &SecretArgs,
184    required: bool,
185    cmd: &str,
186    env: &dyn SecretEnv,
187) -> Result<Option<String>, CliError> {
188    // 1. file.
189    if let Some(path) = args.file.as_deref().filter(|p| !p.is_empty()) {
190        return Ok(Some(trim_secret(&env.read_file(path)?)));
191    }
192    // 2. stdin (explicit flag or the literal value `-`).
193    let stdin_sentinel = args.value.as_deref() == Some("-");
194    if args.stdin || stdin_sentinel {
195        return Ok(Some(trim_secret(&env.read_stdin()?)));
196    }
197    // 3. raw argv value (explicit → beats env), excluding the `-` sentinel.
198    if let Some(value) = args.value.as_deref().filter(|v| !v.is_empty()) {
199        return Ok(Some(value.trim().to_string()));
200    }
201    // 4. env var.
202    if let Some(value) = env.var(kind.env_var()) {
203        return Ok(Some(value.trim().to_string()));
204    }
205    // 5. interactive hidden prompt — only when required AND stdin is a TTY.
206    if required && env.stdin_is_terminal() {
207        let entered = env.prompt_hidden(kind.prompt())?;
208        let trimmed = trim_secret(&entered);
209        if trimmed.is_empty() {
210            return Err(CliError::input(format!(
211                "{cmd}: no {} provided",
212                kind.flag()
213            )));
214        }
215        return Ok(Some(trimmed));
216    }
217    // 6. nothing.
218    if required {
219        Err(CliError::input(format!(
220            "{cmd}: --{flag} is required — pass --{flag}-file <path>, --{flag}-stdin, \
221             set {env}, or run interactively for a hidden prompt",
222            flag = kind.flag(),
223            env = kind.env_var(),
224        )))
225    } else {
226        Ok(None)
227    }
228}
229
230fn decode_and_check(
231    kind: SecretKind,
232    hex: &str,
233    expected_len: usize,
234    cmd: &str,
235) -> Result<Vec<u8>, CliError> {
236    let bytes =
237        hex_to_bytes(hex).map_err(|e| CliError::input(format!("{cmd}: --{} {e}", kind.flag())))?;
238    if bytes.len() != expected_len {
239        return Err(CliError::input(format!(
240            "{cmd}: --{} must decode to exactly {expected_len} bytes (got {})",
241            kind.flag(),
242            bytes.len()
243        )));
244    }
245    Ok(bytes)
246}
247
248// ===========================================================================
249// Non-secret gateway config resolution (base-url, api-key)
250// ===========================================================================
251
252/// Resolve one non-secret config value through `flag > env > profile > error`.
253///
254/// `default` (data gateways only) is appended by the caller for slots that have a
255/// built-in fallback; the API key and base URL have none, so `None` flows through.
256#[must_use]
257pub fn resolve_config_value(
258    flag: Option<&str>,
259    env: Option<&str>,
260    profile: Option<&str>,
261) -> Option<String> {
262    for candidate in [flag, env, profile] {
263        if let Some(value) = candidate.map(str::trim).filter(|v| !v.is_empty()) {
264            return Some(value.to_string());
265        }
266    }
267    None
268}
269
270/// The resolved service-gateway endpoint: the base URL plus the opaque bearer.
271#[derive(Debug, Clone, Default)]
272pub struct ServiceGateway {
273    /// The required base URL.
274    pub base_url: String,
275    /// The opaque bearer API key, when supplied anywhere.
276    pub api_key: Option<String>,
277}
278
279/// Resolve the service-gateway base URL + API key for a network command, applying
280/// `explicit flag > env > active gateway profile` to each, and reading both env
281/// vars through the injected [`SecretEnv`].
282///
283/// The base URL is required; the API key is optional (a key-less public gateway).
284/// `profile` is the active [`GatewayProfile`](crate::config::GatewayProfile)
285/// selected by the caller (the `--gateway-profile` flag or the config default).
286///
287/// # Errors
288///
289/// Returns [`CliError`] (exit `4`) when no base URL resolves from any source.
290pub fn resolve_service_gateway(
291    base_url_flag: Option<&str>,
292    api_key_flag: Option<&str>,
293    profile: Option<&crate::config::GatewayProfile>,
294    cmd: &str,
295    env: &dyn SecretEnv,
296) -> Result<ServiceGateway, CliError> {
297    let profile_base = profile.map(|p| p.base_url.as_str());
298    let profile_key = profile.and_then(|p| p.api_key.as_deref());
299
300    let base_url = resolve_config_value(
301        base_url_flag,
302        env.var("CARDANOWALL_BASE_URL").as_deref(),
303        profile_base,
304    )
305    .ok_or_else(|| {
306        CliError::input(format!(
307            "{cmd}: a gateway base URL is required — pass --base-url, set CARDANOWALL_BASE_URL, \
308             or configure a gateway profile (cardanowall gateway add …)"
309        ))
310    })?;
311
312    let api_key = resolve_config_value(
313        api_key_flag,
314        env.var("CARDANOWALL_API_KEY").as_deref(),
315        profile_key,
316    );
317
318    Ok(ServiceGateway { base_url, api_key })
319}
320
321/// Test doubles for the secret environment, shared by this module's tests and the
322/// command modules' tests so each command can exercise the file/stdin/env/error
323/// paths without a real TTY.
324#[cfg(test)]
325pub mod test_support {
326    use super::*;
327    use std::cell::RefCell;
328    use std::collections::HashMap;
329
330    /// A fake env where stdin is NOT a terminal (so the prompt branch is skipped)
331    /// unless a test opts into `terminal = true`.
332    pub struct FakeSecretEnv {
333        /// Environment variables visible to the fake.
334        pub vars: HashMap<String, String>,
335        /// Canned file contents keyed by path.
336        pub files: HashMap<String, String>,
337        /// Canned stdin contents.
338        pub stdin: Option<String>,
339        /// Whether stdin reports as a TTY (gates the prompt branch).
340        pub terminal: bool,
341        /// The string the hidden prompt returns when invoked.
342        pub prompt_response: Option<String>,
343        /// Records whether the prompt branch was hit.
344        pub prompted: RefCell<bool>,
345    }
346
347    impl Default for FakeSecretEnv {
348        fn default() -> Self {
349            Self {
350                vars: HashMap::new(),
351                files: HashMap::new(),
352                stdin: None,
353                terminal: false,
354                prompt_response: None,
355                prompted: RefCell::new(false),
356            }
357        }
358    }
359
360    impl SecretEnv for FakeSecretEnv {
361        fn var(&self, key: &str) -> Option<String> {
362            self.vars.get(key).cloned().filter(|v| !v.is_empty())
363        }
364        fn read_stdin(&self) -> Result<String, CliError> {
365            self.stdin
366                .clone()
367                .ok_or_else(|| CliError::network("no stdin in fake".to_string()))
368        }
369        fn read_file(&self, path: &str) -> Result<String, CliError> {
370            self.files
371                .get(path)
372                .cloned()
373                .ok_or_else(|| CliError::input(format!("no fake file {path}")))
374        }
375        fn stdin_is_terminal(&self) -> bool {
376            self.terminal
377        }
378        fn prompt_hidden(&self, _prompt: &str) -> Result<String, CliError> {
379            *self.prompted.borrow_mut() = true;
380            self.prompt_response
381                .clone()
382                .ok_or_else(|| CliError::input("no prompt response in fake".to_string()))
383        }
384    }
385}
386
387#[cfg(test)]
388mod tests {
389    use super::test_support::FakeSecretEnv as FakeEnv;
390    use super::*;
391    use std::collections::HashMap;
392
393    fn seed_hex() -> String {
394        "ab".repeat(32)
395    }
396
397    #[test]
398    fn file_beats_stdin_env_value() {
399        let env = FakeEnv {
400            files: HashMap::from([("/s".to_string(), format!("{}\n", seed_hex()))]),
401            stdin: Some("cd".repeat(32)),
402            vars: HashMap::from([("CARDANOWALL_SEED".to_string(), "ef".repeat(32))]),
403            ..FakeEnv::default()
404        };
405        let args = SecretArgs {
406            value: Some("12".repeat(32)),
407            file: Some("/s".to_string()),
408            stdin: true,
409        };
410        let bytes = resolve_secret_bytes(SecretKind::Seed, &args, 32, true, "identity", &env)
411            .unwrap()
412            .unwrap();
413        assert_eq!(bytes, hex_to_bytes(&seed_hex()).unwrap());
414    }
415
416    #[test]
417    fn stdin_beats_env_and_trims_newline() {
418        let env = FakeEnv {
419            stdin: Some(format!("{}\n", seed_hex())),
420            vars: HashMap::from([("CARDANOWALL_SEED".to_string(), "ef".repeat(32))]),
421            ..FakeEnv::default()
422        };
423        let args = SecretArgs {
424            stdin: true,
425            ..SecretArgs::default()
426        };
427        let bytes = resolve_secret_bytes(SecretKind::Seed, &args, 32, true, "identity", &env)
428            .unwrap()
429            .unwrap();
430        assert_eq!(bytes, hex_to_bytes(&seed_hex()).unwrap());
431    }
432
433    #[test]
434    fn dash_value_means_stdin() {
435        let env = FakeEnv {
436            stdin: Some(seed_hex()),
437            ..FakeEnv::default()
438        };
439        let args = SecretArgs {
440            value: Some("-".to_string()),
441            ..SecretArgs::default()
442        };
443        let bytes = resolve_secret_bytes(SecretKind::Seed, &args, 32, true, "identity", &env)
444            .unwrap()
445            .unwrap();
446        assert_eq!(bytes.len(), 32);
447    }
448
449    #[test]
450    fn argv_value_beats_env() {
451        let env = FakeEnv {
452            vars: HashMap::from([("CARDANOWALL_SEED".to_string(), "ef".repeat(32))]),
453            ..FakeEnv::default()
454        };
455        let args = SecretArgs {
456            value: Some(seed_hex()),
457            ..SecretArgs::default()
458        };
459        let bytes = resolve_secret_bytes(SecretKind::Seed, &args, 32, true, "identity", &env)
460            .unwrap()
461            .unwrap();
462        assert_eq!(bytes, hex_to_bytes(&seed_hex()).unwrap());
463    }
464
465    #[test]
466    fn env_used_when_no_flag() {
467        let env = FakeEnv {
468            vars: HashMap::from([("CARDANOWALL_SEED".to_string(), seed_hex())]),
469            ..FakeEnv::default()
470        };
471        let bytes = resolve_secret_bytes(
472            SecretKind::Seed,
473            &SecretArgs::default(),
474            32,
475            true,
476            "identity",
477            &env,
478        )
479        .unwrap()
480        .unwrap();
481        assert_eq!(bytes.len(), 32);
482    }
483
484    #[test]
485    fn missing_required_non_tty_is_input_error_no_prompt() {
486        let env = FakeEnv::default(); // terminal = false
487        let err = resolve_secret_bytes(
488            SecretKind::Seed,
489            &SecretArgs::default(),
490            32,
491            true,
492            "identity",
493            &env,
494        )
495        .unwrap_err();
496        assert_eq!(err.code, 4);
497        assert!(!*env.prompted.borrow(), "must not prompt on a non-TTY");
498    }
499
500    #[test]
501    fn missing_optional_is_none() {
502        let env = FakeEnv::default();
503        let out = resolve_secret_bytes(
504            SecretKind::Seed,
505            &SecretArgs::default(),
506            32,
507            false,
508            "submit",
509            &env,
510        )
511        .unwrap();
512        assert!(out.is_none());
513    }
514
515    #[test]
516    fn prompt_used_only_on_tty_when_required() {
517        let env = FakeEnv {
518            terminal: true,
519            prompt_response: Some(format!("{}\n", seed_hex())),
520            ..FakeEnv::default()
521        };
522        let bytes = resolve_secret_bytes(
523            SecretKind::Seed,
524            &SecretArgs::default(),
525            32,
526            true,
527            "identity",
528            &env,
529        )
530        .unwrap()
531        .unwrap();
532        assert_eq!(bytes.len(), 32);
533        assert!(*env.prompted.borrow());
534    }
535
536    #[test]
537    fn rejects_wrong_length() {
538        let env = FakeEnv {
539            vars: HashMap::from([("CARDANOWALL_SEED".to_string(), "abcd".to_string())]),
540            ..FakeEnv::default()
541        };
542        let err = resolve_secret_bytes(
543            SecretKind::Seed,
544            &SecretArgs::default(),
545            32,
546            true,
547            "identity",
548            &env,
549        )
550        .unwrap_err();
551        assert_eq!(err.code, 4);
552    }
553
554    #[test]
555    fn config_value_precedence() {
556        assert_eq!(
557            resolve_config_value(Some("flag"), Some("env"), Some("prof")),
558            Some("flag".to_string())
559        );
560        assert_eq!(
561            resolve_config_value(None, Some("env"), Some("prof")),
562            Some("env".to_string())
563        );
564        assert_eq!(
565            resolve_config_value(None, None, Some("prof")),
566            Some("prof".to_string())
567        );
568        assert_eq!(resolve_config_value(None, None, None), None);
569        // Empty strings are skipped.
570        assert_eq!(
571            resolve_config_value(Some("  "), None, Some("prof")),
572            Some("prof".to_string())
573        );
574    }
575}