1use 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#[derive(Clone, Debug, PartialEq, Eq, Hash)]
24pub struct TenantId(String);
25
26impl TenantId {
27 #[must_use]
29 pub fn new(value: impl Into<String>) -> Self {
30 Self(value.into())
31 }
32
33 #[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#[derive(Zeroize, ZeroizeOnDrop)]
54pub struct TenantKey([u8; 32]);
55
56impl TenantKey {
57 #[must_use]
59 pub fn from_bytes(bytes: [u8; 32]) -> Self {
60 Self(bytes)
61 }
62
63 #[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#[derive(Clone, PartialEq, Eq)]
78pub struct EncryptedBlob {
79 pub ciphertext: Vec<u8>,
81 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#[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 #[must_use]
115 pub fn blob_id(&self) -> &str {
116 &self.blob_id
117 }
118
119 #[must_use]
121 pub fn tenant_id(&self) -> &TenantId {
122 &self.tenant_id
123 }
124}
125
126#[derive(Debug, Clone, PartialEq, Eq)]
129pub enum DecryptError {
130 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#[derive(Debug)]
146pub enum BlobStoreError {
147 Pool(String),
149 Sqlite(String),
151 Io(String),
153 EmptyTenantId,
155 InvalidTenantId,
157 InvalidNonceLength(usize),
159 NotFound,
161 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#[derive(Debug, Clone, PartialEq, Eq)]
212pub enum EncryptError {
213 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
227pub 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
235pub 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
288pub struct SqliteEncryptedBlobStore {
290 pool: Pool<SqliteConnectionManager>,
291}
292
293const ENCRYPTED_BLOB_STORE_SUPPORTED_SCHEMA_VERSION: i32 = 0;
295const ENCRYPTED_BLOB_STORE_SCHEMA_KEY: &str = "encrypted_blob";
298const ENCRYPTED_BLOB_STORE_LEGACY_ANCHOR_TABLES: &[&str] = &["chio_encrypted_blobs"];
301
302impl SqliteEncryptedBlobStore {
303 pub fn open(path: impl AsRef<Path>) -> Result<Self, BlobStoreError> {
305 let path = path.as_ref();
306 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 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 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 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 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}