feat(cli): add passwd command for instant master password changes
This commit is contained in:
@@ -7,6 +7,10 @@ und dieses Projekt folgt den Richtlinien von [Semantic Versioning](https://semve
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **`sanctum passwd` Befehl**: Sicheres und sofortiges Ändern des Master-Passworts über DEK-Rewrapping mit frischem Argon2id-Salt und neuen KEK-Parametern, ohne die Nutzdaten-Chunks neu verschlüsseln zu müssen.
|
||||||
|
- Integrationstest `test_sanctum_password_change` zur automatisierten Verifikation von Authentifizierungswechsel und Datenintegrität.
|
||||||
|
|
||||||
## [0.1.0] - 2026-09-07
|
## [0.1.0] - 2026-09-07
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -71,6 +71,13 @@ Zum Beenden und sicheren Trennen einfach `Ctrl+C` im Terminal drücken.
|
|||||||
sanctum.exe unmount --drive S
|
sanctum.exe unmount --drive S
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 4. Master-Passwort ändern
|
||||||
|
```powershell
|
||||||
|
sanctum.exe passwd --path "C:\Users\username\Documents\safe.sanctum"
|
||||||
|
```
|
||||||
|
Ändert das Master-Passwort über Key-Wrapping in Sekundenbruchteilen, ohne dass Nutzdatenblöcke neu verschlüsselt werden müssen.
|
||||||
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📦 Release Packaging
|
## 📦 Release Packaging
|
||||||
|
|||||||
+111
-1
@@ -4,7 +4,9 @@ use anyhow::{bail, Context, Result};
|
|||||||
use clap::{Parser, Subcommand};
|
use clap::{Parser, Subcommand};
|
||||||
use tracing_subscriber::EnvFilter;
|
use tracing_subscriber::EnvFilter;
|
||||||
|
|
||||||
use sanctum::crypto::{derive_kek, generate_dek, generate_salt, wrap_dek, KdfParams};
|
use sanctum::crypto::{
|
||||||
|
derive_kek, generate_dek, generate_salt, unwrap_dek, wrap_dek, KdfParams,
|
||||||
|
};
|
||||||
use sanctum::mount::{format_drive, mount_container, unmount_drive};
|
use sanctum::mount::{format_drive, mount_container, unmount_drive};
|
||||||
use sanctum::storage::Database;
|
use sanctum::storage::Database;
|
||||||
use sanctum::ui;
|
use sanctum::ui;
|
||||||
@@ -49,6 +51,13 @@ enum Commands {
|
|||||||
#[arg(short, long)]
|
#[arg(short, long)]
|
||||||
drive: String,
|
drive: String,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/// Ändert das Master-Passwort eines bestehenden Sanctum-Containers
|
||||||
|
Passwd {
|
||||||
|
/// Pfad zur .sanctum Containerdatei
|
||||||
|
#[arg(short, long)]
|
||||||
|
path: PathBuf,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_drive_letter(s: &str) -> Result<char> {
|
fn parse_drive_letter(s: &str) -> Result<char> {
|
||||||
@@ -134,6 +143,103 @@ fn handle_init(container_path: &Path) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn handle_passwd(container_path: &Path) -> Result<()> {
|
||||||
|
if !container_path.exists() {
|
||||||
|
bail!(
|
||||||
|
"Containerdatei '{}' existiert nicht.",
|
||||||
|
container_path.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("┌─────────────────────────────────────────────────────────────┐");
|
||||||
|
println!("│ Sanctum — Master-Passwort ändern │");
|
||||||
|
println!("└─────────────────────────────────────────────────────────────┘");
|
||||||
|
println!(" Container: {}", container_path.display());
|
||||||
|
println!();
|
||||||
|
|
||||||
|
let old_password = rpassword::prompt_password("Aktuelles Master-Passwort eingeben: ")
|
||||||
|
.context("Fehler beim Einlesen des aktuellen Passworts")?;
|
||||||
|
|
||||||
|
if old_password.trim().is_empty() {
|
||||||
|
bail!("Das aktuelle Master-Passwort darf nicht leer sein.");
|
||||||
|
}
|
||||||
|
|
||||||
|
println!();
|
||||||
|
ui::step(1, 4, "📦", "Öffne Container & verifiziere Header...");
|
||||||
|
let db = Database::open(container_path)
|
||||||
|
.context("Konnte Container-Datenbank nicht öffnen")?;
|
||||||
|
|
||||||
|
let meta = db
|
||||||
|
.read_meta()
|
||||||
|
.context("Konnte Container-Header nicht lesen")?;
|
||||||
|
|
||||||
|
ui::step(2, 4, "🔑", "Leite KEK via Argon2id ab & prüfe Passwort...");
|
||||||
|
let old_kek = derive_kek(&old_password, &meta.kdf_salt, &meta.kdf_params)
|
||||||
|
.context("Schlüsselableitung fehlgeschlagen")?;
|
||||||
|
|
||||||
|
let dek = unwrap_dek(
|
||||||
|
&old_kek,
|
||||||
|
&meta.wrapped_dek,
|
||||||
|
&meta.header_nonce,
|
||||||
|
&meta.header_tag,
|
||||||
|
)
|
||||||
|
.map_err(|_| anyhow::anyhow!("Ungültiges aktuelles Master-Passwort! Authentifizierung fehlgeschlagen."))?;
|
||||||
|
|
||||||
|
println!();
|
||||||
|
let new_password = rpassword::prompt_password("Neues Master-Passwort eingeben: ")
|
||||||
|
.context("Fehler beim Einlesen des neuen Passworts")?;
|
||||||
|
|
||||||
|
if new_password.trim().is_empty() {
|
||||||
|
bail!("Das neue Master-Passwort darf nicht leer sein.");
|
||||||
|
}
|
||||||
|
|
||||||
|
let confirm_password = rpassword::prompt_password("Neues Master-Passwort bestätigen: ")
|
||||||
|
.context("Fehler beim Einlesen der Passwort-Bestätigung")?;
|
||||||
|
|
||||||
|
if new_password != confirm_password {
|
||||||
|
bail!("Die eingegebenen Passwörter stimmen nicht überein!");
|
||||||
|
}
|
||||||
|
|
||||||
|
if old_password == new_password {
|
||||||
|
bail!("Das neue Master-Passwort muss sich vom aktuellen Passwort unterscheiden.");
|
||||||
|
}
|
||||||
|
|
||||||
|
println!();
|
||||||
|
ui::step(3, 4, "🔒", "Generiere frisches Salt & leite neuen KEK ab...");
|
||||||
|
let new_salt = generate_salt();
|
||||||
|
let new_params = KdfParams::default();
|
||||||
|
let new_kek = derive_kek(&new_password, &new_salt, &new_params)
|
||||||
|
.context("KDF-Schlüsselableitung mit neuem Passwort fehlgeschlagen")?;
|
||||||
|
|
||||||
|
let (new_wrapped_dek, new_nonce, new_tag) =
|
||||||
|
wrap_dek(&new_kek, &dek).context("DEK-Wrapping mit neuem KEK fehlgeschlagen")?;
|
||||||
|
|
||||||
|
ui::step(4, 4, "💾", "Aktualisiere Container-Header & führe Checkpoint aus...");
|
||||||
|
db.update_meta_keys(
|
||||||
|
&new_salt,
|
||||||
|
&new_params,
|
||||||
|
&new_wrapped_dek,
|
||||||
|
&new_nonce,
|
||||||
|
&new_tag,
|
||||||
|
)
|
||||||
|
.context("Fehler beim Aktualisieren der Container-Metadaten")?;
|
||||||
|
|
||||||
|
db.checkpoint()
|
||||||
|
.context("Fehler beim WAL-Checkpoint nach Passwortänderung")?;
|
||||||
|
|
||||||
|
println!();
|
||||||
|
println!("┌─────────────────────────────────────────────────────────────┐");
|
||||||
|
println!("│ ✔ Master-Passwort erfolgreich geändert! │");
|
||||||
|
println!("└─────────────────────────────────────────────────────────────┘");
|
||||||
|
println!();
|
||||||
|
println!(" • Container: {}", container_path.display());
|
||||||
|
println!(" • KDF: Argon2id mit frischem Salt");
|
||||||
|
println!(" • Status: DEK sicher neu verpackt (alle Chunks intakt)");
|
||||||
|
println!();
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
async fn run() -> Result<()> {
|
async fn run() -> Result<()> {
|
||||||
let cli = Cli::parse();
|
let cli = Cli::parse();
|
||||||
|
|
||||||
@@ -161,11 +267,15 @@ async fn run() -> Result<()> {
|
|||||||
println!("{}", ui::green("OK"));
|
println!("{}", ui::green("OK"));
|
||||||
println!("{} Laufwerk {} erfolgreich getrennt.", ui::green("✔"), drive_str);
|
println!("{} Laufwerk {} erfolgreich getrennt.", ui::green("✔"), drive_str);
|
||||||
}
|
}
|
||||||
|
Commands::Passwd { path } => {
|
||||||
|
handle_passwd(&path)?;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() {
|
async fn main() {
|
||||||
// Windows Konsole für UTF-8 (Code Page 65001) und VT ANSI Processing konfigurieren
|
// Windows Konsole für UTF-8 (Code Page 65001) und VT ANSI Processing konfigurieren
|
||||||
|
|||||||
@@ -225,6 +225,35 @@ impl Database {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Aktualisiert KDF-Salt, KDF-Parameter und den neu verpackten DEK im Header (Passwortänderung).
|
||||||
|
pub fn update_meta_keys(
|
||||||
|
&self,
|
||||||
|
new_salt: &[u8; 16],
|
||||||
|
new_params: &KdfParams,
|
||||||
|
new_wrapped_dek: &[u8],
|
||||||
|
new_header_nonce: &[u8; 12],
|
||||||
|
new_header_tag: &[u8; 16],
|
||||||
|
) -> Result<()> {
|
||||||
|
let conn = self.conn.lock().unwrap();
|
||||||
|
let params_json = serde_json::to_string(new_params)?;
|
||||||
|
let rows_affected = conn.execute(
|
||||||
|
"UPDATE meta SET kdf_salt = ?1, kdf_params = ?2, wrapped_dek = ?3, header_nonce = ?4, header_tag = ?5",
|
||||||
|
params![
|
||||||
|
new_salt.as_slice(),
|
||||||
|
params_json,
|
||||||
|
new_wrapped_dek,
|
||||||
|
new_header_nonce.as_slice(),
|
||||||
|
new_header_tag.as_slice(),
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
|
||||||
|
if rows_affected == 0 {
|
||||||
|
bail!("Konnte Container-Header nicht aktualisieren: meta-Tabelle ist leer");
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Löst einen hierarchischen Pfad (z. B. "/ordner/datei.txt") in den entsprechenden NodeRecord auf.
|
/// Löst einen hierarchischen Pfad (z. B. "/ordner/datei.txt") in den entsprechenden NodeRecord auf.
|
||||||
pub fn resolve_path(&self, raw_path: &str) -> Result<Option<NodeRecord>> {
|
pub fn resolve_path(&self, raw_path: &str) -> Result<Option<NodeRecord>> {
|
||||||
let normalized = raw_path.trim_matches('/');
|
let normalized = raw_path.trim_matches('/');
|
||||||
|
|||||||
@@ -158,3 +158,94 @@ async fn test_sanctum_full_container_lifecycle() {
|
|||||||
assert!(container_path.exists());
|
assert!(container_path.exists());
|
||||||
let _ = std::fs::remove_file(&container_path);
|
let _ = std::fs::remove_file(&container_path);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_sanctum_password_change() {
|
||||||
|
let temp_dir = std::env::temp_dir();
|
||||||
|
let container_path: PathBuf = temp_dir.join(format!("test_sanctum_passwd_{}.sanctum", std::process::id()));
|
||||||
|
|
||||||
|
if container_path.exists() {
|
||||||
|
let _ = std::fs::remove_file(&container_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
let password_v1 = "InitialSecret123!";
|
||||||
|
let password_v2 = "NewSecret456!";
|
||||||
|
|
||||||
|
// 1. Initialisierung mit Passwort v1
|
||||||
|
let salt_v1 = generate_salt();
|
||||||
|
let kdf_params = KdfParams {
|
||||||
|
memory_cost: 1024,
|
||||||
|
time_cost: 1,
|
||||||
|
parallelism: 1,
|
||||||
|
};
|
||||||
|
let kek_v1 = derive_kek(password_v1, &salt_v1, &kdf_params).expect("KEK v1");
|
||||||
|
let original_dek = generate_dek();
|
||||||
|
let (wrapped_dek_v1, nonce_v1, tag_v1) =
|
||||||
|
wrap_dek(&kek_v1, &original_dek).expect("Wrap DEK v1");
|
||||||
|
|
||||||
|
let db = Database::open(&container_path).expect("Open database");
|
||||||
|
db.init_schema(&salt_v1, &kdf_params, &wrapped_dek_v1, &nonce_v1, &tag_v1)
|
||||||
|
.expect("Init schema");
|
||||||
|
db.checkpoint().expect("Checkpoint");
|
||||||
|
|
||||||
|
// Datei im Container mit DEK anlegen
|
||||||
|
let fs_v1 = SanctumFs::new(db.clone(), original_dek.clone());
|
||||||
|
let file_path = DavPath::new("/secret.txt").unwrap();
|
||||||
|
let file_data = b"Verschluesselte Geheimdaten vor der Passwortaenderung!";
|
||||||
|
let mut opts_write = OpenOptions::default();
|
||||||
|
opts_write.write = true;
|
||||||
|
opts_write.create_new = true;
|
||||||
|
let mut write_file = fs_v1
|
||||||
|
.open(&file_path, opts_write)
|
||||||
|
.await
|
||||||
|
.expect("Open write");
|
||||||
|
write_file
|
||||||
|
.write_bytes(Bytes::from_static(file_data))
|
||||||
|
.await
|
||||||
|
.expect("Write bytes");
|
||||||
|
write_file.flush().await.expect("Flush");
|
||||||
|
drop(write_file);
|
||||||
|
drop(fs_v1);
|
||||||
|
db.checkpoint().expect("Checkpoint");
|
||||||
|
|
||||||
|
// 2. Passwortwechsel durchführen
|
||||||
|
let meta_before = db.read_meta().expect("Read meta");
|
||||||
|
let old_kek = derive_kek(password_v1, &meta_before.kdf_salt, &meta_before.kdf_params).expect("Derive old KEK");
|
||||||
|
let recovered_dek = unwrap_dek(&old_kek, &meta_before.wrapped_dek, &meta_before.header_nonce, &meta_before.header_tag)
|
||||||
|
.expect("Unwrap with old password");
|
||||||
|
|
||||||
|
let salt_v2 = generate_salt();
|
||||||
|
let kek_v2 = derive_kek(password_v2, &salt_v2, &kdf_params).expect("Derive new KEK");
|
||||||
|
let (wrapped_dek_v2, nonce_v2, tag_v2) = wrap_dek(&kek_v2, &recovered_dek).expect("Wrap with new KEK");
|
||||||
|
|
||||||
|
db.update_meta_keys(&salt_v2, &kdf_params, &wrapped_dek_v2, &nonce_v2, &tag_v2).expect("Update meta keys");
|
||||||
|
db.checkpoint().expect("Checkpoint");
|
||||||
|
|
||||||
|
// 3. Verifikation: Altes Passwort darf NICHT mehr funktionieren
|
||||||
|
let meta_after = db.read_meta().expect("Read meta after");
|
||||||
|
let old_kek_again = derive_kek(password_v1, &meta_after.kdf_salt, &meta_after.kdf_params).unwrap();
|
||||||
|
assert!(
|
||||||
|
unwrap_dek(&old_kek_again, &meta_after.wrapped_dek, &meta_after.header_nonce, &meta_after.header_tag).is_err(),
|
||||||
|
"Altes Passwort darf nach Passwortaenderung nicht mehr funktionieren!"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 4. Verifikation: Neues Passwort funktioniert und entschlüsselt alte Daten intakt
|
||||||
|
let new_kek = derive_kek(password_v2, &meta_after.kdf_salt, &meta_after.kdf_params).expect("Derive new KEK");
|
||||||
|
let active_dek = unwrap_dek(&new_kek, &meta_after.wrapped_dek, &meta_after.header_nonce, &meta_after.header_tag)
|
||||||
|
.expect("Unwrap with new password");
|
||||||
|
|
||||||
|
let fs_v2 = SanctumFs::new(db.clone(), active_dek);
|
||||||
|
let mut opts_read = OpenOptions::default();
|
||||||
|
opts_read.read = true;
|
||||||
|
let mut read_file = fs_v2.open(&file_path, opts_read).await.expect("Open read with new password");
|
||||||
|
let read_bytes = read_file.read_bytes(file_data.len()).await.expect("Read bytes");
|
||||||
|
assert_eq!(&read_bytes[..], file_data, "Daten muessen nach Passwortaenderung unveraendert lesbar sein!");
|
||||||
|
|
||||||
|
|
||||||
|
drop(read_file);
|
||||||
|
drop(fs_v2);
|
||||||
|
drop(db);
|
||||||
|
|
||||||
|
let _ = std::fs::remove_file(&container_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user