fix(crypto): K-03 — emergency card persistence warning and rekey command

This commit is contained in:
2026-09-19 00:58:39 +02:00
parent 36a4094336
commit bdddd812ac
8 changed files with 560 additions and 9 deletions
+22
View File
@@ -5,6 +5,28 @@ Alle nennenswerten Änderungen an diesem Projekt werden in dieser Datei dokument
Das Format basiert auf [Keep a Changelog](https://keepachangelog.com/de/1.1.0/) Das Format basiert auf [Keep a Changelog](https://keepachangelog.com/de/1.1.0/)
und dieses Projekt folgt den Richtlinien von [Semantic Versioning](https://semver.org/lang/de/). und dieses Projekt folgt den Richtlinien von [Semantic Versioning](https://semver.org/lang/de/).
## [0.8.0] - 2026-09-19
### Security Audit Remediation & Format V3 Release
Dieses Release implementiert die umfassende Härtung aus zwei Security-Audit-Runden, rüstet das Container-Format auf V3 auf und führt wesentliche Sicherheits- und Notfallfunktionen ein.
#### Security & Architecture (Findings S-01, V-01, V-02, K-01, K-02, K-03)
- **K-01: Kanonische Metadaten-Authentifizierung (Format V3)**:
- Ergänzung einer HMAC-SHA-256 Metadaten-MAC (`metadata_mac`) im Header, abgeleitet aus dem DEK.
- Verhindert unbemerkte Manipulation von Verzeichnisstrukturen, Dateigrößen oder Dateitypen.
- **K-02: Chunk-Replay- & Reordering-Schutz (Format V3)**:
- Erweiterung des AAD von 16 Bytes auf 24 Bytes: Bindung an `node_id` (8 Bytes), `chunk_index` (8 Bytes) und monotone `generation` (8 Bytes).
- Verhindert das Wiedereinspielen veralteter Chunks durch Angreifer mit Schreibzugriff auf die Containerdatei.
- **K-03: Notfallblatt-Lebenszyklus & `rekey`-Kommando**:
- `sanctum passwd` weist nun explizit darauf hin, dass das 24-Wörter Notfallblatt (BIP-39) weiterhin gültig bleibt.
- Neues CLI-Kommando `sanctum rekey`: Generiert einen frischen DEK, verschlüsselt alle Chunks um, aktualisiert Header sowie Metadaten-MAC und entwertet das bisherige Notfallblatt unwiderruflich.
- **S-01: Sichere Löschlogik in `sanctum sync`**:
- `--delete` löscht keine ausgeschlossenen Pfade (`--exclude`) mehr. Für das Löschen ausgeschlossener Dateien muss explizit `--delete-excluded` übergeben werden (analog zu rsync).
- **V-01: CarrierFs-Dispatch im Carrier-Modus**:
- `SanctumFs::copy` leitet im Trägermodus Kopieroperationen korrekt an das Trägerdateisystem weiter.
- **V-02: Schutz vor stiller Dateikürzung bei fehlenden Chunks**:
- Fehlt ein Datenblock innerhalb der erwarteten Dateigröße, wird ein harter E/A-Fehler (`EIO` / `DatabaseInconsistency`) ausgelöst statt stiller Kürzung auf 0 Bytes.
## [0.7.2] - 2026-09-18 ## [0.7.2] - 2026-09-18
### Security Audit Remediation Release (Findings SA-01 to SA-07) ### Security Audit Remediation Release (Findings SA-01 to SA-07)
+1
View File
@@ -57,6 +57,7 @@ Die in der [LICENSE](LICENSE) enthaltene US-Standardklausel (*„AS IS, WITHOUT
### B. Eigenverantwortung für Backups & Notfallschlüssel ### B. Eigenverantwortung für Backups & Notfallschlüssel
* Kryptografie verzeiht keine Fehler: Bei Verlust beider Passwörter sowie der 24-Wort BIP-39 Notfallschlüssel ist eine Entschlüsselung mathematisch ausgeschlossen. Der Entwickler verfügt über keinerlei Master-Keys, Backdoors oder Wiederherstellungsmechanismen. * Kryptografie verzeiht keine Fehler: Bei Verlust beider Passwörter sowie der 24-Wort BIP-39 Notfallschlüssel ist eine Entschlüsselung mathematisch ausgeschlossen. Der Entwickler verfügt über keinerlei Master-Keys, Backdoors oder Wiederherstellungsmechanismen.
* Der Anwender ist für die regelmäßige externe Sicherung seiner Container (`sanctum backup`) und das sichere Verwahren seiner BIP-39 Notfallkarten allein verantwortlich. * Der Anwender ist für die regelmäßige externe Sicherung seiner Container (`sanctum backup`) und das sichere Verwahren seiner BIP-39 Notfallkarten allein verantwortlich.
* **Gültigkeit von Notfallkarten bei Passwortänderung**: Ein normales Ändern des Master-Passworts (`sanctum passwd`) ändert lediglich den KEK und belässt den Datenschlüssel (DEK) unverändert; bestehende Notfallkarten bleiben somit vollumfänglich funktionsfähig. Zur Entwertung kompromittierter Notfallkarten und vollständigen Neugenerierung des Datenschlüssels muss explizit der Befehl `sanctum rekey` ausgeführt werden.
--- ---
+7 -1
View File
@@ -73,7 +73,13 @@ Sanctum erkennt automatisch anhand des eingegebenen Passworts, ob der Decoy- ode
```powershell ```powershell
sanctum.exe passwd --path "D:\Tresor\daten.sanctum" --recovery-key sanctum.exe passwd --path "D:\Tresor\daten.sanctum" --recovery-key
``` ```
*(Liest die 24 Wörter maskiert ein, ohne Spuren in der PowerShell-Historie zu hinterlassen, und vergibt ein neues Passwort).* *(Liest die 24 Wörter maskiert ein, ohne Spuren in der PowerShell-Historie zu hinterlassen, und vergibt ein neues Passwort. Hinweis: Das Notfallblatt bleibt danach weiterhin gültig!).*
* **Notfallkarte verloren oder kompromittiert? Schlüssel erneuern (Rekeying)**:
```powershell
sanctum.exe rekey --path "D:\Tresor\daten.sanctum"
```
*(Generiert einen frischen Verschlüsselungsschlüssel, verschlüsselt alle Chunks um und entwertet das bisherige Notfallblatt unwiderruflich. Gibt eine neue 24-Wort Notfallkarte aus).*
--- ---
+15 -3
View File
@@ -121,12 +121,21 @@ sanctum.exe unmount --drive S
--- ---
### 4. Master-Passwort ändern ### 4. Master-Passwort ändern (`passwd`) & Rekeying (`rekey`)
```powershell ```powershell
# Master-Passwort ändern (Key-Rewrapping in Sekundenbruchteilen):
sanctum.exe passwd --path "C:\Pfad\tresor.sanctum" sanctum.exe passwd --path "C:\Pfad\tresor.sanctum"
``` ```
Ändert das Passwort über Key-Rewrapping in Sekundenbruchteilen, ohne die Nutzdaten neu verschlüsseln zu müssen. > [!NOTE]
> `passwd` verpackt den bestehenden Datenschlüssel (DEK) mit einem neuen KEK. Das zuvor notierte **24-Wort Notfallblatt bleibt weiterhin gültig**!
```powershell
# Master-Schlüssel erneuern & bisherige Notfallkarten entwerten (K-03):
sanctum.exe rekey --path "C:\Pfad\tresor.sanctum"
```
> [!IMPORTANT]
> `rekey` generiert einen komplett frischen DEK, verschlüsselt alle Datenblöcke um und gibt ein **neues 24-Wort Notfallblatt** aus. Alle bisherigen Notfallkarten werden dadurch **unwiderruflich ungültig**.
--- ---
@@ -183,9 +192,12 @@ sanctum.exe sync --path "C:\Pfad\tresor.sanctum" "C:\Users\User\Downloads" /Down
# 2. Ordner synchronisieren (unfertige Downloads und Temporärdateien ausschließen): # 2. Ordner synchronisieren (unfertige Downloads und Temporärdateien ausschließen):
sanctum.exe sync --path "C:\Pfad\tresor.sanctum" "C:\Users\User\Downloads" /Downloads --exclude "*.crdownload" --exclude "*.tmp" sanctum.exe sync --path "C:\Pfad\tresor.sanctum" "C:\Users\User\Downloads" /Downloads --exclude "*.crdownload" --exclude "*.tmp"
# 3. Exakte 1:1 Spiegelung (löscht verwaiste Dateien im Ziel): # 3. Exakte 1:1 Spiegelung (löscht verwaiste Dateien im Ziel, schont aber per --exclude ausgeschlossene Pfade):
sanctum.exe sync --path "C:\Pfad\tresor.sanctum" "C:\Users\User\Dokumente" /Dokumente --delete sanctum.exe sync --path "C:\Pfad\tresor.sanctum" "C:\Users\User\Dokumente" /Dokumente --delete
# 3b. Radikales Löschen inklusive ausgeschlossener Pfade:
sanctum.exe sync --path "C:\Pfad\tresor.sanctum" "C:\Users\User\Dokumente" /Dokumente --delete --delete-excluded
# 4. Mit kryptografischer Inhaltsprüfung (SHA-256 Hashes vergleichen): # 4. Mit kryptografischer Inhaltsprüfung (SHA-256 Hashes vergleichen):
sanctum.exe sync --path "C:\Pfad\tresor.sanctum" "C:\Users\User\Projekte" /Projekte --checksum sanctum.exe sync --path "C:\Pfad\tresor.sanctum" "C:\Users\User\Projekte" /Projekte --checksum
+90
View File
@@ -134,6 +134,13 @@ enum Commands {
recovery_key: Option<String>, recovery_key: Option<String>,
}, },
/// Erneuert den internen Verschlüsselungsschlüssel (DEK), verschlüsselt alle Chunks um und generiert ein neues Notfallblatt (K-03)
Rekey {
/// Pfad zur .sanctum Containerdatei
#[arg(short, long)]
path: PathBuf,
},
/// Erstellt ein konsistentes Online-Backup (Hot-Backup) des laufenden Containers /// Erstellt ein konsistentes Online-Backup (Hot-Backup) des laufenden Containers
Backup { Backup {
/// Pfad zur Quelldatei (.sanctum Containerdatei) /// Pfad zur Quelldatei (.sanctum Containerdatei)
@@ -879,6 +886,86 @@ fn handle_passwd(container_path: &Path, recovery_key: Option<&str>) -> Result<()
println!(" • KDF: Argon2id mit frischem Salt"); println!(" • KDF: Argon2id mit frischem Salt");
println!(" • Status: DEK sicher neu verpackt (alle Chunks intakt)"); println!(" • Status: DEK sicher neu verpackt (alle Chunks intakt)");
println!(); println!();
println!(" [!] HINWEIS: Das 24-Wörter-Notfallblatt bleibt weiterhin gültig!");
println!(" Um den Container vollständig mit neuem Master-Schlüssel zu sichern,");
println!(" nutzen Sie `sanctum rekey`.");
println!();
Ok(())
}
fn handle_rekey(container_path: &Path) -> Result<()> {
if !container_path.exists() {
bail!(
"Containerdatei '{}' existiert nicht.",
container_path.display()
);
}
println!("┌─────────────────────────────────────────────────────────────┐");
println!("│ Sanctum — Master-Schlüssel erneuern (Rekeying) │");
println!("└─────────────────────────────────────────────────────────────┘");
println!(" Container: {}", container_path.display());
println!();
println!(" [!] WICHTIGER SICHERHEITSHINWEIS (K-03):");
println!(" Beim Rekeying wird der interne Daten-Verschlüsselungsschlüssel (DEK)");
println!(" neu generiert und alle Datenblöcke werden umverschlüsselt.");
println!(" Das bisherige 24-Wörter-Notfallblatt wird damit UNWIDERRUFLICH UNGÜLTIG.");
println!();
let password = Zeroizing::new(
rpassword::prompt_password("Master-Passwort des Containers eingeben: ")
.context("Fehler beim Einlesen des Master-Passworts")?,
);
if password.trim().is_empty() {
bail!("Das Master-Passwort darf nicht leer sein.");
}
println!();
ui::step(
1,
3,
"🔄",
"Entschlüssele Datenblöcke & verschlüssele mit neuem DEK...",
);
let new_phrase = sanctum::recovery::rekey_container(container_path, &password)
.context("Fehler beim Ausführen des Rekeyings")?;
ui::step(
2,
3,
"🔒",
"Aktualisiere Container-Header & Metadaten-Authentifizierung...",
);
ui::step(3, 3, "🔑", "Generiere neues 24-Wort BIP-39 Notfallblatt...");
println!();
println!("┌─────────────────────────────────────────────────────────────┐");
println!("│ ✔ Container erfolgreich rekeyed! │");
println!("└─────────────────────────────────────────────────────────────┘");
println!();
println!(" • Container: {}", container_path.display());
println!(" • Status: Alle Chunks wurden unter einem frischen DEK neu verschlüsselt.");
println!(" • Alt-Schlüssel:Bisheriges Notfallblatt ist ab sofort ungültig und entwertet.");
println!();
println!("┌─────────────────────────────────────────────────────────────┐");
println!("│ NEUES 24-WORT BIP-39 NOTFALLBLATT (NOTFALL-WIEDERHERSTELLUNG)│");
println!("└─────────────────────────────────────────────────────────────┘");
println!();
let words: Vec<&str> = new_phrase.split_whitespace().collect();
for (i, chunk) in words.chunks(4).enumerate() {
let line = chunk
.iter()
.enumerate()
.map(|(j, w)| format!("{:2}. {:<12}", i * 4 + j + 1, w))
.collect::<Vec<_>>()
.join(" ");
println!(" {}", line);
}
println!();
println!(" [!] WICHTIG: Drucken Sie diese Wörter aus oder notieren Sie sie sicher.");
println!(" Das alte Notfallblatt kann nicht mehr zur Wiederherstellung verwendet werden.");
println!();
Ok(()) Ok(())
} }
@@ -1573,6 +1660,9 @@ async fn run() -> Result<()> {
Commands::UpgradeFormat { path } => { Commands::UpgradeFormat { path } => {
handle_upgrade_format(&path)?; handle_upgrade_format(&path)?;
} }
Commands::Rekey { path } => {
handle_rekey(&path)?;
}
Commands::Sync { Commands::Sync {
path, path,
source, source,
+67
View File
@@ -427,6 +427,73 @@ pub fn restore_header_from_recovery_key(
restore_slot_from_recovery_key(container_path, recovery_key, new_password, 0, None) restore_slot_from_recovery_key(container_path, recovery_key, new_password, 0, None)
} }
/// Führt ein vollständiges Rekeying eines Containers durch (K-03).
/// Generiert einen neuen kryptografischen DEK, verschlüsselt alle Chunks um,
/// verpackt den neuen DEK mit dem bestehenden Passwort und gibt das neue
/// 24-Wort Notfallblatt (BIP-39 Mnemonic) zurück.
/// Das alte Notfallblatt wird dadurch unwiderruflich ungültig.
pub fn rekey_container(container_path: &Path, password: &str) -> Result<String> {
if !container_path.exists() {
bail!(
"Containerdatei '{}' existiert nicht.",
container_path.display()
);
}
let db = Database::open(container_path).context("Konnte Container-Datenbank nicht öffnen")?;
let meta = db
.read_meta()
.context("Konnte Container-Header nicht lesen")?;
let keys = meta.authenticate(password).ok_or_else(|| {
anyhow::anyhow!("Ungültiges Master-Passwort! Authentifizierung fehlgeschlagen.")
})?;
let old_dek = keys.dek().clone();
let slot_id = keys.slot_id();
let version = keys.version();
let carrier_dek = keys.carrier_dek();
let carrier_node_id = keys.carrier_node_id();
// 1. Neuen DEK generieren
let new_dek = crate::crypto::generate_dek();
// 2. Chunks des aktiven Vaults umverschlüsseln
db.rekey_vault(slot_id, &old_dek, &new_dek, version)?;
// 3. Neuen KEK ableiten und DEK neu verpacken
let new_salt = crate::crypto::generate_salt();
let new_params = KdfParams::default();
let new_kek = derive_kek(password, &new_salt, &new_params)?;
let (new_wrapped_dek, new_nonce, new_tag) = if slot_id == 1 {
let c_dek = carrier_dek
.ok_or_else(|| anyhow::anyhow!("DEK_0 (Träger-Schlüssel) für Slot 1 fehlt"))?;
let c_nid = carrier_node_id.unwrap_or(0);
wrap_slot1_payload(&new_kek, &new_dek, &c_dek, c_nid)?
} else {
let c_nid = carrier_node_id.unwrap_or(0);
wrap_slot0_payload(&new_kek, &new_dek, c_nid)?
};
db.update_slot_keys(
slot_id,
&new_salt,
&new_params,
&new_wrapped_dek,
&new_nonce,
&new_tag,
)?;
// 4. Metadaten-MAC mit neuem DEK aktualisieren
db.set_active_slot_and_dek(slot_id, new_dek.clone());
db.update_metadata_mac()?;
db.checkpoint()?;
// 5. Neues BIP-39 Notfallblatt generieren
let new_phrase = crate::crypto::dek_to_mnemonic(&new_dek)?;
Ok(new_phrase)
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
+128 -4
View File
@@ -1128,6 +1128,126 @@ impl Database {
} }
} }
/// Verschlüsselt alle Chunks eines Vaults mit einem neuen DEK um (K-03 Rekeying).
pub fn rekey_vault(
&self,
slot_id: u32,
old_dek: &[u8; 32],
new_dek: &[u8; 32],
version: u32,
) -> Result<()> {
let mut conn = self.conn.lock().unwrap();
let tx = conn.transaction()?;
// Ermittle alle Node-IDs dieses Vaults
let node_ids: Vec<i64> = if slot_id == 1 {
let mut stmt = tx.prepare(
"WITH RECURSIVE vault1(id) AS (
SELECT 2
UNION ALL
SELECT n.id FROM nodes n JOIN vault1 v ON n.parent_id = v.id
) SELECT id FROM vault1",
)?;
let rows = stmt.query_map([], |r| r.get(0))?;
rows.filter_map(|r| r.ok()).collect()
} else {
let mut stmt = tx.prepare(
"WITH RECURSIVE vault0(id) AS (
SELECT 1
UNION ALL
SELECT n.id FROM nodes n JOIN vault0 v ON n.parent_id = v.id
) SELECT id FROM vault0",
)?;
let rows = stmt.query_map([], |r| r.get(0))?;
rows.filter_map(|r| r.ok()).collect()
};
// Option B: Falls Slot 1, verschlüsselte Dateinamen unter parent_id = 2 umverschlüsseln
if slot_id == 1 {
let mut name_stmt =
tx.prepare("SELECT id, parent_id, name FROM nodes WHERE parent_id = 2")?;
let node_rows: Vec<(i64, i64, String)> = name_stmt
.query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))?
.filter_map(|r| r.ok())
.collect();
drop(name_stmt);
for (id, parent_id, enc_name) in node_rows {
if let Some(decrypted_name) =
crate::crypto::decrypt_node_name(old_dek, parent_id, &enc_name)
{
let new_enc =
crate::crypto::encrypt_node_name(new_dek, parent_id, &decrypted_name);
tx.execute(
"UPDATE nodes SET name = ?1 WHERE id = ?2",
params![new_enc, id],
)?;
}
}
}
// Chunks der betroffenen Nodes umverschlüsseln
for nid in node_ids {
let mut chunk_stmt = tx.prepare(
"SELECT chunk_index, generation, nonce, tag, ciphertext FROM chunks WHERE node_id = ?1",
)?;
let chunk_rows: Vec<(u32, u64, [u8; 12], [u8; 16], Vec<u8>)> = chunk_stmt
.query_map(params![nid], |row| {
let chunk_index: u32 = row.get(0)?;
let generation: i64 = row.get(1).unwrap_or(0);
let nonce_vec: Vec<u8> = row.get(2)?;
let tag_vec: Vec<u8> = row.get(3)?;
let ciphertext: Vec<u8> = row.get(4)?;
let mut nonce = [0u8; 12];
let mut tag = [0u8; 16];
if nonce_vec.len() == 12 {
nonce.copy_from_slice(&nonce_vec);
}
if tag_vec.len() == 16 {
tag.copy_from_slice(&tag_vec);
}
Ok((chunk_index, generation as u64, nonce, tag, ciphertext))
})?
.filter_map(|r| r.ok())
.collect();
drop(chunk_stmt);
for (chunk_index, generation, nonce, tag, ct) in chunk_rows {
let plaintext = crate::crypto::decrypt_chunk(
old_dek,
nid,
chunk_index,
&ct,
&nonce,
&tag,
version,
generation,
)?;
let (new_ct, new_nonce, new_tag) = crate::crypto::encrypt_chunk(
new_dek,
nid,
chunk_index,
&plaintext,
version,
generation,
)?;
tx.execute(
"UPDATE chunks SET nonce = ?1, tag = ?2, ciphertext = ?3 WHERE node_id = ?4 AND chunk_index = ?5",
params![
new_nonce.as_slice(),
new_tag.as_slice(),
new_ct,
nid,
chunk_index,
],
)?;
}
}
tx.commit()?;
Ok(())
}
/// Löst einen hierarchischen Pfad innerhalb eines bestimmten Vaults auf. /// Löst einen hierarchischen Pfad innerhalb eines bestimmten Vaults auf.
pub fn resolve_path_in_vault( pub fn resolve_path_in_vault(
&self, &self,
@@ -1824,7 +1944,12 @@ impl Database {
let canonical = self.canonical_nodes_bytes_for_vault(slot_id)?; let canonical = self.canonical_nodes_bytes_for_vault(slot_id)?;
let mac_key = derive_metadata_mac_key(dek); let mac_key = derive_metadata_mac_key(dek);
Ok(verify_metadata_mac(&mac_key, gen, &canonical, &expected_mac)) Ok(verify_metadata_mac(
&mac_key,
gen,
&canonical,
&expected_mac,
))
} }
/// Führt ein Upgrade des Containerformats auf Format V3 durch (Format V3 / K-01 & K-02). /// Führt ein Upgrade des Containerformats auf Format V3 durch (Format V3 / K-01 & K-02).
@@ -1852,9 +1977,8 @@ impl Database {
// K-02: Alle bestehenden Chunks von alter 16-Byte-AAD auf Format V3 24-Byte-AAD (generation = 0) umverschlüsseln // K-02: Alle bestehenden Chunks von alter 16-Byte-AAD auf Format V3 24-Byte-AAD (generation = 0) umverschlüsseln
{ {
let mut chunk_stmt = conn.prepare( let mut chunk_stmt =
"SELECT node_id, chunk_index, nonce, tag, ciphertext FROM chunks", conn.prepare("SELECT node_id, chunk_index, nonce, tag, ciphertext FROM chunks")?;
)?;
let chunk_rows: Vec<(i64, u32, [u8; 12], [u8; 16], Vec<u8>)> = chunk_stmt let chunk_rows: Vec<(i64, u32, [u8; 12], [u8; 16], Vec<u8>)> = chunk_stmt
.query_map([], |row| { .query_map([], |row| {
let node_id: i64 = row.get(0)?; let node_id: i64 = row.get(0)?;
+230 -1
View File
@@ -13,7 +13,10 @@ use sanctum::{
wrap_dek, KdfParams, CHUNK_SIZE, FORMAT_VERSION, FORMAT_VERSION_V1, MIN_MEMORY_COST_KIB, wrap_dek, KdfParams, CHUNK_SIZE, FORMAT_VERSION, FORMAT_VERSION_V1, MIN_MEMORY_COST_KIB,
MIN_TIME_COST, MIN_TIME_COST,
}, },
recovery::{export_header_backup, restore_header_backup, restore_header_from_recovery_key}, recovery::{
export_header_backup, rekey_container, restore_header_backup,
restore_header_from_recovery_key,
},
storage::Database, storage::Database,
verify::verify_container, verify::verify_container,
vfs::SanctumFs, vfs::SanctumFs,
@@ -1561,3 +1564,229 @@ async fn test_webdav_loopback_session_token_and_host_validation() {
let _ = server_handle.await; let _ = server_handle.await;
let _ = std::fs::remove_file(&container_path); let _ = std::fs::remove_file(&container_path);
} }
#[tokio::test]
async fn test_k03_passwd_sheet_remains_valid_and_rekey_revokes_old_sheet() {
let temp_dir = std::env::temp_dir();
let container_path: PathBuf = temp_dir.join(format!(
"test_sanctum_k03_{}_{}.sanctum",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
if container_path.exists() {
let _ = std::fs::remove_file(&container_path);
}
let password_v1 = "InitialPassword2026!";
let password_v2 = "ChangedPassword2026!";
// 1. Initialisiere Container
let salt = generate_salt();
let kdf_params = KdfParams {
memory_cost: MIN_MEMORY_COST_KIB,
time_cost: MIN_TIME_COST,
parallelism: 1,
};
let kek = derive_kek(password_v1, &salt, &kdf_params).unwrap();
let dek = generate_dek();
let (wrapped_dek, header_nonce, header_tag) = wrap_dek(&kek, &dek).unwrap();
let db = Database::open(&container_path).unwrap();
db.init_schema(&salt, &kdf_params, &wrapped_dek, &header_nonce, &header_tag)
.unwrap();
db.checkpoint().unwrap();
// 2. Schreibe eine Testdatei
let fs = SanctumFs::new(db.clone(), dek.clone(), FORMAT_VERSION);
let secret_path = DavPath::new("/secret_doc.txt").unwrap();
let mut opts = OpenOptions::default();
opts.write = true;
opts.create_new = true;
let mut f = fs.open(&secret_path, opts).await.unwrap();
let secret_data = b"Confidential financial data protected by Sanctum K-03.";
f.write_buf(Box::new(Bytes::from_static(secret_data)))
.await
.unwrap();
drop(f);
drop(fs);
db.checkpoint().unwrap();
// Initiales Notfallblatt
let initial_mnemonic = dek_to_mnemonic(&dek).unwrap();
// 3. Simuliere normales Passwort-Ändern (sanctum passwd)
// Bei passwd wird der DEK beibehalten, nur mit neuem KEK verpackt
let new_salt = generate_salt();
let new_kek = derive_kek(password_v2, &new_salt, &kdf_params).unwrap();
let (new_wrapped_dek, new_nonce, new_tag) =
sanctum::crypto::wrap_slot0_payload(&new_kek, &dek, 0).unwrap();
db.update_slot_keys(
0,
&new_salt,
&kdf_params,
&new_wrapped_dek,
&new_nonce,
&new_tag,
)
.unwrap();
db.checkpoint().unwrap();
// Verifiziere: Das ursprüngliche Notfallblatt ist WEITERHIN gültig nach normalem passwd!
// (Beweis für Finding K-03, weshalb Warnung + Rekeying notwendig sind)
{
// Lösche Header und stelle mit initial_mnemonic + temp password wieder her
let conn = rusqlite::Connection::open(&container_path).unwrap();
conn.execute("DELETE FROM meta", []).unwrap();
drop(conn);
restore_header_from_recovery_key(&container_path, &initial_mnemonic, "TempPass2026!")
.expect("Altes Notfallblatt muss nach passwd weiterhin funktionieren");
let r_db = Database::open(&container_path).unwrap();
let r_meta = r_db.read_meta().unwrap();
let r_kek = derive_kek("TempPass2026!", &r_meta.kdf_salt, &r_meta.kdf_params).unwrap();
let r_dek = unwrap_dek(
&r_kek,
&r_meta.wrapped_dek,
&r_meta.header_nonce,
&r_meta.header_tag,
)
.unwrap();
assert_eq!(
*dek, *r_dek,
"DEK muss nach normalem passwd identisch geblieben sein"
);
let r_fs = SanctumFs::new(r_db.clone(), r_dek, r_meta.version);
let mut r_opts = OpenOptions::default();
r_opts.read = true;
let mut r_f = r_fs.open(&secret_path, r_opts).await.unwrap();
let read_bytes = r_f.read_bytes(secret_data.len()).await.unwrap();
assert_eq!(&read_bytes[..], secret_data);
}
// Setze Header wieder auf password_v2
let fresh_salt = generate_salt();
let fresh_kek = derive_kek(password_v2, &fresh_salt, &kdf_params).unwrap();
let (fresh_wrapped, fresh_nonce, fresh_tag) =
sanctum::crypto::wrap_slot0_payload(&fresh_kek, &dek, 0).unwrap();
db.update_slot_keys(
0,
&fresh_salt,
&kdf_params,
&fresh_wrapped,
&fresh_nonce,
&fresh_tag,
)
.unwrap();
db.checkpoint().unwrap();
// 4. Jetzt: Führe echtes Rekeying aus (sanctum rekey)
let rekeyed_mnemonic = rekey_container(&container_path, password_v2)
.expect("Rekeying muss erfolgreich durchlaufen");
assert_ne!(
initial_mnemonic, rekeyed_mnemonic,
"Neues Notfallblatt muss sich unterscheiden"
);
// 5. Container lässt sich mit neuem Passwort öffnen und Daten sind intakt
let rekeyed_db = Database::open(&container_path).unwrap();
let rekeyed_meta = rekeyed_db.read_meta().unwrap();
let rekeyed_kek = derive_kek(
password_v2,
&rekeyed_meta.kdf_salt,
&rekeyed_meta.kdf_params,
)
.unwrap();
let rekeyed_dek = unwrap_dek(
&rekeyed_kek,
&rekeyed_meta.wrapped_dek,
&rekeyed_meta.header_nonce,
&rekeyed_meta.header_tag,
)
.unwrap();
assert_ne!(
*dek, *rekeyed_dek,
"DEK muss nach Rekeying erneuert worden sein"
);
let rekeyed_fs = SanctumFs::new(
rekeyed_db.clone(),
rekeyed_dek.clone(),
rekeyed_meta.version,
);
let mut check_opts = OpenOptions::default();
check_opts.read = true;
let mut check_f = rekeyed_fs.open(&secret_path, check_opts).await.unwrap();
let rekeyed_data = check_f.read_bytes(secret_data.len()).await.unwrap();
assert_eq!(
&rekeyed_data[..],
secret_data,
"Daten müssen nach Rekeying vollständig intakt sein"
);
drop(check_f);
drop(rekeyed_fs);
// Integritätsprüfung nach Rekeying
let report = verify_container(&container_path, Some(&rekeyed_dek), true).unwrap();
assert!(
report.is_healthy(),
"Container muss nach Rekeying gesunden Status aufweisen"
);
assert_eq!(report.corrupted_chunks, 0);
// 6. Altes Notfallblatt ist entwertet: Kann umverschlüsselte Chunks NICHT mehr entschlüsseln!
{
let conn = rusqlite::Connection::open(&container_path).unwrap();
conn.execute("DELETE FROM meta", []).unwrap();
drop(conn);
// Versuch, mit altem Notfallblatt wiederherzustellen
restore_header_from_recovery_key(&container_path, &initial_mnemonic, "OldKeyPassword!")
.unwrap();
let old_res_db = Database::open(&container_path).unwrap();
let old_res_meta = old_res_db.read_meta().unwrap();
let old_res_kek = derive_kek(
"OldKeyPassword!",
&old_res_meta.kdf_salt,
&old_res_meta.kdf_params,
)
.unwrap();
let old_res_dek = unwrap_dek(
&old_res_kek,
&old_res_meta.wrapped_dek,
&old_res_meta.header_nonce,
&old_res_meta.header_tag,
)
.unwrap();
// Entschlüsselung der Datei mit dem alten DEK muss scheitern (AEAD tag mismatch)
let old_fs = SanctumFs::new(
old_res_db.clone(),
old_res_dek.clone(),
old_res_meta.version,
);
let mut fail_opts = OpenOptions::default();
fail_opts.read = true;
let mut fail_f = old_fs.open(&secret_path, fail_opts).await.unwrap();
let fail_res = fail_f.read_bytes(secret_data.len()).await;
assert!(
fail_res.is_err(),
"Altes Notfallblatt darf nach Rekeying Chunks nicht mehr entschlüsseln können!"
);
// Auch verify_container mit altem DEK meldet Korruption / MAC-Fehlschlag
let fail_report = verify_container(&container_path, Some(&old_res_dek), true).unwrap();
assert!(
!fail_report.is_healthy(),
"Prüfung mit altem Notfallschlüssel muss Fehler melden"
);
}
// Aufräumen
let _ = std::fs::remove_file(&container_path);
}