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/)
|
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.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
|
## [0.8.0] - 2026-09-19
|
||||||
|
|
||||||
### Security Audit Remediation & Format V3 Release
|
### Security Audit Remediation & Format V3 Release
|
||||||
|
|||||||
Generated
+1
-1
@@ -1457,7 +1457,7 @@ checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sanctum"
|
name = "sanctum"
|
||||||
version = "0.8.0"
|
version = "0.8.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "sanctum"
|
name = "sanctum"
|
||||||
version = "0.8.0"
|
version = "0.8.1"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
authors = ["Harald Pansi <harald@pansi.eu>", "Sanctum Engineering Team"]
|
authors = ["Harald Pansi <harald@pansi.eu>", "Sanctum Engineering Team"]
|
||||||
description = "Verschlüsselter Ein-Datei-Container unter Windows im reinen Userland via WebDAV"
|
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`).
|
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).
|
2. **Hidden-Passwort**: Öffnet den geheimen Zweittresor (Second Safe).
|
||||||
* *(Hinweis: Schützt vor Schulterblick im Alltag; keine forensische Abstreitbarkeit gegen behördliche Entropieanalyse).*
|
* *(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!
|
* **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.
|
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)**:
|
- **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).
|
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-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`).
|
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)**:
|
- **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
|
## Audit-Zusammenfassung
|
||||||
|
|
||||||
- **Zielversion:** Sanctum v0.8.0
|
- **Zielversion:** Sanctum v0.8.1
|
||||||
- **Behobene Findings:** 33 / 33 (100%)
|
- **Behobene Findings:** 39 / 39 (100%)
|
||||||
- **Test-Ergebnis:** 117 / 117 Tests erfolgreich (100% Pass Rate)
|
- **Test-Ergebnis:** 123 / 123 Tests erfolgreich (100% Pass Rate)
|
||||||
- **Quality Gates:** `cargo fmt --check` (100% sauber), `cargo clippy --all-targets -- -D warnings` (0 Warnungen)
|
- **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).
|
- **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 |
|
| 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`) |
|
| **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-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`) |
|
| **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`).
|
- NTFS Alternate Data Streams (ADS): Alle Zugriffe mit `:` (z. B. `Zone.Identifier`).
|
||||||
- Wildcards: `.trash*`.
|
- Wildcards: `.trash*`.
|
||||||
- Option `--anti-leak-list <FILE>` für benutzerdefinierte Ausschlussmuster.
|
- 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
|
## 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",
|
"description": "Verschlüsselter Ein-Datei-Container unter Windows im reinen Userland via WebDAV",
|
||||||
"homepage": "https://gitea.pansi.eu/harald/sanctum",
|
"homepage": "https://gitea.pansi.eu/harald/sanctum",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"architecture": {
|
"architecture": {
|
||||||
"64bit": {
|
"64bit": {
|
||||||
"url": "https://gitea.pansi.eu/harald/sanctum/releases/download/v0.8.0/sanctum-v0.8.0-windows-x86_64.zip",
|
"url": "https://gitea.pansi.eu/harald/sanctum/releases/download/v0.8.1/sanctum-v0.8.1-windows-x86_64.zip",
|
||||||
"hash": "11fa00bdd0dd48599ee578202c09c86ccbb7b971932bf14c8b4d11817aa26419",
|
"hash": "11f06952f743d625c1cda627df685064a91cd973e52b17e7bcaf96385bb4969c",
|
||||||
"bin": "sanctum.exe"
|
"bin": "sanctum.exe"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# yaml-language-server: $schema=https://aka.ms/winget-manifest.singleton.1.6.0.schema.json
|
# yaml-language-server: $schema=https://aka.ms/winget-manifest.singleton.1.6.0.schema.json
|
||||||
PackageIdentifier: HaraldPansi.Sanctum
|
PackageIdentifier: HaraldPansi.Sanctum
|
||||||
PackageVersion: 0.8.0
|
PackageVersion: 0.8.1
|
||||||
PackageName: Sanctum
|
PackageName: Sanctum
|
||||||
Publisher: Harald Pansi
|
Publisher: Harald Pansi
|
||||||
PublisherUrl: https://gitea.pansi.eu/harald
|
PublisherUrl: https://gitea.pansi.eu/harald
|
||||||
@@ -18,7 +18,7 @@ Tags:
|
|||||||
- webdav
|
- webdav
|
||||||
- container
|
- container
|
||||||
- plausible-deniability
|
- 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:
|
Installers:
|
||||||
- Architecture: x64
|
- Architecture: x64
|
||||||
InstallerType: zip
|
InstallerType: zip
|
||||||
@@ -26,7 +26,7 @@ Installers:
|
|||||||
NestedInstallerFiles:
|
NestedInstallerFiles:
|
||||||
- RelativeFilePath: sanctum.exe
|
- RelativeFilePath: sanctum.exe
|
||||||
PortableCommandAlias: sanctum
|
PortableCommandAlias: sanctum
|
||||||
InstallerUrl: https://gitea.pansi.eu/harald/sanctum/releases/download/v0.8.0/sanctum-v0.8.0-windows-x86_64.zip
|
InstallerUrl: https://gitea.pansi.eu/harald/sanctum/releases/download/v0.8.1/sanctum-v0.8.1-windows-x86_64.zip
|
||||||
InstallerSha256: 11fa00bdd0dd48599ee578202c09c86ccbb7b971932bf14c8b4d11817aa26419
|
InstallerSha256: 11f06952f743d625c1cda627df685064a91cd973e52b17e7bcaf96385bb4969c
|
||||||
ManifestType: singleton
|
ManifestType: singleton
|
||||||
ManifestVersion: 1.6.0
|
ManifestVersion: 1.6.0
|
||||||
|
|||||||
+196
-26
@@ -18,7 +18,7 @@ use futures_util::stream;
|
|||||||
use rand::rngs::OsRng;
|
use rand::rngs::OsRng;
|
||||||
use rand::RngCore;
|
use rand::RngCore;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use tracing::{error, warn};
|
use tracing::{debug, error, warn};
|
||||||
use zeroize::{Zeroize, Zeroizing};
|
use zeroize::{Zeroize, Zeroizing};
|
||||||
|
|
||||||
use crate::crypto::{decrypt_chunk, encrypt_chunk, CHUNK_SIZE};
|
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_MAGIC: &[u8; 8] = b"SANCTCAR";
|
||||||
pub const CARRIER_VERSION: u32 = 1;
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct CarrierManifest {
|
pub struct CarrierManifest {
|
||||||
pub magic: [u8; 8],
|
pub magic: [u8; 8],
|
||||||
pub version: u32,
|
pub version: u32,
|
||||||
|
#[serde(default)]
|
||||||
|
pub manifest_generation: u64,
|
||||||
pub total_blocks: u32,
|
pub total_blocks: u32,
|
||||||
pub free_blocks: Vec<u32>,
|
pub free_blocks: Vec<u32>,
|
||||||
pub next_inode_id: i64,
|
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.
|
// Block 0 und Block 1 sind für das redundante Manifest reserviert (C-02). Nutzblöcke sind 2..total_blocks-1.
|
||||||
let free_blocks = (1..total_blocks).collect();
|
let free_blocks = (2..total_blocks).collect();
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
magic: *CARRIER_MAGIC,
|
magic: *CARRIER_MAGIC,
|
||||||
version: CARRIER_VERSION,
|
version: CARRIER_VERSION,
|
||||||
|
manifest_generation: 0,
|
||||||
total_blocks,
|
total_blocks,
|
||||||
free_blocks,
|
free_blocks,
|
||||||
next_inode_id: 2,
|
next_inode_id: 2,
|
||||||
@@ -274,6 +279,10 @@ pub struct CarrierFsInner {
|
|||||||
pub custom_leak_rules: Arc<Vec<String>>,
|
pub custom_leak_rules: Arc<Vec<String>>,
|
||||||
pub leak_counter: Arc<AtomicU64>,
|
pub leak_counter: Arc<AtomicU64>,
|
||||||
pub manifest: CarrierManifest,
|
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>,
|
pub last_activity: Arc<AtomicU64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -281,26 +290,78 @@ impl CarrierFsInner {
|
|||||||
pub fn is_leak(&self, name: &str) -> bool {
|
pub fn is_leak(&self, name: &str) -> bool {
|
||||||
self.anti_leak && is_leak_file_with_custom(name, &self.custom_leak_rules)
|
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<()> {
|
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)?;
|
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!(
|
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(),
|
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!(
|
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(),
|
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(
|
write_carrier_block(
|
||||||
&self.db,
|
&self.db,
|
||||||
self.carrier_node_id,
|
self.carrier_node_id,
|
||||||
0,
|
target_block,
|
||||||
&self.dek_outer,
|
&self.dek_outer,
|
||||||
&self.dek_inner,
|
&self.dek_inner,
|
||||||
&manifest_bytes,
|
&manifest_bytes,
|
||||||
@@ -362,6 +423,14 @@ impl CarrierFsInner {
|
|||||||
|
|
||||||
impl Drop for CarrierFsInner {
|
impl Drop for CarrierFsInner {
|
||||||
fn drop(&mut self) {
|
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_outer.as_ptr(), 32);
|
||||||
crate::windows::unlock_memory(self.dek_inner.as_ptr(), 32);
|
crate::windows::unlock_memory(self.dek_inner.as_ptr(), 32);
|
||||||
}
|
}
|
||||||
@@ -374,7 +443,8 @@ pub struct CarrierFs {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl 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(
|
pub fn load_with_leak_rules(
|
||||||
db: Database,
|
db: Database,
|
||||||
carrier_node_id: i64,
|
carrier_node_id: i64,
|
||||||
@@ -384,18 +454,91 @@ impl CarrierFs {
|
|||||||
anti_leak: bool,
|
anti_leak: bool,
|
||||||
custom_leak_rules: Vec<String>,
|
custom_leak_rules: Vec<String>,
|
||||||
) -> Result<Self> {
|
) -> 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,
|
&db,
|
||||||
carrier_node_id,
|
carrier_node_id,
|
||||||
0,
|
0,
|
||||||
&dek_outer,
|
&dek_outer,
|
||||||
&dek_inner,
|
&dek_inner,
|
||||||
format_version,
|
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 0");
|
||||||
|
}
|
||||||
|
Ok(m)
|
||||||
|
});
|
||||||
|
|
||||||
let manifest: CarrierManifest = serde_json::from_slice(&manifest_bytes)?;
|
let block_1_res = read_carrier_block(
|
||||||
if manifest.magic != *CARRIER_MAGIC {
|
&db,
|
||||||
bail!("Ungültige Carrier-Magic-Bytes in Block 0");
|
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()
|
let now = SystemTime::now()
|
||||||
@@ -417,6 +560,10 @@ impl CarrierFs {
|
|||||||
custom_leak_rules: Arc::new(custom_leak_rules),
|
custom_leak_rules: Arc::new(custom_leak_rules),
|
||||||
leak_counter: Arc::new(AtomicU64::new(0)),
|
leak_counter: Arc::new(AtomicU64::new(0)),
|
||||||
manifest,
|
manifest,
|
||||||
|
manifest_dirty: false,
|
||||||
|
manifest_save_count: 0,
|
||||||
|
dirty_ops_count: 0,
|
||||||
|
last_manifest_save: now,
|
||||||
last_activity: Arc::new(AtomicU64::new(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> {
|
pub fn leak_counter(&self) -> Arc<AtomicU64> {
|
||||||
let inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
|
let inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
inner.leak_counter.clone()
|
inner.leak_counter.clone()
|
||||||
@@ -523,7 +690,7 @@ impl DavFileSystem for CarrierFs {
|
|||||||
n.size = 0;
|
n.size = 0;
|
||||||
n.modified_at = now;
|
n.modified_at = now;
|
||||||
inner.manifest.inodes.insert(n.id, n.clone());
|
inner.manifest.inodes.insert(n.id, n.clone());
|
||||||
let _ = inner.save_manifest();
|
inner.mark_dirty();
|
||||||
}
|
}
|
||||||
n
|
n
|
||||||
}
|
}
|
||||||
@@ -554,7 +721,7 @@ impl DavFileSystem for CarrierFs {
|
|||||||
};
|
};
|
||||||
|
|
||||||
inner.manifest.inodes.insert(new_id, new_inode.clone());
|
inner.manifest.inodes.insert(new_id, new_inode.clone());
|
||||||
let _ = inner.save_manifest();
|
inner.mark_dirty();
|
||||||
new_inode
|
new_inode
|
||||||
} else {
|
} else {
|
||||||
return Err(FsError::NotFound);
|
return Err(FsError::NotFound);
|
||||||
@@ -676,7 +843,7 @@ impl DavFileSystem for CarrierFs {
|
|||||||
};
|
};
|
||||||
|
|
||||||
inner.manifest.inodes.insert(new_id, new_dir);
|
inner.manifest.inodes.insert(new_id, new_dir);
|
||||||
inner.save_manifest().map_err(|_| FsError::GeneralFailure)?;
|
inner.mark_dirty();
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
@@ -709,7 +876,7 @@ impl DavFileSystem for CarrierFs {
|
|||||||
}
|
}
|
||||||
|
|
||||||
inner.manifest.inodes.remove(&node.id);
|
inner.manifest.inodes.remove(&node.id);
|
||||||
inner.save_manifest().map_err(|_| FsError::GeneralFailure)?;
|
inner.mark_dirty();
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
@@ -738,7 +905,7 @@ impl DavFileSystem for CarrierFs {
|
|||||||
}
|
}
|
||||||
|
|
||||||
inner.manifest.inodes.remove(&node.id);
|
inner.manifest.inodes.remove(&node.id);
|
||||||
inner.save_manifest().map_err(|_| FsError::GeneralFailure)?;
|
inner.mark_dirty();
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
@@ -795,7 +962,7 @@ impl DavFileSystem for CarrierFs {
|
|||||||
inode.modified_at = now;
|
inode.modified_at = now;
|
||||||
}
|
}
|
||||||
|
|
||||||
inner.save_manifest().map_err(|_| FsError::GeneralFailure)?;
|
inner.mark_dirty();
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
@@ -924,7 +1091,7 @@ impl DavFileSystem for CarrierFs {
|
|||||||
};
|
};
|
||||||
|
|
||||||
inner.manifest.inodes.insert(new_id, new_inode);
|
inner.manifest.inodes.insert(new_id, new_inode);
|
||||||
inner.save_manifest().map_err(|_| FsError::GeneralFailure)?;
|
inner.mark_dirty();
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
@@ -1231,7 +1398,10 @@ impl DavFile for CarrierFile {
|
|||||||
inode.modified_at = now;
|
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.size = self.file_size;
|
||||||
self.meta.modified_at = UNIX_EPOCH + Duration::from_secs(now);
|
self.meta.modified_at = UNIX_EPOCH + Duration::from_secs(now);
|
||||||
@@ -1261,7 +1431,7 @@ impl Drop for CarrierFile {
|
|||||||
inode.blocks = self.blocks.clone();
|
inode.blocks = self.blocks.clone();
|
||||||
inode.modified_at = now;
|
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 fs_leak_counter = fs.leak_counter();
|
||||||
let last_activity = fs.last_activity();
|
let last_activity = fs.last_activity();
|
||||||
|
let fs_clone = fs.clone();
|
||||||
let dav_server = DavHandler::builder()
|
let dav_server = DavHandler::builder()
|
||||||
.filesystem(Box::new(fs))
|
.filesystem(Box::new(fs))
|
||||||
.locksystem(FakeLs::new())
|
.locksystem(FakeLs::new())
|
||||||
@@ -282,6 +283,7 @@ pub async fn mount_container(
|
|||||||
|
|
||||||
// Netzlaufwerk bzw. Verzeichnis einbinden
|
// Netzlaufwerk bzw. Verzeichnis einbinden
|
||||||
if let Err(e) = run_mount_command(drive_letter, bound_port, &session_token) {
|
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 _ = shutdown_tx.send(true);
|
||||||
let _ = server_handle.await;
|
let _ = server_handle.await;
|
||||||
return Err(e);
|
return Err(e);
|
||||||
@@ -420,6 +422,22 @@ pub async fn mount_container(
|
|||||||
if enable_tray {
|
if enable_tray {
|
||||||
println!(" • System-Tray: Icon aktiv (Rechtsklick für Explorer/Trennen)");
|
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!();
|
println!();
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
println!(
|
println!(
|
||||||
@@ -541,6 +559,7 @@ pub async fn mount_container(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if stealth {
|
if stealth {
|
||||||
|
let _ = fs_clone.sync_carrier_manifest();
|
||||||
let _ = unmount_drive(drive_letter);
|
let _ = unmount_drive(drive_letter);
|
||||||
let _ = shutdown_tx.send(true);
|
let _ = shutdown_tx.send(true);
|
||||||
let _ = server_handle.await;
|
let _ = server_handle.await;
|
||||||
@@ -555,6 +574,7 @@ pub async fn mount_container(
|
|||||||
#[cfg(not(windows))]
|
#[cfg(not(windows))]
|
||||||
println!("Sanctum: Container geschlossen und WebDAV-Server beendet.");
|
println!("Sanctum: Container geschlossen und WebDAV-Server beendet.");
|
||||||
} else {
|
} else {
|
||||||
|
let _ = fs_clone.sync_carrier_manifest();
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
{
|
{
|
||||||
print!(
|
print!(
|
||||||
|
|||||||
+469
-69
@@ -6,6 +6,7 @@ use anyhow::{bail, Result};
|
|||||||
use rand::rngs::OsRng;
|
use rand::rngs::OsRng;
|
||||||
use rand::RngCore;
|
use rand::RngCore;
|
||||||
use rusqlite::{params, Connection, OptionalExtension};
|
use rusqlite::{params, Connection, OptionalExtension};
|
||||||
|
use tracing::{error, warn};
|
||||||
|
|
||||||
use crate::crypto::{
|
use crate::crypto::{
|
||||||
compute_metadata_mac, decrypt_node_name, derive_kek, derive_metadata_mac_key,
|
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 = crate::carrier::CarrierManifest::new(total_blocks);
|
||||||
let manifest_bytes = serde_json::to_vec(&manifest)?;
|
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)?;
|
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];
|
let mut outer_plaintext_0 = vec![0u8; CHUNK_SIZE];
|
||||||
OsRng.fill_bytes(&mut outer_plaintext);
|
OsRng.fill_bytes(&mut outer_plaintext_0);
|
||||||
|
|
||||||
outer_plaintext[0..12].copy_from_slice(&inner_nonce);
|
outer_plaintext_0[0..12].copy_from_slice(&inner_nonce_0);
|
||||||
outer_plaintext[12..28].copy_from_slice(&inner_tag);
|
outer_plaintext_0[12..28].copy_from_slice(&inner_tag_0);
|
||||||
outer_plaintext[28..32].copy_from_slice(&inner_ct_len.to_le_bytes());
|
outer_plaintext_0[28..32].copy_from_slice(&inner_ct_len_0.to_le_bytes());
|
||||||
let ct_end = 32 + inner_ct.len();
|
let ct_end_0 = 32 + inner_ct_0.len();
|
||||||
if ct_end > CHUNK_SIZE {
|
if ct_end_0 > CHUNK_SIZE {
|
||||||
bail!("Manifest-Payload zu groß für Block 0");
|
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) =
|
let (outer_ct_0, outer_nonce_0, outer_tag_0) = crate::crypto::encrypt_chunk(
|
||||||
crate::crypto::encrypt_chunk(dek_0, c_id, 0, &outer_plaintext, FORMAT_VERSION, 0)?;
|
dek_0,
|
||||||
|
c_id,
|
||||||
|
0,
|
||||||
|
&outer_plaintext_0,
|
||||||
|
FORMAT_VERSION,
|
||||||
|
0,
|
||||||
|
)?;
|
||||||
|
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO chunks (node_id, chunk_index, generation, nonce, tag, ciphertext)
|
"INSERT INTO chunks (node_id, chunk_index, generation, nonce, tag, ciphertext)
|
||||||
VALUES (?1, 0, 0, ?2, ?3, ?4)",
|
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(
|
let mut chunk_stmt = conn.prepare(
|
||||||
"INSERT INTO chunks (node_id, chunk_index, generation, nonce, tag, ciphertext)
|
"INSERT INTO chunks (node_id, chunk_index, generation, nonce, tag, ciphertext)
|
||||||
VALUES (?1, ?2, 0, ?3, ?4, ?5)",
|
VALUES (?1, ?2, 0, ?3, ?4, ?5)",
|
||||||
@@ -770,7 +820,7 @@ impl Database {
|
|||||||
OsRng.fill_bytes(&mut dummy_noise);
|
OsRng.fill_bytes(&mut dummy_noise);
|
||||||
|
|
||||||
conn.execute_batch("BEGIN TRANSACTION;")?;
|
conn.execute_batch("BEGIN TRANSACTION;")?;
|
||||||
for b in 1..total_blocks {
|
for b in 2..total_blocks {
|
||||||
let (ct, nonce, tag) =
|
let (ct, nonce, tag) =
|
||||||
crate::crypto::encrypt_chunk(dek_0, c_id, b, &dummy_noise, FORMAT_VERSION, 0)?;
|
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,])?;
|
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;
|
let mut matched_record = None;
|
||||||
for r in rows {
|
for r in rows {
|
||||||
let (id, p_id, enc_name, is_dir, size, c_at, m_at) = r?;
|
let (id, p_id, enc_name, is_dir, size, c_at, m_at) = r?;
|
||||||
let dec_name =
|
let dec_name = match decrypt_node_name(dek, p_id.unwrap_or(0), &enc_name) {
|
||||||
decrypt_node_name(dek, p_id.unwrap_or(0), &enc_name).unwrap_or(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 {
|
if dec_name == *segment {
|
||||||
matched_record = Some(NodeRecord {
|
matched_record = Some(NodeRecord {
|
||||||
id,
|
id,
|
||||||
@@ -1433,29 +1491,49 @@ impl Database {
|
|||||||
FROM nodes WHERE id = ?1",
|
FROM nodes WHERE id = ?1",
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
let record = stmt
|
let raw = stmt
|
||||||
.query_row(params![id], |row| {
|
.query_row(params![id], |row| {
|
||||||
let enc_name: String = row.get(2)?;
|
Ok((
|
||||||
let p_id: Option<i64> = row.get(1)?;
|
row.get::<_, i64>(0)?,
|
||||||
let name = if vault_id == 1 {
|
row.get::<_, Option<i64>>(1)?,
|
||||||
decrypt_node_name(dek, p_id.unwrap_or(0), &enc_name).unwrap_or(enc_name)
|
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 {
|
} else {
|
||||||
enc_name
|
enc_name
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(NodeRecord {
|
Ok(Some(NodeRecord {
|
||||||
id: row.get(0)?,
|
id,
|
||||||
parent_id: row.get(1)?,
|
parent_id,
|
||||||
name,
|
name,
|
||||||
is_dir: row.get::<_, i32>(3)? != 0,
|
is_dir,
|
||||||
size: row.get::<_, i64>(4)? as u64,
|
size,
|
||||||
created_at: row.get::<_, i64>(5)? as u64,
|
created_at,
|
||||||
modified_at: row.get::<_, i64>(6)? as u64,
|
modified_at,
|
||||||
})
|
}))
|
||||||
})
|
}
|
||||||
.optional()?;
|
None => Ok(None),
|
||||||
|
}
|
||||||
Ok(record)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_node_by_id(&self, id: i64) -> Result<Option<NodeRecord>> {
|
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 rows = stmt.query_map(params![parent_id], |row| {
|
||||||
let enc_name: String = row.get(2)?;
|
Ok((
|
||||||
let name = if vault_id == 1 {
|
row.get::<_, i64>(0)?,
|
||||||
decrypt_node_name(dek, parent_id, &enc_name).unwrap_or(enc_name)
|
row.get::<_, Option<i64>>(1)?,
|
||||||
} else {
|
row.get::<_, String>(2)?,
|
||||||
enc_name
|
row.get::<_, i32>(3)? != 0,
|
||||||
};
|
row.get::<_, i64>(4)? as u64,
|
||||||
|
row.get::<_, i64>(5)? as u64,
|
||||||
Ok(NodeRecord {
|
row.get::<_, i64>(6)? as u64,
|
||||||
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,
|
|
||||||
})
|
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let mut entries = Vec::new();
|
let mut entries = Vec::new();
|
||||||
for r in rows {
|
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)
|
Ok(entries)
|
||||||
}
|
}
|
||||||
@@ -1586,24 +1681,54 @@ impl Database {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Löscht einen Knoten und shreddert alle assoziierten Chunks atomar.
|
fn delete_subtree_tx(
|
||||||
pub fn delete_node(&self, id: i64) -> Result<()> {
|
tx: &rusqlite::Transaction<'_>,
|
||||||
self.assert_not_carrier(id)?;
|
id: i64,
|
||||||
|
carrier_id: Option<i64>,
|
||||||
// Verhindere auch das Löschen eines Verzeichnisses, das die Trägerdatei enthält
|
) -> Result<()> {
|
||||||
if let Ok(Some(carrier_id)) = self.find_carrier_node_id() {
|
if let Some(cid) = carrier_id {
|
||||||
if self.is_descendant_of(carrier_id, id).unwrap_or(false) {
|
if id == cid {
|
||||||
bail!("Verzeichnis enthält die Trägerdatei (Node-ID {}) und darf nicht gelöscht werden.", carrier_id);
|
bail!(
|
||||||
|
"Operation auf Alibi-Trägerdatei (Carrier, Node-ID {}) ist strikt untersagt.",
|
||||||
|
id
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. Shredde Chunks dieses Knotens mit kryptografischem Zufallsrauschen
|
// 1. Chunks für diesen Knoten kryptografisch shreddern
|
||||||
let _ = self.shred_chunks_for_node(id);
|
{
|
||||||
|
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 child_ids: Vec<i64> = {
|
||||||
let conn = self.conn();
|
let mut stmt = tx.prepare("SELECT id FROM nodes WHERE parent_id = ?1")?;
|
||||||
let mut stmt = conn.prepare("SELECT id FROM nodes WHERE parent_id = ?1")?;
|
|
||||||
let ids = stmt
|
let ids = stmt
|
||||||
.query_map(params![id], |row| row.get(0))?
|
.query_map(params![id], |row| row.get(0))?
|
||||||
.filter_map(|r| r.ok())
|
.filter_map(|r| r.ok())
|
||||||
@@ -1611,14 +1736,47 @@ impl Database {
|
|||||||
ids
|
ids
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let mut errors = Vec::new();
|
||||||
for child_id in child_ids {
|
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();
|
// 3. Chunks und Knoten aus der Datenbank löschen
|
||||||
conn.execute("DELETE FROM chunks WHERE node_id = ?1", params![id])?;
|
tx.execute("DELETE FROM chunks WHERE node_id = ?1", params![id])?;
|
||||||
conn.execute("DELETE FROM nodes WHERE 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);
|
drop(conn);
|
||||||
|
|
||||||
let _ = self.update_metadata_mac();
|
let _ = self.update_metadata_mac();
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -2923,4 +3081,246 @@ mod tests {
|
|||||||
assert!(read_dek.is_some());
|
assert!(read_dek.is_some());
|
||||||
assert_eq!(read_dek.unwrap()[0], 42);
|
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 {
|
pub fn leak_count(&self) -> u64 {
|
||||||
self.leak_counter().load(Ordering::Relaxed)
|
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_std);
|
||||||
let _ = std::fs::remove_file(&path_hidden);
|
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