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(&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            // A revoked key should never decrypt — unless data still references it
225            // (e.g. rotation was interrupted by a crash before re-encryption completed).
226            // In that case, load it as decrypt-only so the daemon can start and the
227            // operator can run `secret key rotate --resume` to finish migration.
228            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
254/// Returns `true` if any SecretStore resource still references the given key_id
255/// in its encrypted payload (spec_json).
256fn 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
287// ─── DB queries ──────────────────────────────────────────────────
288
289/// Queries every stored SecretStore key record ordered by creation time.
290pub 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
343// ─── Key ID generation ───────────────────────────────────────────
344
345fn 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
351// ─── Rotation ────────────────────────────────────────────────────
352
353/// Begin key rotation: generate new key, set old active to decrypt_only.
354/// Returns (new_active_record, old_decrypt_only_record).
355pub 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    // Check for existing incomplete rotation
360    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    // Insert new active key
396    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    // Demote old active to decrypt_only
410    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    // Audit events
422    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// ─── Re-encryption ──────────────────────────────────────────────
461
462#[derive(Debug, Clone, Default)]
463/// Reports how many SecretStore payloads were re-encrypted during rotation recovery.
464pub struct ReEncryptionReport {
465    /// Number of current resources updated in the `resources` table.
466    pub resources_updated: usize,
467    /// Number of historical versions updated in `resource_versions`.
468    pub versions_updated: usize,
469    /// Per-resource errors encountered while re-encrypting payloads.
470    pub errors: Vec<String>,
471}
472
473/// Re-encrypt all SecretStore resources from old_encryption to new_encryption.
474/// Runs in a single transaction for atomicity.
475pub 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    // Re-encrypt resources table
487    {
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    // Re-encrypt resource_versions table
516    {
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
563// ─── Complete Rotation ───────────────────────────────────────────
564
565/// Verify no data references old key, then retire it.
566pub fn complete_rotation(conn: &Connection, old_key_id: &str) -> Result<()> {
567    let now = now_ts();
568
569    // Check that old key is decrypt_only
570    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    // Verify no resources still reference old key
584    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
618// ─── Resume Rotation ─────────────────────────────────────────────
619
620/// Resume an incomplete rotation: find decrypt_only key and re-encrypt remaining data.
621/// Resume an incomplete rotation: find decrypt_only key and re-encrypt remaining data.
622pub 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
655// ─── Bootstrap (emergency recovery) ─────────────────────────────
656
657/// Creates a fresh active key when no active key exists (all keys terminal).
658/// This is the recovery path from an all-keys-revoked/retired state.
659pub 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
725// ─── Revoke ──────────────────────────────────────────────────────
726
727/// Revokes a key and optionally allows revoking the currently active key.
728pub 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
779// ─── Migration helper: import legacy key ─────────────────────────
780
781/// Imports the legacy primary key file into the lifecycle table if present.
782pub 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        // Ensure legacy key exists
860        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 to DB
875        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        // Encrypt some data
911        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 and begin rotation
924        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        // Build encryptions for re-encryption
928        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
947        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        // Verify data is readable with new key
957        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        // Revoke the only active key to reach all-terminal state
980        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        // Confirm no active key
985        assert!(query_active_key_record(&conn).expect("query").is_none());
986
987        // Bootstrap should succeed
988        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        // Active key now exists
993        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        // Audit event recorded
998        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        // Active key exists — bootstrap should fail
1017        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        // Revoke without force — should mention "last active key" and "bootstrap"
1033        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        // Encrypt a SecretStore resource with the current key
1050        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        // Force-revoke the key (simulating interrupted rotation)
1061        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        // Create a new active key so the keyring has something
1066        bootstrap_key(&conn, temp.path()).expect("bootstrap");
1067
1068        // Build keyring — the revoked key should be loaded because data still references it
1069        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        // Force-revoke without any data referencing the key
1087        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}