fix(format-v3): harden system binding against replay and bypass attacks (F-01, F-02, F-03)

- F-02: Require restore_nonce token and explicit confirmation (--rebuild-mac) for PendingRebuild
- F-01: Extend canonical MAC transcript to include chunk generation tuples (node_id, chunk_index, generation) and support transparent legacy migration
- F-03: Make V2-to-V3 container upgrade atomic with transactional rollback and dual-slot version update
- Bump version to 0.9.4 and update changelog and security docs
This commit is contained in:
2026-09-22 16:48:12 +02:00
parent abcd85524d
commit 510cb9b64e
12 changed files with 1281 additions and 67 deletions
+952
View File
@@ -0,0 +1,952 @@
use rand::rngs::OsRng;
use rand::RngCore;
use sanctum::crypto::{
decrypt_chunk, derive_kek, encrypt_chunk, generate_dek, generate_salt, wrap_dek,
wrap_slot0_payload, wrap_slot1_payload, KdfParams, FORMAT_VERSION_V2, FORMAT_VERSION_V3,
MAGIC_BYTES, MIN_MEMORY_COST_KIB, MIN_TIME_COST,
};
use sanctum::mount::{mount_container, ContainerAuth};
use sanctum::recovery::{export_header_backup, restore_header_backup};
use sanctum::storage::{Database, MetadataMacStatus};
use std::path::PathBuf;
use std::time::Duration;
use zeroize::Zeroizing;
fn test_kdf_params() -> KdfParams {
KdfParams {
memory_cost: MIN_MEMORY_COST_KIB,
time_cost: MIN_TIME_COST,
parallelism: 1,
}
}
fn temp_container_path(prefix: &str) -> PathBuf {
let mut path = std::env::temp_dir();
let rand_val: u64 = OsRng.next_u64();
path.push(format!("sanctum_{prefix}_{rand_val}.sanctum"));
path
}
// -----------------------------------------------------------------------------
// F-02 TESTS: PendingRebuild Token & Rebuild-MAC Autorisation
// -----------------------------------------------------------------------------
#[tokio::test]
async fn test_f02_pending_rebuild_without_token_fails() {
let path = temp_container_path("f02_no_token");
let password = "TestPasswordF02_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 _ = db.create_node(1, "test.txt", false).unwrap();
db.update_metadata_mac().unwrap();
db.checkpoint().unwrap();
drop(db);
// Angreifer manipuliert meta: metadata_mac = NULL, metadata_gen = 0 (ohne Token)
{
let conn = rusqlite::Connection::open(&path).unwrap();
conn.execute(
"UPDATE meta SET metadata_mac = NULL, metadata_gen = 0, restore_nonce = NULL WHERE slot_id = 0",
[],
)
.unwrap();
}
// 1. Status muss strikt Invalid sein (kein PendingRebuild!)
let check_db = Database::open(&path).unwrap();
let status = check_db
.verify_metadata_mac_status_for_slot(0, &dek)
.unwrap();
assert_eq!(
status,
MetadataMacStatus::Invalid,
"NULL-MAC ohne restore_nonce Token muss strikt Invalid sein (F-02 Bypass-Schutz)"
);
assert!(
!check_db.verify_metadata_mac_for_slot(0, &dek).unwrap(),
"verify_metadata_mac_for_slot muss false liefern"
);
drop(check_db);
// 2. Mount ohne Flag muss fehlschlagen
let auth = ContainerAuth::Password(Zeroizing::new(password.to_string()));
let res = mount_container(
&path,
'Z',
None,
Some(18101),
auth,
false,
false,
None,
false,
false,
None,
true,
false,
)
.await;
assert!(res.is_err(), "Mount muss bei Status Invalid abbrechen");
// 3. Selbst mit --rebuild-mac darf KEIN Rebuild erfolgen, wenn Status Invalid ist!
let auth2 = ContainerAuth::Password(Zeroizing::new(password.to_string()));
let res2 = mount_container(
&path,
'Z',
None,
Some(18102),
auth2,
false,
false,
None,
false,
false,
None,
true,
true,
)
.await;
assert!(
res2.is_err(),
"Mount mit --rebuild-mac darf Invalid-Metadaten nicht neu signieren"
);
// 4. Metadaten-MAC bleibt weiterhin ungesetzt
let verify_db = Database::open(&path).unwrap();
let status_after = verify_db
.verify_metadata_mac_status_for_slot(0, &dek)
.unwrap();
assert_eq!(status_after, MetadataMacStatus::Invalid);
let _ = std::fs::remove_file(&path);
}
#[tokio::test]
async fn test_f02_restore_header_without_flag_fails() {
let path = temp_container_path("f02_restore_noflag");
let backup_path = path.with_extension("hdr");
let password = "TestPasswordF02_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, "file.txt", false).unwrap();
db.update_metadata_mac().unwrap();
db.checkpoint().unwrap();
drop(db);
// Backup exportieren
export_header_backup(&path, &backup_path).unwrap();
// Header zerstören und restaurieren
{
let conn = rusqlite::Connection::open(&path).unwrap();
conn.execute("DELETE FROM meta", []).unwrap();
}
restore_header_backup(&path, &backup_path).unwrap();
// Status nach Restore muss PendingRebuild sein
{
let check_db = Database::open(&path).unwrap();
let status = check_db
.verify_metadata_mac_status_for_slot(0, &dek)
.unwrap();
assert_eq!(
status,
MetadataMacStatus::PendingRebuild,
"Nach restore_header_backup muss Status PendingRebuild sein (restore_nonce gesetzt)"
);
}
// Mount ohne --rebuild-mac (und ohne stdin-Interaktion) muss abbrechen
let auth = ContainerAuth::Password(Zeroizing::new(password.to_string()));
let res = mount_container(
&path,
'Z',
None,
Some(18103),
auth,
false,
false,
None,
false,
false,
None,
true,
false,
)
.await;
assert!(
res.is_err(),
"Mount ohne --rebuild-mac muss nach Header-Restore fail-closed abbrechen"
);
// Nonce muss weiterhin existieren, MAC noch nicht gesetzt
let check_db2 = Database::open(&path).unwrap();
assert_eq!(
check_db2
.verify_metadata_mac_status_for_slot(0, &dek)
.unwrap(),
MetadataMacStatus::PendingRebuild
);
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_file(&backup_path);
}
#[tokio::test]
async fn test_f02_restore_header_with_flag_succeeds() {
let path = temp_container_path("f02_restore_flag");
let backup_path = path.with_extension("hdr");
let password = "TestPasswordF02_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, "restored_payload.pdf", false).unwrap();
db.update_metadata_mac().unwrap();
db.checkpoint().unwrap();
drop(db);
export_header_backup(&path, &backup_path).unwrap();
{
let conn = rusqlite::Connection::open(&path).unwrap();
conn.execute("DELETE FROM meta", []).unwrap();
}
restore_header_backup(&path, &backup_path).unwrap();
// Mount mit rebuild_mac = true
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(18104),
auth,
false,
false,
None,
false,
false,
None,
true,
true,
)
.await
});
tokio::time::sleep(Duration::from_millis(250)).await;
mount_task.abort();
// Nach Mount mit Flag: MAC ist Valid, restore_nonce gelöscht!
let check_db = Database::open(&path).unwrap();
let status = check_db
.verify_metadata_mac_status_for_slot(0, &dek)
.unwrap();
assert_eq!(
status,
MetadataMacStatus::Valid,
"Nach Mount mit --rebuild-mac muss Status Valid sein"
);
assert!(check_db.verify_metadata_mac_for_slot(0, &dek).unwrap());
// Prüfen, ob restore_nonce gelöscht wurde
let conn = check_db.conn();
let nonce_opt: Option<Option<Vec<u8>>> = conn
.query_row(
"SELECT restore_nonce FROM meta WHERE slot_id = 0",
[],
|r| r.get(0),
)
.ok();
assert!(
nonce_opt.flatten().is_none(),
"restore_nonce muss nach erfolgreichem Rebuild gelöscht sein"
);
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_file(&backup_path);
}
// -----------------------------------------------------------------------------
// F-01 TESTS: Chunk-Generation-Replay im Transcript & Migration
// -----------------------------------------------------------------------------
#[tokio::test]
async fn test_f01_chunk_generation_replay_attack() {
let path = temp_container_path("f01_replay_attack");
let password = "TestPasswordF01!";
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());
// 1. Datei schreiben mit Zustand 1 (Generation 1, z. B. 34 Bytes)
let file = db.create_node(1, "financial_report.txt", false).unwrap();
let data_v1 = b"State 1: Balance is 1000 EUR.";
let gen1 = db.next_chunk_generation(file.id, 0).unwrap();
assert_eq!(gen1, 1);
let (ct1, nonce1, tag1) =
encrypt_chunk(&dek, file.id, 0, data_v1, FORMAT_VERSION_V3, gen1).unwrap();
db.write_chunk_and_update_size(
file.id,
0,
gen1,
&nonce1,
&tag1,
&ct1,
data_v1.len() as u64,
1000,
)
.unwrap();
db.checkpoint().unwrap();
// Gespeicherte Zeile aus chunks sichern
let chunk_v1 = db.read_chunk(file.id, 0).unwrap().unwrap();
assert_eq!(chunk_v1.generation, 1);
assert_eq!(
db.verify_metadata_mac_status_for_slot(0, &dek).unwrap(),
MetadataMacStatus::Valid
);
// 2. Datei mit exakt gleicher Größe überschreiben mit Zustand 2 (Generation 2, selbe Länge)
let data_v2 = b"State 2: Balance is 9999 EUR.";
assert_eq!(data_v1.len(), data_v2.len());
let gen2 = db.next_chunk_generation(file.id, 0).unwrap();
assert_eq!(gen2, 2);
let (ct2, nonce2, tag2) =
encrypt_chunk(&dek, file.id, 0, data_v2, FORMAT_VERSION_V3, gen2).unwrap();
db.write_chunk_and_update_size(
file.id,
0,
gen2,
&nonce2,
&tag2,
&ct2,
data_v2.len() as u64,
2000,
)
.unwrap();
db.checkpoint().unwrap();
assert_eq!(
db.verify_metadata_mac_status_for_slot(0, &dek).unwrap(),
MetadataMacStatus::Valid
);
drop(db);
// 3. Angriff: Angreifer restauriert alte Chunk-Zeile (inkl. generation = 1) in SQLite
// Größe und chunk_count des Knotens sind unverändert!
{
let conn = rusqlite::Connection::open(&path).unwrap();
conn.execute(
"UPDATE chunks SET nonce = ?1, tag = ?2, ciphertext = ?3, generation = ?4 WHERE node_id = ?5 AND chunk_index = 0",
rusqlite::params![
chunk_v1.nonce.as_slice(),
chunk_v1.tag.as_slice(),
chunk_v1.ciphertext,
chunk_v1.generation,
file.id,
],
)
.unwrap();
}
// 4. Verifikation des Replay-Schutzes (F-01):
let attack_db = Database::open(&path).unwrap();
// Hinweis: Die AEAD-Entschlüsselung für sich allein würde hier gelingen,
// da die Zeile generation=1 enthält. ABER das Metadaten-Transcript bindet die Generation!
let replayed = attack_db.read_chunk(file.id, 0).unwrap().unwrap();
let aead_decrypt = decrypt_chunk(
&dek,
file.id,
0,
&replayed.ciphertext,
&replayed.nonce,
&replayed.tag,
FORMAT_VERSION_V3,
replayed.generation,
);
assert!(
aead_decrypt.is_ok(),
"AEAD mit replayed Zeile gelingt, weil generation in der gleichen Zeile liegt"
);
// Genau deshalb MUSS der Metadaten-MAC den Replay-Angriff stoppen:
let mac_status = attack_db
.verify_metadata_mac_status_for_slot(0, &dek)
.unwrap();
assert_eq!(
mac_status,
MetadataMacStatus::Invalid,
"F-01: Metadaten-MAC MUSS den Replay-Angriff durch abweichende Generation abwehren!"
);
assert!(
!attack_db.verify_metadata_mac_for_slot(0, &dek).unwrap(),
"verify_metadata_mac_for_slot muss false liefern"
);
drop(attack_db);
// 5. Mount bricht fail-closed ab, kein alter Klartext wird offengelegt
let auth = ContainerAuth::Password(Zeroizing::new(password.to_string()));
let res = mount_container(
&path,
'Z',
None,
Some(18105),
auth,
false,
false,
None,
false,
false,
None,
true,
false,
)
.await;
assert!(
res.is_err(),
"Mount muss bei manipuliertem Chunk-Replay fail-closed abbrechen"
);
let _ = std::fs::remove_file(&path);
}
#[tokio::test]
async fn test_f01_migration_existing_v3_container() {
let path = temp_container_path("f01_migration");
let password = "TestPasswordF01_Mig!";
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 = db.create_node(1, "legacy_v3_doc.txt", false).unwrap();
let data = b"V3 Legacy Container Content";
let gen = db.next_chunk_generation(file.id, 0).unwrap();
let (ct, n, t) = encrypt_chunk(&dek, file.id, 0, data, FORMAT_VERSION_V3, gen).unwrap();
db.write_chunk_and_update_size(file.id, 0, gen, &n, &t, &ct, data.len() as u64, 1000)
.unwrap();
// Simuliere v0.9.3 Zustand: Metadaten-MAC wurde NUR über Knoten berechnet (ohne Chunks)
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 WHERE slot_id = 0",
rusqlite::params![legacy_mac.as_slice()],
)
.unwrap();
}
db.checkpoint().unwrap();
// Status muss LegacyValid sein
let status_before = db.verify_metadata_mac_status_for_slot(0, &dek).unwrap();
assert_eq!(
status_before,
MetadataMacStatus::LegacyValid,
"Alter V3-Container muss als LegacyValid erkannt werden"
);
assert!(db.verify_metadata_mac_for_slot(0, &dek).unwrap());
drop(db);
// Mounten migriert transparent auf neues Transcript
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(18106),
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 nun Valid unter neuem Transcript sein!
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 der MAC auf das neue Format-V3.1 Transcript migriert sein"
);
let _ = std::fs::remove_file(&path);
}
// -----------------------------------------------------------------------------
// F-03 TESTS: upgrade_to_v3 Atomarität & Konsistenz
// -----------------------------------------------------------------------------
#[test]
fn test_f03_v2_upgrade_single_file_success() {
let path = temp_container_path("f03_single_success");
let salt = generate_salt();
let kdf = test_kdf_params();
let dek = generate_dek();
// Erstelle manuell einen V2-Container
{
let conn = rusqlite::Connection::open(&path).unwrap();
conn.execute_batch(
"CREATE TABLE meta (
slot_id INTEGER PRIMARY KEY,
magic BLOB NOT NULL,
version INTEGER NOT NULL,
kdf_salt BLOB NOT NULL,
kdf_params TEXT NOT NULL,
wrapped_dek BLOB NOT NULL,
header_nonce BLOB NOT NULL,
header_tag BLOB NOT NULL
);
CREATE TABLE nodes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
parent_id INTEGER,
name TEXT NOT NULL,
is_dir INTEGER NOT NULL,
size INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
modified_at INTEGER NOT NULL,
is_carrier INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE chunks (
node_id INTEGER NOT NULL,
chunk_index INTEGER NOT NULL,
nonce BLOB NOT NULL,
tag BLOB NOT NULL,
ciphertext BLOB NOT NULL,
PRIMARY KEY (node_id, chunk_index)
);",
)
.unwrap();
let kek = derive_kek("TestV2!", &salt, &kdf).unwrap();
let (wrapped, h_nonce, h_tag) = wrap_dek(&kek, &dek).unwrap();
let params_json = serde_json::to_string(&kdf).unwrap();
conn.execute(
"INSERT INTO meta VALUES (0, ?1, 2, ?2, ?3, ?4, ?5, ?6)",
rusqlite::params![
MAGIC_BYTES.as_slice(),
salt.as_slice(),
params_json,
wrapped,
h_nonce.as_slice(),
h_tag.as_slice(),
],
)
.unwrap();
conn.execute(
"INSERT INTO meta VALUES (1, ?1, 2, ?2, ?3, ?4, ?5, ?6)",
rusqlite::params![
MAGIC_BYTES.as_slice(),
salt.as_slice(),
params_json,
wrapped,
h_nonce.as_slice(),
h_tag.as_slice(),
],
)
.unwrap();
conn.execute(
"INSERT INTO nodes VALUES (1, NULL, '', 1, 0, 100, 100, 0), (2, NULL, '', 1, 0, 100, 100, 0), (3, 1, 'file.txt', 0, 12, 100, 100, 0)",
[],
)
.unwrap();
// Chunk in V2 verschlüsseln (16-Byte AAD, generation wird ignoriert)
let plaintext = b"Hello V2 Upgrade!";
let (ct, n, t) = encrypt_chunk(&dek, 3, 0, plaintext, FORMAT_VERSION_V2, 0).unwrap();
conn.execute(
"INSERT INTO chunks VALUES (3, 0, ?1, ?2, ?3)",
rusqlite::params![n.as_slice(), t.as_slice(), ct],
)
.unwrap();
}
let db = Database::open(&path).unwrap();
let meta_before = db.read_meta().unwrap();
assert_eq!(meta_before.version, 2);
// Upgrade ausführen
db.upgrade_to_v3(&dek).unwrap();
db.checkpoint().unwrap();
// 1. Beide Slots in meta müssen version == 3 haben!
let conn = db.conn();
let v0: u32 = conn
.query_row("SELECT version FROM meta WHERE slot_id = 0", [], |r| {
r.get(0)
})
.unwrap();
let v1: u32 = conn
.query_row("SELECT version FROM meta WHERE slot_id = 1", [], |r| {
r.get(0)
})
.unwrap();
assert_eq!(v0, 3, "Slot 0 version muss 3 sein");
assert_eq!(v1, 3, "Slot 1 version muss 3 sein (F-03)");
drop(conn);
// 2. Chunks müssen generation = 0 haben und mit V3 24-Byte AAD entschlüsselbar sein
let chunk = db.read_chunk(3, 0).unwrap().unwrap();
assert_eq!(chunk.generation, 0);
let decrypted = decrypt_chunk(
&dek,
3,
0,
&chunk.ciphertext,
&chunk.nonce,
&chunk.tag,
FORMAT_VERSION_V3,
chunk.generation,
)
.unwrap();
assert_eq!(decrypted, b"Hello V2 Upgrade!");
// 3. Metadaten-MAC muss Valid sein
assert_eq!(
db.verify_metadata_mac_status_for_slot(0, &dek).unwrap(),
MetadataMacStatus::Valid
);
let _ = std::fs::remove_file(&path);
}
#[test]
fn test_f03_v2_upgrade_corrupted_chunk_rollback() {
let path = temp_container_path("f03_rollback");
let salt = generate_salt();
let kdf = test_kdf_params();
let dek = generate_dek();
{
let conn = rusqlite::Connection::open(&path).unwrap();
conn.execute_batch(
"CREATE TABLE meta (
slot_id INTEGER PRIMARY KEY,
magic BLOB NOT NULL,
version INTEGER NOT NULL,
kdf_salt BLOB NOT NULL,
kdf_params TEXT NOT NULL,
wrapped_dek BLOB NOT NULL,
header_nonce BLOB NOT NULL,
header_tag BLOB NOT NULL
);
CREATE TABLE nodes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
parent_id INTEGER,
name TEXT NOT NULL,
is_dir INTEGER NOT NULL,
size INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
modified_at INTEGER NOT NULL,
is_carrier INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE chunks (
node_id INTEGER NOT NULL,
chunk_index INTEGER NOT NULL,
nonce BLOB NOT NULL,
tag BLOB NOT NULL,
ciphertext BLOB NOT NULL,
PRIMARY KEY (node_id, chunk_index)
);",
)
.unwrap();
let kek = derive_kek("TestV2!", &salt, &kdf).unwrap();
let (wrapped, h_nonce, h_tag) = wrap_dek(&kek, &dek).unwrap();
let params_json = serde_json::to_string(&kdf).unwrap();
conn.execute(
"INSERT INTO meta VALUES (0, ?1, 2, ?2, ?3, ?4, ?5, ?6)",
rusqlite::params![
MAGIC_BYTES.as_slice(),
salt.as_slice(),
params_json,
wrapped,
h_nonce.as_slice(),
h_tag.as_slice(),
],
)
.unwrap();
conn.execute(
"INSERT INTO meta VALUES (1, ?1, 2, ?2, ?3, ?4, ?5, ?6)",
rusqlite::params![
MAGIC_BYTES.as_slice(),
salt.as_slice(),
params_json,
wrapped,
h_nonce.as_slice(),
h_tag.as_slice(),
],
)
.unwrap();
conn.execute(
"INSERT INTO nodes VALUES (1, NULL, '', 1, 0, 100, 100, 0), (2, NULL, '', 1, 0, 100, 100, 0), (3, 1, 'corrupt.txt', 0, 100, 100, 100, 0)",
[],
)
.unwrap();
// Gültiger Chunk 0
let (ct0, n0, t0) =
encrypt_chunk(&dek, 3, 0, b"Valid chunk 0", FORMAT_VERSION_V2, 0).unwrap();
conn.execute(
"INSERT INTO chunks VALUES (3, 0, ?1, ?2, ?3)",
rusqlite::params![n0.as_slice(), t0.as_slice(), ct0],
)
.unwrap();
// Beschädigter Chunk 1 (manipulierter Tag)
let (ct1, n1, mut t1) =
encrypt_chunk(&dek, 3, 1, b"Valid chunk 1", FORMAT_VERSION_V2, 0).unwrap();
t1[0] ^= 0xff; // Tag korrumpieren
conn.execute(
"INSERT INTO chunks VALUES (3, 1, ?1, ?2, ?3)",
rusqlite::params![n1.as_slice(), t1.as_slice(), ct1],
)
.unwrap();
}
let db = Database::open(&path).unwrap();
// Upgrade MUSS abbrechen
let res = db.upgrade_to_v3(&dek);
assert!(
res.is_err(),
"Upgrade muss bei beschädigtem Chunk abbrechen"
);
let err_msg = res.unwrap_err().to_string();
assert!(
err_msg.contains("Node 3") && err_msg.contains("Index 1"),
"Fehler muss Node und Index benennen: {err_msg}"
);
// Rollback-Verifikation:
let conn = db.conn();
let v0: u32 = conn
.query_row("SELECT version FROM meta WHERE slot_id = 0", [], |r| {
r.get(0)
})
.unwrap();
let v1: u32 = conn
.query_row("SELECT version FROM meta WHERE slot_id = 1", [], |r| {
r.get(0)
})
.unwrap();
assert_eq!(v0, 2, "Nach Rollback muss Version 2 bleiben");
assert_eq!(v1, 2, "Nach Rollback muss Version 2 bleiben");
// Chunk 0 darf nicht umverschlüsselt zurückgeblieben sein (muss weiterhin mit V2 entschlüsselbar sein)
drop(conn);
let chunk0 = db.read_chunk(3, 0).unwrap().unwrap();
let dec0 = decrypt_chunk(
&dek,
3,
0,
&chunk0.ciphertext,
&chunk0.nonce,
&chunk0.tag,
FORMAT_VERSION_V2,
0,
);
assert!(
dec0.is_ok(),
"Chunk 0 muss unverändert im V2-Zustand geblieben sein"
);
let _ = std::fs::remove_file(&path);
}
#[tokio::test]
async fn test_f03_dual_vault_v2_upgrade_carrier() {
let path = temp_container_path("f03_dual_carrier");
let pass_decoy = "DecoyPassword2026!";
let pass_hidden = "HiddenPassword2026!";
let kdf = test_kdf_params();
let salt_0 = generate_salt();
let kek_0 = derive_kek(pass_decoy, &salt_0, &kdf).unwrap();
let dek_0 = generate_dek();
let salt_1 = generate_salt();
let kek_1 = derive_kek(pass_hidden, &salt_1, &kdf).unwrap();
let dek_1 = generate_dek();
let carrier_node_id = 3i64;
let (wrapped_0, nonce_0, tag_0) = wrap_slot0_payload(&kek_0, &dek_0, carrier_node_id).unwrap();
let (wrapped_1, nonce_1, tag_1) =
wrap_slot1_payload(&kek_1, &dek_1, &dek_0, carrier_node_id).unwrap();
let carrier_size: u64 = 10 * 1024 * 1024; // 10 MB
let db = Database::open(&path).unwrap();
db.init_schema_with_carrier(
&salt_0,
&kdf,
&wrapped_0,
&nonce_0,
&tag_0,
Some((
"carrier.dat",
carrier_size,
&salt_1,
&kdf,
&wrapped_1,
&nonce_1,
&tag_1,
&dek_0,
&dek_1,
)),
)
.unwrap();
// Simuliere V2-Zustand für beide Slots und Chunks
{
let conn = db.conn();
conn.execute("UPDATE meta SET version = 2", []).unwrap();
// Verschlüssele Carrier-Chunks mit V2 (16-Byte AAD) um
let mut stmt = conn
.prepare("SELECT chunk_index, nonce, tag, ciphertext FROM chunks WHERE node_id = ?1")
.unwrap();
let chunks: Vec<(u32, [u8; 12], [u8; 16], Vec<u8>)> = stmt
.query_map([carrier_node_id], |r| {
let idx: u32 = r.get(0)?;
let n: Vec<u8> = r.get(1)?;
let t: Vec<u8> = r.get(2)?;
let c: Vec<u8> = r.get(3)?;
let mut n_arr = [0u8; 12];
let mut t_arr = [0u8; 16];
n_arr.copy_from_slice(&n);
t_arr.copy_from_slice(&t);
Ok((idx, n_arr, t_arr, c))
})
.unwrap()
.collect::<std::result::Result<Vec<_>, _>>()
.unwrap();
drop(stmt);
for (idx, n, t, c) in chunks {
let pt = decrypt_chunk(
&dek_0,
carrier_node_id,
idx,
&c,
&n,
&t,
FORMAT_VERSION_V3,
0,
)
.unwrap();
let (v2_ct, v2_n, v2_t) =
encrypt_chunk(&dek_0, carrier_node_id, idx, &pt, FORMAT_VERSION_V2, 0).unwrap();
conn.execute(
"UPDATE chunks SET nonce = ?1, tag = ?2, ciphertext = ?3, generation = 0 WHERE node_id = ?4 AND chunk_index = ?5",
rusqlite::params![v2_n.as_slice(), v2_t.as_slice(), v2_ct, carrier_node_id, idx],
)
.unwrap();
}
}
db.checkpoint().unwrap();
// Verifiziere V2-Ausgangszustand
let meta_v2 = db.read_meta().unwrap();
assert_eq!(meta_v2.version, 2);
// Upgrade mit Decoy-Passwort (DEK_0) durchführen
db.upgrade_to_v3(&dek_0).unwrap();
db.checkpoint().unwrap();
// Prüfen: Beide Slots müssen version == 3 sein!
{
let conn = db.conn();
let v0: u32 = conn
.query_row("SELECT version FROM meta WHERE slot_id = 0", [], |r| {
r.get(0)
})
.unwrap();
let v1: u32 = conn
.query_row("SELECT version FROM meta WHERE slot_id = 1", [], |r| {
r.get(0)
})
.unwrap();
assert_eq!(v0, 3, "Slot 0 version muss 3 sein");
assert_eq!(v1, 3, "Slot 1 version muss 3 sein (F-03)");
}
drop(db);
// Hidden-Mount mit pass_hidden ausführen: Muss Carrier-Chunks fehlerfrei unter DEK_0 mit Version 3 lesen
let auth_hidden = ContainerAuth::Password(Zeroizing::new(pass_hidden.to_string()));
let c_path = path.clone();
let mount_task = tokio::spawn(async move {
mount_container(
&c_path,
'Y',
None,
Some(18107),
auth_hidden,
false,
false,
None,
false,
false,
None,
true,
false,
)
.await
});
tokio::time::sleep(Duration::from_millis(300)).await;
mount_task.abort();
let _ = std::fs::remove_file(&path);
}