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