release: v0.7.2 — Security Audit Remediation (SA-01 bis SA-07)
Sanctum Release / Build & Test (Windows x86_64) (push) Waiting to run
Sanctum Release / Sign & Release (push) Blocked by required conditions

- SA-01: Container-DoS / KDF-Amplification Schutz mit Pre-KDF Validierung, max 2 Slots (nur 0 und 1), Slot 0 Pflicht und strikten BLOB-Laengen
- SA-02: Release-Signierung in CI entkoppelt (getrennte build und sign-and-release Jobs, Secret-Isolation)
- SA-03: Pinned Download-Integritaet fuer minisign.exe in CI via SHA-256
- SA-04: Immutable Action-Pinning (@sha) und Toolchain-Pinning (1.85.0) in CI
- SA-05: Session-Token vollstaendig aus URIs verbannt (403 Forbidden bei Vorkommen im Pfad/Query)
- SA-06: Constant-Time Token- und Auth-Vergleiche via subtle::ConstantTimeEq
- SA-07: Dokumentations-Klarstellung bzgl. logischem Shredding vs. physischer SSD/FTL/CoW-Persistenz
This commit is contained in:
2026-09-18 23:40:35 +02:00
parent 1cdb30147b
commit fba7f305e3
28 changed files with 3048 additions and 968 deletions
+140 -35
View File
@@ -7,8 +7,8 @@ use rand::rngs::OsRng;
use rand::RngCore;
use sanctum::crypto::{
derive_kek, generate_dek, generate_salt, wrap_slot0_payload,
wrap_slot1_payload, KdfParams, CHUNK_SIZE, MIN_MEMORY_COST_KIB, MIN_TIME_COST,
derive_kek, generate_dek, generate_salt, wrap_slot0_payload, wrap_slot1_payload, KdfParams,
CHUNK_SIZE, MIN_MEMORY_COST_KIB, MIN_TIME_COST,
};
use sanctum::storage::Database;
use sanctum::verify::verify_container;
@@ -45,8 +45,7 @@ async fn test_model_a_carrier_filesystem_and_accounting_attack_resistance() {
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_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();
@@ -103,7 +102,10 @@ async fn test_model_a_carrier_filesystem_and_accounting_attack_resistance() {
let carrier_path = DavPath::new("/system_backup.dat").unwrap();
// Metadaten der Alibi-Datei im Decoy prüfen
let carrier_meta = decoy_fs.metadata(&carrier_path).await.expect("Carrier meta");
let carrier_meta = decoy_fs
.metadata(&carrier_path)
.await
.expect("Carrier meta");
assert_eq!(carrier_meta.len(), carrier_size_bytes);
assert!(!carrier_meta.is_dir());
@@ -113,20 +115,29 @@ async fn test_model_a_carrier_filesystem_and_accounting_attack_resistance() {
..Default::default()
};
assert!(
matches!(decoy_fs.open(&carrier_path, write_opts).await, Err(FsError::Forbidden)),
matches!(
decoy_fs.open(&carrier_path, write_opts).await,
Err(FsError::Forbidden)
),
"Alibi-Datei darf nicht zum Schreiben geöffnet werden"
);
// Alibi-Datei darf im Decoy-Mount NICHT gelöscht werden
assert!(
matches!(decoy_fs.remove_file(&carrier_path).await, Err(FsError::Forbidden)),
matches!(
decoy_fs.remove_file(&carrier_path).await,
Err(FsError::Forbidden)
),
"Alibi-Datei darf nicht gelöscht werden"
);
// Alibi-Datei darf im Decoy-Mount NICHT umbenannt werden
let new_name = DavPath::new("/renamed.iso").unwrap();
assert!(
matches!(decoy_fs.rename(&carrier_path, &new_name).await, Err(FsError::Forbidden)),
matches!(
decoy_fs.rename(&carrier_path, &new_name).await,
Err(FsError::Forbidden)
),
"Alibi-Datei darf nicht umbenannt werden"
);
@@ -135,8 +146,14 @@ async fn test_model_a_carrier_filesystem_and_accounting_attack_resistance() {
read: true,
..Default::default()
};
let mut file_reader = decoy_fs.open(&carrier_path, read_opts).await.expect("Open read");
let first_mb = file_reader.read_bytes(CHUNK_SIZE).await.expect("Read first chunk");
let mut file_reader = decoy_fs
.open(&carrier_path, read_opts)
.await
.expect("Open read");
let first_mb = file_reader
.read_bytes(CHUNK_SIZE)
.await
.expect("Read first chunk");
assert_eq!(first_mb.len(), CHUNK_SIZE);
// 3. Hidden Mount: Dateisystem-Operationen innerhalb des Alibi-Carriers
@@ -161,11 +178,17 @@ async fn test_model_a_carrier_filesystem_and_accounting_attack_resistance() {
while let Some(item) = stream.next().await {
entries.push(item.unwrap().name());
}
assert!(entries.is_empty(), "Hidden Vault Wurzelverzeichnis muss anfangs leer sein");
assert!(
entries.is_empty(),
"Hidden Vault Wurzelverzeichnis muss anfangs leer sein"
);
// Ordner erstellen
let secret_dir = DavPath::new("/Classified").unwrap();
hidden_fs.create_dir(&secret_dir).await.expect("Create Classified dir");
hidden_fs
.create_dir(&secret_dir)
.await
.expect("Create Classified dir");
// Datei im Ordner anlegen und schreiben
let secret_file_path = DavPath::new("/Classified/passwords.txt").unwrap();
@@ -196,14 +219,24 @@ async fn test_model_a_carrier_filesystem_and_accounting_attack_resistance() {
.open(&secret_file_path, read_opts)
.await
.expect("Open secret file for read");
let read_data = read_handle.read_bytes(1024).await.expect("Read secret bytes");
let read_data = read_handle
.read_bytes(1024)
.await
.expect("Read secret bytes");
assert_eq!(&read_data[..], secret_content);
drop(read_handle);
// Größere Binärdatei schreiben (über 2 MB = 2 Blöcke)
let big_file_path = DavPath::new("/Classified/payload.bin").unwrap();
let mut big_file = hidden_fs
.open(&big_file_path, OpenOptions { create: true, write: true, ..Default::default() })
.open(
&big_file_path,
OpenOptions {
create: true,
write: true,
..Default::default()
},
)
.await
.expect("Create big file");
@@ -211,28 +244,46 @@ async fn test_model_a_carrier_filesystem_and_accounting_attack_resistance() {
let mut payload = vec![0u8; payload_size];
OsRng.fill_bytes(&mut payload);
big_file.write_bytes(Bytes::copy_from_slice(&payload)).await.expect("Write big payload");
big_file
.write_bytes(Bytes::copy_from_slice(&payload))
.await
.expect("Write big payload");
big_file.flush().await.expect("Flush big file");
drop(big_file);
// Datei zurücklesen und Bit-für-Bit verifizieren
let mut read_big = hidden_fs
.open(&big_file_path, OpenOptions { read: true, ..Default::default() })
.open(
&big_file_path,
OpenOptions {
read: true,
..Default::default()
},
)
.await
.expect("Open big file");
let read_big_bytes = read_big.read_bytes(payload_size + 100).await.expect("Read big file bytes");
let read_big_bytes = read_big
.read_bytes(payload_size + 100)
.await
.expect("Read big file bytes");
assert_eq!(read_big_bytes.len(), payload_size);
assert_eq!(&read_big_bytes[..], &payload[..]);
drop(read_big);
// Datei umbenennen
let renamed_path = DavPath::new("/Classified/renamed_payload.bin").unwrap();
hidden_fs.rename(&big_file_path, &renamed_path).await.expect("Rename file");
hidden_fs
.rename(&big_file_path, &renamed_path)
.await
.expect("Rename file");
assert!(hidden_fs.metadata(&big_file_path).await.is_err());
assert!(hidden_fs.metadata(&renamed_path).await.is_ok());
// Datei löschen (Blöcke werden geshreddert und freigegeben)
hidden_fs.remove_file(&renamed_path).await.expect("Remove file");
hidden_fs
.remove_file(&renamed_path)
.await
.expect("Remove file");
assert!(hidden_fs.metadata(&renamed_path).await.is_err());
// Checkpoint SQLite
@@ -284,8 +335,7 @@ async fn test_model_a_container_file_size_invariance() {
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_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();
@@ -315,7 +365,10 @@ async fn test_model_a_container_file_size_invariance() {
// Initiale Dateigröße messen
let initial_file_size = std::fs::metadata(&path).unwrap().len();
assert!(initial_file_size >= carrier_size_bytes, "Containergröße muss mindestens 10 MB betragen");
assert!(
initial_file_size >= carrier_size_bytes,
"Containergröße muss mindestens 10 MB betragen"
);
// Hidden Mount öffnen und 4 MB geheime Daten schreiben
let meta = db.read_meta().unwrap();
@@ -333,13 +386,23 @@ async fn test_model_a_container_file_size_invariance() {
let test_file = DavPath::new("/large_confidential.pdf").unwrap();
let mut handle = hidden_fs
.open(&test_file, OpenOptions { create: true, write: true, ..Default::default() })
.open(
&test_file,
OpenOptions {
create: true,
write: true,
..Default::default()
},
)
.await
.expect("Open file");
let mut random_data = vec![0u8; 4 * 1024 * 1024]; // 4 MB
OsRng.fill_bytes(&mut random_data);
handle.write_bytes(Bytes::copy_from_slice(&random_data)).await.expect("Write 4MB");
handle
.write_bytes(Bytes::copy_from_slice(&random_data))
.await
.expect("Write 4MB");
handle.flush().await.expect("Flush 4MB");
drop(handle);
@@ -383,8 +446,7 @@ async fn test_carrier_file_drop_and_append_mode() {
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_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();
@@ -428,44 +490,87 @@ async fn test_carrier_file_drop_and_append_mode() {
// 1. TEST CarrierFile::drop: Write bytes OHNE expliziten flush(), dann drop(handle)
let test_file = DavPath::new("/drop_flush_test.txt").unwrap();
let mut write_handle = hidden_fs
.open(&test_file, OpenOptions { create: true, write: true, ..Default::default() })
.open(
&test_file,
OpenOptions {
create: true,
write: true,
..Default::default()
},
)
.await
.expect("Open file for write");
let initial_data = b"Hello from unflushed write!";
write_handle.write_bytes(Bytes::from_static(initial_data)).await.expect("Write initial data");
write_handle
.write_bytes(Bytes::from_static(initial_data))
.await
.expect("Write initial data");
// WICHTIG: KEIN write_handle.flush()! Nur drop:
drop(write_handle);
// Jetzt Datei wieder lesend öffnen und prüfen, ob Daten durch Drop persistiert wurden
let mut read_handle = hidden_fs
.open(&test_file, OpenOptions { read: true, ..Default::default() })
.open(
&test_file,
OpenOptions {
read: true,
..Default::default()
},
)
.await
.expect("Open file for read");
let read_back = read_handle.read_bytes(100).await.expect("Read data back");
assert_eq!(&read_back[..], initial_data, "Drop muss ungeflushte Datenblöcke und Inode automatisch sichern");
assert_eq!(
&read_back[..],
initial_data,
"Drop muss ungeflushte Datenblöcke und Inode automatisch sichern"
);
drop(read_handle);
// 2. TEST O_APPEND: Im Append-Modus öffnen und weitere Daten anhängen
let append_data = b" - Appended data at EOF!";
let mut append_handle = hidden_fs
.open(&test_file, OpenOptions { write: true, append: true, ..Default::default() })
.open(
&test_file,
OpenOptions {
write: true,
append: true,
..Default::default()
},
)
.await
.expect("Open file for append");
append_handle.write_bytes(Bytes::from_static(append_data)).await.expect("Write appended data");
append_handle
.write_bytes(Bytes::from_static(append_data))
.await
.expect("Write appended data");
drop(append_handle); // Drop sichert auch hier
// Prüfe den vollständigen Dateiinhalt nach Append
let mut read_handle_2 = hidden_fs
.open(&test_file, OpenOptions { read: true, ..Default::default() })
.open(
&test_file,
OpenOptions {
read: true,
..Default::default()
},
)
.await
.expect("Open file for read after append");
let full_content = read_handle_2.read_bytes(200).await.expect("Read full content");
let full_content = read_handle_2
.read_bytes(200)
.await
.expect("Read full content");
let mut expected = Vec::new();
expected.extend_from_slice(initial_data);
expected.extend_from_slice(append_data);
assert_eq!(&full_content[..], &expected[..], "O_APPEND muss Daten am Dateiende anhängen");
assert_eq!(
&full_content[..],
&expected[..],
"O_APPEND muss Daten am Dateiende anhängen"
);
drop(read_handle_2);
let _ = std::fs::remove_file(&path);
+506 -132
View File
File diff suppressed because it is too large Load Diff
+10 -3
View File
@@ -20,7 +20,10 @@ use sanctum::{
#[tokio::test]
async fn test_live_crash_and_recovery_stress() {
let temp_dir = std::env::temp_dir();
let container_path: PathBuf = temp_dir.join(format!("sanctum_live_stress_{}.sanctum", std::process::id()));
let container_path: PathBuf = temp_dir.join(format!(
"sanctum_live_stress_{}.sanctum",
std::process::id()
));
if container_path.exists() {
let _ = std::fs::remove_file(&container_path);
}
@@ -105,13 +108,17 @@ async fn test_live_crash_and_recovery_stress() {
// 4. Recovery & Integritätsprüfung nach Crash
// Das System muss die SQLite WAL-Datei automatisch erkennen und verarbeiten
let verify_result = verify_container(&container_path, Some(&dek), false).expect("Verify post-crash");
let verify_result =
verify_container(&container_path, Some(&dek), false).expect("Verify post-crash");
assert!(
verify_result.is_healthy(),
"Container muss nach Crash vollkommen konsistent sein! Fehler: {:?}",
verify_result.errors
);
assert_eq!(verify_result.corrupted_chunks, 0, "Keine korrupten Chunks erlaubt");
assert_eq!(
verify_result.corrupted_chunks, 0,
"Keine korrupten Chunks erlaubt"
);
// 5. Konsistentes Weiterarbeiten nach dem Absturz
let db_recovered = Database::open(&container_path).expect("Open database after crash");
+57 -16
View File
@@ -1,10 +1,10 @@
use std::path::PathBuf;
use sanctum::crypto::{
derive_kek, generate_dek, generate_salt, wrap_dek, KdfParams, MIN_MEMORY_COST_KIB,
MIN_TIME_COST,
};
use sanctum::mount::is_loopback_host;
use sanctum::storage::Database;
use std::path::PathBuf;
#[test]
fn test_is_loopback_host_comprehensive() {
@@ -45,7 +45,8 @@ fn test_is_loopback_host_comprehensive() {
#[test]
fn test_wal_and_shm_cleanup_on_close() {
let temp_dir = std::env::temp_dir();
let container_path: PathBuf = temp_dir.join(format!("test_wal_cleanup_{}.sanctum", std::process::id()));
let container_path: PathBuf =
temp_dir.join(format!("test_wal_cleanup_{}.sanctum", std::process::id()));
if container_path.exists() {
let _ = std::fs::remove_file(&container_path);
}
@@ -61,8 +62,9 @@ fn test_wal_and_shm_cleanup_on_close() {
let (wrapped_dek, nonce, tag) = wrap_dek(&kek, &dek).unwrap();
let db = Database::open(&container_path).unwrap();
db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag).unwrap();
db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag)
.unwrap();
// Erstelle Knoten, um Schreibaktivität im WAL zu erzeugen
let _ = db.create_node(1, "test_file.txt", false).unwrap();
db.checkpoint().unwrap();
@@ -77,24 +79,33 @@ fn test_wal_and_shm_cleanup_on_close() {
shm.push("-shm");
let _ = std::fs::remove_file(&shm);
assert!(!PathBuf::from(wal).exists(), "WAL-Datei darf nach sauberem Unmount nicht zurückbleiben");
assert!(!PathBuf::from(shm).exists(), "SHM-Datei darf nach sauberem Unmount nicht zurückbleiben");
assert!(
!PathBuf::from(wal).exists(),
"WAL-Datei darf nach sauberem Unmount nicht zurückbleiben"
);
assert!(
!PathBuf::from(shm).exists(),
"SHM-Datei darf nach sauberem Unmount nicht zurückbleiben"
);
let _ = std::fs::remove_file(&container_path);
}
#[test]
fn test_mount_security_multi_auth_and_no_token_in_url() {
use sanctum::mount::{check_basic_auth, check_token_header, strip_path_prefix};
use sanctum::mount::{check_basic_auth, check_token_header, uri_contains_token};
let session_token = "4f8a12bc90de45f187a23456789abcde";
let port = 8443;
let remote_url = format!("http://127.0.0.1:{}/", port);
// R-05: Die Remote-URL für Mount-Befehle darf niemals das Session-Token enthalten!
assert!(!remote_url.contains(session_token), "Remote URL darf niemals das Session-Token enthalten");
// R-05 & SA-05: Die Remote-URL für Mount-Befehle darf niemals das Session-Token enthalten!
assert!(
!remote_url.contains(session_token),
"Remote URL darf niemals das Session-Token enthalten"
);
// 1. Basic Auth Prüfung
// 1. Basic Auth Prüfung (Constant-Time)
use base64::Engine;
let auth_header = format!(
"Basic {}",
@@ -102,14 +113,44 @@ fn test_mount_security_multi_auth_and_no_token_in_url() {
);
assert!(check_basic_auth(&auth_header, session_token));
// 2. Token Header Prüfung
let wrong_auth = format!(
"Basic {}",
base64::engine::general_purpose::STANDARD.encode("sanctum:wrong_token_1234567890abcdef")
);
assert!(!check_basic_auth(&wrong_auth, session_token));
// 2. Token Header Prüfung (Constant-Time)
let mut headers = hyper::HeaderMap::new();
headers.insert("X-Sanctum-Token", session_token.parse().unwrap());
assert!(check_token_header(&headers, session_token));
// 3. Path-Prefix Fallback
let prefix = format!("/{}", session_token);
let uri: hyper::Uri = format!("http://127.0.0.1:8443/{}/test.txt", session_token).parse().unwrap();
let stripped = strip_path_prefix(&uri, &prefix).expect("Strip prefix");
assert_eq!(stripped.path(), "/test.txt");
let mut wrong_headers = hyper::HeaderMap::new();
wrong_headers.insert(
"X-Sanctum-Token",
"wrong_token_1234567890abcdef".parse().unwrap(),
);
assert!(!check_token_header(&wrong_headers, session_token));
// 3. SA-05: Verifikation, dass Token in URI (Pfad oder Query) erkannt und strikt abgewiesen wird
let uri_path: hyper::Uri = format!("http://127.0.0.1:8443/{}/test.txt", session_token)
.parse()
.unwrap();
assert!(
uri_contains_token(&uri_path, session_token),
"Token im Pfad muss erkannt werden"
);
let uri_query: hyper::Uri = format!("http://127.0.0.1:8443/test.txt?token={}", session_token)
.parse()
.unwrap();
assert!(
uri_contains_token(&uri_query, session_token),
"Token im Query-String muss erkannt werden"
);
let uri_clean: hyper::Uri = "http://127.0.0.1:8443/test.txt".parse().unwrap();
assert!(
!uri_contains_token(&uri_clean, session_token),
"Saubere URI darf kein Token enthalten"
);
}
+40 -10
View File
@@ -27,9 +27,25 @@ fn test_validate_node_name_rejections() {
// 6. Windows reservierte Gerätenamen
let reserved_names = [
"CON", "con", "prn", "PRN", "aux", "AUX", "nul", "NUL",
"COM1", "com2", "COM9", "lpt1", "LPT2", "LPT9",
"con.txt", "aux.dat", "NUL.tar.gz", "com1.log", "prn.pdf",
"CON",
"con",
"prn",
"PRN",
"aux",
"AUX",
"nul",
"NUL",
"COM1",
"com2",
"COM9",
"lpt1",
"LPT2",
"LPT9",
"con.txt",
"aux.dat",
"NUL.tar.gz",
"com1.log",
"prn.pdf",
];
for res in reserved_names {
assert!(
@@ -64,7 +80,10 @@ fn test_validate_node_name_accepted() {
#[test]
fn test_storage_create_and_rename_reject_invalid_names() {
let mut path = std::env::temp_dir();
path.push(format!("sanctum_test_pathutil_{}.sanctum", std::process::id()));
path.push(format!(
"sanctum_test_pathutil_{}.sanctum",
std::process::id()
));
if path.exists() {
let _ = std::fs::remove_file(&path);
}
@@ -80,20 +99,31 @@ fn test_storage_create_and_rename_reject_invalid_names() {
let (wrapped, nonce, tag) = sanctum::crypto::wrap_dek(&kek, &dek).unwrap();
let db = sanctum::storage::Database::open(&path).unwrap();
db.init_schema(&salt, &kdf_params, &wrapped, &nonce, &tag).unwrap();
db.init_schema(&salt, &kdf_params, &wrapped, &nonce, &tag)
.unwrap();
// create_node_in_vault mit ungültigem Namen muss scheitern
assert!(db.create_node_in_vault(0, 1, "..", false, &dek).is_err());
assert!(db.create_node_in_vault(0, 1, "CON", false, &dek).is_err());
assert!(db.create_node_in_vault(0, 1, "sub/dir", false, &dek).is_err());
assert!(db
.create_node_in_vault(0, 1, "sub/dir", false, &dek)
.is_err());
// Gültige Datei erstellen
let valid_node = db.create_node_in_vault(0, 1, "valid.txt", false, &dek).unwrap();
let valid_node = db
.create_node_in_vault(0, 1, "valid.txt", false, &dek)
.unwrap();
// rename_node_in_vault mit ungültigem Namen muss scheitern
assert!(db.rename_node_in_vault(valid_node.id, 1, "..", 0, &dek).is_err());
assert!(db.rename_node_in_vault(valid_node.id, 1, "AUX", 0, &dek).is_err());
assert!(db.rename_node_in_vault(valid_node.id, 1, "bad\\name", 0, &dek).is_err());
assert!(db
.rename_node_in_vault(valid_node.id, 1, "..", 0, &dek)
.is_err());
assert!(db
.rename_node_in_vault(valid_node.id, 1, "AUX", 0, &dek)
.is_err());
assert!(db
.rename_node_in_vault(valid_node.id, 1, "bad\\name", 0, &dek)
.is_err());
let _ = std::fs::remove_file(&path);
}
+68 -23
View File
@@ -1,11 +1,11 @@
use std::collections::HashSet;
use std::path::PathBuf;
use rand::rngs::OsRng;
use rand::RngCore;
use std::collections::HashSet;
use std::path::PathBuf;
use sanctum::crypto::{
derive_kek, generate_dek, generate_salt, wrap_slot0_payload,
wrap_slot1_payload, KdfParams, MIN_MEMORY_COST_KIB, MIN_TIME_COST,
derive_kek, generate_dek, generate_salt, wrap_slot0_payload, wrap_slot1_payload, KdfParams,
MIN_MEMORY_COST_KIB, MIN_TIME_COST,
};
use sanctum::storage::Database;
use sanctum::sync::{delete_orphans_in_vault, SyncStats};
@@ -41,8 +41,7 @@ fn test_carrier_protection_in_storage_and_sync() {
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_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();
@@ -74,18 +73,33 @@ fn test_carrier_protection_in_storage_and_sync() {
// 1. Direkter Löschversuch der Trägerdatei muss fail-closed abgewehrt werden
let del_res = db.delete_node(carrier_node_id);
assert!(del_res.is_err(), "Löschen der Trägerdatei muss fehlschlagen");
assert!(
del_res.is_err(),
"Löschen der Trägerdatei muss fehlschlagen"
);
assert!(del_res.unwrap_err().to_string().contains("Carrier-Schutz"));
// 2. Umbenennung der Trägerdatei muss fail-closed abgewehrt werden
let rename_res = db.rename_node_in_vault(carrier_node_id, 1, "renamed.iso", 0, &dek_0);
assert!(rename_res.is_err(), "Umbenennen der Trägerdatei muss fehlschlagen");
assert!(rename_res.unwrap_err().to_string().contains("Carrier-Schutz"));
assert!(
rename_res.is_err(),
"Umbenennen der Trägerdatei muss fehlschlagen"
);
assert!(rename_res
.unwrap_err()
.to_string()
.contains("Carrier-Schutz"));
// 3. Truncate der Trägerdatei muss fail-closed abgewehrt werden
let trunc_res = db.truncate_chunks_after(carrier_node_id, 0);
assert!(trunc_res.is_err(), "Truncate der Trägerdatei muss fehlschlagen");
assert!(trunc_res.unwrap_err().to_string().contains("Carrier-Schutz"));
assert!(
trunc_res.is_err(),
"Truncate der Trägerdatei muss fehlschlagen"
);
assert!(trunc_res
.unwrap_err()
.to_string()
.contains("Carrier-Schutz"));
// 4. delete_orphans_in_vault mit leerem lokalem Pfad-Set:
// Der Carrier darf unter keinen Umständen gelöscht werden!
@@ -102,16 +116,26 @@ fn test_carrier_protection_in_storage_and_sync() {
true, // quiet
&mut stats,
);
assert!(orphan_res.is_ok(), "delete_orphans_in_vault muss ohne Fehler durchlaufen");
assert_eq!(stats.files_deleted, 0, "Carrier-Datei darf nicht gelöscht worden sein");
assert!(
orphan_res.is_ok(),
"delete_orphans_in_vault muss ohne Fehler durchlaufen"
);
assert_eq!(
stats.files_deleted, 0,
"Carrier-Datei darf nicht gelöscht worden sein"
);
// Sicherstellen, dass Carrier nach wie vor in der DB existiert
let carrier_rec = db.get_node_by_id(carrier_node_id).unwrap();
assert!(carrier_rec.is_some(), "Carrier-Knoten muss nach Sync unversehrt vorhanden sein");
assert!(
carrier_rec.is_some(),
"Carrier-Knoten muss nach Sync unversehrt vorhanden sein"
);
// 5. R-02: sync_single_file_to_host muss Carrier-Pull strikt ablehnen
let carrier_node = db.get_node_by_id(carrier_node_id).unwrap().unwrap();
let local_dest_file = std::env::temp_dir().join(format!("carrier_leak_{}.iso", std::process::id()));
let local_dest_file =
std::env::temp_dir().join(format!("carrier_leak_{}.iso", std::process::id()));
let pull_single_res = sanctum::sync::sync_single_file_to_host(
&db,
0,
@@ -122,11 +146,18 @@ fn test_carrier_protection_in_storage_and_sync() {
false,
false,
);
assert!(pull_single_res.is_err(), "sync_single_file_to_host auf Carrier muss fehlschlagen");
assert!(!local_dest_file.exists(), "Trägerdatei darf niemals auf den Host geschrieben werden");
assert!(
pull_single_res.is_err(),
"sync_single_file_to_host auf Carrier muss fehlschlagen"
);
assert!(
!local_dest_file.exists(),
"Trägerdatei darf niemals auf den Host geschrieben werden"
);
// 6. R-02: run_sync mit Pull muss die Trägerdatei überspringen
let local_pull_dir = std::env::temp_dir().join(format!("sanctum_pull_test_{}", std::process::id()));
let local_pull_dir =
std::env::temp_dir().join(format!("sanctum_pull_test_{}", std::process::id()));
let sync_options = sanctum::sync::SyncOptions {
direction: sanctum::sync::SyncDirection::Pull,
delete: false,
@@ -144,9 +175,15 @@ fn test_carrier_protection_in_storage_and_sync() {
&local_pull_dir.to_string_lossy(),
&sync_options,
);
assert!(pull_res.is_ok(), "run_sync pull muss erfolgreich durchlaufen");
assert!(
pull_res.is_ok(),
"run_sync pull muss erfolgreich durchlaufen"
);
let pulled_carrier = local_pull_dir.join(carrier_name);
assert!(!pulled_carrier.exists(), "Trägerdatei darf bei recursive pull nicht auf den Host kopiert werden");
assert!(
!pulled_carrier.exists(),
"Trägerdatei darf bei recursive pull nicht auf den Host kopiert werden"
);
let _ = std::fs::remove_dir_all(&local_pull_dir);
// 7. R-02: VFS Copy Schutz
@@ -166,14 +203,22 @@ fn test_carrier_protection_in_storage_and_sync() {
let to_copy = DavPath::new("/carrier_copy.iso").unwrap();
let rt = tokio::runtime::Runtime::new().unwrap();
let copy_src_res = rt.block_on(fs.copy(&from_carrier, &to_copy));
assert!(copy_src_res.is_err(), "SanctumFs::copy mit Carrier als Quelle muss FsError::Forbidden liefern");
assert!(
copy_src_res.is_err(),
"SanctumFs::copy mit Carrier als Quelle muss FsError::Forbidden liefern"
);
// Copy Ziel = Carrier
let _regular_file = db.create_node_in_vault(0, 1, "regular.txt", false, &dek_0).unwrap();
let _regular_file = db
.create_node_in_vault(0, 1, "regular.txt", false, &dek_0)
.unwrap();
let from_regular = DavPath::new("/regular.txt").unwrap();
let to_carrier = DavPath::new(&format!("/{}", carrier_name)).unwrap();
let copy_dst_res = rt.block_on(fs.copy(&from_regular, &to_carrier));
assert!(copy_dst_res.is_err(), "SanctumFs::copy mit Carrier als Ziel muss FsError::Forbidden liefern");
assert!(
copy_dst_res.is_err(),
"SanctumFs::copy mit Carrier als Ziel muss FsError::Forbidden liefern"
);
let _ = std::fs::remove_file(&path);
}
+48 -18
View File
@@ -22,13 +22,15 @@ fn create_test_container(path: &Path) -> (Database, [u8; 32]) {
let kek = derive_kek("sync_password", &salt, &kdf_params).unwrap();
let dek = generate_dek();
let (wrapped_dek, header_nonce, header_tag) = wrap_dek(&kek, &dek).unwrap();
db.init_schema(&salt, &kdf_params, &wrapped_dek, &header_nonce, &header_tag).unwrap();
db.init_schema(&salt, &kdf_params, &wrapped_dek, &header_nonce, &header_tag)
.unwrap();
(db, *dek)
}
#[test]
fn test_sync_push_and_pull_basic() {
let temp_root = std::env::temp_dir().join(format!("sanctum_sync_test_{}", rand::random::<u64>()));
let temp_root =
std::env::temp_dir().join(format!("sanctum_sync_test_{}", rand::random::<u64>()));
let container_path = temp_root.join("test.sanctum");
let source_dir = temp_root.join("source");
let target_dir = temp_root.join("restored");
@@ -38,7 +40,11 @@ fn test_sync_push_and_pull_basic() {
// Testdateien anlegen
fs::write(source_dir.join("file1.txt"), b"Hello Sanctum Sync!").unwrap();
fs::write(source_dir.join("sub").join("file2.bin"), vec![0x42u8; 100_000]).unwrap();
fs::write(
source_dir.join("sub").join("file2.bin"),
vec![0x42u8; 100_000],
)
.unwrap();
let (db, dek) = create_test_container(&container_path);
@@ -55,7 +61,8 @@ fn test_sync_push_and_pull_basic() {
source_dir.to_str().unwrap(),
"/Backup",
&opts,
).expect("Sync push");
)
.expect("Sync push");
assert_eq!(stats.files_scanned, 2);
assert_eq!(stats.files_transferred, 2);
@@ -70,7 +77,8 @@ fn test_sync_push_and_pull_basic() {
source_dir.to_str().unwrap(),
"/Backup",
&opts,
).expect("Sync push delta");
)
.expect("Sync push delta");
assert_eq!(stats_delta.files_transferred, 0);
assert_eq!(stats_delta.files_skipped, 2);
@@ -88,13 +96,16 @@ fn test_sync_push_and_pull_basic() {
source_dir.to_str().unwrap(),
"/Backup",
&dry_opts,
).expect("Sync push dry-run");
)
.expect("Sync push dry-run");
assert_eq!(stats_dry.files_transferred, 1);
assert_eq!(stats_dry.files_skipped, 2);
// Verifizieren, dass new_file.txt im Tresor tatsächlich NICHT existiert
let node = db.resolve_path_in_vault("/Backup/new_file.txt", 0, &dek).unwrap();
let node = db
.resolve_path_in_vault("/Backup/new_file.txt", 0, &dek)
.unwrap();
assert!(node.is_none());
// 4. Pull
@@ -110,7 +121,8 @@ fn test_sync_push_and_pull_basic() {
"/Backup",
target_dir.to_str().unwrap(),
&pull_opts,
).expect("Sync pull");
)
.expect("Sync pull");
assert_eq!(stats_pull.files_transferred, 2);
@@ -126,7 +138,8 @@ fn test_sync_push_and_pull_basic() {
#[test]
fn test_sync_multi_megabyte_large_file() {
let temp_root = std::env::temp_dir().join(format!("sanctum_sync_large_{}", rand::random::<u64>()));
let temp_root =
std::env::temp_dir().join(format!("sanctum_sync_large_{}", rand::random::<u64>()));
let container_path = temp_root.join("test_large.sanctum");
let source_dir = temp_root.join("source");
let target_dir = temp_root.join("restored");
@@ -159,10 +172,14 @@ fn test_sync_multi_megabyte_large_file() {
source_dir.to_str().unwrap(),
"/LargeTest",
&opts,
).expect("Sync push large");
)
.expect("Sync push large");
assert_eq!(stats.files_transferred, 1);
assert_eq!(stats.bytes_transferred, 3 * (CHUNK_SIZE as u64) + 512 * 1024);
assert_eq!(
stats.bytes_transferred,
3 * (CHUNK_SIZE as u64) + 512 * 1024
);
// Pull
let mut pull_opts = SyncOptions::default();
@@ -177,7 +194,8 @@ fn test_sync_multi_megabyte_large_file() {
"/LargeTest",
target_dir.to_str().unwrap(),
&pull_opts,
).expect("Sync pull large");
)
.expect("Sync pull large");
let restored = fs::read(target_dir.join("large_payload.bin")).unwrap();
let original = fs::read(&large_file_path).unwrap();
@@ -189,7 +207,8 @@ fn test_sync_multi_megabyte_large_file() {
#[test]
fn test_sync_delete_and_exclude_flags() {
let temp_root = std::env::temp_dir().join(format!("sanctum_sync_flags_{}", rand::random::<u64>()));
let temp_root =
std::env::temp_dir().join(format!("sanctum_sync_flags_{}", rand::random::<u64>()));
let container_path = temp_root.join("test_flags.sanctum");
let source_dir = temp_root.join("source");
@@ -214,10 +233,14 @@ fn test_sync_delete_and_exclude_flags() {
source_dir.to_str().unwrap(),
"/Files",
&opts,
).unwrap();
)
.unwrap();
assert_eq!(stats.files_transferred, 2); // keep.txt und remove_me.txt
assert!(db.resolve_path_in_vault("/Files/ignore.tmp", 0, &dek).unwrap().is_none());
assert!(db
.resolve_path_in_vault("/Files/ignore.tmp", 0, &dek)
.unwrap()
.is_none());
// 2. Lokale Datei löschen und Sync mit --delete ausführen
fs::remove_file(source_dir.join("remove_me.txt")).unwrap();
@@ -231,11 +254,18 @@ fn test_sync_delete_and_exclude_flags() {
source_dir.to_str().unwrap(),
"/Files",
&opts,
).unwrap();
)
.unwrap();
assert_eq!(stats_del.files_deleted, 1);
assert!(db.resolve_path_in_vault("/Files/remove_me.txt", 0, &dek).unwrap().is_none());
assert!(db.resolve_path_in_vault("/Files/keep.txt", 0, &dek).unwrap().is_some());
assert!(db
.resolve_path_in_vault("/Files/remove_me.txt", 0, &dek)
.unwrap()
.is_none());
assert!(db
.resolve_path_in_vault("/Files/keep.txt", 0, &dek)
.unwrap()
.is_some());
let _ = fs::remove_dir_all(&temp_root);
}
+473 -10
View File
@@ -1,9 +1,9 @@
use std::path::PathBuf;
use sanctum::crypto::{
derive_kek, generate_dek, generate_salt, wrap_dek, KdfParams, MIN_MEMORY_COST_KIB,
MIN_TIME_COST,
};
use sanctum::storage::Database;
use std::path::PathBuf;
#[test]
fn test_reject_arbitrary_sqlite_database() {
@@ -16,12 +16,10 @@ fn test_reject_arbitrary_sqlite_database() {
// 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();
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
@@ -114,7 +112,8 @@ fn test_reject_out_of_bounds_kdf_params() {
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.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag)
.unwrap();
db.checkpoint().unwrap();
drop(db);
@@ -169,7 +168,8 @@ fn test_reject_out_of_bounds_kdf_params() {
#[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()));
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);
}
@@ -185,7 +185,8 @@ fn test_reject_corrupted_slot_lengths() {
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.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag)
.unwrap();
db.checkpoint().unwrap();
drop(db);
@@ -214,3 +215,465 @@ fn test_reject_corrupted_slot_lengths() {
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[..],
&params_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[..],
&params_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[..],
&params_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[..],
&params_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[..],
&params_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[..],
&params_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[..],
&params_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());
}
+18 -5
View File
@@ -1,4 +1,4 @@
use sanctum::upgrade::{
use sanctum::upgrade::{
run_upgrade, verify_minisign_signature, UpgradeOptions, SANCTUM_RELEASE_PUBKEY,
};
@@ -12,7 +12,11 @@ trusted comment: timestamp:1789750897\tfile:test_payload.txt\thashed\n\
0l7KsNJkDTo2uOKL8UAiaJOWuhwhGTVZ5fn0JFMdxXE9riYixQO9YnBpSVqz8iCDhWrz8hJBnmUrIWnaRaXVCA==\n";
let res = verify_minisign_signature(payload, sig_text, SANCTUM_RELEASE_PUBKEY);
assert!(res.is_ok(), "Gültige Minisign-Signatur muss erfolgreich verifiziert werden: {:?}", res.err());
assert!(
res.is_ok(),
"Gültige Minisign-Signatur muss erfolgreich verifiziert werden: {:?}",
res.err()
);
}
#[test]
@@ -25,7 +29,10 @@ trusted comment: timestamp:1789750897\tfile:test_payload.txt\thashed\n\
0l7KsNJkDTo2uOKL8UAiaJOWuhwhGTVZ5fn0JFMdxXE9riYixQO9YnBpSVqz8iCDhWrz8hJBnmUrIWnaRaXVCA==\n";
let res = verify_minisign_signature(tampered_payload, sig_text, SANCTUM_RELEASE_PUBKEY);
assert!(res.is_err(), "Manipulierte Binärdaten müssen die Minisign-Prüfung verfehlen");
assert!(
res.is_err(),
"Manipulierte Binärdaten müssen die Minisign-Prüfung verfehlen"
);
assert!(res.unwrap_err().to_string().contains("FEHLGESCHLAGEN"));
}
@@ -41,7 +48,10 @@ trusted comment: timestamp:1789750897\tfile:test_payload.txt\thashed\n\
// Ein anderer, unautorisierter Public Key
let other_key = "RWSb0a70vF1X8qIjc7xi28Gmw+cIWbMipOy4L6ToJEUPkWDw3c0qIjc7";
let res = verify_minisign_signature(payload, sig_text, other_key);
assert!(res.is_err(), "Signaturprüfung mit fremdem Schlüssel muss fehlschlagen");
assert!(
res.is_err(),
"Signaturprüfung mit fremdem Schlüssel muss fehlschlagen"
);
}
#[test]
@@ -55,7 +65,10 @@ fn test_upgrade_blocks_unofficial_url_without_insecure_flag() {
};
let res = run_upgrade(&options);
assert!(res.is_err(), "Inoffizielle Server-URL ohne --insecure-url muss blockiert werden");
assert!(
res.is_err(),
"Inoffizielle Server-URL ohne --insecure-url muss blockiert werden"
);
let err_msg = res.unwrap_err().to_string();
assert!(
err_msg.contains("Sicherheitsverstoß") || err_msg.contains("nicht vertrauenswürdig"),
+2 -1
View File
@@ -150,7 +150,8 @@ fn test_json_deserialization_of_gitea_release() {
]
}"##;
let release: GiteaRelease = serde_json::from_str(gitea_json).expect("Deserialisierung erfolgreich");
let release: GiteaRelease =
serde_json::from_str(gitea_json).expect("Deserialisierung erfolgreich");
assert_eq!(release.id, 27);
assert_eq!(release.tag_name, "v0.5.0");
assert_eq!(release.assets.len(), 3);