1use async_trait::async_trait;
30use sqlx_core::row::Row;
31use sqlx_postgres::{PgConnectOptions, PgPool, PgPoolOptions};
32use std::str::FromStr;
33#[cfg(test)]
35use std::sync::Mutex;
36use std::time::Duration;
37
38use crate::audit_chain::Checkpoint;
39
40#[derive(Debug, thiserror::Error)]
41pub enum CheckpointSinkError {
42 #[error("checkpoint sink connection error: {0}")]
43 Connect(String),
44 #[error("checkpoint sink query error: {0}")]
45 Query(String),
46}
47
48impl From<sqlx_core::Error> for CheckpointSinkError {
49 fn from(e: sqlx_core::Error) -> Self {
50 CheckpointSinkError::Query(e.to_string())
51 }
52}
53
54#[async_trait]
58pub trait CheckpointSink: Send + Sync {
59 async fn append(&self, checkpoint: &Checkpoint) -> Result<(), CheckpointSinkError>;
61
62 async fn load_all(&self) -> Result<Vec<Checkpoint>, CheckpointSinkError>;
64}
65
66#[cfg(test)]
71#[derive(Debug, Default)]
72pub struct InMemoryCheckpointSink {
73 stored: Mutex<Vec<Checkpoint>>,
74}
75
76#[cfg(test)]
77impl InMemoryCheckpointSink {
78 pub fn new() -> Self {
79 Self::default()
80 }
81}
82
83#[cfg(test)]
84#[async_trait]
85impl CheckpointSink for InMemoryCheckpointSink {
86 async fn append(&self, checkpoint: &Checkpoint) -> Result<(), CheckpointSinkError> {
87 let mut stored = self
88 .stored
89 .lock()
90 .map_err(|e| CheckpointSinkError::Query(format!("lock poisoned: {e}")))?;
91 stored.push(checkpoint.clone());
92 Ok(())
93 }
94
95 async fn load_all(&self) -> Result<Vec<Checkpoint>, CheckpointSinkError> {
96 let stored = self
97 .stored
98 .lock()
99 .map_err(|e| CheckpointSinkError::Query(format!("lock poisoned: {e}")))?;
100 let mut out = stored.clone();
101 out.sort_by_key(|c| c.seq);
102 Ok(out)
103 }
104}
105
106#[derive(Debug)]
110pub struct PostgresCheckpointSink {
111 pool: PgPool,
112}
113
114impl PostgresCheckpointSink {
115 pub async fn connect(url: &str) -> Result<Self, CheckpointSinkError> {
118 let opts = PgConnectOptions::from_str(url).map_err(|_| {
120 CheckpointSinkError::Connect("invalid checkpoint database URL".to_string())
121 })?;
122 let pool = PgPoolOptions::new()
123 .max_connections(4)
124 .acquire_timeout(Duration::from_secs(10))
125 .connect_with(opts)
126 .await
127 .map_err(|e| CheckpointSinkError::Connect(e.to_string()))?;
128 let sink = Self { pool };
129 sink.initialize().await?;
130 Ok(sink)
131 }
132
133 async fn initialize(&self) -> Result<(), CheckpointSinkError> {
134 let row = sqlx_core::query::query(
139 "SELECT to_regclass('audit_checkpoints') IS NOT NULL AS present",
140 )
141 .fetch_one(&self.pool)
142 .await?;
143 let present: bool = row.try_get("present")?;
144 if present {
145 return Ok(());
146 }
147 sqlx_core::query::query(
148 "CREATE TABLE IF NOT EXISTS audit_checkpoints (\
149 seq BIGINT NOT NULL, \
150 chain_tip TEXT NOT NULL, \
151 created_at TEXT NOT NULL, \
152 signature TEXT NOT NULL\
153 )",
154 )
155 .execute(&self.pool)
156 .await?;
157 Ok(())
158 }
159}
160
161#[async_trait]
162impl CheckpointSink for PostgresCheckpointSink {
163 async fn append(&self, checkpoint: &Checkpoint) -> Result<(), CheckpointSinkError> {
164 sqlx_core::query::query(
165 "INSERT INTO audit_checkpoints (seq, chain_tip, created_at, signature) \
166 VALUES ($1, $2, $3, $4)",
167 )
168 .bind(i64::try_from(checkpoint.seq).map_err(|_| {
169 CheckpointSinkError::Query("checkpoint seq exceeds i64 range".to_string())
170 })?)
171 .bind(&checkpoint.chain_tip)
172 .bind(&checkpoint.created_at)
173 .bind(&checkpoint.signature)
174 .execute(&self.pool)
175 .await?;
176 Ok(())
177 }
178
179 async fn load_all(&self) -> Result<Vec<Checkpoint>, CheckpointSinkError> {
180 let rows = sqlx_core::query::query(
181 "SELECT seq, chain_tip, created_at, signature \
182 FROM audit_checkpoints ORDER BY seq ASC",
183 )
184 .fetch_all(&self.pool)
185 .await?;
186
187 let mut out = Vec::with_capacity(rows.len());
188 for row in rows {
189 let seq: i64 = row.try_get("seq")?;
190 out.push(Checkpoint {
191 seq: u64::try_from(seq).map_err(|e| {
192 CheckpointSinkError::Query(format!("negative checkpoint seq: {e}"))
193 })?,
194 chain_tip: row.try_get("chain_tip")?,
195 created_at: row.try_get("created_at")?,
196 signature: row.try_get("signature")?,
197 });
198 }
199 Ok(out)
200 }
201}
202
203#[derive(Debug, PartialEq, Eq)]
214pub enum AnchorOutcome {
215 Anchored { seq: u64, checkpoints_checked: u64 },
217 ChainEmpty,
219 ChainBroken(crate::audit_chain::VerifyOutcome),
221 ReadBackMissing,
223 Inconsistent(crate::audit_chain::CheckpointOutcome),
225}
226
227pub async fn anchor_once(
239 key: &crate::audit_chain::AuditKey,
240 rows: &[crate::audit_chain::ChainRow],
241 sink: &dyn CheckpointSink,
242 created_at: &str,
243) -> Result<AnchorOutcome, CheckpointSinkError> {
244 use crate::audit_chain::{verify_chain, verify_checkpoints, CheckpointOutcome, VerifyOutcome};
245
246 let Some(tip) = rows.last() else {
247 return Ok(AnchorOutcome::ChainEmpty);
248 };
249
250 match verify_chain(key, rows) {
251 VerifyOutcome::Intact { .. } => {}
252 broken => return Ok(AnchorOutcome::ChainBroken(broken)),
253 }
254
255 let checkpoint = key.sign_checkpoint(tip.seq, &tip.chain_hash, created_at);
256 sink.append(&checkpoint).await?;
257
258 let anchored = sink.load_all().await?;
259 if !anchored.contains(&checkpoint) {
260 return Ok(AnchorOutcome::ReadBackMissing);
261 }
262
263 match verify_checkpoints(&key.verifying_key_hex(), rows, &anchored) {
264 CheckpointOutcome::Consistent {
265 checkpoints_checked,
266 } => Ok(AnchorOutcome::Anchored {
267 seq: checkpoint.seq,
268 checkpoints_checked,
269 }),
270 other => Ok(AnchorOutcome::Inconsistent(other)),
271 }
272}
273
274pub const DEFAULT_ANCHOR_INTERVAL: Duration = Duration::from_secs(15 * 60);
281
282pub fn spawn_periodic_anchor(
290 audit: std::sync::Arc<dyn crate::store::AuditStore>,
291 key: std::sync::Arc<crate::audit_chain::AuditKey>,
292 sink: std::sync::Arc<dyn CheckpointSink>,
293 interval: Duration,
294) -> tokio::task::JoinHandle<()> {
295 tokio::spawn(async move {
296 loop {
297 tokio::time::sleep(interval).await;
298
299 let rows = match audit.fetch_chain_rows().await {
300 Ok(rows) => rows,
301 Err(e) => {
302 eprintln!("[sysknife-daemon] checkpoint anchor: reading chain failed: {e}");
303 continue;
304 }
305 };
306
307 let created_at = chrono::Utc::now().to_rfc3339();
308 match anchor_once(&key, &rows, sink.as_ref(), &created_at).await {
309 Ok(AnchorOutcome::Anchored {
310 seq,
311 checkpoints_checked,
312 }) => {
313 eprintln!(
314 "[sysknife-daemon] anchored checkpoint seq={seq} \
315 ({checkpoints_checked} verified)"
316 );
317 }
318 Ok(AnchorOutcome::ChainEmpty) => {}
319 Ok(AnchorOutcome::ChainBroken(outcome)) => {
323 eprintln!(
324 "[sysknife-daemon] checkpoint anchor REFUSED: the local audit chain \
325 does not verify ({outcome:?}); not anchoring a tampered chain"
326 );
327 }
328 Ok(AnchorOutcome::ReadBackMissing) => {
329 eprintln!(
330 "[sysknife-daemon] checkpoint anchor FAILED: the checkpoint was absent \
331 on read-back; the sink may be a lagging replica or a different database"
332 );
333 }
334 Ok(AnchorOutcome::Inconsistent(outcome)) => {
335 eprintln!(
336 "[sysknife-daemon] checkpoint verification FAILED: {outcome:?} — \
337 the local chain disagrees with a previously anchored checkpoint"
338 );
339 }
340 Err(e) => {
341 eprintln!("[sysknife-daemon] checkpoint anchor: sink error: {e}");
342 }
343 }
344 }
345 })
346}
347
348#[cfg(test)]
349mod tests {
350 use super::*;
351 use crate::audit_chain::{
352 verify_checkpoints, AuditKey, ChainContent, ChainIdentity, ChainRow, CheckpointOutcome,
353 CHAIN_VERSION_CURRENT,
354 };
355 use sysknife_types::RiskLevel;
356
357 fn key() -> AuditKey {
358 AuditKey::from_bytes(vec![0x42; 32])
359 }
360
361 pub(super) fn build_chain(key: &AuditKey, count: usize) -> Vec<ChainRow> {
363 let mut rows = Vec::with_capacity(count);
364 let mut prev = String::new();
365 for i in 0..count {
366 let seq = (i + 1) as u64;
367 let txid = format!("tx{i}");
368 let content = ChainContent {
369 seq,
370 key_id: "v1",
371 transaction_id: &txid,
372 request_id: "req",
373 request_hash: "hash",
374 action_name: "UpdateSystem",
375 risk_level: RiskLevel::High,
376 summary: "s",
377 approval_id: None,
378 warnings_json: "[]",
379 created_at: "2026-04-24T12:00:00Z",
380 identity: ChainIdentity::V3 {
381 caller_role: "dev",
382 event_tip: "",
383 caller_principal: "uid:1000",
384 },
385 };
386 let hash = key.chain_hash(&content, &prev);
387 rows.push(ChainRow {
388 seq,
389 key_id: "v1".to_string(),
390 transaction_id: txid,
391 request_id: "req".to_string(),
392 request_hash: "hash".to_string(),
393 action_name: "UpdateSystem".to_string(),
394 risk_level: RiskLevel::High,
395 summary: "s".to_string(),
396 approval_id: None,
397 warnings_json: "[]".to_string(),
398 created_at: "2026-04-24T12:00:00Z".to_string(),
399 prev_chain_hash: prev.clone(),
400 chain_hash: hash.clone(),
401 chain_version: CHAIN_VERSION_CURRENT,
402 caller_role: Some("dev".to_string()),
403 event_tip: Some(String::new()),
404 caller_principal: Some("uid:1000".to_string()),
405 });
406 prev = hash;
407 }
408 rows
409 }
410
411 #[tokio::test]
412 async fn in_memory_append_and_load_round_trip() {
413 let key = key();
414 let rows = build_chain(&key, 3);
415 let sink = InMemoryCheckpointSink::new();
416 let cp = key.sign_checkpoint(3, &rows[2].chain_hash, "2026-04-24T12:00:00Z");
417 sink.append(&cp).await.unwrap();
418 let loaded = sink.load_all().await.unwrap();
419 assert_eq!(loaded, vec![cp]);
420 }
421
422 #[tokio::test]
423 async fn anchored_checkpoints_verify_and_catch_truncation() {
424 let key = key();
425 let full = build_chain(&key, 5);
426 let sink = InMemoryCheckpointSink::new();
427 let cp = key.sign_checkpoint(5, &full[4].chain_hash, "2026-04-24T12:00:00Z");
429 sink.append(&cp).await.unwrap();
430
431 let anchored = sink.load_all().await.unwrap();
433 assert_eq!(
434 verify_checkpoints(&key.verifying_key_hex(), &full, &anchored),
435 CheckpointOutcome::Consistent {
436 checkpoints_checked: 1
437 }
438 );
439
440 let truncated = &full[..3];
443 assert!(matches!(
444 verify_checkpoints(&key.verifying_key_hex(), truncated, &anchored),
445 CheckpointOutcome::Truncated {
446 checkpoint_seq: 5,
447 current_max_seq: 3
448 }
449 ));
450 }
451}
452
453#[cfg(test)]
454mod anchor_tests {
455 use super::*;
456 use crate::audit_chain::{AuditKey, ChainContent, ChainRow, CheckpointOutcome, VerifyOutcome};
457
458 fn key() -> AuditKey {
459 AuditKey::from_bytes(vec![0x42; 32])
460 }
461
462 fn reseal(key: &AuditKey, rows: &[ChainRow]) -> Vec<ChainRow> {
466 let mut out = Vec::with_capacity(rows.len());
467 let mut prev = String::new();
468 for row in rows {
469 let content = ChainContent {
470 seq: row.seq,
471 key_id: &row.key_id,
472 transaction_id: &row.transaction_id,
473 request_id: &row.request_id,
474 request_hash: &row.request_hash,
475 action_name: &row.action_name,
476 risk_level: row.risk_level,
477 summary: &row.summary,
478 approval_id: row.approval_id.as_deref(),
479 warnings_json: &row.warnings_json,
480 created_at: &row.created_at,
481 identity: row.identity().expect("fixture rows are well-formed"),
482 };
483 let hash = key.chain_hash(&content, &prev);
484 let mut cloned = row.clone();
485 cloned.prev_chain_hash = prev.clone();
486 cloned.chain_hash = hash.clone();
487 out.push(cloned);
488 prev = hash;
489 }
490 out
491 }
492
493 fn chain(key: &AuditKey, count: usize) -> Vec<ChainRow> {
494 super::tests::build_chain(key, count)
495 }
496
497 #[tokio::test]
498 async fn anchor_once_writes_and_verifies_the_tip() {
499 let key = key();
500 let rows = chain(&key, 3);
501 let sink = InMemoryCheckpointSink::new();
502
503 let outcome = anchor_once(&key, &rows, &sink, "2026-04-24T12:00:00Z")
504 .await
505 .unwrap();
506
507 assert_eq!(
508 outcome,
509 AnchorOutcome::Anchored {
510 seq: 3,
511 checkpoints_checked: 1
512 }
513 );
514 assert_eq!(sink.load_all().await.unwrap().len(), 1);
515 }
516
517 #[tokio::test]
518 async fn anchor_once_refuses_to_launder_a_tampered_chain() {
519 let key = key();
522 let mut rows = chain(&key, 3);
523 rows[1].summary = "tampered".to_string();
524 let sink = InMemoryCheckpointSink::new();
525
526 let outcome = anchor_once(&key, &rows, &sink, "2026-04-24T12:00:00Z")
527 .await
528 .unwrap();
529
530 assert!(
531 matches!(
532 outcome,
533 AnchorOutcome::ChainBroken(VerifyOutcome::Broken { .. })
534 ),
535 "expected a refusal, got {outcome:?}"
536 );
537 assert!(
538 sink.load_all().await.unwrap().is_empty(),
539 "nothing may be anchored when the chain does not verify"
540 );
541 }
542
543 #[tokio::test]
544 async fn anchor_once_reports_an_empty_chain_rather_than_anchoring_nothing() {
545 let key = key();
546 let sink = InMemoryCheckpointSink::new();
547 let outcome = anchor_once(&key, &[], &sink, "2026-04-24T12:00:00Z")
548 .await
549 .unwrap();
550 assert_eq!(outcome, AnchorOutcome::ChainEmpty);
551 assert!(sink.load_all().await.unwrap().is_empty());
552 }
553
554 #[derive(Default)]
557 struct BlackHoleSink;
558
559 #[async_trait]
560 impl CheckpointSink for BlackHoleSink {
561 async fn append(
562 &self,
563 _checkpoint: &crate::audit_chain::Checkpoint,
564 ) -> Result<(), CheckpointSinkError> {
565 Ok(())
566 }
567 async fn load_all(
568 &self,
569 ) -> Result<Vec<crate::audit_chain::Checkpoint>, CheckpointSinkError> {
570 Ok(Vec::new())
571 }
572 }
573
574 #[tokio::test]
575 async fn anchor_once_detects_a_write_that_did_not_land() {
576 let key = key();
581 let rows = chain(&key, 2);
582
583 let outcome = anchor_once(&key, &rows, &BlackHoleSink, "2026-04-24T12:00:00Z")
584 .await
585 .unwrap();
586
587 assert_eq!(outcome, AnchorOutcome::ReadBackMissing);
588 }
589
590 #[tokio::test]
591 async fn a_previously_anchored_checkpoint_catches_a_key_holder_rewrite() {
592 let key = key();
600 let original = chain(&key, 5);
601 let sink = InMemoryCheckpointSink::new();
602
603 anchor_once(&key, &original, &sink, "2026-04-24T12:00:00Z")
604 .await
605 .unwrap();
606 let anchored = sink.load_all().await.unwrap();
607
608 let mut tampered = original.clone();
609 tampered[3].summary = "quietly rewritten".to_string();
610 let tampered = reseal(&key, &tampered);
611
612 assert!(
613 matches!(
614 crate::audit_chain::verify_chain(&key, &tampered),
615 VerifyOutcome::Intact { .. }
616 ),
617 "the forged chain must verify — otherwise this test proves nothing \
618 beyond what a plain signature check already catches"
619 );
620
621 let outcome =
622 crate::audit_chain::verify_checkpoints(&key.verifying_key_hex(), &tampered, &anchored);
623 assert!(
624 matches!(outcome, CheckpointOutcome::TipMismatch { .. }),
625 "the anchored tip must expose the rewrite, got {outcome:?}"
626 );
627 }
628
629 #[tokio::test]
630 async fn verify_chain_alone_cannot_detect_tail_truncation() {
631 let key = key();
634 let full = chain(&key, 5);
635 let truncated = &full[..3];
636
637 assert!(
638 matches!(
639 crate::audit_chain::verify_chain(&key, truncated),
640 VerifyOutcome::Intact {
641 rows_checked: 3,
642 ..
643 }
644 ),
645 "a truncated chain still verifies on its own — this is the gap"
646 );
647
648 let sink = InMemoryCheckpointSink::new();
649 anchor_once(&key, &full, &sink, "2026-04-24T12:00:00Z")
650 .await
651 .unwrap();
652 let anchored = sink.load_all().await.unwrap();
653
654 assert!(
655 matches!(
656 crate::audit_chain::verify_checkpoints(
657 &key.verifying_key_hex(),
658 truncated,
659 &anchored
660 ),
661 CheckpointOutcome::Truncated { .. }
662 ),
663 "the anchor is what turns an undetectable truncation into a detected one"
664 );
665 }
666}