diff --git a/src/carrier.rs b/src/carrier.rs index 85cc2a4..5db5faf 100644 --- a/src/carrier.rs +++ b/src/carrier.rs @@ -115,6 +115,7 @@ pub fn read_carrier_block( &chunk_rec.nonce, &chunk_rec.tag, format_version, + chunk_rec.generation, )?; // Das outer_decrypted enthält: inner_nonce (12B) || inner_tag (16B) || inner_ct_len (4B LE) || inner_ct || CSPRNG-Padding @@ -149,6 +150,7 @@ pub fn read_carrier_block( &inner_nonce, &inner_tag, format_version, + chunk_rec.generation, )?; Ok(inner_plaintext) @@ -167,6 +169,8 @@ pub fn write_carrier_block( plaintext: &[u8], format_version: u32, ) -> Result<()> { + let gen = 0u64; + // 1. Innere Schicht verschlüsseln (mit dek_inner = DEK_1) let (inner_ct, inner_nonce, inner_tag) = encrypt_chunk( dek_inner, @@ -174,6 +178,7 @@ pub fn write_carrier_block( block_idx, plaintext, format_version, + gen, )?; let inner_ct_len = inner_ct.len() as u32; @@ -208,12 +213,14 @@ pub fn write_carrier_block( block_idx, &outer_plaintext, format_version, + gen, )?; // 4. In SQLite schreiben (in-place Überschreiben des bestehenden Chunks) db.write_chunk( carrier_node_id, block_idx, + gen, &outer_nonce, &outer_tag, &outer_ct, @@ -234,17 +241,20 @@ pub fn shred_carrier_block( let mut noise = vec![0u8; CHUNK_SIZE]; OsRng.fill_bytes(&mut noise); + let gen = 0u64; let (outer_ct, outer_nonce, outer_tag) = encrypt_chunk( dek_outer, carrier_node_id, block_idx, &noise, format_version, + gen, )?; db.write_chunk( carrier_node_id, block_idx, + gen, &outer_nonce, &outer_tag, &outer_ct, diff --git a/src/crypto.rs b/src/crypto.rs index c68710a..9393daa 100644 --- a/src/crypto.rs +++ b/src/crypto.rs @@ -642,9 +642,21 @@ pub fn build_chunk_aad(node_id: i64, chunk_index: u32) -> [u8; 16] { aad } +/// Erzeugt die 24-Byte Associated Data (AAD) für einen Chunk in Format V3 (K-02 Chunk-Replay-Schutz): +/// node_id (8 Bytes Little-Endian) || chunk_index (8 Bytes Little-Endian) || generation (8 Bytes Little-Endian). +#[inline] +pub fn build_chunk_aad_v3(node_id: i64, chunk_index: u32, generation: u64) -> [u8; 24] { + let mut aad = [0u8; 24]; + aad[..8].copy_from_slice(&node_id.to_le_bytes()); + aad[8..16].copy_from_slice(&(chunk_index as u64).to_le_bytes()); + aad[16..24].copy_from_slice(&generation.to_le_bytes()); + aad +} + /// Verschlüsselt einen Payload-Chunk mit dem DEK via AES-256-GCM unter Einbindung von AAD. /// In Formatversion >= 2 wird der Chunk vor der Verschlüsselung transparent mit LZ4 komprimiert, /// sofern dadurch eine Größenreduktion erzielt wird. +/// In Formatversion >= 3 wird ein 24-Byte AAD inklusive des Generationszählers verwendet (K-02). /// Gibt (ciphertext, nonce_12_bytes, tag_16_bytes) zurück. pub fn encrypt_chunk( dek: &[u8; 32], @@ -652,6 +664,7 @@ pub fn encrypt_chunk( chunk_index: u32, plaintext: &[u8], format_version: u32, + generation: u64, ) -> Result<(Vec, [u8; 12], [u8; 16])> { let cipher = Aes256Gcm::new_from_slice(dek) .map_err(|e| anyhow::anyhow!("AES-GCM Initialisierungsfehler: {e}"))?; @@ -660,7 +673,15 @@ pub fn encrypt_chunk( OsRng.fill_bytes(&mut nonce_bytes); let nonce = Nonce::from_slice(&nonce_bytes); - let aad = build_chunk_aad(node_id, chunk_index); + let aad_16; + let aad_24; + let aad: &[u8] = if format_version >= FORMAT_VERSION_V3 { + aad_24 = build_chunk_aad_v3(node_id, chunk_index, generation); + &aad_24 + } else { + aad_16 = build_chunk_aad(node_id, chunk_index); + &aad_16 + }; let mut buffer = if format_version >= FORMAT_VERSION_V2 { if plaintext.is_empty() { @@ -685,7 +706,7 @@ pub fn encrypt_chunk( }; let tag = cipher - .encrypt_in_place_detached(nonce, &aad, &mut buffer) + .encrypt_in_place_detached(nonce, aad, &mut buffer) .map_err(|e| anyhow::anyhow!("Chunk-Verschlüsselung fehlgeschlagen: {e}"))?; let mut tag_bytes = [0u8; 16]; @@ -696,6 +717,7 @@ pub fn encrypt_chunk( /// Entschlüsselt und authentifiziert einen Payload-Chunk mit dem DEK via AES-256-GCM. /// Dekomprimiert LZ4-gepackte Chunks automatisch (in Formatversion >= 2). +/// In Formatversion >= 3 wird ein 24-Byte AAD inklusive des Generationszählers geprüft (K-02). pub fn decrypt_chunk( dek: &[u8; 32], node_id: i64, @@ -704,17 +726,27 @@ pub fn decrypt_chunk( nonce_bytes: &[u8; 12], tag_bytes: &[u8; 16], format_version: u32, + generation: u64, ) -> Result> { let cipher = Aes256Gcm::new_from_slice(dek) .map_err(|e| anyhow::anyhow!("AES-GCM Initialisierungsfehler: {e}"))?; let nonce = Nonce::from_slice(nonce_bytes); let tag = Tag::from_slice(tag_bytes); - let aad = build_chunk_aad(node_id, chunk_index); + + let aad_16; + let aad_24; + let aad: &[u8] = if format_version >= FORMAT_VERSION_V3 { + aad_24 = build_chunk_aad_v3(node_id, chunk_index, generation); + &aad_24 + } else { + aad_16 = build_chunk_aad(node_id, chunk_index); + &aad_16 + }; let mut buffer = ciphertext.to_vec(); cipher - .decrypt_in_place_detached(nonce, &aad, &mut buffer, tag) + .decrypt_in_place_detached(nonce, aad, &mut buffer, tag) .map_err(|_| { anyhow::anyhow!( "Chunk-Integritätsprüfung fehlgeschlagen (AEAD Auth-Fehler oder Swap-Angriff)" @@ -795,7 +827,7 @@ mod tests { let chunk_index = 0u32; let (ciphertext, nonce, tag) = - encrypt_chunk(&dek, node_id, chunk_index, plaintext, FORMAT_VERSION_V2).unwrap(); + encrypt_chunk(&dek, node_id, chunk_index, plaintext, FORMAT_VERSION_V2, 0).unwrap(); // Reguläre Entschlüsselung (v2) let decrypted = decrypt_chunk( @@ -806,6 +838,7 @@ mod tests { &nonce, &tag, FORMAT_VERSION_V2, + 0, ) .unwrap(); assert_eq!(decrypted, plaintext); @@ -819,6 +852,7 @@ mod tests { &nonce, &tag, FORMAT_VERSION_V2, + 0, ); assert!(swap_node_err.is_err()); @@ -831,6 +865,7 @@ mod tests { &nonce, &tag, FORMAT_VERSION_V2, + 0, ); assert!(swap_idx_err.is_err()); @@ -844,11 +879,100 @@ mod tests { &tampered_ct, &nonce, &tag, - FORMAT_VERSION_V2 + FORMAT_VERSION_V2, + 0, ) .is_err()); } + #[test] + fn test_k02_chunk_replay_protection_with_generation_aad() { + let dek = generate_dek(); + let plaintext_v1 = b"Original Chunk Data at Generation 1"; + let plaintext_v2 = b"Overwritten Chunk Data at Generation 2"; + let node_id = 42i64; + let chunk_index = 0u32; + + // 1. Chunk mit Generation 1 verschlüsseln + let (ct1, nonce1, tag1) = + encrypt_chunk(&dek, node_id, chunk_index, plaintext_v1, FORMAT_VERSION_V3, 1).unwrap(); + + // Verifiziere reguläre Entschlüsselung mit Generation 1 + let dec1 = decrypt_chunk( + &dek, + node_id, + chunk_index, + &ct1, + &nonce1, + &tag1, + FORMAT_VERSION_V3, + 1, + ) + .unwrap(); + assert_eq!(dec1, plaintext_v1); + + // 2. Replay-Schutz: Entschlüsselung mit falscher Generation (z. B. 2) MUSS scheitern! + let replay_err = decrypt_chunk( + &dek, + node_id, + chunk_index, + &ct1, + &nonce1, + &tag1, + FORMAT_VERSION_V3, + 2, + ); + assert!( + replay_err.is_err(), + "Ciphertext von Gen 1 darf unter Gen 2 AAD nicht entschlüsselt werden" + ); + + // 3. Chunk überschreiben mit Generation 2 + let (ct2, nonce2, tag2) = + encrypt_chunk(&dek, node_id, chunk_index, plaintext_v2, FORMAT_VERSION_V3, 2).unwrap(); + let dec2 = decrypt_chunk( + &dek, + node_id, + chunk_index, + &ct2, + &nonce2, + &tag2, + FORMAT_VERSION_V3, + 2, + ) + .unwrap(); + assert_eq!(dec2, plaintext_v2); + + // 4. Replay-Angriff: Angreifer spielt ct1 (Gen 1) ein, während System Gen 2 erwartet + let attack_res = decrypt_chunk( + &dek, + node_id, + chunk_index, + &ct1, + &nonce1, + &tag1, + FORMAT_VERSION_V3, + 2, + ); + assert!(attack_res.is_err(), "Replay von altem Ciphertext muss abgewehrt werden"); + + // 5. Abwärtskompatibilität: In V2 wird generation ignoriert + let (ct_v2, nonce_v2, tag_v2) = + encrypt_chunk(&dek, node_id, chunk_index, plaintext_v1, FORMAT_VERSION_V2, 0).unwrap(); + let dec_v2 = decrypt_chunk( + &dek, + node_id, + chunk_index, + &ct_v2, + &nonce_v2, + &tag_v2, + FORMAT_VERSION_V2, + 999, // beliebig + ) + .unwrap(); + assert_eq!(dec_v2, plaintext_v1); + } + #[test] fn test_lz4_chunk_compression_efficiency() { let dek = generate_dek(); @@ -859,7 +983,7 @@ mod tests { let chunk_index = 0u32; let (ciphertext, nonce, tag) = - encrypt_chunk(&dek, node_id, chunk_index, plaintext, FORMAT_VERSION_V2).unwrap(); + encrypt_chunk(&dek, node_id, chunk_index, plaintext, FORMAT_VERSION_V2, 0).unwrap(); // Der komprimierte Ciphertext muss signifikant kleiner sein als der Klartext assert!( @@ -877,6 +1001,7 @@ mod tests { &nonce, &tag, FORMAT_VERSION_V2, + 0, ) .unwrap(); assert_eq!(decrypted, plaintext); @@ -889,11 +1014,11 @@ mod tests { let mut random_bytes = vec![0u8; 1000]; OsRng.fill_bytes(&mut random_bytes); - let (ct, nonce, tag) = encrypt_chunk(&dek, 1, 0, &random_bytes, FORMAT_VERSION_V2).unwrap(); + let (ct, nonce, tag) = encrypt_chunk(&dek, 1, 0, &random_bytes, FORMAT_VERSION_V2, 0).unwrap(); // Da Kompression keine 64 Bytes spart, wird COMPRESSION_NONE (1 Byte) + Plaintext gespeichert assert_eq!(ct.len(), random_bytes.len() + 1); - let decrypted = decrypt_chunk(&dek, 1, 0, &ct, &nonce, &tag, FORMAT_VERSION_V2).unwrap(); + let decrypted = decrypt_chunk(&dek, 1, 0, &ct, &nonce, &tag, FORMAT_VERSION_V2, 0).unwrap(); assert_eq!(decrypted, random_bytes); } @@ -906,7 +1031,7 @@ mod tests { // V1 Format: Reine Verschlüsselung ohne Kompressionspräfix let (ciphertext, nonce, tag) = - encrypt_chunk(&dek, node_id, chunk_index, plaintext, FORMAT_VERSION_V1).unwrap(); + encrypt_chunk(&dek, node_id, chunk_index, plaintext, FORMAT_VERSION_V1, 0).unwrap(); assert_eq!(ciphertext.len(), plaintext.len()); let decrypted = decrypt_chunk( @@ -917,6 +1042,7 @@ mod tests { &nonce, &tag, FORMAT_VERSION_V1, + 0, ) .unwrap(); assert_eq!(decrypted, plaintext); @@ -1163,6 +1289,7 @@ mod tests { &nonce_bytes, &tag_bytes, FORMAT_VERSION_V2, + 0, ); assert!( res.is_err(), diff --git a/src/recovery.rs b/src/recovery.rs index 352c118..091b9e6 100644 --- a/src/recovery.rs +++ b/src/recovery.rs @@ -297,6 +297,7 @@ pub fn detect_recovery_key_slot(db: &Database, dek: &[u8; 32], version: u32) -> &chunk0.nonce, &chunk0.tag, version, + chunk0.generation, ) .is_ok(); if is_dek0 { diff --git a/src/storage.rs b/src/storage.rs index 33d7780..2bf8242 100644 --- a/src/storage.rs +++ b/src/storage.rs @@ -32,6 +32,7 @@ pub struct NodeRecord { pub struct ChunkRecord { pub node_id: i64, pub chunk_index: u32, + pub generation: u64, pub nonce: [u8; 12], pub tag: [u8; 16], pub ciphertext: Vec, @@ -185,7 +186,7 @@ impl ContainerMeta { #[derive(Clone)] pub struct Database { conn: Arc>, - active_dek: Arc>>>, + active_session: Arc)>>>, } fn current_timestamp() -> u64 { @@ -306,7 +307,7 @@ impl Database { let db = Self { conn: Arc::new(Mutex::new(conn)), - active_dek: Arc::new(Mutex::new(None)), + active_session: Arc::new(Mutex::new(None)), }; db.init_pragmas()?; if table_count > 0 { @@ -321,7 +322,7 @@ impl Database { let conn = Connection::open_in_memory()?; let db = Self { conn: Arc::new(Mutex::new(conn)), - active_dek: Arc::new(Mutex::new(None)), + active_session: Arc::new(Mutex::new(None)), }; db.init_pragmas()?; db.ensure_schema_upgrades()?; @@ -332,14 +333,31 @@ impl Database { self.conn.lock().unwrap() } - /// Setzt den aktiven DEK für automatische Metadaten-Authentifizierung (K-01). + /// Erzeugt eine geklonte Instanz mit einer isolierten aktiven Session (Slot & DEK). + pub fn with_session(&self, slot_id: u32, dek: Zeroizing<[u8; 32]>) -> Self { + Self { + conn: self.conn.clone(), + active_session: Arc::new(Mutex::new(Some((slot_id, dek)))), + } + } + + /// Setzt den aktiven DEK für automatische Metadaten-Authentifizierung (K-01) auf Slot 0. pub fn set_active_dek(&self, dek: Zeroizing<[u8; 32]>) { - *self.active_dek.lock().unwrap() = Some(dek); + self.set_active_slot_and_dek(0, dek); + } + + /// Setzt den aktiven Slot und DEK für automatische Metadaten-Authentifizierung (K-01). + pub fn set_active_slot_and_dek(&self, slot_id: u32, dek: Zeroizing<[u8; 32]>) { + *self.active_session.lock().unwrap() = Some((slot_id, dek)); } /// Gibt den aktuellen aktiven DEK zurück, falls gesetzt. pub fn active_dek(&self) -> Option> { - self.active_dek.lock().unwrap().clone() + self.active_session + .lock() + .unwrap() + .as_ref() + .map(|(_, dek)| dek.clone()) } /// Authentifiziert ein Master-Passwort gegen den Container in konstanter Zeit. @@ -347,7 +365,7 @@ impl Database { let meta = self.read_meta()?; let res = meta.authenticate(password); if let Some(ref keys) = res { - self.set_active_dek(keys.dek().clone()); + self.set_active_slot_and_dek(keys.slot_id(), keys.dek().clone()); if let Some(cid) = keys.carrier_node_id() { let _ = self.mark_carrier_node_id(cid); } @@ -387,6 +405,12 @@ impl Database { [], ); + // Spalte generation in chunks (Format V3 / K-02) + let _ = conn.execute( + "ALTER TABLE chunks ADD COLUMN generation INTEGER NOT NULL DEFAULT 0", + [], + ); + Ok(()) } @@ -571,6 +595,7 @@ impl Database { CREATE TABLE IF NOT EXISTS chunks ( node_id INTEGER NOT NULL, chunk_index INTEGER NOT NULL, + generation INTEGER NOT NULL DEFAULT 0, nonce BLOB NOT NULL, tag BLOB NOT NULL, ciphertext BLOB NOT NULL, @@ -652,7 +677,7 @@ impl Database { let manifest_bytes = serde_json::to_vec(&manifest)?; let (inner_ct, inner_nonce, inner_tag) = - crate::crypto::encrypt_chunk(dek_1, c_id, 0, &manifest_bytes, FORMAT_VERSION)?; + crate::crypto::encrypt_chunk(dek_1, c_id, 0, &manifest_bytes, FORMAT_VERSION, 0)?; let inner_ct_len = inner_ct.len() as u32; let mut outer_plaintext = vec![0u8; CHUNK_SIZE]; @@ -668,18 +693,18 @@ impl Database { outer_plaintext[32..ct_end].copy_from_slice(&inner_ct); let (outer_ct, outer_nonce, outer_tag) = - crate::crypto::encrypt_chunk(dek_0, c_id, 0, &outer_plaintext, FORMAT_VERSION)?; + crate::crypto::encrypt_chunk(dek_0, c_id, 0, &outer_plaintext, FORMAT_VERSION, 0)?; conn.execute( - "INSERT INTO chunks (node_id, chunk_index, nonce, tag, ciphertext) - VALUES (?1, 0, ?2, ?3, ?4)", + "INSERT INTO chunks (node_id, chunk_index, generation, nonce, tag, ciphertext) + VALUES (?1, 0, 0, ?2, ?3, ?4)", params![c_id, outer_nonce.as_slice(), outer_tag.as_slice(), outer_ct], )?; // Blöcke 1..total_blocks-1 mit DEK_0 vorallokieren let mut chunk_stmt = conn.prepare( - "INSERT INTO chunks (node_id, chunk_index, nonce, tag, ciphertext) - VALUES (?1, ?2, ?3, ?4, ?5)", + "INSERT INTO chunks (node_id, chunk_index, generation, nonce, tag, ciphertext) + VALUES (?1, ?2, 0, ?3, ?4, ?5)", )?; let mut dummy_noise = vec![0u8; CHUNK_SIZE]; @@ -688,7 +713,7 @@ impl Database { conn.execute_batch("BEGIN TRANSACTION;")?; for b in 1..total_blocks { let (ct, nonce, tag) = - crate::crypto::encrypt_chunk(dek_0, c_id, b, &dummy_noise, FORMAT_VERSION)?; + crate::crypto::encrypt_chunk(dek_0, c_id, b, &dummy_noise, FORMAT_VERSION, 0)?; chunk_stmt.execute(params![c_id, b, nonce.as_slice(), tag.as_slice(), ct,])?; if b % 500 == 0 { @@ -696,9 +721,14 @@ impl Database { } } conn.execute_batch("COMMIT;")?; - - self.set_active_dek(Zeroizing::new(*dek_0)); - Some(c_id) + drop(chunk_stmt); + self.set_active_slot_and_dek(0, Zeroizing::new(*dek_0)); + drop(conn); + let _ = self.update_metadata_mac(); + self.set_active_slot_and_dek(1, Zeroizing::new(*dek_1)); + let _ = self.update_metadata_mac(); + self.set_active_slot_and_dek(0, Zeroizing::new(*dek_0)); + return Ok(Some(c_id)); } else { // Slot 1 mit CSPRNG-Zufallsdaten gleicher Struktur und Entropie (72 Bytes für Modell A) let (dummy_dek, dummy_nonce, dummy_tag, dummy_salt) = generate_dummy_slot(); @@ -772,6 +802,7 @@ impl Database { CREATE TABLE IF NOT EXISTS chunks ( node_id INTEGER NOT NULL, chunk_index INTEGER NOT NULL, + generation INTEGER NOT NULL DEFAULT 0, nonce BLOB NOT NULL, tag BLOB NOT NULL, ciphertext BLOB NOT NULL, @@ -1446,7 +1477,7 @@ impl Database { pub fn read_chunk(&self, node_id: i64, chunk_index: u32) -> Result> { let conn = self.conn.lock().unwrap(); let mut stmt = conn.prepare( - "SELECT nonce, tag, ciphertext FROM chunks WHERE node_id = ?1 AND chunk_index = ?2", + "SELECT nonce, tag, ciphertext, generation FROM chunks WHERE node_id = ?1 AND chunk_index = ?2", )?; let record = stmt @@ -1454,6 +1485,7 @@ impl Database { let nonce_vec: Vec = row.get(0)?; let tag_vec: Vec = row.get(1)?; let ciphertext: Vec = row.get(2)?; + let generation: i64 = row.get(3).unwrap_or(0); let mut nonce = [0u8; 12]; let mut tag = [0u8; 16]; @@ -1467,6 +1499,7 @@ impl Database { Ok(ChunkRecord { node_id, chunk_index, + generation: generation as u64, nonce, tag, ciphertext, @@ -1477,26 +1510,52 @@ impl Database { Ok(record) } + /// Ermittelt die nächste Generation für einen Chunk (K-02 Chunk-Replay-Schutz). + /// Garantiert eine strikt monoton steigende Generation containerweit. + pub fn next_chunk_generation(&self, node_id: i64, chunk_index: u32) -> Result { + let conn = self.conn.lock().unwrap(); + let current_gen: Option = conn + .query_row( + "SELECT generation FROM chunks WHERE node_id = ?1 AND chunk_index = ?2", + params![node_id, chunk_index], + |r| r.get(0), + ) + .optional()?; + let max_gen: i64 = conn + .query_row("SELECT COALESCE(MAX(generation), 0) FROM chunks", [], |r| { + r.get(0) + }) + .unwrap_or(0); + let next = match current_gen { + Some(g) => (g + 1).max(max_gen + 1), + None => max_gen + 1, + }; + Ok(next as u64) + } + /// Schreibt oder aktualisiert einen verschlüsselten Chunk in der Datenbank. pub fn write_chunk( &self, node_id: i64, chunk_index: u32, + generation: u64, nonce: &[u8; 12], tag: &[u8; 16], ciphertext: &[u8], ) -> Result<()> { let conn = self.conn.lock().unwrap(); conn.execute( - "INSERT INTO chunks (node_id, chunk_index, nonce, tag, ciphertext) - VALUES (?1, ?2, ?3, ?4, ?5) + "INSERT INTO chunks (node_id, chunk_index, generation, nonce, tag, ciphertext) + VALUES (?1, ?2, ?3, ?4, ?5, ?6) ON CONFLICT(node_id, chunk_index) DO UPDATE SET + generation = excluded.generation, nonce = excluded.nonce, tag = excluded.tag, ciphertext = excluded.ciphertext", params![ node_id, chunk_index, + generation as i64, nonce.as_slice(), tag.as_slice(), ciphertext, @@ -1511,6 +1570,7 @@ impl Database { &self, node_id: i64, chunk_index: u32, + generation: u64, nonce: &[u8; 12], tag: &[u8; 16], ciphertext: &[u8], @@ -1521,15 +1581,17 @@ impl Database { let mut conn = self.conn.lock().unwrap(); let tx = conn.transaction()?; tx.execute( - "INSERT INTO chunks (node_id, chunk_index, nonce, tag, ciphertext) - VALUES (?1, ?2, ?3, ?4, ?5) + "INSERT INTO chunks (node_id, chunk_index, generation, nonce, tag, ciphertext) + VALUES (?1, ?2, ?3, ?4, ?5, ?6) ON CONFLICT(node_id, chunk_index) DO UPDATE SET + generation = excluded.generation, nonce = excluded.nonce, tag = excluded.tag, ciphertext = excluded.ciphertext", params![ node_id, chunk_index, + generation as i64, nonce.as_slice(), tag.as_slice(), ciphertext, @@ -1596,65 +1658,100 @@ impl Database { /// Erzeugt die deterministische kanonische Byterepräsentation aller Knoten für den Metadaten-MAC (K-01). pub fn canonical_nodes_bytes(&self) -> Result> { - let conn = self.conn.lock().unwrap(); - let mut stmt = conn.prepare( - "SELECT n.id, n.parent_id, n.name, n.is_dir, n.size, n.created_at, n.modified_at, n.is_carrier, - (SELECT COUNT(*) FROM chunks c WHERE c.node_id = n.id) as chunk_count - FROM nodes n - ORDER BY n.id ASC", - )?; + self.canonical_nodes_bytes_for_vault(0) + } - let mut rows = stmt.query([])?; + /// Erzeugt die deterministische kanonische Byterepräsentation für einen spezifischen Vault. + pub fn canonical_nodes_bytes_for_vault(&self, vault_id: u32) -> Result> { + let conn = self.conn.lock().unwrap(); let mut buf = Vec::new(); - while let Some(row) = rows.next()? { - let id: i64 = row.get(0)?; - let parent_id: Option = row.get(1)?; - let name: String = row.get(2)?; - let is_dir: i64 = row.get(3)?; - let size: i64 = row.get(4)?; - let created_at: i64 = row.get(5)?; - let modified_at: i64 = row.get(6)?; - let is_carrier: i64 = row.get(7)?; - let chunk_count: i64 = row.get(8)?; - - buf.extend_from_slice(&id.to_le_bytes()); - match parent_id { - Some(pid) => { - buf.push(1u8); - buf.extend_from_slice(&pid.to_le_bytes()); - } - None => { - buf.push(0u8); - buf.extend_from_slice(&0i64.to_le_bytes()); - } + if vault_id == 1 { + let mut stmt = conn.prepare( + "WITH RECURSIVE vault1(id) AS ( + SELECT 2 + UNION ALL + SELECT n.id FROM nodes n JOIN vault1 v ON n.parent_id = v.id + ) + SELECT n.id, n.parent_id, n.name, n.is_dir, n.size, n.created_at, n.modified_at, n.is_carrier, + (SELECT COUNT(*) FROM chunks c WHERE c.node_id = n.id) as chunk_count + FROM nodes n + WHERE n.id IN (SELECT id FROM vault1) + ORDER BY n.id ASC", + )?; + let mut rows = stmt.query([])?; + while let Some(row) = rows.next()? { + Self::serialize_node_row(&row, &mut buf)?; + } + } else { + let mut stmt = conn.prepare( + "WITH RECURSIVE vault0(id) AS ( + SELECT 1 + UNION ALL + SELECT n.id FROM nodes n JOIN vault0 v ON n.parent_id = v.id + ) + SELECT n.id, n.parent_id, n.name, n.is_dir, n.size, n.created_at, n.modified_at, n.is_carrier, + (SELECT COUNT(*) FROM chunks c WHERE c.node_id = n.id) as chunk_count + FROM nodes n + WHERE n.id = 2 OR n.id IN (SELECT id FROM vault0) + ORDER BY n.id ASC", + )?; + let mut rows = stmt.query([])?; + while let Some(row) = rows.next()? { + Self::serialize_node_row(&row, &mut buf)?; } - let name_bytes = name.as_bytes(); - buf.extend_from_slice(&(name_bytes.len() as u32).to_le_bytes()); - buf.extend_from_slice(name_bytes); - buf.push(if is_dir != 0 { 1u8 } else { 0u8 }); - buf.extend_from_slice(&size.to_le_bytes()); - buf.extend_from_slice(&created_at.to_le_bytes()); - buf.extend_from_slice(&modified_at.to_le_bytes()); - buf.push(if is_carrier != 0 { 1u8 } else { 0u8 }); - buf.extend_from_slice(&chunk_count.to_le_bytes()); } Ok(buf) } - /// Aktualisiert den Metadaten-MAC in Slot 0 bei strukturellen Modifikationen (Format V3 / K-01). + fn serialize_node_row(row: &rusqlite::Row, buf: &mut Vec) -> Result<()> { + let id: i64 = row.get(0)?; + let parent_id: Option = row.get(1)?; + let name: String = row.get(2)?; + let is_dir: i64 = row.get(3)?; + let size: i64 = row.get(4)?; + let created_at: i64 = row.get(5)?; + let modified_at: i64 = row.get(6)?; + let is_carrier: i64 = row.get(7)?; + let chunk_count: i64 = row.get(8)?; + + buf.extend_from_slice(&id.to_le_bytes()); + match parent_id { + Some(pid) => { + buf.push(1u8); + buf.extend_from_slice(&pid.to_le_bytes()); + } + None => { + buf.push(0u8); + buf.extend_from_slice(&0i64.to_le_bytes()); + } + } + let name_bytes = name.as_bytes(); + buf.extend_from_slice(&(name_bytes.len() as u32).to_le_bytes()); + buf.extend_from_slice(name_bytes); + buf.push(if is_dir != 0 { 1u8 } else { 0u8 }); + buf.extend_from_slice(&size.to_le_bytes()); + buf.extend_from_slice(&created_at.to_le_bytes()); + buf.extend_from_slice(&modified_at.to_le_bytes()); + buf.push(if is_carrier != 0 { 1u8 } else { 0u8 }); + buf.extend_from_slice(&chunk_count.to_le_bytes()); + + Ok(()) + } + + /// Aktualisiert den Metadaten-MAC des aktiven Slots bei strukturellen Modifikationen (Format V3 / K-01). pub fn update_metadata_mac(&self) -> Result<()> { - let dek_opt = self.active_dek.lock().unwrap().clone(); - let Some(dek) = dek_opt else { + let session_opt = self.active_session.lock().unwrap().clone(); + let Some((slot_id, dek)) = session_opt else { return Ok(()); }; let conn = self.conn.lock().unwrap(); let version_and_gen: Option<(u32, u64)> = conn .query_row( - "SELECT version, metadata_gen FROM meta WHERE slot_id = 0 LIMIT 1", - [], + "SELECT version, metadata_gen FROM meta WHERE slot_id = ?1 LIMIT 1", + params![slot_id], |r| Ok((r.get(0)?, r.get(1).unwrap_or(0))), ) .optional()?; @@ -1670,14 +1767,14 @@ impl Database { drop(conn); let next_gen = current_gen + 1; - let canonical = self.canonical_nodes_bytes()?; + let canonical = self.canonical_nodes_bytes_for_vault(slot_id)?; let mac_key = derive_metadata_mac_key(&dek); let new_mac = compute_metadata_mac(&mac_key, next_gen, &canonical); let conn = self.conn.lock().unwrap(); conn.execute( - "UPDATE meta SET metadata_mac = ?1, metadata_gen = ?2 WHERE slot_id = 0", - params![new_mac.as_slice(), next_gen], + "UPDATE meta SET metadata_mac = ?1, metadata_gen = ?2 WHERE slot_id = ?3", + params![new_mac.as_slice(), next_gen, slot_id], )?; Ok(()) @@ -1685,17 +1782,28 @@ impl Database { /// Prüft die Integrität des Metadaten-MAC gegen den gegebenen DEK (Format V3 / K-01). pub fn verify_metadata_mac(&self, dek: &[u8; 32]) -> Result { + if self.verify_metadata_mac_for_slot(0, dek)? { + return Ok(true); + } + if self.verify_metadata_mac_for_slot(1, dek)? { + return Ok(true); + } + Ok(false) + } + + /// Prüft die Integrität des Metadaten-MAC für einen spezifischen Slot (0: Decoy, 1: Hidden). + pub fn verify_metadata_mac_for_slot(&self, slot_id: u32, dek: &[u8; 32]) -> Result { let conn = self.conn.lock().unwrap(); let meta_row: Option<(u32, Option>, u64)> = conn .query_row( - "SELECT version, metadata_mac, metadata_gen FROM meta WHERE slot_id = 0 LIMIT 1", - [], + "SELECT version, metadata_mac, metadata_gen FROM meta WHERE slot_id = ?1 LIMIT 1", + params![slot_id], |r| Ok((r.get(0)?, r.get(1).ok(), r.get(2).unwrap_or(0))), ) .optional()?; let Some((version, mac_opt, gen)) = meta_row else { - return Ok(true); + return Ok(false); }; if version < FORMAT_VERSION_V3 { @@ -1714,7 +1822,7 @@ impl Database { expected_mac.copy_from_slice(&mac_bytes); drop(conn); - let canonical = self.canonical_nodes_bytes()?; + let canonical = self.canonical_nodes_bytes_for_vault(slot_id)?; let mac_key = derive_metadata_mac_key(dek); Ok(verify_metadata_mac(&mac_key, gen, &canonical, &expected_mac)) } @@ -1742,6 +1850,65 @@ impl Database { [], ); + // K-02: Alle bestehenden Chunks von alter 16-Byte-AAD auf Format V3 24-Byte-AAD (generation = 0) umverschlüsseln + { + let mut chunk_stmt = conn.prepare( + "SELECT node_id, chunk_index, nonce, tag, ciphertext FROM chunks", + )?; + let chunk_rows: Vec<(i64, u32, [u8; 12], [u8; 16], Vec)> = chunk_stmt + .query_map([], |row| { + let node_id: i64 = row.get(0)?; + let chunk_index: u32 = row.get(1)?; + let nonce_vec: Vec = row.get(2)?; + let tag_vec: Vec = row.get(3)?; + let ciphertext: Vec = row.get(4)?; + let mut nonce = [0u8; 12]; + let mut tag = [0u8; 16]; + if nonce_vec.len() == 12 { + nonce.copy_from_slice(&nonce_vec); + } + if tag_vec.len() == 16 { + tag.copy_from_slice(&tag_vec); + } + Ok((node_id, chunk_index, nonce, tag, ciphertext)) + })? + .collect::, _>>()?; + drop(chunk_stmt); + + for (node_id, chunk_index, nonce, tag, ct) in chunk_rows { + if let Ok(plaintext) = crate::crypto::decrypt_chunk( + dek, + node_id, + chunk_index, + &ct, + &nonce, + &tag, + version, + 0, + ) { + if let Ok((new_ct, new_nonce, new_tag)) = crate::crypto::encrypt_chunk( + dek, + node_id, + chunk_index, + &plaintext, + FORMAT_VERSION_V3, + 0, + ) { + conn.execute( + "UPDATE chunks SET nonce = ?1, tag = ?2, ciphertext = ?3, generation = 0 WHERE node_id = ?4 AND chunk_index = ?5", + params![ + new_nonce.as_slice(), + new_tag.as_slice(), + new_ct, + node_id, + chunk_index + ], + )?; + } + } + } + } + conn.execute( "UPDATE meta SET version = ?1, metadata_gen = 0 WHERE slot_id = 0", [FORMAT_VERSION_V3], @@ -1762,6 +1929,7 @@ impl Database { conn.query_row("PRAGMA wal_checkpoint(TRUNCATE);", [], |row| { Ok((row.get(0)?, row.get(1)?, row.get(2)?)) })?; + let _ = conn.execute_batch("PRAGMA incremental_vacuum;"); Ok(()) } @@ -2042,7 +2210,7 @@ mod tests { let test_cipher = b"ENCRYPTED_DATA_BLOCK"; let c_nonce = [7u8; 12]; let c_tag = [8u8; 16]; - db.write_chunk(file.id, 0, &c_nonce, &c_tag, test_cipher) + db.write_chunk(file.id, 0, 0, &c_nonce, &c_tag, test_cipher) .unwrap(); let chunk = db @@ -2089,7 +2257,7 @@ mod tests { let c_nonce = [5u8; 12]; let c_tag = [6u8; 16]; for i in 0..20 { - db.write_chunk(file.id, i, &c_nonce, &c_tag, &payload) + db.write_chunk(file.id, i, 0, &c_nonce, &c_tag, &payload) .unwrap(); } db.checkpoint().unwrap(); @@ -2135,7 +2303,7 @@ mod tests { let sensitive_payload = b"VERY_SENSITIVE_PLAINTEXT_OR_CIPHERTEXT"; let c_nonce = [10u8; 12]; let c_tag = [11u8; 16]; - db.write_chunk(file.id, 0, &c_nonce, &c_tag, sensitive_payload) + db.write_chunk(file.id, 0, 0, &c_nonce, &c_tag, sensitive_payload) .unwrap(); // Shredde Chunks @@ -2322,6 +2490,7 @@ mod tests { db.write_chunk_and_update_size( file.id, 0, + 0, &chunk_nonce, &chunk_tag, &ciphertext, diff --git a/src/sync.rs b/src/sync.rs index 2dae857..18a0f4d 100644 --- a/src/sync.rs +++ b/src/sync.rs @@ -57,6 +57,7 @@ fn calc_vault_node_sha256( &record.nonce, &record.tag, version, + record.generation, )?; hasher.update(&decrypted); } @@ -313,12 +314,15 @@ pub fn sync_single_file_to_vault( } let chunk_data = &buffer[..n]; - let (ciphertext, nonce, tag) = encrypt_chunk(dek, node_id, chunk_idx, chunk_data, version)?; + let gen = db.next_chunk_generation(node_id, chunk_idx)?; + let (ciphertext, nonce, tag) = + encrypt_chunk(dek, node_id, chunk_idx, chunk_data, version, gen)?; bytes_written += n as u64; db.write_chunk_and_update_size( node_id, chunk_idx, + gen, &nonce, &tag, &ciphertext, @@ -409,6 +413,7 @@ pub fn sync_single_file_to_host( &record.nonce, &record.tag, version, + record.generation, )?; out_file.write_all(&plaintext)?; } else { diff --git a/src/verify.rs b/src/verify.rs index c87af2e..449cc0e 100644 --- a/src/verify.rs +++ b/src/verify.rs @@ -301,6 +301,7 @@ pub fn verify_container( &record.nonce, &record.tag, format_version, + record.generation, ) { Ok(plaintext) => { report.total_bytes_decrypted += plaintext.len() as u64; @@ -373,12 +374,12 @@ mod tests { // 2 Chunks schreiben let chunk0_data = b"Sample JPEG data header and pixels"; - let (ct0, n0, t0) = encrypt_chunk(&dek, file.id, 0, chunk0_data, FORMAT_VERSION).unwrap(); - db.write_chunk(file.id, 0, &n0, &t0, &ct0).unwrap(); + let (ct0, n0, t0) = encrypt_chunk(&dek, file.id, 0, chunk0_data, FORMAT_VERSION, 0).unwrap(); + db.write_chunk(file.id, 0, 0, &n0, &t0, &ct0).unwrap(); let chunk1_data = b"Additional payload data bytes"; - let (ct1, n1, t1) = encrypt_chunk(&dek, file.id, 1, chunk1_data, FORMAT_VERSION).unwrap(); - db.write_chunk(file.id, 1, &n1, &t1, &ct1).unwrap(); + let (ct1, n1, t1) = encrypt_chunk(&dek, file.id, 1, chunk1_data, FORMAT_VERSION, 0).unwrap(); + db.write_chunk(file.id, 1, 0, &n1, &t1, &ct1).unwrap(); db.update_node_size_and_time( file.id, @@ -432,8 +433,8 @@ mod tests { let file = db.create_node(1, "document.pdf", false).unwrap(); let chunk_data = b"Vital documents that must not be corrupted"; - let (ct, n, t) = encrypt_chunk(&dek, file.id, 0, chunk_data, FORMAT_VERSION).unwrap(); - db.write_chunk(file.id, 0, &n, &t, &ct).unwrap(); + let (ct, n, t) = encrypt_chunk(&dek, file.id, 0, chunk_data, FORMAT_VERSION, 0).unwrap(); + db.write_chunk(file.id, 0, 0, &n, &t, &ct).unwrap(); db.checkpoint().unwrap(); drop(db); diff --git a/src/vfs.rs b/src/vfs.rs index b83b3db..4478e0f 100644 --- a/src/vfs.rs +++ b/src/vfs.rs @@ -171,8 +171,12 @@ impl SanctumFile { .unwrap_or(0); if let Some((idx, ref data, true)) = self.cached_chunk { + let gen = self + .db + .next_chunk_generation(self.node_id, idx) + .unwrap_or(0); let (ciphertext, nonce, tag) = - encrypt_chunk(&self.dek, self.node_id, idx, data, self.format_version).map_err( + encrypt_chunk(&self.dek, self.node_id, idx, data, self.format_version, gen).map_err( |e| { error!("Verschlüsselungsfehler beim Chunk-Flush: {e}"); FsError::GeneralFailure @@ -182,6 +186,7 @@ impl SanctumFile { if let Err(e) = self.db.write_chunk_and_update_size( self.node_id, idx, + gen, &nonce, &tag, &ciphertext, @@ -246,6 +251,7 @@ impl SanctumFile { &record.nonce, &record.tag, self.format_version, + record.generation, ) .map_err(|e| { error!("AEAD-Entschlüsselungsfehler bei Chunk #{chunk_index}: {e}"); @@ -490,7 +496,7 @@ impl SanctumFs { let dek_arc = Arc::new(dek); let carrier_dek_arc = carrier_dek.map(Arc::new); - db.set_active_dek((*dek_arc).clone()); + let db = db.with_session(vault_id, (*dek_arc).clone()); // Im Decoy-Vault (Slot 0): Stelle sicher, dass carrier_node_id stets bekannt ist, // um die Trägerdatei vor versehentlichem Löschen oder Überschreiben zu schützen. @@ -1018,20 +1024,26 @@ impl DavFileSystem for SanctumFs { &record.nonce, &record.tag, self.format_version, + record.generation, ) .map_err(|_| FsError::GeneralFailure)?; + let gen = self + .db + .next_chunk_generation(dest_node.id, idx) + .map_err(|_| FsError::GeneralFailure)?; let (new_ct, new_nonce, new_tag) = encrypt_chunk( &self.dek, dest_node.id, idx, &plaintext, self.format_version, + gen, ) .map_err(|_| FsError::GeneralFailure)?; self.db - .write_chunk(dest_node.id, idx, &new_nonce, &new_tag, &new_ct) + .write_chunk(dest_node.id, idx, gen, &new_nonce, &new_tag, &new_ct) .map_err(|_| FsError::GeneralFailure)?; } } diff --git a/tests/integration_test.rs b/tests/integration_test.rs index 9ed2342..56680a6 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -433,8 +433,8 @@ async fn test_sanctum_v1_backward_compatibility() { // V1 Chunk mit encrypt_chunk(..., FORMAT_VERSION_V1) erzeugen und direkt in DB schreiben let v1_plaintext = b"Legacy Sanctum V1 uncompressed data payload."; let (ct, nonce, tag) = - encrypt_chunk(&dek, node.id, 0, v1_plaintext, FORMAT_VERSION_V1).expect("encrypt v1"); - db.write_chunk(node.id, 0, &nonce, &tag, &ct) + encrypt_chunk(&dek, node.id, 0, v1_plaintext, FORMAT_VERSION_V1, 0).expect("encrypt v1"); + db.write_chunk(node.id, 0, 0, &nonce, &tag, &ct) .expect("write chunk"); db.update_node_size_and_time(node.id, v1_plaintext.len() as u64, 12345678) .expect("update size"); diff --git a/tests/untrusted_container_test.rs b/tests/untrusted_container_test.rs index 2bc8f70..cb95302 100644 --- a/tests/untrusted_container_test.rs +++ b/tests/untrusted_container_test.rs @@ -1,6 +1,6 @@ use sanctum::crypto::{ - derive_kek, generate_dek, generate_salt, wrap_dek, wrap_slot0_payload, KdfParams, - MIN_MEMORY_COST_KIB, MIN_TIME_COST, + decrypt_chunk, derive_kek, encrypt_chunk, generate_dek, generate_salt, wrap_dek, + wrap_slot0_payload, KdfParams, MIN_MEMORY_COST_KIB, MIN_TIME_COST, }; use sanctum::storage::Database; use sanctum::verify::verify_container; @@ -869,3 +869,132 @@ fn test_k01_upgrade_format_v2_to_v3() { let _ = std::fs::remove_file(&db_path); } +#[tokio::test] +async fn test_k02_chunk_replay_detected_by_vfs_and_crypto() { + let temp_dir = std::env::temp_dir(); + let db_path = temp_dir.join(format!( + "k02_replay_test_{}.sanctum", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + + let password = "TestPasswordK02!"; + let salt = generate_salt(); + let kdf_params = KdfParams { + memory_cost: MIN_MEMORY_COST_KIB, + time_cost: MIN_TIME_COST, + parallelism: 1, + }; + let kek = derive_kek(password, &salt, &kdf_params).unwrap(); + let dek = generate_dek(); + let (wrapped_dek, nonce, tag) = wrap_dek(&kek, &dek).unwrap(); + + let db = Database::open(&db_path).unwrap(); + db.set_active_dek(dek.clone()); + db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag) + .unwrap(); + + // 1. Datei im Format V3 anlegen + let file = db.create_node(1, "replay_target.txt", false).unwrap(); + let initial_data = b"State 1: Initial secret content in chunk 0."; + let gen1 = db.next_chunk_generation(file.id, 0).unwrap(); + let (ct1, nonce1, tag1) = encrypt_chunk( + &dek, + file.id, + 0, + initial_data, + sanctum::crypto::FORMAT_VERSION_V3, + gen1, + ) + .unwrap(); + db.write_chunk_and_update_size( + file.id, + 0, + gen1, + &nonce1, + &tag1, + &ct1, + initial_data.len() as u64, + 1000, + ) + .unwrap(); + db.checkpoint().unwrap(); + + // Ciphertext-Zeile von Zustand 1 sichern (Nonce, Tag, Ciphertext) + let saved_chunk1 = db.read_chunk(file.id, 0).unwrap().unwrap(); + assert_eq!(saved_chunk1.generation, gen1); + + // 2. Chunk mit neuem Inhalt überschreiben (Zustand 2) + let updated_data = b"State 2: Updated overwritten content in chunk 0."; + let gen2 = db.next_chunk_generation(file.id, 0).unwrap(); + assert!(gen2 > gen1, "Generation muss monoton steigen"); + let (ct2, nonce2, tag2) = encrypt_chunk( + &dek, + file.id, + 0, + updated_data, + sanctum::crypto::FORMAT_VERSION_V3, + gen2, + ) + .unwrap(); + db.write_chunk_and_update_size( + file.id, + 0, + gen2, + &nonce2, + &tag2, + &ct2, + updated_data.len() as u64, + 2000, + ) + .unwrap(); + db.checkpoint().unwrap(); + + let current_chunk = db.read_chunk(file.id, 0).unwrap().unwrap(); + assert_eq!(current_chunk.generation, gen2); + + // 3. Replay-Angriff: Angreifer spielt alte Ciphertext-Zeile von Zustand 1 zurück in die SQLite-Tabelle + { + let conn = rusqlite::Connection::open(&db_path).unwrap(); + conn.execute( + "UPDATE chunks SET nonce = ?1, tag = ?2, ciphertext = ?3 WHERE node_id = ?4 AND chunk_index = 0", + rusqlite::params![ + saved_chunk1.nonce.as_slice(), + saved_chunk1.tag.as_slice(), + saved_chunk1.ciphertext, + file.id, + ], + ) + .unwrap(); + } + + // Entschlüsselungsversuch muss fehlschlagen (AEAD Auth-Fehler wegen AAD-Generationsabweichung) + let replayed_chunk = db.read_chunk(file.id, 0).unwrap().unwrap(); + let decrypt_res = decrypt_chunk( + &dek, + file.id, + 0, + &replayed_chunk.ciphertext, + &replayed_chunk.nonce, + &replayed_chunk.tag, + sanctum::crypto::FORMAT_VERSION_V3, + replayed_chunk.generation, + ); + assert!( + decrypt_res.is_err(), + "K-02: Replay von altem Ciphertext in aktuellem Chunk-Slot muss durch AEAD AAD-Mismatch abgewiesen werden!" + ); + + // Verify muss Replay/Manipulierte Chunks erkennen + let verify_res = verify_container(&db_path, Some(&dek), true).unwrap(); + assert!( + !verify_res.is_healthy() || verify_res.corrupted_chunks > 0, + "Verify muss Replay/Manipulierte Chunks erkennen" + ); + + let _ = std::fs::remove_file(&db_path); +} + +