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.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,
+137 -10
View File
@@ -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>, [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<Vec<u8>> {
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(),
+1
View File
@@ -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 {
+244 -75
View File
@@ -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<u8>,
@@ -185,7 +186,7 @@ impl ContainerMeta {
#[derive(Clone)]
pub struct Database {
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 {
@@ -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<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.
@@ -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<Option<ChunkRecord>> {
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<u8> = row.get(0)?;
let tag_vec: Vec<u8> = row.get(1)?;
let ciphertext: Vec<u8> = 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<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.
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<Vec<u8>> {
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<Vec<u8>> {
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<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());
}
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<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<()> {
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<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 meta_row: Option<(u32, Option<Vec<u8>>, 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<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(
"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,
+6 -1
View File
@@ -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 {
+7 -6
View File
@@ -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);
+15 -3
View File
@@ -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)?;
}
}
+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
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");
+131 -2
View File
@@ -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);
}