fix(crypto): K-03 — emergency card persistence warning and rekey command
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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).*
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user