sesame/
secrets.rs

1use anyhow::Context;
2use core_types::{EventKind, SecurityLevel, SensitiveBytes, TrustProfileName};
3use owo_colors::OwoColorize;
4use zeroize::Zeroize;
5
6use crate::helpers::{format_denial_reason, validate_profile_in_config, validate_secret_key};
7use crate::ipc::{connect, rpc};
8
9pub(crate) async fn cmd_secret_set(profile: &str, key: &str) -> anyhow::Result<()> {
10    validate_secret_key(key)?;
11    validate_profile_in_config(profile)?;
12    let client = connect().await?;
13    let profile = TrustProfileName::try_from(profile).map_err(|e| anyhow::anyhow!("{e}"))?;
14
15    let mut value = if std::io::IsTerminal::is_terminal(&std::io::stdin()) {
16        dialoguer::Password::new()
17            .with_prompt(format!("Value for '{key}'"))
18            .interact()
19            .context("failed to read secret value")?
20    } else {
21        let mut buf = String::new();
22        std::io::Read::read_to_string(&mut std::io::stdin(), &mut buf)
23            .context("failed to read secret value from stdin")?;
24        // Trim trailing newline from piped input.
25        if buf.ends_with('\n') {
26            buf.pop();
27            if buf.ends_with('\r') {
28                buf.pop();
29            }
30        }
31        buf
32    };
33
34    let event = EventKind::SecretSet {
35        profile: profile.clone(),
36        key: key.to_owned(),
37        value: SensitiveBytes::from_slice(value.as_bytes()),
38    };
39    value.zeroize();
40
41    match rpc(&client, event, SecurityLevel::SecretsOnly).await? {
42        EventKind::SecretSetResponse { success: true, .. } => {
43            println!("Secret '{key}' stored in profile '{profile}'.");
44        }
45        EventKind::SecretSetResponse {
46            success: false,
47            denial,
48        } => {
49            if let Some(reason) = denial {
50                anyhow::bail!("{}", format_denial_reason(&reason, key, &profile));
51            }
52            anyhow::bail!("failed to store secret");
53        }
54        other => anyhow::bail!("unexpected response: {other:?}"),
55    }
56
57    Ok(())
58}
59
60pub(crate) async fn cmd_secret_get(profile: &str, key: &str) -> anyhow::Result<()> {
61    validate_secret_key(key)?;
62    validate_profile_in_config(profile)?;
63    let client = connect().await?;
64    let profile = TrustProfileName::try_from(profile).map_err(|e| anyhow::anyhow!("{e}"))?;
65
66    let event = EventKind::SecretGet {
67        profile: profile.clone(),
68        key: key.to_owned(),
69    };
70
71    match rpc(&client, event, SecurityLevel::SecretsOnly).await? {
72        EventKind::SecretGetResponse {
73            key: k,
74            value,
75            denial,
76        } => {
77            if let Some(reason) = denial {
78                anyhow::bail!("{}", format_denial_reason(&reason, &k, &profile));
79            }
80            if value.is_empty() {
81                anyhow::bail!("secret '{k}' not found in profile '{profile}'");
82            }
83            // With default config (ipc-field-encryption off), value is
84            // plaintext over Noise-encrypted transport. Print as UTF-8
85            // if valid, hex otherwise. Zeroize all copies after printing.
86            match String::from_utf8(value.as_bytes().to_vec()) {
87                Ok(mut s) => {
88                    println!("{s}");
89                    s.zeroize();
90                }
91                Err(_) => {
92                    let mut hex: String = value
93                        .as_bytes()
94                        .iter()
95                        .map(|b| format!("{b:02x}"))
96                        .collect();
97                    println!("{hex}");
98                    hex.zeroize();
99                }
100            }
101        }
102        other => anyhow::bail!("unexpected response: {other:?}"),
103    }
104
105    Ok(())
106}
107
108pub(crate) async fn cmd_secret_delete(
109    profile: &str,
110    key: &str,
111    skip_confirm: bool,
112) -> anyhow::Result<()> {
113    validate_secret_key(key)?;
114    validate_profile_in_config(profile)?;
115    let client = connect().await?;
116    let profile = TrustProfileName::try_from(profile).map_err(|e| anyhow::anyhow!("{e}"))?;
117
118    // Confirm deletion with TTY and non-TTY support.
119    if !skip_confirm {
120        let confirmed = if std::io::IsTerminal::is_terminal(&std::io::stdin()) {
121            dialoguer::Confirm::new()
122                .with_prompt(format!("Delete secret '{key}' from profile '{profile}'?"))
123                .default(false)
124                .interact()
125                .context("failed to read confirmation")?
126        } else {
127            // Non-TTY: read a line from stdin and check for "y" or "yes".
128            eprintln!("Delete secret '{key}' from profile '{profile}'? [y/N]");
129            let mut buf = String::new();
130            std::io::BufRead::read_line(&mut std::io::BufReader::new(std::io::stdin()), &mut buf)
131                .context("failed to read confirmation from stdin")?;
132            let answer = buf.trim().to_lowercase();
133            answer == "y" || answer == "yes"
134        };
135
136        if !confirmed {
137            println!("Cancelled.");
138            return Ok(());
139        }
140    }
141
142    let event = EventKind::SecretDelete {
143        profile: profile.clone(),
144        key: key.to_owned(),
145    };
146
147    match rpc(&client, event, SecurityLevel::SecretsOnly).await? {
148        EventKind::SecretDeleteResponse { success: true, .. } => {
149            println!("Secret '{key}' deleted from profile '{profile}'.");
150        }
151        EventKind::SecretDeleteResponse {
152            success: false,
153            denial,
154        } => {
155            if let Some(reason) = denial {
156                anyhow::bail!("{}", format_denial_reason(&reason, key, &profile));
157            }
158            anyhow::bail!("failed to delete secret");
159        }
160        other => anyhow::bail!("unexpected response: {other:?}"),
161    }
162
163    Ok(())
164}
165
166pub(crate) async fn cmd_secret_list(profile: &str) -> anyhow::Result<()> {
167    validate_profile_in_config(profile)?;
168    let client = connect().await?;
169    let profile = TrustProfileName::try_from(profile).map_err(|e| anyhow::anyhow!("{e}"))?;
170
171    let event = EventKind::SecretList {
172        profile: profile.clone(),
173    };
174
175    match rpc(&client, event, SecurityLevel::SecretsOnly).await? {
176        EventKind::SecretListResponse { keys, denial } => {
177            if let Some(reason) = denial {
178                anyhow::bail!("{}", format_denial_reason(&reason, "", &profile));
179            }
180            if keys.is_empty() {
181                println!("{}", "No secrets in this profile.".dimmed());
182            } else {
183                println!("Secrets in profile '{}':", profile.as_ref().bold());
184                for k in &keys {
185                    println!("  - {k}");
186                }
187            }
188        }
189        other => anyhow::bail!("unexpected response: {other:?}"),
190    }
191
192    Ok(())
193}