Skip to main content

chio_store_sqlite/
encrypted_blob.rs

1//! Tenant-scoped encrypted BLOB persistence for tee capture payloads.
2//!
3//! The tee stores redacted request and response bodies as opaque
4//! binary payloads. This module keeps those payloads encrypted at rest
5//! with a tenant-provided 32-byte key and a per-blob ChaCha20-Poly1305
6//! nonce. Authentication failures on read are surfaced as errors and do
7//! not return plaintext.
8
9use std::fs;
10use std::path::Path;
11use std::time::{SystemTime, UNIX_EPOCH};
12
13use chacha20poly1305::aead::rand_core::RngCore;
14use chacha20poly1305::aead::{Aead, KeyInit, OsRng, Payload};
15use chacha20poly1305::{ChaCha20Poly1305, Key, Nonce};
16use r2d2::Pool;
17use r2d2_sqlite::SqliteConnectionManager;
18use rusqlite::{params, OptionalExtension};
19use uuid::Uuid;
20use zeroize::{Zeroize, ZeroizeOnDrop};
21
22/// Tenant identifier used to isolate encrypted BLOB rows.
23#[derive(Clone, Debug, PartialEq, Eq, Hash)]
24pub struct TenantId(String);
25
26impl TenantId {
27    /// Construct a tenant id from a string-like value.
28    #[must_use]
29    pub fn new(value: impl Into<String>) -> Self {
30        Self(value.into())
31    }
32
33    /// Borrow the tenant id as stored in SQLite.
34    #[must_use]
35    pub fn as_str(&self) -> &str {
36        &self.0
37    }
38}
39
40impl From<&str> for TenantId {
41    fn from(value: &str) -> Self {
42        Self::new(value)
43    }
44}
45
46impl From<String> for TenantId {
47    fn from(value: String) -> Self {
48        Self::new(value)
49    }
50}
51
52/// Tenant-scoped 256-bit AEAD key.
53#[derive(Zeroize, ZeroizeOnDrop)]
54pub struct TenantKey([u8; 32]);
55
56impl TenantKey {
57    /// Construct from exactly 32 bytes.
58    #[must_use]
59    pub fn from_bytes(bytes: [u8; 32]) -> Self {
60        Self(bytes)
61    }
62
63    /// Borrow key bytes for callers that already own secure key custody.
64    #[must_use]
65    pub fn as_bytes(&self) -> &[u8; 32] {
66        &self.0
67    }
68}
69
70impl std::fmt::Debug for TenantKey {
71    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
72        f.write_str("TenantKey(<redacted>)")
73    }
74}
75
76/// Ciphertext plus the unique nonce required for decryption.
77#[derive(Clone, PartialEq, Eq)]
78pub struct EncryptedBlob {
79    /// ChaCha20-Poly1305 ciphertext and authentication tag.
80    pub ciphertext: Vec<u8>,
81    /// 96-bit nonce generated per encrypted blob.
82    pub nonce: [u8; 12],
83}
84
85struct StoredEncryptedBlob {
86    blob: EncryptedBlob,
87    blob_id: String,
88    tenant_id: TenantId,
89    created_at: i64,
90}
91
92impl std::fmt::Debug for EncryptedBlob {
93    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
94        f.debug_struct("EncryptedBlob")
95            .field("ciphertext_len", &self.ciphertext.len())
96            .field("nonce", &self.nonce)
97            .finish()
98    }
99}
100
101/// Opaque handle returned after a blob is written.
102#[derive(Clone, Debug, PartialEq, Eq, Hash)]
103pub struct BlobHandle {
104    blob_id: String,
105    tenant_id: TenantId,
106}
107
108impl BlobHandle {
109    fn new(blob_id: String, tenant_id: TenantId) -> Self {
110        Self { blob_id, tenant_id }
111    }
112
113    /// Stable blob id generated by the store.
114    #[must_use]
115    pub fn blob_id(&self) -> &str {
116        &self.blob_id
117    }
118
119    /// Tenant scope bound into this handle.
120    #[must_use]
121    pub fn tenant_id(&self) -> &TenantId {
122        &self.tenant_id
123    }
124}
125
126/// Decryption failure. Authentication failure is intentionally coarse so
127/// callers cannot distinguish a wrong key from ciphertext tampering.
128#[derive(Debug, Clone, PartialEq, Eq)]
129pub enum DecryptError {
130    /// AEAD authentication failed.
131    AuthenticationFailed,
132}
133
134impl std::fmt::Display for DecryptError {
135    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
136        match self {
137            Self::AuthenticationFailed => f.write_str("blob authentication failed"),
138        }
139    }
140}
141
142impl std::error::Error for DecryptError {}
143
144/// Errors returned by SQLite encrypted BLOB persistence.
145#[derive(Debug)]
146pub enum BlobStoreError {
147    /// SQLite connection pool acquisition failed.
148    Pool(String),
149    /// SQLite returned an error.
150    Sqlite(String),
151    /// Filesystem operation failed while opening a store.
152    Io(String),
153    /// Tenant ids must be present before a blob can be persisted.
154    EmptyTenantId,
155    /// Tenant ids must be exact storage scope keys.
156    InvalidTenantId,
157    /// Stored nonce length was not the required 12 bytes.
158    InvalidNonceLength(usize),
159    /// The handle did not identify a row in its tenant scope.
160    NotFound,
161    /// AEAD authentication failed.
162    Decrypt(DecryptError),
163}
164
165impl std::fmt::Display for BlobStoreError {
166    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
167        match self {
168            Self::Pool(error) => write!(f, "sqlite encrypted blob pool error: {error}"),
169            Self::Sqlite(error) => write!(f, "sqlite encrypted blob error: {error}"),
170            Self::Io(error) => write!(f, "sqlite encrypted blob io error: {error}"),
171            Self::EmptyTenantId => f.write_str("tenant id must not be empty"),
172            Self::InvalidTenantId => {
173                f.write_str("tenant id must be unpadded and contain no control characters")
174            }
175            Self::InvalidNonceLength(len) => {
176                write!(f, "encrypted blob nonce must be 12 bytes, got {len}")
177            }
178            Self::NotFound => f.write_str("encrypted blob handle not found"),
179            Self::Decrypt(error) => write!(f, "encrypted blob decrypt error: {error}"),
180        }
181    }
182}
183
184impl std::error::Error for BlobStoreError {}
185
186impl From<rusqlite::Error> for BlobStoreError {
187    fn from(error: rusqlite::Error) -> Self {
188        Self::Sqlite(error.to_string())
189    }
190}
191
192impl From<r2d2::Error> for BlobStoreError {
193    fn from(error: r2d2::Error) -> Self {
194        Self::Pool(error.to_string())
195    }
196}
197
198impl From<std::io::Error> for BlobStoreError {
199    fn from(error: std::io::Error) -> Self {
200        Self::Io(error.to_string())
201    }
202}
203
204impl From<DecryptError> for BlobStoreError {
205    fn from(error: DecryptError) -> Self {
206        Self::Decrypt(error)
207    }
208}
209
210/// Encryption failure surfaced without panicking in public helpers.
211#[derive(Debug, Clone, PartialEq, Eq)]
212pub enum EncryptError {
213    /// AEAD encryption failed.
214    EncryptionFailed,
215}
216
217impl std::fmt::Display for EncryptError {
218    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
219        match self {
220            Self::EncryptionFailed => f.write_str("blob encryption failed"),
221        }
222    }
223}
224
225impl std::error::Error for EncryptError {}
226
227/// Encrypt `plaintext` with the tenant key.
228pub fn encrypt_blob(
229    tenant_key: &TenantKey,
230    plaintext: &[u8],
231) -> Result<EncryptedBlob, EncryptError> {
232    try_encrypt_blob(tenant_key, plaintext).map_err(|_| EncryptError::EncryptionFailed)
233}
234
235/// Decrypt `blob` with the tenant key.
236pub fn decrypt_blob(tenant_key: &TenantKey, blob: &EncryptedBlob) -> Result<Vec<u8>, DecryptError> {
237    let cipher = cipher_for_key(tenant_key);
238    cipher
239        .decrypt(Nonce::from_slice(&blob.nonce), blob.ciphertext.as_ref())
240        .map_err(|_| DecryptError::AuthenticationFailed)
241}
242
243pub(crate) fn decrypt_blob_with_aad(
244    tenant_key: &TenantKey,
245    blob: &EncryptedBlob,
246    aad: &[u8],
247) -> Result<Vec<u8>, DecryptError> {
248    let cipher = cipher_for_key(tenant_key);
249    cipher
250        .decrypt(
251            Nonce::from_slice(&blob.nonce),
252            Payload {
253                msg: blob.ciphertext.as_ref(),
254                aad,
255            },
256        )
257        .map_err(|_| DecryptError::AuthenticationFailed)
258}
259
260fn try_encrypt_blob(tenant_key: &TenantKey, plaintext: &[u8]) -> Result<EncryptedBlob, ()> {
261    try_encrypt_blob_with_aad(tenant_key, plaintext, &[])
262}
263
264pub(crate) fn try_encrypt_blob_with_aad(
265    tenant_key: &TenantKey,
266    plaintext: &[u8],
267    aad: &[u8],
268) -> Result<EncryptedBlob, ()> {
269    let cipher = cipher_for_key(tenant_key);
270    let mut nonce = [0_u8; 12];
271    OsRng.fill_bytes(&mut nonce);
272    let ciphertext = cipher
273        .encrypt(
274            Nonce::from_slice(&nonce),
275            Payload {
276                msg: plaintext,
277                aad,
278            },
279        )
280        .map_err(|_| ())?;
281    Ok(EncryptedBlob { ciphertext, nonce })
282}
283
284fn cipher_for_key(tenant_key: &TenantKey) -> ChaCha20Poly1305 {
285    ChaCha20Poly1305::new(Key::from_slice(tenant_key.as_bytes()))
286}
287
288/// SQLite-backed encrypted BLOB store.
289pub struct SqliteEncryptedBlobStore {
290    pool: Pool<SqliteConnectionManager>,
291}
292
293/// Encrypted-blob-store schema revision. Bump on every schema-affecting change.
294const ENCRYPTED_BLOB_STORE_SUPPORTED_SCHEMA_VERSION: i32 = 0;
295/// Stable key under which this store records its schema revision in the shared
296/// keyed metadata table, distinct from any co-located store's key.
297const ENCRYPTED_BLOB_STORE_SCHEMA_KEY: &str = "encrypted_blob";
298/// Tables shipped before schema stamping existed, used to adopt a pre-stamping
299/// encrypted-blob database rather than reject it as foreign.
300const ENCRYPTED_BLOB_STORE_LEGACY_ANCHOR_TABLES: &[&str] = &["chio_encrypted_blobs"];
301
302impl SqliteEncryptedBlobStore {
303    /// Open the store at `path`, creating parent directories if needed.
304    pub fn open(path: impl AsRef<Path>) -> Result<Self, BlobStoreError> {
305        let path = path.as_ref();
306        // Resolve any `file:` URI to its on-disk parent before creating it, so a
307        // URI-configured store creates the real backing directory rather than a
308        // bogus scheme-prefixed one.
309        if let Some(parent) = crate::sqlite_parent_dir_to_create(path) {
310            fs::create_dir_all(&parent)?;
311        }
312        let manager = SqliteConnectionManager::file(path);
313        let pool = Pool::builder().max_size(8).build(manager)?;
314        let store = Self { pool };
315        store.run_migrations()?;
316        Ok(store)
317    }
318
319    /// Open an in-memory store for tests.
320    pub fn open_in_memory() -> Result<Self, BlobStoreError> {
321        let manager = SqliteConnectionManager::memory();
322        let pool = Pool::builder().max_size(1).build(manager)?;
323        let store = Self { pool };
324        store.run_migrations()?;
325        Ok(store)
326    }
327
328    fn run_migrations(&self) -> Result<(), BlobStoreError> {
329        let conn = self.pool.get()?;
330        crate::check_schema_version(
331            &conn,
332            ENCRYPTED_BLOB_STORE_SCHEMA_KEY,
333            ENCRYPTED_BLOB_STORE_SUPPORTED_SCHEMA_VERSION,
334            ENCRYPTED_BLOB_STORE_LEGACY_ANCHOR_TABLES,
335        )
336        .map_err(|error| BlobStoreError::Sqlite(error.to_string()))?;
337        conn.execute_batch(
338            r#"
339            PRAGMA journal_mode = WAL;
340            PRAGMA synchronous = FULL;
341            PRAGMA busy_timeout = 5000;
342
343            CREATE TABLE IF NOT EXISTS chio_encrypted_blobs (
344                blob_id TEXT PRIMARY KEY,
345                tenant_id TEXT NOT NULL,
346                nonce BLOB NOT NULL CHECK(length(nonce) = 12),
347                ciphertext BLOB NOT NULL,
348                created_at INTEGER NOT NULL
349            );
350
351            CREATE INDEX IF NOT EXISTS idx_chio_encrypted_blobs_tenant
352                ON chio_encrypted_blobs(tenant_id, created_at);
353            "#,
354        )?;
355        crate::stamp_schema_version(
356            &conn,
357            ENCRYPTED_BLOB_STORE_SCHEMA_KEY,
358            ENCRYPTED_BLOB_STORE_SUPPORTED_SCHEMA_VERSION,
359        )
360        .map_err(|error| BlobStoreError::Sqlite(error.to_string()))?;
361        Ok(())
362    }
363
364    /// Write `payload` encrypted under `key` and return an opaque handle.
365    pub fn write_encrypted_blob(
366        &self,
367        tenant_id: &TenantId,
368        key: &TenantKey,
369        payload: &[u8],
370    ) -> Result<BlobHandle, BlobStoreError> {
371        validate_tenant_id(tenant_id)?;
372        let blob_id = format!("blob-{}", Uuid::now_v7());
373        let created_at = now_secs();
374        let aad = blob_aad(&blob_id, tenant_id.as_str(), created_at);
375        let encrypted = try_encrypt_blob_with_aad(key, payload, &aad)
376            .map_err(|_| BlobStoreError::Decrypt(DecryptError::AuthenticationFailed))?;
377        let conn = self.pool.get()?;
378        conn.execute(
379            r#"
380            INSERT INTO chio_encrypted_blobs
381                (blob_id, tenant_id, nonce, ciphertext, created_at)
382            VALUES
383                (?1, ?2, ?3, ?4, ?5)
384            "#,
385            params![
386                blob_id,
387                tenant_id.as_str(),
388                encrypted.nonce.as_slice(),
389                encrypted.ciphertext,
390                created_at,
391            ],
392        )?;
393        Ok(BlobHandle::new(blob_id, tenant_id.clone()))
394    }
395
396    /// Load encrypted material without decrypting it. Intended for
397    /// audit, migration, and test verification paths.
398    pub fn load_encrypted_blob(
399        &self,
400        handle: &BlobHandle,
401    ) -> Result<EncryptedBlob, BlobStoreError> {
402        let conn = self.pool.get()?;
403        let row = conn
404            .query_row(
405                r#"
406                SELECT nonce, ciphertext
407                FROM chio_encrypted_blobs
408                WHERE blob_id = ?1 AND tenant_id = ?2
409                "#,
410                params![handle.blob_id(), handle.tenant_id().as_str()],
411                |row| {
412                    let nonce: Vec<u8> = row.get("nonce")?;
413                    let ciphertext: Vec<u8> = row.get("ciphertext")?;
414                    Ok((nonce, ciphertext))
415                },
416            )
417            .optional()?;
418
419        let Some((nonce, ciphertext)) = row else {
420            return Err(BlobStoreError::NotFound);
421        };
422        let nonce_len = nonce.len();
423        let nonce = match <[u8; 12]>::try_from(nonce) {
424            Ok(value) => value,
425            Err(_) => return Err(BlobStoreError::InvalidNonceLength(nonce_len)),
426        };
427        Ok(EncryptedBlob { ciphertext, nonce })
428    }
429
430    /// Read and decrypt a blob. Wrong keys, wrong handles, and tampered
431    /// ciphertext all fail closed by returning an error.
432    pub fn read_encrypted_blob(
433        &self,
434        handle: &BlobHandle,
435        key: &TenantKey,
436    ) -> Result<Vec<u8>, BlobStoreError> {
437        let stored = self.load_encrypted_blob_record(handle)?;
438        let aad = blob_aad(
439            &stored.blob_id,
440            stored.tenant_id.as_str(),
441            stored.created_at,
442        );
443        Ok(decrypt_blob_with_aad(key, &stored.blob, &aad)?)
444    }
445
446    fn load_encrypted_blob_record(
447        &self,
448        handle: &BlobHandle,
449    ) -> Result<StoredEncryptedBlob, BlobStoreError> {
450        let conn = self.pool.get()?;
451        let row = conn
452            .query_row(
453                r#"
454                SELECT blob_id, tenant_id, nonce, ciphertext, created_at
455                FROM chio_encrypted_blobs
456                WHERE blob_id = ?1 AND tenant_id = ?2
457                "#,
458                params![handle.blob_id(), handle.tenant_id().as_str()],
459                |row| {
460                    let blob_id: String = row.get("blob_id")?;
461                    let tenant_id: String = row.get("tenant_id")?;
462                    let nonce: Vec<u8> = row.get("nonce")?;
463                    let ciphertext: Vec<u8> = row.get("ciphertext")?;
464                    let created_at: i64 = row.get("created_at")?;
465                    Ok((blob_id, tenant_id, nonce, ciphertext, created_at))
466                },
467            )
468            .optional()?;
469
470        let Some((blob_id, tenant_id, nonce, ciphertext, created_at)) = row else {
471            return Err(BlobStoreError::NotFound);
472        };
473        let nonce_len = nonce.len();
474        let nonce = match <[u8; 12]>::try_from(nonce) {
475            Ok(value) => value,
476            Err(_) => return Err(BlobStoreError::InvalidNonceLength(nonce_len)),
477        };
478        Ok(StoredEncryptedBlob {
479            blob: EncryptedBlob { ciphertext, nonce },
480            blob_id,
481            tenant_id: TenantId::new(tenant_id),
482            created_at,
483        })
484    }
485}
486
487fn blob_aad(blob_id: &str, tenant_id: &str, created_at: i64) -> Vec<u8> {
488    format!("{blob_id}\0{tenant_id}\0{created_at}").into_bytes()
489}
490
491fn validate_tenant_id(tenant_id: &TenantId) -> Result<(), BlobStoreError> {
492    let value = tenant_id.as_str();
493    if value.trim().is_empty() {
494        return Err(BlobStoreError::EmptyTenantId);
495    }
496    if value.trim() != value || value.chars().any(char::is_control) {
497        return Err(BlobStoreError::InvalidTenantId);
498    }
499    Ok(())
500}
501
502fn now_secs() -> i64 {
503    match SystemTime::now().duration_since(UNIX_EPOCH) {
504        Ok(duration) => i64::try_from(duration.as_secs()).unwrap_or(i64::MAX),
505        Err(_) => 0,
506    }
507}
508
509#[cfg(test)]
510#[allow(clippy::unwrap_used, clippy::expect_used)]
511mod tests {
512    use super::*;
513
514    #[test]
515    fn encrypt_decrypt_round_trip() {
516        let key = TenantKey::from_bytes([7; 32]);
517        let plaintext = b"redacted fixture payload";
518        let blob = encrypt_blob(&key, plaintext).expect("encrypt with valid key");
519        assert_ne!(blob.ciphertext, plaintext);
520        let decrypted = decrypt_blob(&key, &blob).expect("decrypt with correct key");
521        assert_eq!(decrypted, plaintext);
522    }
523
524    #[test]
525    fn decrypt_rejects_wrong_key() {
526        let key = TenantKey::from_bytes([7; 32]);
527        let wrong_key = TenantKey::from_bytes([8; 32]);
528        let blob = encrypt_blob(&key, b"payload").expect("encrypt with valid key");
529        let error = decrypt_blob(&wrong_key, &blob).expect_err("wrong key must fail");
530        assert_eq!(error, DecryptError::AuthenticationFailed);
531    }
532
533    #[test]
534    fn stored_blob_ciphertext_is_bound_to_handle_metadata() {
535        let store = SqliteEncryptedBlobStore::open_in_memory().unwrap();
536        let tenant = TenantId::new("tenant-a");
537        let key = TenantKey::from_bytes([9; 32]);
538        let source = store
539            .write_encrypted_blob(&tenant, &key, b"source payload")
540            .unwrap();
541        let target = store
542            .write_encrypted_blob(&tenant, &key, b"target payload")
543            .unwrap();
544
545        let conn = store.pool.get().unwrap();
546        conn.execute(
547            r#"
548            UPDATE chio_encrypted_blobs
549            SET nonce = (
550                    SELECT nonce FROM chio_encrypted_blobs WHERE blob_id = ?1
551                ),
552                ciphertext = (
553                    SELECT ciphertext FROM chio_encrypted_blobs WHERE blob_id = ?1
554                )
555            WHERE blob_id = ?2
556            "#,
557            params![source.blob_id(), target.blob_id()],
558        )
559        .unwrap();
560        drop(conn);
561
562        let error = store
563            .read_encrypted_blob(&target, &key)
564            .expect_err("transplanted ciphertext should fail metadata-bound AEAD");
565        assert!(matches!(
566            error,
567            BlobStoreError::Decrypt(DecryptError::AuthenticationFailed)
568        ));
569    }
570
571    #[test]
572    fn write_rejects_padded_or_control_tenant_id_before_persistence() {
573        let store = SqliteEncryptedBlobStore::open_in_memory().unwrap();
574        let key = TenantKey::from_bytes([9; 32]);
575
576        for tenant in [" tenant-a", "tenant-a ", "tenant-a\n"] {
577            let error = store
578                .write_encrypted_blob(&TenantId::new(tenant), &key, b"payload")
579                .expect_err("malformed tenant id must fail closed");
580            assert!(
581                error.to_string().contains("tenant id"),
582                "unexpected tenant validation error: {error}"
583            );
584        }
585
586        let count: i64 = store
587            .pool
588            .get()
589            .unwrap()
590            .query_row("SELECT COUNT(*) FROM chio_encrypted_blobs", [], |row| {
591                row.get(0)
592            })
593            .unwrap();
594        assert_eq!(count, 0);
595    }
596
597    #[test]
598    fn store_schema_does_not_publish_plaintext_hashes() {
599        let store = SqliteEncryptedBlobStore::open_in_memory().unwrap();
600        let conn = store.pool.get().unwrap();
601        let mut statement = conn
602            .prepare("PRAGMA table_info(chio_encrypted_blobs)")
603            .unwrap();
604        let names = statement
605            .query_map([], |row| row.get::<_, String>(1))
606            .unwrap()
607            .collect::<Result<Vec<_>, _>>()
608            .unwrap();
609        assert!(
610            !names.iter().any(|name| name == "plaintext_sha256"),
611            "encrypted blob store must not persist plaintext-derived hashes"
612        );
613    }
614
615    #[test]
616    fn tenant_key_implements_zeroize_on_drop() {
617        fn assert_zod<T: ZeroizeOnDrop>() {}
618        assert_zod::<TenantKey>();
619    }
620
621    #[test]
622    fn tenant_key_debug_redacts_material() {
623        let key = TenantKey::from_bytes([7; 32]);
624        assert_eq!(std::format!("{key:?}"), "TenantKey(<redacted>)");
625    }
626}