Files
sanctum/tests/mount_security_test.rs
T
harald badbe3bd18
Sanctum Release / Build & Test (Windows x86_64 & Linux musl) (push) Waiting to run
Sanctum Release / Sign & Release (push) Blocked by required conditions
fix(recovery): resolve metadata MAC desync after recovery (R-NEW-1) & complete release workflow (CI-01)
- R-NEW-1: Recalculate metadata HMAC upon recovery-key restore and mark backup restores as PendingRebuild to rebuild transparently on first mount
- CI-01: Update release.yaml to compile both Windows x86_64 and Linux musl with pinned Zig 0.16.0 and cargo-zigbuild 0.23.4
- W-1: Implement statvfs quota determination on Unix via libc
- Add RELEASE_PROCESS.md documenting release architecture and steps
- Bump version to 0.9.2 across manifests, lockfile, docs, Scoop and WinGet
2026-09-21 08:15:17 +02:00

683 lines
22 KiB
Rust

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() {
// Gültige IPv4 Loopback Varianten
assert!(is_loopback_host("127.0.0.1"));
assert!(is_loopback_host("127.0.0.1:80"));
assert!(is_loopback_host("127.0.0.1:8080"));
assert!(is_loopback_host("127.0.0.1:65535"));
// Gültige Hostname Loopback Varianten
assert!(is_loopback_host("localhost"));
assert!(is_loopback_host("localhost:80"));
assert!(is_loopback_host("localhost:8443"));
assert!(is_loopback_host("LOCALHOST"));
assert!(is_loopback_host("LocalHost:9000"));
// Gültige IPv6 Loopback Varianten
assert!(is_loopback_host("[::1]"));
assert!(is_loopback_host("[::1]:80"));
assert!(is_loopback_host("[::1]:8443"));
assert!(is_loopback_host("::1"));
// DNS-Rebinding & Spoofing Angriffe (MÜSSEN abgewiesen werden)
assert!(!is_loopback_host("127.0.0.1.attacker.com"));
assert!(!is_loopback_host("localhost.attacker.com"));
assert!(!is_loopback_host("localhost.evil.org:8080"));
assert!(!is_loopback_host("notlocalhost"));
assert!(!is_loopback_host("attacker.com"));
assert!(!is_loopback_host("attacker.com:127001"));
assert!(!is_loopback_host("192.168.1.1"));
assert!(!is_loopback_host("10.0.0.1"));
assert!(!is_loopback_host("0.0.0.0"));
assert!(!is_loopback_host(""));
assert!(!is_loopback_host(" "));
assert!(!is_loopback_host("foo:bar:baz"));
}
#[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()));
if container_path.exists() {
let _ = std::fs::remove_file(&container_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("WalCleanupPassword2026!", &salt, &kdf_params).unwrap();
let dek = generate_dek();
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();
// Erstelle Knoten, um Schreibaktivität im WAL zu erzeugen
let _ = db.create_node(1, "test_file.txt", false).unwrap();
db.checkpoint().unwrap();
drop(db);
// Bereinigungslogik (wie in mount.rs unmount) ausführen
let base_os = container_path.as_os_str().to_os_string();
let mut wal = base_os.clone();
wal.push("-wal");
let _ = std::fs::remove_file(&wal);
let mut shm = base_os;
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"
);
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, uri_contains_token};
let session_token = "4f8a12bc90de45f187a23456789abcde";
let port = 8443;
let remote_url = format!("http://127.0.0.1:{}/", port);
// 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 (Constant-Time)
use base64::Engine;
let auth_header = format!(
"Basic {}",
base64::engine::general_purpose::STANDARD.encode(format!("sanctum:{}", session_token))
);
assert!(check_basic_auth(&auth_header, session_token));
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));
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"
);
}
#[tokio::test]
async fn test_vfs_memory_lock_retention_on_clone_v03() {
use dav_server::davpath::DavPath;
use dav_server::fs::{DavFileSystem, ReadDirMeta};
use futures_util::StreamExt;
use sanctum::vfs::SanctumFs;
let temp_dir = std::env::temp_dir();
let container_path: PathBuf =
temp_dir.join(format!("test_v03_lock_{}.sanctum", std::process::id()));
if container_path.exists() {
let _ = std::fs::remove_file(&container_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("TestPassV03!", &salt, &kdf_params).unwrap();
let dek = generate_dek();
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();
// 1. Initialisiere SanctumFs
let fs = SanctumFs::new(db.clone(), dek, sanctum::crypto::FORMAT_VERSION);
assert_eq!(
fs.dek_strong_count(),
1,
"Anfangs muss genau 1 Referenz auf den verriegelten DEK existieren"
);
// 2. Klone SanctumFs (wie bei jeder HTTP/WebDAV-Anfrage im Server)
let fs_clone = fs.clone();
assert_eq!(
fs.dek_strong_count(),
2,
"Nach dem Klonen müssen 2 Referenzen existieren"
);
// 3. Droppe den Klon
drop(fs_clone);
// 4. V-03: Der Refcount muss nun wieder 1 sein. Der Speicherbereich darf NICHT
// vorzeitig über Drop eines Klons entriegelt worden sein!
assert_eq!(
fs.dek_strong_count(),
1,
"Nach Drop des Klons muss genau 1 Referenz erhalten bleiben"
);
// Verifiziere funktionale Nutzbarkeit nach Drop des Klons
let root_path = DavPath::new("/").unwrap();
let mut entries = fs.read_dir(&root_path, ReadDirMeta::None).await.unwrap();
assert!(entries.next().await.is_none());
drop(fs);
let _ = std::fs::remove_file(&container_path);
}
#[test]
fn test_z01_decoy_password_zeroize_memory() {
use sanctum::mount::ContainerAuth;
use zeroize::Zeroizing;
// Test that ContainerAuth properly encapsulates Zeroizing credentials
let raw_pass = "TopSecretDecoyPass2026!".to_string();
let zeroized_pass = Zeroizing::new(raw_pass.clone());
let auth = ContainerAuth::Password(zeroized_pass.clone());
if let ContainerAuth::Password(ref p) = auth {
assert_eq!(p.as_str(), raw_pass.as_str());
} else {
panic!("ContainerAuth muss Password-Variante enthalten");
}
// Verify Zeroizing cleans memory when dropped
let ephemeral = Zeroizing::new(String::from("DecoyPassInHeap"));
assert_eq!(&*ephemeral, "DecoyPassInHeap");
// Explicit drop calls zeroize
drop(ephemeral);
}
#[tokio::test]
async fn test_z04_webdav_quota_report() {
use dav_server::fs::DavFileSystem;
use sanctum::vfs::SanctumFs;
let temp_dir = std::env::temp_dir();
let container_path: PathBuf =
temp_dir.join(format!("test_z04_quota_{}.sanctum", std::process::id()));
if container_path.exists() {
let _ = std::fs::remove_file(&container_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("TestPassZ04!", &salt, &kdf_params).unwrap();
let dek = generate_dek();
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();
// Erstelle einen Testknoten mit Chunks
let node = db.create_node(1, "payload.bin", false).unwrap();
let dummy_chunk = vec![0xAAu8; 1024 * 1024]; // 1 MiB
db.write_chunk_and_update_size(
node.id,
0,
0,
&[0u8; 12],
&[0u8; 16],
&dummy_chunk,
1024 * 1024,
123456,
)
.unwrap();
let fs = SanctumFs::new(db.clone(), dek, sanctum::crypto::FORMAT_VERSION);
// Rufe WebDAV get_quota ab
let (used, total_opt) = fs.get_quota().await.unwrap();
assert!(
used > 0,
"Z-04: Verwendeter Speicherplatz muss > 0 sein (gemeldet: {} Bytes)",
used
);
assert!(
total_opt.is_some(),
"Z-04: Gesamtkapazität muss gemeldet werden"
);
let total = total_opt.unwrap();
assert!(
total >= used,
"Z-04: Gesamtkapazität ({}) muss mindestens dem belegten Speicher ({}) entsprechen",
total,
used
);
drop(fs);
drop(db);
let _ = std::fs::remove_file(&container_path);
}
#[tokio::test]
async fn test_mount_rejects_tampered_metadata_mac() {
let temp_dir = std::env::temp_dir();
let container_path: PathBuf = temp_dir.join(format!(
"test_mount_tampered_mac_{}.sanctum",
std::process::id()
));
if container_path.exists() {
let _ = std::fs::remove_file(&container_path);
}
let password = "TestTamperedMacPassword2026!";
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(&container_path).unwrap();
db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag)
.unwrap();
db.set_active_slot_and_dek(0, dek.clone());
// Erstelle einen Testknoten
let node = db.create_node(1, "original.txt", false).unwrap();
db.update_metadata_mac().unwrap();
// Vor Manipulation: verify_metadata_mac muss erfolgreich sein
assert!(db.verify_metadata_mac_for_slot(0, &dek).unwrap());
// Manipuliere SQLite-Metadaten direkt (Böswilliger Angreifer)
{
let conn = rusqlite::Connection::open(&container_path).unwrap();
conn.execute(
"UPDATE nodes SET name = 'hacked.exe' WHERE id = ?1",
[node.id],
)
.unwrap();
}
// Nach Manipulation: Direkte Verifikation meldet 'false' (Integritätsbruch)
assert!(!db.verify_metadata_mac_for_slot(0, &dek).unwrap());
drop(db);
// Aufruf von mount_container muss fail-closed abbrechen
let auth =
sanctum::mount::ContainerAuth::Password(zeroize::Zeroizing::new(password.to_string()));
let res = sanctum::mount::mount_container(
&container_path,
'Z',
None,
Some(18943),
auth,
false,
false,
None,
false,
false,
None,
true,
)
.await;
assert!(
res.is_err(),
"Mount mit manipulierten Metadaten muss fehlschlagen!"
);
let err_msg = res.unwrap_err().to_string();
assert!(
err_msg.contains("Metadaten-MAC-Verifikation fehlgeschlagen") || err_msg.contains("K-01"),
"Erwartete Fehlermeldung zu K-01 Metadaten-MAC, erhalten: {}",
err_msg
);
let _ = std::fs::remove_file(&container_path);
}
#[tokio::test]
async fn test_mount_succeeds_and_rebuilds_mac_after_header_file_restore() {
use sanctum::recovery::{export_header_backup, restore_header_backup};
use sanctum::storage::MetadataMacStatus;
let temp_dir = std::env::temp_dir();
let id = std::process::id();
let container_path: PathBuf = temp_dir.join(format!("test_hdr_restore_mount_{}.sanctum", id));
let backup_path: PathBuf = temp_dir.join(format!("test_hdr_restore_mount_{}.sanctum.hdr", id));
if container_path.exists() {
let _ = std::fs::remove_file(&container_path);
}
if backup_path.exists() {
let _ = std::fs::remove_file(&backup_path);
}
let password = "RestoreHeaderPassword2026!";
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(&container_path).unwrap();
db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag)
.unwrap();
db.set_active_slot_and_dek(0, dek.clone());
let _ = db.create_node(1, "important_doc.pdf", false).unwrap();
db.update_metadata_mac().unwrap();
db.checkpoint().unwrap();
drop(db);
// 1. Header-Backup exportieren
export_header_backup(&container_path, &backup_path).expect("Export header");
// 2. Header mutwillig zerstören
{
let conn = rusqlite::Connection::open(&container_path).unwrap();
conn.execute("DELETE FROM meta", []).unwrap();
}
// 3. Header aus Backup wiederherstellen
restore_header_backup(&container_path, &backup_path).expect("Restore header");
// 4. Status vor dem Mount prüfen: muss PendingRebuild sein
{
let check_db = Database::open(&container_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"
);
}
// 5. Echter Mount-Aufruf muss gelingen und den MAC transparent neu aufbauen
let auth =
sanctum::mount::ContainerAuth::Password(zeroize::Zeroizing::new(password.to_string()));
let c_path = container_path.clone();
let mount_task = tokio::spawn(async move {
sanctum::mount::mount_container(
&c_path,
'Y',
None,
Some(19482),
auth,
false,
false,
None,
false,
false,
None,
true,
)
.await
});
// Kurz warten, bis mount_container die Vorabprüfung & den MAC-Rebuild vollzogen hat
tokio::time::sleep(tokio::time::Duration::from_millis(250)).await;
mount_task.abort();
// 6. Nach dem Mount: MAC muss nun Valid und persistent gespeichert sein!
let verified_db = Database::open(&container_path).unwrap();
let new_status = verified_db
.verify_metadata_mac_status_for_slot(0, &dek)
.unwrap();
assert_eq!(
new_status,
MetadataMacStatus::Valid,
"Nach Mount muss der MAC erfolgreich neu aufgebaut und Valid sein"
);
assert!(verified_db.verify_metadata_mac_for_slot(0, &dek).unwrap());
let _ = std::fs::remove_file(&container_path);
let _ = std::fs::remove_file(&backup_path);
}
#[tokio::test]
async fn test_mount_succeeds_after_recovery_key_restore_slot0() {
use sanctum::crypto::dek_to_mnemonic;
use sanctum::recovery::restore_header_from_recovery_key;
use sanctum::storage::MetadataMacStatus;
let temp_dir = std::env::temp_dir();
let id = std::process::id();
let container_path: PathBuf = temp_dir.join(format!("test_rec_mount_slot0_{}.sanctum", id));
if container_path.exists() {
let _ = std::fs::remove_file(&container_path);
}
let old_password = "ForgottenOldPassword2026!";
let new_password = "RescuedNewPassword2026!";
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(old_password, &salt, &kdf_params).unwrap();
let dek = generate_dek();
let (wrapped_dek, nonce, tag) = wrap_dek(&kek, &dek).unwrap();
let phrase = dek_to_mnemonic(&dek).unwrap();
let db = Database::open(&container_path).unwrap();
db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag)
.unwrap();
db.set_active_slot_and_dek(0, dek.clone());
let _ = db.create_node(1, "my_secrets.txt", false).unwrap();
db.update_metadata_mac().unwrap();
db.checkpoint().unwrap();
drop(db);
// Header zerstören
{
let conn = rusqlite::Connection::open(&container_path).unwrap();
conn.execute("DELETE FROM meta", []).unwrap();
}
// Mit 24-Wort Notfallschlüssel und neuem Passwort wiederherstellen
restore_header_from_recovery_key(&container_path, &phrase, new_password)
.expect("Restore from recovery key");
// R-NEW-1: Unmittelbar nach restore_from_recovery_key MUSS der MAC Valid sein!
let restored_db = Database::open(&container_path).unwrap();
let status = restored_db
.verify_metadata_mac_status_for_slot(0, &dek)
.unwrap();
assert_eq!(
status,
MetadataMacStatus::Valid,
"Nach restore_from_recovery_key muss MAC sofort Valid sein"
);
assert!(
restored_db.verify_metadata_mac_for_slot(0, &dek).unwrap(),
"verify_metadata_mac_for_slot muss direkt Ok(true) liefern (R-NEW-1 Fix)"
);
drop(restored_db);
// Mount mit neuem Passwort ausführen
let auth =
sanctum::mount::ContainerAuth::Password(zeroize::Zeroizing::new(new_password.to_string()));
let c_path = container_path.clone();
let mount_task = tokio::spawn(async move {
sanctum::mount::mount_container(
&c_path,
'Y',
None,
Some(19483),
auth,
false,
false,
None,
false,
false,
None,
true,
)
.await
});
tokio::time::sleep(tokio::time::Duration::from_millis(250)).await;
mount_task.abort();
let _ = std::fs::remove_file(&container_path);
}
#[tokio::test]
async fn test_mount_succeeds_after_recovery_key_restore_slot1() {
use rand::RngCore;
use sanctum::crypto::{dek_to_mnemonic, wrap_slot0_payload, wrap_slot1_payload};
use sanctum::recovery::restore_slot_from_recovery_key;
use sanctum::storage::MetadataMacStatus;
let temp_dir = std::env::temp_dir();
let id: u64 = rand::rngs::OsRng.next_u64();
let container_path: PathBuf = temp_dir.join(format!("test_rec_mount_slot1_{}.sanctum", id));
if container_path.exists() {
let _ = std::fs::remove_file(&container_path);
}
let pass_decoy = "DecoyPassword2026!";
let pass_hidden_old = "HiddenPasswordOld2026!";
let pass_hidden_new = "HiddenPasswordNew2026!";
let kdf_params = KdfParams {
memory_cost: MIN_MEMORY_COST_KIB,
time_cost: MIN_TIME_COST,
parallelism: 1,
};
let salt_0 = generate_salt();
let kek_0 = derive_kek(pass_decoy, &salt_0, &kdf_params).unwrap();
let dek_0 = generate_dek();
let salt_1 = generate_salt();
let kek_1 = derive_kek(pass_hidden_old, &salt_1, &kdf_params).unwrap();
let dek_1 = generate_dek();
let phrase_1 = dek_to_mnemonic(&dek_1).unwrap();
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 db = Database::open(&container_path).unwrap();
db.init_schema_with_carrier(
&salt_0,
&kdf_params,
&wrapped_0,
&nonce_0,
&tag_0,
Some((
"carrier.dat",
10 * 1024 * 1024,
&salt_1,
&kdf_params,
&wrapped_1,
&nonce_1,
&tag_1,
&dek_0,
&dek_1,
)),
)
.unwrap();
db.set_active_slot_and_dek(1, dek_1.clone());
db.update_metadata_mac().unwrap();
db.checkpoint().unwrap();
drop(db);
// Slot 1 Header zerstören
{
let conn = rusqlite::Connection::open(&container_path).unwrap();
conn.execute("DELETE FROM meta WHERE slot_id = 1", [])
.unwrap();
}
// Slot 1 mit 24-Wort Schlüssel und neuem Passwort wiederherstellen (dek_0 vorhanden)
restore_slot_from_recovery_key(&container_path, &phrase_1, pass_hidden_new, 1, Some(&dek_0))
.expect("Restore slot 1 from recovery key");
// R-NEW-1: Unmittelbar nach restore_slot_from_recovery_key MUSS Slot 1 MAC Valid sein!
let restored_db = Database::open(&container_path).unwrap();
let status_1 = restored_db
.verify_metadata_mac_status_for_slot(1, &dek_1)
.unwrap();
assert_eq!(
status_1,
MetadataMacStatus::Valid,
"Nach restore_slot_from_recovery_key muss Slot 1 MAC Valid sein"
);
assert!(
restored_db.verify_metadata_mac_for_slot(1, &dek_1).unwrap(),
"verify_metadata_mac_for_slot(1) muss direkt Ok(true) liefern (R-NEW-1 Fix)"
);
drop(restored_db);
let _ = std::fs::remove_file(&container_path);
}