fix(format-v3): transcript versioning in HMAC and atomic upgrade MAC (F-04, F-05)
Sanctum Release / Build & Test (Windows x86_64 & Linux musl) (push) Waiting to run
Sanctum Release / Sign & Release (push) Blocked by required conditions

This commit is contained in:
2026-09-22 21:02:22 +02:00
parent 3a1a1ca6ce
commit 76c23dbfa3
15 changed files with 755 additions and 77 deletions
+420 -1
View File
@@ -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);
}