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, ChainIdentity, ChainRow, CheckpointOutcome,
340 CHAIN_VERSION_CURRENT,
341 };
342 use sysknife_types::RiskLevel;
343
344 fn key() -> AuditKey {
345 AuditKey::from_bytes(vec![0x42; 32])
346 }
347
348 pub(super) fn build_chain(key: &AuditKey, count: usize) -> Vec<ChainRow> {
350 let mut rows = Vec::with_capacity(count);
351 let mut prev = String::new();
352 for i in 0..count {
353 let seq = (i + 1) as u64;
354 let txid = format!("tx{i}");
355 let content = ChainContent {
356 seq,
357 key_id: "v1",
358 transaction_id: &txid,
359 request_id: "req",
360 request_hash: "hash",
361 action_name: "UpdateSystem",
362 risk_level: RiskLevel::High,
363 summary: "s",
364 approval_id: None,
365 warnings_json: "[]",
366 created_at: "2026-04-24T12:00:00Z",
367 identity: ChainIdentity::V2 {
368 caller_role: "dev",
369 event_tip: "",
370 },
371 };
372 let hash = key.chain_hash(&content, &prev);
373 rows.push(ChainRow {
374 seq,
375 key_id: "v1".to_string(),
376 transaction_id: txid,
377 request_id: "req".to_string(),
378 request_hash: "hash".to_string(),
379 action_name: "UpdateSystem".to_string(),
380 risk_level: RiskLevel::High,
381 summary: "s".to_string(),
382 approval_id: None,
383 warnings_json: "[]".to_string(),
384 created_at: "2026-04-24T12:00:00Z".to_string(),
385 prev_chain_hash: prev.clone(),
386 chain_hash: hash.clone(),
387 chain_version: CHAIN_VERSION_CURRENT,
388 caller_role: Some("dev".to_string()),
389 event_tip: Some(String::new()),
390 });
391 prev = hash;
392 }
393 rows
394 }
395
396 #[tokio::test]
397 async fn in_memory_append_and_load_round_trip() {
398 let key = key();
399 let rows = build_chain(&key, 3);
400 let sink = InMemoryCheckpointSink::new();
401 let cp = key.sign_checkpoint(3, &rows[2].chain_hash, "2026-04-24T12:00:00Z");
402 sink.append(&cp).await.unwrap();
403 let loaded = sink.load_all().await.unwrap();
404 assert_eq!(loaded, vec![cp]);
405 }
406
407 #[tokio::test]
408 async fn anchored_checkpoints_verify_and_catch_truncation() {
409 let key = key();
410 let full = build_chain(&key, 5);
411 let sink = InMemoryCheckpointSink::new();
412 let cp = key.sign_checkpoint(5, &full[4].chain_hash, "2026-04-24T12:00:00Z");
414 sink.append(&cp).await.unwrap();
415
416 let anchored = sink.load_all().await.unwrap();
418 assert_eq!(
419 verify_checkpoints(&key.verifying_key_hex(), &full, &anchored),
420 CheckpointOutcome::Consistent {
421 checkpoints_checked: 1
422 }
423 );
424
425 let truncated = &full[..3];
428 assert!(matches!(
429 verify_checkpoints(&key.verifying_key_hex(), truncated, &anchored),
430 CheckpointOutcome::Truncated {
431 checkpoint_seq: 5,
432 current_max_seq: 3
433 }
434 ));
435 }
436}
437
438#[cfg(test)]
439mod anchor_tests {
440 use super::*;
441 use crate::audit_chain::{AuditKey, ChainContent, ChainRow, CheckpointOutcome, VerifyOutcome};
442
443 fn key() -> AuditKey {
444 AuditKey::from_bytes(vec![0x42; 32])
445 }
446
447 fn reseal(key: &AuditKey, rows: &[ChainRow]) -> Vec<ChainRow> {
451 let mut out = Vec::with_capacity(rows.len());
452 let mut prev = String::new();
453 for row in rows {
454 let content = ChainContent {
455 seq: row.seq,
456 key_id: &row.key_id,
457 transaction_id: &row.transaction_id,
458 request_id: &row.request_id,
459 request_hash: &row.request_hash,
460 action_name: &row.action_name,
461 risk_level: row.risk_level,
462 summary: &row.summary,
463 approval_id: row.approval_id.as_deref(),
464 warnings_json: &row.warnings_json,
465 created_at: &row.created_at,
466 identity: row.identity().expect("fixture rows are well-formed"),
467 };
468 let hash = key.chain_hash(&content, &prev);
469 let mut cloned = row.clone();
470 cloned.prev_chain_hash = prev.clone();
471 cloned.chain_hash = hash.clone();
472 out.push(cloned);
473 prev = hash;
474 }
475 out
476 }
477
478 fn chain(key: &AuditKey, count: usize) -> Vec<ChainRow> {
479 super::tests::build_chain(key, count)
480 }
481
482 #[tokio::test]
483 async fn anchor_once_writes_and_verifies_the_tip() {
484 let key = key();
485 let rows = chain(&key, 3);
486 let sink = InMemoryCheckpointSink::new();
487
488 let outcome = anchor_once(&key, &rows, &sink, "2026-04-24T12:00:00Z")
489 .await
490 .unwrap();
491
492 assert_eq!(
493 outcome,
494 AnchorOutcome::Anchored {
495 seq: 3,
496 checkpoints_checked: 1
497 }
498 );
499 assert_eq!(sink.load_all().await.unwrap().len(), 1);
500 }
501
502 #[tokio::test]
503 async fn anchor_once_refuses_to_launder_a_tampered_chain() {
504 let key = key();
507 let mut rows = chain(&key, 3);
508 rows[1].summary = "tampered".to_string();
509 let sink = InMemoryCheckpointSink::new();
510
511 let outcome = anchor_once(&key, &rows, &sink, "2026-04-24T12:00:00Z")
512 .await
513 .unwrap();
514
515 assert!(
516 matches!(
517 outcome,
518 AnchorOutcome::ChainBroken(VerifyOutcome::Broken { .. })
519 ),
520 "expected a refusal, got {outcome:?}"
521 );
522 assert!(
523 sink.load_all().await.unwrap().is_empty(),
524 "nothing may be anchored when the chain does not verify"
525 );
526 }
527
528 #[tokio::test]
529 async fn anchor_once_reports_an_empty_chain_rather_than_anchoring_nothing() {
530 let key = key();
531 let sink = InMemoryCheckpointSink::new();
532 let outcome = anchor_once(&key, &[], &sink, "2026-04-24T12:00:00Z")
533 .await
534 .unwrap();
535 assert_eq!(outcome, AnchorOutcome::ChainEmpty);
536 assert!(sink.load_all().await.unwrap().is_empty());
537 }
538
539 #[derive(Default)]
542 struct BlackHoleSink;
543
544 #[async_trait]
545 impl CheckpointSink for BlackHoleSink {
546 async fn append(
547 &self,
548 _checkpoint: &crate::audit_chain::Checkpoint,
549 ) -> Result<(), CheckpointSinkError> {
550 Ok(())
551 }
552 async fn load_all(
553 &self,
554 ) -> Result<Vec<crate::audit_chain::Checkpoint>, CheckpointSinkError> {
555 Ok(Vec::new())
556 }
557 }
558
559 #[tokio::test]
560 async fn anchor_once_detects_a_write_that_did_not_land() {
561 let key = key();
566 let rows = chain(&key, 2);
567
568 let outcome = anchor_once(&key, &rows, &BlackHoleSink, "2026-04-24T12:00:00Z")
569 .await
570 .unwrap();
571
572 assert_eq!(outcome, AnchorOutcome::ReadBackMissing);
573 }
574
575 #[tokio::test]
576 async fn a_previously_anchored_checkpoint_catches_a_key_holder_rewrite() {
577 let key = key();
585 let original = chain(&key, 5);
586 let sink = InMemoryCheckpointSink::new();
587
588 anchor_once(&key, &original, &sink, "2026-04-24T12:00:00Z")
589 .await
590 .unwrap();
591 let anchored = sink.load_all().await.unwrap();
592
593 let mut tampered = original.clone();
594 tampered[3].summary = "quietly rewritten".to_string();
595 let tampered = reseal(&key, &tampered);
596
597 assert!(
598 matches!(
599 crate::audit_chain::verify_chain(&key, &tampered),
600 VerifyOutcome::Intact { .. }
601 ),
602 "the forged chain must verify — otherwise this test proves nothing \
603 beyond what a plain signature check already catches"
604 );
605
606 let outcome =
607 crate::audit_chain::verify_checkpoints(&key.verifying_key_hex(), &tampered, &anchored);
608 assert!(
609 matches!(outcome, CheckpointOutcome::TipMismatch { .. }),
610 "the anchored tip must expose the rewrite, got {outcome:?}"
611 );
612 }
613
614 #[tokio::test]
615 async fn verify_chain_alone_cannot_detect_tail_truncation() {
616 let key = key();
619 let full = chain(&key, 5);
620 let truncated = &full[..3];
621
622 assert!(
623 matches!(
624 crate::audit_chain::verify_chain(&key, truncated),
625 VerifyOutcome::Intact {
626 rows_checked: 3,
627 ..
628 }
629 ),
630 "a truncated chain still verifies on its own — this is the gap"
631 );
632
633 let sink = InMemoryCheckpointSink::new();
634 anchor_once(&key, &full, &sink, "2026-04-24T12:00:00Z")
635 .await
636 .unwrap();
637 let anchored = sink.load_all().await.unwrap();
638
639 assert!(
640 matches!(
641 crate::audit_chain::verify_checkpoints(
642 &key.verifying_key_hex(),
643 truncated,
644 &anchored
645 ),
646 CheckpointOutcome::Truncated { .. }
647 ),
648 "the anchor is what turns an undetectable truncation into a detected one"
649 );
650 }
651}