- 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
953 lines
30 KiB
Rust
953 lines
30 KiB
Rust
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);
|
|
}
|