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
+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(),