release: v0.7.1 — Security-Patch (R-01 bis R-06)
Sanctum Release / Build & Release (Windows x86_64) (push) Waiting to run

- R-01: Bereinigung verbliebener Restbehauptungen in Doku und Code (LEGAL.md, README.md, QUICKSTART.md, main.rs, crypto/storage/recovery/verify.rs)
- R-02: Lückenloser Carrier-Schutz in SanctumFs::copy (Quelle & Ziel) und sync (Pull-Skip & Push-Schutz)
- R-03: Shared Dateinamen-Validierung (validate_node_name) in pathutil.rs, durchgesetzt in storage.rs und vfs.rs
- R-04: Fail-closed Release-Packaging & obligatorische Minisign-Signatur in CI (.gitea/workflows/release.yaml) und Scripts
- R-05: Session-Token Beseitigung im argv: In-Process Win32 WNetAddConnection2W/WNetCancelConnection2W, kein gio argv-Token, Multi-Auth HTTP Middleware (Basic Auth, X-Sanctum-Token, Path-Fallback) mit 401 WWW-Authenticate
- R-06: Sofortiges Löschen von SANCTUM_RECOVERY_KEY aus der Prozessumgebung
This commit is contained in:
2026-09-18 19:49:38 +02:00
parent 436790abf0
commit 1cdb30147b
24 changed files with 663 additions and 174 deletions
+1 -2
View File
@@ -241,8 +241,7 @@ pub fn wrap_slot1_payload(
}
/// Erzeugt einen Dummy-Header-Slot mit kryptografisch sicherem Zufallsrauschen derselben Länge wie
/// ein echter Modell-A Slot 1 (72 Bytes wrapped Payload). Dadurch sind Standard-Container von
/// Containern mit Hidden Vault auf Bitebene und Entropieebene ununterscheidbar (Plausible Deniability).
/// ein echter Modell-A Slot 1 (72 Bytes wrapped Payload) für Dummy-Slot-Längenparität im Header.
pub fn generate_dummy_slot() -> (Vec<u8>, [u8; 12], [u8; 16], [u8; 16]) {
let mut wrapped_dek = vec![0u8; 72];
let mut nonce = [0u8; 12];
+1
View File
@@ -1,6 +1,7 @@
pub mod carrier;
pub mod crypto;
pub mod mount;
pub mod pathutil;
pub mod platform;
pub mod recovery;
pub mod storage;
+4 -4
View File
@@ -45,10 +45,8 @@ enum Commands {
#[arg(long, default_value_t = false)]
with_hidden: bool,
/// Dateiname der Alibi-Trägerdatei im Decoy-Vault (Standard: system_backup.dat)
/// Tipp: Für maximale Plausible Deniability (CWE-209) empfehlen sich Dateitypen mit
/// natürlicherweise maximaler Entropie (.dat, .bin, .enc, .bak), um Anomalie-Detektion
/// in forensischen Entropie-Scannern zu verhindern.
/// Dateiname der Alibi-Trägerdatei im Standard-Tresor (Standard: system_backup.dat).
/// Hinweis: Die Datei dient als Träger für den zweiten Tresor und ist forensisch erkennbar.
#[arg(long, default_value = "system_backup.dat")]
carrier_name: String,
@@ -340,6 +338,8 @@ fn resolve_recovery_key(arg: Option<&str>) -> Result<Option<Zeroizing<String>>>
Ok(Some(Zeroizing::new(key.to_string())))
}
} else if let Ok(env_key) = std::env::var("SANCTUM_RECOVERY_KEY") {
// R-06: Umgebungsvariable nach dem Einlesen sofort aus dem Speicher des Prozesses entfernen
std::env::remove_var("SANCTUM_RECOVERY_KEY");
let trimmed = env_key.trim();
if !trimmed.is_empty() {
println!(" [{}] Verwende Notfallschlüssel aus Umgebungsvariable 'SANCTUM_RECOVERY_KEY'.", ui::cyan(""));
+180 -97
View File
@@ -1,6 +1,7 @@
use std::convert::Infallible;
use std::net::SocketAddr;
use std::path::Path;
#[cfg(unix)]
use std::process::Command;
use std::sync::atomic::Ordering;
use std::time::{SystemTime, UNIX_EPOCH};
@@ -32,29 +33,11 @@ pub fn format_drive(drive_letter: char) -> String {
format!("{}:", drive_letter.to_ascii_uppercase())
}
/// Trennt ein Windows-Netzlaufwerk via `net use <DRIVE>: /delete /y` bzw. Unix-Mountpoint via `gio mount -u`.
/// Trennt ein Windows-Netzlaufwerk via WNetCancelConnection2W bzw. Unix-Mountpoint via gio.
pub fn unmount_drive(drive_letter: char) -> Result<()> {
#[cfg(windows)]
{
let drive_str = format_drive(drive_letter);
let output = Command::new("net")
.args(["use", &drive_str, "/delete", "/y"])
.output()
.context("Fehler beim Ausführen des Befehls 'net use'")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
bail!(
"Netzlaufwerk {} konnte nicht getrennt werden:\n{}{}",
drive_str,
stdout,
stderr
);
}
Ok(())
crate::windows::unmount_drive_wnet(drive_letter)
}
#[cfg(unix)]
{
@@ -68,74 +51,25 @@ pub fn unmount_drive(drive_letter: char) -> Result<()> {
}
}
/// Bindet ein Windows-Netzlaufwerk via `net use <DRIVE>: http://127.0.0.1:<PORT>/<TOKEN>/ /persistent:no` ein
/// bzw. unter Unix via `gio mount dav://127.0.0.1:<PORT>/<TOKEN>/`.
fn run_mount_command(drive_str: &str, port: u16, session_token: &str) -> Result<()> {
/// Bindet ein Windows-Netzlaufwerk via in-process WNetAddConnection2W ein (R-05, kein Token in argv)
/// bzw. unter Unix via `gio mount dav://127.0.0.1:<PORT>/`.
fn run_mount_command(drive_letter: char, port: u16, session_token: &str) -> Result<()> {
#[cfg(windows)]
{
let url = format!("http://127.0.0.1:{}/{}/", port, session_token);
let mut output = Command::new("net")
.args(["use", drive_str, &url, "/persistent:no"])
.output()
.context("Fehler beim Ausführen des Befehls 'net use'")?;
// PRR-01: Selbstreparatur bei verwaister Zuordnung nach unsauberem Vorläufer (Systemfehler 85)
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
let is_already_in_use = stderr.contains("85")
|| stderr.contains("bereits verwendet")
|| stderr.contains("already in use")
|| stdout.contains("85")
|| stdout.contains("bereits verwendet")
|| stdout.contains("already in use");
if is_already_in_use {
debug!("Verwaiste Zuordnung für {} entdeckt — führe automatische Bereinigung durch...", drive_str);
let _ = Command::new("net")
.args(["use", drive_str, "/delete", "/y"])
.output();
// Zweiter Versuch nach automatischer Bereinigung
output = Command::new("net")
.args(["use", drive_str, &url, "/persistent:no"])
.output()
.context("Fehler beim erneuten Ausführen des Befehls 'net use'")?;
}
}
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
let webclient_hint = if stderr.contains("67") || stderr.contains("Netzwerkname") || stderr.contains("Systemfehler") {
"\n\nHinweis: Das Einbinden von Netzlaufwerken erfordert den Windows-Dienst 'WebClient'. Prüfen Sie in einer Administrator-Konsole: 'net start WebClient'."
} else {
""
};
bail!(
"Laufwerk {} konnte nicht eingebunden werden:\n{}{}{}",
drive_str,
stdout,
stderr,
webclient_hint
);
}
Ok(())
crate::windows::mount_drive_wnet(drive_letter, port, session_token)
}
#[cfg(unix)]
{
let dav_url = format!("dav://127.0.0.1:{}/{}/", port, session_token);
let dav_url = format!("dav://127.0.0.1:{}/", port);
let _ = Command::new("gio")
.args(["mount", &dav_url])
.output();
let _ = drive_str;
let _ = (drive_letter, session_token);
Ok(())
}
#[cfg(not(any(windows, unix)))]
{
let _ = (drive_str, port, session_token);
let _ = (drive_letter, port, session_token);
Ok(())
}
}
@@ -155,6 +89,7 @@ pub async fn mount_container(
stealth: bool,
) -> Result<()> {
let drive_str = format_drive(drive_letter);
let _ = &drive_str;
let _ = mount_point;
let _ = open_explorer;
let _ = enable_tray;
@@ -237,7 +172,6 @@ pub async fn mount_container(
let mut token_bytes = [0u8; 16];
rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut token_bytes);
let session_token = hex::encode(token_bytes);
let token_path_prefix = format!("/{}", session_token);
// WebDAV Filesystem und Handler konfigurieren (mit Anti-Leak Shield & Carrier-Routing)
let fs = SanctumFs::with_carrier(
@@ -251,7 +185,6 @@ pub async fn mount_container(
);
let last_activity = fs.last_activity();
let dav_server = DavHandler::builder()
.strip_prefix(token_path_prefix.clone())
.filesystem(Box::new(fs))
.locksystem(FakeLs::new())
.build_handler();
@@ -302,12 +235,12 @@ pub async fn mount_container(
let server_handle = tokio::spawn(serve_webdav_loop(
listener,
dav_server,
token_path_prefix.clone(),
session_token.clone(),
shutdown_rx,
));
// Netzlaufwerk bzw. Verzeichnis einbinden
if let Err(e) = run_mount_command(&drive_str, bound_port, &session_token) {
if let Err(e) = run_mount_command(drive_letter, bound_port, &session_token) {
let _ = shutdown_tx.send(true);
let _ = server_handle.await;
return Err(e);
@@ -416,12 +349,12 @@ pub async fn mount_container(
println!(" • Modus: WebDAV Userland-VFS");
}
}
println!(" • WebDAV-URL: http://127.0.0.1:{}/<session-token>/ (lokal geschützt)", bound_port);
println!(" • WebDAV-URL: http://127.0.0.1:{}/ (lokal geschützt)", bound_port);
#[cfg(not(windows))]
{
println!(" • gio Befehl: gio mount dav://127.0.0.1:{}/<session-token>/", bound_port);
println!(" • gio Befehl: gio mount dav://127.0.0.1:{}/", bound_port);
if let Some(mp) = mount_point {
println!(" • davfs2: mount -t davfs http://127.0.0.1:{}/<session-token>/ {}", bound_port, mp.display());
println!(" • davfs2: mount -t davfs http://127.0.0.1:{}/ {}", bound_port, mp.display());
}
}
if let Some(secs) = idle_timeout {
@@ -681,14 +614,88 @@ const MAX_CONCURRENT_DAV_CONNECTIONS: usize = 64;
/// Timeout für das Lesen von HTTP-Headern (Schutz gegen Slowloris-Angriffe auf Loopback).
const HTTP_HEADER_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15);
/// Führt die asynchrone WebDAV HTTP-Server-Schleife mit Session-Token & Host-Header Sicherheits-Middleware aus.
/// Hilfsfunktion zur Validierung von HTTP Basic Auth.
/// Unterstützt Format `username:password` (wobei Passwort dem Session-Token entspricht).
pub fn check_basic_auth(auth_header: &str, expected_token: &str) -> bool {
let auth_str = auth_header.trim();
let encoded = if let Some(rest) = auth_str.strip_prefix("Basic ") {
rest
} else if let Some(rest) = auth_str.strip_prefix("basic ") {
rest
} else {
return false;
};
use base64::Engine;
let decoded_bytes = match base64::engine::general_purpose::STANDARD.decode(encoded.trim()) {
Ok(bytes) => bytes,
Err(_) => return false,
};
let decoded_str = match std::str::from_utf8(&decoded_bytes) {
Ok(s) => s,
Err(_) => return false,
};
if let Some((_user, pass)) = decoded_str.split_once(':') {
pass == expected_token
} else {
false
}
}
/// Hilfsfunktion zur Validierung des X-Sanctum-Token Headers.
pub fn check_token_header(headers: &hyper::HeaderMap, expected_token: &str) -> bool {
if let Some(val) = headers.get("X-Sanctum-Token") {
if let Ok(val_str) = val.to_str() {
return val_str.trim() == expected_token;
}
}
false
}
/// Schneidet ein Pfad-Präfix /<session_token> aus der Request-URI heraus (Defense-in-Depth Fallback).
pub fn strip_path_prefix(uri: &hyper::Uri, prefix: &str) -> Option<hyper::Uri> {
let path = uri.path();
if !path.starts_with(prefix) {
return None;
}
let rest = &path[prefix.len()..];
let new_path = if rest.is_empty() || !rest.starts_with('/') {
format!("/{}", rest)
} else {
rest.to_string()
};
let path_and_query = match uri.query() {
Some(q) => format!("{}?{}", new_path, q),
None => new_path,
};
let mut parts = uri.clone().into_parts();
parts.path_and_query = Some(path_and_query.parse().ok()?);
hyper::Uri::from_parts(parts).ok()
}
/// Erzeugt eine standardkonforme HTTP 401 Unauthorized Antwort mit WWW-Authenticate Header.
fn unauthorized_response() -> hyper::Response<dav_server::body::Body> {
hyper::Response::builder()
.status(hyper::StatusCode::UNAUTHORIZED)
.header(hyper::header::WWW_AUTHENTICATE, "Basic realm=\"Sanctum\"")
.header(hyper::header::CONTENT_LENGTH, "0")
.body(dav_server::body::Body::empty())
.unwrap()
}
/// Führt die asynchrone WebDAV HTTP-Server-Schleife mit Multi-Auth & Host-Header Sicherheits-Middleware aus.
pub async fn serve_webdav_loop(
listener: TcpListener,
dav_server: DavHandler,
token_path_prefix: String,
session_token: String,
mut shutdown_rx: watch::Receiver<bool>,
) {
let conn_semaphore = std::sync::Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_DAV_CONNECTIONS));
let token_prefix = format!("/{}", session_token);
loop {
tokio::select! {
@@ -716,13 +723,15 @@ pub async fn serve_webdav_loop(
let io = TokioIo::new(stream);
let handler = dav_server.clone();
let expected_prefix = token_path_prefix.clone();
let expected_token = session_token.clone();
let expected_prefix = token_prefix.clone();
tokio::spawn(async move {
let _permit = permit; // Permit wird bei Verbindungsende automatisch freigegeben
let service = service_fn(move |req| {
let service = service_fn(move |mut req| {
let h = handler.clone();
let token = expected_token.clone();
let prefix = expected_prefix.clone();
async move {
// 1. RT-02: Strikte Fail-Closed Host-Header Validierung (Anti-DNS-Rebinding & Anti-Spoofing)
@@ -746,15 +755,36 @@ pub async fn serve_webdav_loop(
return Ok::<_, Infallible>(res);
}
// 2. Session-Token Pfadprüfung (Loopback-Schutz gegen unbefugte lokale Prozesse & Browser CSRF)
let path = req.uri().path();
if !path.starts_with(&prefix) {
debug!("Abgewiesener Zugriff ohne gültiges Session-Token: {}", path);
let res = hyper::Response::builder()
.status(hyper::StatusCode::FORBIDDEN)
.body(dav_server::body::Body::empty())
.unwrap();
return Ok::<_, Infallible>(res);
// 2. R-05: Multi-Auth Middleware
// a) HTTP Basic Auth (Authorization: Basic ...)
// b) Header X-Sanctum-Token
// c) Pfad-Präfix Fallback (/<session_token>/...)
let mut authenticated = false;
if let Some(auth_val) = req.headers().get(hyper::header::AUTHORIZATION) {
if let Ok(auth_str) = auth_val.to_str() {
if check_basic_auth(auth_str, &token) {
authenticated = true;
}
}
}
if !authenticated && check_token_header(req.headers(), &token) {
authenticated = true;
}
if !authenticated {
if let Some(rewritten_uri) = strip_path_prefix(req.uri(), &prefix) {
*req.uri_mut() = rewritten_uri;
authenticated = true;
}
} else if let Some(rewritten_uri) = strip_path_prefix(req.uri(), &prefix) {
*req.uri_mut() = rewritten_uri;
}
if !authenticated {
debug!("Abgewiesener unauthentifizierter Zugriff auf: {}", req.uri().path());
return Ok::<_, Infallible>(unauthorized_response());
}
Ok::<_, Infallible>(h.handle(req).await)
@@ -767,7 +797,6 @@ pub async fn serve_webdav_loop(
builder.header_read_timeout(HTTP_HEADER_READ_TIMEOUT);
if let Err(err) = builder.serve_connection(io, service).await {
// Client-Disconnects im Explorer oder Timeout-Drops sind normal
debug!("HTTP-Verbindungsende: {:?}", err);
}
});
@@ -806,6 +835,60 @@ mod tests {
assert_eq!(format_drive('s'), "S:");
assert_eq!(format_drive('Z'), "Z:");
}
#[test]
fn test_check_basic_auth() {
use base64::Engine;
let token = "deadbeefcafebabe0123456789abcdef";
let creds = format!("sanctum:{}", token);
let header_val = format!("Basic {}", base64::engine::general_purpose::STANDARD.encode(creds));
assert!(check_basic_auth(&header_val, token));
// Kleingeschriebenes basic Präfix
let lower_header = format!("basic {}", base64::engine::general_purpose::STANDARD.encode(format!("user:{}", token)));
assert!(check_basic_auth(&lower_header, token));
// Falsches Token
let wrong_token_header = format!("Basic {}", base64::engine::general_purpose::STANDARD.encode("sanctum:wrongtoken"));
assert!(!check_basic_auth(&wrong_token_header, token));
// Ungültiges Base64 oder Format
assert!(!check_basic_auth("Basic !!!notbase64!!!", token));
assert!(!check_basic_auth("Bearer 12345", token));
assert!(!check_basic_auth("", token));
}
#[test]
fn test_check_token_header() {
let token = "deadbeefcafebabe0123456789abcdef";
let mut headers = hyper::HeaderMap::new();
assert!(!check_token_header(&headers, token));
headers.insert("X-Sanctum-Token", token.parse().unwrap());
assert!(check_token_header(&headers, token));
let mut wrong_headers = hyper::HeaderMap::new();
wrong_headers.insert("X-Sanctum-Token", "wrong".parse().unwrap());
assert!(!check_token_header(&wrong_headers, token));
}
#[test]
fn test_strip_path_prefix() {
let token = "deadbeefcafebabe0123456789abcdef";
let prefix = format!("/{}", token);
let uri: hyper::Uri = format!("http://127.0.0.1:8443/{}/Photos/vacation.jpg?sort=date", token).parse().unwrap();
let stripped = strip_path_prefix(&uri, &prefix).expect("Should strip prefix");
assert_eq!(stripped.path(), "/Photos/vacation.jpg");
assert_eq!(stripped.query(), Some("sort=date"));
let uri_root: hyper::Uri = format!("http://127.0.0.1:8443/{}", token).parse().unwrap();
let stripped_root = strip_path_prefix(&uri_root, &prefix).expect("Should strip root");
assert_eq!(stripped_root.path(), "/");
let uri_no_prefix: hyper::Uri = "http://127.0.0.1:8443/other/path".parse().unwrap();
assert!(strip_path_prefix(&uri_no_prefix, &prefix).is_none());
}
}
+65
View File
@@ -0,0 +1,65 @@
use anyhow::{bail, Result};
/// Validiert einen Datei- oder Verzeichnisnamen gegen Path-Traversal, Null-Bytes,
/// unzulässige Steuerzeichen und Windows-reservierte Gerätenamen (S-08, R-03).
pub fn validate_node_name(name: &str) -> Result<()> {
if name.is_empty() {
bail!("Dateiname darf nicht leer sein.");
}
if name == "." || name == ".." {
bail!("Ungültiger Dateiname (Verzeichnisreferenz verboten): '{}'", name);
}
if name.contains('/') || name.contains('\\') || name.contains('\0') {
bail!(
"Dateiname enthält unzulässige Trennzeichen oder Null-Bytes: '{}'",
name
);
}
for c in name.chars() {
if (c as u32) < 0x20 {
bail!("Dateiname enthält Steuerzeichen: '{}'", name);
}
}
// Windows reservierte Gerätenamen (CON, PRN, AUX, NUL, COM1..9, LPT1..9)
let base_name = if let Some(dot_idx) = name.find('.') {
&name[..dot_idx]
} else {
name
};
let base_upper = base_name.to_ascii_uppercase();
let reserved = [
"CON", "PRN", "AUX", "NUL",
"COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9",
"LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
];
if reserved.contains(&base_upper.as_str()) {
bail!(
"Dateiname '{}' kollidiert mit einem reservierten Windows-Gerätenamen.",
name
);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_validate_node_name() {
assert!(validate_node_name("test.txt").is_ok());
assert!(validate_node_name("").is_err());
assert!(validate_node_name(".").is_err());
assert!(validate_node_name("..").is_err());
assert!(validate_node_name("foo/bar").is_err());
assert!(validate_node_name("foo\\bar").is_err());
assert!(validate_node_name("CON").is_err());
assert!(validate_node_name("aux.txt").is_err());
assert!(validate_node_name("nul").is_err());
assert!(validate_node_name("com1.log").is_err());
assert!(validate_node_name("line\nbreak").is_err());
assert!(validate_node_name("null\0byte").is_err());
}
}
+1 -1
View File
@@ -314,7 +314,7 @@ pub fn restore_slot_from_recovery_key(
existing_slots.push(new_slot);
existing_slots.sort_by_key(|s| s.slot_id);
// Falls Slot 1 fehlt, Dummy-Slot 1 ergänzen für Plausible Deniability
// Falls Slot 1 fehlt, Dummy-Slot 1 ergänzen für Dummy-Slot-Längenparität
if !existing_slots.iter().any(|s| s.slot_id == 1) {
let (dummy_dek, dummy_nonce, dummy_tag, dummy_salt) = generate_dummy_slot();
let dummy_slot = SlotMeta {
+7 -5
View File
@@ -639,7 +639,7 @@ impl Database {
Ok(carrier_node_id)
}
/// Initialisiert das Datenbankschema mit Unterstützung für Plausible Deniability (optionaler Hidden Vault).
/// Initialisiert das Datenbankschema mit Unterstützung für Dual-Vault (optionaler Hidden Vault).
/// Sowohl Standard-Container als auch Container mit Hidden Vault besitzen eine bit- und schemagleiche Struktur:
/// - 2 Slots in der meta-Tabelle (Slot 0 + Slot 1 mit echtem KEK oder ununterscheidbarem CSPRNG-Rauschen)
/// - 2 Root-Knoten (id=1 für Vault 0, id=2 für Vault 1)
@@ -720,7 +720,7 @@ impl Database {
params![now, now],
)?;
// Slot 1: Entweder echter Hidden Vault ODER ununterscheidbares kryptografisches Rauschen (Plausible Deniability)
// Slot 1: Entweder echter Hidden Vault ODER CSPRNG-Rauschen (Dummy-Slot-Längenparität)
if let Some((h_salt, h_params, h_wrapped, h_nonce, h_tag)) = hidden {
let params_json_1 = serde_json::to_string(h_params)?;
conn.execute(
@@ -758,7 +758,7 @@ impl Database {
Ok(())
}
/// Initialisiert das Datenbankschema für einen Standard-Container (mit Dummy-Slot für Plausible Deniability).
/// Initialisiert das Datenbankschema für einen Standard-Container (mit Dummy-Slot-Längenparität).
pub fn init_schema(
&self,
salt: &[u8; 16],
@@ -1191,6 +1191,7 @@ impl Database {
is_dir: bool,
dek: &[u8; 32],
) -> Result<NodeRecord> {
crate::pathutil::validate_node_name(name)?;
let now = current_timestamp();
let conn = self.conn.lock().unwrap();
@@ -1299,6 +1300,7 @@ impl Database {
dek: &[u8; 32],
) -> Result<()> {
self.assert_not_carrier(id)?;
crate::pathutil::validate_node_name(new_name)?;
let now = current_timestamp();
let conn = self.conn.lock().unwrap();
let stored_name = if vault_id == 1 {
@@ -1590,7 +1592,7 @@ impl Database {
)?;
}
// Falls Slot 1 nicht existiert (z. B. altes Single-Slot Backup), erzeuge Dummy-Slot für Plausible Deniability
// Falls Slot 1 nicht existiert (z. B. altes Single-Slot Backup), erzeuge Dummy-Slot für Längenparität
if !has_slot1 {
let (dummy_dek, dummy_nonce, dummy_tag, dummy_salt) = generate_dummy_slot();
let dummy_params_json = serde_json::to_string(&KdfParams::default())?;
@@ -1910,7 +1912,7 @@ mod tests {
params![hidden_file.id],
|r| r.get(0),
).unwrap();
// Plausible Deniability: Kein $h$-Präfix, kein Klartext
// Dual-Vault Isolation: Kein $h$-Präfix, kein Klartext
assert!(!raw_name_v1.starts_with("$h$"), "Hidden Vault Dateiname darf kein $h$-Präfix mehr besitzen");
assert!(!raw_name_v1.contains("classified_leak"), "Plaintext darf keinesfalls in SQLite DB auftauchen");
+9 -41
View File
@@ -13,47 +13,7 @@ use crate::storage::{Database, NodeRecord};
use crate::ui;
use crate::vfs::is_leak_file;
/// Validiert Knotennamen gegen Path-Traversal (CWE-22) und reservierte Windows-Gerätenamen (S-08).
pub fn validate_node_name(name: &str) -> Result<()> {
if name.is_empty() {
bail!("Dateiname darf nicht leer sein.");
}
if name == "." || name == ".." {
bail!("Ungültiger Dateiname (Verzeichnisreferenz verboten): '{}'", name);
}
if name.contains('/') || name.contains('\\') || name.contains('\0') {
bail!(
"Dateiname enthält unzulässige Trennzeichen oder Null-Bytes: '{}'",
name
);
}
for c in name.chars() {
if (c as u32) < 0x20 {
bail!("Dateiname enthält Steuerzeichen: '{}'", name);
}
}
// Windows reservierte Gerätenamen (CON, PRN, AUX, NUL, COM1..9, LPT1..9)
let base_name = if let Some(dot_idx) = name.find('.') {
&name[..dot_idx]
} else {
name
};
let base_upper = base_name.to_ascii_uppercase();
let reserved = [
"CON", "PRN", "AUX", "NUL",
"COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9",
"LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
];
if reserved.contains(&base_upper.as_str()) {
bail!(
"Dateiname '{}' kollidiert mit einem reservierten Windows-Gerätenamen.",
name
);
}
Ok(())
}
pub use crate::pathutil::validate_node_name;
/// Berechnet den SHA-256 Hash einer lokalen Datei für verlässliche Checksummen-Vergleiche (S-10).
fn calc_local_file_sha256(path: &Path) -> Result<String> {
@@ -288,6 +248,7 @@ pub fn sync_single_file_to_vault(
let existing_node = children.into_iter().find(|c| c.name == file_name);
if let Some(ref node) = existing_node {
db.assert_not_carrier(node.id)?;
if node.is_dir {
bail!("Pfad-Konflikt: '{}' existiert im Tresor als Ordner", file_name);
}
@@ -370,6 +331,7 @@ pub fn sync_single_file_to_host(
checksum: bool,
dry_run: bool,
) -> Result<FileTransferResult> {
db.assert_not_carrier(node.id)?;
validate_node_name(&node.name)?;
if local_path.exists() {
@@ -828,9 +790,15 @@ fn collect_and_pull_dir(
stats: &mut SyncStats,
vault_relative_paths: &mut HashSet<String>,
) -> Result<()> {
let carrier_id = db.find_carrier_node_id()?.unwrap_or(0);
let children = db.list_children_in_vault(vault_node_id, vault_id, dek)?;
for child in children {
// R-02 Carrier Guard: Trägerdatei niemals auf den Host spiegeln / herausziehen
if carrier_id > 0 && (child.id == carrier_id || db.is_descendant_of(carrier_id, child.id).unwrap_or(false)) {
continue;
}
validate_node_name(&child.name)?;
let child_rel = if rel_prefix.is_empty() {
+1 -1
View File
@@ -341,7 +341,7 @@ mod tests {
let report = verify_container(&container_path, Some(&dek), true).expect("Verify container");
assert!(report.is_healthy(), "Container must be healthy, report: {:?}", report);
assert_eq!(report.total_files, 1);
assert_eq!(report.total_dirs, 3); // Root 1 + Root 2 (Plausible Deniability) + photos
assert_eq!(report.total_dirs, 3); // Root 1 + Root 2 (Dual-Vault) + photos
assert_eq!(report.total_chunks, 2);
assert_eq!(report.corrupted_chunks, 0);
assert_eq!(report.orphan_nodes, 0);
+7
View File
@@ -562,6 +562,7 @@ impl SanctumFs {
fn create_node(&self, parent_id: i64, name: &str, is_dir: bool) -> Result<NodeRecord, FsError> {
validate_path_safety(name)?;
crate::pathutil::validate_node_name(name).map_err(|_| FsError::Forbidden)?;
self.db
.create_node_in_vault(self.vault_id, parent_id, name, is_dir, &self.dek)
.map_err(|e| {
@@ -572,6 +573,7 @@ impl SanctumFs {
fn rename_node(&self, id: i64, new_parent_id: i64, new_name: &str) -> Result<(), FsError> {
validate_path_safety(new_name)?;
crate::pathutil::validate_node_name(new_name).map_err(|_| FsError::Forbidden)?;
self.db
.rename_node_in_vault(id, new_parent_id, new_name, self.vault_id, &self.dek)
.map_err(|_| FsError::GeneralFailure)
@@ -941,6 +943,11 @@ impl DavFileSystem for SanctumFs {
return Err(FsError::NotImplemented);
}
// R-02: Schutz der Trägerdatei: Kopieren der Trägerdatei (Quelle) ist strikt verboten!
if self.carrier_node_id == Some(node.id) {
return Err(FsError::Forbidden);
}
let (to_parent_path, to_name) = self.split_parent_and_name(&to_str);
if self.anti_leak && is_leak_file(to_name) {
+136 -4
View File
@@ -2,6 +2,8 @@ use std::process::Command;
#[allow(unused_imports)]
use anyhow::{bail, Context, Result};
#[cfg(windows)]
use tracing::debug;
/// Ermittelt den nächsten verfügbaren Windows-Laufwerksbuchstaben (von 'Z' rückwärts bis 'D').
pub fn find_next_available_drive() -> Result<char> {
@@ -524,6 +526,37 @@ static CONSOLE_CTRL_TX: std::sync::Mutex<Option<tokio::sync::mpsc::Sender<()>>>
#[cfg(windows)]
static CONSOLE_CTRL_DRIVE: std::sync::Mutex<Option<char>> = std::sync::Mutex::new(None);
#[cfg(windows)]
#[repr(C)]
#[allow(non_snake_case)]
struct NETRESOURCEW {
dwScope: u32,
dwType: u32,
dwDisplayType: u32,
dwUsage: u32,
lpLocalName: *mut u16,
lpRemoteName: *mut u16,
lpComment: *mut u16,
lpProvider: *mut u16,
}
#[cfg(windows)]
#[link(name = "mpr")]
extern "system" {
fn WNetAddConnection2W(
lpNetResource: *const NETRESOURCEW,
lpPassword: *const u16,
lpUserName: *const u16,
dwFlags: u32,
) -> u32;
fn WNetCancelConnection2W(
lpName: *const u16,
dwFlags: u32,
fForce: i32,
) -> u32;
}
#[cfg(windows)]
unsafe extern "system" fn console_ctrl_routine(ctrl_type: u32) -> i32 {
const CTRL_C_EVENT: u32 = 0;
@@ -534,13 +567,17 @@ unsafe extern "system" fn console_ctrl_routine(ctrl_type: u32) -> i32 {
match ctrl_type {
CTRL_CLOSE_EVENT | CTRL_LOGOFF_EVENT | CTRL_SHUTDOWN_EVENT => {
// 1. Sofortiges Notfall-Unmount direkt aus dem Win32-Callback ausführen
// 1. Sofortiges Notfall-Unmount direkt aus dem Win32-Callback ausführen (in-process)
if let Ok(guard) = CONSOLE_CTRL_DRIVE.lock() {
if let Some(dl) = *guard {
let drive_str = format!("{}:", dl.to_ascii_uppercase());
let _ = std::process::Command::new("net")
.args(["use", &drive_str, "/delete", "/y"])
.output();
let wide_drive: Vec<u16> = drive_str
.encode_utf16()
.chain(std::iter::once(0))
.collect();
unsafe {
WNetCancelConnection2W(wide_drive.as_ptr(), 0, 1);
}
}
}
// 2. Asynchronen Shutdown-Kanal benachrichtigen (für SQLite WAL Checkpoint)
@@ -681,6 +718,101 @@ pub fn unlock_memory(ptr: *const u8, len: usize) -> bool {
}
}
/// Bindet ein Windows-Netzlaufwerk über WNetAddConnection2W ein (in-process, ohne argv-Token-Leak).
#[cfg(windows)]
pub fn mount_drive_wnet(drive_letter: char, port: u16, session_token: &str) -> Result<()> {
let drive_str = format!("{}:", drive_letter.to_ascii_uppercase());
let mut local_name: Vec<u16> = drive_str.encode_utf16().chain(std::iter::once(0)).collect();
let remote_url = format!("http://127.0.0.1:{}/", port);
let mut remote_name: Vec<u16> = remote_url.encode_utf16().chain(std::iter::once(0)).collect();
let username: Vec<u16> = "sanctum".encode_utf16().chain(std::iter::once(0)).collect();
let password: Vec<u16> = session_token.encode_utf16().chain(std::iter::once(0)).collect();
let nr = NETRESOURCEW {
dwScope: 0,
dwType: 1, // RESOURCETYPE_DISK
dwDisplayType: 0,
dwUsage: 0,
lpLocalName: local_name.as_mut_ptr(),
lpRemoteName: remote_name.as_mut_ptr(),
lpComment: std::ptr::null_mut(),
lpProvider: std::ptr::null_mut(),
};
let mut res = unsafe {
WNetAddConnection2W(
&nr,
password.as_ptr(),
username.as_ptr(),
0, // Nicht persistent
)
};
// PRR-01: Selbstreparatur bei verwaister Zuordnung nach unsauberem Vorläufer (Systemfehler 85 / 1202)
if res == 85 || res == 1202 {
debug!("Verwaiste Zuordnung für {} entdeckt — führe automatische Bereinigung durch...", drive_str);
unsafe {
WNetCancelConnection2W(local_name.as_ptr(), 0, 1);
}
res = unsafe {
WNetAddConnection2W(
&nr,
password.as_ptr(),
username.as_ptr(),
0,
)
};
}
if res != 0 {
let webclient_hint = if res == 67 {
"\n\nHinweis: Das Einbinden von Netzlaufwerken erfordert den Windows-Dienst 'WebClient'. Prüfen Sie in einer Administrator-Konsole: 'net start WebClient'."
} else {
""
};
bail!(
"Laufwerk {} konnte nicht eingebunden werden (Win32 Fehlercode {}){}",
drive_str,
res,
webclient_hint
);
}
Ok(())
}
/// Trennt ein eingebundenes Windows-Netzlaufwerk via WNetCancelConnection2W (in-process).
#[cfg(windows)]
pub fn unmount_drive_wnet(drive_letter: char) -> Result<()> {
let drive_str = format!("{}:", drive_letter.to_ascii_uppercase());
let local_name: Vec<u16> = drive_str.encode_utf16().chain(std::iter::once(0)).collect();
let res = unsafe {
WNetCancelConnection2W(local_name.as_ptr(), 0, 1 /* fForce = TRUE */)
};
// 0 = NO_ERROR, 2250 = ERROR_NOT_CONNECTED (bereits getrennt)
if res != 0 && res != 2250 {
bail!(
"Netzlaufwerk {} konnte nicht getrennt werden (Win32 Fehlercode {})",
drive_str,
res
);
}
Ok(())
}
#[cfg(not(windows))]
pub fn mount_drive_wnet(_drive_letter: char, _port: u16, _session_token: &str) -> Result<()> {
Ok(())
}
#[cfg(not(windows))]
pub fn unmount_drive_wnet(_drive_letter: char) -> Result<()> {
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;