sesame/
ipc.rs

1use anyhow::Context;
2use core_ipc::BusClient;
3use core_types::{DaemonId, EventKind, SecurityLevel, TrustProfileName};
4use owo_colors::OwoColorize;
5use std::time::Duration;
6
7use crate::env::{is_denied_env_var, secret_key_to_env_var};
8use crate::helpers::format_denial_reason;
9
10/// Default RPC timeout.
11pub(crate) const RPC_TIMEOUT: Duration = Duration::from_secs(5);
12
13/// Find the index of an SSH key by fingerprint in the agent's eligible key list.
14pub(crate) async fn find_ssh_key_index(fingerprint: &str) -> anyhow::Result<usize> {
15    let sock_path =
16        std::env::var("SSH_AUTH_SOCK").context("SSH_AUTH_SOCK not set — is ssh-agent running?")?;
17    let mut agent = ssh_agent_client_rs::Client::connect(std::path::Path::new(&sock_path))
18        .context("failed to connect to SSH agent")?;
19    let identities = agent
20        .list_all_identities()
21        .context("failed to list SSH agent keys")?;
22
23    let eligible: Vec<_> = identities
24        .into_iter()
25        .filter(|id| {
26            let algo = match id {
27                ssh_agent_client_rs::Identity::PublicKey(cow) => cow.algorithm(),
28                ssh_agent_client_rs::Identity::Certificate(cow) => cow.algorithm(),
29            };
30            core_auth::SshKeyType::from_algorithm(&algo).is_ok()
31        })
32        .collect();
33
34    if eligible.is_empty() {
35        anyhow::bail!(
36            "no eligible SSH keys found in agent.\n\
37             Only Ed25519 and RSA keys are supported (ECDSA uses non-deterministic signatures).\n\
38             Add a key with: ssh-add ~/.ssh/id_ed25519"
39        );
40    }
41
42    let fp_normalized = fingerprint.strip_prefix("SHA256:").unwrap_or(fingerprint);
43
44    eligible
45        .iter()
46        .position(|id| {
47            let id_fp = match id {
48                ssh_agent_client_rs::Identity::PublicKey(cow) => {
49                    cow.fingerprint(ssh_key::HashAlg::Sha256).to_string()
50                }
51                ssh_agent_client_rs::Identity::Certificate(cow) => cow
52                    .public_key()
53                    .fingerprint(ssh_key::HashAlg::Sha256)
54                    .to_string(),
55            };
56            let id_fp_bare = id_fp.strip_prefix("SHA256:").unwrap_or(&id_fp);
57            id_fp_bare == fp_normalized
58        })
59        .ok_or_else(|| {
60            anyhow::anyhow!(
61                "SSH key with fingerprint '{fingerprint}' not found in agent.\n\
62                 Use `ssh-add -l` to list loaded keys."
63            )
64        })
65}
66
67pub(crate) async fn connect() -> anyhow::Result<BusClient> {
68    let socket_path = core_ipc::socket_path().context("failed to resolve IPC socket path")?;
69
70    let server_pub = core_ipc::noise::read_bus_public_key()
71        .await
72        .context("daemon-profile is not running (no bus public key found)")?;
73
74    let daemon_id = DaemonId::new();
75
76    // CLI uses ephemeral keypair — server assigns Open clearance for unknown keys.
77    let client_keypair =
78        core_ipc::generate_keypair().context("failed to generate ephemeral keypair")?;
79
80    let mut client = BusClient::connect_encrypted(
81        daemon_id,
82        &socket_path,
83        &server_pub,
84        client_keypair.as_inner(),
85    )
86    .await
87    .context("failed to connect to IPC bus — is daemon-profile running?")?;
88
89    // Populate origin_installation on outbound messages if installation.toml exists.
90    if let Ok(install_config) = core_config::load_installation() {
91        let install_id = core_types::InstallationId {
92            id: install_config.id,
93            org_ns: install_config
94                .org
95                .map(|o| core_types::OrganizationNamespace {
96                    domain: o.domain,
97                    namespace: o.namespace,
98                }),
99            namespace: install_config.namespace,
100            machine_binding: None,
101        };
102        client.set_installation(install_id);
103    }
104
105    Ok(client)
106}
107
108/// Send an RPC request and wait for the correlated response.
109pub(crate) async fn rpc(
110    client: &BusClient,
111    event: EventKind,
112    security_level: SecurityLevel,
113) -> anyhow::Result<EventKind> {
114    let response = client
115        .request(event, security_level, RPC_TIMEOUT)
116        .await
117        .map_err(|e| {
118            let msg = e.to_string();
119            if msg.contains("timed out") {
120                eprintln!(
121                    "{}: no response within {}s",
122                    "timeout".yellow().bold(),
123                    RPC_TIMEOUT.as_secs()
124                );
125                std::process::exit(2);
126            }
127            anyhow::anyhow!("{e}")
128        })?;
129    if let EventKind::AccessDenied { reason } = &response.payload {
130        anyhow::bail!("access denied: {reason}");
131    }
132    Ok(response.payload)
133}
134
135/// A parsed profile spec from CSV input like "org:vault" or bare "vault".
136#[derive(Debug, Clone)]
137pub(crate) struct ProfileSpec {
138    /// Organizational namespace (optional). Currently informational.
139    pub org: Option<String>,
140    /// The vault/profile name used for IPC.
141    pub vault: String,
142}
143
144/// Parse a CSV profile spec string.
145///
146/// Format: `vault,org:vault,org:vault`
147/// - `default` → ProfileSpec { org: None, vault: "default" }
148/// - `braincraft:operations` → ProfileSpec { org: Some("braincraft"), vault: "operations" }
149///
150/// Designed for future extension to `docker.io/project/org:vault@sha256`.
151pub(crate) fn parse_profile_specs(input: &str) -> Vec<ProfileSpec> {
152    input
153        .split(',')
154        .map(|s| s.trim())
155        .filter(|s| !s.is_empty())
156        .map(|entry| {
157            if let Some((org, vault)) = entry.rsplit_once(':') {
158                ProfileSpec {
159                    org: Some(org.to_string()),
160                    vault: vault.to_string(),
161                }
162            } else {
163                ProfileSpec {
164                    org: None,
165                    vault: entry.to_string(),
166                }
167            }
168        })
169        .collect()
170}
171
172/// Resolve profile specs from a CLI flag or SESAME_PROFILES env var.
173pub(crate) fn resolve_profile_specs(cli_arg: Option<&str>) -> Vec<ProfileSpec> {
174    let input = match cli_arg {
175        Some(p) => p.to_string(),
176        None => std::env::var("SESAME_PROFILES")
177            .unwrap_or_else(|_| core_types::DEFAULT_PROFILE_NAME.into()),
178    };
179    parse_profile_specs(&input)
180}
181
182/// Fetch secrets from multiple profiles, merging with left-wins collision resolution.
183pub(crate) async fn fetch_multi_profile_secrets(
184    client: &BusClient,
185    specs: &[ProfileSpec],
186    prefix: Option<&str>,
187) -> anyhow::Result<Vec<(String, Vec<u8>)>> {
188    let mut seen_keys = std::collections::HashSet::new();
189    let mut merged = Vec::new();
190
191    for spec in specs {
192        let profile = TrustProfileName::try_from(spec.vault.as_str())
193            .map_err(|e| anyhow::anyhow!("invalid profile/vault '{}': {e}", spec.vault))?;
194        let secrets = fetch_profile_secrets(client, &profile, prefix).await?;
195        for (key, value) in secrets {
196            if seen_keys.insert(key.clone()) {
197                merged.push((key, value));
198            }
199        }
200    }
201
202    Ok(merged)
203}
204
205/// Fetch all secrets for a profile from the vault via IPC.
206///
207/// Returns sanitized env var name/value pairs. Secrets that map to denied
208/// env var names are skipped with a warning on stderr.
209async fn fetch_profile_secrets(
210    client: &BusClient,
211    profile: &TrustProfileName,
212    prefix: Option<&str>,
213) -> anyhow::Result<Vec<(String, Vec<u8>)>> {
214    // 1. List all secret keys in this profile.
215    let keys = match rpc(
216        client,
217        EventKind::SecretList {
218            profile: profile.clone(),
219        },
220        SecurityLevel::SecretsOnly,
221    )
222    .await?
223    {
224        EventKind::SecretListResponse { keys, denial } => {
225            if let Some(reason) = denial {
226                anyhow::bail!("{}", format_denial_reason(&reason, "", profile));
227            }
228            keys
229        }
230        other => anyhow::bail!("unexpected response to SecretList: {other:?}"),
231    };
232
233    if keys.is_empty() {
234        eprintln!(
235            "{}: profile '{}' has no secrets",
236            "warning".yellow().bold(),
237            profile,
238        );
239        return Ok(Vec::new());
240    }
241
242    // 2. Fetch each secret value, apply env var mapping and denylist.
243    let mut env_vars: Vec<(String, Vec<u8>)> = Vec::with_capacity(keys.len());
244
245    for key in &keys {
246        let event = EventKind::SecretGet {
247            profile: profile.clone(),
248            key: key.clone(),
249        };
250
251        match rpc(client, event, SecurityLevel::SecretsOnly).await? {
252            EventKind::SecretGetResponse { value, denial, .. }
253                if denial.is_none() && !value.is_empty() =>
254            {
255                let env_name = secret_key_to_env_var(key, prefix);
256                if is_denied_env_var(&env_name) {
257                    eprintln!(
258                        "{}: secret '{}' maps to denied env var '{}', skipping (security policy)",
259                        "error".red().bold(),
260                        key,
261                        env_name,
262                    );
263                    continue;
264                }
265                env_vars.push((env_name, value.as_bytes().to_vec()));
266            }
267            EventKind::SecretGetResponse {
268                denial: Some(reason),
269                key: k,
270                ..
271            } => {
272                eprintln!(
273                    "{}: {}",
274                    "warning".yellow().bold(),
275                    format_denial_reason(&reason, &k, profile),
276                );
277            }
278            _ => {
279                eprintln!(
280                    "{}: failed to resolve secret '{}', skipping",
281                    "warning".yellow().bold(),
282                    key,
283                );
284            }
285        }
286    }
287
288    Ok(env_vars)
289}
290
291#[cfg(test)]
292mod tests {
293    use super::*;
294
295    #[test]
296    fn parse_specs_bare_vaults() {
297        let specs = parse_profile_specs("a,b,c");
298        assert_eq!(specs.len(), 3);
299        assert!(specs[0].org.is_none());
300        assert_eq!(specs[0].vault, "a");
301        assert_eq!(specs[1].vault, "b");
302        assert_eq!(specs[2].vault, "c");
303    }
304
305    #[test]
306    fn parse_specs_org_vault() {
307        let specs = parse_profile_specs("braincraft:operations,braincraft:frontend,default:dev");
308        assert_eq!(specs.len(), 3);
309        assert_eq!(specs[0].org.as_deref(), Some("braincraft"));
310        assert_eq!(specs[0].vault, "operations");
311        assert_eq!(specs[1].org.as_deref(), Some("braincraft"));
312        assert_eq!(specs[1].vault, "frontend");
313        assert_eq!(specs[2].org.as_deref(), Some("default"));
314        assert_eq!(specs[2].vault, "dev");
315    }
316
317    #[test]
318    fn parse_specs_mixed() {
319        let specs = parse_profile_specs("default,braincraft:ops");
320        assert_eq!(specs.len(), 2);
321        assert!(specs[0].org.is_none());
322        assert_eq!(specs[0].vault, "default");
323        assert_eq!(specs[1].org.as_deref(), Some("braincraft"));
324        assert_eq!(specs[1].vault, "ops");
325    }
326
327    #[test]
328    fn parse_specs_single() {
329        let specs = parse_profile_specs("default");
330        assert_eq!(specs.len(), 1);
331        assert_eq!(specs[0].vault, "default");
332    }
333
334    #[test]
335    fn parse_specs_empty_segments_filtered() {
336        let specs = parse_profile_specs("a,,b");
337        assert_eq!(specs.len(), 2);
338    }
339
340    #[test]
341    fn parse_specs_whitespace_trimmed() {
342        let specs = parse_profile_specs(" a , b ");
343        assert_eq!(specs.len(), 2);
344        assert_eq!(specs[0].vault, "a");
345        assert_eq!(specs[1].vault, "b");
346    }
347
348    #[test]
349    fn parse_specs_empty_string() {
350        assert!(parse_profile_specs("").is_empty());
351    }
352
353    #[test]
354    fn parse_specs_org_with_no_vault() {
355        let specs = parse_profile_specs("org:");
356        assert_eq!(specs.len(), 1);
357        assert_eq!(specs[0].org.as_deref(), Some("org"));
358        assert_eq!(specs[0].vault, "");
359    }
360}