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