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
+134 -80
View File
@@ -61,9 +61,7 @@ fn run_mount_command(drive_letter: char, port: u16, session_token: &str) -> Resu
#[cfg(unix)]
{
let dav_url = format!("dav://127.0.0.1:{}/", port);
let _ = Command::new("gio")
.args(["mount", &dav_url])
.output();
let _ = Command::new("gio").args(["mount", &dav_url]).output();
let _ = (drive_letter, session_token);
Ok(())
}
@@ -106,8 +104,7 @@ pub async fn mount_container(
println!();
ui::step(1, 4, "📦", "Öffne Container & verifiziere Header...");
}
let db = Database::open(container_path)
.context("Konnte Container-Datenbank nicht öffnen")?;
let db = Database::open(container_path).context("Konnte Container-Datenbank nicht öffnen")?;
let meta = db
.read_meta()
@@ -116,12 +113,22 @@ pub async fn mount_container(
let (dek, carrier_dek, carrier_node_id, version, vault_id) = match auth {
ContainerAuth::Password(ref password) => {
if !stealth {
ui::step(2, 4, "🔑", "Leite KEK via Argon2id ab (konstante Zeit über alle Slots)...");
ui::step(
2,
4,
"🔑",
"Leite KEK via Argon2id ab (konstante Zeit über alle Slots)...",
);
}
match meta.authenticate(password) {
Some(keys) => {
if !stealth {
ui::step(3, 4, "🔓", "Master-Passwort erfolgreich verifiziert & DEK entschlüsselt!");
ui::step(
3,
4,
"🔓",
"Master-Passwort erfolgreich verifiziert & DEK entschlüsselt!",
);
}
let vault_id = keys.slot_id();
let ver = keys.version();
@@ -138,8 +145,7 @@ pub async fn mount_container(
if !stealth {
ui::step(2, 4, "🔑", "Dekodiere DEK aus 24-Wort Notfallschlüssel...");
}
let dek = mnemonic_to_dek(phrase)
.context("Ungültiger 24-Wort Notfallschlüssel")?;
let dek = mnemonic_to_dek(phrase).context("Ungültiger 24-Wort Notfallschlüssel")?;
if !stealth {
ui::step(3, 4, "🔓", "Notfallschlüssel erfolgreich verifiziert!");
}
@@ -152,10 +158,12 @@ pub async fn mount_container(
println!(" [i] Der angegebene Notfallschlüssel gehört zum Hidden-Vault (Slot 1).");
println!(" Für den Zugriff auf die Trägerdatei wird das Passwort des Standard-Vaults benötigt.");
}
let decoy_pass = rpassword::prompt_password("Master-Passwort für Standard-Vault eingeben: ")
.context("Fehler beim Einlesen des Standard-Vault Passworts")?;
let decoy_keys = meta.authenticate(&decoy_pass)
.ok_or_else(|| anyhow::anyhow!("Ungültiges Passwort für Standard-Vault."))?;
let decoy_pass =
rpassword::prompt_password("Master-Passwort für Standard-Vault eingeben: ")
.context("Fehler beim Einlesen des Standard-Vault Passworts")?;
let decoy_keys = meta.authenticate(&decoy_pass).ok_or_else(|| {
anyhow::anyhow!("Ungültiges Passwort für Standard-Vault.")
})?;
(Some(decoy_keys.dek().clone()), Some(carrier_node_id), 1)
} else {
(None, Some(carrier_node_id), 0)
@@ -164,7 +172,13 @@ pub async fn mount_container(
(None, None, 0)
};
(dek, carrier_dek, final_carrier_node_id, meta.version, vault_id)
(
dek,
carrier_dek,
final_carrier_node_id,
meta.version,
vault_id,
)
}
};
@@ -340,7 +354,10 @@ pub async fn mount_container(
println!();
println!(" • Container: {}", container_path.display());
#[cfg(windows)]
println!(" • Netzlaufwerk: {} (im Windows Explorer bereit)", ui::cyan(&drive_str));
println!(
" • Netzlaufwerk: {} (im Windows Explorer bereit)",
ui::cyan(&drive_str)
);
#[cfg(not(windows))]
{
if let Some(mp) = mount_point {
@@ -349,12 +366,22 @@ pub async fn mount_container(
println!(" • Modus: WebDAV Userland-VFS");
}
}
println!(" • WebDAV-URL: http://127.0.0.1:{}/ (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:{}/", 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:{}/ {}", bound_port, mp.display());
println!(
" • davfs2: mount -t davfs http://127.0.0.1:{}/ {}",
bound_port,
mp.display()
);
}
}
if let Some(secs) = idle_timeout {
@@ -376,9 +403,15 @@ pub async fn mount_container(
}
println!();
#[cfg(windows)]
println!(" [{}] Drücke [Ctrl+C] oder nutze das Tray-Icon zum Beenden.", ui::yellow("Tipp"));
println!(
" [{}] Drücke [Ctrl+C] oder nutze das Tray-Icon zum Beenden.",
ui::yellow("Tipp")
);
#[cfg(not(windows))]
println!(" [{}] Drücke [Ctrl+C] zum sicheren Beenden.", ui::yellow("Tipp"));
println!(
" [{}] Drücke [Ctrl+C] zum sicheren Beenden.",
ui::yellow("Tipp")
);
println!();
}
@@ -386,11 +419,13 @@ pub async fn mount_container(
#[cfg(windows)]
let (console_close_tx, mut console_close_rx) = tokio::sync::mpsc::channel::<()>(1);
#[cfg(windows)]
let _console_guard = crate::windows::start_console_ctrl_monitor(console_close_tx, drive_letter).ok();
let _console_guard =
crate::windows::start_console_ctrl_monitor(console_close_tx, drive_letter).ok();
// Unix Signale (SIGTERM, SIGHUP)
#[cfg(unix)]
let mut sigterm = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()).ok();
let mut sigterm =
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()).ok();
#[cfg(unix)]
let mut sighup = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::hangup()).ok();
@@ -503,7 +538,11 @@ pub async fn mount_container(
} else {
#[cfg(windows)]
{
print!(" {} Trenne Windows-Netzlaufwerk {} ... ", ui::dim("[-]"), drive_str);
print!(
" {} Trenne Windows-Netzlaufwerk {} ... ",
ui::dim("[-]"),
drive_str
);
let _ = std::io::Write::flush(&mut std::io::stdout());
// Automatisches Unmount
@@ -527,7 +566,11 @@ pub async fn mount_container(
// Storage-Kompaktierung (Incremental Vacuum), falls freie Seiten existieren
if let Ok(freelist) = db.freelist_count() {
if freelist > 0 {
print!(" {} Führe Storage-Kompaktierung aus ({} freie Seiten) ... ", ui::dim("[-]"), freelist);
print!(
" {} Führe Storage-Kompaktierung aus ({} freie Seiten) ... ",
ui::dim("[-]"),
freelist
);
let _ = std::io::Write::flush(&mut std::io::stdout());
match db.incremental_vacuum(None) {
Ok(freed) => println!("{} ({} Seiten freigegeben)", ui::green("OK"), freed),
@@ -546,7 +589,10 @@ pub async fn mount_container(
}
println!();
println!("{} Sanctum Container wurde sicher und vollständig geschlossen.", ui::green(""));
println!(
"{} Sanctum Container wurde sicher und vollständig geschlossen.",
ui::green("")
);
println!();
}
@@ -616,6 +662,7 @@ const HTTP_HEADER_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_
/// Hilfsfunktion zur Validierung von HTTP Basic Auth.
/// Unterstützt Format `username:password` (wobei Passwort dem Session-Token entspricht).
/// SA-06: Verwendet strikten Constant-Time-Vergleich gegen Timing-Side-Channel-Angriffe.
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 ") {
@@ -637,44 +684,36 @@ pub fn check_basic_auth(auth_header: &str, expected_token: &str) -> bool {
Err(_) => return false,
};
use subtle::ConstantTimeEq;
if let Some((_user, pass)) = decoded_str.split_once(':') {
pass == expected_token
pass.as_bytes().ct_eq(expected_token.as_bytes()).into()
} else {
false
}
}
/// Hilfsfunktion zur Validierung des X-Sanctum-Token Headers.
/// SA-06: Verwendet strikten Constant-Time-Vergleich gegen Timing-Side-Channel-Angriffe.
pub fn check_token_header(headers: &hyper::HeaderMap, expected_token: &str) -> bool {
use subtle::ConstantTimeEq;
if let Some(val) = headers.get("X-Sanctum-Token") {
if let Ok(val_str) = val.to_str() {
return val_str.trim() == expected_token;
return val_str
.trim()
.as_bytes()
.ct_eq(expected_token.as_bytes())
.into();
}
}
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;
/// Prüft, ob eine URI oder deren Query-Parameter das sensible Session-Token enthält (SA-05 / CWE-598).
pub fn uri_contains_token(uri: &hyper::Uri, session_token: &str) -> bool {
if session_token.is_empty() {
return false;
}
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()
uri.path().contains(session_token) || uri.query().unwrap_or("").contains(session_token)
}
/// Erzeugt eine standardkonforme HTTP 401 Unauthorized Antwort mit WWW-Authenticate Header.
@@ -687,15 +726,15 @@ fn unauthorized_response() -> hyper::Response<dav_server::body::Body> {
.unwrap()
}
/// Führt die asynchrone WebDAV HTTP-Server-Schleife mit Multi-Auth & Host-Header Sicherheits-Middleware aus.
/// Führt die asynchrone WebDAV HTTP-Server-Schleife mit Header-basierter Authentifizierung & Host-Header Sicherheits-Middleware aus.
pub async fn serve_webdav_loop(
listener: TcpListener,
dav_server: DavHandler,
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);
let conn_semaphore =
std::sync::Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_DAV_CONNECTIONS));
loop {
tokio::select! {
@@ -724,15 +763,13 @@ pub async fn serve_webdav_loop(
let io = TokioIo::new(stream);
let handler = dav_server.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 |mut req| {
let service = service_fn(move |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)
let host_valid = match req.headers().get(hyper::header::HOST) {
@@ -750,15 +787,27 @@ pub async fn serve_webdav_loop(
);
let res = hyper::Response::builder()
.status(hyper::StatusCode::FORBIDDEN)
.header(hyper::header::CONTENT_LENGTH, "0")
.body(dav_server::body::Body::empty())
.unwrap();
return Ok::<_, Infallible>(res);
}
// 2. R-05: Multi-Auth Middleware
// 2. SA-05: Verhindere Session-Token in Request-URI oder Query (CWE-598).
// Session-Tokens dürfen ausschließlich in HTTP-Headern übertragen werden.
if uri_contains_token(req.uri(), &token) {
warn!("Abgewiesener Zugriff: Session-Token in URI/Query übermittelt (SA-05 / CWE-598)");
let res = hyper::Response::builder()
.status(hyper::StatusCode::FORBIDDEN)
.header(hyper::header::CONTENT_LENGTH, "0")
.body(dav_server::body::Body::empty())
.unwrap();
return Ok::<_, Infallible>(res);
}
// 3. SA-05 & SA-06: Header-basierte Authentifizierung mit Constant-Time Token-Vergleich
// 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) {
@@ -773,15 +822,6 @@ pub async fn serve_webdav_loop(
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());
@@ -841,15 +881,24 @@ mod tests {
use base64::Engine;
let token = "deadbeefcafebabe0123456789abcdef";
let creds = format!("sanctum:{}", token);
let header_val = format!("Basic {}", base64::engine::general_purpose::STANDARD.encode(creds));
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)));
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"));
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
@@ -873,22 +922,27 @@ mod tests {
}
#[test]
fn test_strip_path_prefix() {
fn test_uri_contains_token_rejection() {
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"));
// Token im Pfad
let uri_path: hyper::Uri = format!("http://127.0.0.1:8443/{}/Photos/vacation.jpg", token)
.parse()
.unwrap();
assert!(uri_contains_token(&uri_path, token));
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(), "/");
// Token im Query-String
let uri_query: hyper::Uri =
format!("http://127.0.0.1:8443/Photos/vacation.jpg?token={}", token)
.parse()
.unwrap();
assert!(uri_contains_token(&uri_query, token));
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());
// Harmloser Pfad ohne Token
let uri_clean: hyper::Uri = "http://127.0.0.1:8443/Photos/vacation.jpg".parse().unwrap();
assert!(!uri_contains_token(&uri_clean, token));
// Leeres Token darf niemals matchen
assert!(!uri_contains_token(&uri_clean, ""));
}
}