fix(crypto): K-02 — format v3 chunk replay protection with generation aad

This commit is contained in:
2026-09-19 00:52:13 +02:00
parent ad531d8393
commit 36a4094336
9 changed files with 553 additions and 99 deletions
+10
View File
@@ -115,6 +115,7 @@ pub fn read_carrier_block(
&chunk_rec.nonce, &chunk_rec.nonce,
&chunk_rec.tag, &chunk_rec.tag,
format_version, 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 // 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_nonce,
&inner_tag, &inner_tag,
format_version, format_version,
chunk_rec.generation,
)?; )?;
Ok(inner_plaintext) Ok(inner_plaintext)
@@ -167,6 +169,8 @@ pub fn write_carrier_block(
plaintext: &[u8], plaintext: &[u8],
format_version: u32, format_version: u32,
) -> Result<()> { ) -> Result<()> {
let gen = 0u64;
// 1. Innere Schicht verschlüsseln (mit dek_inner = DEK_1) // 1. Innere Schicht verschlüsseln (mit dek_inner = DEK_1)
let (inner_ct, inner_nonce, inner_tag) = encrypt_chunk( let (inner_ct, inner_nonce, inner_tag) = encrypt_chunk(
dek_inner, dek_inner,
@@ -174,6 +178,7 @@ pub fn write_carrier_block(
block_idx, block_idx,
plaintext, plaintext,
format_version, format_version,
gen,
)?; )?;
let inner_ct_len = inner_ct.len() as u32; let inner_ct_len = inner_ct.len() as u32;
@@ -208,12 +213,14 @@ pub fn write_carrier_block(
block_idx, block_idx,
&outer_plaintext, &outer_plaintext,
format_version, format_version,
gen,
)?; )?;
// 4. In SQLite schreiben (in-place Überschreiben des bestehenden Chunks) // 4. In SQLite schreiben (in-place Überschreiben des bestehenden Chunks)
db.write_chunk( db.write_chunk(
carrier_node_id, carrier_node_id,
block_idx, block_idx,
gen,
&outer_nonce, &outer_nonce,
&outer_tag, &outer_tag,
&outer_ct, &outer_ct,
@@ -234,17 +241,20 @@ pub fn shred_carrier_block(
let mut noise = vec![0u8; CHUNK_SIZE]; let mut noise = vec![0u8; CHUNK_SIZE];
OsRng.fill_bytes(&mut noise); OsRng.fill_bytes(&mut noise);
let gen = 0u64;
let (outer_ct, outer_nonce, outer_tag) = encrypt_chunk( let (outer_ct, outer_nonce, outer_tag) = encrypt_chunk(
dek_outer, dek_outer,
carrier_node_id, carrier_node_id,
block_idx, block_idx,
&noise, &noise,
format_version, format_version,
gen,
)?; )?;
db.write_chunk( db.write_chunk(
carrier_node_id, carrier_node_id,
block_idx, block_idx,
gen,
&outer_nonce, &outer_nonce,
&outer_tag, &outer_tag,
&outer_ct, &outer_ct,
+137 -10
View File
@@ -642,9 +642,21 @@ pub fn build_chunk_aad(node_id: i64, chunk_index: u32) -> [u8; 16] {
aad 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. /// 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, /// In Formatversion >= 2 wird der Chunk vor der Verschlüsselung transparent mit LZ4 komprimiert,
/// sofern dadurch eine Größenreduktion erzielt wird. /// 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. /// Gibt (ciphertext, nonce_12_bytes, tag_16_bytes) zurück.
pub fn encrypt_chunk( pub fn encrypt_chunk(
dek: &[u8; 32], dek: &[u8; 32],
@@ -652,6 +664,7 @@ pub fn encrypt_chunk(
chunk_index: u32, chunk_index: u32,
plaintext: &[u8], plaintext: &[u8],
format_version: u32, format_version: u32,
generation: u64,
) -> Result<(Vec<u8>, [u8; 12], [u8; 16])> { ) -> Result<(Vec<u8>, [u8; 12], [u8; 16])> {
let cipher = Aes256Gcm::new_from_slice(dek) let cipher = Aes256Gcm::new_from_slice(dek)
.map_err(|e| anyhow::anyhow!("AES-GCM Initialisierungsfehler: {e}"))?; .map_err(|e| anyhow::anyhow!("AES-GCM Initialisierungsfehler: {e}"))?;
@@ -660,7 +673,15 @@ pub fn encrypt_chunk(
OsRng.fill_bytes(&mut nonce_bytes); OsRng.fill_bytes(&mut nonce_bytes);
let nonce = Nonce::from_slice(&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 { let mut buffer = if format_version >= FORMAT_VERSION_V2 {
if plaintext.is_empty() { if plaintext.is_empty() {
@@ -685,7 +706,7 @@ pub fn encrypt_chunk(
}; };
let tag = cipher 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}"))?; .map_err(|e| anyhow::anyhow!("Chunk-Verschlüsselung fehlgeschlagen: {e}"))?;
let mut tag_bytes = [0u8; 16]; 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. /// Entschlüsselt und authentifiziert einen Payload-Chunk mit dem DEK via AES-256-GCM.
/// Dekomprimiert LZ4-gepackte Chunks automatisch (in Formatversion >= 2). /// 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( pub fn decrypt_chunk(
dek: &[u8; 32], dek: &[u8; 32],
node_id: i64, node_id: i64,
@@ -704,17 +726,27 @@ pub fn decrypt_chunk(
nonce_bytes: &[u8; 12], nonce_bytes: &[u8; 12],
tag_bytes: &[u8; 16], tag_bytes: &[u8; 16],
format_version: u32, format_version: u32,
generation: u64,
) -> Result<Vec<u8>> { ) -> Result<Vec<u8>> {
let cipher = Aes256Gcm::new_from_slice(dek) let cipher = Aes256Gcm::new_from_slice(dek)
.map_err(|e| anyhow::anyhow!("AES-GCM Initialisierungsfehler: {e}"))?; .map_err(|e| anyhow::anyhow!("AES-GCM Initialisierungsfehler: {e}"))?;
let nonce = Nonce::from_slice(nonce_bytes); let nonce = Nonce::from_slice(nonce_bytes);
let tag = Tag::from_slice(tag_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(); let mut buffer = ciphertext.to_vec();
cipher cipher
.decrypt_in_place_detached(nonce, &aad, &mut buffer, tag) .decrypt_in_place_detached(nonce, aad, &mut buffer, tag)
.map_err(|_| { .map_err(|_| {
anyhow::anyhow!( anyhow::anyhow!(
"Chunk-Integritätsprüfung fehlgeschlagen (AEAD Auth-Fehler oder Swap-Angriff)" "Chunk-Integritätsprüfung fehlgeschlagen (AEAD Auth-Fehler oder Swap-Angriff)"
@@ -795,7 +827,7 @@ mod tests {
let chunk_index = 0u32; let chunk_index = 0u32;
let (ciphertext, nonce, tag) = 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) // Reguläre Entschlüsselung (v2)
let decrypted = decrypt_chunk( let decrypted = decrypt_chunk(
@@ -806,6 +838,7 @@ mod tests {
&nonce, &nonce,
&tag, &tag,
FORMAT_VERSION_V2, FORMAT_VERSION_V2,
0,
) )
.unwrap(); .unwrap();
assert_eq!(decrypted, plaintext); assert_eq!(decrypted, plaintext);
@@ -819,6 +852,7 @@ mod tests {
&nonce, &nonce,
&tag, &tag,
FORMAT_VERSION_V2, FORMAT_VERSION_V2,
0,
); );
assert!(swap_node_err.is_err()); assert!(swap_node_err.is_err());
@@ -831,6 +865,7 @@ mod tests {
&nonce, &nonce,
&tag, &tag,
FORMAT_VERSION_V2, FORMAT_VERSION_V2,
0,
); );
assert!(swap_idx_err.is_err()); assert!(swap_idx_err.is_err());
@@ -844,11 +879,100 @@ mod tests {
&tampered_ct, &tampered_ct,
&nonce, &nonce,
&tag, &tag,
FORMAT_VERSION_V2 FORMAT_VERSION_V2,
0,
) )
.is_err()); .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] #[test]
fn test_lz4_chunk_compression_efficiency() { fn test_lz4_chunk_compression_efficiency() {
let dek = generate_dek(); let dek = generate_dek();
@@ -859,7 +983,7 @@ mod tests {
let chunk_index = 0u32; let chunk_index = 0u32;
let (ciphertext, nonce, tag) = 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 // Der komprimierte Ciphertext muss signifikant kleiner sein als der Klartext
assert!( assert!(
@@ -877,6 +1001,7 @@ mod tests {
&nonce, &nonce,
&tag, &tag,
FORMAT_VERSION_V2, FORMAT_VERSION_V2,
0,
) )
.unwrap(); .unwrap();
assert_eq!(decrypted, plaintext); assert_eq!(decrypted, plaintext);
@@ -889,11 +1014,11 @@ mod tests {
let mut random_bytes = vec![0u8; 1000]; let mut random_bytes = vec![0u8; 1000];
OsRng.fill_bytes(&mut random_bytes); 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 // Da Kompression keine 64 Bytes spart, wird COMPRESSION_NONE (1 Byte) + Plaintext gespeichert
assert_eq!(ct.len(), random_bytes.len() + 1); 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); assert_eq!(decrypted, random_bytes);
} }
@@ -906,7 +1031,7 @@ mod tests {
// V1 Format: Reine Verschlüsselung ohne Kompressionspräfix // V1 Format: Reine Verschlüsselung ohne Kompressionspräfix
let (ciphertext, nonce, tag) = 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()); assert_eq!(ciphertext.len(), plaintext.len());
let decrypted = decrypt_chunk( let decrypted = decrypt_chunk(
@@ -917,6 +1042,7 @@ mod tests {
&nonce, &nonce,
&tag, &tag,
FORMAT_VERSION_V1, FORMAT_VERSION_V1,
0,
) )
.unwrap(); .unwrap();
assert_eq!(decrypted, plaintext); assert_eq!(decrypted, plaintext);
@@ -1163,6 +1289,7 @@ mod tests {
&nonce_bytes, &nonce_bytes,
&tag_bytes, &tag_bytes,
FORMAT_VERSION_V2, FORMAT_VERSION_V2,
0,
); );
assert!( assert!(
res.is_err(), res.is_err(),
+1
View File
@@ -297,6 +297,7 @@ pub fn detect_recovery_key_slot(db: &Database, dek: &[u8; 32], version: u32) ->
&chunk0.nonce, &chunk0.nonce,
&chunk0.tag, &chunk0.tag,
version, version,
chunk0.generation,
) )
.is_ok(); .is_ok();
if is_dek0 { if is_dek0 {
+244 -75
View File
@@ -32,6 +32,7 @@ pub struct NodeRecord {
pub struct ChunkRecord { pub struct ChunkRecord {
pub node_id: i64, pub node_id: i64,
pub chunk_index: u32, pub chunk_index: u32,
pub generation: u64,
pub nonce: [u8; 12], pub nonce: [u8; 12],
pub tag: [u8; 16], pub tag: [u8; 16],
pub ciphertext: Vec<u8>, pub ciphertext: Vec<u8>,
@@ -185,7 +186,7 @@ impl ContainerMeta {
#[derive(Clone)] #[derive(Clone)]
pub struct Database { pub struct Database {
conn: Arc<Mutex<Connection>>, conn: Arc<Mutex<Connection>>,
active_dek: Arc<Mutex<Option<Zeroizing<[u8; 32]>>>>, active_session: Arc<Mutex<Option<(u32, Zeroizing<[u8; 32]>)>>>,
} }
fn current_timestamp() -> u64 { fn current_timestamp() -> u64 {
@@ -306,7 +307,7 @@ impl Database {
let db = Self { let db = Self {
conn: Arc::new(Mutex::new(conn)), conn: Arc::new(Mutex::new(conn)),
active_dek: Arc::new(Mutex::new(None)), active_session: Arc::new(Mutex::new(None)),
}; };
db.init_pragmas()?; db.init_pragmas()?;
if table_count > 0 { if table_count > 0 {
@@ -321,7 +322,7 @@ impl Database {
let conn = Connection::open_in_memory()?; let conn = Connection::open_in_memory()?;
let db = Self { let db = Self {
conn: Arc::new(Mutex::new(conn)), conn: Arc::new(Mutex::new(conn)),
active_dek: Arc::new(Mutex::new(None)), active_session: Arc::new(Mutex::new(None)),
}; };
db.init_pragmas()?; db.init_pragmas()?;
db.ensure_schema_upgrades()?; db.ensure_schema_upgrades()?;
@@ -332,14 +333,31 @@ impl Database {
self.conn.lock().unwrap() 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]>) { 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. /// Gibt den aktuellen aktiven DEK zurück, falls gesetzt.
pub fn active_dek(&self) -> Option<Zeroizing<[u8; 32]>> { pub fn active_dek(&self) -> Option<Zeroizing<[u8; 32]>> {
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. /// Authentifiziert ein Master-Passwort gegen den Container in konstanter Zeit.
@@ -347,7 +365,7 @@ impl Database {
let meta = self.read_meta()?; let meta = self.read_meta()?;
let res = meta.authenticate(password); let res = meta.authenticate(password);
if let Some(ref keys) = res { 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() { if let Some(cid) = keys.carrier_node_id() {
let _ = self.mark_carrier_node_id(cid); 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(()) Ok(())
} }
@@ -571,6 +595,7 @@ impl Database {
CREATE TABLE IF NOT EXISTS chunks ( CREATE TABLE IF NOT EXISTS chunks (
node_id INTEGER NOT NULL, node_id INTEGER NOT NULL,
chunk_index INTEGER NOT NULL, chunk_index INTEGER NOT NULL,
generation INTEGER NOT NULL DEFAULT 0,
nonce BLOB NOT NULL, nonce BLOB NOT NULL,
tag BLOB NOT NULL, tag BLOB NOT NULL,
ciphertext BLOB NOT NULL, ciphertext BLOB NOT NULL,
@@ -652,7 +677,7 @@ impl Database {
let manifest_bytes = serde_json::to_vec(&manifest)?; let manifest_bytes = serde_json::to_vec(&manifest)?;
let (inner_ct, inner_nonce, inner_tag) = 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 inner_ct_len = inner_ct.len() as u32;
let mut outer_plaintext = vec![0u8; CHUNK_SIZE]; let mut outer_plaintext = vec![0u8; CHUNK_SIZE];
@@ -668,18 +693,18 @@ impl Database {
outer_plaintext[32..ct_end].copy_from_slice(&inner_ct); outer_plaintext[32..ct_end].copy_from_slice(&inner_ct);
let (outer_ct, outer_nonce, outer_tag) = 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( conn.execute(
"INSERT INTO chunks (node_id, chunk_index, nonce, tag, ciphertext) "INSERT INTO chunks (node_id, chunk_index, generation, nonce, tag, ciphertext)
VALUES (?1, 0, ?2, ?3, ?4)", VALUES (?1, 0, 0, ?2, ?3, ?4)",
params![c_id, outer_nonce.as_slice(), outer_tag.as_slice(), outer_ct], params![c_id, outer_nonce.as_slice(), outer_tag.as_slice(), outer_ct],
)?; )?;
// Blöcke 1..total_blocks-1 mit DEK_0 vorallokieren // Blöcke 1..total_blocks-1 mit DEK_0 vorallokieren
let mut chunk_stmt = conn.prepare( let mut chunk_stmt = conn.prepare(
"INSERT INTO chunks (node_id, chunk_index, nonce, tag, ciphertext) "INSERT INTO chunks (node_id, chunk_index, generation, nonce, tag, ciphertext)
VALUES (?1, ?2, ?3, ?4, ?5)", VALUES (?1, ?2, 0, ?3, ?4, ?5)",
)?; )?;
let mut dummy_noise = vec![0u8; CHUNK_SIZE]; let mut dummy_noise = vec![0u8; CHUNK_SIZE];
@@ -688,7 +713,7 @@ impl Database {
conn.execute_batch("BEGIN TRANSACTION;")?; conn.execute_batch("BEGIN TRANSACTION;")?;
for b in 1..total_blocks { for b in 1..total_blocks {
let (ct, nonce, tag) = 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,])?; chunk_stmt.execute(params![c_id, b, nonce.as_slice(), tag.as_slice(), ct,])?;
if b % 500 == 0 { if b % 500 == 0 {
@@ -696,9 +721,14 @@ impl Database {
} }
} }
conn.execute_batch("COMMIT;")?; conn.execute_batch("COMMIT;")?;
drop(chunk_stmt);
self.set_active_dek(Zeroizing::new(*dek_0)); self.set_active_slot_and_dek(0, Zeroizing::new(*dek_0));
Some(c_id) 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 { } else {
// Slot 1 mit CSPRNG-Zufallsdaten gleicher Struktur und Entropie (72 Bytes für Modell A) // 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(); let (dummy_dek, dummy_nonce, dummy_tag, dummy_salt) = generate_dummy_slot();
@@ -772,6 +802,7 @@ impl Database {
CREATE TABLE IF NOT EXISTS chunks ( CREATE TABLE IF NOT EXISTS chunks (
node_id INTEGER NOT NULL, node_id INTEGER NOT NULL,
chunk_index INTEGER NOT NULL, chunk_index INTEGER NOT NULL,
generation INTEGER NOT NULL DEFAULT 0,
nonce BLOB NOT NULL, nonce BLOB NOT NULL,
tag BLOB NOT NULL, tag BLOB NOT NULL,
ciphertext 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<Option<ChunkRecord>> { pub fn read_chunk(&self, node_id: i64, chunk_index: u32) -> Result<Option<ChunkRecord>> {
let conn = self.conn.lock().unwrap(); let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare( 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 let record = stmt
@@ -1454,6 +1485,7 @@ impl Database {
let nonce_vec: Vec<u8> = row.get(0)?; let nonce_vec: Vec<u8> = row.get(0)?;
let tag_vec: Vec<u8> = row.get(1)?; let tag_vec: Vec<u8> = row.get(1)?;
let ciphertext: Vec<u8> = row.get(2)?; let ciphertext: Vec<u8> = row.get(2)?;
let generation: i64 = row.get(3).unwrap_or(0);
let mut nonce = [0u8; 12]; let mut nonce = [0u8; 12];
let mut tag = [0u8; 16]; let mut tag = [0u8; 16];
@@ -1467,6 +1499,7 @@ impl Database {
Ok(ChunkRecord { Ok(ChunkRecord {
node_id, node_id,
chunk_index, chunk_index,
generation: generation as u64,
nonce, nonce,
tag, tag,
ciphertext, ciphertext,
@@ -1477,26 +1510,52 @@ impl Database {
Ok(record) 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<u64> {
let conn = self.conn.lock().unwrap();
let current_gen: Option<i64> = 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. /// Schreibt oder aktualisiert einen verschlüsselten Chunk in der Datenbank.
pub fn write_chunk( pub fn write_chunk(
&self, &self,
node_id: i64, node_id: i64,
chunk_index: u32, chunk_index: u32,
generation: u64,
nonce: &[u8; 12], nonce: &[u8; 12],
tag: &[u8; 16], tag: &[u8; 16],
ciphertext: &[u8], ciphertext: &[u8],
) -> Result<()> { ) -> Result<()> {
let conn = self.conn.lock().unwrap(); let conn = self.conn.lock().unwrap();
conn.execute( conn.execute(
"INSERT INTO chunks (node_id, chunk_index, nonce, tag, ciphertext) "INSERT INTO chunks (node_id, chunk_index, generation, nonce, tag, ciphertext)
VALUES (?1, ?2, ?3, ?4, ?5) VALUES (?1, ?2, ?3, ?4, ?5, ?6)
ON CONFLICT(node_id, chunk_index) DO UPDATE SET ON CONFLICT(node_id, chunk_index) DO UPDATE SET
generation = excluded.generation,
nonce = excluded.nonce, nonce = excluded.nonce,
tag = excluded.tag, tag = excluded.tag,
ciphertext = excluded.ciphertext", ciphertext = excluded.ciphertext",
params![ params![
node_id, node_id,
chunk_index, chunk_index,
generation as i64,
nonce.as_slice(), nonce.as_slice(),
tag.as_slice(), tag.as_slice(),
ciphertext, ciphertext,
@@ -1511,6 +1570,7 @@ impl Database {
&self, &self,
node_id: i64, node_id: i64,
chunk_index: u32, chunk_index: u32,
generation: u64,
nonce: &[u8; 12], nonce: &[u8; 12],
tag: &[u8; 16], tag: &[u8; 16],
ciphertext: &[u8], ciphertext: &[u8],
@@ -1521,15 +1581,17 @@ impl Database {
let mut conn = self.conn.lock().unwrap(); let mut conn = self.conn.lock().unwrap();
let tx = conn.transaction()?; let tx = conn.transaction()?;
tx.execute( tx.execute(
"INSERT INTO chunks (node_id, chunk_index, nonce, tag, ciphertext) "INSERT INTO chunks (node_id, chunk_index, generation, nonce, tag, ciphertext)
VALUES (?1, ?2, ?3, ?4, ?5) VALUES (?1, ?2, ?3, ?4, ?5, ?6)
ON CONFLICT(node_id, chunk_index) DO UPDATE SET ON CONFLICT(node_id, chunk_index) DO UPDATE SET
generation = excluded.generation,
nonce = excluded.nonce, nonce = excluded.nonce,
tag = excluded.tag, tag = excluded.tag,
ciphertext = excluded.ciphertext", ciphertext = excluded.ciphertext",
params![ params![
node_id, node_id,
chunk_index, chunk_index,
generation as i64,
nonce.as_slice(), nonce.as_slice(),
tag.as_slice(), tag.as_slice(),
ciphertext, ciphertext,
@@ -1596,65 +1658,100 @@ impl Database {
/// Erzeugt die deterministische kanonische Byterepräsentation aller Knoten für den Metadaten-MAC (K-01). /// Erzeugt die deterministische kanonische Byterepräsentation aller Knoten für den Metadaten-MAC (K-01).
pub fn canonical_nodes_bytes(&self) -> Result<Vec<u8>> { pub fn canonical_nodes_bytes(&self) -> Result<Vec<u8>> {
let conn = self.conn.lock().unwrap(); self.canonical_nodes_bytes_for_vault(0)
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",
)?;
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<Vec<u8>> {
let conn = self.conn.lock().unwrap();
let mut buf = Vec::new(); let mut buf = Vec::new();
while let Some(row) = rows.next()? { if vault_id == 1 {
let id: i64 = row.get(0)?; let mut stmt = conn.prepare(
let parent_id: Option<i64> = row.get(1)?; "WITH RECURSIVE vault1(id) AS (
let name: String = row.get(2)?; SELECT 2
let is_dir: i64 = row.get(3)?; UNION ALL
let size: i64 = row.get(4)?; SELECT n.id FROM nodes n JOIN vault1 v ON n.parent_id = v.id
let created_at: i64 = row.get(5)?; )
let modified_at: i64 = row.get(6)?; SELECT n.id, n.parent_id, n.name, n.is_dir, n.size, n.created_at, n.modified_at, n.is_carrier,
let is_carrier: i64 = row.get(7)?; (SELECT COUNT(*) FROM chunks c WHERE c.node_id = n.id) as chunk_count
let chunk_count: i64 = row.get(8)?; FROM nodes n
WHERE n.id IN (SELECT id FROM vault1)
buf.extend_from_slice(&id.to_le_bytes()); ORDER BY n.id ASC",
match parent_id { )?;
Some(pid) => { let mut rows = stmt.query([])?;
buf.push(1u8); while let Some(row) = rows.next()? {
buf.extend_from_slice(&pid.to_le_bytes()); Self::serialize_node_row(&row, &mut buf)?;
} }
None => { } else {
buf.push(0u8); let mut stmt = conn.prepare(
buf.extend_from_slice(&0i64.to_le_bytes()); "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) 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<u8>) -> Result<()> {
let id: i64 = row.get(0)?;
let parent_id: Option<i64> = 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<()> { pub fn update_metadata_mac(&self) -> Result<()> {
let dek_opt = self.active_dek.lock().unwrap().clone(); let session_opt = self.active_session.lock().unwrap().clone();
let Some(dek) = dek_opt else { let Some((slot_id, dek)) = session_opt else {
return Ok(()); return Ok(());
}; };
let conn = self.conn.lock().unwrap(); let conn = self.conn.lock().unwrap();
let version_and_gen: Option<(u32, u64)> = conn let version_and_gen: Option<(u32, u64)> = conn
.query_row( .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))), |r| Ok((r.get(0)?, r.get(1).unwrap_or(0))),
) )
.optional()?; .optional()?;
@@ -1670,14 +1767,14 @@ impl Database {
drop(conn); drop(conn);
let next_gen = current_gen + 1; 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 mac_key = derive_metadata_mac_key(&dek);
let new_mac = compute_metadata_mac(&mac_key, next_gen, &canonical); let new_mac = compute_metadata_mac(&mac_key, next_gen, &canonical);
let conn = self.conn.lock().unwrap(); let conn = self.conn.lock().unwrap();
conn.execute( conn.execute(
"UPDATE meta SET metadata_mac = ?1, metadata_gen = ?2 WHERE slot_id = 0", "UPDATE meta SET metadata_mac = ?1, metadata_gen = ?2 WHERE slot_id = ?3",
params![new_mac.as_slice(), next_gen], params![new_mac.as_slice(), next_gen, slot_id],
)?; )?;
Ok(()) Ok(())
@@ -1685,17 +1782,28 @@ impl Database {
/// Prüft die Integrität des Metadaten-MAC gegen den gegebenen DEK (Format V3 / K-01). /// 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<bool> { pub fn verify_metadata_mac(&self, dek: &[u8; 32]) -> Result<bool> {
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<bool> {
let conn = self.conn.lock().unwrap(); let conn = self.conn.lock().unwrap();
let meta_row: Option<(u32, Option<Vec<u8>>, u64)> = conn let meta_row: Option<(u32, Option<Vec<u8>>, u64)> = conn
.query_row( .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))), |r| Ok((r.get(0)?, r.get(1).ok(), r.get(2).unwrap_or(0))),
) )
.optional()?; .optional()?;
let Some((version, mac_opt, gen)) = meta_row else { let Some((version, mac_opt, gen)) = meta_row else {
return Ok(true); return Ok(false);
}; };
if version < FORMAT_VERSION_V3 { if version < FORMAT_VERSION_V3 {
@@ -1714,7 +1822,7 @@ impl Database {
expected_mac.copy_from_slice(&mac_bytes); expected_mac.copy_from_slice(&mac_bytes);
drop(conn); 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); let mac_key = derive_metadata_mac_key(dek);
Ok(verify_metadata_mac(&mac_key, gen, &canonical, &expected_mac)) 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<u8>)> = chunk_stmt
.query_map([], |row| {
let node_id: i64 = row.get(0)?;
let chunk_index: u32 = row.get(1)?;
let nonce_vec: Vec<u8> = row.get(2)?;
let tag_vec: Vec<u8> = row.get(3)?;
let ciphertext: Vec<u8> = 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::<std::result::Result<Vec<_>, _>>()?;
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( conn.execute(
"UPDATE meta SET version = ?1, metadata_gen = 0 WHERE slot_id = 0", "UPDATE meta SET version = ?1, metadata_gen = 0 WHERE slot_id = 0",
[FORMAT_VERSION_V3], [FORMAT_VERSION_V3],
@@ -1762,6 +1929,7 @@ impl Database {
conn.query_row("PRAGMA wal_checkpoint(TRUNCATE);", [], |row| { conn.query_row("PRAGMA wal_checkpoint(TRUNCATE);", [], |row| {
Ok((row.get(0)?, row.get(1)?, row.get(2)?)) Ok((row.get(0)?, row.get(1)?, row.get(2)?))
})?; })?;
let _ = conn.execute_batch("PRAGMA incremental_vacuum;");
Ok(()) Ok(())
} }
@@ -2042,7 +2210,7 @@ mod tests {
let test_cipher = b"ENCRYPTED_DATA_BLOCK"; let test_cipher = b"ENCRYPTED_DATA_BLOCK";
let c_nonce = [7u8; 12]; let c_nonce = [7u8; 12];
let c_tag = [8u8; 16]; 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(); .unwrap();
let chunk = db let chunk = db
@@ -2089,7 +2257,7 @@ mod tests {
let c_nonce = [5u8; 12]; let c_nonce = [5u8; 12];
let c_tag = [6u8; 16]; let c_tag = [6u8; 16];
for i in 0..20 { 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(); .unwrap();
} }
db.checkpoint().unwrap(); db.checkpoint().unwrap();
@@ -2135,7 +2303,7 @@ mod tests {
let sensitive_payload = b"VERY_SENSITIVE_PLAINTEXT_OR_CIPHERTEXT"; let sensitive_payload = b"VERY_SENSITIVE_PLAINTEXT_OR_CIPHERTEXT";
let c_nonce = [10u8; 12]; let c_nonce = [10u8; 12];
let c_tag = [11u8; 16]; 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(); .unwrap();
// Shredde Chunks // Shredde Chunks
@@ -2322,6 +2490,7 @@ mod tests {
db.write_chunk_and_update_size( db.write_chunk_and_update_size(
file.id, file.id,
0, 0,
0,
&chunk_nonce, &chunk_nonce,
&chunk_tag, &chunk_tag,
&ciphertext, &ciphertext,
+6 -1
View File
@@ -57,6 +57,7 @@ fn calc_vault_node_sha256(
&record.nonce, &record.nonce,
&record.tag, &record.tag,
version, version,
record.generation,
)?; )?;
hasher.update(&decrypted); hasher.update(&decrypted);
} }
@@ -313,12 +314,15 @@ pub fn sync_single_file_to_vault(
} }
let chunk_data = &buffer[..n]; 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; bytes_written += n as u64;
db.write_chunk_and_update_size( db.write_chunk_and_update_size(
node_id, node_id,
chunk_idx, chunk_idx,
gen,
&nonce, &nonce,
&tag, &tag,
&ciphertext, &ciphertext,
@@ -409,6 +413,7 @@ pub fn sync_single_file_to_host(
&record.nonce, &record.nonce,
&record.tag, &record.tag,
version, version,
record.generation,
)?; )?;
out_file.write_all(&plaintext)?; out_file.write_all(&plaintext)?;
} else { } else {
+7 -6
View File
@@ -301,6 +301,7 @@ pub fn verify_container(
&record.nonce, &record.nonce,
&record.tag, &record.tag,
format_version, format_version,
record.generation,
) { ) {
Ok(plaintext) => { Ok(plaintext) => {
report.total_bytes_decrypted += plaintext.len() as u64; report.total_bytes_decrypted += plaintext.len() as u64;
@@ -373,12 +374,12 @@ mod tests {
// 2 Chunks schreiben // 2 Chunks schreiben
let chunk0_data = b"Sample JPEG data header and pixels"; 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(); let (ct0, n0, t0) = encrypt_chunk(&dek, file.id, 0, chunk0_data, FORMAT_VERSION, 0).unwrap();
db.write_chunk(file.id, 0, &n0, &t0, &ct0).unwrap(); db.write_chunk(file.id, 0, 0, &n0, &t0, &ct0).unwrap();
let chunk1_data = b"Additional payload data bytes"; let chunk1_data = b"Additional payload data bytes";
let (ct1, n1, t1) = encrypt_chunk(&dek, file.id, 1, chunk1_data, FORMAT_VERSION).unwrap(); let (ct1, n1, t1) = encrypt_chunk(&dek, file.id, 1, chunk1_data, FORMAT_VERSION, 0).unwrap();
db.write_chunk(file.id, 1, &n1, &t1, &ct1).unwrap(); db.write_chunk(file.id, 1, 0, &n1, &t1, &ct1).unwrap();
db.update_node_size_and_time( db.update_node_size_and_time(
file.id, file.id,
@@ -432,8 +433,8 @@ mod tests {
let file = db.create_node(1, "document.pdf", false).unwrap(); let file = db.create_node(1, "document.pdf", false).unwrap();
let chunk_data = b"Vital documents that must not be corrupted"; 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(); let (ct, n, t) = encrypt_chunk(&dek, file.id, 0, chunk_data, FORMAT_VERSION, 0).unwrap();
db.write_chunk(file.id, 0, &n, &t, &ct).unwrap(); db.write_chunk(file.id, 0, 0, &n, &t, &ct).unwrap();
db.checkpoint().unwrap(); db.checkpoint().unwrap();
drop(db); drop(db);
+15 -3
View File
@@ -171,8 +171,12 @@ impl SanctumFile {
.unwrap_or(0); .unwrap_or(0);
if let Some((idx, ref data, true)) = self.cached_chunk { 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) = 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| { |e| {
error!("Verschlüsselungsfehler beim Chunk-Flush: {e}"); error!("Verschlüsselungsfehler beim Chunk-Flush: {e}");
FsError::GeneralFailure FsError::GeneralFailure
@@ -182,6 +186,7 @@ impl SanctumFile {
if let Err(e) = self.db.write_chunk_and_update_size( if let Err(e) = self.db.write_chunk_and_update_size(
self.node_id, self.node_id,
idx, idx,
gen,
&nonce, &nonce,
&tag, &tag,
&ciphertext, &ciphertext,
@@ -246,6 +251,7 @@ impl SanctumFile {
&record.nonce, &record.nonce,
&record.tag, &record.tag,
self.format_version, self.format_version,
record.generation,
) )
.map_err(|e| { .map_err(|e| {
error!("AEAD-Entschlüsselungsfehler bei Chunk #{chunk_index}: {e}"); error!("AEAD-Entschlüsselungsfehler bei Chunk #{chunk_index}: {e}");
@@ -490,7 +496,7 @@ impl SanctumFs {
let dek_arc = Arc::new(dek); let dek_arc = Arc::new(dek);
let carrier_dek_arc = carrier_dek.map(Arc::new); 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, // 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. // um die Trägerdatei vor versehentlichem Löschen oder Überschreiben zu schützen.
@@ -1018,20 +1024,26 @@ impl DavFileSystem for SanctumFs {
&record.nonce, &record.nonce,
&record.tag, &record.tag,
self.format_version, self.format_version,
record.generation,
) )
.map_err(|_| FsError::GeneralFailure)?; .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( let (new_ct, new_nonce, new_tag) = encrypt_chunk(
&self.dek, &self.dek,
dest_node.id, dest_node.id,
idx, idx,
&plaintext, &plaintext,
self.format_version, self.format_version,
gen,
) )
.map_err(|_| FsError::GeneralFailure)?; .map_err(|_| FsError::GeneralFailure)?;
self.db 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)?; .map_err(|_| FsError::GeneralFailure)?;
} }
} }
+2 -2
View File
@@ -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 // 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 v1_plaintext = b"Legacy Sanctum V1 uncompressed data payload.";
let (ct, nonce, tag) = let (ct, nonce, tag) =
encrypt_chunk(&dek, node.id, 0, v1_plaintext, FORMAT_VERSION_V1).expect("encrypt v1"); encrypt_chunk(&dek, node.id, 0, v1_plaintext, FORMAT_VERSION_V1, 0).expect("encrypt v1");
db.write_chunk(node.id, 0, &nonce, &tag, &ct) db.write_chunk(node.id, 0, 0, &nonce, &tag, &ct)
.expect("write chunk"); .expect("write chunk");
db.update_node_size_and_time(node.id, v1_plaintext.len() as u64, 12345678) db.update_node_size_and_time(node.id, v1_plaintext.len() as u64, 12345678)
.expect("update size"); .expect("update size");
+131 -2
View File
@@ -1,6 +1,6 @@
use sanctum::crypto::{ use sanctum::crypto::{
derive_kek, generate_dek, generate_salt, wrap_dek, wrap_slot0_payload, KdfParams, decrypt_chunk, derive_kek, encrypt_chunk, generate_dek, generate_salt, wrap_dek,
MIN_MEMORY_COST_KIB, MIN_TIME_COST, wrap_slot0_payload, KdfParams, MIN_MEMORY_COST_KIB, MIN_TIME_COST,
}; };
use sanctum::storage::Database; use sanctum::storage::Database;
use sanctum::verify::verify_container; use sanctum::verify::verify_container;
@@ -869,3 +869,132 @@ fn test_k01_upgrade_format_v2_to_v3() {
let _ = std::fs::remove_file(&db_path); 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);
}