Skip to main content

sysknife_daemon/
checkpoint_sink.rs

1//! External anchor sinks for signed audit checkpoints.
2//!
3//! A [`Checkpoint`] commits `(seq, chain_tip,
4//! timestamp)` with an Ed25519 signature. Anchoring those checkpoints to an
5//! **independent, append-only** store is what makes tail-truncation and
6//! rewrite of the local chain *detectable* by a host attacker who controls the
7//! primary database: they cannot reproduce a previously anchored signed tip
8//! (see [`verify_checkpoints`](crate::audit_chain::verify_checkpoints)).
9//!
10//! This module defines a small [`CheckpointSink`] interface with two backends:
11//!
12//! - [`PostgresCheckpointSink`] — writes checkpoints to an append-only
13//!   `audit_checkpoints` table on a separate Postgres database. INSERT-only by
14//!   construction (this code never issues UPDATE/DELETE). Operators should
15//!   additionally grant the SysKnife role only `INSERT`/`SELECT` on the table
16//!   and `REVOKE UPDATE, DELETE` so a stolen daemon credential cannot rewrite
17//!   the anchor either. Append-only permissions alone do not stop a DB
18//!   superuser; the *signature* is what makes tampering detectable.
19//! - [`InMemoryCheckpointSink`] — for tests and dry runs.
20//!
21//! Additional verifiable backends (immudb, WORM object storage, an RFC 3161
22//! timestamp authority) can implement the same trait.
23
24use 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/// An append-only sink that stores signed checkpoints and hands them back for
48/// verification. Implementations must never mutate or delete a stored
49/// checkpoint.
50#[async_trait]
51pub trait CheckpointSink: Send + Sync {
52    /// Append one signed checkpoint. Must not overwrite prior checkpoints.
53    async fn append(&self, checkpoint: &Checkpoint) -> Result<(), CheckpointSinkError>;
54
55    /// Load every stored checkpoint, ordered by `seq` ascending.
56    async fn load_all(&self) -> Result<Vec<Checkpoint>, CheckpointSinkError>;
57}
58
59/// In-memory checkpoint sink for tests and dry runs. Append-only.
60#[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/// Postgres append-only checkpoint sink. Anchors signed checkpoints to a
94/// separate database so a host attacker cannot silently rewrite or truncate
95/// the local chain without being detected on the next `verify`.
96#[derive(Debug)]
97pub struct PostgresCheckpointSink {
98    pool: PgPool,
99}
100
101impl PostgresCheckpointSink {
102    /// Connect to `url`, create the append-only `audit_checkpoints` table if it
103    /// does not exist, and return the sink.
104    pub async fn connect(url: &str) -> Result<Self, CheckpointSinkError> {
105        // Do not echo the URL/DSN into the error (it can carry credentials).
106        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        // Only CREATE when the table is absent. This lets a hardened,
122        // least-privilege role (INSERT/SELECT only, no DDL, per the module
123        // docs) connect to an already-provisioned table; the one-time CREATE
124        // is an admin/bootstrap step that a DDL-capable role performs once.
125        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// ---------------------------------------------------------------------------
191// Anchoring
192// ---------------------------------------------------------------------------
193
194/// Result of one anchoring attempt.
195///
196/// Every non-`Anchored` variant means the anchor did **not** advance, and the
197/// tamper-evidence window did not move forward. Callers must surface them;
198/// treating a failed anchor as routine is how this defence quietly stops
199/// working.
200#[derive(Debug, PartialEq, Eq)]
201pub enum AnchorOutcome {
202    /// A checkpoint was written and every anchored checkpoint still verifies.
203    Anchored { seq: u64, checkpoints_checked: u64 },
204    /// Nothing to anchor yet — the chain has no rows.
205    ChainEmpty,
206    /// The local chain does not verify, so anchoring was refused.
207    ChainBroken(crate::audit_chain::VerifyOutcome),
208    /// The write appeared to succeed but the checkpoint was absent on read-back.
209    ReadBackMissing,
210    /// A stored checkpoint no longer matches the local chain.
211    Inconsistent(crate::audit_chain::CheckpointOutcome),
212}
213
214/// Sign the current chain tip and anchor it to `sink`, then prove the anchor
215/// landed and still agrees with the local chain.
216///
217/// Shared by the `sysknife audit checkpoint` CLI command and the daemon's
218/// periodic anchor task so both enforce the same guarantees:
219///
220/// 1. **Refuse to anchor a broken chain.** Anchoring the tip of a tampered
221///    chain would launder the tamper into a signed checkpoint.
222/// 2. **Read back after writing.** A lagging replica or the wrong database
223///    would otherwise render as a reassuring "consistent (0 verified)".
224/// 3. **Re-verify every anchored checkpoint**, not just the new one.
225pub 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
261/// How often the daemon anchors a checkpoint when a sink is configured.
262///
263/// This bounds the tamper window: an attacker who compromises the host can
264/// rewrite history only back to the last anchor, so the interval is the
265/// exposure. Fifteen minutes keeps the write volume trivial (one row) while
266/// keeping that window short.
267pub const DEFAULT_ANCHOR_INTERVAL: Duration = Duration::from_secs(15 * 60);
268
269/// Periodically anchor the chain tip to `sink` for as long as the daemon runs.
270///
271/// Without this the checkpoint machinery is inert: `sign_checkpoint`,
272/// `verify_checkpoints` and the sinks all existed and were tested, but nothing
273/// in the daemon ever called them, so the tail-truncation defence the threat
274/// model relies on was only active if an operator happened to run
275/// `sysknife audit checkpoint` on a timer of their own.
276pub 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                // Everything below means the anchor did not advance. These are
307                // loud on purpose: a silent anchor failure looks exactly like a
308                // working one until the day someone needs the evidence.
309                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    /// Build a small intact chain the same way the daemon would.
348    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        // Anchor a checkpoint at the tip.
405        let cp = key.sign_checkpoint(5, &full[4].chain_hash, "2026-04-24T12:00:00Z");
406        sink.append(&cp).await.unwrap();
407
408        // Intact: loading the anchored checkpoints verifies against the full chain.
409        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        // Truncated: the local chain is cut to 3, but the anchored tip (seq=5)
418        // can no longer be reproduced -> detected.
419        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    /// Rebuild a chain from `rows`, re-signing every row with `key` so the
440    /// result verifies cleanly. Used to forge a tampered-but-valid tail — the
441    /// attack only a checkpoint can catch.
442    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        // Anchoring the tip of a broken chain would sign the tamper into the
496        // one record that is supposed to detect it.
497        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    /// A sink that accepts writes and then loses them. Models a lagging read
531    /// replica or a misconfigured second database.
532    #[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        // A sink that swallows writes must not read as a successful anchor:
553        // `verify_checkpoints` over zero checkpoints is trivially "consistent",
554        // which is exactly the reassuring-but-wrong result the read-back check
555        // exists to prevent.
556        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        // THE reason checkpoints exist. An attacker with root AND the signing
569        // key can rebuild history so the chain verifies perfectly — every
570        // existing "rewrite" test corrupts `chain_hash` with garbage that a
571        // plain signature check already rejects, so none of them exercise this.
572        //
573        // Here the tampered tail is re-signed with the real key, so
574        // `verify_chain` says Intact. Only the anchored tip catches it.
575        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        // The claim the module docs make about why anchoring is necessary,
608        // asserted directly: a truncated chain is internally consistent.
609        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}