fix(format-v3): transcript versioning in HMAC and atomic upgrade MAC (F-04, F-05)
This commit is contained in:
@@ -466,7 +466,7 @@ async fn test_f01_migration_existing_v3_container() {
|
||||
{
|
||||
let conn = db.conn();
|
||||
conn.execute(
|
||||
"UPDATE meta SET metadata_mac = ?1, metadata_gen = 1 WHERE slot_id = 0",
|
||||
"UPDATE meta SET metadata_mac = ?1, metadata_gen = 1, transcript_ver = 1 WHERE slot_id = 0",
|
||||
rusqlite::params![legacy_mac.as_slice()],
|
||||
)
|
||||
.unwrap();
|
||||
@@ -950,3 +950,422 @@ async fn test_f03_dual_vault_v2_upgrade_carrier() {
|
||||
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// F-04 TESTS: Transcript-Versionierung im HMAC & Rebind-Schutz
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_f04_replayed_093_mac_after_rebind_rejected() {
|
||||
let path = temp_container_path("f04_replayed_093_mac");
|
||||
let password = "TestPasswordF04_1!";
|
||||
let salt = generate_salt();
|
||||
let kdf = test_kdf_params();
|
||||
let kek = derive_kek(password, &salt, &kdf).unwrap();
|
||||
let dek = generate_dek();
|
||||
let (wrapped, nonce, tag) = wrap_dek(&kek, &dek).unwrap();
|
||||
|
||||
let db = Database::open(&path).unwrap();
|
||||
db.init_schema(&salt, &kdf, &wrapped, &nonce, &tag).unwrap();
|
||||
db.set_active_slot_and_dek(0, dek.clone());
|
||||
|
||||
let file_id = db.create_node(1, "document.pdf", false).unwrap().id;
|
||||
let chunk_data = b"Inhalt fuer Test F-04 Replay-Schutz";
|
||||
let (ct, c_nonce, c_tag) =
|
||||
encrypt_chunk(&dek, file_id, 0, chunk_data, FORMAT_VERSION_V3, 0).unwrap();
|
||||
{
|
||||
let conn = db.conn();
|
||||
conn.execute(
|
||||
"INSERT INTO chunks (node_id, chunk_index, generation, nonce, tag, ciphertext) VALUES (?1, 0, 0, ?2, ?3, ?4)",
|
||||
rusqlite::params![file_id, c_nonce.as_slice(), c_tag.as_slice(), ct],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// 1. Simuliere v0.9.3 Container: Metadaten-MAC über reine Knoten ohne transcript_ver
|
||||
let legacy_canonical = db.canonical_nodes_bytes_for_vault_legacy(0).unwrap();
|
||||
let mac_key = sanctum::crypto::derive_metadata_mac_key(&dek);
|
||||
let legacy_mac = sanctum::crypto::compute_metadata_mac(&mac_key, 1, &legacy_canonical);
|
||||
{
|
||||
let conn = db.conn();
|
||||
conn.execute(
|
||||
"UPDATE meta SET metadata_mac = ?1, metadata_gen = 1, transcript_ver = 1 WHERE slot_id = 0",
|
||||
rusqlite::params![legacy_mac.as_slice()],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
db.checkpoint().unwrap();
|
||||
|
||||
// Vor Mount: LegacyValid
|
||||
let status_before = db.verify_metadata_mac_status_for_slot(0, &dek).unwrap();
|
||||
assert_eq!(
|
||||
status_before,
|
||||
MetadataMacStatus::LegacyValid,
|
||||
"Alter 0.9.3 MAC muss vor Mount als LegacyValid erkannt werden"
|
||||
);
|
||||
drop(db);
|
||||
|
||||
// 2. Erster Mount: migriert transparent auf transcript_ver = 2
|
||||
let auth = ContainerAuth::Password(Zeroizing::new(password.to_string()));
|
||||
let c_path = path.clone();
|
||||
let mount_task = tokio::spawn(async move {
|
||||
mount_container(
|
||||
&c_path,
|
||||
'Y',
|
||||
None,
|
||||
Some(18108),
|
||||
auth,
|
||||
false,
|
||||
false,
|
||||
None,
|
||||
false,
|
||||
false,
|
||||
None,
|
||||
true,
|
||||
false,
|
||||
)
|
||||
.await
|
||||
});
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(250)).await;
|
||||
mount_task.abort();
|
||||
|
||||
// Nach Mount: Status muss Valid sein und transcript_ver = 2
|
||||
let db_after = Database::open(&path).unwrap();
|
||||
let status_after = db_after
|
||||
.verify_metadata_mac_status_for_slot(0, &dek)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
status_after,
|
||||
MetadataMacStatus::Valid,
|
||||
"Nach erstem Mount muss der MAC auf Transcript-Version 2 migriert sein"
|
||||
);
|
||||
|
||||
// 3. Angreifer versucht Rollback: Alten 0.9.3 MAC-Blob wieder einspielen
|
||||
{
|
||||
let conn = db_after.conn();
|
||||
conn.execute(
|
||||
"UPDATE meta SET metadata_mac = ?1, metadata_gen = 1 WHERE slot_id = 0",
|
||||
rusqlite::params![legacy_mac.as_slice()],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
db_after.checkpoint().unwrap();
|
||||
|
||||
// 4. Verifikation: Nach Rebind MUSS der alte MAC strikt Invalid sein (kein zweites LegacyValid!)
|
||||
let status_replayed = db_after
|
||||
.verify_metadata_mac_status_for_slot(0, &dek)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
status_replayed,
|
||||
MetadataMacStatus::Invalid,
|
||||
"Alter 0.9.3-MAC darf nach Rebind auf ver=2 NIEMALS mehr als LegacyValid akzeptiert werden (F-04)"
|
||||
);
|
||||
assert!(!db_after.verify_metadata_mac_for_slot(0, &dek).unwrap());
|
||||
|
||||
drop(db_after);
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_f04_native_094_mac_mount_rebinds_to_v2() {
|
||||
let path = temp_container_path("f04_094_rebind");
|
||||
let password = "TestPasswordF04_2!";
|
||||
let salt = generate_salt();
|
||||
let kdf = test_kdf_params();
|
||||
let kek = derive_kek(password, &salt, &kdf).unwrap();
|
||||
let dek = generate_dek();
|
||||
let (wrapped, nonce, tag) = wrap_dek(&kek, &dek).unwrap();
|
||||
|
||||
let db = Database::open(&path).unwrap();
|
||||
db.init_schema(&salt, &kdf, &wrapped, &nonce, &tag).unwrap();
|
||||
db.set_active_slot_and_dek(0, dek.clone());
|
||||
|
||||
let file_id = db.create_node(1, "notes.txt", false).unwrap().id;
|
||||
let chunk_data = b"0.9.4 natives Transcript ohne Versionsfeld";
|
||||
let (ct, c_nonce, c_tag) =
|
||||
encrypt_chunk(&dek, file_id, 0, chunk_data, FORMAT_VERSION_V3, 0).unwrap();
|
||||
{
|
||||
let conn = db.conn();
|
||||
conn.execute(
|
||||
"INSERT INTO chunks (node_id, chunk_index, generation, nonce, tag, ciphertext) VALUES (?1, 0, 0, ?2, ?3, ?4)",
|
||||
rusqlite::params![file_id, c_nonce.as_slice(), c_tag.as_slice(), ct],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// 0.9.4 MAC: canonical_v2 (mit Generationen), aber OHNE transcript_ver im Input
|
||||
let canonical_v2 = db.canonical_nodes_bytes_for_vault(0).unwrap();
|
||||
let mac_key = sanctum::crypto::derive_metadata_mac_key(&dek);
|
||||
let mac_094 = sanctum::crypto::compute_metadata_mac(&mac_key, 1, &canonical_v2);
|
||||
{
|
||||
let conn = db.conn();
|
||||
conn.execute(
|
||||
"UPDATE meta SET metadata_mac = ?1, metadata_gen = 1, transcript_ver = 1 WHERE slot_id = 0",
|
||||
rusqlite::params![mac_094.as_slice()],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
db.checkpoint().unwrap();
|
||||
|
||||
// Vor Mount: NeedsTranscriptVer
|
||||
let status_before = db.verify_metadata_mac_status_for_slot(0, &dek).unwrap();
|
||||
assert_eq!(
|
||||
status_before,
|
||||
MetadataMacStatus::NeedsTranscriptVer,
|
||||
"0.9.4 Container muss als NeedsTranscriptVer eingestuft werden"
|
||||
);
|
||||
assert!(db.verify_metadata_mac_for_slot(0, &dek).unwrap());
|
||||
drop(db);
|
||||
|
||||
// Mounten migriert auf ver=2
|
||||
let auth = ContainerAuth::Password(Zeroizing::new(password.to_string()));
|
||||
let c_path = path.clone();
|
||||
let mount_task = tokio::spawn(async move {
|
||||
mount_container(
|
||||
&c_path,
|
||||
'Y',
|
||||
None,
|
||||
Some(18109),
|
||||
auth,
|
||||
false,
|
||||
false,
|
||||
None,
|
||||
false,
|
||||
false,
|
||||
None,
|
||||
true,
|
||||
false,
|
||||
)
|
||||
.await
|
||||
});
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(250)).await;
|
||||
mount_task.abort();
|
||||
|
||||
let db_after = Database::open(&path).unwrap();
|
||||
let status_after = db_after
|
||||
.verify_metadata_mac_status_for_slot(0, &dek)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
status_after,
|
||||
MetadataMacStatus::Valid,
|
||||
"Nach Mount muss 0.9.4-Container auf transcript_ver=2 gebunden sein"
|
||||
);
|
||||
|
||||
drop(db_after);
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_f04_manipulated_transcript_ver_column_uses_hmac() {
|
||||
let path = temp_container_path("f04_manipulated_column");
|
||||
let password = "TestPasswordF04_3!";
|
||||
let salt = generate_salt();
|
||||
let kdf = test_kdf_params();
|
||||
let kek = derive_kek(password, &salt, &kdf).unwrap();
|
||||
let dek = generate_dek();
|
||||
let (wrapped, nonce, tag) = wrap_dek(&kek, &dek).unwrap();
|
||||
|
||||
let db = Database::open(&path).unwrap();
|
||||
db.init_schema(&salt, &kdf, &wrapped, &nonce, &tag).unwrap();
|
||||
db.set_active_slot_and_dek(0, dek.clone());
|
||||
let _ = db.create_node(1, "authentic.txt", false).unwrap();
|
||||
db.update_metadata_mac().unwrap();
|
||||
db.checkpoint().unwrap();
|
||||
|
||||
// Sicherstellen, dass MAC mit ver=2 berechnet wurde
|
||||
assert_eq!(
|
||||
db.verify_metadata_mac_status_for_slot(0, &dek).unwrap(),
|
||||
MetadataMacStatus::Valid
|
||||
);
|
||||
|
||||
// Angreifer manipuliert nur die SQLite-Spalte: transcript_ver = 1 (MAC ist weiterhin ver=2)
|
||||
{
|
||||
let conn = db.conn();
|
||||
conn.execute("UPDATE meta SET transcript_ver = 1 WHERE slot_id = 0", [])
|
||||
.unwrap();
|
||||
}
|
||||
db.checkpoint().unwrap();
|
||||
|
||||
// Verify nutzt den HMAC (Stufe 1 mit ver=2 trifft) und ignoriert die manipulierte Spalte -> Valid
|
||||
let status = db.verify_metadata_mac_status_for_slot(0, &dek).unwrap();
|
||||
assert_eq!(
|
||||
status,
|
||||
MetadataMacStatus::Valid,
|
||||
"Verify prüft HMAC ver=2 und lässt sich nicht durch manipulierte transcript_ver=1 Spalte täuschen (F-04)"
|
||||
);
|
||||
|
||||
drop(db);
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// F-05 TESTS: Atomare Transaktion & Zombie-Container Reparatur
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_f05_atomic_upgrade_commit_includes_mac() {
|
||||
let path = temp_container_path("f05_atomic_commit");
|
||||
let password = "TestPasswordF05_1!";
|
||||
let salt = generate_salt();
|
||||
let kdf = test_kdf_params();
|
||||
let kek = derive_kek(password, &salt, &kdf).unwrap();
|
||||
let dek = generate_dek();
|
||||
let (wrapped, nonce, tag) = wrap_dek(&kek, &dek).unwrap();
|
||||
|
||||
let db = Database::open(&path).unwrap();
|
||||
db.init_schema(&salt, &kdf, &wrapped, &nonce, &tag).unwrap();
|
||||
|
||||
// Auf V2 zurücksetzen
|
||||
{
|
||||
let conn = db.conn();
|
||||
conn.execute(
|
||||
"UPDATE meta SET version = 2, metadata_mac = NULL, metadata_gen = 0",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
db.checkpoint().unwrap();
|
||||
|
||||
// Upgrade auf V3 ausführen
|
||||
db.upgrade_to_v3(&dek).unwrap();
|
||||
db.checkpoint().unwrap();
|
||||
|
||||
// Verifizieren: In SQLite muss atomar version = 3, metadata_mac IS NOT NULL und transcript_ver = 2 gesetzt sein
|
||||
{
|
||||
let conn = db.conn();
|
||||
let (v, mac_opt, gen, trans_ver): (u32, Option<Vec<u8>>, u64, u32) = conn
|
||||
.query_row(
|
||||
"SELECT version, metadata_mac, metadata_gen, transcript_ver FROM meta WHERE slot_id = 0",
|
||||
[],
|
||||
|r| Ok((r.get(0)?, r.get(1).ok(), r.get(2)?, r.get(3)?)),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(v, 3);
|
||||
assert!(
|
||||
mac_opt.is_some(),
|
||||
"MAC muss direkt beim Commit persistiert sein"
|
||||
);
|
||||
assert_eq!(gen, 1);
|
||||
assert_eq!(trans_ver, 2);
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
db.verify_metadata_mac_status_for_slot(0, &dek).unwrap(),
|
||||
MetadataMacStatus::Valid
|
||||
);
|
||||
|
||||
drop(db);
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_f05_zombie_container_repaired_by_upgrade_format() {
|
||||
let path = temp_container_path("f05_zombie_repair");
|
||||
let password = "TestPasswordF05_2!";
|
||||
let salt = generate_salt();
|
||||
let kdf = test_kdf_params();
|
||||
let kek = derive_kek(password, &salt, &kdf).unwrap();
|
||||
let dek = generate_dek();
|
||||
let (wrapped, nonce, tag) = wrap_dek(&kek, &dek).unwrap();
|
||||
|
||||
let db = Database::open(&path).unwrap();
|
||||
db.init_schema(&salt, &kdf, &wrapped, &nonce, &tag).unwrap();
|
||||
db.set_active_slot_and_dek(0, dek.clone());
|
||||
let _ = db.create_node(1, "survivor.dat", false).unwrap();
|
||||
db.update_metadata_mac().unwrap();
|
||||
db.checkpoint().unwrap();
|
||||
|
||||
// Künstlich Zombie-Zustand herstellen: version=3, MAC NULL, restore_nonce NULL
|
||||
{
|
||||
let conn = db.conn();
|
||||
conn.execute(
|
||||
"UPDATE meta SET version = 3, metadata_mac = NULL, metadata_gen = 0, restore_nonce = NULL WHERE slot_id = 0",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
db.checkpoint().unwrap();
|
||||
|
||||
// Vor Reparatur: Status ist Invalid (F-02 Fail-Closed)
|
||||
assert_eq!(
|
||||
db.verify_metadata_mac_status_for_slot(0, &dek).unwrap(),
|
||||
MetadataMacStatus::Invalid
|
||||
);
|
||||
|
||||
// Reparatur via upgrade_to_v3 (bzw. Repair-Pfad)
|
||||
db.upgrade_to_v3(&dek).unwrap();
|
||||
db.checkpoint().unwrap();
|
||||
|
||||
// Nach Reparatur: Status muss Valid sein (ver=2)
|
||||
assert_eq!(
|
||||
db.verify_metadata_mac_status_for_slot(0, &dek).unwrap(),
|
||||
MetadataMacStatus::Valid,
|
||||
"Zombie-Container muss nach upgrade_to_v3 mit neuem MAC repariert sein"
|
||||
);
|
||||
|
||||
// Container muss nun fehlerfrei mountbar sein
|
||||
let auth = ContainerAuth::Password(Zeroizing::new(password.to_string()));
|
||||
let c_path = path.clone();
|
||||
let mount_task = tokio::spawn(async move {
|
||||
mount_container(
|
||||
&c_path,
|
||||
'Y',
|
||||
None,
|
||||
Some(18110),
|
||||
auth,
|
||||
false,
|
||||
false,
|
||||
None,
|
||||
false,
|
||||
false,
|
||||
None,
|
||||
true,
|
||||
false,
|
||||
)
|
||||
.await
|
||||
});
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(250)).await;
|
||||
mount_task.abort();
|
||||
|
||||
drop(db);
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_f05_zombie_repair_aborts_if_restore_nonce_set() {
|
||||
let path = temp_container_path("f05_nonce_guard");
|
||||
let password = "TestPasswordF05_3!";
|
||||
let salt = generate_salt();
|
||||
let kdf = test_kdf_params();
|
||||
let kek = derive_kek(password, &salt, &kdf).unwrap();
|
||||
let dek = generate_dek();
|
||||
let (wrapped, nonce, tag) = wrap_dek(&kek, &dek).unwrap();
|
||||
|
||||
let db = Database::open(&path).unwrap();
|
||||
db.init_schema(&salt, &kdf, &wrapped, &nonce, &tag).unwrap();
|
||||
|
||||
// Setze restore_nonce (simuliert restore_header_backup)
|
||||
let restore_token = [42u8; 32];
|
||||
{
|
||||
let conn = db.conn();
|
||||
conn.execute(
|
||||
"UPDATE meta SET version = 3, metadata_mac = NULL, metadata_gen = 0, restore_nonce = ?1 WHERE slot_id = 0",
|
||||
rusqlite::params![restore_token.as_slice()],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
db.checkpoint().unwrap();
|
||||
|
||||
// repair_v3_mac DARF NICHT auslösen, wenn restore_nonce gesetzt ist (F-02 bleibt zuständig)
|
||||
let res = db.repair_v3_mac(&dek, None);
|
||||
assert!(
|
||||
res.is_err(),
|
||||
"repair_v3_mac muss fehlschlagen, wenn restore_nonce gesetzt ist (kein F-02 Bypass)"
|
||||
);
|
||||
|
||||
drop(db);
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user