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(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(data_dir: &Path, records: Vec<KeyRecord>) -> Result<KeyRing> {
212 let mut active_key = None;
213 let mut decrypt_keys = HashMap::new();
214
215 for record in &records {
216 if record.state.is_terminal() {
217 continue;
218 }
219 let key_path = resolve_key_file_path(data_dir, &record.file_path);
220 if let Some(handle) = load_key_file(&key_path, &record.key_id)? {
221 if record.state == KeyState::Active {
222 active_key = Some(handle.clone());
223 }
224 decrypt_keys.insert(record.key_id.clone(), handle);
225 }
226 }
227
228 Ok(KeyRing {
229 records,
230 active_key,
231 decrypt_keys,
232 })
233}
234
235fn resolve_key_file_path(data_dir: &Path, file_path: &str) -> PathBuf {
236 let p = Path::new(file_path);
237 if p.is_absolute() {
238 p.to_path_buf()
239 } else {
240 data_dir.join(file_path)
241 }
242}
243
244fn load_key_file(path: &Path, key_id: &str) -> Result<Option<SecretKeyHandle>> {
245 if !path.exists() {
246 return Ok(None);
247 }
248 crate::secret_store_crypto::load_key_file_as_handle(path, key_id)
249 .map(Some)
250 .with_context(|| format!("failed to load key file for key_id '{key_id}'"))
251}
252
253pub fn query_all_key_records(conn: &Connection) -> Result<Vec<KeyRecord>> {
257 let mut stmt = conn.prepare(
258 "SELECT key_id, state, fingerprint, file_path, created_at, activated_at, rotated_out_at, retired_at, revoked_at
259 FROM secret_keys ORDER BY created_at ASC",
260 )?;
261 let rows = stmt.query_map([], |row| {
262 Ok((
263 row.get::<_, String>(0)?,
264 row.get::<_, String>(1)?,
265 row.get::<_, String>(2)?,
266 row.get::<_, String>(3)?,
267 row.get::<_, String>(4)?,
268 row.get::<_, Option<String>>(5)?,
269 row.get::<_, Option<String>>(6)?,
270 row.get::<_, Option<String>>(7)?,
271 row.get::<_, Option<String>>(8)?,
272 ))
273 })?;
274
275 let mut records = Vec::new();
276 for row in rows {
277 let (
278 key_id,
279 state_str,
280 fingerprint,
281 file_path,
282 created_at,
283 activated_at,
284 rotated_out_at,
285 retired_at,
286 revoked_at,
287 ) = row?;
288 records.push(KeyRecord {
289 key_id,
290 state: KeyState::from_str_value(&state_str)?,
291 fingerprint,
292 file_path,
293 created_at,
294 activated_at,
295 rotated_out_at,
296 retired_at,
297 revoked_at,
298 });
299 }
300 Ok(records)
301}
302
303fn query_active_key_record(conn: &Connection) -> Result<Option<KeyRecord>> {
304 Ok(query_all_key_records(conn)?
305 .into_iter()
306 .find(|r| r.state == KeyState::Active))
307}
308
309fn generate_key_id() -> String {
312 let ts = chrono::Utc::now().format("%Y%m%d%H%M%S");
313 let rand_part: u16 = rand::random();
314 format!("k-{ts}-{rand_part:04x}")
315}
316
317pub fn begin_rotation(conn: &Connection, data_dir: &Path) -> Result<(KeyRecord, KeyRecord)> {
322 let old_active = query_active_key_record(conn)?
323 .ok_or_else(|| anyhow::anyhow!("no active key found; cannot begin rotation"))?;
324
325 let records = query_all_key_records(conn)?;
327 if records.iter().any(|r| r.state == KeyState::DecryptOnly) {
328 bail!(
329 "incomplete rotation detected: a key is already in decrypt_only state; use --resume to complete the previous rotation first"
330 );
331 }
332
333 let new_key_id = generate_key_id();
334 let keys_dir = data_dir.join("secrets/keys");
335 std::fs::create_dir_all(&keys_dir)
336 .with_context(|| format!("failed to create keys dir {}", keys_dir.display()))?;
337 #[cfg(unix)]
338 {
339 use std::os::unix::fs::PermissionsExt;
340 std::fs::set_permissions(&keys_dir, std::fs::Permissions::from_mode(0o700))?;
341 }
342
343 let new_key_path = keys_dir.join(format!("{new_key_id}.key"));
344 let handle =
345 crate::secret_store_crypto::generate_and_write_key_file(&new_key_path, &new_key_id)?;
346 let now = now_ts();
347
348 let new_file_path = format!("secrets/keys/{new_key_id}.key");
349 let new_record = KeyRecord {
350 key_id: new_key_id.clone(),
351 state: KeyState::Active,
352 fingerprint: handle.fingerprint().to_string(),
353 file_path: new_file_path.clone(),
354 created_at: now.clone(),
355 activated_at: Some(now.clone()),
356 rotated_out_at: None,
357 retired_at: None,
358 revoked_at: None,
359 };
360
361 conn.execute(
363 "INSERT INTO secret_keys (key_id, state, fingerprint, file_path, created_at, activated_at)
364 VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
365 params![
366 new_key_id,
367 KeyState::Active.as_str(),
368 handle.fingerprint(),
369 new_file_path,
370 now,
371 now
372 ],
373 )?;
374
375 conn.execute(
377 "UPDATE secret_keys SET state = ?1, rotated_out_at = ?2 WHERE key_id = ?3",
378 params![KeyState::DecryptOnly.as_str(), now, old_active.key_id],
379 )?;
380
381 let old_record = KeyRecord {
382 state: KeyState::DecryptOnly,
383 rotated_out_at: Some(now.clone()),
384 ..old_active
385 };
386
387 crate::secret_key_audit::insert_key_audit_event(
389 conn,
390 &audit_event_for_record(
391 crate::secret_key_audit::KeyAuditEventKind::KeyCreated,
392 &new_record,
393 "cli:rotate",
394 "{}".to_string(),
395 &now,
396 ),
397 )?;
398 crate::secret_key_audit::insert_key_audit_event(
399 conn,
400 &audit_event_for_record(
401 crate::secret_key_audit::KeyAuditEventKind::KeyActivated,
402 &new_record,
403 "cli:rotate",
404 "{}".to_string(),
405 &now,
406 ),
407 )?;
408 crate::secret_key_audit::insert_key_audit_event(
409 conn,
410 &audit_event_for_record(
411 crate::secret_key_audit::KeyAuditEventKind::RotateStarted,
412 &old_record,
413 "cli:rotate",
414 serde_json::json!({
415 "new_key_id": new_record.key_id,
416 "old_key_id": old_record.key_id,
417 })
418 .to_string(),
419 &now,
420 ),
421 )?;
422
423 Ok((new_record, old_record))
424}
425
426#[derive(Debug, Clone, Default)]
429pub struct ReEncryptionReport {
431 pub resources_updated: usize,
433 pub versions_updated: usize,
435 pub errors: Vec<String>,
437}
438
439pub fn re_encrypt_all_secrets(
442 conn: &Connection,
443 old_encryption: &SecretEncryption,
444 new_encryption: &SecretEncryption,
445) -> Result<ReEncryptionReport> {
446 let mut report = ReEncryptionReport::default();
447
448 let tx = conn
449 .unchecked_transaction()
450 .context("failed to begin re-encryption transaction")?;
451
452 {
454 let mut stmt = tx.prepare(
455 "SELECT rowid, project, name, spec_json FROM resources WHERE kind = 'SecretStore'",
456 )?;
457 let rows: Vec<(i64, String, String, String)> = stmt
458 .query_map([], |row| {
459 Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?))
460 })?
461 .collect::<std::result::Result<Vec<_>, _>>()?;
462
463 for (rowid, project, name, spec_json) in rows {
464 match re_encrypt_single(old_encryption, new_encryption, &project, &name, &spec_json) {
465 Ok(new_spec_json) => {
466 tx.execute(
467 "UPDATE resources SET spec_json = ?1 WHERE rowid = ?2",
468 params![new_spec_json, rowid],
469 )?;
470 report.resources_updated += 1;
471 }
472 Err(e) => {
473 report
474 .errors
475 .push(format!("SecretStore/{project}/{name}: {e}"));
476 }
477 }
478 }
479 }
480
481 {
483 let mut stmt = tx.prepare(
484 "SELECT id, project, name, spec_json FROM resource_versions WHERE kind = 'SecretStore' AND version > 0",
485 )?;
486 let rows: Vec<(i64, String, String, String)> = stmt
487 .query_map([], |row| {
488 Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?))
489 })?
490 .collect::<std::result::Result<Vec<_>, _>>()?;
491
492 for (id, project, name, spec_json) in rows {
493 match re_encrypt_single(old_encryption, new_encryption, &project, &name, &spec_json) {
494 Ok(new_spec_json) => {
495 tx.execute(
496 "UPDATE resource_versions SET spec_json = ?1 WHERE id = ?2",
497 params![new_spec_json, id],
498 )?;
499 report.versions_updated += 1;
500 }
501 Err(e) => {
502 report.errors.push(format!(
503 "resource_versions SecretStore/{project}/{name}: {e}"
504 ));
505 }
506 }
507 }
508 }
509
510 tx.commit()
511 .context("failed to commit re-encryption transaction")?;
512 Ok(report)
513}
514
515fn re_encrypt_single(
516 old_enc: &SecretEncryption,
517 new_enc: &SecretEncryption,
518 project: &str,
519 name: &str,
520 spec_json: &str,
521) -> Result<String> {
522 if !crate::secret_store_crypto::is_encrypted_secret_store_json(spec_json) {
523 return Ok(spec_json.to_string());
524 }
525 let plaintext = old_enc.decrypt_secret_store_spec(project, name, spec_json)?;
526 new_enc.encrypt_secret_store_spec(project, name, &plaintext)
527}
528
529pub fn complete_rotation(conn: &Connection, old_key_id: &str) -> Result<()> {
533 let now = now_ts();
534
535 let records = query_all_key_records(conn)?;
537 let old_record = records
538 .iter()
539 .find(|r| r.key_id == old_key_id)
540 .ok_or_else(|| anyhow::anyhow!("key '{old_key_id}' not found"))?;
541
542 if old_record.state != KeyState::DecryptOnly {
543 bail!(
544 "key '{old_key_id}' is in state '{}', expected 'decrypt_only'",
545 old_record.state
546 );
547 }
548
549 let still_referenced: bool = conn.query_row(
551 "SELECT EXISTS(
552 SELECT 1 FROM resources
553 WHERE kind = 'SecretStore'
554 AND instr(spec_json, ?1) > 0
555 )",
556 params![format!("\"key_id\":\"{old_key_id}\"")],
557 |row| row.get(0),
558 )?;
559
560 if still_referenced {
561 bail!("cannot complete rotation: some resources still reference key '{old_key_id}'");
562 }
563
564 conn.execute(
565 "UPDATE secret_keys SET state = ?1, retired_at = ?2 WHERE key_id = ?3",
566 params![KeyState::Retired.as_str(), now, old_key_id],
567 )?;
568
569 crate::secret_key_audit::insert_key_audit_event(
570 conn,
571 &crate::secret_key_audit::KeyAuditEvent {
572 event_kind: crate::secret_key_audit::KeyAuditEventKind::RotateCompleted,
573 key_id: old_key_id.to_string(),
574 key_fingerprint: old_record.fingerprint.clone(),
575 actor: "cli:rotate".to_string(),
576 detail_json: "{}".to_string(),
577 created_at: now,
578 },
579 )?;
580
581 Ok(())
582}
583
584pub fn resume_rotation(conn: &Connection, data_dir: &Path) -> Result<ReEncryptionReport> {
589 let records = query_all_key_records(conn)?;
590 let old_record = records
591 .iter()
592 .find(|r| r.state == KeyState::DecryptOnly)
593 .ok_or_else(|| {
594 anyhow::anyhow!("no incomplete rotation found (no key in decrypt_only state)")
595 })?;
596 let new_record = records
597 .iter()
598 .find(|r| r.state == KeyState::Active)
599 .ok_or_else(|| anyhow::anyhow!("no active key found to complete rotation"))?;
600
601 let old_key_path = resolve_key_file_path(data_dir, &old_record.file_path);
602 let new_key_path = resolve_key_file_path(data_dir, &new_record.file_path);
603
604 let old_handle =
605 crate::secret_store_crypto::load_key_file_as_handle(&old_key_path, &old_record.key_id)?;
606 let new_handle =
607 crate::secret_store_crypto::load_key_file_as_handle(&new_key_path, &new_record.key_id)?;
608
609 let old_encryption = SecretEncryption::from_key(old_handle);
610 let new_encryption = SecretEncryption::from_key(new_handle);
611
612 let report = re_encrypt_all_secrets(conn, &old_encryption, &new_encryption)?;
613
614 if report.errors.is_empty() {
615 complete_rotation(conn, &old_record.key_id)?;
616 }
617
618 Ok(report)
619}
620
621pub fn revoke_key(conn: &Connection, key_id: &str, force: bool) -> Result<()> {
625 let records = query_all_key_records(conn)?;
626 let record = records
627 .iter()
628 .find(|r| r.key_id == key_id)
629 .ok_or_else(|| anyhow::anyhow!("key '{key_id}' not found"))?;
630
631 if record.state.is_terminal() {
632 bail!(
633 "key '{key_id}' is already in terminal state '{}'",
634 record.state
635 );
636 }
637
638 if record.state == KeyState::Active && !force {
639 bail!(
640 "refusing to revoke active key '{key_id}' without --force; this will block all SecretStore writes"
641 );
642 }
643
644 let now = now_ts();
645 conn.execute(
646 "UPDATE secret_keys SET state = ?1, revoked_at = ?2 WHERE key_id = ?3",
647 params![KeyState::Revoked.as_str(), now, key_id],
648 )?;
649
650 crate::secret_key_audit::insert_key_audit_event(
651 conn,
652 &crate::secret_key_audit::KeyAuditEvent {
653 event_kind: crate::secret_key_audit::KeyAuditEventKind::KeyRevoked,
654 key_id: key_id.to_string(),
655 key_fingerprint: record.fingerprint.clone(),
656 actor: "cli:revoke".to_string(),
657 detail_json: serde_json::json!({ "force": force }).to_string(),
658 created_at: now,
659 },
660 )?;
661
662 Ok(())
663}
664
665pub fn import_legacy_key_record(conn: &Connection, data_dir: &Path) -> Result<Option<KeyRecord>> {
669 let legacy_path = crate::secret_store_crypto::secret_key_path(data_dir);
670 if !legacy_path.exists() {
671 return Ok(None);
672 }
673
674 let handle = match crate::secret_store_crypto::load_existing_secret_key(data_dir)? {
675 Some(h) => h,
676 None => return Ok(None),
677 };
678
679 let now = now_ts();
680 let relative_path = "secrets/secretstore.key";
681
682 let record = KeyRecord {
683 key_id: handle.key_id().to_string(),
684 state: KeyState::Active,
685 fingerprint: handle.fingerprint().to_string(),
686 file_path: relative_path.to_string(),
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 OR IGNORE INTO secret_keys (key_id, state, fingerprint, file_path, created_at, activated_at)
696 VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
697 params![
698 record.key_id,
699 record.state.as_str(),
700 record.fingerprint,
701 record.file_path,
702 now,
703 now
704 ],
705 )?;
706
707 Ok(Some(record))
708}
709
710#[cfg(test)]
711mod tests {
712 use super::*;
713
714 fn setup_test_db() -> (tempfile::TempDir, std::path::PathBuf) {
715 let temp = tempfile::tempdir().expect("tempdir");
716 let db_path = temp.path().join("data/agent_orchestrator.db");
717 std::fs::create_dir_all(db_path.parent().expect("parent")).expect("create data dir");
718 crate::init_test_schema(&db_path).expect("init schema");
719 (temp, db_path)
720 }
721
722 #[test]
723 fn key_state_round_trip() {
724 for state in [
725 KeyState::Active,
726 KeyState::DecryptOnly,
727 KeyState::Revoked,
728 KeyState::Retired,
729 ] {
730 assert_eq!(KeyState::from_str_value(state.as_str()).unwrap(), state);
731 }
732 }
733
734 #[test]
735 fn terminal_states() {
736 assert!(!KeyState::Active.is_terminal());
737 assert!(!KeyState::DecryptOnly.is_terminal());
738 assert!(KeyState::Revoked.is_terminal());
739 assert!(KeyState::Retired.is_terminal());
740 }
741
742 #[test]
743 fn load_keyring_from_legacy_key() {
744 let (temp, db_path) = setup_test_db();
745 crate::secret_store_crypto::ensure_secret_key(temp.path(), &db_path).expect("ensure key");
747
748 let keyring = load_keyring(temp.path(), &db_path).expect("load keyring");
749 assert!(keyring.has_active_key());
750 assert_eq!(keyring.all_records().len(), 1);
751 assert_eq!(keyring.all_records()[0].state, KeyState::Active);
752 }
753
754 #[test]
755 fn begin_rotation_creates_new_key_and_demotes_old() {
756 let (temp, db_path) = setup_test_db();
757 crate::secret_store_crypto::ensure_secret_key(temp.path(), &db_path).expect("ensure key");
758
759 let conn = crate::open_conn(&db_path).expect("open");
760 import_legacy_key_record(&conn, temp.path()).expect("import legacy");
762
763 let (new_rec, old_rec) = begin_rotation(&conn, temp.path()).expect("begin rotation");
764 assert_eq!(new_rec.state, KeyState::Active);
765 assert_eq!(old_rec.state, KeyState::DecryptOnly);
766 assert!(new_rec.key_id.starts_with("k-"));
767 }
768
769 #[test]
770 fn revoke_active_key_requires_force() {
771 let (temp, db_path) = setup_test_db();
772 crate::secret_store_crypto::ensure_secret_key(temp.path(), &db_path).expect("ensure key");
773
774 let conn = crate::open_conn(&db_path).expect("open");
775 import_legacy_key_record(&conn, temp.path()).expect("import legacy");
776
777 let records = query_all_key_records(&conn).expect("query");
778 let active_id = &records[0].key_id;
779
780 let err = revoke_key(&conn, active_id, false).expect_err("should require force");
781 assert!(err.to_string().contains("--force"));
782
783 revoke_key(&conn, active_id, true).expect("force revoke should succeed");
784
785 let records_after = query_all_key_records(&conn).expect("query after");
786 assert_eq!(records_after[0].state, KeyState::Revoked);
787 }
788
789 #[test]
790 fn full_rotation_lifecycle() {
791 let (temp, db_path) = setup_test_db();
792 let handle =
793 crate::secret_store_crypto::ensure_secret_key(temp.path(), &db_path).expect("ensure");
794 let enc = SecretEncryption::from_key(handle);
795
796 let spec = serde_json::json!({"data": {"API_KEY": "sk-test"}});
798 let cipher = enc
799 .encrypt_secret_store_spec("default", "test-secret", &spec)
800 .expect("encrypt");
801
802 let conn = crate::open_conn(&db_path).expect("open");
803 conn.execute(
804 "INSERT INTO resources (kind, project, name, api_version, spec_json, metadata_json, generation, created_at, updated_at)
805 VALUES ('SecretStore', 'default', 'test-secret', 'v2', ?1, '{}', 1, datetime('now'), datetime('now'))",
806 params![cipher],
807 ).expect("insert");
808
809 import_legacy_key_record(&conn, temp.path()).expect("import");
811 let (new_rec, old_rec) = begin_rotation(&conn, temp.path()).expect("begin rotation");
812
813 let old_key_path = resolve_key_file_path(temp.path(), &old_rec.file_path);
815 let new_key_path = resolve_key_file_path(temp.path(), &new_rec.file_path);
816 let old_handle =
817 crate::secret_store_crypto::load_key_file_as_handle(&old_key_path, &old_rec.key_id)
818 .expect("load old");
819 let new_handle =
820 crate::secret_store_crypto::load_key_file_as_handle(&new_key_path, &new_rec.key_id)
821 .expect("load new");
822
823 let report = re_encrypt_all_secrets(
824 &conn,
825 &SecretEncryption::from_key(old_handle),
826 &SecretEncryption::from_key(new_handle.clone()),
827 )
828 .expect("re-encrypt");
829 assert_eq!(report.resources_updated, 1);
830 assert!(report.errors.is_empty());
831
832 complete_rotation(&conn, &old_rec.key_id).expect("complete rotation");
834
835 let records = query_all_key_records(&conn).expect("query");
836 let old = records
837 .iter()
838 .find(|r| r.key_id == old_rec.key_id)
839 .expect("find old");
840 assert_eq!(old.state, KeyState::Retired);
841
842 let new_enc = SecretEncryption::from_key(new_handle);
844 let spec_json: String = conn
845 .query_row(
846 "SELECT spec_json FROM resources WHERE kind='SecretStore' AND name='test-secret'",
847 [],
848 |row| row.get(0),
849 )
850 .expect("load");
851 let decrypted = new_enc
852 .decrypt_secret_store_spec("default", "test-secret", &spec_json)
853 .expect("decrypt");
854 assert_eq!(decrypted, spec);
855 }
856}