Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
02f731f57d | ||
|
|
728ac008ae | ||
|
|
8ff426d1b0 | ||
|
|
efddc2c477 | ||
|
|
dd6b6ff94d | ||
|
|
c21d63ad28 | ||
|
|
c606fa8132 | ||
|
|
8455dc02db |
@@ -5,6 +5,34 @@ 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/)
|
||||
und dieses Projekt folgt den Richtlinien von [Semantic Versioning](https://semver.org/lang/de/).
|
||||
|
||||
## [0.8.1] - 2026-09-19
|
||||
|
||||
### Carrier-Manifest-Resilienz & Storage-Hygiene (Dritte Audit-Runde)
|
||||
Dieses Release behebt sechs Findings aus der dritten Audit-Runde in `carrier.rs` (steganografisches Dateisystem des Hidden Vault) und `storage.rs`, schließt alle verbleibenden Punkte und erreicht 39/39 (100%) behobene Audit-Findings.
|
||||
|
||||
#### Steganografischer Hidden Vault & Carrier-Dateisystem (C-02, C-03, C-04)
|
||||
- **C-02 (HOCH): Rollierendes Dual-Block-Manifest & Failover-Recovery**:
|
||||
- Beseitigung des Single Point of Failure: Das Dateisystem-Manifest wird nun abwechselnd auf Block 0 und Block 1 geschrieben (`manifest_generation: u64`).
|
||||
- Beim Mounten liest Sanctum beide Blöcke, wählt die neuere Generation und stellt den Tresor selbst bei Beschädigung (z. B. Bitrot) eines Blocks transparent wieder her.
|
||||
- Vollständige Abwärtskompatibilität: Block 1 wird für bestehende Container automatisch reserviert.
|
||||
- **C-03 (MITTEL): Entkopplung der Manifest-Neuverschlüsselung via Dirty-Tracking**:
|
||||
- Metadatenänderungen (`create_dir`, `create_file`, `remove_file`, `remove_dir`, `rename`, `copy`, `drop`) markieren das Manifest als `dirty`, anstatt jedes Mal synchron 1 MB per AES-256-GCM neu zu verschlüsseln.
|
||||
- Synchrone Sicherung erfolgt gebündelt bei Datei-`flush()`, unmount/drop oder nach 50 Operationen.
|
||||
- Gewährleistet massive Performancesteigerung bei Massenoperationen im Hidden Vault.
|
||||
- **C-04 (NIEDRIG): Kapazitätsgrenze dokumentiert & Warnung bei Füllstand**:
|
||||
- Dokumentation der Obergrenze (~7.000 Dateien/Verzeichnisse pro 1-MB-Manifest-Block) in `README.md` und `QUICKSTART.md`.
|
||||
- Automatische Warnung beim Mounten und Speichern, wenn das Manifest mehr als 80% Füllstand (~838 KB) erreicht.
|
||||
|
||||
#### Storage-Hygiene & Fehlerbehandlung (ST-01, ST-02, ST-03)
|
||||
- **ST-01 (MITTEL): Fail-Closed bei Entschlüsselungsfehlern von Knotennamen**:
|
||||
- Beseitigung des Hex-Ciphertext-Leaks (`unwrap_or(enc_name)`): Bei Entschlüsselungsfehlern (z. B. Bitrot oder unlesbare Knoten) werden betroffene Einträge in `list_children_in_vault`, `resolve_path_in_vault` und `get_node_by_id_in_vault` übersprungen und geloggt (`warn!`), anstatt rohe Hex-Strings auszuliefern.
|
||||
- **ST-02 (NIEDRIG): Fehleraggregation bei rekursivem Löschen**:
|
||||
- `delete_node` verschluckt Fehler bei Kindknoten nicht mehr (`let _ =`).
|
||||
- Fehler werden gesammelt, protokolliert (`error!`) und führen zum Abbruch mit detaillierter Zusammenfassung, wodurch verwaiste Knoten (Orphans) verhindert werden.
|
||||
- **ST-03 (NIEDRIG): Atomare SQLite-Transaktion für rekursives Löschen**:
|
||||
- Das gesamte rekursive Löschen eines Baumes (inklusive kryptografischem Chunk-Shredding) wird nun in einer einzigen atomaren SQLite-Transaktion ausgeführt.
|
||||
- Schlägt das Löschen eines Teilbaums fehl, wird ein automatischer Rollback ausgeführt; Teilbaumlöschungen und inkonsistente Zustände sind ausgeschlossen.
|
||||
|
||||
## [0.8.0] - 2026-09-19
|
||||
|
||||
### Security Audit Remediation & Format V3 Release
|
||||
|
||||
Generated
+1
-1
@@ -1457,7 +1457,7 @@ checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
|
||||
|
||||
[[package]]
|
||||
name = "sanctum"
|
||||
version = "0.8.0"
|
||||
version = "0.8.1"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"anyhow",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "sanctum"
|
||||
version = "0.8.0"
|
||||
version = "0.8.1"
|
||||
edition = "2021"
|
||||
authors = ["Harald Pansi <harald@pansi.eu>", "Sanctum Engineering Team"]
|
||||
description = "Verschlüsselter Ein-Datei-Container unter Windows im reinen Userland via WebDAV"
|
||||
|
||||
@@ -37,6 +37,7 @@ Beim Erstellen eines Tresors wählst du zwischen zwei Sicherheitsstufen:
|
||||
1. **Decoy-Passwort**: Öffnet den äußeren Safe (enthält eine scheinbare Backup-Datei `system_backup.dat`).
|
||||
2. **Hidden-Passwort**: Öffnet den geheimen Zweittresor (Second Safe).
|
||||
* *(Hinweis: Schützt vor Schulterblick im Alltag; keine forensische Abstreitbarkeit gegen behördliche Entropieanalyse).*
|
||||
* **Kapazitätsgrenze (C-04)**: Empfohlene Obergrenze ca. 7.000 Dateien/Verzeichnisse im Hidden Vault (~1 MB Manifest-Block). Sanctum warnt beim Mounten automatisch bei über 80% Füllstand.
|
||||
* **Wichtig**: Notiere dir die ausgegebenen **24 Wörter des Notfallschlüssels (BIP-39)** auf der untenstehenden Notfallkarte!
|
||||
|
||||
---
|
||||
|
||||
@@ -37,6 +37,7 @@ Sanctum ist eine eigenständige, speichersichere und hochperformante CLI-Anwendu
|
||||
Als Authenticated Associated Data (AAD) werden `node_id` (8 Bytes LE) und `chunk_index` (8 Bytes LE) an jeden Block gebunden. Ein Vertauschen von Chunks zwischen Dateien oder innerhalb einer Datei führt zum Authentifizierungsfehler.
|
||||
- **Dual-Vault (Multi-Slot & Carrier)**:
|
||||
Konstante 2-Slot-Architektur. Slot 0 dient als Standard-/Decoy-Vault, Slot 1 als Second Safe (Hidden Vault) oder CSPRNG-Dummy. Dient dem Schutz vor neugierigen Blicken oder beiläufigem Zwang im Alltag. (Hinweis: Die Trägerdatei besitzt hohe Entropie und ist forensisch nachweisbar; kein Anspruch auf juristisch unnachweisbare Abstreitbarkeit gegen behördliche Beschlagnahme).
|
||||
*Kapazitätsgrenze des Hidden Vault (C-04)*: Das Dateisystem des Hidden Vaults verwaltet seine Inodes in einem dual-block rollierenden Manifest (~1 MB pro Block). Daraus ergibt sich eine empfohlene Obergrenze von ca. 7.000 Dateien und Verzeichnissen im Hidden Vault. Bei Überschreiten von 80% Füllstand (~838 KB) warnt Sanctum beim Einbinden automatisch vor Erreichen der Blockgrenze.
|
||||
- **Dateinamen-Verschlüsselung**:
|
||||
Dateinamen im Hidden Vault werden mit frischen CSPRNG-Nonces und AES-256-GCM verschlüsselt in der Datenbank gespeichert (Legacy-Kompatibilität über `--legacy-names`).
|
||||
- **Kryptografisches Chunk-Shredding (Logisches Löschen vs. Physikalische Bereinigung)**:
|
||||
|
||||
+29
-7
@@ -1,16 +1,17 @@
|
||||
# Sanctum Security Audit & Remediation Log (v0.8.0)
|
||||
# Sanctum Security Audit & Remediation Log (v0.8.1)
|
||||
|
||||
Dieses Dokument fasst alle 33 Findings aus zwei umfassenden externen Sicherheitsaudits zusammen, dokumentiert die angewandten Härtungsmaßnahmen, referenziert die jeweiligen Git-Commits und benennt die zugehörigen automatisierten Regressionstests.
|
||||
Dieses Dokument fasst alle 39 Findings aus drei umfassenden externen Sicherheitsaudits zusammen, dokumentiert die angewandten Härtungsmaßnahmen, referenziert die jeweiligen Git-Commits und benennt die zugehörigen automatisierten Regressionstests.
|
||||
|
||||
---
|
||||
|
||||
## Audit-Zusammenfassung
|
||||
|
||||
- **Zielversion:** Sanctum v0.8.0
|
||||
- **Behobene Findings:** 33 / 33 (100%)
|
||||
- **Test-Ergebnis:** 117 / 117 Tests erfolgreich (100% Pass Rate)
|
||||
- **Zielversion:** Sanctum v0.8.1
|
||||
- **Behobene Findings:** 39 / 39 (100%)
|
||||
- **Test-Ergebnis:** 123 / 123 Tests erfolgreich (100% Pass Rate)
|
||||
- **Quality Gates:** `cargo fmt --check` (100% sauber), `cargo clippy --all-targets -- -D warnings` (0 Warnungen)
|
||||
- **Container-Format:** Upgrade auf Container-Format V3 mit kanonischer Metadaten-Authentifizierung (HMAC-SHA256) und Chunk-Replay-Schutz (Generation-gebundenes AAD).
|
||||
- **Hidden Vault & Storage-Resilienz:** Dual-Block rollierendes Manifest (C-02), Manifest-Entkopplung mit Dirty-Tracking (C-03), Kapazitäts-Warnung (C-04), Fail-Closed Node-Name-Decryption (ST-01), rekursive Fehleraggregation (ST-02) und atomare SQLite-Transaktionen für Baum-Löschungen (ST-03).
|
||||
|
||||
---
|
||||
|
||||
@@ -18,6 +19,12 @@ Dieses Dokument fasst alle 33 Findings aus zwei umfassenden externen Sicherheits
|
||||
|
||||
| Finding | Priorität | Modul | Kurzbeschreibung | Commit | Status & Regressionstest |
|
||||
|---|---|---|---|---|---|
|
||||
| **C-02** | HOCH | `carrier` | Single Point of Failure beseitigt: Rollierendes Dual-Block-Manifest (Block 0 & 1, `manifest_generation`) mit transparenter Failover-Wiederherstellung | `8455dc0` | ✅ Bestanden (`test_c02_dual_block_rolling_manifest_redundancy_and_recovery`) |
|
||||
| **C-03** | MITTEL | `carrier`, `vfs`, `mount` | Entkopplung der Manifest-Neuverschlüsselung via Dirty-Tracking; synchrone Sicherung bei File-Flush und Unmount | `c606fa8` | ✅ Bestanden (`test_c03_manifest_dirty_decoupling_and_unmount_flush`) |
|
||||
| **C-04** | NIEDRIG | `carrier`, `mount`, `doc` | Dokumentation der Hidden-Vault-Kapazität (~7.000 Inodes) & automatische 80%-Füllstandswarnung beim Mounten | `c21d63a` | ✅ Bestanden (`test_c04_manifest_capacity_limit_and_warning`) |
|
||||
| **ST-01** | MITTEL | `storage` | Fail-Closed bei Knotennamen-Entschlüsselung; Beseitigung von Hex-Ciphertext-Leaks bei Bitrot/Korruption | `dd6b6ff` | ✅ Bestanden (`test_st01_fail_closed_name_decryption_no_hex_leak`) |
|
||||
| **ST-02** | NIEDRIG | `storage` | Fehleraggregation beim rekursiven Löschen in `delete_node` ohne stilles Verschlucken | `efddc2c` | ✅ Bestanden (`test_st02_recursive_delete_error_propagation_not_swallowed`) |
|
||||
| **ST-03** | NIEDRIG | `storage` | Atomare SQLite-Transaktion (`tx.commit()` / Rollback) beim rekursiven Löschen in `delete_node` | `8ff426d` | ✅ Bestanden (`test_st03_recursive_delete_transaction_rollback`) |
|
||||
| **S-01** | HOCH | `sync` | `--delete` löscht keine ausgeschlossenen Pfade mehr; Schutz ganzer Verzeichnisbäume; `--delete-excluded` eingeführt | `374150e` | ✅ Bestanden (`test_s01_delete_preserves_excluded_files_and_directories`, `test_s01_pull_delete_preserves_local_excluded_and_leak_files`) |
|
||||
| **V-02** | HOCH | `vfs` | Fehlender Chunk innerhalb erwarteter Dateigröße löst E/A-Fehler aus statt stiller Dateikürzung | `5337644` | ✅ Bestanden (`test_v02_missing_chunk_returns_error_instead_of_silent_truncation`) |
|
||||
| **V-01** | HOCH | `vfs`, `carrier` | `copy()` delegiert im Carrier-Modus korrekt an `CarrierFs` | `151fdba` | ✅ Bestanden (`test_v01_carrier_fs_copy_delegation_and_functionality`) |
|
||||
@@ -86,10 +93,25 @@ Dieses Dokument fasst alle 33 Findings aus zwei umfassenden externen Sicherheits
|
||||
- NTFS Alternate Data Streams (ADS): Alle Zugriffe mit `:` (z. B. `Zone.Identifier`).
|
||||
- Wildcards: `.trash*`.
|
||||
- Option `--anti-leak-list <FILE>` für benutzerdefinierte Ausschlussmuster.
|
||||
- Statistische Zählung aller blockierten Schreib-/Lesezugriffe und transparente Zusammenfassung beim Unmount.
|
||||
### 4. Carrier-Manifest-Resilienz & Storage-Hygiene (Dritte Audit-Runde)
|
||||
- **Rollierendes Dual-Block-Manifest (C-02):**
|
||||
- Das Carrier-Manifest wird abwechselnd auf Block 0 und Block 1 geschrieben, geschützt durch ein monotones Generation-Attribut (`manifest_generation: u64`).
|
||||
- Beim Mounten liest Sanctum beide Blöcke. Ist ein Block korrumpiert (z. B. Bitrot oder unvollständiges Schreiben), stellt Sanctum den Hidden Vault transparent aus der intakten Kopie wieder her.
|
||||
- **Entkopplung der Manifest-Neuverschlüsselung (C-03):**
|
||||
- Vor v0.8.1 wurde bei jeder Metadatenänderung (Dateierstellung, Ordnererstellung, Löschen, Umbenennen, Drop) das gesamte 1-MB-Manifest synchron per AES-256-GCM neu verschlüsselt.
|
||||
- v0.8.1 führt Dirty-Tracking ein: Änderungen markieren das Manifest als `dirty`. Die tatsächliche Verschlüsselung erfolgt gebündelt bei Datei-`flush()`, Unmount/Drop oder nach 50 Operationen.
|
||||
- **Kapazitätsgrenze & Warnschwelle (C-04):**
|
||||
- Durch den 1-MB-Block für das Manifest ergibt sich eine empfohlene Obergrenze von ~7.000 Inodes im Hidden Vault.
|
||||
- Ab 80% Füllstand (~838 KB) warnt Sanctum beim Mounten und Speichern unübersehbar.
|
||||
- **Fail-Closed bei Knotennamen (ST-01):**
|
||||
- Bei Fehlschlagen der Entschlüsselung von Knotennamen werden keine Hex-Ciphertext-Fragmente mehr exponiert (`unwrap_or(enc_name)` eliminiert).
|
||||
- Unlesbare Knoten werden mit `warn!` geloggt und in `list_children_in_vault`, `resolve_path_in_vault` und `get_node_by_id_in_vault` fail-closed übersprungen.
|
||||
- **Fehleraggregation & Transaktions-Rollback bei rekursivem Löschen (ST-02 & ST-03):**
|
||||
- Fehler beim Löschen von Kindknoten werden nicht mehr ignoriert (`let _ =` eliminiert), sondern aggregiert und protokolliert.
|
||||
- Das gesamte Löschen eines Baumes samt kryptografischem Chunk-Shredding läuft in einer atomaren SQLite-Transaktion. Schlägt ein Knoten fehl, wird der gesamte Vorgang per Rollback zurückgesetzt; verwaiste Knoten (Orphans) sind ausgeschlossen.
|
||||
|
||||
---
|
||||
|
||||
## Verifikationsnachweis
|
||||
|
||||
Alle 117 Unit- und Integrationstests wurden auf einem Windows x86_64 Host mit 100% Erfolgsquote ausgeführt. Die Release-Paketierung für Windows und Linux musl ist voll automatisiert und wird mit Minisign kryptografisch abgesichert.
|
||||
Alle Unit- und Integrationstests wurden auf einem Windows x86_64 Host mit 100% Erfolgsquote ausgeführt. Die Release-Paketierung für Windows und Linux musl ist voll automatisiert und wird mit Minisign kryptografisch abgesichert.
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"version": "0.8.0",
|
||||
"version": "0.8.1",
|
||||
"description": "Verschlüsselter Ein-Datei-Container unter Windows im reinen Userland via WebDAV",
|
||||
"homepage": "https://gitea.pansi.eu/harald/sanctum",
|
||||
"license": "MIT",
|
||||
"architecture": {
|
||||
"64bit": {
|
||||
"url": "https://gitea.pansi.eu/harald/sanctum/releases/download/v0.8.0/sanctum-v0.8.0-windows-x86_64.zip",
|
||||
"hash": "11fa00bdd0dd48599ee578202c09c86ccbb7b971932bf14c8b4d11817aa26419",
|
||||
"url": "https://gitea.pansi.eu/harald/sanctum/releases/download/v0.8.1/sanctum-v0.8.1-windows-x86_64.zip",
|
||||
"hash": "11f06952f743d625c1cda627df685064a91cd973e52b17e7bcaf96385bb4969c",
|
||||
"bin": "sanctum.exe"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# yaml-language-server: $schema=https://aka.ms/winget-manifest.singleton.1.6.0.schema.json
|
||||
PackageIdentifier: HaraldPansi.Sanctum
|
||||
PackageVersion: 0.8.0
|
||||
PackageVersion: 0.8.1
|
||||
PackageName: Sanctum
|
||||
Publisher: Harald Pansi
|
||||
PublisherUrl: https://gitea.pansi.eu/harald
|
||||
@@ -18,7 +18,7 @@ Tags:
|
||||
- webdav
|
||||
- container
|
||||
- plausible-deniability
|
||||
ReleaseNotesUrl: https://gitea.pansi.eu/harald/sanctum/releases/tag/v0.8.0
|
||||
ReleaseNotesUrl: https://gitea.pansi.eu/harald/sanctum/releases/tag/v0.8.1
|
||||
Installers:
|
||||
- Architecture: x64
|
||||
InstallerType: zip
|
||||
@@ -26,7 +26,7 @@ Installers:
|
||||
NestedInstallerFiles:
|
||||
- RelativeFilePath: sanctum.exe
|
||||
PortableCommandAlias: sanctum
|
||||
InstallerUrl: https://gitea.pansi.eu/harald/sanctum/releases/download/v0.8.0/sanctum-v0.8.0-windows-x86_64.zip
|
||||
InstallerSha256: 11fa00bdd0dd48599ee578202c09c86ccbb7b971932bf14c8b4d11817aa26419
|
||||
InstallerUrl: https://gitea.pansi.eu/harald/sanctum/releases/download/v0.8.1/sanctum-v0.8.1-windows-x86_64.zip
|
||||
InstallerSha256: 11f06952f743d625c1cda627df685064a91cd973e52b17e7bcaf96385bb4969c
|
||||
ManifestType: singleton
|
||||
ManifestVersion: 1.6.0
|
||||
|
||||
+196
-26
@@ -18,7 +18,7 @@ use futures_util::stream;
|
||||
use rand::rngs::OsRng;
|
||||
use rand::RngCore;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::{error, warn};
|
||||
use tracing::{debug, error, warn};
|
||||
use zeroize::{Zeroize, Zeroizing};
|
||||
|
||||
use crate::crypto::{decrypt_chunk, encrypt_chunk, CHUNK_SIZE};
|
||||
@@ -27,12 +27,16 @@ use crate::vfs::{is_leak_file_with_custom, SanctumDirEntry, SanctumMetaData};
|
||||
|
||||
pub const CARRIER_MAGIC: &[u8; 8] = b"SANCTCAR";
|
||||
pub const CARRIER_VERSION: u32 = 1;
|
||||
pub const CARRIER_MANIFEST_CAPACITY: usize = CHUNK_SIZE - 64; // 1,048,512 Bytes
|
||||
pub const CARRIER_MANIFEST_WARN_THRESHOLD: usize = CARRIER_MANIFEST_CAPACITY * 8 / 10; // 838,809 Bytes (80%, C-04)
|
||||
|
||||
/// Manifest für das steganografische Dateisystem innerhalb des Alibi-Carriers (Block 0).
|
||||
/// Manifest für das steganografische Dateisystem innerhalb des Alibi-Carriers (Block 0 und 1, C-02).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CarrierManifest {
|
||||
pub magic: [u8; 8],
|
||||
pub version: u32,
|
||||
#[serde(default)]
|
||||
pub manifest_generation: u64,
|
||||
pub total_blocks: u32,
|
||||
pub free_blocks: Vec<u32>,
|
||||
pub next_inode_id: i64,
|
||||
@@ -62,12 +66,13 @@ impl CarrierManifest {
|
||||
},
|
||||
);
|
||||
|
||||
// Block 0 ist für das Manifest reserviert. Nutzblöcke sind 1..total_blocks-1.
|
||||
let free_blocks = (1..total_blocks).collect();
|
||||
// Block 0 und Block 1 sind für das redundante Manifest reserviert (C-02). Nutzblöcke sind 2..total_blocks-1.
|
||||
let free_blocks = (2..total_blocks).collect();
|
||||
|
||||
Self {
|
||||
magic: *CARRIER_MAGIC,
|
||||
version: CARRIER_VERSION,
|
||||
manifest_generation: 0,
|
||||
total_blocks,
|
||||
free_blocks,
|
||||
next_inode_id: 2,
|
||||
@@ -274,6 +279,10 @@ pub struct CarrierFsInner {
|
||||
pub custom_leak_rules: Arc<Vec<String>>,
|
||||
pub leak_counter: Arc<AtomicU64>,
|
||||
pub manifest: CarrierManifest,
|
||||
pub manifest_dirty: bool,
|
||||
pub manifest_save_count: u64,
|
||||
pub dirty_ops_count: u32,
|
||||
pub last_manifest_save: u64,
|
||||
pub last_activity: Arc<AtomicU64>,
|
||||
}
|
||||
|
||||
@@ -281,26 +290,78 @@ impl CarrierFsInner {
|
||||
pub fn is_leak(&self, name: &str) -> bool {
|
||||
self.anti_leak && is_leak_file_with_custom(name, &self.custom_leak_rules)
|
||||
}
|
||||
|
||||
/// Markiert das Manifest als modifiziert (dirty) ohne teure synchrone Neuverschlüsselung (C-03).
|
||||
/// Erst bei flush(), close(), unmount/drop oder nach Überschreiten von 50 Operationen wird
|
||||
/// das vollständige 1-MB-Manifest tatsächlich neu verschlüsselt und geschrieben.
|
||||
///
|
||||
/// Konsistenzhinweis:
|
||||
/// Bei einem abrupten Prozessabbruch gilt der zuletzt gespeicherte Zustand. Dank C-02
|
||||
/// (rollierendes Manifest auf Block 0 und Block 1) ist die Integrität stets gewährleistet.
|
||||
pub fn mark_dirty(&mut self) {
|
||||
self.manifest_dirty = true;
|
||||
self.dirty_ops_count += 1;
|
||||
if self.dirty_ops_count >= 50 {
|
||||
let _ = self.flush_manifest_if_dirty();
|
||||
}
|
||||
}
|
||||
|
||||
/// Schreibt das Manifest nur, wenn ungespeicherte Änderungen anstehen (C-03).
|
||||
pub fn flush_manifest_if_dirty(&mut self) -> Result<()> {
|
||||
if self.manifest_dirty {
|
||||
self.save_manifest()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Ermittelt die aktuelle Bytegröße des Manifests, die Kapazität und den Füllstand in Prozent (C-04).
|
||||
pub fn manifest_usage(&self) -> (usize, usize, f64) {
|
||||
let len = serde_json::to_vec(&self.manifest)
|
||||
.map(|v| v.len())
|
||||
.unwrap_or(0);
|
||||
let pct = (len as f64 / CARRIER_MANIFEST_CAPACITY as f64) * 100.0;
|
||||
(len, CARRIER_MANIFEST_CAPACITY, pct)
|
||||
}
|
||||
|
||||
pub fn save_manifest(&mut self) -> Result<()> {
|
||||
self.manifest_save_count += 1;
|
||||
self.manifest_dirty = false;
|
||||
self.dirty_ops_count = 0;
|
||||
self.last_manifest_save = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
self.manifest.manifest_generation += 1;
|
||||
let manifest_bytes = serde_json::to_vec(&self.manifest)?;
|
||||
if manifest_bytes.len() > (crate::crypto::CHUNK_SIZE - 64) {
|
||||
if manifest_bytes.len() > CARRIER_MANIFEST_CAPACITY {
|
||||
bail!(
|
||||
"Carrier-Manifest überschreitet die maximale Blockgröße ({} > {} Bytes).",
|
||||
"Carrier-Manifest überschreitet die maximale Blockgröße ({} > {} Bytes). Empfohlene Obergrenze ca. 7.000 Dateien/Verzeichnisse beachten.",
|
||||
manifest_bytes.len(),
|
||||
crate::crypto::CHUNK_SIZE - 64
|
||||
CARRIER_MANIFEST_CAPACITY
|
||||
);
|
||||
}
|
||||
if manifest_bytes.len() > 800_000 {
|
||||
if manifest_bytes.len() >= CARRIER_MANIFEST_WARN_THRESHOLD {
|
||||
let pct = (manifest_bytes.len() * 100) / CARRIER_MANIFEST_CAPACITY;
|
||||
warn!(
|
||||
"Carrier-Manifest erreicht 80% der Blockkapazität ({}/{} Bytes)",
|
||||
"Warnung (C-04): Hidden-Vault Manifest zu {}% gefüllt ({}/{} Bytes). Empfohlene Obergrenze beachten (~7.000 Dateien/Verzeichnisse).",
|
||||
pct,
|
||||
manifest_bytes.len(),
|
||||
crate::crypto::CHUNK_SIZE - 64
|
||||
CARRIER_MANIFEST_CAPACITY
|
||||
);
|
||||
}
|
||||
|
||||
// C-02: Rollierendes Dual-Block-Manifest.
|
||||
// Gerade Generation -> Block 0, ungerade Generation -> Block 1.
|
||||
let target_block = if self.manifest.manifest_generation.is_multiple_of(2) {
|
||||
0
|
||||
} else {
|
||||
1
|
||||
};
|
||||
|
||||
write_carrier_block(
|
||||
&self.db,
|
||||
self.carrier_node_id,
|
||||
0,
|
||||
target_block,
|
||||
&self.dek_outer,
|
||||
&self.dek_inner,
|
||||
&manifest_bytes,
|
||||
@@ -362,6 +423,14 @@ impl CarrierFsInner {
|
||||
|
||||
impl Drop for CarrierFsInner {
|
||||
fn drop(&mut self) {
|
||||
if self.manifest_dirty {
|
||||
if let Err(e) = self.save_manifest() {
|
||||
error!(
|
||||
"Fehler beim finalen Speichern des Carrier-Manifests im Drop: {:?}",
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
crate::windows::unlock_memory(self.dek_outer.as_ptr(), 32);
|
||||
crate::windows::unlock_memory(self.dek_inner.as_ptr(), 32);
|
||||
}
|
||||
@@ -374,7 +443,8 @@ pub struct CarrierFs {
|
||||
}
|
||||
|
||||
impl CarrierFs {
|
||||
/// Lädt ein bestehendes Carrier-Dateisystem mit benutzerdefinierten Anti-Leak-Regeln (V-04).
|
||||
/// Lädt ein bestehendes Carrier-Dateisystem mit redundanter Block-0/Block-1 Prüfung (C-02)
|
||||
/// und benutzerdefinierten Anti-Leak-Regeln (V-04).
|
||||
pub fn load_with_leak_rules(
|
||||
db: Database,
|
||||
carrier_node_id: i64,
|
||||
@@ -384,19 +454,92 @@ impl CarrierFs {
|
||||
anti_leak: bool,
|
||||
custom_leak_rules: Vec<String>,
|
||||
) -> Result<Self> {
|
||||
let manifest_bytes = read_carrier_block(
|
||||
// C-02: Versuche beide redundante Blöcke (Block 0 und Block 1) zu lesen und zu verifizieren
|
||||
let block_0_res = read_carrier_block(
|
||||
&db,
|
||||
carrier_node_id,
|
||||
0,
|
||||
&dek_outer,
|
||||
&dek_inner,
|
||||
format_version,
|
||||
)?;
|
||||
|
||||
let manifest: CarrierManifest = serde_json::from_slice(&manifest_bytes)?;
|
||||
if manifest.magic != *CARRIER_MAGIC {
|
||||
)
|
||||
.and_then(|bytes| {
|
||||
let m: CarrierManifest = serde_json::from_slice(&bytes)?;
|
||||
if m.magic != *CARRIER_MAGIC {
|
||||
bail!("Ungültige Carrier-Magic-Bytes in Block 0");
|
||||
}
|
||||
Ok(m)
|
||||
});
|
||||
|
||||
let block_1_res = read_carrier_block(
|
||||
&db,
|
||||
carrier_node_id,
|
||||
1,
|
||||
&dek_outer,
|
||||
&dek_inner,
|
||||
format_version,
|
||||
)
|
||||
.and_then(|bytes| {
|
||||
let m: CarrierManifest = serde_json::from_slice(&bytes)?;
|
||||
if m.magic != *CARRIER_MAGIC {
|
||||
bail!("Ungültige Carrier-Magic-Bytes in Block 1");
|
||||
}
|
||||
Ok(m)
|
||||
});
|
||||
|
||||
let mut manifest = match (block_0_res, block_1_res) {
|
||||
(Ok(m0), Ok(m1)) => {
|
||||
if m1.manifest_generation > m0.manifest_generation {
|
||||
debug!(
|
||||
"C-02: Beide Manifest-Kopien intakt. Wähle neuere Generation {} aus Block 1 (Block 0: Gen {})",
|
||||
m1.manifest_generation, m0.manifest_generation
|
||||
);
|
||||
m1
|
||||
} else {
|
||||
debug!(
|
||||
"C-02: Beide Manifest-Kopien intakt. Wähle Generation {} aus Block 0 (Block 1: Gen {})",
|
||||
m0.manifest_generation, m1.manifest_generation
|
||||
);
|
||||
m0
|
||||
}
|
||||
}
|
||||
(Ok(m0), Err(e1)) => {
|
||||
debug!(
|
||||
"C-02: Verwende primäres Manifest aus Block 0 (Gen {}). Block 1 unlesbar oder uninitialisiert: {}",
|
||||
m0.manifest_generation, e1
|
||||
);
|
||||
m0
|
||||
}
|
||||
(Err(e0), Ok(m1)) => {
|
||||
warn!(
|
||||
"C-02: Block 0 korrupt oder unlesbar ({})! Erfolgreiche Wiederherstellung aus redundanter Kopie in Block 1 (Gen {}).",
|
||||
e0, m1.manifest_generation
|
||||
);
|
||||
m1
|
||||
}
|
||||
(Err(e0), Err(e1)) => {
|
||||
bail!(
|
||||
"Kritischer Datenverlust (C-02): Weder Block 0 ({}) noch Block 1 ({}) enthalten ein gültiges Carrier-Manifest! Hidden Vault nicht wiederherstellbar.",
|
||||
e0, e1
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Rückwärtskompatibilität zu v0.8.0-Containern: Block 1 als redundante Manifest-Kopie reservieren
|
||||
manifest.free_blocks.retain(|&b| b >= 2);
|
||||
|
||||
// C-04: Warnung bei Füllstand über 80% beim Mounten
|
||||
if let Ok(manifest_bytes) = serde_json::to_vec(&manifest) {
|
||||
if manifest_bytes.len() >= CARRIER_MANIFEST_WARN_THRESHOLD {
|
||||
let pct = (manifest_bytes.len() * 100) / CARRIER_MANIFEST_CAPACITY;
|
||||
warn!(
|
||||
"Warnung (C-04): Hidden-Vault Manifest zu {}% gefüllt ({}/{} Bytes). Empfohlene Obergrenze beachten (~7.000 Dateien/Verzeichnisse).",
|
||||
pct,
|
||||
manifest_bytes.len(),
|
||||
CARRIER_MANIFEST_CAPACITY
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
@@ -417,6 +560,10 @@ impl CarrierFs {
|
||||
custom_leak_rules: Arc::new(custom_leak_rules),
|
||||
leak_counter: Arc::new(AtomicU64::new(0)),
|
||||
manifest,
|
||||
manifest_dirty: false,
|
||||
manifest_save_count: 0,
|
||||
dirty_ops_count: 0,
|
||||
last_manifest_save: now,
|
||||
last_activity: Arc::new(AtomicU64::new(now)),
|
||||
};
|
||||
|
||||
@@ -445,6 +592,26 @@ impl CarrierFs {
|
||||
)
|
||||
}
|
||||
|
||||
pub fn sync_manifest(&self) -> Result<()> {
|
||||
let mut inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
|
||||
inner.flush_manifest_if_dirty()
|
||||
}
|
||||
|
||||
pub fn manifest_save_count(&self) -> u64 {
|
||||
let inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
|
||||
inner.manifest_save_count
|
||||
}
|
||||
|
||||
pub fn is_manifest_dirty(&self) -> bool {
|
||||
let inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
|
||||
inner.manifest_dirty
|
||||
}
|
||||
|
||||
pub fn manifest_usage(&self) -> (usize, usize, f64) {
|
||||
let inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
|
||||
inner.manifest_usage()
|
||||
}
|
||||
|
||||
pub fn leak_counter(&self) -> Arc<AtomicU64> {
|
||||
let inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
|
||||
inner.leak_counter.clone()
|
||||
@@ -523,7 +690,7 @@ impl DavFileSystem for CarrierFs {
|
||||
n.size = 0;
|
||||
n.modified_at = now;
|
||||
inner.manifest.inodes.insert(n.id, n.clone());
|
||||
let _ = inner.save_manifest();
|
||||
inner.mark_dirty();
|
||||
}
|
||||
n
|
||||
}
|
||||
@@ -554,7 +721,7 @@ impl DavFileSystem for CarrierFs {
|
||||
};
|
||||
|
||||
inner.manifest.inodes.insert(new_id, new_inode.clone());
|
||||
let _ = inner.save_manifest();
|
||||
inner.mark_dirty();
|
||||
new_inode
|
||||
} else {
|
||||
return Err(FsError::NotFound);
|
||||
@@ -676,7 +843,7 @@ impl DavFileSystem for CarrierFs {
|
||||
};
|
||||
|
||||
inner.manifest.inodes.insert(new_id, new_dir);
|
||||
inner.save_manifest().map_err(|_| FsError::GeneralFailure)?;
|
||||
inner.mark_dirty();
|
||||
|
||||
Ok(())
|
||||
})
|
||||
@@ -709,7 +876,7 @@ impl DavFileSystem for CarrierFs {
|
||||
}
|
||||
|
||||
inner.manifest.inodes.remove(&node.id);
|
||||
inner.save_manifest().map_err(|_| FsError::GeneralFailure)?;
|
||||
inner.mark_dirty();
|
||||
|
||||
Ok(())
|
||||
})
|
||||
@@ -738,7 +905,7 @@ impl DavFileSystem for CarrierFs {
|
||||
}
|
||||
|
||||
inner.manifest.inodes.remove(&node.id);
|
||||
inner.save_manifest().map_err(|_| FsError::GeneralFailure)?;
|
||||
inner.mark_dirty();
|
||||
|
||||
Ok(())
|
||||
})
|
||||
@@ -795,7 +962,7 @@ impl DavFileSystem for CarrierFs {
|
||||
inode.modified_at = now;
|
||||
}
|
||||
|
||||
inner.save_manifest().map_err(|_| FsError::GeneralFailure)?;
|
||||
inner.mark_dirty();
|
||||
|
||||
Ok(())
|
||||
})
|
||||
@@ -924,7 +1091,7 @@ impl DavFileSystem for CarrierFs {
|
||||
};
|
||||
|
||||
inner.manifest.inodes.insert(new_id, new_inode);
|
||||
inner.save_manifest().map_err(|_| FsError::GeneralFailure)?;
|
||||
inner.mark_dirty();
|
||||
|
||||
Ok(())
|
||||
})
|
||||
@@ -1231,7 +1398,10 @@ impl DavFile for CarrierFile {
|
||||
inode.modified_at = now;
|
||||
}
|
||||
|
||||
inner.save_manifest().map_err(|_| FsError::GeneralFailure)?;
|
||||
inner.mark_dirty();
|
||||
inner
|
||||
.flush_manifest_if_dirty()
|
||||
.map_err(|_| FsError::GeneralFailure)?;
|
||||
|
||||
self.meta.size = self.file_size;
|
||||
self.meta.modified_at = UNIX_EPOCH + Duration::from_secs(now);
|
||||
@@ -1261,7 +1431,7 @@ impl Drop for CarrierFile {
|
||||
inode.blocks = self.blocks.clone();
|
||||
inode.modified_at = now;
|
||||
}
|
||||
let _ = inner.save_manifest();
|
||||
inner.mark_dirty();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -225,6 +225,7 @@ pub async fn mount_container(
|
||||
);
|
||||
let fs_leak_counter = fs.leak_counter();
|
||||
let last_activity = fs.last_activity();
|
||||
let fs_clone = fs.clone();
|
||||
let dav_server = DavHandler::builder()
|
||||
.filesystem(Box::new(fs))
|
||||
.locksystem(FakeLs::new())
|
||||
@@ -282,6 +283,7 @@ pub async fn mount_container(
|
||||
|
||||
// Netzlaufwerk bzw. Verzeichnis einbinden
|
||||
if let Err(e) = run_mount_command(drive_letter, bound_port, &session_token) {
|
||||
let _ = fs_clone.sync_carrier_manifest();
|
||||
let _ = shutdown_tx.send(true);
|
||||
let _ = server_handle.await;
|
||||
return Err(e);
|
||||
@@ -420,6 +422,22 @@ pub async fn mount_container(
|
||||
if enable_tray {
|
||||
println!(" • System-Tray: Icon aktiv (Rechtsklick für Explorer/Trennen)");
|
||||
}
|
||||
if vault_id == 1 {
|
||||
if let Some((len, cap, pct)) = fs_clone.carrier_manifest_usage() {
|
||||
if len >= crate::carrier::CARRIER_MANIFEST_WARN_THRESHOLD {
|
||||
println!(
|
||||
" {} Warnung (C-04): Hidden-Vault Manifest zu {:.1}% gefüllt ({}/{} Bytes).",
|
||||
ui::yellow("[!]"),
|
||||
pct,
|
||||
len,
|
||||
cap
|
||||
);
|
||||
println!(
|
||||
" Empfohlene Obergrenze beachten: ca. 7.000 Dateien/Verzeichnisse im Hidden Vault."
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
println!();
|
||||
#[cfg(windows)]
|
||||
println!(
|
||||
@@ -541,6 +559,7 @@ pub async fn mount_container(
|
||||
}
|
||||
|
||||
if stealth {
|
||||
let _ = fs_clone.sync_carrier_manifest();
|
||||
let _ = unmount_drive(drive_letter);
|
||||
let _ = shutdown_tx.send(true);
|
||||
let _ = server_handle.await;
|
||||
@@ -555,6 +574,7 @@ pub async fn mount_container(
|
||||
#[cfg(not(windows))]
|
||||
println!("Sanctum: Container geschlossen und WebDAV-Server beendet.");
|
||||
} else {
|
||||
let _ = fs_clone.sync_carrier_manifest();
|
||||
#[cfg(windows)]
|
||||
{
|
||||
print!(
|
||||
|
||||
+469
-69
@@ -6,6 +6,7 @@ use anyhow::{bail, Result};
|
||||
use rand::rngs::OsRng;
|
||||
use rand::RngCore;
|
||||
use rusqlite::{params, Connection, OptionalExtension};
|
||||
use tracing::{error, warn};
|
||||
|
||||
use crate::crypto::{
|
||||
compute_metadata_mac, decrypt_node_name, derive_kek, derive_metadata_mac_key,
|
||||
@@ -731,36 +732,85 @@ impl Database {
|
||||
],
|
||||
)?;
|
||||
|
||||
// Initialisiere CarrierManifest für Block 0
|
||||
// Initialisiere CarrierManifest für redundante Blöcke 0 und 1 (C-02)
|
||||
let manifest = crate::carrier::CarrierManifest::new(total_blocks);
|
||||
let manifest_bytes = serde_json::to_vec(&manifest)?;
|
||||
|
||||
let (inner_ct, inner_nonce, inner_tag) =
|
||||
// 1. Block 0 schreiben (Primärkopie, Gen 0)
|
||||
let (inner_ct_0, inner_nonce_0, inner_tag_0) =
|
||||
crate::crypto::encrypt_chunk(dek_1, c_id, 0, &manifest_bytes, FORMAT_VERSION, 0)?;
|
||||
let inner_ct_len = inner_ct.len() as u32;
|
||||
let inner_ct_len_0 = inner_ct_0.len() as u32;
|
||||
|
||||
let mut outer_plaintext = vec![0u8; CHUNK_SIZE];
|
||||
OsRng.fill_bytes(&mut outer_plaintext);
|
||||
let mut outer_plaintext_0 = vec![0u8; CHUNK_SIZE];
|
||||
OsRng.fill_bytes(&mut outer_plaintext_0);
|
||||
|
||||
outer_plaintext[0..12].copy_from_slice(&inner_nonce);
|
||||
outer_plaintext[12..28].copy_from_slice(&inner_tag);
|
||||
outer_plaintext[28..32].copy_from_slice(&inner_ct_len.to_le_bytes());
|
||||
let ct_end = 32 + inner_ct.len();
|
||||
if ct_end > CHUNK_SIZE {
|
||||
outer_plaintext_0[0..12].copy_from_slice(&inner_nonce_0);
|
||||
outer_plaintext_0[12..28].copy_from_slice(&inner_tag_0);
|
||||
outer_plaintext_0[28..32].copy_from_slice(&inner_ct_len_0.to_le_bytes());
|
||||
let ct_end_0 = 32 + inner_ct_0.len();
|
||||
if ct_end_0 > CHUNK_SIZE {
|
||||
bail!("Manifest-Payload zu groß für Block 0");
|
||||
}
|
||||
outer_plaintext[32..ct_end].copy_from_slice(&inner_ct);
|
||||
outer_plaintext_0[32..ct_end_0].copy_from_slice(&inner_ct_0);
|
||||
|
||||
let (outer_ct, outer_nonce, outer_tag) =
|
||||
crate::crypto::encrypt_chunk(dek_0, c_id, 0, &outer_plaintext, FORMAT_VERSION, 0)?;
|
||||
let (outer_ct_0, outer_nonce_0, outer_tag_0) = crate::crypto::encrypt_chunk(
|
||||
dek_0,
|
||||
c_id,
|
||||
0,
|
||||
&outer_plaintext_0,
|
||||
FORMAT_VERSION,
|
||||
0,
|
||||
)?;
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO chunks (node_id, chunk_index, generation, nonce, tag, ciphertext)
|
||||
VALUES (?1, 0, 0, ?2, ?3, ?4)",
|
||||
params![c_id, outer_nonce.as_slice(), outer_tag.as_slice(), outer_ct],
|
||||
params![
|
||||
c_id,
|
||||
outer_nonce_0.as_slice(),
|
||||
outer_tag_0.as_slice(),
|
||||
outer_ct_0
|
||||
],
|
||||
)?;
|
||||
|
||||
// Blöcke 1..total_blocks-1 mit DEK_0 vorallokieren
|
||||
// 2. Block 1 schreiben (Redundante Zweitkopie, Gen 0, C-02)
|
||||
let (inner_ct_1, inner_nonce_1, inner_tag_1) =
|
||||
crate::crypto::encrypt_chunk(dek_1, c_id, 1, &manifest_bytes, FORMAT_VERSION, 0)?;
|
||||
let inner_ct_len_1 = inner_ct_1.len() as u32;
|
||||
|
||||
let mut outer_plaintext_1 = vec![0u8; CHUNK_SIZE];
|
||||
OsRng.fill_bytes(&mut outer_plaintext_1);
|
||||
|
||||
outer_plaintext_1[0..12].copy_from_slice(&inner_nonce_1);
|
||||
outer_plaintext_1[12..28].copy_from_slice(&inner_tag_1);
|
||||
outer_plaintext_1[28..32].copy_from_slice(&inner_ct_len_1.to_le_bytes());
|
||||
let ct_end_1 = 32 + inner_ct_1.len();
|
||||
if ct_end_1 > CHUNK_SIZE {
|
||||
bail!("Manifest-Payload zu groß für Block 1");
|
||||
}
|
||||
outer_plaintext_1[32..ct_end_1].copy_from_slice(&inner_ct_1);
|
||||
|
||||
let (outer_ct_1, outer_nonce_1, outer_tag_1) = crate::crypto::encrypt_chunk(
|
||||
dek_0,
|
||||
c_id,
|
||||
1,
|
||||
&outer_plaintext_1,
|
||||
FORMAT_VERSION,
|
||||
0,
|
||||
)?;
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO chunks (node_id, chunk_index, generation, nonce, tag, ciphertext)
|
||||
VALUES (?1, 1, 0, ?2, ?3, ?4)",
|
||||
params![
|
||||
c_id,
|
||||
outer_nonce_1.as_slice(),
|
||||
outer_tag_1.as_slice(),
|
||||
outer_ct_1
|
||||
],
|
||||
)?;
|
||||
|
||||
// Blöcke 2..total_blocks-1 mit DEK_0 vorallokieren (C-02)
|
||||
let mut chunk_stmt = conn.prepare(
|
||||
"INSERT INTO chunks (node_id, chunk_index, generation, nonce, tag, ciphertext)
|
||||
VALUES (?1, ?2, 0, ?3, ?4, ?5)",
|
||||
@@ -770,7 +820,7 @@ impl Database {
|
||||
OsRng.fill_bytes(&mut dummy_noise);
|
||||
|
||||
conn.execute_batch("BEGIN TRANSACTION;")?;
|
||||
for b in 1..total_blocks {
|
||||
for b in 2..total_blocks {
|
||||
let (ct, nonce, tag) =
|
||||
crate::crypto::encrypt_chunk(dek_0, c_id, b, &dummy_noise, FORMAT_VERSION, 0)?;
|
||||
chunk_stmt.execute(params![c_id, b, nonce.as_slice(), tag.as_slice(), ct,])?;
|
||||
@@ -1384,8 +1434,16 @@ impl Database {
|
||||
let mut matched_record = None;
|
||||
for r in rows {
|
||||
let (id, p_id, enc_name, is_dir, size, c_at, m_at) = r?;
|
||||
let dec_name =
|
||||
decrypt_node_name(dek, p_id.unwrap_or(0), &enc_name).unwrap_or(enc_name);
|
||||
let dec_name = match decrypt_node_name(dek, p_id.unwrap_or(0), &enc_name) {
|
||||
Some(name) => name,
|
||||
None => {
|
||||
warn!(
|
||||
"Knoten {} in Pfadauflösung übersprungen: Name konnte nicht entschlüsselt werden (ST-01)",
|
||||
id
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if dec_name == *segment {
|
||||
matched_record = Some(NodeRecord {
|
||||
id,
|
||||
@@ -1433,29 +1491,49 @@ impl Database {
|
||||
FROM nodes WHERE id = ?1",
|
||||
)?;
|
||||
|
||||
let record = stmt
|
||||
let raw = stmt
|
||||
.query_row(params![id], |row| {
|
||||
let enc_name: String = row.get(2)?;
|
||||
let p_id: Option<i64> = row.get(1)?;
|
||||
let name = if vault_id == 1 {
|
||||
decrypt_node_name(dek, p_id.unwrap_or(0), &enc_name).unwrap_or(enc_name)
|
||||
Ok((
|
||||
row.get::<_, i64>(0)?,
|
||||
row.get::<_, Option<i64>>(1)?,
|
||||
row.get::<_, String>(2)?,
|
||||
row.get::<_, i32>(3)? != 0,
|
||||
row.get::<_, i64>(4)? as u64,
|
||||
row.get::<_, i64>(5)? as u64,
|
||||
row.get::<_, i64>(6)? as u64,
|
||||
))
|
||||
})
|
||||
.optional()?;
|
||||
|
||||
match raw {
|
||||
Some((id, parent_id, enc_name, is_dir, size, created_at, modified_at)) => {
|
||||
let name = if vault_id == 1 && parent_id.is_some() {
|
||||
match decrypt_node_name(dek, parent_id.unwrap_or(0), &enc_name) {
|
||||
Some(dec) => dec,
|
||||
None => {
|
||||
warn!(
|
||||
"Knoten {} kann nicht geladen werden: Name konnte nicht entschlüsselt werden (ST-01)",
|
||||
id
|
||||
);
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
enc_name
|
||||
};
|
||||
|
||||
Ok(NodeRecord {
|
||||
id: row.get(0)?,
|
||||
parent_id: row.get(1)?,
|
||||
Ok(Some(NodeRecord {
|
||||
id,
|
||||
parent_id,
|
||||
name,
|
||||
is_dir: row.get::<_, i32>(3)? != 0,
|
||||
size: row.get::<_, i64>(4)? as u64,
|
||||
created_at: row.get::<_, i64>(5)? as u64,
|
||||
modified_at: row.get::<_, i64>(6)? as u64,
|
||||
})
|
||||
})
|
||||
.optional()?;
|
||||
|
||||
Ok(record)
|
||||
is_dir,
|
||||
size,
|
||||
created_at,
|
||||
modified_at,
|
||||
}))
|
||||
}
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_node_by_id(&self, id: i64) -> Result<Option<NodeRecord>> {
|
||||
@@ -1478,27 +1556,44 @@ impl Database {
|
||||
)?;
|
||||
|
||||
let rows = stmt.query_map(params![parent_id], |row| {
|
||||
let enc_name: String = row.get(2)?;
|
||||
let name = if vault_id == 1 {
|
||||
decrypt_node_name(dek, parent_id, &enc_name).unwrap_or(enc_name)
|
||||
} else {
|
||||
enc_name
|
||||
};
|
||||
|
||||
Ok(NodeRecord {
|
||||
id: row.get(0)?,
|
||||
parent_id: row.get(1)?,
|
||||
name,
|
||||
is_dir: row.get::<_, i32>(3)? != 0,
|
||||
size: row.get::<_, i64>(4)? as u64,
|
||||
created_at: row.get::<_, i64>(5)? as u64,
|
||||
modified_at: row.get::<_, i64>(6)? as u64,
|
||||
})
|
||||
Ok((
|
||||
row.get::<_, i64>(0)?,
|
||||
row.get::<_, Option<i64>>(1)?,
|
||||
row.get::<_, String>(2)?,
|
||||
row.get::<_, i32>(3)? != 0,
|
||||
row.get::<_, i64>(4)? as u64,
|
||||
row.get::<_, i64>(5)? as u64,
|
||||
row.get::<_, i64>(6)? as u64,
|
||||
))
|
||||
})?;
|
||||
|
||||
let mut entries = Vec::new();
|
||||
for r in rows {
|
||||
entries.push(r?);
|
||||
let (id, p_id, enc_name, is_dir, size, created_at, modified_at) = r?;
|
||||
let name = if vault_id == 1 {
|
||||
match decrypt_node_name(dek, parent_id, &enc_name) {
|
||||
Some(dec) => dec,
|
||||
None => {
|
||||
warn!(
|
||||
"Knoten {} in Verzeichnisauflistung übersprungen: Name konnte nicht entschlüsselt werden (ST-01)",
|
||||
id
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
enc_name
|
||||
};
|
||||
|
||||
entries.push(NodeRecord {
|
||||
id,
|
||||
parent_id: p_id,
|
||||
name,
|
||||
is_dir,
|
||||
size,
|
||||
created_at,
|
||||
modified_at,
|
||||
});
|
||||
}
|
||||
Ok(entries)
|
||||
}
|
||||
@@ -1586,24 +1681,54 @@ impl Database {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Löscht einen Knoten und shreddert alle assoziierten Chunks atomar.
|
||||
pub fn delete_node(&self, id: i64) -> Result<()> {
|
||||
self.assert_not_carrier(id)?;
|
||||
|
||||
// Verhindere auch das Löschen eines Verzeichnisses, das die Trägerdatei enthält
|
||||
if let Ok(Some(carrier_id)) = self.find_carrier_node_id() {
|
||||
if self.is_descendant_of(carrier_id, id).unwrap_or(false) {
|
||||
bail!("Verzeichnis enthält die Trägerdatei (Node-ID {}) und darf nicht gelöscht werden.", carrier_id);
|
||||
fn delete_subtree_tx(
|
||||
tx: &rusqlite::Transaction<'_>,
|
||||
id: i64,
|
||||
carrier_id: Option<i64>,
|
||||
) -> Result<()> {
|
||||
if let Some(cid) = carrier_id {
|
||||
if id == cid {
|
||||
bail!(
|
||||
"Operation auf Alibi-Trägerdatei (Carrier, Node-ID {}) ist strikt untersagt.",
|
||||
id
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Shredde Chunks dieses Knotens mit kryptografischem Zufallsrauschen
|
||||
let _ = self.shred_chunks_for_node(id);
|
||||
// 1. Chunks für diesen Knoten kryptografisch shreddern
|
||||
{
|
||||
let mut stmt = tx
|
||||
.prepare("SELECT chunk_index, length(ciphertext) FROM chunks WHERE node_id = ?1")?;
|
||||
let chunks: Vec<(u32, usize)> = stmt
|
||||
.query_map(params![id], |row| Ok((row.get(0)?, row.get(1)?)))?
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
|
||||
// 2. Shredde auch rekursiv alle Unterknoten
|
||||
let mut update_stmt = tx.prepare(
|
||||
"UPDATE chunks SET nonce = ?1, tag = ?2, ciphertext = ?3, generation = 0 WHERE node_id = ?4 AND chunk_index = ?5",
|
||||
)?;
|
||||
|
||||
for (chunk_idx, ct_len) in chunks {
|
||||
let mut noise = vec![0u8; ct_len];
|
||||
let mut nonce_noise = [0u8; 12];
|
||||
let mut tag_noise = [0u8; 16];
|
||||
OsRng.fill_bytes(&mut noise);
|
||||
OsRng.fill_bytes(&mut nonce_noise);
|
||||
OsRng.fill_bytes(&mut tag_noise);
|
||||
|
||||
let _ = update_stmt.execute(params![
|
||||
nonce_noise.as_slice(),
|
||||
tag_noise.as_slice(),
|
||||
noise,
|
||||
id,
|
||||
chunk_idx
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Kindknoten ermitteln und rekursiv innerhalb derselben Transaktion löschen
|
||||
let child_ids: Vec<i64> = {
|
||||
let conn = self.conn();
|
||||
let mut stmt = conn.prepare("SELECT id FROM nodes WHERE parent_id = ?1")?;
|
||||
let mut stmt = tx.prepare("SELECT id FROM nodes WHERE parent_id = ?1")?;
|
||||
let ids = stmt
|
||||
.query_map(params![id], |row| row.get(0))?
|
||||
.filter_map(|r| r.ok())
|
||||
@@ -1611,14 +1736,47 @@ impl Database {
|
||||
ids
|
||||
};
|
||||
|
||||
let mut errors = Vec::new();
|
||||
for child_id in child_ids {
|
||||
let _ = self.delete_node(child_id);
|
||||
if let Err(e) = Self::delete_subtree_tx(tx, child_id, carrier_id) {
|
||||
error!("Konnte Kindknoten {} nicht löschen: {e}", child_id);
|
||||
errors.push(e);
|
||||
}
|
||||
}
|
||||
if !errors.is_empty() {
|
||||
bail!(
|
||||
"Fehler beim rekursiven Löschen: {} Kindknoten konnten nicht gelöscht werden (ST-02)",
|
||||
errors.len()
|
||||
);
|
||||
}
|
||||
|
||||
let conn = self.conn();
|
||||
conn.execute("DELETE FROM chunks WHERE node_id = ?1", params![id])?;
|
||||
conn.execute("DELETE FROM nodes WHERE id = ?1", params![id])?;
|
||||
// 3. Chunks und Knoten aus der Datenbank löschen
|
||||
tx.execute("DELETE FROM chunks WHERE node_id = ?1", params![id])?;
|
||||
tx.execute("DELETE FROM nodes WHERE id = ?1", params![id])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Löscht einen Knoten und shreddert alle assoziierten Chunks atomar in einer SQLite-Transaktion (ST-03).
|
||||
pub fn delete_node(&self, id: i64) -> Result<()> {
|
||||
self.assert_not_carrier(id)?;
|
||||
|
||||
// Verhindere auch das Löschen eines Verzeichnisses, das die Trägerdatei enthält
|
||||
let carrier_id = self.find_carrier_node_id().ok().flatten();
|
||||
if let Some(cid) = carrier_id {
|
||||
if self.is_descendant_of(cid, id).unwrap_or(false) {
|
||||
bail!(
|
||||
"Verzeichnis enthält die Trägerdatei (Node-ID {}) und darf nicht gelöscht werden.",
|
||||
cid
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let mut conn = self.conn();
|
||||
let tx = conn.transaction()?;
|
||||
Self::delete_subtree_tx(&tx, id, carrier_id)?;
|
||||
tx.commit()?;
|
||||
drop(conn);
|
||||
|
||||
let _ = self.update_metadata_mac();
|
||||
Ok(())
|
||||
}
|
||||
@@ -2923,4 +3081,246 @@ mod tests {
|
||||
assert!(read_dek.is_some());
|
||||
assert_eq!(read_dek.unwrap()[0], 42);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_st01_fail_closed_name_decryption_no_hex_leak() {
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
let (salt0, kdf0) = ([1u8; 16], KdfParams::default());
|
||||
let (salt1, kdf1) = ([2u8; 16], KdfParams::default());
|
||||
let (dek0, dek1) = ([10u8; 32], [20u8; 32]);
|
||||
let carrier_name = "backup.iso";
|
||||
let carrier_size = 5 * 1024 * 1024;
|
||||
|
||||
db.init_schema_with_carrier(
|
||||
&salt0,
|
||||
&kdf0,
|
||||
&[0u8; 40],
|
||||
&[0u8; 12],
|
||||
&[0u8; 16],
|
||||
Some((
|
||||
carrier_name,
|
||||
carrier_size,
|
||||
&salt1,
|
||||
&kdf1,
|
||||
&[1u8; 40],
|
||||
&[1u8; 12],
|
||||
&[1u8; 16],
|
||||
&dek0,
|
||||
&dek1,
|
||||
)),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let root1 = db.resolve_path_in_vault("/", 1, &dek1).unwrap().unwrap();
|
||||
assert_eq!(root1.id, 2);
|
||||
|
||||
// 1. Zwei Dateien in Vault 1 anlegen
|
||||
let node_valid = db
|
||||
.create_node_in_vault(1, root1.id, "secret_valid.txt", false, &dek1)
|
||||
.unwrap();
|
||||
let node_corrupt = db
|
||||
.create_node_in_vault(1, root1.id, "secret_corrupt.txt", false, &dek1)
|
||||
.unwrap();
|
||||
|
||||
// 2. Vor Korruption: Beide Dateien sichtbar
|
||||
let children_before = db.list_children_in_vault(root1.id, 1, &dek1).unwrap();
|
||||
assert_eq!(children_before.len(), 2);
|
||||
|
||||
// 3. Simuliere Bitrot / Manipulation: Überschreibe den verschlüsselten Namen in der DB
|
||||
{
|
||||
let conn = db.conn();
|
||||
conn.execute(
|
||||
"UPDATE nodes SET name = 'deadbeef_invalid_ciphertext_without_valid_tag' WHERE id = ?1",
|
||||
params![node_corrupt.id],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// 4. ST-01 Prüfungen (Fail-Closed):
|
||||
// a) list_children_in_vault filtert den korrupten Knoten heraus
|
||||
let children_after = db.list_children_in_vault(root1.id, 1, &dek1).unwrap();
|
||||
assert_eq!(
|
||||
children_after.len(),
|
||||
1,
|
||||
"ST-01: Korrupter Knoten muss aus der Liste herausgefiltert werden"
|
||||
);
|
||||
assert_eq!(children_after[0].id, node_valid.id);
|
||||
assert_eq!(children_after[0].name, "secret_valid.txt");
|
||||
assert!(
|
||||
!children_after.iter().any(|c| c.name.contains("deadbeef")),
|
||||
"Kein Hex-Ciphertext-Leak"
|
||||
);
|
||||
|
||||
// b) resolve_path_in_vault gibt None zurück (nicht den Hex-Namen)
|
||||
let resolved = db
|
||||
.resolve_path_in_vault("/secret_corrupt.txt", 1, &dek1)
|
||||
.unwrap();
|
||||
assert!(
|
||||
resolved.is_none(),
|
||||
"ST-01: Pfad mit korruptem Namen darf nicht aufgelöst werden"
|
||||
);
|
||||
|
||||
// c) get_node_by_id_in_vault gibt None zurück (fail-closed statt Hex-String)
|
||||
let by_id = db
|
||||
.get_node_by_id_in_vault(node_corrupt.id, 1, &dek1)
|
||||
.unwrap();
|
||||
assert!(
|
||||
by_id.is_none(),
|
||||
"ST-01: get_node_by_id_in_vault muss None zurückgeben, wenn Name unlesbar ist"
|
||||
);
|
||||
|
||||
// d) Gültiger Knoten kann normal geladen werden
|
||||
let valid_by_id = db.get_node_by_id_in_vault(node_valid.id, 1, &dek1).unwrap();
|
||||
assert!(valid_by_id.is_some());
|
||||
assert_eq!(valid_by_id.unwrap().name, "secret_valid.txt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_st02_recursive_delete_error_propagation_not_swallowed() {
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
let (salt, kdf, wrapped_dek, nonce, tag) = (
|
||||
[1u8; 16],
|
||||
KdfParams::default(),
|
||||
vec![2u8; 40],
|
||||
[3u8; 12],
|
||||
[4u8; 16],
|
||||
);
|
||||
db.init_schema(&salt, &kdf, &wrapped_dek, &nonce, &tag)
|
||||
.unwrap();
|
||||
|
||||
// Verzeichnisstruktur erstellen:
|
||||
// /parent (id=3)
|
||||
// /parent/normal.txt (id=4)
|
||||
// /parent/protected.txt (id=5)
|
||||
let parent = db.create_node(1, "parent", true).unwrap();
|
||||
let _child_normal = db.create_node(parent.id, "normal.txt", false).unwrap();
|
||||
let _child_protected = db.create_node(parent.id, "protected.txt", false).unwrap();
|
||||
|
||||
// SQLite-Trigger einrichten, der das Löschen von 'protected.txt' blockiert
|
||||
{
|
||||
let conn = db.conn();
|
||||
conn.execute(
|
||||
"CREATE TRIGGER block_delete_protected BEFORE DELETE ON nodes
|
||||
WHEN OLD.name = 'protected.txt'
|
||||
BEGIN
|
||||
SELECT RAISE(FAIL, 'Löschen von protected.txt blockiert');
|
||||
END;",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// ST-02: delete_node(parent.id) darf den Fehler nicht mehr mit `let _ =` verschlucken!
|
||||
let res = db.delete_node(parent.id);
|
||||
assert!(
|
||||
res.is_err(),
|
||||
"Löschen muss fehlschlagen, wenn ein Kindknoten nicht gelöscht werden kann"
|
||||
);
|
||||
let err_msg = res.unwrap_err().to_string();
|
||||
assert!(
|
||||
err_msg.contains(
|
||||
"Fehler beim rekursiven Löschen: 1 Kindknoten konnten nicht gelöscht werden"
|
||||
),
|
||||
"Fehlermeldung muss die Anzahl fehlgeschlagener Kindknoten exakt ausweisen: {}",
|
||||
err_msg
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_st03_recursive_delete_transaction_rollback() {
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
let (salt, kdf, wrapped_dek, nonce, tag) = (
|
||||
[1u8; 16],
|
||||
KdfParams::default(),
|
||||
vec![2u8; 40],
|
||||
[3u8; 12],
|
||||
[4u8; 16],
|
||||
);
|
||||
db.init_schema(&salt, &kdf, &wrapped_dek, &nonce, &tag)
|
||||
.unwrap();
|
||||
|
||||
// 1. Ordner mit zwei Dateien anlegen
|
||||
let folder = db.create_node(1, "folder", true).unwrap();
|
||||
let file1 = db.create_node(folder.id, "file1.txt", false).unwrap();
|
||||
let file2 = db.create_node(folder.id, "file2.txt", false).unwrap();
|
||||
|
||||
// Chunks für file1 und file2 anlegen
|
||||
let chunk_data = vec![0x42u8; 1024];
|
||||
let nonce = [1u8; 12];
|
||||
let tag = [2u8; 16];
|
||||
db.write_chunk(file1.id, 0, 1, &nonce, &tag, &chunk_data)
|
||||
.unwrap();
|
||||
db.write_chunk(file2.id, 0, 1, &nonce, &tag, &chunk_data)
|
||||
.unwrap();
|
||||
|
||||
// 2. Trigger erstellen, der Löschen von file2 verhindert
|
||||
{
|
||||
let conn = db.conn();
|
||||
conn.execute(
|
||||
"CREATE TRIGGER prevent_del_file2 BEFORE DELETE ON nodes
|
||||
WHEN OLD.name = 'file2.txt'
|
||||
BEGIN
|
||||
SELECT RAISE(FAIL, 'file2 ist schreibgeschützt');
|
||||
END;",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// 3. Löschen des gesamten Ordners aufrufen -> schlägt fehl
|
||||
let res = db.delete_node(folder.id);
|
||||
assert!(res.is_err(), "delete_node muss fehlschlagen");
|
||||
|
||||
// 4. ST-03 Atomaritätsprüfung (Rollback):
|
||||
// Weder folder noch file1 noch file2 dürfen gelöscht worden sein!
|
||||
assert!(
|
||||
db.get_node_by_id(folder.id).unwrap().is_some(),
|
||||
"Folder darf nach Rollback nicht gelöscht sein"
|
||||
);
|
||||
assert!(
|
||||
db.get_node_by_id(file1.id).unwrap().is_some(),
|
||||
"file1 darf nach Rollback nicht gelöscht sein"
|
||||
);
|
||||
assert!(
|
||||
db.get_node_by_id(file2.id).unwrap().is_some(),
|
||||
"file2 darf nach Rollback nicht gelöscht sein"
|
||||
);
|
||||
|
||||
// Chunk von file1 muss unversehrt existieren
|
||||
let chunk1 = db.read_chunk(file1.id, 0).unwrap();
|
||||
assert!(
|
||||
chunk1.is_some(),
|
||||
"Chunk von file1 darf nach Rollback nicht gelöscht sein"
|
||||
);
|
||||
assert_eq!(
|
||||
chunk1.unwrap().ciphertext,
|
||||
chunk_data,
|
||||
"Chunk von file1 muss unversehrt sein"
|
||||
);
|
||||
|
||||
// 5. Trigger entfernen und erneut löschen -> muss jetzt vollständig gelingen
|
||||
{
|
||||
let conn = db.conn();
|
||||
conn.execute("DROP TRIGGER prevent_del_file2", []).unwrap();
|
||||
}
|
||||
db.delete_node(folder.id)
|
||||
.expect("delete_node nach Trigger-Entfernung muss gelingen");
|
||||
|
||||
assert!(
|
||||
db.get_node_by_id(folder.id).unwrap().is_none(),
|
||||
"Folder muss gelöscht sein"
|
||||
);
|
||||
assert!(
|
||||
db.get_node_by_id(file1.id).unwrap().is_none(),
|
||||
"file1 muss gelöscht sein"
|
||||
);
|
||||
assert!(
|
||||
db.get_node_by_id(file2.id).unwrap().is_none(),
|
||||
"file2 muss gelöscht sein"
|
||||
);
|
||||
assert!(
|
||||
db.read_chunk(file1.id, 0).unwrap().is_none(),
|
||||
"Chunk von file1 muss gelöscht sein"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+11
@@ -788,6 +788,17 @@ impl SanctumFs {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn sync_carrier_manifest(&self) -> Result<()> {
|
||||
if let Some(ref cfs) = self.carrier_fs {
|
||||
cfs.sync_manifest()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn carrier_manifest_usage(&self) -> Option<(usize, usize, f64)> {
|
||||
self.carrier_fs.as_ref().map(|cfs| cfs.manifest_usage())
|
||||
}
|
||||
|
||||
pub fn leak_count(&self) -> u64 {
|
||||
self.leak_counter().load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
@@ -923,3 +923,534 @@ fn test_m05_deniability_schema_equality_standard_vs_hidden() {
|
||||
let _ = std::fs::remove_file(&path_std);
|
||||
let _ = std::fs::remove_file(&path_hidden);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_c02_dual_block_rolling_manifest_redundancy_and_recovery() {
|
||||
let path = temp_db_path("c02_rolling_manifest");
|
||||
let carrier_size_bytes = 10 * 1024 * 1024;
|
||||
let carrier_name = "test_rolling.dat";
|
||||
let pass_decoy = "DecoyPassword2026!";
|
||||
let pass_hidden = "SuperSecretHiddenPassword2026!";
|
||||
|
||||
let kdf_params = KdfParams {
|
||||
memory_cost: MIN_MEMORY_COST_KIB,
|
||||
time_cost: MIN_TIME_COST,
|
||||
parallelism: 1,
|
||||
};
|
||||
|
||||
let salt_0 = generate_salt();
|
||||
let kek_0 = derive_kek(pass_decoy, &salt_0, &kdf_params).unwrap();
|
||||
let dek_0 = generate_dek();
|
||||
|
||||
let salt_1 = generate_salt();
|
||||
let kek_1 = derive_kek(pass_hidden, &salt_1, &kdf_params).unwrap();
|
||||
let dek_1 = generate_dek();
|
||||
|
||||
let carrier_node_id = 3i64;
|
||||
let (wrapped_0, nonce_0, tag_0) = wrap_slot0_payload(&kek_0, &dek_0, carrier_node_id).unwrap();
|
||||
let (wrapped_1, nonce_1, tag_1) =
|
||||
wrap_slot1_payload(&kek_1, &dek_1, &dek_0, carrier_node_id).unwrap();
|
||||
|
||||
let db = Database::open(&path).expect("Open database");
|
||||
db.init_schema_with_carrier(
|
||||
&salt_0,
|
||||
&kdf_params,
|
||||
&wrapped_0,
|
||||
&nonce_0,
|
||||
&tag_0,
|
||||
Some((
|
||||
carrier_name,
|
||||
carrier_size_bytes,
|
||||
&salt_1,
|
||||
&kdf_params,
|
||||
&wrapped_1,
|
||||
&nonce_1,
|
||||
&tag_1,
|
||||
&dek_0,
|
||||
&dek_1,
|
||||
)),
|
||||
)
|
||||
.expect("Init carrier schema");
|
||||
db.checkpoint().unwrap();
|
||||
|
||||
let meta = db.read_meta().unwrap();
|
||||
let auth_hidden = meta.authenticate(pass_hidden).expect("Auth hidden");
|
||||
|
||||
let cfs = sanctum::carrier::CarrierFs::load(
|
||||
db.clone(),
|
||||
carrier_node_id,
|
||||
std::sync::Arc::new(auth_hidden.carrier_dek().unwrap().clone()),
|
||||
std::sync::Arc::new(auth_hidden.dek().clone()),
|
||||
auth_hidden.version(),
|
||||
true,
|
||||
)
|
||||
.expect("Initial load of dual-block manifest");
|
||||
|
||||
// Datei anlegen -> save_manifest() -> Gen 1 -> schreibt Block 1
|
||||
let file1_path = DavPath::new("/file_gen1.txt").unwrap();
|
||||
let open_write = OpenOptions {
|
||||
create_new: true,
|
||||
write: true,
|
||||
..Default::default()
|
||||
};
|
||||
let mut file1 = cfs
|
||||
.open(&file1_path, open_write)
|
||||
.await
|
||||
.expect("Create file1");
|
||||
file1
|
||||
.write_bytes(Bytes::from_static(b"generation 1 content"))
|
||||
.await
|
||||
.unwrap();
|
||||
file1.flush().await.unwrap();
|
||||
drop(file1);
|
||||
drop(cfs);
|
||||
|
||||
// Verifiziere: Block 1 hat jetzt Gen 1
|
||||
let cfs_reloaded = sanctum::carrier::CarrierFs::load(
|
||||
db.clone(),
|
||||
carrier_node_id,
|
||||
std::sync::Arc::new(auth_hidden.carrier_dek().unwrap().clone()),
|
||||
std::sync::Arc::new(auth_hidden.dek().clone()),
|
||||
auth_hidden.version(),
|
||||
true,
|
||||
)
|
||||
.expect("Reload higher generation from block 1");
|
||||
|
||||
// Zweite Datei anlegen -> save_manifest() -> Gen 2 -> schreibt Block 0
|
||||
let file2_path = DavPath::new("/file_gen2.txt").unwrap();
|
||||
let open_write2 = OpenOptions {
|
||||
create_new: true,
|
||||
write: true,
|
||||
..Default::default()
|
||||
};
|
||||
let mut file2 = cfs_reloaded
|
||||
.open(&file2_path, open_write2)
|
||||
.await
|
||||
.expect("Create file2");
|
||||
file2
|
||||
.write_bytes(Bytes::from_static(b"generation 2 content"))
|
||||
.await
|
||||
.unwrap();
|
||||
file2.flush().await.unwrap();
|
||||
drop(file2);
|
||||
drop(cfs_reloaded);
|
||||
|
||||
// 2. Simuliere Bitrot auf Block 0 (gezielt mit Rauschen überschreiben)
|
||||
{
|
||||
let conn = rusqlite::Connection::open(&path).unwrap();
|
||||
let mut noise = vec![0u8; 100];
|
||||
OsRng.fill_bytes(&mut noise);
|
||||
conn.execute(
|
||||
"UPDATE chunks SET ciphertext = ?1 WHERE node_id = ?2 AND chunk_index = 0",
|
||||
rusqlite::params![noise, carrier_node_id],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// Laden MUSS über Block 1 gelingen (Failover-Schutz)
|
||||
let cfs_recovered_from_b1 = sanctum::carrier::CarrierFs::load(
|
||||
db.clone(),
|
||||
carrier_node_id,
|
||||
std::sync::Arc::new(auth_hidden.carrier_dek().unwrap().clone()),
|
||||
std::sync::Arc::new(auth_hidden.dek().clone()),
|
||||
auth_hidden.version(),
|
||||
true,
|
||||
)
|
||||
.expect("Recovery from Block 1 must succeed despite damaged Block 0");
|
||||
|
||||
assert!(cfs_recovered_from_b1.metadata(&file1_path).await.is_ok());
|
||||
drop(cfs_recovered_from_b1);
|
||||
|
||||
// 3. Simuliere Beschädigung von Block 1, während Block 0 intakt ist
|
||||
let cfs_repair = sanctum::carrier::CarrierFs::load(
|
||||
db.clone(),
|
||||
carrier_node_id,
|
||||
std::sync::Arc::new(auth_hidden.carrier_dek().unwrap().clone()),
|
||||
std::sync::Arc::new(auth_hidden.dek().clone()),
|
||||
auth_hidden.version(),
|
||||
true,
|
||||
)
|
||||
.unwrap();
|
||||
let file3_path = DavPath::new("/file_gen3.txt").unwrap();
|
||||
let mut file3 = cfs_repair
|
||||
.open(
|
||||
&file3_path,
|
||||
OpenOptions {
|
||||
create_new: true,
|
||||
write: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
file3
|
||||
.write_bytes(Bytes::from_static(b"gen 3"))
|
||||
.await
|
||||
.unwrap();
|
||||
file3.flush().await.unwrap();
|
||||
drop(file3);
|
||||
drop(cfs_repair);
|
||||
|
||||
// Jetzt Block 1 beschädigen
|
||||
{
|
||||
let conn = rusqlite::Connection::open(&path).unwrap();
|
||||
let mut noise = vec![0u8; 100];
|
||||
OsRng.fill_bytes(&mut noise);
|
||||
conn.execute(
|
||||
"UPDATE chunks SET ciphertext = ?1 WHERE node_id = ?2 AND chunk_index = 1",
|
||||
rusqlite::params![noise, carrier_node_id],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// Laden MUSS über Block 0 gelingen
|
||||
let cfs_recovered_from_b0 = sanctum::carrier::CarrierFs::load(
|
||||
db.clone(),
|
||||
carrier_node_id,
|
||||
std::sync::Arc::new(auth_hidden.carrier_dek().unwrap().clone()),
|
||||
std::sync::Arc::new(auth_hidden.dek().clone()),
|
||||
auth_hidden.version(),
|
||||
true,
|
||||
)
|
||||
.expect("Recovery from Block 0 must succeed despite damaged Block 1");
|
||||
drop(cfs_recovered_from_b0);
|
||||
|
||||
// 4. Wenn BEIDE Blöcke zerstört sind: Totalverlust mit klarer Fehlermeldung
|
||||
{
|
||||
let conn = rusqlite::Connection::open(&path).unwrap();
|
||||
let mut noise = vec![0u8; 100];
|
||||
OsRng.fill_bytes(&mut noise);
|
||||
conn.execute(
|
||||
"UPDATE chunks SET ciphertext = ?1 WHERE node_id = ?2 AND chunk_index = 0",
|
||||
rusqlite::params![noise, carrier_node_id],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
let both_damaged = sanctum::carrier::CarrierFs::load(
|
||||
db.clone(),
|
||||
carrier_node_id,
|
||||
std::sync::Arc::new(auth_hidden.carrier_dek().unwrap().clone()),
|
||||
std::sync::Arc::new(auth_hidden.dek().clone()),
|
||||
auth_hidden.version(),
|
||||
true,
|
||||
);
|
||||
let err_msg = match both_damaged {
|
||||
Ok(_) => panic!("Both blocks damaged must return Error"),
|
||||
Err(e) => e.to_string(),
|
||||
};
|
||||
assert!(
|
||||
err_msg.contains("Kritischer Datenverlust (C-02)"),
|
||||
"Error message must clearly identify C-02 failure: {}",
|
||||
err_msg
|
||||
);
|
||||
|
||||
drop(db);
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_c03_manifest_dirty_decoupling_and_unmount_flush() {
|
||||
let path = temp_db_path("c03_dirty");
|
||||
let carrier_size_bytes = 5 * 1024 * 1024; // 5 MB
|
||||
let carrier_name = "carrier_c03.bin";
|
||||
|
||||
let pass_decoy = "DecoyPass2026!";
|
||||
let pass_hidden = "HiddenPass2026!";
|
||||
|
||||
let kdf_params = KdfParams {
|
||||
memory_cost: MIN_MEMORY_COST_KIB,
|
||||
time_cost: MIN_TIME_COST,
|
||||
parallelism: 1,
|
||||
};
|
||||
|
||||
let salt_0 = generate_salt();
|
||||
let kek_0 = derive_kek(pass_decoy, &salt_0, &kdf_params).unwrap();
|
||||
let dek_0 = generate_dek();
|
||||
|
||||
let salt_1 = generate_salt();
|
||||
let kek_1 = derive_kek(pass_hidden, &salt_1, &kdf_params).unwrap();
|
||||
let dek_1 = generate_dek();
|
||||
|
||||
let carrier_node_id = 3i64;
|
||||
let (wrapped_0, nonce_0, tag_0) = wrap_slot0_payload(&kek_0, &dek_0, carrier_node_id).unwrap();
|
||||
let (wrapped_1, nonce_1, tag_1) =
|
||||
wrap_slot1_payload(&kek_1, &dek_1, &dek_0, carrier_node_id).unwrap();
|
||||
|
||||
let db = Database::open(&path).expect("Open database");
|
||||
db.init_schema_with_carrier(
|
||||
&salt_0,
|
||||
&kdf_params,
|
||||
&wrapped_0,
|
||||
&nonce_0,
|
||||
&tag_0,
|
||||
Some((
|
||||
carrier_name,
|
||||
carrier_size_bytes,
|
||||
&salt_1,
|
||||
&kdf_params,
|
||||
&wrapped_1,
|
||||
&nonce_1,
|
||||
&tag_1,
|
||||
&dek_0,
|
||||
&dek_1,
|
||||
)),
|
||||
)
|
||||
.expect("Init carrier schema");
|
||||
db.checkpoint().unwrap();
|
||||
|
||||
let meta = db.read_meta().unwrap();
|
||||
let auth_hidden = meta.authenticate(pass_hidden).expect("Auth hidden");
|
||||
let cfs = sanctum::carrier::CarrierFs::load(
|
||||
db.clone(),
|
||||
carrier_node_id,
|
||||
std::sync::Arc::new(auth_hidden.carrier_dek().unwrap().clone()),
|
||||
std::sync::Arc::new(auth_hidden.dek().clone()),
|
||||
auth_hidden.version(),
|
||||
true,
|
||||
)
|
||||
.expect("CarrierFs load");
|
||||
|
||||
// 1. Unmittelbar nach Mounten: nicht dirty, 0 Saves
|
||||
assert_eq!(cfs.manifest_save_count(), 0);
|
||||
assert!(!cfs.is_manifest_dirty());
|
||||
|
||||
// 2. Metadaten-Operationen: 3 Ordner anlegen
|
||||
let dir1 = DavPath::new("/dir1").unwrap();
|
||||
let dir2 = DavPath::new("/dir2").unwrap();
|
||||
let dir3 = DavPath::new("/dir3").unwrap();
|
||||
cfs.create_dir(&dir1).await.expect("create_dir dir1");
|
||||
cfs.create_dir(&dir2).await.expect("create_dir dir2");
|
||||
cfs.create_dir(&dir3).await.expect("create_dir dir3");
|
||||
|
||||
// C-03 Verifikation: Manifest ist dirty, aber save_count ist IMMER NOCH 0!
|
||||
assert!(
|
||||
cfs.is_manifest_dirty(),
|
||||
"Manifest must be dirty after create_dir"
|
||||
);
|
||||
assert_eq!(
|
||||
cfs.manifest_save_count(),
|
||||
0,
|
||||
"No synchronous save_manifest on create_dir (C-03 decoupling)"
|
||||
);
|
||||
|
||||
// Datei im Verzeichnis erstellen
|
||||
let file_path = DavPath::new("/dir1/note.txt").unwrap();
|
||||
let open_write = OpenOptions {
|
||||
create_new: true,
|
||||
write: true,
|
||||
..Default::default()
|
||||
};
|
||||
let mut file = cfs.open(&file_path, open_write).await.expect("open create");
|
||||
assert_eq!(
|
||||
cfs.manifest_save_count(),
|
||||
0,
|
||||
"No synchronous save_manifest on file create"
|
||||
);
|
||||
|
||||
// 3. Expliziter Sync über sync_manifest()
|
||||
cfs.sync_manifest().expect("sync_manifest");
|
||||
assert!(
|
||||
!cfs.is_manifest_dirty(),
|
||||
"Manifest must be clean after sync"
|
||||
);
|
||||
assert_eq!(
|
||||
cfs.manifest_save_count(),
|
||||
1,
|
||||
"Save count incremented to 1 after sync"
|
||||
);
|
||||
|
||||
// Nochmal sync: da nicht dirty, kein unnötiger Save
|
||||
cfs.sync_manifest().expect("sync_manifest 2");
|
||||
assert_eq!(
|
||||
cfs.manifest_save_count(),
|
||||
1,
|
||||
"Save count remains 1 when not dirty"
|
||||
);
|
||||
|
||||
// 4. In Datei schreiben und flush() ausführen -> führt zu Manifest-Flush
|
||||
file.write_bytes(Bytes::from_static(b"carrier dirty test payload"))
|
||||
.await
|
||||
.unwrap();
|
||||
file.flush().await.expect("file flush");
|
||||
assert_eq!(
|
||||
cfs.manifest_save_count(),
|
||||
2,
|
||||
"File flush must flush manifest"
|
||||
);
|
||||
assert!(!cfs.is_manifest_dirty());
|
||||
drop(file);
|
||||
|
||||
// 5. Weiteren Ordner anlegen (mark_dirty), dann cfs droppen
|
||||
let dir4 = DavPath::new("/dir4").unwrap();
|
||||
cfs.create_dir(&dir4).await.expect("create_dir dir4");
|
||||
assert!(cfs.is_manifest_dirty());
|
||||
assert_eq!(cfs.manifest_save_count(), 2);
|
||||
|
||||
// Drop von CarrierFs (Drop-Garantie bei Unmount)
|
||||
drop(cfs);
|
||||
|
||||
// 6. Neu laden und prüfen, dass dir4 persistiert wurde
|
||||
let cfs_reloaded = sanctum::carrier::CarrierFs::load(
|
||||
db.clone(),
|
||||
carrier_node_id,
|
||||
std::sync::Arc::new(auth_hidden.carrier_dek().unwrap().clone()),
|
||||
std::sync::Arc::new(auth_hidden.dek().clone()),
|
||||
auth_hidden.version(),
|
||||
true,
|
||||
)
|
||||
.expect("CarrierFs reload");
|
||||
|
||||
assert!(
|
||||
cfs_reloaded
|
||||
.read_dir(&dir4, ReadDirMeta::None)
|
||||
.await
|
||||
.is_ok(),
|
||||
"Directory created before drop must exist"
|
||||
);
|
||||
|
||||
drop(cfs_reloaded);
|
||||
drop(db);
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_c04_manifest_capacity_limit_and_warning() {
|
||||
let path = temp_db_path("c04_capacity");
|
||||
let carrier_size_bytes = 10 * 1024 * 1024; // 10 MB
|
||||
let carrier_name = "carrier_c04.bin";
|
||||
|
||||
let pass_decoy = "DecoyPass2026!";
|
||||
let pass_hidden = "HiddenPass2026!";
|
||||
|
||||
let kdf_params = KdfParams {
|
||||
memory_cost: MIN_MEMORY_COST_KIB,
|
||||
time_cost: MIN_TIME_COST,
|
||||
parallelism: 1,
|
||||
};
|
||||
|
||||
let salt_0 = generate_salt();
|
||||
let kek_0 = derive_kek(pass_decoy, &salt_0, &kdf_params).unwrap();
|
||||
let dek_0 = generate_dek();
|
||||
|
||||
let salt_1 = generate_salt();
|
||||
let kek_1 = derive_kek(pass_hidden, &salt_1, &kdf_params).unwrap();
|
||||
let dek_1 = generate_dek();
|
||||
|
||||
let carrier_node_id = 3i64;
|
||||
let (wrapped_0, nonce_0, tag_0) = wrap_slot0_payload(&kek_0, &dek_0, carrier_node_id).unwrap();
|
||||
let (wrapped_1, nonce_1, tag_1) =
|
||||
wrap_slot1_payload(&kek_1, &dek_1, &dek_0, carrier_node_id).unwrap();
|
||||
|
||||
let db = Database::open(&path).expect("Open database");
|
||||
db.init_schema_with_carrier(
|
||||
&salt_0,
|
||||
&kdf_params,
|
||||
&wrapped_0,
|
||||
&nonce_0,
|
||||
&tag_0,
|
||||
Some((
|
||||
carrier_name,
|
||||
carrier_size_bytes,
|
||||
&salt_1,
|
||||
&kdf_params,
|
||||
&wrapped_1,
|
||||
&nonce_1,
|
||||
&tag_1,
|
||||
&dek_0,
|
||||
&dek_1,
|
||||
)),
|
||||
)
|
||||
.expect("Init carrier schema");
|
||||
db.checkpoint().unwrap();
|
||||
|
||||
let meta = db.read_meta().unwrap();
|
||||
let auth_hidden = meta.authenticate(pass_hidden).expect("Auth hidden");
|
||||
let cfs = sanctum::carrier::CarrierFs::load(
|
||||
db.clone(),
|
||||
carrier_node_id,
|
||||
std::sync::Arc::new(auth_hidden.carrier_dek().unwrap().clone()),
|
||||
std::sync::Arc::new(auth_hidden.dek().clone()),
|
||||
auth_hidden.version(),
|
||||
true,
|
||||
)
|
||||
.expect("CarrierFs load");
|
||||
|
||||
// 1. Initialer Zustand: Manifest minimal (< 5 KB, << 80%)
|
||||
let (len, cap, pct) = cfs.manifest_usage();
|
||||
assert_eq!(cap, sanctum::carrier::CARRIER_MANIFEST_CAPACITY);
|
||||
assert!(len < 5_000);
|
||||
assert!(pct < 1.0);
|
||||
|
||||
// 2. Fülle Manifest gezielt auf > 80% (> 838.809 Bytes)
|
||||
// Erstelle Verzeichnisse mit langen Pfadnamen, bis die Größe über 840 KB liegt
|
||||
for i in 0..1000 {
|
||||
let dir_path = DavPath::new(&format!("/d_{:04}_{}", i, "a".repeat(1000))).unwrap();
|
||||
cfs.create_dir(&dir_path).await.expect("create_dir");
|
||||
let (current_len, _, _) = cfs.manifest_usage();
|
||||
if current_len >= sanctum::carrier::CARRIER_MANIFEST_WARN_THRESHOLD {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let (len80, _, pct80) = cfs.manifest_usage();
|
||||
assert!(
|
||||
len80 >= sanctum::carrier::CARRIER_MANIFEST_WARN_THRESHOLD,
|
||||
"Manifest size must exceed 80% threshold, got: {}",
|
||||
len80
|
||||
);
|
||||
assert!(
|
||||
pct80 >= 80.0,
|
||||
"Manifest pct must be >= 80.0%, got: {}",
|
||||
pct80
|
||||
);
|
||||
|
||||
// Speichern (sync_manifest) muss mit Warnung gelingen
|
||||
cfs.sync_manifest()
|
||||
.expect("sync_manifest at > 80% must succeed");
|
||||
drop(cfs);
|
||||
|
||||
// Neu laden: muss erfolgreich laden und beim Laden die 80%-Warnung auslösen
|
||||
let cfs_loaded = sanctum::carrier::CarrierFs::load(
|
||||
db.clone(),
|
||||
carrier_node_id,
|
||||
std::sync::Arc::new(auth_hidden.carrier_dek().unwrap().clone()),
|
||||
std::sync::Arc::new(auth_hidden.dek().clone()),
|
||||
auth_hidden.version(),
|
||||
true,
|
||||
)
|
||||
.expect("CarrierFs load at > 80% must succeed");
|
||||
let (reloaded_len, _, reloaded_pct) = cfs_loaded.manifest_usage();
|
||||
assert!(reloaded_pct >= 80.0);
|
||||
assert_eq!(reloaded_len, len80);
|
||||
|
||||
// 3. Grenze überschreiten: weiter füllen bis > 1.048.512 Bytes
|
||||
for i in 1000..1500 {
|
||||
let dir_path = DavPath::new(&format!("/overflow_{:04}_{}", i, "x".repeat(1000))).unwrap();
|
||||
cfs_loaded
|
||||
.create_dir(&dir_path)
|
||||
.await
|
||||
.expect("create_dir overflow");
|
||||
let (cur_len, _, _) = cfs_loaded.manifest_usage();
|
||||
if cur_len > sanctum::carrier::CARRIER_MANIFEST_CAPACITY {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// sync_manifest() MUSS jetzt mit Fehler abbrechen
|
||||
let err = cfs_loaded.sync_manifest().unwrap_err();
|
||||
let err_str = err.to_string();
|
||||
assert!(
|
||||
err_str.contains("Carrier-Manifest überschreitet die maximale Blockgröße"),
|
||||
"Error message must specify carrier capacity limit: {}",
|
||||
err_str
|
||||
);
|
||||
assert!(
|
||||
err_str.contains("7.000 Dateien/Verzeichnisse"),
|
||||
"Error message must recommend file limit: {}",
|
||||
err_str
|
||||
);
|
||||
|
||||
drop(cfs_loaded);
|
||||
drop(db);
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user