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