1use anyhow::{Context, Result, bail};
2use rusqlite::{Connection, params};
3use serde::{Deserialize, Serialize};
4use std::collections::HashMap;
5use std::path::{Path, PathBuf};
6
7use crate::now_ts;
8use crate::secret_store_crypto::{SecretEncryption, SecretKeyHandle};
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
13#[serde(rename_all = "snake_case")]
14pub enum KeyState {
16 Active,
18 DecryptOnly,
20 Revoked,
22 Retired,
24}
25
26impl KeyState {
27 pub fn as_str(&self) -> &'static str {
29 match self {
30 Self::Active => "active",
31 Self::DecryptOnly => "decrypt_only",
32 Self::Revoked => "revoked",
33 Self::Retired => "retired",
34 }
35 }
36
37 pub fn from_str_value(s: &str) -> Result<Self> {
39 match s {
40 "active" => Ok(Self::Active),
41 "decrypt_only" => Ok(Self::DecryptOnly),
42 "revoked" => Ok(Self::Revoked),
43 "retired" => Ok(Self::Retired),
44 other => bail!("unknown key state: {other}"),
45 }
46 }
47
48 pub fn is_terminal(&self) -> bool {
50 matches!(self, Self::Revoked | Self::Retired)
51 }
52}
53
54impl std::fmt::Display for KeyState {
55 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
56 f.write_str(self.as_str())
57 }
58}
59
60#[derive(Debug, Clone, Serialize, Deserialize)]
63pub struct KeyRecord {
65 pub key_id: String,
67 pub state: KeyState,
69 pub fingerprint: String,
71 pub file_path: String,
73 pub created_at: String,
75 pub activated_at: Option<String>,
77 pub rotated_out_at: Option<String>,
79 pub retired_at: Option<String>,
81 pub revoked_at: Option<String>,
83}
84
85pub struct KeyRing {
89 records: Vec<KeyRecord>,
90 active_key: Option<SecretKeyHandle>,
91 decrypt_keys: HashMap<String, SecretKeyHandle>,
92}
93
94impl KeyRing {
95 pub fn active_key(&self) -> Result<&SecretKeyHandle> {
97 self.active_key.as_ref().ok_or_else(|| {
98 anyhow::anyhow!(
99 "SecretStore write blocked: no active encryption key (all keys revoked or retired)"
100 )
101 })
102 }
103
104 pub fn decrypt_key(&self, key_id: &str) -> Result<&SecretKeyHandle> {
106 self.decrypt_keys.get(key_id).ok_or_else(|| {
107 anyhow::anyhow!(
108 "no decryption key available for key_id '{key_id}' (key may be revoked or missing)"
109 )
110 })
111 }
112
113 pub fn all_records(&self) -> &[KeyRecord] {
115 &self.records
116 }
117
118 pub fn active_record(&self) -> Option<&KeyRecord> {
120 self.records.iter().find(|r| r.state == KeyState::Active)
121 }
122
123 pub fn has_active_key(&self) -> bool {
125 self.active_key.is_some()
126 }
127
128 pub fn decrypt_only_records(&self) -> Vec<&KeyRecord> {
130 self.records
131 .iter()
132 .filter(|r| r.state == KeyState::DecryptOnly)
133 .collect()
134 }
135
136 pub fn decrypt_keys_iter(&self) -> impl Iterator<Item = (&str, &SecretKeyHandle)> {
138 self.decrypt_keys.iter().map(|(k, v)| (k.as_str(), v))
139 }
140}
141
142fn audit_event_for_record(
143 event_kind: crate::secret_key_audit::KeyAuditEventKind,
144 record: &KeyRecord,
145 actor: &str,
146 detail_json: String,
147 created_at: &str,
148) -> crate::secret_key_audit::KeyAuditEvent {
149 crate::secret_key_audit::KeyAuditEvent {
150 event_kind,
151 key_id: record.key_id.clone(),
152 key_fingerprint: record.fingerprint.clone(),
153 actor: actor.to_owned(),
154 detail_json,
155 created_at: created_at.to_owned(),
156 }
157}
158
159pub fn load_keyring(data_dir: &Path, db_path: &Path) -> Result<KeyRing> {
163 let conn = crate::open_conn(db_path)?;
164 let table_exists: bool = conn
165 .query_row(
166 "SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='table' AND name='secret_keys'",
167 [],
168 |row| row.get(0),
169 )
170 .unwrap_or(false);
171
172 if !table_exists {
173 return load_keyring_legacy(data_dir, db_path);
175 }
176
177 let records = query_all_key_records(&conn)?;
178 if records.is_empty() {
179 return load_keyring_legacy(data_dir, db_path);
181 }
182
183 build_keyring_from_records(&conn, data_dir, records)
184}
185
186fn load_keyring_legacy(data_dir: &Path, db_path: &Path) -> Result<KeyRing> {
187 let handle = crate::secret_store_crypto::ensure_secret_key(data_dir, db_path)?;
188 let key_id = handle.key_id().to_string();
189 let record = KeyRecord {
190 key_id: key_id.clone(),
191 state: KeyState::Active,
192 fingerprint: handle.fingerprint().to_string(),
193 file_path: crate::secret_store_crypto::secret_key_path(data_dir)
194 .to_string_lossy()
195 .to_string(),
196 created_at: now_ts(),
197 activated_at: Some(now_ts()),
198 rotated_out_at: None,
199 retired_at: None,
200 revoked_at: None,
201 };
202 let mut decrypt_keys = HashMap::new();
203 decrypt_keys.insert(key_id, handle.clone());
204 Ok(KeyRing {
205 records: vec![record],
206 active_key: Some(handle),
207 decrypt_keys,
208 })
209}
210
211fn build_keyring_from_records(
212 conn: &Connection,
213 data_dir: &Path,
214 records: Vec<KeyRecord>,
215) -> Result<KeyRing> {
216 let mut active_key = None;
217 let mut decrypt_keys = HashMap::new();
218
219 for record in &records {
220 if record.state == KeyState::Retired {
221 continue;
222 }
223 if record.state == KeyState::Revoked {
224 if !is_key_still_referenced(conn, &record.key_id)? {
229 continue;
230 }
231 tracing::warn!(
232 key_id = %record.key_id,
233 "revoked key still referenced by SecretStore data; \
234 loading as decrypt-only for crash recovery — \
235 run `secret key rotate --resume` to complete migration"
236 );
237 }
238 let key_path = resolve_key_file_path(data_dir, &record.file_path);
239 if let Some(handle) = load_key_file(&key_path, &record.key_id)? {
240 if record.state == KeyState::Active {
241 active_key = Some(handle.clone());
242 }
243 decrypt_keys.insert(record.key_id.clone(), handle);
244 }
245 }
246
247 Ok(KeyRing {
248 records,
249 active_key,
250 decrypt_keys,
251 })
252}
253
254fn is_key_still_referenced(conn: &Connection, key_id: &str) -> Result<bool> {
257 let referenced: bool = conn.query_row(
258 "SELECT EXISTS(
259 SELECT 1 FROM resources
260 WHERE kind = 'SecretStore'
261 AND instr(spec_json, ?1) > 0
262 )",
263 params![format!("\"key_id\":\"{key_id}\"")],
264 |row| row.get(0),
265 )?;
266 Ok(referenced)
267}
268
269fn resolve_key_file_path(data_dir: &Path, file_path: &str) -> PathBuf {
270 let p = Path::new(file_path);
271 if p.is_absolute() {
272 p.to_path_buf()
273 } else {
274 data_dir.join(file_path)
275 }
276}
277
278fn load_key_file(path: &Path, key_id: &str) -> Result<Option<SecretKeyHandle>> {
279 if !path.exists() {
280 return Ok(None);
281 }
282 crate::secret_store_crypto::load_key_file_as_handle(path, key_id)
283 .map(Some)
284 .with_context(|| format!("failed to load key file for key_id '{key_id}'"))
285}
286
287pub fn query_all_key_records(conn: &Connection) -> Result<Vec<KeyRecord>> {
291 let mut stmt = conn.prepare(
292 "SELECT key_id, state, fingerprint, file_path, created_at, activated_at, rotated_out_at, retired_at, revoked_at
293 FROM secret_keys ORDER BY created_at ASC",
294 )?;
295 let rows = stmt.query_map([], |row| {
296 Ok((
297 row.get::<_, String>(0)?,
298 row.get::<_, String>(1)?,
299 row.get::<_, String>(2)?,
300 row.get::<_, String>(3)?,
301 row.get::<_, String>(4)?,
302 row.get::<_, Option<String>>(5)?,
303 row.get::<_, Option<String>>(6)?,
304 row.get::<_, Option<String>>(7)?,
305 row.get::<_, Option<String>>(8)?,
306 ))
307 })?;
308
309 let mut records = Vec::new();
310 for row in rows {
311 let (
312 key_id,
313 state_str,
314 fingerprint,
315 file_path,
316 created_at,
317 activated_at,
318 rotated_out_at,
319 retired_at,
320 revoked_at,
321 ) = row?;
322 records.push(KeyRecord {
323 key_id,
324 state: KeyState::from_str_value(&state_str)?,
325 fingerprint,
326 file_path,
327 created_at,
328 activated_at,
329 rotated_out_at,
330 retired_at,
331 revoked_at,
332 });
333 }
334 Ok(records)
335}
336
337fn query_active_key_record(conn: &Connection) -> Result<Option<KeyRecord>> {
338 Ok(query_all_key_records(conn)?
339 .into_iter()
340 .find(|r| r.state == KeyState::Active))
341}
342
343fn generate_key_id() -> String {
346 let ts = chrono::Utc::now().format("%Y%m%d%H%M%S");
347 let rand_part: u16 = rand::random();
348 format!("k-{ts}-{rand_part:04x}")
349}
350
351pub fn begin_rotation(conn: &Connection, data_dir: &Path) -> Result<(KeyRecord, KeyRecord)> {
356 let old_active = query_active_key_record(conn)?
357 .ok_or_else(|| anyhow::anyhow!("no active key found; cannot begin rotation"))?;
358
359 let records = query_all_key_records(conn)?;
361 if records.iter().any(|r| r.state == KeyState::DecryptOnly) {
362 bail!(
363 "incomplete rotation detected: a key is already in decrypt_only state; use --resume to complete the previous rotation first"
364 );
365 }
366
367 let new_key_id = generate_key_id();
368 let keys_dir = data_dir.join("secrets/keys");
369 std::fs::create_dir_all(&keys_dir)
370 .with_context(|| format!("failed to create keys dir {}", keys_dir.display()))?;
371 #[cfg(unix)]
372 {
373 use std::os::unix::fs::PermissionsExt;
374 std::fs::set_permissions(&keys_dir, std::fs::Permissions::from_mode(0o700))?;
375 }
376
377 let new_key_path = keys_dir.join(format!("{new_key_id}.key"));
378 let handle =
379 crate::secret_store_crypto::generate_and_write_key_file(&new_key_path, &new_key_id)?;
380 let now = now_ts();
381
382 let new_file_path = format!("secrets/keys/{new_key_id}.key");
383 let new_record = KeyRecord {
384 key_id: new_key_id.clone(),
385 state: KeyState::Active,
386 fingerprint: handle.fingerprint().to_string(),
387 file_path: new_file_path.clone(),
388 created_at: now.clone(),
389 activated_at: Some(now.clone()),
390 rotated_out_at: None,
391 retired_at: None,
392 revoked_at: None,
393 };
394
395 conn.execute(
397 "INSERT INTO secret_keys (key_id, state, fingerprint, file_path, created_at, activated_at)
398 VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
399 params![
400 new_key_id,
401 KeyState::Active.as_str(),
402 handle.fingerprint(),
403 new_file_path,
404 now,
405 now
406 ],
407 )?;
408
409 conn.execute(
411 "UPDATE secret_keys SET state = ?1, rotated_out_at = ?2 WHERE key_id = ?3",
412 params![KeyState::DecryptOnly.as_str(), now, old_active.key_id],
413 )?;
414
415 let old_record = KeyRecord {
416 state: KeyState::DecryptOnly,
417 rotated_out_at: Some(now.clone()),
418 ..old_active
419 };
420
421 crate::secret_key_audit::insert_key_audit_event(
423 conn,
424 &audit_event_for_record(
425 crate::secret_key_audit::KeyAuditEventKind::KeyCreated,
426 &new_record,
427 "cli:rotate",
428 "{}".to_string(),
429 &now,
430 ),
431 )?;
432 crate::secret_key_audit::insert_key_audit_event(
433 conn,
434 &audit_event_for_record(
435 crate::secret_key_audit::KeyAuditEventKind::KeyActivated,
436 &new_record,
437 "cli:rotate",
438 "{}".to_string(),
439 &now,
440 ),
441 )?;
442 crate::secret_key_audit::insert_key_audit_event(
443 conn,
444 &audit_event_for_record(
445 crate::secret_key_audit::KeyAuditEventKind::RotateStarted,
446 &old_record,
447 "cli:rotate",
448 serde_json::json!({
449 "new_key_id": new_record.key_id,
450 "old_key_id": old_record.key_id,
451 })
452 .to_string(),
453 &now,
454 ),
455 )?;
456
457 Ok((new_record, old_record))
458}
459
460#[derive(Debug, Clone, Default)]
463pub struct ReEncryptionReport {
465 pub resources_updated: usize,
467 pub versions_updated: usize,
469 pub errors: Vec<String>,
471}
472
473pub fn re_encrypt_all_secrets(
476 conn: &Connection,
477 old_encryption: &SecretEncryption,
478 new_encryption: &SecretEncryption,
479) -> Result<ReEncryptionReport> {
480 let mut report = ReEncryptionReport::default();
481
482 let tx = conn
483 .unchecked_transaction()
484 .context("failed to begin re-encryption transaction")?;
485
486 {
488 let mut stmt = tx.prepare(
489 "SELECT rowid, project, name, spec_json FROM resources WHERE kind = 'SecretStore'",
490 )?;
491 let rows: Vec<(i64, String, String, String)> = stmt
492 .query_map([], |row| {
493 Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?))
494 })?
495 .collect::<std::result::Result<Vec<_>, _>>()?;
496
497 for (rowid, project, name, spec_json) in rows {
498 match re_encrypt_single(old_encryption, new_encryption, &project, &name, &spec_json) {
499 Ok(new_spec_json) => {
500 tx.execute(
501 "UPDATE resources SET spec_json = ?1 WHERE rowid = ?2",
502 params![new_spec_json, rowid],
503 )?;
504 report.resources_updated += 1;
505 }
506 Err(e) => {
507 report
508 .errors
509 .push(format!("SecretStore/{project}/{name}: {e}"));
510 }
511 }
512 }
513 }
514
515 {
517 let mut stmt = tx.prepare(
518 "SELECT id, project, name, spec_json FROM resource_versions WHERE kind = 'SecretStore' AND version > 0",
519 )?;
520 let rows: Vec<(i64, String, String, String)> = stmt
521 .query_map([], |row| {
522 Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?))
523 })?
524 .collect::<std::result::Result<Vec<_>, _>>()?;
525
526 for (id, project, name, spec_json) in rows {
527 match re_encrypt_single(old_encryption, new_encryption, &project, &name, &spec_json) {
528 Ok(new_spec_json) => {
529 tx.execute(
530 "UPDATE resource_versions SET spec_json = ?1 WHERE id = ?2",
531 params![new_spec_json, id],
532 )?;
533 report.versions_updated += 1;
534 }
535 Err(e) => {
536 report.errors.push(format!(
537 "resource_versions SecretStore/{project}/{name}: {e}"
538 ));
539 }
540 }
541 }
542 }
543
544 tx.commit()
545 .context("failed to commit re-encryption transaction")?;
546 Ok(report)
547}
548
549fn re_encrypt_single(
550 old_enc: &SecretEncryption,
551 new_enc: &SecretEncryption,
552 project: &str,
553 name: &str,
554 spec_json: &str,
555) -> Result<String> {
556 if !crate::secret_store_crypto::is_encrypted_secret_store_json(spec_json) {
557 return Ok(spec_json.to_string());
558 }
559 let plaintext = old_enc.decrypt_secret_store_spec(project, name, spec_json)?;
560 new_enc.encrypt_secret_store_spec(project, name, &plaintext)
561}
562
563pub fn complete_rotation(conn: &Connection, old_key_id: &str) -> Result<()> {
567 let now = now_ts();
568
569 let records = query_all_key_records(conn)?;
571 let old_record = records
572 .iter()
573 .find(|r| r.key_id == old_key_id)
574 .ok_or_else(|| anyhow::anyhow!("key '{old_key_id}' not found"))?;
575
576 if old_record.state != KeyState::DecryptOnly {
577 bail!(
578 "key '{old_key_id}' is in state '{}', expected 'decrypt_only'",
579 old_record.state
580 );
581 }
582
583 let still_referenced: bool = conn.query_row(
585 "SELECT EXISTS(
586 SELECT 1 FROM resources
587 WHERE kind = 'SecretStore'
588 AND instr(spec_json, ?1) > 0
589 )",
590 params![format!("\"key_id\":\"{old_key_id}\"")],
591 |row| row.get(0),
592 )?;
593
594 if still_referenced {
595 bail!("cannot complete rotation: some resources still reference key '{old_key_id}'");
596 }
597
598 conn.execute(
599 "UPDATE secret_keys SET state = ?1, retired_at = ?2 WHERE key_id = ?3",
600 params![KeyState::Retired.as_str(), now, old_key_id],
601 )?;
602
603 crate::secret_key_audit::insert_key_audit_event(
604 conn,
605 &crate::secret_key_audit::KeyAuditEvent {
606 event_kind: crate::secret_key_audit::KeyAuditEventKind::RotateCompleted,
607 key_id: old_key_id.to_string(),
608 key_fingerprint: old_record.fingerprint.clone(),
609 actor: "cli:rotate".to_string(),
610 detail_json: "{}".to_string(),
611 created_at: now,
612 },
613 )?;
614
615 Ok(())
616}
617
618pub fn resume_rotation(conn: &Connection, data_dir: &Path) -> Result<ReEncryptionReport> {
623 let records = query_all_key_records(conn)?;
624 let old_record = records
625 .iter()
626 .find(|r| r.state == KeyState::DecryptOnly)
627 .ok_or_else(|| {
628 anyhow::anyhow!("no incomplete rotation found (no key in decrypt_only state)")
629 })?;
630 let new_record = records
631 .iter()
632 .find(|r| r.state == KeyState::Active)
633 .ok_or_else(|| anyhow::anyhow!("no active key found to complete rotation"))?;
634
635 let old_key_path = resolve_key_file_path(data_dir, &old_record.file_path);
636 let new_key_path = resolve_key_file_path(data_dir, &new_record.file_path);
637
638 let old_handle =
639 crate::secret_store_crypto::load_key_file_as_handle(&old_key_path, &old_record.key_id)?;
640 let new_handle =
641 crate::secret_store_crypto::load_key_file_as_handle(&new_key_path, &new_record.key_id)?;
642
643 let old_encryption = SecretEncryption::from_key(old_handle);
644 let new_encryption = SecretEncryption::from_key(new_handle);
645
646 let report = re_encrypt_all_secrets(conn, &old_encryption, &new_encryption)?;
647
648 if report.errors.is_empty() {
649 complete_rotation(conn, &old_record.key_id)?;
650 }
651
652 Ok(report)
653}
654
655pub fn bootstrap_key(conn: &Connection, data_dir: &Path) -> Result<KeyRecord> {
660 if query_active_key_record(conn)?.is_some() {
661 bail!(
662 "an active key already exists; bootstrap is only for recovery when no active key is available"
663 );
664 }
665
666 let new_key_id = generate_key_id();
667 let keys_dir = data_dir.join("secrets/keys");
668 std::fs::create_dir_all(&keys_dir)
669 .with_context(|| format!("failed to create keys dir {}", keys_dir.display()))?;
670 #[cfg(unix)]
671 {
672 use std::os::unix::fs::PermissionsExt;
673 std::fs::set_permissions(&keys_dir, std::fs::Permissions::from_mode(0o700))?;
674 }
675
676 let new_key_path = keys_dir.join(format!("{new_key_id}.key"));
677 let handle =
678 crate::secret_store_crypto::generate_and_write_key_file(&new_key_path, &new_key_id)?;
679 let now = now_ts();
680
681 let new_file_path = format!("secrets/keys/{new_key_id}.key");
682 let record = KeyRecord {
683 key_id: new_key_id.clone(),
684 state: KeyState::Active,
685 fingerprint: handle.fingerprint().to_string(),
686 file_path: new_file_path.clone(),
687 created_at: now.clone(),
688 activated_at: Some(now.clone()),
689 rotated_out_at: None,
690 retired_at: None,
691 revoked_at: None,
692 };
693
694 conn.execute(
695 "INSERT INTO secret_keys (key_id, state, fingerprint, file_path, created_at, activated_at)
696 VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
697 params![
698 new_key_id,
699 KeyState::Active.as_str(),
700 handle.fingerprint(),
701 new_file_path,
702 now,
703 now
704 ],
705 )?;
706
707 crate::secret_key_audit::insert_key_audit_event(
708 conn,
709 &crate::secret_key_audit::KeyAuditEvent {
710 event_kind: crate::secret_key_audit::KeyAuditEventKind::KeyBootstrapped,
711 key_id: new_key_id,
712 key_fingerprint: record.fingerprint.clone(),
713 actor: "cli:bootstrap".to_string(),
714 detail_json: serde_json::json!({
715 "reason": "emergency recovery — no active key available"
716 })
717 .to_string(),
718 created_at: now,
719 },
720 )?;
721
722 Ok(record)
723}
724
725pub fn revoke_key(conn: &Connection, key_id: &str, force: bool) -> Result<()> {
729 let records = query_all_key_records(conn)?;
730 let record = records
731 .iter()
732 .find(|r| r.key_id == key_id)
733 .ok_or_else(|| anyhow::anyhow!("key '{key_id}' not found"))?;
734
735 if record.state.is_terminal() {
736 bail!(
737 "key '{key_id}' is already in terminal state '{}'",
738 record.state
739 );
740 }
741
742 if record.state == KeyState::Active && !force {
743 let active_count = records
744 .iter()
745 .filter(|r| r.state == KeyState::Active)
746 .count();
747 if active_count <= 1 {
748 bail!(
749 "refusing to revoke the last active key '{key_id}' without --force; \
750 this will leave SecretStore inoperable. Use 'secret key bootstrap' to recover"
751 );
752 }
753 bail!(
754 "refusing to revoke active key '{key_id}' without --force; this will block all SecretStore writes"
755 );
756 }
757
758 let now = now_ts();
759 conn.execute(
760 "UPDATE secret_keys SET state = ?1, revoked_at = ?2 WHERE key_id = ?3",
761 params![KeyState::Revoked.as_str(), now, key_id],
762 )?;
763
764 crate::secret_key_audit::insert_key_audit_event(
765 conn,
766 &crate::secret_key_audit::KeyAuditEvent {
767 event_kind: crate::secret_key_audit::KeyAuditEventKind::KeyRevoked,
768 key_id: key_id.to_string(),
769 key_fingerprint: record.fingerprint.clone(),
770 actor: "cli:revoke".to_string(),
771 detail_json: serde_json::json!({ "force": force }).to_string(),
772 created_at: now,
773 },
774 )?;
775
776 Ok(())
777}
778
779pub fn import_legacy_key_record(conn: &Connection, data_dir: &Path) -> Result<Option<KeyRecord>> {
783 let legacy_path = crate::secret_store_crypto::secret_key_path(data_dir);
784 if !legacy_path.exists() {
785 return Ok(None);
786 }
787
788 let handle = match crate::secret_store_crypto::load_existing_secret_key(data_dir)? {
789 Some(h) => h,
790 None => return Ok(None),
791 };
792
793 let now = now_ts();
794 let relative_path = "secrets/secretstore.key";
795
796 let record = KeyRecord {
797 key_id: handle.key_id().to_string(),
798 state: KeyState::Active,
799 fingerprint: handle.fingerprint().to_string(),
800 file_path: relative_path.to_string(),
801 created_at: now.clone(),
802 activated_at: Some(now.clone()),
803 rotated_out_at: None,
804 retired_at: None,
805 revoked_at: None,
806 };
807
808 conn.execute(
809 "INSERT OR IGNORE INTO secret_keys (key_id, state, fingerprint, file_path, created_at, activated_at)
810 VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
811 params![
812 record.key_id,
813 record.state.as_str(),
814 record.fingerprint,
815 record.file_path,
816 now,
817 now
818 ],
819 )?;
820
821 Ok(Some(record))
822}
823
824#[cfg(test)]
825mod tests {
826 use super::*;
827
828 fn setup_test_db() -> (tempfile::TempDir, std::path::PathBuf) {
829 let temp = tempfile::tempdir().expect("tempdir");
830 let db_path = temp.path().join("data/agent_orchestrator.db");
831 std::fs::create_dir_all(db_path.parent().expect("parent")).expect("create data dir");
832 crate::init_test_schema(&db_path).expect("init schema");
833 (temp, db_path)
834 }
835
836 #[test]
837 fn key_state_round_trip() {
838 for state in [
839 KeyState::Active,
840 KeyState::DecryptOnly,
841 KeyState::Revoked,
842 KeyState::Retired,
843 ] {
844 assert_eq!(KeyState::from_str_value(state.as_str()).unwrap(), state);
845 }
846 }
847
848 #[test]
849 fn terminal_states() {
850 assert!(!KeyState::Active.is_terminal());
851 assert!(!KeyState::DecryptOnly.is_terminal());
852 assert!(KeyState::Revoked.is_terminal());
853 assert!(KeyState::Retired.is_terminal());
854 }
855
856 #[test]
857 fn load_keyring_from_legacy_key() {
858 let (temp, db_path) = setup_test_db();
859 crate::secret_store_crypto::ensure_secret_key(temp.path(), &db_path).expect("ensure key");
861
862 let keyring = load_keyring(temp.path(), &db_path).expect("load keyring");
863 assert!(keyring.has_active_key());
864 assert_eq!(keyring.all_records().len(), 1);
865 assert_eq!(keyring.all_records()[0].state, KeyState::Active);
866 }
867
868 #[test]
869 fn begin_rotation_creates_new_key_and_demotes_old() {
870 let (temp, db_path) = setup_test_db();
871 crate::secret_store_crypto::ensure_secret_key(temp.path(), &db_path).expect("ensure key");
872
873 let conn = crate::open_conn(&db_path).expect("open");
874 import_legacy_key_record(&conn, temp.path()).expect("import legacy");
876
877 let (new_rec, old_rec) = begin_rotation(&conn, temp.path()).expect("begin rotation");
878 assert_eq!(new_rec.state, KeyState::Active);
879 assert_eq!(old_rec.state, KeyState::DecryptOnly);
880 assert!(new_rec.key_id.starts_with("k-"));
881 }
882
883 #[test]
884 fn revoke_active_key_requires_force() {
885 let (temp, db_path) = setup_test_db();
886 crate::secret_store_crypto::ensure_secret_key(temp.path(), &db_path).expect("ensure key");
887
888 let conn = crate::open_conn(&db_path).expect("open");
889 import_legacy_key_record(&conn, temp.path()).expect("import legacy");
890
891 let records = query_all_key_records(&conn).expect("query");
892 let active_id = &records[0].key_id;
893
894 let err = revoke_key(&conn, active_id, false).expect_err("should require force");
895 assert!(err.to_string().contains("--force"));
896
897 revoke_key(&conn, active_id, true).expect("force revoke should succeed");
898
899 let records_after = query_all_key_records(&conn).expect("query after");
900 assert_eq!(records_after[0].state, KeyState::Revoked);
901 }
902
903 #[test]
904 fn full_rotation_lifecycle() {
905 let (temp, db_path) = setup_test_db();
906 let handle =
907 crate::secret_store_crypto::ensure_secret_key(temp.path(), &db_path).expect("ensure");
908 let enc = SecretEncryption::from_key(handle);
909
910 let spec = serde_json::json!({"data": {"API_KEY": "sk-test"}});
912 let cipher = enc
913 .encrypt_secret_store_spec("default", "test-secret", &spec)
914 .expect("encrypt");
915
916 let conn = crate::open_conn(&db_path).expect("open");
917 conn.execute(
918 "INSERT INTO resources (kind, project, name, api_version, spec_json, metadata_json, generation, created_at, updated_at)
919 VALUES ('SecretStore', 'default', 'test-secret', 'v2', ?1, '{}', 1, datetime('now'), datetime('now'))",
920 params![cipher],
921 ).expect("insert");
922
923 import_legacy_key_record(&conn, temp.path()).expect("import");
925 let (new_rec, old_rec) = begin_rotation(&conn, temp.path()).expect("begin rotation");
926
927 let old_key_path = resolve_key_file_path(temp.path(), &old_rec.file_path);
929 let new_key_path = resolve_key_file_path(temp.path(), &new_rec.file_path);
930 let old_handle =
931 crate::secret_store_crypto::load_key_file_as_handle(&old_key_path, &old_rec.key_id)
932 .expect("load old");
933 let new_handle =
934 crate::secret_store_crypto::load_key_file_as_handle(&new_key_path, &new_rec.key_id)
935 .expect("load new");
936
937 let report = re_encrypt_all_secrets(
938 &conn,
939 &SecretEncryption::from_key(old_handle),
940 &SecretEncryption::from_key(new_handle.clone()),
941 )
942 .expect("re-encrypt");
943 assert_eq!(report.resources_updated, 1);
944 assert!(report.errors.is_empty());
945
946 complete_rotation(&conn, &old_rec.key_id).expect("complete rotation");
948
949 let records = query_all_key_records(&conn).expect("query");
950 let old = records
951 .iter()
952 .find(|r| r.key_id == old_rec.key_id)
953 .expect("find old");
954 assert_eq!(old.state, KeyState::Retired);
955
956 let new_enc = SecretEncryption::from_key(new_handle);
958 let spec_json: String = conn
959 .query_row(
960 "SELECT spec_json FROM resources WHERE kind='SecretStore' AND name='test-secret'",
961 [],
962 |row| row.get(0),
963 )
964 .expect("load");
965 let decrypted = new_enc
966 .decrypt_secret_store_spec("default", "test-secret", &spec_json)
967 .expect("decrypt");
968 assert_eq!(decrypted, spec);
969 }
970
971 #[test]
972 fn bootstrap_creates_key_when_no_active() {
973 let (temp, db_path) = setup_test_db();
974 crate::secret_store_crypto::ensure_secret_key(temp.path(), &db_path).expect("ensure key");
975
976 let conn = crate::open_conn(&db_path).expect("open");
977 import_legacy_key_record(&conn, temp.path()).expect("import legacy");
978
979 let records = query_all_key_records(&conn).expect("query");
981 let active_id = records[0].key_id.clone();
982 revoke_key(&conn, &active_id, true).expect("force revoke");
983
984 assert!(query_active_key_record(&conn).expect("query").is_none());
986
987 let record = bootstrap_key(&conn, temp.path()).expect("bootstrap");
989 assert_eq!(record.state, KeyState::Active);
990 assert!(record.key_id.starts_with("k-"));
991
992 let active = query_active_key_record(&conn).expect("query");
994 assert!(active.is_some());
995 assert_eq!(active.unwrap().key_id, record.key_id);
996
997 let events =
999 crate::secret_key_audit::query_key_audit_events_for_key(&conn, &record.key_id, 10)
1000 .expect("audit");
1001 assert!(
1002 events.iter().any(
1003 |e| e.event_kind == crate::secret_key_audit::KeyAuditEventKind::KeyBootstrapped
1004 )
1005 );
1006 }
1007
1008 #[test]
1009 fn bootstrap_fails_when_active_key_exists() {
1010 let (temp, db_path) = setup_test_db();
1011 crate::secret_store_crypto::ensure_secret_key(temp.path(), &db_path).expect("ensure key");
1012
1013 let conn = crate::open_conn(&db_path).expect("open");
1014 import_legacy_key_record(&conn, temp.path()).expect("import legacy");
1015
1016 let err = bootstrap_key(&conn, temp.path()).expect_err("should fail");
1018 assert!(err.to_string().contains("active key already exists"));
1019 }
1020
1021 #[test]
1022 fn revoke_last_active_key_warns() {
1023 let (temp, db_path) = setup_test_db();
1024 crate::secret_store_crypto::ensure_secret_key(temp.path(), &db_path).expect("ensure key");
1025
1026 let conn = crate::open_conn(&db_path).expect("open");
1027 import_legacy_key_record(&conn, temp.path()).expect("import legacy");
1028
1029 let records = query_all_key_records(&conn).expect("query");
1030 let active_id = &records[0].key_id;
1031
1032 let err = revoke_key(&conn, active_id, false).expect_err("should fail");
1034 let msg = err.to_string();
1035 assert!(msg.contains("last active key"), "error: {msg}");
1036 assert!(msg.contains("bootstrap"), "error: {msg}");
1037 }
1038
1039 #[test]
1040 fn build_keyring_loads_revoked_key_when_still_referenced() {
1041 let (temp, db_path) = setup_test_db();
1042 let handle =
1043 crate::secret_store_crypto::ensure_secret_key(temp.path(), &db_path).expect("ensure");
1044 let enc = SecretEncryption::from_key(handle);
1045
1046 let conn = crate::open_conn(&db_path).expect("open");
1047 import_legacy_key_record(&conn, temp.path()).expect("import");
1048
1049 let spec = serde_json::json!({"data": {"KEY": "val"}});
1051 let cipher = enc
1052 .encrypt_secret_store_spec("default", "revoke-test", &spec)
1053 .expect("encrypt");
1054 conn.execute(
1055 "INSERT INTO resources (kind, project, name, api_version, spec_json, metadata_json, generation, created_at, updated_at)
1056 VALUES ('SecretStore', 'default', 'revoke-test', 'v2', ?1, '{}', 1, datetime('now'), datetime('now'))",
1057 params![cipher],
1058 ).expect("insert");
1059
1060 let records = query_all_key_records(&conn).expect("query");
1062 let key_id = records[0].key_id.clone();
1063 revoke_key(&conn, &key_id, true).expect("force revoke");
1064
1065 bootstrap_key(&conn, temp.path()).expect("bootstrap");
1067
1068 let all_records = query_all_key_records(&conn).expect("query");
1070 let keyring = build_keyring_from_records(&conn, temp.path(), all_records).expect("build");
1071
1072 assert!(
1073 keyring.decrypt_keys.contains_key(&key_id),
1074 "revoked-but-referenced key should be in decrypt_keys"
1075 );
1076 }
1077
1078 #[test]
1079 fn build_keyring_skips_revoked_key_when_not_referenced() {
1080 let (temp, db_path) = setup_test_db();
1081 crate::secret_store_crypto::ensure_secret_key(temp.path(), &db_path).expect("ensure");
1082
1083 let conn = crate::open_conn(&db_path).expect("open");
1084 import_legacy_key_record(&conn, temp.path()).expect("import");
1085
1086 let records = query_all_key_records(&conn).expect("query");
1088 let key_id = records[0].key_id.clone();
1089 revoke_key(&conn, &key_id, true).expect("force revoke");
1090
1091 bootstrap_key(&conn, temp.path()).expect("bootstrap");
1092
1093 let all_records = query_all_key_records(&conn).expect("query");
1094 let keyring = build_keyring_from_records(&conn, temp.path(), all_records).expect("build");
1095
1096 assert!(
1097 !keyring.decrypt_keys.contains_key(&key_id),
1098 "revoked key with no data references should NOT be in decrypt_keys"
1099 );
1100 }
1101}