Initial commit: Sanctum encrypted single-file container for Windows

This commit is contained in:
2026-09-07 15:40:58 +02:00
commit 9115596763
11 changed files with 4020 additions and 0 deletions
+168
View File
@@ -0,0 +1,168 @@
use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
use clap::{Parser, Subcommand};
use tracing::info;
use tracing_subscriber::EnvFilter;
use sanctum::crypto::{derive_kek, generate_dek, generate_salt, wrap_dek, KdfParams};
use sanctum::mount::{format_drive, mount_container, unmount_drive};
use sanctum::storage::Database;
#[derive(Parser)]
#[command(name = "sanctum")]
#[command(author = "Sanctum Systems & Security Engineering")]
#[command(about = "Sanctum: Verschlüsselter Ein-Datei-Container unter Windows im reinen Userland", long_about = None)]
#[command(version)]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
/// Initialisiert einen neuen verschlüsselten Sanctum-Container (.sanctum)
Init {
/// Pfad zur zu erstellenden .sanctum Containerdatei
#[arg(short, long)]
path: PathBuf,
},
/// Bindet einen Sanctum-Container als Windows-Netzlaufwerk via WebDAV ein
Mount {
/// Pfad zur .sanctum Containerdatei
#[arg(short, long)]
path: PathBuf,
/// Laufwerksbuchstabe (z. B. 'S' oder 'S:')
#[arg(short, long)]
drive: String,
/// Optionaler TCP-Port für den lokalen WebDAV-Server (Standard: 8443)
#[arg(long)]
port: Option<u16>,
},
/// Trennt ein eingebundenes Netzlaufwerk manuell
Unmount {
/// Laufwerksbuchstabe (z. B. 'S' oder 'S:')
#[arg(short, long)]
drive: String,
},
}
fn parse_drive_letter(s: &str) -> Result<char> {
let trimmed = s.trim();
let ch = trimmed
.chars()
.next()
.context("Laufwerksangabe darf nicht leer sein")?;
if !ch.is_ascii_alphabetic() {
bail!("Ungültiger Laufwerksbuchstabe: '{}'", trimmed);
}
Ok(ch.to_ascii_uppercase())
}
fn handle_init(container_path: &Path) -> Result<()> {
if container_path.exists() {
bail!(
"Zieldatei '{}' existiert bereits. Initialisierung abgebrochen, um Überschreiben zu verhindern.",
container_path.display()
);
}
println!("============================================================");
println!(" Sanctum Container Initialisierung");
println!(" Zieldatei: {}", container_path.display());
println!("============================================================");
let password = rpassword::prompt_password("Master-Passwort eingeben: ")
.context("Fehler beim Einlesen des Passworts")?;
if password.trim().is_empty() {
bail!("Das Master-Passwort darf nicht leer sein.");
}
let confirm_password = rpassword::prompt_password("Master-Passwort bestätigen: ")
.context("Fehler beim Einlesen der Passwort-Bestätigung")?;
if password != confirm_password {
bail!("Die eingegebenen Passwörter stimmen nicht überein!");
}
info!("Generiere KDF-Salt und leite KEK via Argon2id ab ...");
let salt = generate_salt();
let kdf_params = KdfParams::default();
let kek = derive_kek(&password, &salt, &kdf_params)
.context("KDF-Schlüsselableitung fehlgeschlagen")?;
info!("Erzeuge kryptografisch sicheren DEK via CSPRNG (OsRng) ...");
let dek = generate_dek();
info!("Verschlüssele DEK mit KEK via AES-256-GCM ...");
let (wrapped_dek, header_nonce, header_tag) =
wrap_dek(&kek, &dek).context("DEK-Wrapping fehlgeschlagen")?;
info!("Initialisiere SQLite-Containerstruktur mit WAL-Modus ...");
let db = Database::open(container_path)
.context("Konnte SQLite-Containerdatei nicht anlegen")?;
db.init_schema(&salt, &kdf_params, &wrapped_dek, &header_nonce, &header_tag)
.context("Fehler bei der Schema-Initialisierung")?;
db.checkpoint()
.context("Fehler beim finalen WAL-Checkpoint")?;
println!();
println!("✔ Sanctum-Container erfolgreich initialisiert!");
println!(" Container: {}", container_path.display());
println!(" Format: Version 1 (Magic: SANCTUM\\0)");
println!(" KDF: Argon2id (M=64MB, T=3, P=4)");
println!(" Cipher: AES-256-GCM mit AEAD-Swap-Protection (1-MB Chunks)");
println!();
println!("Zum Einbinden ausführen:");
println!(
" sanctum mount --path \"{}\" --drive S",
container_path.display()
);
Ok(())
}
#[tokio::main]
async fn main() -> Result<()> {
// Tracing / Logging initialisieren (Standard-Filter: info)
tracing_subscriber::fmt()
.with_env_filter(
EnvFilter::try_from_default_env()
.unwrap_or_else(|_| EnvFilter::new("sanctum=info,dav_server=warn,hyper=warn")),
)
.init();
let cli = Cli::parse();
match cli.command {
Commands::Init { path } => {
handle_init(&path)?;
}
Commands::Mount { path, drive, port } => {
let drive_char = parse_drive_letter(&drive)?;
let password = rpassword::prompt_password(format!(
"Master-Passwort für Container '{}' eingeben: ",
path.display()
))
.context("Fehler beim Einlesen des Passworts")?;
mount_container(&path, drive_char, port, &password).await?;
}
Commands::Unmount { drive } => {
let drive_char = parse_drive_letter(&drive)?;
unmount_drive(drive_char)?;
println!("✔ Laufwerk {} erfolgreich getrennt.", format_drive(drive_char));
}
}
Ok(())
}