1001 lines
34 KiB
Rust
1001 lines
34 KiB
Rust
use sanctum::crypto::{
|
|
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;
|
|
use std::path::PathBuf;
|
|
|
|
#[test]
|
|
fn test_reject_arbitrary_sqlite_database() {
|
|
let temp_dir = std::env::temp_dir();
|
|
let db_path: PathBuf = temp_dir.join(format!("test_foreign_sqlite_{}.db", std::process::id()));
|
|
if db_path.exists() {
|
|
let _ = std::fs::remove_file(&db_path);
|
|
}
|
|
|
|
// Erstelle eine fremde SQLite-Datenbank mit beliebigen Daten
|
|
{
|
|
let conn = rusqlite::Connection::open(&db_path).unwrap();
|
|
conn.execute("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)", [])
|
|
.unwrap();
|
|
conn.execute("INSERT INTO users (name) VALUES ('Alice')", [])
|
|
.unwrap();
|
|
}
|
|
|
|
// Sanctum Database::open muss die Datei sofort fail-closed ablehnen
|
|
let res = Database::open(&db_path);
|
|
match res {
|
|
Err(err) => {
|
|
let msg = err.to_string();
|
|
assert!(
|
|
msg.contains("kein gültiger Sanctum-Container"),
|
|
"Fehler muss ungültigen Container abweisen: {msg}"
|
|
);
|
|
}
|
|
Ok(_) => panic!("Fremde SQLite-DB ohne meta-Tabelle muss abgelehnt werden"),
|
|
}
|
|
|
|
let _ = std::fs::remove_file(&db_path);
|
|
}
|
|
|
|
#[test]
|
|
fn test_reject_sqlite_database_with_invalid_magic() {
|
|
let temp_dir = std::env::temp_dir();
|
|
let db_path: PathBuf = temp_dir.join(format!("test_invalid_magic_{}.db", std::process::id()));
|
|
if db_path.exists() {
|
|
let _ = std::fs::remove_file(&db_path);
|
|
}
|
|
|
|
// Erstelle SQLite DB mit 'meta' Tabelle, aber falscher Magic
|
|
{
|
|
let conn = rusqlite::Connection::open(&db_path).unwrap();
|
|
conn.execute(
|
|
"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
|
|
);",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
conn.execute(
|
|
"INSERT INTO meta (slot_id, magic, version, kdf_salt, kdf_params, wrapped_dek, header_nonce, header_tag)
|
|
VALUES (0, ?1, 2, ?2, '{}', ?3, ?4, ?5)",
|
|
rusqlite::params![
|
|
b"EVIL_MAGIC",
|
|
&[0u8; 16][..],
|
|
&[0u8; 40][..],
|
|
&[0u8; 12][..],
|
|
&[0u8; 16][..]
|
|
],
|
|
)
|
|
.unwrap();
|
|
}
|
|
|
|
let res = Database::open(&db_path);
|
|
match res {
|
|
Err(err) => {
|
|
let msg = err.to_string();
|
|
assert!(
|
|
msg.contains("kein gültiger Sanctum-Container"),
|
|
"Fehler muss ungültigen Magic-Header monieren: {msg}"
|
|
);
|
|
}
|
|
Ok(_) => panic!("Container mit ungültigen Magic Bytes muss abgelehnt werden"),
|
|
}
|
|
|
|
let _ = std::fs::remove_file(&db_path);
|
|
}
|
|
|
|
#[test]
|
|
fn test_reject_out_of_bounds_kdf_params() {
|
|
let temp_dir = std::env::temp_dir();
|
|
let db_path: PathBuf = temp_dir.join(format!("test_oob_kdf_{}.sanctum", std::process::id()));
|
|
if db_path.exists() {
|
|
let _ = std::fs::remove_file(&db_path);
|
|
}
|
|
|
|
// 1. Initialisiere gültigen Container
|
|
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("ValidPassword2026!", &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.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag)
|
|
.unwrap();
|
|
db.checkpoint().unwrap();
|
|
drop(db);
|
|
|
|
// 2. Manipuliere KDF-Parameter in der meta-Tabelle auf gefährliche Werte (DoS: 16 GiB Memory)
|
|
{
|
|
let conn = rusqlite::Connection::open(&db_path).unwrap();
|
|
conn.execute(
|
|
"UPDATE meta SET kdf_params = '{\"memory_cost\": 16777216, \"time_cost\": 3, \"parallelism\": 4}' WHERE slot_id = 0",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
}
|
|
|
|
// 3. Öffnen und read_slots() aufrufen: muss mit Kdf-Param-Fehler abbrechen
|
|
let db_reopened = Database::open(&db_path).unwrap();
|
|
let slots_res = db_reopened.read_slots();
|
|
match slots_res {
|
|
Err(err) => {
|
|
let msg = err.to_string();
|
|
assert!(
|
|
msg.contains("KDF-Parameter"),
|
|
"Fehler muss KDF-Parameter monieren: {msg}"
|
|
);
|
|
}
|
|
Ok(_) => panic!("Überhöhte KDF-Parameter müssen fail-closed abgewehrt werden"),
|
|
}
|
|
|
|
// 4. Manipuliere KDF-Parameter auf zu schwache Werte (< MIN_MEMORY_COST_KIB)
|
|
{
|
|
let conn = rusqlite::Connection::open(&db_path).unwrap();
|
|
conn.execute(
|
|
"UPDATE meta SET kdf_params = '{\"memory_cost\": 1024, \"time_cost\": 3, \"parallelism\": 4}' WHERE slot_id = 0",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
}
|
|
let slots_res2 = db_reopened.read_slots();
|
|
match slots_res2 {
|
|
Err(err) => {
|
|
let msg = err.to_string();
|
|
assert!(
|
|
msg.contains("KDF-Parameter"),
|
|
"Fehler muss KDF-Parameter monieren: {msg}"
|
|
);
|
|
}
|
|
Ok(_) => panic!("Zu schwache KDF-Parameter müssen fail-closed abgewehrt werden"),
|
|
}
|
|
|
|
let _ = std::fs::remove_file(&db_path);
|
|
}
|
|
|
|
#[test]
|
|
fn test_reject_corrupted_slot_lengths() {
|
|
let temp_dir = std::env::temp_dir();
|
|
let db_path: PathBuf =
|
|
temp_dir.join(format!("test_slot_lengths_{}.sanctum", std::process::id()));
|
|
if db_path.exists() {
|
|
let _ = std::fs::remove_file(&db_path);
|
|
}
|
|
|
|
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("ValidPassword2026!", &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.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag)
|
|
.unwrap();
|
|
db.checkpoint().unwrap();
|
|
drop(db);
|
|
|
|
// Manipuliere Salt-Länge auf 8 Byte statt 16 Byte
|
|
{
|
|
let conn = rusqlite::Connection::open(&db_path).unwrap();
|
|
conn.execute(
|
|
"UPDATE meta SET kdf_salt = ?1 WHERE slot_id = 0",
|
|
[&[0u8; 8][..]],
|
|
)
|
|
.unwrap();
|
|
}
|
|
|
|
let db_reopened = Database::open(&db_path).unwrap();
|
|
let res = db_reopened.read_slots();
|
|
match res {
|
|
Err(err) => {
|
|
let msg = err.to_string();
|
|
assert!(
|
|
msg.contains("Salt-Länge"),
|
|
"Fehler muss fehlerhafte Salt-Länge monieren: {msg}"
|
|
);
|
|
}
|
|
Ok(_) => panic!("Ungültige Salt-Länge muss abgewiesen werden"),
|
|
}
|
|
|
|
let _ = std::fs::remove_file(&db_path);
|
|
}
|
|
|
|
#[test]
|
|
fn test_reject_slot_amplification_and_excessive_slots_dos() {
|
|
let temp_dir = std::env::temp_dir();
|
|
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("TestPassword2026!", &salt, &kdf_params).unwrap();
|
|
let dek = generate_dek();
|
|
let (wrapped_dek, nonce, tag) = wrap_dek(&kek, &dek).unwrap();
|
|
let params_json = serde_json::to_string(&kdf_params).unwrap();
|
|
|
|
// 1. Fall: 0 Slots (leere meta-Tabelle)
|
|
{
|
|
let db_path = temp_dir.join(format!("test_0_slots_{}.sanctum", std::process::id()));
|
|
let _ = std::fs::remove_file(&db_path);
|
|
{
|
|
let conn = rusqlite::Connection::open(&db_path).unwrap();
|
|
conn.execute(
|
|
"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
|
|
);",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
conn.execute("CREATE TABLE nodes (id INTEGER PRIMARY KEY);", [])
|
|
.unwrap();
|
|
conn.execute("CREATE TABLE chunks (node_id INTEGER);", [])
|
|
.unwrap();
|
|
}
|
|
let db = Database::open(&db_path).unwrap();
|
|
let res = db.read_slots();
|
|
assert!(res.is_err());
|
|
let err_msg = res.err().unwrap().to_string();
|
|
assert!(
|
|
err_msg.contains("leer oder beschädigt"),
|
|
"Fehlermeldung: {err_msg}"
|
|
);
|
|
let _ = std::fs::remove_file(&db_path);
|
|
}
|
|
|
|
// 2. Fall: Genau 1 Slot (Slot 0 Decoy/Standard) -> MUSS erfolgreich sein
|
|
{
|
|
let db_path = temp_dir.join(format!("test_1_slot_{}.sanctum", std::process::id()));
|
|
let _ = std::fs::remove_file(&db_path);
|
|
let db = Database::open(&db_path).unwrap();
|
|
db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag)
|
|
.unwrap();
|
|
// Lösche Dummy-Slot 1, um reinen 1-Slot Container zu simulieren
|
|
{
|
|
let conn = rusqlite::Connection::open(&db_path).unwrap();
|
|
conn.execute("DELETE FROM meta WHERE slot_id = 1", [])
|
|
.unwrap();
|
|
}
|
|
let db_reopened = Database::open(&db_path).unwrap();
|
|
let slots = db_reopened
|
|
.read_slots()
|
|
.expect("1 gültiger Slot (Slot 0) muss erfolgreich gelesen werden");
|
|
assert_eq!(slots.len(), 1);
|
|
assert_eq!(slots[0].slot_id, 0);
|
|
let _ = std::fs::remove_file(&db_path);
|
|
}
|
|
|
|
// 3. Fall: Genau 2 Slots (Slot 0 & Slot 1) -> MUSS erfolgreich sein
|
|
{
|
|
let db_path = temp_dir.join(format!("test_2_slots_{}.sanctum", std::process::id()));
|
|
let _ = std::fs::remove_file(&db_path);
|
|
let db = Database::open(&db_path).unwrap();
|
|
db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag)
|
|
.unwrap();
|
|
let slots = db
|
|
.read_slots()
|
|
.expect("2 gültige Slots müssen erfolgreich gelesen werden");
|
|
assert_eq!(slots.len(), 2);
|
|
assert_eq!(slots[0].slot_id, 0);
|
|
assert_eq!(slots[1].slot_id, 1);
|
|
let _ = std::fs::remove_file(&db_path);
|
|
}
|
|
|
|
// 4. Fall: 3 Slots (Überschreitung des Limits von 2) -> MUSS fail-closed abgewehrt werden
|
|
{
|
|
let db_path = temp_dir.join(format!("test_3_slots_{}.sanctum", std::process::id()));
|
|
let _ = std::fs::remove_file(&db_path);
|
|
{
|
|
let conn = rusqlite::Connection::open(&db_path).unwrap();
|
|
conn.execute(
|
|
"CREATE TABLE meta (
|
|
slot_id INTEGER, 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
|
|
);",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
for i in 0..3 {
|
|
conn.execute(
|
|
"INSERT INTO meta VALUES (?1, ?2, 2, ?3, ?4, ?5, ?6, ?7)",
|
|
rusqlite::params![
|
|
i,
|
|
b"SANCTUM\0",
|
|
&salt[..],
|
|
¶ms_json,
|
|
&wrapped_dek[..],
|
|
&nonce[..],
|
|
&tag[..]
|
|
],
|
|
)
|
|
.unwrap();
|
|
}
|
|
}
|
|
let open_res = Database::open(&db_path);
|
|
assert!(
|
|
open_res.is_err(),
|
|
"Database::open muss 3 Slots sofort abweisen"
|
|
);
|
|
let err_msg = open_res.err().unwrap().to_string();
|
|
assert!(
|
|
err_msg.contains("Ungültige Slot-Anzahl"),
|
|
"Fehlermeldung: {err_msg}"
|
|
);
|
|
let _ = std::fs::remove_file(&db_path);
|
|
}
|
|
|
|
// 5. Fall: 100 Slots (KDF-Amplification DoS Angriff) -> MUSS sofort ohne KDF abgewehrt werden
|
|
{
|
|
let db_path = temp_dir.join(format!("test_100_slots_{}.sanctum", std::process::id()));
|
|
let _ = std::fs::remove_file(&db_path);
|
|
{
|
|
let conn = rusqlite::Connection::open(&db_path).unwrap();
|
|
conn.execute(
|
|
"CREATE TABLE meta (
|
|
slot_id INTEGER, 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
|
|
);",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
for i in 0..100 {
|
|
conn.execute(
|
|
"INSERT INTO meta VALUES (?1, ?2, 2, ?3, ?4, ?5, ?6, ?7)",
|
|
rusqlite::params![
|
|
i,
|
|
b"SANCTUM\0",
|
|
&salt[..],
|
|
¶ms_json,
|
|
&wrapped_dek[..],
|
|
&nonce[..],
|
|
&tag[..]
|
|
],
|
|
)
|
|
.unwrap();
|
|
}
|
|
}
|
|
let open_res = Database::open(&db_path);
|
|
assert!(
|
|
open_res.is_err(),
|
|
"100 Slots müssen sofort abgewiesen werden"
|
|
);
|
|
let _ = std::fs::remove_file(&db_path);
|
|
}
|
|
|
|
// 6. Fall: 1000 Slots (Massiver DoS-Angriff) -> MUSS sofort abgewehrt werden
|
|
{
|
|
let db_path = temp_dir.join(format!("test_1000_slots_{}.sanctum", std::process::id()));
|
|
let _ = std::fs::remove_file(&db_path);
|
|
{
|
|
let conn = rusqlite::Connection::open(&db_path).unwrap();
|
|
conn.execute(
|
|
"CREATE TABLE meta (
|
|
slot_id INTEGER, 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
|
|
);",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
let mut stmt = conn
|
|
.prepare("INSERT INTO meta VALUES (?1, ?2, 2, ?3, ?4, ?5, ?6, ?7)")
|
|
.unwrap();
|
|
for i in 0..1000 {
|
|
stmt.execute(rusqlite::params![
|
|
i,
|
|
b"SANCTUM\0",
|
|
&salt[..],
|
|
¶ms_json,
|
|
&wrapped_dek[..],
|
|
&nonce[..],
|
|
&tag[..]
|
|
])
|
|
.unwrap();
|
|
}
|
|
}
|
|
let open_res = Database::open(&db_path);
|
|
assert!(
|
|
open_res.is_err(),
|
|
"1000 Slots müssen sofort abgewiesen werden"
|
|
);
|
|
let err_msg = open_res.err().unwrap().to_string();
|
|
assert!(
|
|
err_msg.contains("Ungültige Slot-Anzahl"),
|
|
"Fehlermeldung: {err_msg}"
|
|
);
|
|
let _ = std::fs::remove_file(&db_path);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_reject_duplicate_and_invalid_slot_ids() {
|
|
let temp_dir = std::env::temp_dir();
|
|
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("TestPassword2026!", &salt, &kdf_params).unwrap();
|
|
let dek = generate_dek();
|
|
let (wrapped_dek, nonce, tag) = wrap_dek(&kek, &dek).unwrap();
|
|
let params_json = serde_json::to_string(&kdf_params).unwrap();
|
|
|
|
// 1. Fall: Doppelte Slot-ID (zwei Slots mit slot_id = 0)
|
|
{
|
|
let db_path = temp_dir.join(format!("test_dup_slot_0_{}.sanctum", std::process::id()));
|
|
let _ = std::fs::remove_file(&db_path);
|
|
{
|
|
let conn = rusqlite::Connection::open(&db_path).unwrap();
|
|
conn.execute(
|
|
"CREATE TABLE meta (
|
|
slot_id INTEGER, 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
|
|
);",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
conn.execute(
|
|
"INSERT INTO meta VALUES (0, ?1, 2, ?2, ?3, ?4, ?5, ?6)",
|
|
rusqlite::params![
|
|
b"SANCTUM\0",
|
|
&salt[..],
|
|
¶ms_json,
|
|
&wrapped_dek[..],
|
|
&nonce[..],
|
|
&tag[..]
|
|
],
|
|
)
|
|
.unwrap();
|
|
conn.execute(
|
|
"INSERT INTO meta VALUES (0, ?1, 2, ?2, ?3, ?4, ?5, ?6)",
|
|
rusqlite::params![
|
|
b"SANCTUM\0",
|
|
&salt[..],
|
|
¶ms_json,
|
|
&wrapped_dek[..],
|
|
&nonce[..],
|
|
&tag[..]
|
|
],
|
|
)
|
|
.unwrap();
|
|
}
|
|
let db = Database::open(&db_path).unwrap();
|
|
let res = db.read_slots();
|
|
assert!(res.is_err(), "Doppelte Slot-ID 0 muss abgewiesen werden");
|
|
let err_msg = res.err().unwrap().to_string();
|
|
assert!(
|
|
err_msg.contains("Doppelte Slot-ID 0"),
|
|
"Fehlermeldung: {err_msg}"
|
|
);
|
|
let _ = std::fs::remove_file(&db_path);
|
|
}
|
|
|
|
// 2. Fall: Ungültige Slot-ID (z. B. slot_id = 5 statt 0 oder 1)
|
|
{
|
|
let db_path = temp_dir.join(format!(
|
|
"test_invalid_slot_id_{}.sanctum",
|
|
std::process::id()
|
|
));
|
|
let _ = std::fs::remove_file(&db_path);
|
|
{
|
|
let conn = rusqlite::Connection::open(&db_path).unwrap();
|
|
conn.execute(
|
|
"CREATE TABLE meta (
|
|
slot_id INTEGER, 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
|
|
);",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
conn.execute(
|
|
"INSERT INTO meta VALUES (5, ?1, 2, ?2, ?3, ?4, ?5, ?6)",
|
|
rusqlite::params![
|
|
b"SANCTUM\0",
|
|
&salt[..],
|
|
¶ms_json,
|
|
&wrapped_dek[..],
|
|
&nonce[..],
|
|
&tag[..]
|
|
],
|
|
)
|
|
.unwrap();
|
|
}
|
|
let db = Database::open(&db_path).unwrap();
|
|
let res = db.read_slots();
|
|
assert!(res.is_err(), "Ungültige Slot-ID 5 muss abgewiesen werden");
|
|
let err_msg = res.err().unwrap().to_string();
|
|
assert!(
|
|
err_msg.contains("Ungültige Slot-ID 5"),
|
|
"Fehlermeldung: {err_msg}"
|
|
);
|
|
let _ = std::fs::remove_file(&db_path);
|
|
}
|
|
|
|
// 3. Fall: Fehlender Slot 0 (nur Slot 1 vorhanden)
|
|
{
|
|
let db_path = temp_dir.join(format!(
|
|
"test_missing_slot_0_{}.sanctum",
|
|
std::process::id()
|
|
));
|
|
let _ = std::fs::remove_file(&db_path);
|
|
{
|
|
let conn = rusqlite::Connection::open(&db_path).unwrap();
|
|
conn.execute(
|
|
"CREATE TABLE meta (
|
|
slot_id INTEGER, 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
|
|
);",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
conn.execute(
|
|
"INSERT INTO meta VALUES (1, ?1, 2, ?2, ?3, ?4, ?5, ?6)",
|
|
rusqlite::params![
|
|
b"SANCTUM\0",
|
|
&salt[..],
|
|
¶ms_json,
|
|
&wrapped_dek[..],
|
|
&nonce[..],
|
|
&tag[..]
|
|
],
|
|
)
|
|
.unwrap();
|
|
}
|
|
let db = Database::open(&db_path).unwrap();
|
|
let res = db.read_slots();
|
|
assert!(res.is_err(), "Fehlender Slot 0 muss abgewiesen werden");
|
|
let err_msg = res.err().unwrap().to_string();
|
|
assert!(
|
|
err_msg.contains("Slot 0 (Standard/Decoy Vault) fehlt"),
|
|
"Fehlermeldung: {err_msg}"
|
|
);
|
|
let _ = std::fs::remove_file(&db_path);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_container_meta_authenticate_defense_in_depth() {
|
|
use sanctum::storage::{ContainerMeta, SlotMeta};
|
|
|
|
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("TestPassword2026!", &salt, &kdf_params).unwrap();
|
|
let dek = generate_dek();
|
|
let (wrapped_dek, nonce, tag) = wrap_dek(&kek, &dek).unwrap();
|
|
|
|
let valid_slot_0 = SlotMeta {
|
|
slot_id: 0,
|
|
version: 2,
|
|
kdf_salt: salt,
|
|
kdf_params: kdf_params.clone(),
|
|
wrapped_dek: wrapped_dek.clone(),
|
|
header_nonce: nonce,
|
|
header_tag: tag,
|
|
};
|
|
|
|
let valid_slot_1 = SlotMeta {
|
|
slot_id: 1,
|
|
version: 2,
|
|
kdf_salt: salt,
|
|
kdf_params: kdf_params.clone(),
|
|
wrapped_dek: wrapped_dek.clone(),
|
|
header_nonce: nonce,
|
|
header_tag: tag,
|
|
};
|
|
|
|
// A. Leere Slots -> sofort None
|
|
let meta_empty = ContainerMeta {
|
|
version: 2,
|
|
kdf_salt: salt,
|
|
kdf_params: kdf_params.clone(),
|
|
wrapped_dek: wrapped_dek.clone(),
|
|
header_nonce: nonce,
|
|
header_tag: tag,
|
|
slots: vec![],
|
|
};
|
|
assert!(meta_empty.authenticate("TestPassword2026!").is_none());
|
|
|
|
// B. Mehr als 2 Slots -> sofort None (keine KDF-Berechnung!)
|
|
let meta_3_slots = ContainerMeta {
|
|
version: 2,
|
|
kdf_salt: salt,
|
|
kdf_params: kdf_params.clone(),
|
|
wrapped_dek: wrapped_dek.clone(),
|
|
header_nonce: nonce,
|
|
header_tag: tag,
|
|
slots: vec![
|
|
valid_slot_0.clone(),
|
|
valid_slot_1.clone(),
|
|
valid_slot_0.clone(),
|
|
],
|
|
};
|
|
assert!(meta_3_slots.authenticate("TestPassword2026!").is_none());
|
|
|
|
// C. Fehlender Slot 0 (nur Slot 1) -> sofort None
|
|
let meta_no_slot0 = ContainerMeta {
|
|
version: 2,
|
|
kdf_salt: salt,
|
|
kdf_params: kdf_params.clone(),
|
|
wrapped_dek: wrapped_dek.clone(),
|
|
header_nonce: nonce,
|
|
header_tag: tag,
|
|
slots: vec![valid_slot_1.clone()],
|
|
};
|
|
assert!(meta_no_slot0.authenticate("TestPassword2026!").is_none());
|
|
|
|
// D. Ungültige Slot-ID (> 1) -> sofort None
|
|
let mut invalid_slot = valid_slot_0.clone();
|
|
invalid_slot.slot_id = 99;
|
|
let meta_invalid_id = ContainerMeta {
|
|
version: 2,
|
|
kdf_salt: salt,
|
|
kdf_params: kdf_params.clone(),
|
|
wrapped_dek: wrapped_dek.clone(),
|
|
header_nonce: nonce,
|
|
header_tag: tag,
|
|
slots: vec![valid_slot_0.clone(), invalid_slot],
|
|
};
|
|
assert!(meta_invalid_id.authenticate("TestPassword2026!").is_none());
|
|
|
|
// E. Korrekter 1-Slot Container -> Erfolgreiche Authentifizierung
|
|
let meta_valid_1 = ContainerMeta {
|
|
version: 2,
|
|
kdf_salt: salt,
|
|
kdf_params: kdf_params.clone(),
|
|
wrapped_dek: wrapped_dek.clone(),
|
|
header_nonce: nonce,
|
|
header_tag: tag,
|
|
slots: vec![valid_slot_0.clone()],
|
|
};
|
|
assert!(meta_valid_1.authenticate("TestPassword2026!").is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn test_k01_metadata_tampering_detected_by_verify() {
|
|
let temp_dir = std::env::temp_dir();
|
|
let db_path: PathBuf = temp_dir.join(format!("test_k01_meta_{}.sanctum", std::process::id()));
|
|
if db_path.exists() {
|
|
let _ = std::fs::remove_file(&db_path);
|
|
}
|
|
|
|
let password = "SecretMasterPassword2026!";
|
|
let kdf_params = KdfParams {
|
|
memory_cost: MIN_MEMORY_COST_KIB,
|
|
time_cost: MIN_TIME_COST,
|
|
parallelism: 1,
|
|
};
|
|
let salt = generate_salt();
|
|
let kek = derive_kek(password, &salt, &kdf_params).unwrap();
|
|
let dek = generate_dek();
|
|
let (wrapped_dek, nonce, tag) = wrap_slot0_payload(&kek, &dek, 0).unwrap();
|
|
|
|
let db = Database::open(&db_path).unwrap();
|
|
db.set_active_dek(dek.clone());
|
|
db.init_schema_with_carrier(&salt, &kdf_params, &wrapped_dek, &nonce, &tag, None)
|
|
.unwrap();
|
|
|
|
// Knoten anlegen
|
|
let node = db
|
|
.create_node(1, "secret_financials.xlsx", false)
|
|
.unwrap();
|
|
db.update_node_size_and_time(node.id, 50000, 1000).unwrap();
|
|
db.checkpoint().unwrap();
|
|
|
|
// 1. Initialer Zustand: Verifikation muss erfolgreich sein
|
|
let report = verify_container(&db_path, Some(&dek), true).unwrap();
|
|
assert!(
|
|
report.is_healthy(),
|
|
"Initialer V3-Container muss gesund sein, aber Fehler: {:?}",
|
|
report.errors
|
|
);
|
|
|
|
// 2. Angreifer manipuliert Dateigröße direkt in SQLite (ohne MAC aktualisieren zu können)
|
|
{
|
|
let conn = rusqlite::Connection::open(&db_path).unwrap();
|
|
conn.execute(
|
|
"UPDATE nodes SET size = 999999 WHERE name = 'secret_financials.xlsx';",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
}
|
|
|
|
// Verifikation MUSS fehlschlagen und den K-01 Metadatenfehler melden!
|
|
let tampered_report = verify_container(&db_path, Some(&dek), true).unwrap();
|
|
assert!(
|
|
!tampered_report.is_healthy(),
|
|
"Container mit manipulierter Knotengröße darf nicht als gesund eingestuft werden!"
|
|
);
|
|
assert!(
|
|
tampered_report
|
|
.errors
|
|
.iter()
|
|
.any(|e| e.contains("K-01") || e.contains("Metadaten-MAC")),
|
|
"Fehlermeldung zu Metadaten-MAC erwartet, erhalten: {:?}",
|
|
tampered_report.errors
|
|
);
|
|
|
|
// 3. Angreifer manipuliert Dateinamen direkt in SQLite
|
|
{
|
|
let conn = rusqlite::Connection::open(&db_path).unwrap();
|
|
conn.execute(
|
|
"UPDATE nodes SET name = 'backdoor.exe' WHERE id = ?1;",
|
|
[node.id],
|
|
)
|
|
.unwrap();
|
|
}
|
|
|
|
let tampered_name_report = verify_container(&db_path, Some(&dek), true).unwrap();
|
|
assert!(
|
|
!tampered_name_report.is_healthy(),
|
|
"Container mit manipuliertem Knotennamen darf nicht als gesund eingestuft werden!"
|
|
);
|
|
assert!(
|
|
tampered_name_report
|
|
.errors
|
|
.iter()
|
|
.any(|e| e.contains("K-01") || e.contains("Metadaten-MAC")),
|
|
"Fehlermeldung zu Metadaten-MAC erwartet, erhalten: {:?}",
|
|
tampered_name_report.errors
|
|
);
|
|
|
|
let _ = std::fs::remove_file(&db_path);
|
|
}
|
|
|
|
#[test]
|
|
fn test_k01_upgrade_format_v2_to_v3() {
|
|
let temp_dir = std::env::temp_dir();
|
|
let db_path: PathBuf = temp_dir.join(format!("test_k01_upgrade_{}.sanctum", std::process::id()));
|
|
if db_path.exists() {
|
|
let _ = std::fs::remove_file(&db_path);
|
|
}
|
|
|
|
let password = "UpgradeMasterPassword2026!";
|
|
let kdf_params = KdfParams {
|
|
memory_cost: MIN_MEMORY_COST_KIB,
|
|
time_cost: MIN_TIME_COST,
|
|
parallelism: 1,
|
|
};
|
|
let salt = generate_salt();
|
|
let kek = derive_kek(password, &salt, &kdf_params).unwrap();
|
|
let dek = generate_dek();
|
|
let (wrapped_dek, nonce, tag) = wrap_slot0_payload(&kek, &dek, 0).unwrap();
|
|
|
|
// Erzeuge bewusst einen V2-Container ohne metadata_mac
|
|
{
|
|
let conn = rusqlite::Connection::open(&db_path).unwrap();
|
|
conn.execute_batch(
|
|
"CREATE TABLE meta (
|
|
slot_id INTEGER NOT NULL 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 params_json = serde_json::to_string(&kdf_params).unwrap();
|
|
conn.execute(
|
|
"INSERT INTO meta (slot_id, magic, version, kdf_salt, kdf_params, wrapped_dek, header_nonce, header_tag)
|
|
VALUES (0, ?1, 2, ?2, ?3, ?4, ?5, ?6)",
|
|
rusqlite::params![
|
|
sanctum::crypto::MAGIC_BYTES.as_slice(),
|
|
salt.as_slice(),
|
|
params_json,
|
|
wrapped_dek,
|
|
nonce.as_slice(),
|
|
tag.as_slice(),
|
|
],
|
|
)
|
|
.unwrap();
|
|
|
|
conn.execute(
|
|
"INSERT INTO nodes (id, parent_id, name, is_dir, size, created_at, modified_at)
|
|
VALUES (1, NULL, '', 1, 0, 100, 100), (2, NULL, '', 1, 0, 100, 100);",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
}
|
|
|
|
let db = Database::open(&db_path).unwrap();
|
|
let meta_before = db.read_meta().unwrap();
|
|
assert_eq!(meta_before.version, 2);
|
|
|
|
// Upgrade auf Format V3 durchführen
|
|
db.upgrade_to_v3(&dek).unwrap();
|
|
db.checkpoint().unwrap();
|
|
|
|
let meta_after = db.read_meta().unwrap();
|
|
assert_eq!(meta_after.version, 3);
|
|
|
|
// V3-Container muss nach dem Upgrade integer und gesund sein
|
|
let report = verify_container(&db_path, Some(&dek), true).unwrap();
|
|
assert!(
|
|
report.is_healthy(),
|
|
"Container nach upgrade_to_v3 muss gesund sein: {:?}",
|
|
report.errors
|
|
);
|
|
|
|
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);
|
|
}
|
|
|
|
|