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
+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);
}