Skip to main content

orchestrator_security/
secret_key_lifecycle.rs

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// ─── Key State Machine ───────────────────────────────────────────
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
13#[serde(rename_all = "snake_case")]
14/// Lifecycle states for SecretStore encryption keys.
15pub enum KeyState {
16    /// Active encryption key used for both encrypt and decrypt operations.
17    Active,
18    /// Legacy key retained only for decryption during or after rotation.
19    DecryptOnly,
20    /// Key revoked from further use because it should no longer decrypt data.
21    Revoked,
22    /// Key retired after all encrypted payloads were migrated away from it.
23    Retired,
24}
25
26impl KeyState {
27    /// Returns the stable persisted label for the state.
28    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    /// Parses a persisted key-state label.
38    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    /// Returns `true` when the state should never be used for encryption or decryption again.
49    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// ─── Key Record ──────────────────────────────────────────────────
61
62#[derive(Debug, Clone, Serialize, Deserialize)]
63/// Persisted metadata for one SecretStore key.
64pub struct KeyRecord {
65    /// Stable identifier of the key.
66    pub key_id: String,
67    /// Lifecycle state currently assigned to the key.
68    pub state: KeyState,
69    /// Fingerprint derived from the key material.
70    pub fingerprint: String,
71    /// Relative or absolute path to the key file.
72    pub file_path: String,
73    /// Timestamp when the key record was created.
74    pub created_at: String,
75    /// Timestamp when the key became active, if applicable.
76    pub activated_at: Option<String>,
77    /// Timestamp when the key left the active state during rotation.
78    pub rotated_out_at: Option<String>,
79    /// Timestamp when the key was fully retired.
80    pub retired_at: Option<String>,
81    /// Timestamp when the key was revoked.
82    pub revoked_at: Option<String>,
83}
84
85// ─── KeyRing ─────────────────────────────────────────────────────
86
87/// Loaded key material plus lifecycle metadata used by SecretStore operations.
88pub struct KeyRing {
89    records: Vec<KeyRecord>,
90    active_key: Option<SecretKeyHandle>,
91    decrypt_keys: HashMap<String, SecretKeyHandle>,
92}
93
94impl KeyRing {
95    /// Returns the active key used for new encryption operations.
96    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    /// Returns a decryption key for the requested key identifier.
105    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    /// Returns every persisted key record in load order.
114    pub fn all_records(&self) -> &[KeyRecord] {
115        &self.records
116    }
117
118    /// Returns the active key record, if any.
119    pub fn active_record(&self) -> Option<&KeyRecord> {
120        self.records.iter().find(|r| r.state == KeyState::Active)
121    }
122
123    /// Returns `true` when an active encryption key is available.
124    pub fn has_active_key(&self) -> bool {
125        self.active_key.is_some()
126    }
127
128    /// Returns all records currently in the decrypt-only state.
129    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    /// Iterates over key identifiers and handles that can decrypt existing payloads.
137    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
159// ─── Load KeyRing ────────────────────────────────────────────────
160
161/// Loads the SecretStore keyring from the lifecycle tables or legacy single-key storage.
162pub 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        // Pre-migration: fall back to legacy single-key loading
174        return load_keyring_legacy(data_dir, db_path);
175    }
176
177    let records = query_all_key_records(&conn)?;
178    if records.is_empty() {
179        // Table exists but empty — fall back to legacy
180        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
253// ─── DB queries ──────────────────────────────────────────────────
254
255/// Queries every stored SecretStore key record ordered by creation time.
256pub 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
309// ─── Key ID generation ───────────────────────────────────────────
310
311fn 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
317// ─── Rotation ────────────────────────────────────────────────────
318
319/// Begin key rotation: generate new key, set old active to decrypt_only.
320/// Returns (new_active_record, old_decrypt_only_record).
321pub 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    // Check for existing incomplete rotation
326    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    // Insert new active key
362    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    // Demote old active to decrypt_only
376    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    // Audit events
388    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// ─── Re-encryption ──────────────────────────────────────────────
427
428#[derive(Debug, Clone, Default)]
429/// Reports how many SecretStore payloads were re-encrypted during rotation recovery.
430pub struct ReEncryptionReport {
431    /// Number of current resources updated in the `resources` table.
432    pub resources_updated: usize,
433    /// Number of historical versions updated in `resource_versions`.
434    pub versions_updated: usize,
435    /// Per-resource errors encountered while re-encrypting payloads.
436    pub errors: Vec<String>,
437}
438
439/// Re-encrypt all SecretStore resources from old_encryption to new_encryption.
440/// Runs in a single transaction for atomicity.
441pub 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    // Re-encrypt resources table
453    {
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    // Re-encrypt resource_versions table
482    {
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
529// ─── Complete Rotation ───────────────────────────────────────────
530
531/// Verify no data references old key, then retire it.
532pub fn complete_rotation(conn: &Connection, old_key_id: &str) -> Result<()> {
533    let now = now_ts();
534
535    // Check that old key is decrypt_only
536    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    // Verify no resources still reference old key
550    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
584// ─── Resume Rotation ─────────────────────────────────────────────
585
586/// Resume an incomplete rotation: find decrypt_only key and re-encrypt remaining data.
587/// Resume an incomplete rotation: find decrypt_only key and re-encrypt remaining data.
588pub 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
621// ─── Revoke ──────────────────────────────────────────────────────
622
623/// Revokes a key and optionally allows revoking the currently active key.
624pub 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
665// ─── Migration helper: import legacy key ─────────────────────────
666
667/// Imports the legacy primary key file into the lifecycle table if present.
668pub 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        // Ensure legacy key exists
746        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 to DB
761        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        // Encrypt some data
797        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 and begin rotation
810        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        // Build encryptions for re-encryption
814        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
833        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        // Verify data is readable with new key
843        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}