Skip to main content

chio_store_sqlite/
execution_nonce_store.rs

1//! Phase 1.1: SQLite-backed `ExecutionNonceStore`.
2//!
3//! Durable replay-prevention for execution nonces so a kernel that
4//! crashes and restarts cannot be tricked into accepting a nonce that was
5//! already consumed by the previous process. Expiry is enforced by
6//! storing the nonce's `expires_at` alongside the consumed marker;
7//! `reserve` refuses to recycle a slot until the nonce is past its
8//! expiry.
9//!
10//! The schema is:
11//!
12//! ```sql
13//! CREATE TABLE chio_execution_nonces (
14//!     nonce_id    TEXT PRIMARY KEY,
15//!     consumed_at INTEGER NOT NULL,
16//!     expires_at  INTEGER NOT NULL
17//! );
18//! CREATE INDEX idx_chio_execution_nonces_expires_at
19//!     ON chio_execution_nonces(expires_at);
20//! ```
21
22use std::fs;
23use std::path::Path;
24use std::time::{SystemTime, UNIX_EPOCH};
25
26use chio_kernel::{ExecutionNonceStore, KernelError};
27use r2d2::Pool;
28use r2d2_sqlite::SqliteConnectionManager;
29use rusqlite::params;
30
31/// Default number of seconds a consumed-marker persists after its
32/// `expires_at` before the garbage collector reclaims the row. Keeps the
33/// table bounded without letting a replay slip through immediately after
34/// the nonce would have expired anyway.
35const RETENTION_GRACE_SECS: i64 = 60;
36
37/// Opaque error type returned by the SQLite nonce store.
38#[derive(Debug)]
39pub struct SqliteExecutionNonceStoreError(String);
40
41impl std::fmt::Display for SqliteExecutionNonceStoreError {
42    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
43        write!(f, "sqlite execution nonce store error: {}", self.0)
44    }
45}
46
47impl std::error::Error for SqliteExecutionNonceStoreError {}
48
49impl From<rusqlite::Error> for SqliteExecutionNonceStoreError {
50    fn from(e: rusqlite::Error) -> Self {
51        Self(e.to_string())
52    }
53}
54
55impl From<std::io::Error> for SqliteExecutionNonceStoreError {
56    fn from(e: std::io::Error) -> Self {
57        Self(e.to_string())
58    }
59}
60
61impl From<r2d2::Error> for SqliteExecutionNonceStoreError {
62    fn from(e: r2d2::Error) -> Self {
63        Self(e.to_string())
64    }
65}
66
67/// SQLite-backed replay-prevention store for execution nonces.
68pub struct SqliteExecutionNonceStore {
69    pool: Pool<SqliteConnectionManager>,
70}
71
72impl SqliteExecutionNonceStore {
73    /// Open the store at the given path. Creates the parent directory
74    /// if needed.
75    pub fn open(path: impl AsRef<Path>) -> Result<Self, SqliteExecutionNonceStoreError> {
76        let path = path.as_ref();
77        if let Some(parent) = path.parent() {
78            if !parent.as_os_str().is_empty() {
79                fs::create_dir_all(parent)?;
80            }
81        }
82        let manager = SqliteConnectionManager::file(path);
83        let pool = Pool::builder().max_size(8).build(manager)?;
84        let store = Self { pool };
85        store.run_migrations()?;
86        Ok(store)
87    }
88
89    /// Open an in-memory store for tests.
90    pub fn open_in_memory() -> Result<Self, SqliteExecutionNonceStoreError> {
91        let manager = SqliteConnectionManager::memory();
92        let pool = Pool::builder().max_size(1).build(manager)?;
93        let store = Self { pool };
94        store.run_migrations()?;
95        Ok(store)
96    }
97
98    fn run_migrations(&self) -> Result<(), SqliteExecutionNonceStoreError> {
99        let conn = self
100            .pool
101            .get()
102            .map_err(|e| SqliteExecutionNonceStoreError(format!("pool acquire: {e}")))?;
103        conn.execute_batch(
104            r#"
105            PRAGMA journal_mode = WAL;
106            PRAGMA synchronous = FULL;
107            PRAGMA busy_timeout = 5000;
108
109            CREATE TABLE IF NOT EXISTS chio_execution_nonces (
110                nonce_id    TEXT PRIMARY KEY,
111                consumed_at INTEGER NOT NULL,
112                expires_at  INTEGER NOT NULL
113            );
114
115            CREATE INDEX IF NOT EXISTS idx_chio_execution_nonces_expires_at
116                ON chio_execution_nonces(expires_at);
117            "#,
118        )?;
119        Ok(())
120    }
121
122    /// Reserve a nonce id. Shared code path for the trait impl and
123    /// tests -- takes an explicit `expires_at` for caller-controlled
124    /// retention (the trait method uses `now + RETENTION_GRACE_SECS`).
125    pub fn try_reserve(
126        &self,
127        nonce_id: &str,
128        now: i64,
129        expires_at: i64,
130    ) -> Result<bool, SqliteExecutionNonceStoreError> {
131        let mut conn = self
132            .pool
133            .get()
134            .map_err(|e| SqliteExecutionNonceStoreError(format!("pool acquire: {e}")))?;
135
136        let tx = conn.transaction()?;
137
138        // First, prune any rows that are past their `expires_at` so a
139        // long-lived kernel doesn't accumulate garbage. Keeping the
140        // prune here (rather than a background job) is safe because the
141        // query is indexed on expires_at.
142        tx.execute(
143            "DELETE FROM chio_execution_nonces WHERE expires_at <= ?1",
144            params![now],
145        )?;
146
147        // Then attempt the reservation. A conflicting row means the
148        // nonce was already consumed and is still within the retention
149        // window; return `false`.
150        let rows = tx.execute(
151            r#"
152            INSERT INTO chio_execution_nonces (nonce_id, consumed_at, expires_at)
153            VALUES (?1, ?2, ?3)
154            ON CONFLICT(nonce_id) DO NOTHING
155            "#,
156            params![nonce_id, now, expires_at],
157        )?;
158        tx.commit()?;
159        Ok(rows > 0)
160    }
161}
162
163fn now_secs() -> i64 {
164    i64::try_from(
165        SystemTime::now()
166            .duration_since(UNIX_EPOCH)
167            .map(|d| d.as_secs())
168            .unwrap_or(0),
169    )
170    .unwrap_or(i64::MAX)
171}
172
173impl ExecutionNonceStore for SqliteExecutionNonceStore {
174    fn reserve(&self, nonce_id: &str) -> Result<bool, KernelError> {
175        // Back-compat path: callers that do not know the nonce's signed
176        // expiry fall through to a 60s retention grace. This branch is
177        // wrong for `nonce_ttl_secs > 60` deployments -- it can prune a
178        // consumed marker while the nonce is still cryptographically
179        // valid and allow replay. New callers use `reserve_until` with
180        // the signed `expires_at` from the presented nonce.
181        let now = now_secs();
182        let expires_at = now.saturating_add(RETENTION_GRACE_SECS);
183        self.try_reserve(nonce_id, now, expires_at)
184            .map_err(|e| KernelError::Internal(format!("sqlite execution nonce store: {e}")))
185    }
186
187    fn reserve_until(&self, nonce_id: &str, nonce_expires_at: i64) -> Result<bool, KernelError> {
188        // Retain the consumed marker for the full signed validity window
189        // plus a small grace, so a pruner cannot reclaim the row while
190        // the nonce is still cryptographically valid. Take the max of
191        // `nonce_expires_at + RETENTION_GRACE_SECS` and
192        // `now + RETENTION_GRACE_SECS`, preserving the original grace
193        // for clock-skew safety.
194        let now = now_secs();
195        let retention = nonce_expires_at.saturating_add(RETENTION_GRACE_SECS);
196        let baseline = now.saturating_add(RETENTION_GRACE_SECS);
197        let expires_at = retention.max(baseline);
198        self.try_reserve(nonce_id, now, expires_at)
199            .map_err(|e| KernelError::Internal(format!("sqlite execution nonce store: {e}")))
200    }
201}
202
203#[cfg(test)]
204#[allow(clippy::expect_used, clippy::unwrap_used)]
205mod tests {
206    use super::*;
207
208    fn unique_db_path(prefix: &str) -> std::path::PathBuf {
209        let nonce = SystemTime::now()
210            .duration_since(UNIX_EPOCH)
211            .expect("time before epoch")
212            .as_nanos();
213        std::env::temp_dir().join(format!("{prefix}-{nonce}.sqlite3"))
214    }
215
216    #[test]
217    fn fresh_nonce_is_reserved() {
218        let store = SqliteExecutionNonceStore::open_in_memory().unwrap();
219        assert!(<SqliteExecutionNonceStore as ExecutionNonceStore>::reserve(&store, "a").unwrap());
220    }
221
222    #[test]
223    fn duplicate_nonce_is_rejected() {
224        let store = SqliteExecutionNonceStore::open_in_memory().unwrap();
225        assert!(store.try_reserve("a", 1_000, 1_100).unwrap());
226        assert!(!store.try_reserve("a", 1_001, 1_100).unwrap());
227    }
228
229    #[test]
230    fn expired_row_is_pruned_and_slot_reusable() {
231        let store = SqliteExecutionNonceStore::open_in_memory().unwrap();
232        assert!(store.try_reserve("a", 1_000, 1_030).unwrap());
233        // "Now" after expiry + retention: prune removes the row and the
234        // same id can be re-reserved (this is benign because verify_
235        // execution_nonce also checks the signed expires_at).
236        assert!(store.try_reserve("a", 2_000, 2_030).unwrap());
237    }
238
239    #[test]
240    fn persists_across_reopen() {
241        let path = unique_db_path("chio-exec-nonce");
242        {
243            let store = SqliteExecutionNonceStore::open(&path).unwrap();
244            assert!(store
245                .try_reserve("persistent-nonce", 1_000, 1_000_000_000)
246                .unwrap());
247        }
248        let reopened = SqliteExecutionNonceStore::open(&path).unwrap();
249        assert!(!reopened
250            .try_reserve("persistent-nonce", 1_001, 1_000_000_000)
251            .unwrap());
252        let _ = fs::remove_file(path);
253    }
254}