1use orchestrator_config::resource_store::SYSTEM_PROJECT;
2use aes_gcm_siv::Aes256GcmSiv;
3use aes_gcm_siv::aead::{Aead, KeyInit, Payload};
4use anyhow::{Context, Result, anyhow, bail};
5use base64::Engine;
6use rand::RngCore;
7use serde::{Deserialize, Serialize};
8use serde_json::Value;
9use sha2::{Digest, Sha256};
10use std::fs::OpenOptions;
11use std::io::Write;
12use std::path::{Path, PathBuf};
13
14const KEY_RELATIVE_PATH: &str = "secrets/secretstore.key";
15const KEY_META_RELATIVE_PATH: &str = "secrets/secretstore.key.meta.json";
16const KEY_ID_PRIMARY: &str = "primary";
17const KEY_SIZE_BYTES: usize = 32;
18const NONCE_SIZE_BYTES: usize = 12;
19pub const SECRETSTORE_ENCRYPTION_SCHEME: &str = "secretstore.aead.v1";
21pub const ENCRYPTED_PLACEHOLDER: &str = "[ENCRYPTED]";
23
24#[derive(Debug, Clone)]
25pub struct SecretKeyHandle {
27 key_bytes: [u8; KEY_SIZE_BYTES],
28 key_id: String,
29 fingerprint: String,
30 #[allow(dead_code)]
31 path: PathBuf,
32}
33
34impl SecretKeyHandle {
35 pub fn key_id(&self) -> &str {
37 &self.key_id
38 }
39
40 pub fn fingerprint(&self) -> &str {
42 &self.fingerprint
43 }
44
45 fn key_bytes(&self) -> &[u8; KEY_SIZE_BYTES] {
46 &self.key_bytes
47 }
48}
49
50#[derive(Debug, Clone)]
51pub struct SecretEncryption {
53 key: SecretKeyHandle,
54 decrypt_keys: std::collections::HashMap<String, SecretKeyHandle>,
57}
58
59impl SecretEncryption {
60 pub fn from_key(key: SecretKeyHandle) -> Self {
62 Self {
63 key,
64 decrypt_keys: std::collections::HashMap::new(),
65 }
66 }
67
68 pub fn from_keyring(keyring: &crate::secret_key_lifecycle::KeyRing) -> Result<Self> {
70 let active = keyring.active_key()?.clone();
71 let mut decrypt_keys = std::collections::HashMap::new();
72 for (kid, handle) in keyring.decrypt_keys_iter() {
73 decrypt_keys.insert(kid.to_string(), handle.clone());
74 }
75 Ok(Self {
76 key: active,
77 decrypt_keys,
78 })
79 }
80
81 pub fn encrypt_secret_store_spec(
83 &self,
84 project: &str,
85 name: &str,
86 spec: &Value,
87 ) -> Result<String> {
88 let plain = serde_json::to_vec(spec).context("failed to serialize secret store spec")?;
89 let aad = SecretEnvelopeAad {
90 kind: "SecretStore".to_string(),
91 project: project.to_string(),
92 name: name.to_string(),
93 };
94 let cipher = Aes256GcmSiv::new_from_slice(self.key.key_bytes())
95 .map_err(|_| anyhow!("failed to initialize secret store cipher"))?;
96 let mut nonce_bytes = [0_u8; NONCE_SIZE_BYTES];
97 rand::rngs::OsRng.fill_bytes(&mut nonce_bytes);
98 let nonce = aes_gcm_siv::Nonce::from_slice(&nonce_bytes);
99 let aad_json = serde_json::to_vec(&aad).context("failed to serialize secret AAD")?;
100 let ciphertext = cipher
101 .encrypt(
102 nonce,
103 Payload {
104 msg: &plain,
105 aad: &aad_json,
106 },
107 )
108 .map_err(|_| anyhow!("failed to encrypt secret store spec"))?;
109 let envelope = SecretEnvelope {
110 encrypted: true,
111 scheme: SECRETSTORE_ENCRYPTION_SCHEME.to_string(),
112 key_id: self.key.key_id().to_string(),
113 nonce: base64::engine::general_purpose::STANDARD.encode(nonce_bytes),
114 ciphertext: base64::engine::general_purpose::STANDARD.encode(ciphertext),
115 aad,
116 };
117 serde_json::to_string(&envelope).context("failed to serialize encrypted secret envelope")
118 }
119
120 pub fn decrypt_secret_store_spec(
122 &self,
123 project: &str,
124 name: &str,
125 spec_json: &str,
126 ) -> Result<Value> {
127 let envelope: SecretEnvelope =
128 serde_json::from_str(spec_json).context("failed to parse encrypted secret envelope")?;
129 if !envelope.encrypted {
130 bail!("secret store envelope missing encrypted marker");
131 }
132 if envelope.scheme != SECRETSTORE_ENCRYPTION_SCHEME {
133 bail!(
134 "unsupported secret store encryption scheme: {}",
135 envelope.scheme
136 );
137 }
138 if envelope.aad.kind != "SecretStore"
139 || envelope.aad.project != project
140 || envelope.aad.name != name
141 {
142 bail!(
143 "secret store envelope AAD mismatch for SecretStore/{}/{}",
144 project,
145 name
146 );
147 }
148
149 let decrypt_handle = self.resolve_decrypt_key(&envelope.key_id)?;
151
152 let nonce_bytes = base64::engine::general_purpose::STANDARD
153 .decode(&envelope.nonce)
154 .context("failed to decode secret envelope nonce")?;
155 if nonce_bytes.len() != NONCE_SIZE_BYTES {
156 bail!("invalid secret envelope nonce length");
157 }
158 let ciphertext = base64::engine::general_purpose::STANDARD
159 .decode(&envelope.ciphertext)
160 .context("failed to decode secret envelope ciphertext")?;
161 let cipher = Aes256GcmSiv::new_from_slice(decrypt_handle.key_bytes())
162 .map_err(|_| anyhow!("failed to initialize secret store cipher"))?;
163 let aad_json =
164 serde_json::to_vec(&envelope.aad).context("failed to serialize envelope AAD")?;
165 let plain = cipher
166 .decrypt(
167 aes_gcm_siv::Nonce::from_slice(&nonce_bytes),
168 Payload {
169 msg: &ciphertext,
170 aad: &aad_json,
171 },
172 )
173 .map_err(|_| {
174 anyhow!(
175 "failed to decrypt secret store spec (key_id: {})",
176 envelope.key_id
177 )
178 })?;
179 serde_json::from_slice(&plain).context("failed to parse decrypted secret store spec")
180 }
181
182 fn resolve_decrypt_key(&self, key_id: &str) -> Result<&SecretKeyHandle> {
185 if let Some(handle) = self.decrypt_keys.get(key_id) {
186 return Ok(handle);
187 }
188 if self.key.key_id() == key_id {
189 return Ok(&self.key);
190 }
191 bail!(
192 "no decryption key available for key_id '{}'; available keys: [{}]",
193 key_id,
194 {
195 let mut ids: Vec<&str> = self.decrypt_keys.keys().map(|s| s.as_str()).collect();
196 ids.push(self.key.key_id());
197 ids.join(", ")
198 }
199 )
200 }
201}
202
203#[derive(Debug, Clone, Serialize, Deserialize)]
204struct SecretKeyMetadata {
205 key_id: String,
206 created_at: String,
207 last_rotated_at: String,
208 fingerprint: String,
209 format_version: u32,
210}
211
212#[derive(Debug, Clone, Serialize, Deserialize)]
213struct SecretEnvelopeAad {
214 kind: String,
215 project: String,
216 name: String,
217}
218
219#[derive(Debug, Clone, Serialize, Deserialize)]
220struct SecretEnvelope {
221 #[serde(rename = "_encrypted")]
222 encrypted: bool,
223 scheme: String,
224 key_id: String,
225 nonce: String,
226 ciphertext: String,
227 aad: SecretEnvelopeAad,
228}
229
230pub fn load_key_file_as_handle(path: &Path, key_id: &str) -> Result<SecretKeyHandle> {
232 validate_secret_key_permissions(path)?;
233 let encoded = std::fs::read_to_string(path)
234 .with_context(|| format!("failed to read key file {}", path.display()))?;
235 let decoded =
236 base64::Engine::decode(&base64::engine::general_purpose::STANDARD, encoded.trim())
237 .context("failed to decode key file")?;
238 if decoded.len() != KEY_SIZE_BYTES {
239 bail!(
240 "invalid key length in {}: expected {} bytes",
241 path.display(),
242 KEY_SIZE_BYTES
243 );
244 }
245 let mut key_bytes = [0_u8; KEY_SIZE_BYTES];
246 key_bytes.copy_from_slice(&decoded);
247 let fingerprint = key_fingerprint(&key_bytes);
248 Ok(SecretKeyHandle {
249 key_bytes,
250 key_id: key_id.to_string(),
251 fingerprint,
252 path: path.to_path_buf(),
253 })
254}
255
256pub fn generate_and_write_key_file(path: &Path, key_id: &str) -> Result<SecretKeyHandle> {
259 let mut key_bytes = [0_u8; KEY_SIZE_BYTES];
260 rand::rngs::OsRng.fill_bytes(&mut key_bytes);
261 let encoded = base64::engine::general_purpose::STANDARD.encode(key_bytes);
262 write_atomic_secret_file(path, encoded.as_bytes())?;
263 let fingerprint = key_fingerprint(&key_bytes);
264 Ok(SecretKeyHandle {
265 key_bytes,
266 key_id: key_id.to_string(),
267 fingerprint,
268 path: path.to_path_buf(),
269 })
270}
271
272pub fn secret_key_path(data_dir: &Path) -> PathBuf {
274 data_dir.join(KEY_RELATIVE_PATH)
275}
276
277pub fn secret_key_meta_path(data_dir: &Path) -> PathBuf {
279 data_dir.join(KEY_META_RELATIVE_PATH)
280}
281
282pub fn resolve_data_dir_from_db_path(db_path: &Path) -> Result<PathBuf> {
284 let parent = db_path
285 .parent()
286 .with_context(|| format!("db path has no parent: {}", db_path.display()))?;
287 if parent.file_name().and_then(|s| s.to_str()) == Some("data") {
288 parent
289 .parent()
290 .map(Path::to_path_buf)
291 .with_context(|| format!("data dir has no parent: {}", parent.display()))
292 } else {
293 Ok(parent.to_path_buf())
294 }
295}
296
297pub fn ensure_secret_key(data_dir: &Path, db_path: &Path) -> Result<SecretKeyHandle> {
299 if let Some(existing) = load_existing_secret_key(data_dir)? {
300 return Ok(existing);
301 }
302 if encrypted_secret_data_exists(db_path)? {
303 bail!(
304 "secret store key missing at {} while encrypted SecretStore data exists; restore the original key before starting",
305 secret_key_path(data_dir).display()
306 );
307 }
308 initialize_secret_key(data_dir)
309}
310
311pub fn load_existing_secret_key(data_dir: &Path) -> Result<Option<SecretKeyHandle>> {
313 let path = secret_key_path(data_dir);
314 if !path.exists() {
315 return Ok(None);
316 }
317 validate_secret_key_permissions(&path)?;
318 let encoded = std::fs::read_to_string(&path)
319 .with_context(|| format!("failed to read secret key file {}", path.display()))?;
320 let decoded = base64::engine::general_purpose::STANDARD
321 .decode(encoded.trim())
322 .context("failed to decode secret key file")?;
323 if decoded.len() != KEY_SIZE_BYTES {
324 bail!(
325 "invalid secret key length: expected {} bytes",
326 KEY_SIZE_BYTES
327 );
328 }
329 let mut key_bytes = [0_u8; KEY_SIZE_BYTES];
330 key_bytes.copy_from_slice(&decoded);
331 let fingerprint = key_fingerprint(&key_bytes);
332 Ok(Some(SecretKeyHandle {
333 key_bytes,
334 key_id: KEY_ID_PRIMARY.to_string(),
335 fingerprint,
336 path,
337 }))
338}
339
340pub fn is_encrypted_secret_store_json(spec_json: &str) -> bool {
342 spec_json.contains("\"scheme\":\"secretstore.aead.v1\"")
343 || spec_json.contains("\"_encrypted\":true")
344}
345
346pub fn redact_secret_data_map(map: &mut serde_json::Map<String, Value>) {
348 for value in map.values_mut() {
349 *value = Value::String(ENCRYPTED_PLACEHOLDER.to_string());
350 }
351}
352
353fn initialize_secret_key(data_dir: &Path) -> Result<SecretKeyHandle> {
354 let key_path = secret_key_path(data_dir);
355 let meta_path = secret_key_meta_path(data_dir);
356 let secrets_dir = key_path
357 .parent()
358 .with_context(|| format!("secret key path has no parent: {}", key_path.display()))?;
359 std::fs::create_dir_all(secrets_dir)
360 .with_context(|| format!("failed to create secrets dir {}", secrets_dir.display()))?;
361 #[cfg(unix)]
362 {
363 use std::os::unix::fs::PermissionsExt;
364 std::fs::set_permissions(secrets_dir, std::fs::Permissions::from_mode(0o700))
365 .with_context(|| {
366 format!(
367 "failed to set permissions on secrets dir {}",
368 secrets_dir.display()
369 )
370 })?;
371 }
372 let mut key_bytes = [0_u8; KEY_SIZE_BYTES];
373 rand::rngs::OsRng.fill_bytes(&mut key_bytes);
374 let encoded = base64::engine::general_purpose::STANDARD.encode(key_bytes);
375 write_atomic_secret_file(&key_path, encoded.as_bytes())?;
376 let now = crate::now_ts();
377 let metadata = SecretKeyMetadata {
378 key_id: KEY_ID_PRIMARY.to_string(),
379 created_at: now.clone(),
380 last_rotated_at: now,
381 fingerprint: key_fingerprint(&key_bytes),
382 format_version: 1,
383 };
384 let meta_json =
385 serde_json::to_vec_pretty(&metadata).context("failed to serialize key metadata")?;
386 write_atomic_secret_file(&meta_path, &meta_json)?;
387 Ok(SecretKeyHandle {
388 key_bytes,
389 key_id: metadata.key_id,
390 fingerprint: metadata.fingerprint,
391 path: key_path,
392 })
393}
394
395fn write_atomic_secret_file(path: &Path, contents: &[u8]) -> Result<()> {
396 let tmp_path = path.with_extension(format!(
397 "{}tmp",
398 path.extension()
399 .and_then(|ext| ext.to_str())
400 .map(|ext| format!("{ext}."))
401 .unwrap_or_default()
402 ));
403 let mut file = OpenOptions::new()
404 .write(true)
405 .create_new(true)
406 .open(&tmp_path)
407 .with_context(|| {
408 format!(
409 "failed to create temporary secret file {}",
410 tmp_path.display()
411 )
412 })?;
413 #[cfg(unix)]
414 {
415 use std::os::unix::fs::PermissionsExt;
416 file.set_permissions(std::fs::Permissions::from_mode(0o600))
417 .with_context(|| {
418 format!(
419 "failed to set permissions on temporary secret file {}",
420 tmp_path.display()
421 )
422 })?;
423 }
424 file.write_all(contents).with_context(|| {
425 format!(
426 "failed to write temporary secret file {}",
427 tmp_path.display()
428 )
429 })?;
430 file.sync_all().with_context(|| {
431 format!(
432 "failed to fsync temporary secret file {}",
433 tmp_path.display()
434 )
435 })?;
436 drop(file);
437 std::fs::rename(&tmp_path, path).with_context(|| {
438 format!(
439 "failed to rename temporary secret file {} -> {}",
440 tmp_path.display(),
441 path.display()
442 )
443 })?;
444 validate_secret_key_permissions(path)?;
445 Ok(())
446}
447
448fn encrypted_secret_data_exists(db_path: &Path) -> Result<bool> {
449 let conn = crate::open_conn(db_path)?;
450 let resources_exists: bool = conn
451 .query_row(
452 "SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='table' AND name='resources'",
453 [],
454 |row| row.get(0),
455 )
456 .unwrap_or(false);
457 let versions_exists: bool = conn
458 .query_row(
459 "SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='table' AND name='resource_versions'",
460 [],
461 |row| row.get(0),
462 )
463 .unwrap_or(false);
464 let mut encrypted = false;
465 if resources_exists {
466 encrypted = conn.query_row(
467 "SELECT EXISTS(
468 SELECT 1 FROM resources
469 WHERE kind = 'SecretStore'
470 AND (instr(spec_json, '\"scheme\":\"secretstore.aead.v1\"') > 0
471 OR instr(spec_json, '\"_encrypted\":true') > 0)
472 )",
473 [],
474 |row| row.get(0),
475 )?;
476 }
477 if !encrypted && versions_exists {
478 encrypted = conn.query_row(
479 "SELECT EXISTS(
480 SELECT 1 FROM resource_versions
481 WHERE kind = 'SecretStore'
482 AND version > 0
483 AND (instr(spec_json, '\"scheme\":\"secretstore.aead.v1\"') > 0
484 OR instr(spec_json, '\"_encrypted\":true') > 0)
485 )",
486 [],
487 |row| row.get(0),
488 )?;
489 }
490 Ok(encrypted)
491}
492
493fn validate_secret_key_permissions(path: &Path) -> Result<()> {
494 #[cfg(unix)]
495 {
496 use std::os::unix::fs::PermissionsExt;
497 let metadata = std::fs::metadata(path)
498 .with_context(|| format!("failed to read secret key metadata {}", path.display()))?;
499 let mode = metadata.permissions().mode() & 0o777;
500 if mode & 0o077 != 0 {
501 bail!(
502 "secret key file {} must have permissions 0600 or stricter (found {:o})",
503 path.display(),
504 mode
505 );
506 }
507 }
508 Ok(())
509}
510
511fn key_fingerprint(key_bytes: &[u8; KEY_SIZE_BYTES]) -> String {
512 let digest = Sha256::digest(key_bytes);
513 digest[..8]
514 .iter()
515 .map(|byte| format!("{byte:02x}"))
516 .collect()
517}
518
519pub fn decrypt_resource_spec_json(
521 encryption: Option<&SecretEncryption>,
522 kind: &str,
523 project: &str,
524 name: &str,
525 spec_json: &str,
526) -> Result<Value> {
527 if kind != "SecretStore" {
528 return serde_json::from_str(spec_json).context("failed to parse resource spec json");
529 }
530 if !is_encrypted_secret_store_json(spec_json) {
531 return serde_json::from_str(spec_json)
532 .context("failed to parse plaintext secret store spec json");
533 }
534 let encryption = encryption.ok_or_else(|| {
535 anyhow!(
536 "encrypted SecretStore/{}/{} cannot be loaded because the secret key is unavailable",
537 project,
538 name
539 )
540 })?;
541 encryption.decrypt_secret_store_spec(project, name, spec_json)
542}
543
544pub fn encrypt_resource_spec_json(
546 encryption: &SecretEncryption,
547 kind: &str,
548 project: &str,
549 name: &str,
550 spec: &Value,
551) -> Result<String> {
552 if kind == "SecretStore" {
553 encryption.encrypt_secret_store_spec(project, name, spec)
554 } else {
555 serde_json::to_string(spec).context("failed to serialize resource spec json")
556 }
557}
558
559pub fn secret_project_or_default(project: Option<&str>) -> &str {
561 project
562 .filter(|value| !value.trim().is_empty())
563 .unwrap_or(SYSTEM_PROJECT)
564}
565
566#[cfg(test)]
567mod tests {
568 use super::*;
569 use tempfile::tempdir;
570
571 #[test]
572 fn ensure_secret_key_creates_and_reuses_key_file() {
573 let temp = tempdir().expect("tempdir");
574 let db_path = temp.path().join("data/agent_orchestrator.db");
575 std::fs::create_dir_all(db_path.parent().expect("db path should have parent"))
576 .expect("create data dir");
577 crate::init_test_schema(&db_path).expect("init schema");
578
579 let first = ensure_secret_key(temp.path(), &db_path).expect("create key");
580 let second = ensure_secret_key(temp.path(), &db_path).expect("reuse key");
581
582 assert_eq!(first.fingerprint(), second.fingerprint());
583 assert!(secret_key_path(temp.path()).exists());
584 assert!(secret_key_meta_path(temp.path()).exists());
585 }
586
587 #[test]
588 fn encrypt_and_decrypt_secret_store_round_trip() {
589 let temp = tempdir().expect("tempdir");
590 let db_path = temp.path().join("agent_orchestrator.db");
591 crate::init_test_schema(&db_path).expect("init schema");
592 let key = ensure_secret_key(temp.path(), &db_path).expect("create key");
593 let encryption = SecretEncryption::from_key(key);
594 let spec = serde_json::json!({"data": {"API_KEY": "sk-123"}});
595
596 let cipher = encryption
597 .encrypt_secret_store_spec("default", "api-keys", &spec)
598 .expect("encrypt");
599 assert!(is_encrypted_secret_store_json(&cipher));
600 assert!(!cipher.contains("sk-123"));
601
602 let plain = encryption
603 .decrypt_secret_store_spec("default", "api-keys", &cipher)
604 .expect("decrypt");
605 assert_eq!(plain, spec);
606 }
607
608 #[test]
609 fn ensure_secret_key_refuses_to_regenerate_when_encrypted_data_exists() {
610 let temp = tempdir().expect("tempdir");
611 let db_path = temp.path().join("agent_orchestrator.db");
612 crate::init_test_schema(&db_path).expect("init schema");
613 let key = ensure_secret_key(temp.path(), &db_path).expect("create key");
614 let encryption = SecretEncryption::from_key(key);
615 let spec = serde_json::json!({"data": {"API_KEY": "sk-123"}});
616 let cipher = encryption
617 .encrypt_secret_store_spec("default", "api-keys", &spec)
618 .expect("encrypt");
619 let conn = crate::open_conn(&db_path).expect("open sqlite");
620 conn.execute(
621 "INSERT INTO resources (kind, project, name, api_version, spec_json, metadata_json, generation, created_at, updated_at)
622 VALUES ('SecretStore', 'default', 'api-keys', 'orchestrator.dev/v2', ?1, '{}', 1, datetime('now'), datetime('now'))",
623 rusqlite::params![cipher],
624 )
625 .expect("insert encrypted secret resource");
626 std::fs::remove_file(secret_key_path(temp.path())).expect("remove secret key");
627
628 let err =
629 ensure_secret_key(temp.path(), &db_path).expect_err("should refuse to regenerate");
630 assert!(
631 err.to_string()
632 .contains("encrypted SecretStore data exists")
633 );
634 }
635
636 #[test]
637 fn resolve_data_dir_from_db_path_accepts_data_and_flat_layouts() {
638 let temp = tempdir().expect("tempdir");
639 let nested = temp.path().join("data/agent_orchestrator.db");
640 let flat = temp.path().join("agent_orchestrator.db");
641
642 assert_eq!(
643 resolve_data_dir_from_db_path(&nested).expect("nested root"),
644 temp.path()
645 );
646 assert_eq!(
647 resolve_data_dir_from_db_path(&flat).expect("flat root"),
648 temp.path()
649 );
650 }
651}