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
10pub(crate) const RPC_TIMEOUT: Duration = Duration::from_secs(5);
12
13pub(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 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 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
108pub(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#[derive(Debug, Clone)]
137pub(crate) struct ProfileSpec {
138 pub org: Option<String>,
140 pub vault: String,
142}
143
144pub(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
172pub(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
182pub(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
205async fn fetch_profile_secrets(
210 client: &BusClient,
211 profile: &TrustProfileName,
212 prefix: Option<&str>,
213) -> anyhow::Result<Vec<(String, Vec<u8>)>> {
214 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 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}