release: v0.7.2 — Security Audit Remediation (SA-01 bis SA-07)
- 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:
+134
-80
@@ -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, ""));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user