fix(crypto): K-02 — format v3 chunk replay protection with generation aad
This commit is contained in:
+137
-10
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user