Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d93b03322e | ||
|
|
76c23dbfa3 |
No files matched your search
@@ -57,7 +57,7 @@ Die gesamte Genese dieses Projekts (Audit-Behebung aller 39 Findings, Carrier V2
|
||||
| [`RELEASE_PROCESS.md`](RELEASE_PROCESS.md) | Multi-Platform CI/CD Release-Architektur, Toolchain-Pins und Minisign-Signierung |
|
||||
| [`QUICKSTART.md`](QUICKSTART.md) | Kompakte Schritt-für-Schritt-Anleitung für Endanwender |
|
||||
| [`INSTALL.md`](INSTALL.md) | Installationsanweisungen (Binaries, Scoop, WinGet, Cargo) |
|
||||
| [`CHANGELOG.md`](CHANGELOG.md) | Keep-a-Changelog Versionshistorie (aktuell: v0.9.4) |
|
||||
| [`CHANGELOG.md`](CHANGELOG.md) | Keep-a-Changelog Versionshistorie (aktuell: v0.9.6) |
|
||||
| [`deny.toml`](deny.toml) | Strikte Konfiguration für `cargo-deny` (Advisories, Bans, Lizenzen, Quellen) |
|
||||
| [`.gitea/workflows/release.yaml`](.gitea/workflows/release.yaml) | Automatisierte CI/CD Release-Pipeline für Windows & Linux musl |
|
||||
|
||||
@@ -75,7 +75,7 @@ Jede Code- oder Konfigurationsänderung **MUSS** vor einem Release oder Commit f
|
||||
```powershell
|
||||
cargo clippy --all-targets -- -D warnings
|
||||
```
|
||||
3. **Vollständige Testsuite** (aktuell 136 Tests):
|
||||
3. **Vollständige Testsuite** (aktuell 142 Tests):
|
||||
```powershell
|
||||
cargo test --all
|
||||
```
|
||||
|
||||
@@ -5,6 +5,48 @@ 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.9.6] - 2026-10-21
|
||||
|
||||
### Windows Cloud Files Engine (`cldapi.dll`) & Userland-Fallback ohne `WebClient`
|
||||
Dieses Release macht `sanctum mount` unter Windows unabhängig vom Windows-Dienst `WebClient` (`mrxdav.sys`), sodass Container auch dann als regulärer Laufwerksbuchstabe (z. B. `S:` oder `Z:`) im Windows Explorer eingebunden werden können, wenn `WebClient` durch Gruppenrichtlinien oder die Systemadministration deaktiviert (`Disabled`) wurde – komplett im Userland und ohne Administratorrechte:
|
||||
- **Native Windows Cloud Files API (`src/cloudfiles.rs`)**:
|
||||
- Verbindet `SanctumFs` direkt mit der in Windows 10/11 integrierten Cloud-Filter-Engine (`cldapi.dll` / `cldflt.sys`) und weist das Staging-Verzeichnis via `DefineDosDeviceW` (`DDD_NO_BROADCAST_SYSTEM` + `SHChangeNotify`) dem gewünschten oder automatisch gewählten freien Laufwerksbuchstaben (`--drive` bzw. `Z:` abwärts) zu.
|
||||
- **0-Byte Placeholders**: Beim Einbinden werden mittels `CfCreatePlaceholders` ausschließlich 0-Byte-Platzhalterdateien angelegt; es befindet sich beim Start kein entschlüsselter Dateiinhalt auf dem Host-Datenträger.
|
||||
- **On-Demand Hydration (`CF_CALLBACK_TYPE_FETCH_DATA`)**: Erst beim Öffnen/Lesen einer Datei entschlüsselt Sanctum die angeforderten 1-MB-Chunks live aus `SanctumFs` (regulärer Tresor sowie versteckter `CarrierFs`-Tresor), überträgt sie via `CfExecute` an den Kernel und nullt den temporären Puffer (`Zeroizing`).
|
||||
- **Bidirektionale Synchronisation, Auto-Dehydration & CSPRNG-Shredding**:
|
||||
- Neue, geänderte oder gelöschte Dateien und Ordner auf dem Laufwerk werden unter Einhaltung des **Anti-Leak Shields** und des **Carrier-Schutzes** laufend in den verschlüsselten Container zurückgeschrieben.
|
||||
- Nicht mehr genutzte hydrierte Dateien werden nach Inaktivität automatisch via `CfDehydratePlaceholder` wieder auf 0 Bytes dehydriert.
|
||||
- Beim Aushängen (`unmount`, `Ctrl+C`, Sitzungssperre oder Idle-Timeout) werden etwaige auf Disk liegende Dateibytes mit kryptografischem Zufallsrauschen (`OsRng`) überschrieben (geshreddet) und das Staging-Verzeichnis samt Laufwerksbuchstaben restlos entfernt.
|
||||
- **Dreistufiges Mount-Backend (`--backend auto|cloudfiles|webdav`) & ETW-Service-Trigger**:
|
||||
- `auto` (Standard): Prüft ohne Administratorrechte den Status des Dienstes `WebClient`. Ist er lediglich gestoppt (`Manual`), weckt Sanctum ihn automatisch per Userland-ETW-Trigger (`22b6d684-fa63-4578-87c9-effcbe6643c7`) auf. Ist `WebClient` administrativ deaktiviert (`Disabled`) oder schlägt `WNetAddConnection2W` fehl, wechselt Sanctum nahtlos auf die `CloudFilesSession` auf demselben Laufwerksbuchstaben.
|
||||
- `cloudfiles`: Erzwingt das direkte Einbinden über `cldapi.dll` + `DefineDosDeviceW` ohne `WebClient` (z. B. auch zur Umgehung des 4-GB-Limits von `WebClient`).
|
||||
- `webdav`: Erzwingt das klassische Einbinden über `WNetAddConnection2W`.
|
||||
|
||||
## [0.9.5] - 2026-09-22
|
||||
|
||||
### Transcript-Version & Upgrade-MAC (F-04, F-05)
|
||||
Dieses Patch-Release schließt die verbliebenen Bindungs- und Zustands-Schwachstellen im Format-V3-Kryptosystem:
|
||||
- **F-04 (MITTEL): Schließung des unbegrenzten LegacyValid-Rollback-Fensters**:
|
||||
- *Problem*: In v0.9.4 blieb `LegacyValid` zeitlich unbegrenzt aktiv. Ein Angreifer mit Container-Schreibzugriff konnte nach einem legitimen 0.9.4-Mount einen alten 0.9.3-MAC-Blob und alte Chunk-Zeilen replizieren, was zu einem unbemerkten Content-Rollback führte.
|
||||
- *Fix*: Erweiterung des HMAC-Transcript-Inputs um `transcript_ver` (Little-Endian u32):
|
||||
`b"SANCTUM_META_V3\0" || metadata_gen (LE64) || transcript_ver (LE32) || canonical`.
|
||||
- `transcript_ver = 2`: Format v0.9.4+ (Knoten + sortierte Chunk-Tupel `(node_id, chunk_index, generation)`).
|
||||
- Verifikations-Kaskade:
|
||||
1. Prüfung mit `ver=2` und aktuellem V2-Transcript -> `Valid`. (Verhindert jeglichen Legacy-Fallback, wenn V2 passt).
|
||||
2. Schlägt V2 fehl und ist `transcript_ver >= 2` in SQLite hinterlegt -> strikt `Invalid` (schließt Replay alter 0.9.3-Blobs nach Rebind aus).
|
||||
3. Schlägt V2 fehl und ist `transcript_ver < 2` (Altsystem):
|
||||
- Prüfung gegen natives 0.9.4-Transcript (ohne Versionsfeld + V2-Transcript) -> `NeedsTranscriptVer`.
|
||||
- Prüfung gegen natives 0.9.3-Transcript (ohne Versionsfeld oder ver=1 + Legacy-Transcript) -> `LegacyValid`.
|
||||
4. Andernfalls -> strikt `Invalid`.
|
||||
- Transparentes Rebind beim Mounten: Sowohl `NeedsTranscriptVer` als auch `LegacyValid` werden beim regulären Mounten transparent auf `transcript_ver = 2` migriert.
|
||||
- Datenbankschema: Ergänzung der Spalte `transcript_ver INTEGER NOT NULL DEFAULT 1` in Tabelle `meta`.
|
||||
- **F-05 (NIEDRIG / Verfügbarkeit): Atomare MAC-Persistierung & Zombie-Container-Reparatur**:
|
||||
- *Problem*: `upgrade_to_v3` committete `version = 3` in der Datenbank *vor* dem Errechnen und Schreiben des initialen Metadaten-MACs. Ein Absturz in diesem Fenster hinterließ einen Zombie-Container (`version = 3`, `metadata_mac IS NULL`), der nicht mehr gemountet werden konnte.
|
||||
- *Fix*: Berechnung und Schreiben des Metadaten-MACs (mit `transcript_ver = 2`, `gen = 1`) innerhalb der identischen atomaren SQLite-Transaktion wie die Chunk-Umschlüsselung vor `tx.commit()`.
|
||||
- *Reparaturpfad*: Befindet sich ein Container im Zombie-Zustand (`version >= 3`, `metadata_mac IS NULL`, `restore_nonce IS NULL`), repariert `sanctum upgrade-format` diesen automatisch durch Errechnen und Persistieren des V2-MACs.
|
||||
- *Fail-Closed Guard*: Ist `restore_nonce IS NOT NULL`, bricht die Reparatur strikt ab, um die Autorisierungsgarantien von F-02 (`--rebuild-mac` / interaktives `"JA"`) zu bewahren.
|
||||
- *Mount-Führung*: Beim Versuch, einen Zombie-Container zu mounten, weist Sanctum den Benutzer gezielt auf `sanctum upgrade-format` hin.
|
||||
|
||||
## [0.9.4] - 2026-09-22
|
||||
|
||||
### Format-V3 Systembindung & Integritäts-Härtung (F-01, F-02, F-03)
|
||||
|
||||
Generated
+1
-1
@@ -1457,7 +1457,7 @@ checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
|
||||
|
||||
[[package]]
|
||||
name = "sanctum"
|
||||
version = "0.9.4"
|
||||
version = "0.9.6"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"anyhow",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "sanctum"
|
||||
version = "0.9.4"
|
||||
version = "0.9.6"
|
||||
edition = "2021"
|
||||
authors = ["Harald Pansi <harald@pansi.eu>", "Sanctum Engineering Team"]
|
||||
description = "Verschlüsselter Ein-Datei-Container unter Windows im reinen Userland via WebDAV"
|
||||
|
||||
@@ -33,8 +33,8 @@ Sanctum ist eine eigenständige, speichersichere und hochperformante CLI-Anwendu
|
||||
Alle Schlüsselstrukturen implementieren das `Zeroize`-Trait (`Zeroizing<[u8; 32]>`), um sensible Schlüsseldaten beim Verlassen des Gültigkeitsbereichs im RAM sofort sicher zu nullen. Schlüsseldaten werden mittels `VirtualLock` / `mlock` vor Paging geschützt.
|
||||
- **Chunk-Verschlüsselung (AES-256-GCM & 24-Byte AAD, K-02)**:
|
||||
Dateien werden in Blöcken von 1 MB verschlüsselt. Als Authenticated Associated Data (AAD) werden `node_id` (8 Bytes LE), `chunk_index` (8 Bytes LE) und `generation` (8 Bytes LE) an jeden Block gebunden. Ein Vertauschen oder Zurücksetzen von Chunks führt zum Authentifizierungsfehler.
|
||||
- **Metadaten-Authentifizierung & Replay-Schutz (HMAC-SHA256, K-01 / F-01)**:
|
||||
Ein kryptografischer HMAC-SHA256 (abgeleitet via HKDF `SANCTUM_META_MAC_V3`) bindet den gesamten Verzeichnisbaum deterministisch an den Datenschlüssel (DEK). Das kanonische Transcript sichert nicht nur Inode-Metadaten (Namen, Pfade, Größen, Zeitstempel), sondern auch die sortierte Sequenz aller Chunk-Generationen (`node_id LE64 ‖ chunk_index LE32 ‖ generation LE64`, F-01). Dies schließt Angriffe aus, bei denen ein Angreifer alte Datenbankzeilen gleicher Dateigröße wiederherstellt (Content-Replay).
|
||||
- **Metadaten-Authentifizierung & Replay-Schutz (HMAC-SHA256, K-01 / F-01 / F-04)**:
|
||||
Ein kryptografischer HMAC-SHA256 (abgeleitet via HKDF `SANCTUM_META_MAC_V3`) bindet den gesamten Verzeichnisbaum deterministisch an den Datenschlüssel (DEK). Das kanonische Transcript sichert nicht nur Inode-Metadaten (Namen, Pfade, Größen, Zeitstempel), sondern auch die sortierte Sequenz aller Chunk-Generationen (`node_id LE64 ‖ chunk_index LE32 ‖ generation LE64`, F-01). Der HMAC-Input bindet zudem eine explizite Transkript-Version (`b"SANCTUM_META_V3\0" ‖ metadata_gen LE64 ‖ transcript_ver LE32 ‖ canonical`, F-04) ein, was Replay-Angriffe mit alten Metadaten-Blobs nach erfolgter Migration dauerhaft ausschließt.
|
||||
- **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).
|
||||
*Carrier-Format V2 mit Paged Manifest*: Das steganografische Dateisystem des Hidden Vaults nutzt eine skalierbare Paged-Manifest-Architektur. Blöcke 0 und 1 speichern den redundanten Superblock (C-02), während Inodes über dedizierte Inode-Pages (~1 MB Nutzdaten je Seite, ca. 4.500–7.000 Inodes pro Seite) dynamisch aus dem Blockpool verwaltet werden. Die Kapazität ist nicht mehr auf 7.000 Dateien limitiert, sondern skaliert dynamisch mit den verfügbaren Trägerblöcken. Robuste Fail-Soft-Resilienz (D-01) isoliert Seitenbeschädigungen, ein In-Memory Sekundärindex (D-02) beschleunigt Pfadoperationen auf O(Geschwister), und Sanctum warnt beim Einbinden automatisch bei Blockknappheit (< 20 freie Blöcke oder < 5% Restkapazität).
|
||||
@@ -102,6 +102,9 @@ sanctum.exe mount --path "C:\Pfad\tresor.sanctum"
|
||||
# Bestimmten Laufwerksbuchstaben erzwingen:
|
||||
sanctum.exe mount --path "C:\Pfad\tresor.sanctum" --drive S
|
||||
|
||||
# Mount-Backend wählen (auto | cloudfiles | webdav; Standard: auto mit Cloud-Files-Fallback falls WebClient deaktiviert ist):
|
||||
sanctum.exe mount --path "C:\Pfad\tresor.sanctum" --backend cloudfiles
|
||||
|
||||
# Mit Inaktivitäts-Timeout (in Sekunden):
|
||||
sanctum.exe mount --path "C:\Pfad\tresor.sanctum" --idle-timeout 300
|
||||
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@ Sicherheitsupdates und Patches werden jeweils für die neueste Version von Sanct
|
||||
|
||||
| Version | Status | Sicherheits-Support |
|
||||
|:---:|:---:|:---:|
|
||||
| **v0.9.x** | **Aktiv (Aktuell: v0.9.4)** | **Vollständig unterstützt** |
|
||||
| **v0.9.x** | **Aktiv (Aktuell: v0.9.6)** | **Vollständig unterstützt** |
|
||||
| <= v0.8.x | Veraltet | Nicht mehr unterstützt (Upgrade empfohlen) |
|
||||
|
||||
---
|
||||
|
||||
+12
-3
@@ -1,13 +1,13 @@
|
||||
# Sanctum Security Audit & Remediation Log (v0.9.4)
|
||||
# Sanctum Security Audit & Remediation Log (v0.9.5)
|
||||
|
||||
Dieses Dokument fasst alle 39 Findings aus drei umfassenden externen Sicherheitsaudits sowie nachfolgende Härtungen zusammen, dokumentiert die angewandten Härtungsmaßnahmen, referenziert die jeweiligen Git-Commits und benennt die zugehörigen automatisierten Regressionstests. Abschnitt 5 dokumentiert die Formaterweiterung Carrier V2 (Paged Manifest) aus v0.9.0, Abschnitt 6 die Minor Issues aus v0.9.1, Abschnitt 7 den Recovery-MAC-Fix (R-NEW-1) aus v0.9.2, Abschnitt 8 die Supply-Chain- und Secret-Audits aus v0.9.3 und Abschnitt 9 die Format-V3 Systembindungshärtungen (F-01, F-02, F-03) aus v0.9.4.
|
||||
Dieses Dokument fasst alle 39 Findings aus drei umfassenden externen Sicherheitsaudits sowie nachfolgende Härtungen zusammen, dokumentiert die angewandten Härtungsmaßnahmen, referenziert die jeweiligen Git-Commits und benennt die zugehörigen automatisierten Regressionstests. Abschnitt 5 dokumentiert die Formaterweiterung Carrier V2 (Paged Manifest) aus v0.9.0, Abschnitt 6 die Minor Issues aus v0.9.1, Abschnitt 7 den Recovery-MAC-Fix (R-NEW-1) aus v0.9.2, Abschnitt 8 die Supply-Chain- und Secret-Audits aus v0.9.3, Abschnitt 9 die Format-V3 Systembindungshärtungen (F-01, F-02, F-03) aus v0.9.4 und Abschnitt 10 die Transcript-Versionierung und atomare Upgrade-Persistierung (F-04, F-05) aus v0.9.5.
|
||||
|
||||
---
|
||||
|
||||
## Audit-Zusammenfassung
|
||||
|
||||
- **Status:** Vollständig gehärtet & verifiziert
|
||||
- **Zielversion:** Sanctum v0.9.4 (Basis v0.9.3)
|
||||
- **Zielversion:** Sanctum v0.9.5 (Basis v0.9.4)
|
||||
- **Behobene Findings:** 39 / 39 (100%) Audit-Findings + R-NEW-1
|
||||
- **Quality Gates:** `cargo fmt --check` (100% sauber), `cargo clippy --all-targets -- -D warnings` (0 Warnungen), `cargo-deny` (100% ok), `gitleaks` (0 Leaks)
|
||||
- **Container-Format:** Container-Format V3 mit kanonischer Metadaten-Authentifizierung (HMAC-SHA256) beim Mount und Chunk-Replay-Schutz (Generation-gebundenes AAD).
|
||||
@@ -178,6 +178,15 @@ Dieses Dokument fasst alle 39 Findings aus drei umfassenden externen Sicherheits
|
||||
- Sowohl Slot 0 als auch Slot 1 werden konsistent auf `version = 3` aktualisiert, um Korruption in Dual-Vault-Szenarien auszuschließen.
|
||||
- Neuer Metadaten-MAC wird direkt im Upgrade-Lauf persistiert.
|
||||
|
||||
### 10. Sanctum v0.9.5 — Transcript-Version & Upgrade-MAC (F-04, F-05)
|
||||
- **Schließung des unbegrenzten LegacyValid-Rollback-Fensters (F-04, MEDIUM):**
|
||||
- Erweiterung des kanonischen HMAC-SHA256 Inputs um ein 4-Byte Transkript-Versionsfeld: `b"SANCTUM_META_V3\0" ‖ metadata_gen (LE64) ‖ transcript_ver (LE32) ‖ canonical`.
|
||||
- Strikte Verifikations-Kaskade: Container mit persistiertem `transcript_ver >= 2` lehnen alte 0.9.3-Transkripte kompromisslos als `Invalid` ab, wodurch ein nachträglicher Content-Rollback via Altdaten-Replay unmöglich wird.
|
||||
- Transparentes Rebind: Ältere Container (`NeedsTranscriptVer` aus v0.9.4 und `LegacyValid` aus v0.9.3) werden beim Mounten automatisch auf `transcript_ver = 2` angehoben.
|
||||
- **Atomare MAC-Persistierung & Zombie-Container-Reparatur (F-05, LOW / Verfügbarkeit):**
|
||||
- `upgrade_to_v3` persistiert den Metadaten-MAC (`transcript_ver = 2`, `gen = 1`) in derselben Transaktion vor dem Commit, wodurch Absturzfenster mit `version = 3` und `metadata_mac IS NULL` eliminiert sind.
|
||||
- Automatischer Reparaturpfad in `sanctum upgrade-format` für unvollständig migrierte Altsysteme, mit striktem Abbruch, falls `restore_nonce IS NOT NULL` gesetzt ist (Erhalt der F-02 Schutzgarantien).
|
||||
|
||||
---
|
||||
|
||||
## Verifikationsnachweis
|
||||
|
||||
+21
-2
@@ -1,6 +1,6 @@
|
||||
# Threat Model & Sicherheitsarchitektur von Sanctum
|
||||
|
||||
Dieses Dokument beschreibt das Bedrohungsmodell, die Sicherheitsannahmen und die Schutzmechanismen von **Sanctum v0.9.4** im reinen Userland-Betrieb.
|
||||
Dieses Dokument beschreibt das Bedrohungsmodell, die Sicherheitsannahmen und die Schutzmechanismen von **Sanctum v0.9.5** im reinen Userland-Betrieb.
|
||||
|
||||
---
|
||||
|
||||
@@ -23,7 +23,7 @@ Dieses Dokument beschreibt das Bedrohungsmodell, die Sicherheitsannahmen und die
|
||||
|
||||
| Angreifer / Bedrohung | Beschreibung | Sanctum-Gegenmaßnahme |
|
||||
|---|---|---|
|
||||
| **Kalter Datenträger-Angriff** | Angreifer hat physischen oder dateibasierten Zugriff auf die `.sanctum`-Datei auf USB-Stick, SSD oder Cloud-Storage. | Argon2id KDF (256 MiB, t=4, p=4), AES-256-GCM mit eindeutiger Chunk-Generation AAD, kanonische HMAC-SHA256 Metadaten-Authentifizierung (Format V3). |
|
||||
| **Kalter Datenträger-Angriff** | Angreifer hat physischen oder dateibasierten Zugriff auf die `.sanctum`-Datei auf USB-Stick, SSD oder Cloud-Storage. | Argon2id KDF (256 MiB, t=4, p=4), AES-256-GCM mit eindeutiger Chunk-Generation AAD, kanonische HMAC-SHA256 Metadaten-Authentifizierung mit Transkript-Versionierung (Format V3.2, F-01/F-04). |
|
||||
| **Böswillige Manipulation / Bitrot** | Gezieltes Verändern von Metadaten oder Chunks im Speicher. | AEAD-Tags auf allen Datenblöcken; `sanctum verify` erkennt jede Modifikation; Schreib- und Leseoperationen verwerfen manipulierte Blöcke sofort (`Fail-Closed`). |
|
||||
| **Replay- & Chunk-Vertauschungsangriffe** | Vertauschen von Chunks zwischen Dateien oder Einspielen alter Versionen. | Kryptografische Bindung aller Chunks an `(node_id, chunk_index, generation)` in den AEAD Additional Authenticated Data (AAD). |
|
||||
| **Lokale Benutzerisolation** | Mehrbenutzersysteme: Andere Standardbenutzer auf demselben Rechner. | Windows- und Unix-Dateirechte; TCP-Bind ausschließlich an Loopback `127.0.0.1`; dynamisches 128-Bit Session-Token. |
|
||||
@@ -139,5 +139,24 @@ In Sanctum werden Dateinamen mit AES-256-GCM verschlüsselt. Um Swap-Angriffe zu
|
||||
Frühere Versionen von Sanctum (vor v0.8.0) verwendeten leere AAD für Dateinamen. Das Flag `--legacy-names` erlaubt die Entschlüsselung von Dateinamen ohne AAD-Bindung als Fallback.
|
||||
- **Risiko**: Bei aktiviertem `--legacy-names` ist der Schutz vor Vertauschen von Dateinamen aufgehoben. Ein Angreifer könnte Dateien manipulieren, indem er verschlüsselte Namen zwischen verschiedenen Verzeichnissen vertauscht.
|
||||
- **Empfehlung**: Dieses Flag darf **ausschließlich** zur einmaligen Migration von Altdaten verwendet werden. Im regulären Betrieb muss es deaktiviert bleiben.
|
||||
|
||||
---
|
||||
|
||||
## 8. Format-V3 Systembindung & Integritätshärtung (F-01 bis F-05)
|
||||
|
||||
### 8.1 Transkript-Bindung & Replay-Schutz (F-01, F-04)
|
||||
- **Problem**: Bei isolierter AEAD-Bindung der Generation im Chunk-Header (`node_id ‖ chunk_index ‖ generation`) konnte ein Angreifer alte Chunk-Zeilen (`ct, nonce, tag, generation`) wiederherstellen, wenn die Datei dieselbe Größe behielt.
|
||||
- **Kanonischer HMAC**: Sanctum bindet alle sortierten Chunk-Tupel (`ORDER BY node_id, chunk_index: node_id LE64 ‖ chunk_index LE32 ‖ generation LE64`) direkt in das Metadaten-Transkript ein.
|
||||
- **Transkript-Versionierung (F-04)**: Um ein unbegrenztes Rollback auf v0.9.3-Metadaten-Blobs (`LegacyValid`) zu verhindern, umfasst der HMAC-Transkript-Input die Little-Endian `transcript_ver`:
|
||||
`b"SANCTUM_META_V3\0" ‖ metadata_gen (LE64) ‖ transcript_ver (LE32) ‖ canonical`.
|
||||
Sobald ein Container auf `transcript_ver = 2` migriert wurde, lehnt Sanctum ältere 0.9.3-Transkripte strikt als `Invalid` ab.
|
||||
|
||||
### 8.2 Autorisierter Header-Rebuild (F-02)
|
||||
- Ein Rebuild des Metadaten-MACs (`metadata_gen == 0 && metadata_mac IS NULL`) nach einem Header-Restore wird ausschließlich zugelassen, wenn ein kryptografisches CSPRNG-Token (`restore_nonce`) in `meta` existiert und der Benutzer explizit `--rebuild-mac` übergibt oder interaktiv `"JA"` bestätigt. Manipulierte SQLite-Header ohne dieses Token werden unweigerlich abgewiesen.
|
||||
|
||||
### 8.3 Transaktions-Atomarität & Resilienz (F-03, F-05)
|
||||
- Das Format-Upgrade (`upgrade_to_v3`) wird in einer einzigen atomaren SQLite-Transaktion ausgeführt. Bei jeglichem Entschlüsselungs- oder Umschlüsselungsfehler erfolgt ein vollständiger Rollback auf Version 2.
|
||||
- Der initiale Metadaten-MAC (`transcript_ver = 2`, `gen = 1`) wird vor dem Transaktions-Commit berechnet und geschrieben (F-05), sodass keine ungeschützten Zombie-Container (`version = 3, metadata_mac IS NULL`) entstehen können.
|
||||
- Im Falle eines abrupten Stromausfalls vor Commit verbleibt der Container sauber auf Version 2. Sollte ein Altsystem dennoch in einen Zombie-Zustand geraten sein, bietet `sanctum upgrade-format` eine automatische Reparatur, sofern kein unautorisierter Restore vorliegt (`restore_nonce IS NULL`).
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"version": "0.9.4",
|
||||
"version": "0.9.6",
|
||||
"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.9.4/sanctum-v0.9.4-windows-x86_64.zip",
|
||||
"hash": "28fb0ce43ab167930e8f98424dae263625b71085e3ee65052aba9fa57833e4b1",
|
||||
"url": "https://gitea.pansi.eu/harald/sanctum/releases/download/v0.9.6/sanctum-v0.9.6-windows-x86_64.zip",
|
||||
"hash": "f5236b24d0c8c645364e1c5d3ca9c503518646c879163cedabe160029db9a099",
|
||||
"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.9.4
|
||||
PackageVersion: 0.9.6
|
||||
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.9.4
|
||||
ReleaseNotesUrl: https://gitea.pansi.eu/harald/sanctum/releases/tag/v0.9.6
|
||||
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.9.4/sanctum-v0.9.4-windows-x86_64.zip
|
||||
InstallerSha256: 28fb0ce43ab167930e8f98424dae263625b71085e3ee65052aba9fa57833e4b1
|
||||
InstallerUrl: https://gitea.pansi.eu/harald/sanctum/releases/download/v0.9.6/sanctum-v0.9.6-windows-x86_64.zip
|
||||
InstallerSha256: f5236b24d0c8c645364e1c5d3ca9c503518646c879163cedabe160029db9a099
|
||||
ManifestType: singleton
|
||||
ManifestVersion: 1.6.0
|
||||
+1767
File diff suppressed because it is too large.
Load diff
@@ -645,6 +645,34 @@ pub fn verify_metadata_mac(
|
||||
computed.ct_eq(expected_mac).into()
|
||||
}
|
||||
|
||||
/// Berechnet den Metadaten-MAC mit Transcript-Versionierung (Format V3.2 / F-04).
|
||||
/// Format: b"SANCTUM_META_V3\0" || metadata_gen LE64 || transcript_ver LE32 || canonical
|
||||
pub fn compute_metadata_mac_v2(
|
||||
mac_key: &[u8; 32],
|
||||
metadata_gen: u64,
|
||||
transcript_ver: u32,
|
||||
canonical: &[u8],
|
||||
) -> [u8; 32] {
|
||||
let mut data = Vec::with_capacity(28 + canonical.len());
|
||||
data.extend_from_slice(b"SANCTUM_META_V3\0");
|
||||
data.extend_from_slice(&metadata_gen.to_le_bytes());
|
||||
data.extend_from_slice(&transcript_ver.to_le_bytes());
|
||||
data.extend_from_slice(canonical);
|
||||
hmac_sha256(mac_key, &data)
|
||||
}
|
||||
|
||||
/// Prüft in konstanter Zeit, ob der gegebene Metadaten-MAC mit Transcript-Versionierung gültig ist (Format V3.2 / F-04).
|
||||
pub fn verify_metadata_mac_v2(
|
||||
mac_key: &[u8; 32],
|
||||
metadata_gen: u64,
|
||||
transcript_ver: u32,
|
||||
canonical: &[u8],
|
||||
expected_mac: &[u8; 32],
|
||||
) -> bool {
|
||||
let computed = compute_metadata_mac_v2(mac_key, metadata_gen, transcript_ver, canonical);
|
||||
computed.ct_eq(expected_mac).into()
|
||||
}
|
||||
|
||||
/// Erzeugt die 16-Byte Associated Data (AAD) für einen Chunk, um Swap-Angriffe zu verhindern:
|
||||
/// node_id (8 Bytes Little-Endian) || chunk_index (8 Bytes Little-Endian).
|
||||
#[inline]
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod carrier;
|
||||
pub mod cloudfiles;
|
||||
pub mod crypto;
|
||||
pub mod mount;
|
||||
pub mod pathutil;
|
||||
|
||||
+32
-6
@@ -10,7 +10,9 @@ use sanctum::crypto::{
|
||||
mnemonic_to_dek, set_allow_legacy_names, validate_password, wrap_slot0_payload,
|
||||
wrap_slot1_payload, KdfParams, FORMAT_VERSION, FORMAT_VERSION_V3,
|
||||
};
|
||||
use sanctum::mount::{format_drive, mount_container, unmount_drive, ContainerAuth};
|
||||
use sanctum::mount::{
|
||||
format_drive, mount_container_with_backend, unmount_drive, ContainerAuth, MountBackend,
|
||||
};
|
||||
use sanctum::recovery::{
|
||||
detect_recovery_key_slot, export_header_backup, restore_header_backup,
|
||||
restore_slot_from_recovery_key,
|
||||
@@ -121,6 +123,10 @@ enum Commands {
|
||||
/// Autorisiert den Neuaufbau des Metadaten-MAC nach einem Header-Restore (F-02)
|
||||
#[arg(long, default_value_t = false)]
|
||||
rebuild_mac: bool,
|
||||
|
||||
/// Mount-Backend unter Windows: 'auto' (WebClient mit nahtlosem Fallback auf Cloud-Files), 'cloudfiles' (cldapi.dll ohne WebClient) oder 'webdav'
|
||||
#[arg(long, default_value = "auto", value_name = "MODE")]
|
||||
backend: String,
|
||||
},
|
||||
|
||||
/// Trennt ein eingebundenes Netzlaufwerk manuell
|
||||
@@ -1354,7 +1360,20 @@ fn handle_upgrade_format(container_path: &Path) -> Result<()> {
|
||||
}
|
||||
};
|
||||
|
||||
if keys.version() >= FORMAT_VERSION_V3 {
|
||||
// F-05: Wenn bereits V3, aber MAC fehlt (Zombie-Zustand), repariere mit upgrade_to_v3
|
||||
let is_zombie = {
|
||||
use rusqlite::OptionalExtension;
|
||||
let conn = db.conn();
|
||||
conn.query_row(
|
||||
"SELECT 1 FROM meta WHERE slot_id = 0 AND version >= 3 AND metadata_mac IS NULL AND restore_nonce IS NULL",
|
||||
[],
|
||||
|_| Ok(true),
|
||||
)
|
||||
.optional()?
|
||||
.unwrap_or(false)
|
||||
};
|
||||
|
||||
if keys.version() >= FORMAT_VERSION_V3 && !is_zombie {
|
||||
println!(
|
||||
" • Container ist bereits Format V{} (keine Migration erforderlich).",
|
||||
keys.version()
|
||||
@@ -1365,9 +1384,13 @@ fn handle_upgrade_format(container_path: &Path) -> Result<()> {
|
||||
db.upgrade_to_v3(keys.dek())?;
|
||||
db.checkpoint()?;
|
||||
|
||||
println!(" ✔ Container erfolgreich auf Format V3 aktualisiert!");
|
||||
println!(" • Metadaten-Authentifizierung (HMAC-SHA256) aktiviert.");
|
||||
println!(" • Replay-Schutz für Chunks (24-Byte AAD mit Generation) aktiviert.");
|
||||
if is_zombie {
|
||||
println!(" ✔ Metadaten-MAC erfolgreich rekonstruiert und gebunden (Format V3 Reparatur)!");
|
||||
} else {
|
||||
println!(" ✔ Container erfolgreich auf Format V3 aktualisiert!");
|
||||
println!(" • Metadaten-Authentifizierung (HMAC-SHA256) aktiviert.");
|
||||
println!(" • Replay-Schutz für Chunks (24-Byte AAD mit Generation) aktiviert.");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1631,7 +1654,9 @@ async fn run() -> Result<()> {
|
||||
anti_leak_list,
|
||||
stealth,
|
||||
rebuild_mac,
|
||||
backend,
|
||||
} => {
|
||||
let mount_backend = MountBackend::from_str_opt(&backend)?;
|
||||
let drive_char = match drive {
|
||||
Some(ref d) => parse_drive_letter(d)?,
|
||||
None => {
|
||||
@@ -1671,7 +1696,7 @@ async fn run() -> Result<()> {
|
||||
let enable_tray = !no_tray && !stealth;
|
||||
let lock_on_screen_lock = !no_screen_lock;
|
||||
let anti_leak = !no_anti_leak;
|
||||
mount_container(
|
||||
mount_container_with_backend(
|
||||
&path,
|
||||
drive_char,
|
||||
mount_point.as_deref(),
|
||||
@@ -1685,6 +1710,7 @@ async fn run() -> Result<()> {
|
||||
anti_leak_list.as_deref(),
|
||||
stealth,
|
||||
rebuild_mac,
|
||||
mount_backend,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
+247
-35
@@ -29,6 +29,32 @@ pub enum ContainerAuth {
|
||||
RecoveryKey(Zeroizing<String>),
|
||||
}
|
||||
|
||||
/// Auswahl des Mount-Backends unter Windows.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum MountBackend {
|
||||
/// Versucht zuerst WebClient (inkl. Userland-ETW-Wakeup); falls deaktiviert,
|
||||
/// übernimmt nahtlos die Windows Cloud-Files-Engine (`cldapi.dll` + `DefineDosDeviceW`).
|
||||
Auto,
|
||||
/// Nutzt direkt die Windows Cloud-Files-Engine (`cldapi.dll` + `DefineDosDeviceW`) ohne WebClient.
|
||||
CloudFiles,
|
||||
/// Nutzt ausschließlich das klassische WebDAV-Netzlaufwerk (`WNetAddConnection2W`).
|
||||
WebDav,
|
||||
}
|
||||
|
||||
impl MountBackend {
|
||||
pub fn from_str_opt(s: &str) -> Result<Self> {
|
||||
match s.trim().to_ascii_lowercase().as_str() {
|
||||
"auto" => Ok(Self::Auto),
|
||||
"cloudfiles" | "cfapi" | "cldapi" | "cloud" => Ok(Self::CloudFiles),
|
||||
"webdav" | "wnet" | "dav" => Ok(Self::WebDav),
|
||||
other => bail!(
|
||||
"Unbekanntes Mount-Backend '{}'. Erlaubte Werte: auto, cloudfiles, webdav",
|
||||
other
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Hilfsfunktion zur Formatierung des Laufwerksbuchstabens (z. B. 'S' -> "S:")
|
||||
pub fn format_drive(drive_letter: char) -> String {
|
||||
format!("{}:", drive_letter.to_ascii_uppercase())
|
||||
@@ -88,6 +114,42 @@ pub async fn mount_container(
|
||||
anti_leak_list: Option<&Path>,
|
||||
stealth: bool,
|
||||
rebuild_mac: bool,
|
||||
) -> Result<()> {
|
||||
mount_container_with_backend(
|
||||
container_path,
|
||||
drive_letter,
|
||||
mount_point,
|
||||
requested_port,
|
||||
auth,
|
||||
open_explorer,
|
||||
enable_tray,
|
||||
idle_timeout,
|
||||
lock_on_screen_lock,
|
||||
anti_leak,
|
||||
anti_leak_list,
|
||||
stealth,
|
||||
rebuild_mac,
|
||||
MountBackend::Auto,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Startet das Sanctum-VFS mit expliziter Auswahl des Mount-Backends (`Auto`, `CloudFiles`, `WebDav`).
|
||||
pub async fn mount_container_with_backend(
|
||||
container_path: &Path,
|
||||
drive_letter: char,
|
||||
mount_point: Option<&Path>,
|
||||
requested_port: Option<u16>,
|
||||
auth: ContainerAuth,
|
||||
open_explorer: bool,
|
||||
enable_tray: bool,
|
||||
idle_timeout: Option<u64>,
|
||||
lock_on_screen_lock: bool,
|
||||
anti_leak: bool,
|
||||
anti_leak_list: Option<&Path>,
|
||||
stealth: bool,
|
||||
rebuild_mac: bool,
|
||||
backend: MountBackend,
|
||||
) -> Result<()> {
|
||||
let drive_str = format_drive(drive_letter);
|
||||
let _ = &drive_str;
|
||||
@@ -95,6 +157,7 @@ pub async fn mount_container(
|
||||
let _ = open_explorer;
|
||||
let _ = enable_tray;
|
||||
let _ = lock_on_screen_lock;
|
||||
let _ = backend;
|
||||
|
||||
if !container_path.exists() {
|
||||
bail!(
|
||||
@@ -199,10 +262,20 @@ pub async fn mount_container(
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(crate::storage::MetadataMacStatus::NeedsTranscriptVer) => {
|
||||
if !stealth {
|
||||
println!(
|
||||
" • Metadaten-MAC: ℹ Format-V3 Metadaten-MAC auf Transcript-Version 2 aktualisiert (F-04)"
|
||||
);
|
||||
}
|
||||
db.set_active_slot_and_dek(vault_id, dek.clone());
|
||||
db.update_metadata_mac()?;
|
||||
db.checkpoint()?;
|
||||
}
|
||||
Ok(crate::storage::MetadataMacStatus::LegacyValid) => {
|
||||
if !stealth {
|
||||
println!(
|
||||
" • Metadaten-MAC: ℹ Format-V3 Metadaten-MAC auf neues Transcript migriert (F-01)"
|
||||
" • Metadaten-MAC: ℹ Format-V3 Metadaten-MAC von Legacy-Knoten auf Transcript-Version 2 migriert (F-01/F-04)"
|
||||
);
|
||||
}
|
||||
db.set_active_slot_and_dek(vault_id, dek.clone());
|
||||
@@ -245,8 +318,25 @@ pub async fn mount_container(
|
||||
db.checkpoint()?;
|
||||
}
|
||||
Ok(crate::storage::MetadataMacStatus::Invalid) => {
|
||||
// F-05: Prüfen, ob es sich um den unvollständigen Zombie-Zustand handelt
|
||||
let is_zombie = {
|
||||
use rusqlite::OptionalExtension;
|
||||
let conn = db.conn();
|
||||
conn.query_row(
|
||||
"SELECT 1 FROM meta WHERE slot_id = ?1 AND version >= 3 AND metadata_mac IS NULL AND restore_nonce IS NULL",
|
||||
[vault_id],
|
||||
|_| Ok(true),
|
||||
)
|
||||
.optional()?
|
||||
.unwrap_or(false)
|
||||
};
|
||||
if is_zombie {
|
||||
bail!(
|
||||
"Container befindet sich im unvollständigen Zustand (Format V3 ohne Metadaten-MAC). Führen Sie zur Reparatur 'sanctum upgrade-format --path <CONTAINER>' nach Entsperrung aus."
|
||||
);
|
||||
}
|
||||
bail!(
|
||||
"Metadaten-MAC-Verifikation fehlgeschlagen: Die Container-Metadaten wurden manipuliert oder sind beschädigt (K-01 / F-01 / F-02)."
|
||||
"Metadaten-MAC-Verifikation fehlgeschlagen: Die Container-Metadaten wurden manipuliert oder sind beschädigt (K-01 / F-01 / F-02 / F-04)."
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -319,15 +409,19 @@ pub async fn mount_container(
|
||||
|
||||
if !stealth {
|
||||
#[cfg(windows)]
|
||||
ui::step(
|
||||
4,
|
||||
4,
|
||||
"🌐",
|
||||
&format!(
|
||||
"Starte WebDAV (Port {}) & binde Netzlaufwerk {} ein...",
|
||||
bound_port, drive_str
|
||||
),
|
||||
);
|
||||
{
|
||||
let step_msg = match backend {
|
||||
MountBackend::CloudFiles => format!(
|
||||
"Initialisiere Cloud-Files Userland-Engine (cldapi.dll) & binde Laufwerk {} ein...",
|
||||
drive_str
|
||||
),
|
||||
_ => format!(
|
||||
"Starte WebDAV (Port {}) & binde Laufwerk {} ein...",
|
||||
bound_port, drive_str
|
||||
),
|
||||
};
|
||||
ui::step(4, 4, "🌐", &step_msg);
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
ui::step(
|
||||
4,
|
||||
@@ -350,13 +444,80 @@ pub async fn mount_container(
|
||||
shutdown_rx,
|
||||
));
|
||||
|
||||
// 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);
|
||||
// Netzlaufwerk bzw. Cloud-Files-Laufwerk einbinden
|
||||
let mut cf_session: Option<crate::cloudfiles::CloudFilesSession> = None;
|
||||
#[cfg(windows)]
|
||||
{
|
||||
match backend {
|
||||
MountBackend::CloudFiles => {
|
||||
match crate::cloudfiles::CloudFilesSession::mount(drive_letter, fs_clone.clone())
|
||||
.await
|
||||
{
|
||||
Ok(s) => cf_session = Some(s),
|
||||
Err(e) => {
|
||||
let _ = fs_clone.sync_carrier_manifest();
|
||||
let _ = shutdown_tx.send(true);
|
||||
let _ = server_handle.await;
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
MountBackend::WebDav => {
|
||||
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);
|
||||
}
|
||||
}
|
||||
MountBackend::Auto => {
|
||||
if let Err(wnet_err) = run_mount_command(drive_letter, bound_port, &session_token) {
|
||||
if !stealth {
|
||||
println!(
|
||||
" {} Windows WebClient nicht verfügbar ({}) — schalte nahtlos auf Userland Cloud-Files-Engine (cldapi.dll) um...",
|
||||
ui::yellow("[i]"),
|
||||
wnet_err
|
||||
.to_string()
|
||||
.lines()
|
||||
.next()
|
||||
.unwrap_or("WebClient deaktiviert")
|
||||
);
|
||||
}
|
||||
match crate::cloudfiles::CloudFilesSession::mount(
|
||||
drive_letter,
|
||||
fs_clone.clone(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(s) => cf_session = Some(s),
|
||||
Err(cf_err) => {
|
||||
let _ = fs_clone.sync_carrier_manifest();
|
||||
let _ = shutdown_tx.send(true);
|
||||
let _ = server_handle.await;
|
||||
bail!(
|
||||
"Weder WebClient ({}) noch Cloud-Files-Engine ({}) konnten Laufwerk {} einbinden.",
|
||||
wnet_err,
|
||||
cf_err,
|
||||
drive_str
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
let using_cloudfiles = cf_session.is_some();
|
||||
let _ = using_cloudfiles;
|
||||
|
||||
// Optional automatisch im Windows Explorer öffnen (visuelle Parität für Decoy und Hidden Vault)
|
||||
#[cfg(windows)]
|
||||
@@ -423,6 +584,24 @@ pub async fn mount_container(
|
||||
}
|
||||
}
|
||||
|
||||
// Monitor für externes `sanctum unmount --drive <LETTER>` bei Cloud-Files DOS-Device
|
||||
#[cfg(windows)]
|
||||
let (ext_unmount_tx, mut ext_unmount_rx) = tokio::sync::mpsc::channel::<()>(1);
|
||||
#[cfg(windows)]
|
||||
if using_cloudfiles {
|
||||
let dl = drive_letter;
|
||||
tokio::spawn(async move {
|
||||
let mut interval = tokio::time::interval(std::time::Duration::from_secs(1));
|
||||
loop {
|
||||
interval.tick().await;
|
||||
if !crate::windows::is_dos_device_mounted(dl) {
|
||||
let _ = ext_unmount_tx.send(()).await;
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Windows-Sitzungssperre (Win + L Auto-Lock, nur Windows)
|
||||
#[cfg(windows)]
|
||||
let (session_lock_tx, mut session_lock_rx) = tokio::sync::mpsc::channel::<()>(1);
|
||||
@@ -452,10 +631,19 @@ pub async fn mount_container(
|
||||
println!();
|
||||
println!(" • Container: {}", container_path.display());
|
||||
#[cfg(windows)]
|
||||
println!(
|
||||
" • Netzlaufwerk: {} (im Windows Explorer bereit)",
|
||||
ui::cyan(&drive_str)
|
||||
);
|
||||
{
|
||||
if using_cloudfiles {
|
||||
println!(
|
||||
" • Laufwerk: {} (Cloud-Files Userland-Engine cldapi.dll, ohne WebClient)",
|
||||
ui::cyan(&drive_str)
|
||||
);
|
||||
} else {
|
||||
println!(
|
||||
" • Netzlaufwerk: {} (im Windows Explorer bereit)",
|
||||
ui::cyan(&drive_str)
|
||||
);
|
||||
}
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
if let Some(mp) = mount_point {
|
||||
@@ -542,7 +730,7 @@ pub async fn mount_container(
|
||||
#[cfg(unix)]
|
||||
let mut sighup = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::hangup()).ok();
|
||||
|
||||
// Warten auf Beendigungssignal (Ctrl+C, Tray-Klick, Inaktivität, Win+L, Konsolenfenster-Schließen)
|
||||
// Warten auf Beendigungssignal (Ctrl+C, Tray-Klick, Inaktivität, Win+L, Konsolenfenster-Schließen, externes Unmount)
|
||||
#[cfg(windows)]
|
||||
tokio::select! {
|
||||
res = tokio::signal::ctrl_c() => {
|
||||
@@ -576,6 +764,12 @@ pub async fn mount_container(
|
||||
println!(" {} Inaktivitäts-Timeout erreicht — Auto-Lock ausgelöst!", ui::yellow("[!]"));
|
||||
}
|
||||
}
|
||||
Some(()) = ext_unmount_rx.recv() => {
|
||||
if !stealth {
|
||||
println!();
|
||||
println!(" {} Externer Unmount-Befehl für {} erkannt — schließe Container sicher ab!", ui::yellow("[!]"), drive_str);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
@@ -635,8 +829,12 @@ pub async fn mount_container(
|
||||
}
|
||||
|
||||
if stealth {
|
||||
if let Some(mut cf) = cf_session.take() {
|
||||
let _ = cf.shutdown().await;
|
||||
} else {
|
||||
let _ = unmount_drive(drive_letter);
|
||||
}
|
||||
let _ = fs_clone.sync_carrier_manifest();
|
||||
let _ = unmount_drive(drive_letter);
|
||||
let _ = shutdown_tx.send(true);
|
||||
let _ = server_handle.await;
|
||||
if let Ok(freelist) = db.freelist_count() {
|
||||
@@ -650,23 +848,37 @@ 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!(
|
||||
" {} Trenne Windows-Netzlaufwerk {} ... ",
|
||||
ui::dim("[-]"),
|
||||
drive_str
|
||||
);
|
||||
let _ = std::io::Write::flush(&mut std::io::stdout());
|
||||
|
||||
// Automatisches Unmount
|
||||
if let Err(e) = unmount_drive(drive_letter) {
|
||||
println!("{}", ui::yellow(&format!("Warnung ({e})")));
|
||||
if let Some(mut cf) = cf_session.take() {
|
||||
print!(
|
||||
" {} Synchronisiere & schreddere Cloud-Files-Staging für {} ... ",
|
||||
ui::dim("[-]"),
|
||||
drive_str
|
||||
);
|
||||
let _ = std::io::Write::flush(&mut std::io::stdout());
|
||||
if let Err(e) = cf.shutdown().await {
|
||||
println!("{}", ui::yellow(&format!("Warnung ({e})")));
|
||||
} else {
|
||||
println!("{}", ui::green("OK"));
|
||||
}
|
||||
} else {
|
||||
println!("{}", ui::green("OK"));
|
||||
print!(
|
||||
" {} Trenne Windows-Netzlaufwerk {} ... ",
|
||||
ui::dim("[-]"),
|
||||
drive_str
|
||||
);
|
||||
let _ = std::io::Write::flush(&mut std::io::stdout());
|
||||
|
||||
// Automatisches Unmount
|
||||
if let Err(e) = unmount_drive(drive_letter) {
|
||||
println!("{}", ui::yellow(&format!("Warnung ({e})")));
|
||||
} else {
|
||||
println!("{}", ui::green("OK"));
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = fs_clone.sync_carrier_manifest();
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
print!(" {} Beende WebDAV-Server ... ", ui::dim("[-]"));
|
||||
|
||||
+185
-51
@@ -9,10 +9,10 @@ use rusqlite::{params, Connection, OptionalExtension};
|
||||
use tracing::{error, warn};
|
||||
|
||||
use crate::crypto::{
|
||||
compute_metadata_mac, decrypt_node_name, derive_kek, derive_metadata_mac_key,
|
||||
compute_metadata_mac_v2, decrypt_node_name, derive_kek, derive_metadata_mac_key,
|
||||
encrypt_node_name, generate_dummy_slot, unwrap_key_payload, validate_kdf_params,
|
||||
verify_metadata_mac, KdfParams, CHUNK_SIZE, FORMAT_VERSION, FORMAT_VERSION_V1,
|
||||
FORMAT_VERSION_V2, FORMAT_VERSION_V3, MAGIC_BYTES,
|
||||
verify_metadata_mac, verify_metadata_mac_v2, KdfParams, CHUNK_SIZE, FORMAT_VERSION,
|
||||
FORMAT_VERSION_V1, FORMAT_VERSION_V2, FORMAT_VERSION_V3, MAGIC_BYTES,
|
||||
};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
@@ -211,10 +211,13 @@ fn current_timestamp() -> u64 {
|
||||
/// Status der Metadaten-MAC-Integritätsprüfung (K-01 / F-01 / F-02).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum MetadataMacStatus {
|
||||
/// Metadaten-MAC ist vorhanden und stimmt mit den kanonischen Metadaten inklusive Chunk-Generationen überein (F-01).
|
||||
/// Metadaten-MAC ist vorhanden und stimmt mit Transcript-Version 2 (inklusive Chunk-Generationen) überein (F-01 / F-04).
|
||||
Valid,
|
||||
/// Metadaten-MAC stimmt mit dem älteren V3-Transcript (nur Knoten-Metadaten, ohne Chunk-Generationen) überein.
|
||||
/// Wird beim Mounten transparent auf das neue V3.1-Transcript gebunden (F-01 Migration).
|
||||
/// Metadaten-MAC stammt aus v0.9.4 (Knoten + Chunk-Generationen, jedoch ohne transcript_ver im HMAC-Input).
|
||||
/// Wird beim Mounten transparent auf Transcript-Version 2 aktualisiert (F-04 Migration).
|
||||
NeedsTranscriptVer,
|
||||
/// Metadaten-MAC stimmt mit dem älteren V3.0-Transcript (nur Knoten-Metadaten aus v0.9.3) überein.
|
||||
/// Wird beim Mounten transparent auf das neue V3.2-Transcript gebunden (F-01 / F-04 Migration).
|
||||
LegacyValid,
|
||||
/// Container-Header wurde aus einem Backup restauriert (restore_nonce vorhanden und metadata_gen == 0 && metadata_mac IS NULL).
|
||||
/// Der MAC muss beim Mounten mit --rebuild-mac (oder interaktivem 'JA') neu aufgebaut werden (F-02).
|
||||
@@ -486,6 +489,12 @@ impl Database {
|
||||
// Spalte restore_nonce in meta (Format V3 / F-02)
|
||||
let _ = conn.execute("ALTER TABLE meta ADD COLUMN restore_nonce BLOB", []);
|
||||
|
||||
// Spalte transcript_ver in meta (Format V3.2 / F-04)
|
||||
let _ = conn.execute(
|
||||
"ALTER TABLE meta ADD COLUMN transcript_ver INTEGER NOT NULL DEFAULT 1",
|
||||
[],
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -653,6 +662,7 @@ impl Database {
|
||||
metadata_mac BLOB,
|
||||
metadata_gen INTEGER NOT NULL DEFAULT 0,
|
||||
restore_nonce BLOB,
|
||||
transcript_ver INTEGER NOT NULL DEFAULT 1,
|
||||
lock_pid INTEGER,
|
||||
lock_host TEXT,
|
||||
lock_time INTEGER
|
||||
@@ -967,6 +977,7 @@ impl Database {
|
||||
metadata_mac BLOB,
|
||||
metadata_gen INTEGER NOT NULL DEFAULT 0,
|
||||
restore_nonce BLOB,
|
||||
transcript_ver INTEGER NOT NULL DEFAULT 1,
|
||||
lock_pid INTEGER,
|
||||
lock_host TEXT,
|
||||
lock_time INTEGER
|
||||
@@ -2203,8 +2214,16 @@ impl Database {
|
||||
/// Erzeugt die deterministische kanonische Byterepräsentation für einen spezifischen Vault
|
||||
/// inklusive sortierter Chunk-Generationen (Format V3.1 / F-01).
|
||||
pub fn canonical_nodes_bytes_for_vault(&self, vault_id: u32) -> Result<Vec<u8>> {
|
||||
let mut buf = self.canonical_nodes_bytes_for_vault_legacy(vault_id)?;
|
||||
let conn = self.conn();
|
||||
Self::canonical_nodes_bytes_for_vault_on_conn(&self.conn(), vault_id)
|
||||
}
|
||||
|
||||
/// Erzeugt die deterministische kanonische Byterepräsentation für einen spezifischen Vault
|
||||
/// auf einer bestehenden SQLite-Verbindung oder Transaktion (F-05).
|
||||
pub fn canonical_nodes_bytes_for_vault_on_conn(
|
||||
conn: &rusqlite::Connection,
|
||||
vault_id: u32,
|
||||
) -> Result<Vec<u8>> {
|
||||
let mut buf = Self::canonical_nodes_bytes_for_vault_legacy_on_conn(conn, vault_id)?;
|
||||
|
||||
if vault_id == 1 {
|
||||
let mut stmt = conn.prepare(
|
||||
@@ -2255,7 +2274,14 @@ impl Database {
|
||||
|
||||
/// Erzeugt die Legacy-Byterepräsentation ohne Chunk-Generationen (zur Migration bestehender V3-Container, F-01).
|
||||
pub fn canonical_nodes_bytes_for_vault_legacy(&self, vault_id: u32) -> Result<Vec<u8>> {
|
||||
let conn = self.conn();
|
||||
Self::canonical_nodes_bytes_for_vault_legacy_on_conn(&self.conn(), vault_id)
|
||||
}
|
||||
|
||||
/// Erzeugt die Legacy-Byterepräsentation ohne Chunk-Generationen auf einer bestehenden SQLite-Verbindung (F-05).
|
||||
pub fn canonical_nodes_bytes_for_vault_legacy_on_conn(
|
||||
conn: &rusqlite::Connection,
|
||||
vault_id: u32,
|
||||
) -> Result<Vec<u8>> {
|
||||
let mut buf = Vec::new();
|
||||
|
||||
if vault_id == 1 {
|
||||
@@ -2365,11 +2391,11 @@ impl Database {
|
||||
let next_gen = current_gen + 1;
|
||||
let canonical = self.canonical_nodes_bytes_for_vault(slot_id)?;
|
||||
let mac_key = derive_metadata_mac_key(&dek);
|
||||
let new_mac = compute_metadata_mac(&mac_key, next_gen, &canonical);
|
||||
let new_mac = compute_metadata_mac_v2(&mac_key, next_gen, 2, &canonical);
|
||||
|
||||
let conn = self.conn();
|
||||
conn.execute(
|
||||
"UPDATE meta SET metadata_mac = ?1, metadata_gen = ?2 WHERE slot_id = ?3",
|
||||
"UPDATE meta SET metadata_mac = ?1, metadata_gen = ?2, transcript_ver = 2 WHERE slot_id = ?3",
|
||||
params![new_mac.as_slice(), next_gen, slot_id],
|
||||
)?;
|
||||
|
||||
@@ -2390,25 +2416,27 @@ impl Database {
|
||||
/// Prüft die Integrität des Metadaten-MAC für einen spezifischen Slot (0: Decoy, 1: Hidden).
|
||||
pub fn verify_metadata_mac_for_slot(&self, slot_id: u32, dek: &[u8; 32]) -> Result<bool> {
|
||||
let status = self.verify_metadata_mac_status_for_slot(slot_id, dek)?;
|
||||
Ok(status == MetadataMacStatus::Valid || status == MetadataMacStatus::LegacyValid)
|
||||
Ok(status == MetadataMacStatus::Valid
|
||||
|| status == MetadataMacStatus::LegacyValid
|
||||
|| status == MetadataMacStatus::NeedsTranscriptVer)
|
||||
}
|
||||
|
||||
/// Prüft den detaillierten Integritätsstatus des Metadaten-MAC für einen spezifischen Slot (F-01 / F-02).
|
||||
/// Prüft den detaillierten Integritätsstatus des Metadaten-MAC für einen spezifischen Slot (F-01 / F-02 / F-04).
|
||||
pub fn verify_metadata_mac_status_for_slot(
|
||||
&self,
|
||||
slot_id: u32,
|
||||
dek: &[u8; 32],
|
||||
) -> Result<MetadataMacStatus> {
|
||||
let conn = self.conn();
|
||||
let meta_row: Option<(u32, Option<Vec<u8>>, u64, Option<Vec<u8>>)> = conn
|
||||
let meta_row: Option<(u32, Option<Vec<u8>>, u64, Option<Vec<u8>>, u32)> = conn
|
||||
.query_row(
|
||||
"SELECT version, metadata_mac, metadata_gen, restore_nonce FROM meta WHERE slot_id = ?1 LIMIT 1",
|
||||
"SELECT version, metadata_mac, metadata_gen, restore_nonce, transcript_ver FROM meta WHERE slot_id = ?1 LIMIT 1",
|
||||
params![slot_id],
|
||||
|r| Ok((r.get(0)?, r.get(1).ok(), r.get(2).unwrap_or(0), r.get(3).ok())),
|
||||
|r| Ok((r.get(0)?, r.get(1).ok(), r.get(2).unwrap_or(0), r.get(3).ok(), r.get(4).unwrap_or(1))),
|
||||
)
|
||||
.optional()?;
|
||||
|
||||
let Some((version, mac_opt, gen, restore_nonce)) = meta_row else {
|
||||
let Some((version, mac_opt, gen, restore_nonce, transcript_ver)) = meta_row else {
|
||||
return Ok(MetadataMacStatus::Invalid);
|
||||
};
|
||||
|
||||
@@ -2442,15 +2470,29 @@ impl Database {
|
||||
|
||||
let mac_key = derive_metadata_mac_key(dek);
|
||||
|
||||
// F-01: 1. Neues kanonisches Transcript prüfen (inklusive Chunk-Generationen)
|
||||
let canonical = self.canonical_nodes_bytes_for_vault(slot_id)?;
|
||||
if verify_metadata_mac(&mac_key, gen, &canonical, &expected_mac) {
|
||||
// F-04: Stufe 1: Rechne MAC mit ver=2 + canonical_v2 (0.9.5-native)
|
||||
// Treffer -> Valid. Fertig. Nie Legacy versuchen.
|
||||
let canonical_v2 = self.canonical_nodes_bytes_for_vault(slot_id)?;
|
||||
if verify_metadata_mac_v2(&mac_key, gen, 2, &canonical_v2, &expected_mac) {
|
||||
return Ok(MetadataMacStatus::Valid);
|
||||
}
|
||||
|
||||
// F-01 / §7: 2. Altes Transcript prüfen (nur Knoten, ohne Chunk-Generationen) für sichere Migration
|
||||
// F-04: Wenn Stufe 1 fehlschlägt und der Container in SQLite bereits transcript_ver >= 2
|
||||
// hat, ist kein Fallback auf Legacy erlaubt (Verhinderung von Replay-Angriffen nach Rebind).
|
||||
if transcript_ver >= 2 {
|
||||
return Ok(MetadataMacStatus::Invalid);
|
||||
}
|
||||
|
||||
// F-04: Stufe 2: 0.9.4-native (ohne transcript_ver im Input, canonical_v2 mit Generationen)
|
||||
if verify_metadata_mac(&mac_key, gen, &canonical_v2, &expected_mac) {
|
||||
return Ok(MetadataMacStatus::NeedsTranscriptVer);
|
||||
}
|
||||
|
||||
// F-04: Stufe 3: 0.9.3-native (ohne transcript_ver oder ver=1, canonical_legacy nur Knoten)
|
||||
let canonical_legacy = self.canonical_nodes_bytes_for_vault_legacy(slot_id)?;
|
||||
if verify_metadata_mac(&mac_key, gen, &canonical_legacy, &expected_mac) {
|
||||
if verify_metadata_mac(&mac_key, gen, &canonical_legacy, &expected_mac)
|
||||
|| verify_metadata_mac_v2(&mac_key, gen, 1, &canonical_legacy, &expected_mac)
|
||||
{
|
||||
return Ok(MetadataMacStatus::LegacyValid);
|
||||
}
|
||||
|
||||
@@ -2462,18 +2504,25 @@ impl Database {
|
||||
self.upgrade_to_v3_ext(dek, None)
|
||||
}
|
||||
|
||||
/// Führt ein transaktionales Upgrade des Containerformats auf Format V3 durch (F-03).
|
||||
/// Läuft in einer einzigen atomaren SQLite-Transaktion. Bei jeglichem Chunk-Fehler
|
||||
/// wird ein Rollback durchgeführt und der Fehler fail-closed propagiert.
|
||||
/// Führt ein transaktionales Upgrade des Containerformats auf Format V3 durch (F-03 / F-05).
|
||||
/// Läuft in einer einzigen atomaren SQLite-Transaktion. Sowohl die Re-Verschlüsselung der Chunks,
|
||||
/// das Setzen von version = 3 als auch die Berechnung und Speicherung des Metadaten-MACs (transcript_ver = 2)
|
||||
/// erfolgen vor dem Commit (F-05). Bei jeglichem Chunk-Fehler wird ein Rollback durchgeführt.
|
||||
pub fn upgrade_to_v3_ext(&self, dek_0: &[u8; 32], dek_1: Option<&[u8; 32]>) -> Result<()> {
|
||||
let mut conn = self.conn();
|
||||
let version: u32 = conn.query_row(
|
||||
"SELECT version FROM meta WHERE slot_id = 0 LIMIT 1",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
let (version, mac_opt, nonce_opt): (u32, Option<Vec<u8>>, Option<Vec<u8>>) = conn
|
||||
.query_row(
|
||||
"SELECT version, metadata_mac, restore_nonce FROM meta WHERE slot_id = 0 LIMIT 1",
|
||||
[],
|
||||
|r| Ok((r.get(0)?, r.get(1).ok(), r.get(2).ok())),
|
||||
)?;
|
||||
|
||||
if version >= FORMAT_VERSION_V3 {
|
||||
// F-05: Zombie-Container Reparatur (V3, MAC NULL, restore_nonce NULL)
|
||||
if mac_opt.is_none() && nonce_opt.is_none() {
|
||||
drop(conn);
|
||||
return self.repair_v3_mac(dek_0, dek_1);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -2483,12 +2532,16 @@ impl Database {
|
||||
[],
|
||||
);
|
||||
let _ = conn.execute("ALTER TABLE meta ADD COLUMN restore_nonce BLOB", []);
|
||||
let _ = conn.execute(
|
||||
"ALTER TABLE meta ADD COLUMN transcript_ver INTEGER NOT NULL DEFAULT 1",
|
||||
[],
|
||||
);
|
||||
let _ = conn.execute(
|
||||
"ALTER TABLE chunks ADD COLUMN generation INTEGER NOT NULL DEFAULT 0",
|
||||
[],
|
||||
);
|
||||
|
||||
// F-03: Gesamte Migration in einer einzigen atomaren SQLite-Transaktion
|
||||
// F-03 / F-05: Gesamte Migration inkl. MAC-Persistierung in einer einzigen atomaren SQLite-Transaktion
|
||||
let tx = conn.transaction()?;
|
||||
|
||||
// Alle bestehenden Chunks von alter 16-Byte-AAD auf Format V3 24-Byte-AAD (generation = 0) umverschlüsseln
|
||||
@@ -2578,44 +2631,124 @@ impl Database {
|
||||
}
|
||||
}
|
||||
|
||||
// F-03: Aktualisiere version = 3 für Slot 0 UND Slot 1 (inkl. Dummy-Slot)
|
||||
// F-05: In derselben Transaktion vor dem Commit: Metadaten-MAC berechnen und persistieren
|
||||
let canonical_0 = Self::canonical_nodes_bytes_for_vault_on_conn(&tx, 0)?;
|
||||
let mac_key_0 = derive_metadata_mac_key(dek_0);
|
||||
let mac_0 = compute_metadata_mac_v2(&mac_key_0, 1, 2, &canonical_0);
|
||||
|
||||
tx.execute(
|
||||
"UPDATE meta SET version = ?1, metadata_gen = 0",
|
||||
[FORMAT_VERSION_V3],
|
||||
"UPDATE meta SET version = ?1, metadata_mac = ?2, metadata_gen = 1, transcript_ver = 2 WHERE slot_id = 0",
|
||||
params![FORMAT_VERSION_V3, mac_0.as_slice()],
|
||||
)?;
|
||||
|
||||
tx.commit()?;
|
||||
drop(conn);
|
||||
|
||||
// Slot 0 Metadaten-MAC berechnen und persistieren
|
||||
self.set_active_slot_and_dek(0, Zeroizing::new(*dek_0));
|
||||
self.update_metadata_mac()?;
|
||||
|
||||
// Falls echte SQLite-Knoten unter Root 2 existieren (Legacy Hidden Vault) und dek_1 vorliegt,
|
||||
// auch Slot 1 MAC aktualisieren. (Bei Modell A bleibt Carrier-Inner Carrier-AEAD).
|
||||
let has_vault1_nodes: bool = {
|
||||
let conn = self.conn();
|
||||
conn.query_row(
|
||||
let has_vault1_nodes: bool = tx
|
||||
.query_row(
|
||||
"SELECT 1 FROM nodes WHERE parent_id = 2 LIMIT 1",
|
||||
[],
|
||||
|_| Ok(true),
|
||||
)
|
||||
.optional()?
|
||||
.unwrap_or(false)
|
||||
};
|
||||
.unwrap_or(false);
|
||||
|
||||
if has_vault1_nodes {
|
||||
if let Some(d1) = dek_1 {
|
||||
self.set_active_slot_and_dek(1, Zeroizing::new(*d1));
|
||||
self.update_metadata_mac()?;
|
||||
let canonical_1 = Self::canonical_nodes_bytes_for_vault_on_conn(&tx, 1)?;
|
||||
let mac_key_1 = derive_metadata_mac_key(d1);
|
||||
let mac_1 = compute_metadata_mac_v2(&mac_key_1, 1, 2, &canonical_1);
|
||||
tx.execute(
|
||||
"UPDATE meta SET version = ?1, metadata_mac = ?2, metadata_gen = 1, transcript_ver = 2 WHERE slot_id = 1",
|
||||
params![FORMAT_VERSION_V3, mac_1.as_slice()],
|
||||
)?;
|
||||
}
|
||||
} else {
|
||||
tx.execute(
|
||||
"UPDATE meta SET version = ?1, metadata_gen = 0, transcript_ver = 2 WHERE slot_id != 0 AND (metadata_mac IS NULL OR slot_id = 1)",
|
||||
params![FORMAT_VERSION_V3],
|
||||
)?;
|
||||
}
|
||||
|
||||
// Aktive Session auf Slot 0 zurücksetzen
|
||||
tx.commit()?;
|
||||
drop(conn);
|
||||
|
||||
// Aktive Session auf Slot 0 setzen
|
||||
self.set_active_slot_and_dek(0, Zeroizing::new(*dek_0));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Repariert einen unvollständigen Zombie-Container (version >= 3, metadata_mac IS NULL, restore_nonce IS NULL) (F-05).
|
||||
/// Bricht strikt ab, falls restore_nonce gesetzt ist (dies ist F-02 vorbehalten).
|
||||
pub fn repair_v3_mac(&self, dek_0: &[u8; 32], dek_1: Option<&[u8; 32]>) -> Result<()> {
|
||||
let mut conn = self.conn();
|
||||
let (version, mac_opt, nonce_opt): (u32, Option<Vec<u8>>, Option<Vec<u8>>) = conn
|
||||
.query_row(
|
||||
"SELECT version, metadata_mac, restore_nonce FROM meta WHERE slot_id = 0 LIMIT 1",
|
||||
[],
|
||||
|r| Ok((r.get(0)?, r.get(1).ok(), r.get(2).ok())),
|
||||
)?;
|
||||
|
||||
if version < FORMAT_VERSION_V3 {
|
||||
bail!("Reparatur abgebrochen: Container ist nicht Format V3.");
|
||||
}
|
||||
|
||||
if nonce_opt.is_some() {
|
||||
bail!(
|
||||
"Reparatur abgebrochen: Container enthält restore_nonce Token. Starten Sie 'sanctum mount --rebuild-mac' (F-02)."
|
||||
);
|
||||
}
|
||||
|
||||
if mac_opt.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let _ = conn.execute(
|
||||
"ALTER TABLE meta ADD COLUMN transcript_ver INTEGER NOT NULL DEFAULT 1",
|
||||
[],
|
||||
);
|
||||
|
||||
let tx = conn.transaction()?;
|
||||
|
||||
let canonical_0 = Self::canonical_nodes_bytes_for_vault_on_conn(&tx, 0)?;
|
||||
let mac_key_0 = derive_metadata_mac_key(dek_0);
|
||||
let mac_0 = compute_metadata_mac_v2(&mac_key_0, 1, 2, &canonical_0);
|
||||
|
||||
tx.execute(
|
||||
"UPDATE meta SET version = ?1, metadata_mac = ?2, metadata_gen = 1, transcript_ver = 2 WHERE slot_id = 0",
|
||||
params![FORMAT_VERSION_V3, mac_0.as_slice()],
|
||||
)?;
|
||||
|
||||
let has_vault1_nodes: bool = tx
|
||||
.query_row(
|
||||
"SELECT 1 FROM nodes WHERE parent_id = 2 LIMIT 1",
|
||||
[],
|
||||
|_| Ok(true),
|
||||
)
|
||||
.optional()?
|
||||
.unwrap_or(false);
|
||||
|
||||
if has_vault1_nodes {
|
||||
if let Some(d1) = dek_1 {
|
||||
let canonical_1 = Self::canonical_nodes_bytes_for_vault_on_conn(&tx, 1)?;
|
||||
let mac_key_1 = derive_metadata_mac_key(d1);
|
||||
let mac_1 = compute_metadata_mac_v2(&mac_key_1, 1, 2, &canonical_1);
|
||||
tx.execute(
|
||||
"UPDATE meta SET version = ?1, metadata_mac = ?2, metadata_gen = 1, transcript_ver = 2 WHERE slot_id = 1",
|
||||
params![FORMAT_VERSION_V3, mac_1.as_slice()],
|
||||
)?;
|
||||
}
|
||||
} else {
|
||||
tx.execute(
|
||||
"UPDATE meta SET version = ?1, metadata_gen = 0, transcript_ver = 2 WHERE slot_id != 0 AND (metadata_mac IS NULL OR slot_id = 1)",
|
||||
params![FORMAT_VERSION_V3],
|
||||
)?;
|
||||
}
|
||||
|
||||
tx.commit()?;
|
||||
drop(conn);
|
||||
self.set_active_slot_and_dek(0, Zeroizing::new(*dek_0));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Erzwingt einen SQLite WAL Checkpoint und leert das Write-Ahead-Log.
|
||||
pub fn checkpoint(&self) -> Result<()> {
|
||||
let conn = self.conn();
|
||||
@@ -2708,6 +2841,7 @@ impl Database {
|
||||
metadata_mac BLOB,
|
||||
metadata_gen INTEGER NOT NULL DEFAULT 0,
|
||||
restore_nonce BLOB,
|
||||
transcript_ver INTEGER NOT NULL DEFAULT 1,
|
||||
lock_pid INTEGER,
|
||||
lock_host TEXT,
|
||||
lock_time INTEGER
|
||||
|
||||
+343
-4
@@ -581,6 +581,11 @@ unsafe extern "system" fn console_ctrl_routine(ctrl_type: u32) -> i32 {
|
||||
drive_str.encode_utf16().chain(std::iter::once(0)).collect();
|
||||
unsafe {
|
||||
WNetCancelConnection2W(wide_drive.as_ptr(), 0, 1);
|
||||
DefineDosDeviceW(
|
||||
0x0000_0002, /* DDD_REMOVE_DEFINITION */
|
||||
wide_drive.as_ptr(),
|
||||
std::ptr::null(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -722,9 +727,338 @@ pub fn unlock_memory(ptr: *const u8, len: usize) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
extern "system" {
|
||||
fn DefineDosDeviceW(
|
||||
dw_flags: u32,
|
||||
lp_device_name: *const u16,
|
||||
lp_target_path: *const u16,
|
||||
) -> i32;
|
||||
fn QueryDosDeviceW(lp_device_name: *const u16, lp_target_path: *mut u16, ucch_max: u32) -> u32;
|
||||
}
|
||||
|
||||
/// Prüft ohne Administratorrechte über den Windows Service Control Manager,
|
||||
/// ob der Dienst `WebClient` deaktiviert (`SERVICE_DISABLED = 4`) oder bereits aktiv ist.
|
||||
/// Rückgabe: `(is_running, is_disabled)`
|
||||
#[cfg(windows)]
|
||||
pub fn query_webclient_service_state() -> (bool, bool) {
|
||||
#[repr(C)]
|
||||
struct SERVICE_STATUS {
|
||||
dw_service_type: u32,
|
||||
dw_current_state: u32,
|
||||
dw_controls_accepted: u32,
|
||||
dw_win32_exit_code: u32,
|
||||
dw_service_specific_exit_code: u32,
|
||||
dw_check_point: u32,
|
||||
dw_wait_hint: u32,
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
struct QUERY_SERVICE_CONFIGW {
|
||||
dw_service_type: u32,
|
||||
dw_start_type: u32,
|
||||
dw_error_control: u32,
|
||||
lp_binary_path_name: *mut u16,
|
||||
lp_load_order_group: *mut u16,
|
||||
dw_tag_id: u32,
|
||||
lp_dependencies: *mut u16,
|
||||
lp_service_start_name: *mut u16,
|
||||
lp_display_name: *mut u16,
|
||||
}
|
||||
|
||||
#[link(name = "advapi32")]
|
||||
extern "system" {
|
||||
fn OpenSCManagerW(
|
||||
lp_machine_name: *const u16,
|
||||
lp_database_name: *const u16,
|
||||
dw_desired_access: u32,
|
||||
) -> isize;
|
||||
fn OpenServiceW(
|
||||
h_sc_manager: isize,
|
||||
lp_service_name: *const u16,
|
||||
dw_desired_access: u32,
|
||||
) -> isize;
|
||||
fn QueryServiceStatus(h_service: isize, lp_service_status: *mut SERVICE_STATUS) -> i32;
|
||||
fn QueryServiceConfigW(
|
||||
h_service: isize,
|
||||
lp_service_config: *mut QUERY_SERVICE_CONFIGW,
|
||||
cb_buf_size: u32,
|
||||
pcb_bytes_needed: *mut u32,
|
||||
) -> i32;
|
||||
fn CloseServiceHandle(h_sc_object: isize) -> i32;
|
||||
}
|
||||
|
||||
const SC_MANAGER_CONNECT: u32 = 0x0001;
|
||||
const SERVICE_QUERY_CONFIG: u32 = 0x0001;
|
||||
const SERVICE_QUERY_STATUS: u32 = 0x0004;
|
||||
const SERVICE_RUNNING: u32 = 4;
|
||||
const SERVICE_DISABLED: u32 = 4;
|
||||
|
||||
unsafe {
|
||||
let scm = OpenSCManagerW(std::ptr::null(), std::ptr::null(), SC_MANAGER_CONNECT);
|
||||
if scm == 0 {
|
||||
return (false, false);
|
||||
}
|
||||
let svc_name: Vec<u16> = "WebClient\0".encode_utf16().collect();
|
||||
let svc = OpenServiceW(
|
||||
scm,
|
||||
svc_name.as_ptr(),
|
||||
SERVICE_QUERY_STATUS | SERVICE_QUERY_CONFIG,
|
||||
);
|
||||
if svc == 0 {
|
||||
CloseServiceHandle(scm);
|
||||
return (false, true);
|
||||
}
|
||||
|
||||
let mut status: SERVICE_STATUS = std::mem::zeroed();
|
||||
let is_running =
|
||||
QueryServiceStatus(svc, &mut status) != 0 && status.dw_current_state == SERVICE_RUNNING;
|
||||
|
||||
let mut bytes_needed: u32 = 0;
|
||||
let _ = QueryServiceConfigW(svc, std::ptr::null_mut(), 0, &mut bytes_needed);
|
||||
let mut is_disabled = false;
|
||||
if bytes_needed > 0 {
|
||||
let mut buf = vec![0u8; bytes_needed as usize];
|
||||
let cfg_ptr = buf.as_mut_ptr() as *mut QUERY_SERVICE_CONFIGW;
|
||||
if QueryServiceConfigW(svc, cfg_ptr, bytes_needed, &mut bytes_needed) != 0 {
|
||||
if (*cfg_ptr).dw_start_type == SERVICE_DISABLED {
|
||||
is_disabled = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
CloseServiceHandle(svc);
|
||||
CloseServiceHandle(scm);
|
||||
(is_running, is_disabled)
|
||||
}
|
||||
}
|
||||
|
||||
/// Weckt den Windows-Dienst `WebClient` aus dem reinen Userland (ohne Administratorrechte)
|
||||
/// über seinen offiziellen Microsoft ETW-Service-Trigger (`22b6d684-fa63-4578-87c9-effcbe6643c7`) auf.
|
||||
#[cfg(windows)]
|
||||
pub fn trigger_webclient_service_etw() -> bool {
|
||||
#[repr(C)]
|
||||
struct GUID {
|
||||
data1: u32,
|
||||
data2: u16,
|
||||
data3: u16,
|
||||
data4: [u8; 8],
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
struct EVENT_DESCRIPTOR {
|
||||
id: u16,
|
||||
version: u8,
|
||||
channel: u8,
|
||||
level: u8,
|
||||
opcode: u8,
|
||||
task: u16,
|
||||
keyword: u64,
|
||||
}
|
||||
|
||||
#[link(name = "advapi32")]
|
||||
extern "system" {
|
||||
fn EventRegister(
|
||||
provider_id: *const GUID,
|
||||
enable_callback: *const std::ffi::c_void,
|
||||
callback_context: *const std::ffi::c_void,
|
||||
reg_handle: *mut u64,
|
||||
) -> u32;
|
||||
fn EventWrite(
|
||||
reg_handle: u64,
|
||||
event_descriptor: *const EVENT_DESCRIPTOR,
|
||||
user_data_count: u32,
|
||||
user_data: *const std::ffi::c_void,
|
||||
) -> u32;
|
||||
fn EventUnregister(reg_handle: u64) -> u32;
|
||||
}
|
||||
|
||||
let (running, disabled) = query_webclient_service_state();
|
||||
if running {
|
||||
return true;
|
||||
}
|
||||
if disabled {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Offizielle Microsoft ETW-Trigger-GUID für WebClient (MRxDAV): 22b6d684-fa63-4578-87c9-effcbe6643c7
|
||||
let webclient_trigger_guid = GUID {
|
||||
data1: 0x22b6_d684,
|
||||
data2: 0xfa63,
|
||||
data3: 0x4578,
|
||||
data4: [0x87, 0xc9, 0xef, 0xfc, 0xbe, 0x66, 0x43, 0xc7],
|
||||
};
|
||||
|
||||
unsafe {
|
||||
let mut reg_handle: u64 = 0;
|
||||
if EventRegister(
|
||||
&webclient_trigger_guid,
|
||||
std::ptr::null(),
|
||||
std::ptr::null(),
|
||||
&mut reg_handle,
|
||||
) == 0
|
||||
{
|
||||
let desc = EVENT_DESCRIPTOR {
|
||||
id: 1,
|
||||
version: 0,
|
||||
channel: 0,
|
||||
level: 0,
|
||||
opcode: 0,
|
||||
task: 0,
|
||||
keyword: 0,
|
||||
};
|
||||
let _ = EventWrite(reg_handle, &desc, 0, std::ptr::null());
|
||||
let _ = EventUnregister(reg_handle);
|
||||
}
|
||||
}
|
||||
|
||||
// Warte kurz (max. 1.5s), bis der SCM den Dienst auf SERVICE_RUNNING geschaltet hat
|
||||
for _ in 0..15 {
|
||||
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||
let (now_running, _) = query_webclient_service_state();
|
||||
if now_running {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
/// Bindet ein lokales Verzeichnis als Laufwerksbuchstaben im aktuellen Benutzersitzungs-Namensraum
|
||||
/// via `DefineDosDeviceW` ein (erfordert keine Administratorrechte und keinen WebClient-Dienst).
|
||||
#[cfg(windows)]
|
||||
pub fn mount_dos_device(drive_letter: char, target_dir: &std::path::Path) -> Result<()> {
|
||||
use std::os::windows::ffi::OsStrExt;
|
||||
|
||||
let drive_str = format!("{}:", drive_letter.to_ascii_uppercase());
|
||||
let wide_drive: Vec<u16> = drive_str.encode_utf16().chain(std::iter::once(0)).collect();
|
||||
let wide_target: Vec<u16> = target_dir
|
||||
.as_os_str()
|
||||
.encode_wide()
|
||||
.chain(std::iter::once(0))
|
||||
.collect();
|
||||
|
||||
// Bereinige ggf. verwaiste Zuordnung auf demselben Buchstaben
|
||||
if is_dos_device_mounted(drive_letter) {
|
||||
unsafe {
|
||||
DefineDosDeviceW(0x0000_0002, wide_drive.as_ptr(), std::ptr::null());
|
||||
}
|
||||
}
|
||||
|
||||
let ok = unsafe { DefineDosDeviceW(0, wide_drive.as_ptr(), wide_target.as_ptr()) };
|
||||
if ok == 0 {
|
||||
bail!(
|
||||
"DefineDosDeviceW konnte Laufwerk {} nicht auf '{}' zuweisen",
|
||||
drive_str,
|
||||
target_dir.display()
|
||||
);
|
||||
}
|
||||
|
||||
// Benachrichtige die Windows Shell (Explorer) über das neue Laufwerk (SHCNE_DRIVEADD)
|
||||
notify_shell_drive_change(drive_letter, true);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Entfernt eine über `DefineDosDeviceW` erstellte Laufwerkszuordnung.
|
||||
#[cfg(windows)]
|
||||
pub fn unmount_dos_device(drive_letter: char, target_dir: Option<&std::path::Path>) -> Result<()> {
|
||||
use std::os::windows::ffi::OsStrExt;
|
||||
|
||||
const DDD_REMOVE_DEFINITION: u32 = 0x0000_0002;
|
||||
const DDD_EXACT_MATCH_ON_REMOVE: u32 = 0x0000_0004;
|
||||
|
||||
let drive_str = format!("{}:", drive_letter.to_ascii_uppercase());
|
||||
let wide_drive: Vec<u16> = drive_str.encode_utf16().chain(std::iter::once(0)).collect();
|
||||
|
||||
let ok = if let Some(dir) = target_dir {
|
||||
let wide_target: Vec<u16> = dir
|
||||
.as_os_str()
|
||||
.encode_wide()
|
||||
.chain(std::iter::once(0))
|
||||
.collect();
|
||||
unsafe {
|
||||
DefineDosDeviceW(
|
||||
DDD_REMOVE_DEFINITION | DDD_EXACT_MATCH_ON_REMOVE,
|
||||
wide_drive.as_ptr(),
|
||||
wide_target.as_ptr(),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
unsafe { DefineDosDeviceW(DDD_REMOVE_DEFINITION, wide_drive.as_ptr(), std::ptr::null()) }
|
||||
};
|
||||
|
||||
if ok == 0 {
|
||||
// Fallback ohne exakten Pfad
|
||||
let _ = unsafe {
|
||||
DefineDosDeviceW(DDD_REMOVE_DEFINITION, wide_drive.as_ptr(), std::ptr::null())
|
||||
};
|
||||
}
|
||||
|
||||
notify_shell_drive_change(drive_letter, false);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Prüft, ob ein Laufwerksbuchstabe aktuell als DOS-Device existiert.
|
||||
#[cfg(windows)]
|
||||
pub fn is_dos_device_mounted(drive_letter: char) -> bool {
|
||||
let drive_str = format!("{}:", drive_letter.to_ascii_uppercase());
|
||||
let wide_drive: Vec<u16> = drive_str.encode_utf16().chain(std::iter::once(0)).collect();
|
||||
let mut buf = [0u16; 512];
|
||||
let len = unsafe { QueryDosDeviceW(wide_drive.as_ptr(), buf.as_mut_ptr(), buf.len() as u32) };
|
||||
len > 0
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
pub fn is_dos_device_mounted(_drive_letter: char) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn notify_shell_drive_change(drive_letter: char, added: bool) {
|
||||
extern "system" {
|
||||
fn SHChangeNotify(
|
||||
w_event_id: i32,
|
||||
u_flags: u32,
|
||||
dw_item1: *const std::ffi::c_void,
|
||||
dw_item2: *const std::ffi::c_void,
|
||||
);
|
||||
}
|
||||
const SHCNE_DRIVEADD: i32 = 0x0000_0100;
|
||||
const SHCNE_DRIVEREMOVED: i32 = 0x0000_0080;
|
||||
const SHCNF_PATHW: u32 = 0x0005;
|
||||
|
||||
let root_str = format!("{}:\\", drive_letter.to_ascii_uppercase());
|
||||
let wide_root: Vec<u16> = root_str.encode_utf16().chain(std::iter::once(0)).collect();
|
||||
let event = if added {
|
||||
SHCNE_DRIVEADD
|
||||
} else {
|
||||
SHCNE_DRIVEREMOVED
|
||||
};
|
||||
unsafe {
|
||||
SHChangeNotify(
|
||||
event,
|
||||
SHCNF_PATHW,
|
||||
wide_root.as_ptr() as *const std::ffi::c_void,
|
||||
std::ptr::null(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Bindet ein Windows-Netzlaufwerk über WNetAddConnection2W ein (in-process, ohne argv-Token-Leak).
|
||||
#[cfg(windows)]
|
||||
pub fn mount_drive_wnet(drive_letter: char, port: u16, session_token: &str) -> Result<()> {
|
||||
// Versuche vorab den WebClient-Dienst per Userland-ETW-Trigger aufzuwecken, falls er nur gestoppt (Manual) ist
|
||||
let (running, disabled) = query_webclient_service_state();
|
||||
if disabled {
|
||||
bail!(
|
||||
"Der Windows-Dienst 'WebClient' wurde durch die Systemadministration deaktiviert (StartType = Disabled)."
|
||||
);
|
||||
}
|
||||
if !running {
|
||||
debug!("WebClient-Dienst ist gestoppt — sende Userland-ETW-Wakeup-Trigger...");
|
||||
let _ = trigger_webclient_service_etw();
|
||||
}
|
||||
|
||||
let drive_str = format!("{}:", drive_letter.to_ascii_uppercase());
|
||||
let mut local_name: Vec<u16> = drive_str.encode_utf16().chain(std::iter::once(0)).collect();
|
||||
let remote_url = format!("http://127.0.0.1:{}/", port);
|
||||
@@ -771,13 +1105,13 @@ pub fn mount_drive_wnet(drive_letter: char, port: u16, session_token: &str) -> R
|
||||
}
|
||||
|
||||
if res != 0 {
|
||||
let webclient_hint = if res == 67 {
|
||||
"\n\nHinweis: Das Einbinden von Netzlaufwerken erfordert den Windows-Dienst 'WebClient'. Prüfen Sie in einer Administrator-Konsole: 'net start WebClient'."
|
||||
let webclient_hint = if res == 67 || res == 1244 {
|
||||
"\n\nHinweis: Das Einbinden von WebDAV-Netzlaufwerken erfordert den Windows-Dienst 'WebClient'."
|
||||
} else {
|
||||
""
|
||||
};
|
||||
bail!(
|
||||
"Laufwerk {} konnte nicht eingebunden werden (Win32 Fehlercode {}){}",
|
||||
"Laufwerk {} konnte nicht über WebClient eingebunden werden (Win32 Fehlercode {}){}",
|
||||
drive_str,
|
||||
res,
|
||||
webclient_hint
|
||||
@@ -787,7 +1121,7 @@ pub fn mount_drive_wnet(drive_letter: char, port: u16, session_token: &str) -> R
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Trennt ein eingebundenes Windows-Netzlaufwerk via WNetCancelConnection2W (in-process).
|
||||
/// Trennt ein eingebundenes Windows-Netzlaufwerk via WNetCancelConnection2W oder Cloud-Files DOS-Device (in-process).
|
||||
#[cfg(windows)]
|
||||
pub fn unmount_drive_wnet(drive_letter: char) -> Result<()> {
|
||||
let drive_str = format!("{}:", drive_letter.to_ascii_uppercase());
|
||||
@@ -797,6 +1131,11 @@ pub fn unmount_drive_wnet(drive_letter: char) -> Result<()> {
|
||||
WNetCancelConnection2W(local_name.as_ptr(), 0, 1 /* fForce = TRUE */)
|
||||
};
|
||||
|
||||
// Falls kein WNet-Laufwerk verbunden war (2250 = ERROR_NOT_CONNECTED), prüfe auf DefineDosDeviceW (Cloud Files)
|
||||
if res == 2250 && is_dos_device_mounted(drive_letter) {
|
||||
return unmount_dos_device(drive_letter, None);
|
||||
}
|
||||
|
||||
// 0 = NO_ERROR, 2250 = ERROR_NOT_CONNECTED (bereits getrennt)
|
||||
if res != 0 && res != 2250 {
|
||||
bail!(
|
||||
|
||||
@@ -466,7 +466,7 @@ async fn test_f01_migration_existing_v3_container() {
|
||||
{
|
||||
let conn = db.conn();
|
||||
conn.execute(
|
||||
"UPDATE meta SET metadata_mac = ?1, metadata_gen = 1 WHERE slot_id = 0",
|
||||
"UPDATE meta SET metadata_mac = ?1, metadata_gen = 1, transcript_ver = 1 WHERE slot_id = 0",
|
||||
rusqlite::params![legacy_mac.as_slice()],
|
||||
)
|
||||
.unwrap();
|
||||
@@ -950,3 +950,422 @@ async fn test_f03_dual_vault_v2_upgrade_carrier() {
|
||||
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// F-04 TESTS: Transcript-Versionierung im HMAC & Rebind-Schutz
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_f04_replayed_093_mac_after_rebind_rejected() {
|
||||
let path = temp_container_path("f04_replayed_093_mac");
|
||||
let password = "TestPasswordF04_1!";
|
||||
let salt = generate_salt();
|
||||
let kdf = test_kdf_params();
|
||||
let kek = derive_kek(password, &salt, &kdf).unwrap();
|
||||
let dek = generate_dek();
|
||||
let (wrapped, nonce, tag) = wrap_dek(&kek, &dek).unwrap();
|
||||
|
||||
let db = Database::open(&path).unwrap();
|
||||
db.init_schema(&salt, &kdf, &wrapped, &nonce, &tag).unwrap();
|
||||
db.set_active_slot_and_dek(0, dek.clone());
|
||||
|
||||
let file_id = db.create_node(1, "document.pdf", false).unwrap().id;
|
||||
let chunk_data = b"Inhalt fuer Test F-04 Replay-Schutz";
|
||||
let (ct, c_nonce, c_tag) =
|
||||
encrypt_chunk(&dek, file_id, 0, chunk_data, FORMAT_VERSION_V3, 0).unwrap();
|
||||
{
|
||||
let conn = db.conn();
|
||||
conn.execute(
|
||||
"INSERT INTO chunks (node_id, chunk_index, generation, nonce, tag, ciphertext) VALUES (?1, 0, 0, ?2, ?3, ?4)",
|
||||
rusqlite::params![file_id, c_nonce.as_slice(), c_tag.as_slice(), ct],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// 1. Simuliere v0.9.3 Container: Metadaten-MAC über reine Knoten ohne transcript_ver
|
||||
let legacy_canonical = db.canonical_nodes_bytes_for_vault_legacy(0).unwrap();
|
||||
let mac_key = sanctum::crypto::derive_metadata_mac_key(&dek);
|
||||
let legacy_mac = sanctum::crypto::compute_metadata_mac(&mac_key, 1, &legacy_canonical);
|
||||
{
|
||||
let conn = db.conn();
|
||||
conn.execute(
|
||||
"UPDATE meta SET metadata_mac = ?1, metadata_gen = 1, transcript_ver = 1 WHERE slot_id = 0",
|
||||
rusqlite::params![legacy_mac.as_slice()],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
db.checkpoint().unwrap();
|
||||
|
||||
// Vor Mount: LegacyValid
|
||||
let status_before = db.verify_metadata_mac_status_for_slot(0, &dek).unwrap();
|
||||
assert_eq!(
|
||||
status_before,
|
||||
MetadataMacStatus::LegacyValid,
|
||||
"Alter 0.9.3 MAC muss vor Mount als LegacyValid erkannt werden"
|
||||
);
|
||||
drop(db);
|
||||
|
||||
// 2. Erster Mount: migriert transparent auf transcript_ver = 2
|
||||
let auth = ContainerAuth::Password(Zeroizing::new(password.to_string()));
|
||||
let c_path = path.clone();
|
||||
let mount_task = tokio::spawn(async move {
|
||||
mount_container(
|
||||
&c_path,
|
||||
'Y',
|
||||
None,
|
||||
Some(18108),
|
||||
auth,
|
||||
false,
|
||||
false,
|
||||
None,
|
||||
false,
|
||||
false,
|
||||
None,
|
||||
true,
|
||||
false,
|
||||
)
|
||||
.await
|
||||
});
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(250)).await;
|
||||
mount_task.abort();
|
||||
|
||||
// Nach Mount: Status muss Valid sein und transcript_ver = 2
|
||||
let db_after = Database::open(&path).unwrap();
|
||||
let status_after = db_after
|
||||
.verify_metadata_mac_status_for_slot(0, &dek)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
status_after,
|
||||
MetadataMacStatus::Valid,
|
||||
"Nach erstem Mount muss der MAC auf Transcript-Version 2 migriert sein"
|
||||
);
|
||||
|
||||
// 3. Angreifer versucht Rollback: Alten 0.9.3 MAC-Blob wieder einspielen
|
||||
{
|
||||
let conn = db_after.conn();
|
||||
conn.execute(
|
||||
"UPDATE meta SET metadata_mac = ?1, metadata_gen = 1 WHERE slot_id = 0",
|
||||
rusqlite::params![legacy_mac.as_slice()],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
db_after.checkpoint().unwrap();
|
||||
|
||||
// 4. Verifikation: Nach Rebind MUSS der alte MAC strikt Invalid sein (kein zweites LegacyValid!)
|
||||
let status_replayed = db_after
|
||||
.verify_metadata_mac_status_for_slot(0, &dek)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
status_replayed,
|
||||
MetadataMacStatus::Invalid,
|
||||
"Alter 0.9.3-MAC darf nach Rebind auf ver=2 NIEMALS mehr als LegacyValid akzeptiert werden (F-04)"
|
||||
);
|
||||
assert!(!db_after.verify_metadata_mac_for_slot(0, &dek).unwrap());
|
||||
|
||||
drop(db_after);
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_f04_native_094_mac_mount_rebinds_to_v2() {
|
||||
let path = temp_container_path("f04_094_rebind");
|
||||
let password = "TestPasswordF04_2!";
|
||||
let salt = generate_salt();
|
||||
let kdf = test_kdf_params();
|
||||
let kek = derive_kek(password, &salt, &kdf).unwrap();
|
||||
let dek = generate_dek();
|
||||
let (wrapped, nonce, tag) = wrap_dek(&kek, &dek).unwrap();
|
||||
|
||||
let db = Database::open(&path).unwrap();
|
||||
db.init_schema(&salt, &kdf, &wrapped, &nonce, &tag).unwrap();
|
||||
db.set_active_slot_and_dek(0, dek.clone());
|
||||
|
||||
let file_id = db.create_node(1, "notes.txt", false).unwrap().id;
|
||||
let chunk_data = b"0.9.4 natives Transcript ohne Versionsfeld";
|
||||
let (ct, c_nonce, c_tag) =
|
||||
encrypt_chunk(&dek, file_id, 0, chunk_data, FORMAT_VERSION_V3, 0).unwrap();
|
||||
{
|
||||
let conn = db.conn();
|
||||
conn.execute(
|
||||
"INSERT INTO chunks (node_id, chunk_index, generation, nonce, tag, ciphertext) VALUES (?1, 0, 0, ?2, ?3, ?4)",
|
||||
rusqlite::params![file_id, c_nonce.as_slice(), c_tag.as_slice(), ct],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// 0.9.4 MAC: canonical_v2 (mit Generationen), aber OHNE transcript_ver im Input
|
||||
let canonical_v2 = db.canonical_nodes_bytes_for_vault(0).unwrap();
|
||||
let mac_key = sanctum::crypto::derive_metadata_mac_key(&dek);
|
||||
let mac_094 = sanctum::crypto::compute_metadata_mac(&mac_key, 1, &canonical_v2);
|
||||
{
|
||||
let conn = db.conn();
|
||||
conn.execute(
|
||||
"UPDATE meta SET metadata_mac = ?1, metadata_gen = 1, transcript_ver = 1 WHERE slot_id = 0",
|
||||
rusqlite::params![mac_094.as_slice()],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
db.checkpoint().unwrap();
|
||||
|
||||
// Vor Mount: NeedsTranscriptVer
|
||||
let status_before = db.verify_metadata_mac_status_for_slot(0, &dek).unwrap();
|
||||
assert_eq!(
|
||||
status_before,
|
||||
MetadataMacStatus::NeedsTranscriptVer,
|
||||
"0.9.4 Container muss als NeedsTranscriptVer eingestuft werden"
|
||||
);
|
||||
assert!(db.verify_metadata_mac_for_slot(0, &dek).unwrap());
|
||||
drop(db);
|
||||
|
||||
// Mounten migriert auf ver=2
|
||||
let auth = ContainerAuth::Password(Zeroizing::new(password.to_string()));
|
||||
let c_path = path.clone();
|
||||
let mount_task = tokio::spawn(async move {
|
||||
mount_container(
|
||||
&c_path,
|
||||
'Y',
|
||||
None,
|
||||
Some(18109),
|
||||
auth,
|
||||
false,
|
||||
false,
|
||||
None,
|
||||
false,
|
||||
false,
|
||||
None,
|
||||
true,
|
||||
false,
|
||||
)
|
||||
.await
|
||||
});
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(250)).await;
|
||||
mount_task.abort();
|
||||
|
||||
let db_after = Database::open(&path).unwrap();
|
||||
let status_after = db_after
|
||||
.verify_metadata_mac_status_for_slot(0, &dek)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
status_after,
|
||||
MetadataMacStatus::Valid,
|
||||
"Nach Mount muss 0.9.4-Container auf transcript_ver=2 gebunden sein"
|
||||
);
|
||||
|
||||
drop(db_after);
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_f04_manipulated_transcript_ver_column_uses_hmac() {
|
||||
let path = temp_container_path("f04_manipulated_column");
|
||||
let password = "TestPasswordF04_3!";
|
||||
let salt = generate_salt();
|
||||
let kdf = test_kdf_params();
|
||||
let kek = derive_kek(password, &salt, &kdf).unwrap();
|
||||
let dek = generate_dek();
|
||||
let (wrapped, nonce, tag) = wrap_dek(&kek, &dek).unwrap();
|
||||
|
||||
let db = Database::open(&path).unwrap();
|
||||
db.init_schema(&salt, &kdf, &wrapped, &nonce, &tag).unwrap();
|
||||
db.set_active_slot_and_dek(0, dek.clone());
|
||||
let _ = db.create_node(1, "authentic.txt", false).unwrap();
|
||||
db.update_metadata_mac().unwrap();
|
||||
db.checkpoint().unwrap();
|
||||
|
||||
// Sicherstellen, dass MAC mit ver=2 berechnet wurde
|
||||
assert_eq!(
|
||||
db.verify_metadata_mac_status_for_slot(0, &dek).unwrap(),
|
||||
MetadataMacStatus::Valid
|
||||
);
|
||||
|
||||
// Angreifer manipuliert nur die SQLite-Spalte: transcript_ver = 1 (MAC ist weiterhin ver=2)
|
||||
{
|
||||
let conn = db.conn();
|
||||
conn.execute("UPDATE meta SET transcript_ver = 1 WHERE slot_id = 0", [])
|
||||
.unwrap();
|
||||
}
|
||||
db.checkpoint().unwrap();
|
||||
|
||||
// Verify nutzt den HMAC (Stufe 1 mit ver=2 trifft) und ignoriert die manipulierte Spalte -> Valid
|
||||
let status = db.verify_metadata_mac_status_for_slot(0, &dek).unwrap();
|
||||
assert_eq!(
|
||||
status,
|
||||
MetadataMacStatus::Valid,
|
||||
"Verify prüft HMAC ver=2 und lässt sich nicht durch manipulierte transcript_ver=1 Spalte täuschen (F-04)"
|
||||
);
|
||||
|
||||
drop(db);
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// F-05 TESTS: Atomare Transaktion & Zombie-Container Reparatur
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_f05_atomic_upgrade_commit_includes_mac() {
|
||||
let path = temp_container_path("f05_atomic_commit");
|
||||
let password = "TestPasswordF05_1!";
|
||||
let salt = generate_salt();
|
||||
let kdf = test_kdf_params();
|
||||
let kek = derive_kek(password, &salt, &kdf).unwrap();
|
||||
let dek = generate_dek();
|
||||
let (wrapped, nonce, tag) = wrap_dek(&kek, &dek).unwrap();
|
||||
|
||||
let db = Database::open(&path).unwrap();
|
||||
db.init_schema(&salt, &kdf, &wrapped, &nonce, &tag).unwrap();
|
||||
|
||||
// Auf V2 zurücksetzen
|
||||
{
|
||||
let conn = db.conn();
|
||||
conn.execute(
|
||||
"UPDATE meta SET version = 2, metadata_mac = NULL, metadata_gen = 0",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
db.checkpoint().unwrap();
|
||||
|
||||
// Upgrade auf V3 ausführen
|
||||
db.upgrade_to_v3(&dek).unwrap();
|
||||
db.checkpoint().unwrap();
|
||||
|
||||
// Verifizieren: In SQLite muss atomar version = 3, metadata_mac IS NOT NULL und transcript_ver = 2 gesetzt sein
|
||||
{
|
||||
let conn = db.conn();
|
||||
let (v, mac_opt, gen, trans_ver): (u32, Option<Vec<u8>>, u64, u32) = conn
|
||||
.query_row(
|
||||
"SELECT version, metadata_mac, metadata_gen, transcript_ver FROM meta WHERE slot_id = 0",
|
||||
[],
|
||||
|r| Ok((r.get(0)?, r.get(1).ok(), r.get(2)?, r.get(3)?)),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(v, 3);
|
||||
assert!(
|
||||
mac_opt.is_some(),
|
||||
"MAC muss direkt beim Commit persistiert sein"
|
||||
);
|
||||
assert_eq!(gen, 1);
|
||||
assert_eq!(trans_ver, 2);
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
db.verify_metadata_mac_status_for_slot(0, &dek).unwrap(),
|
||||
MetadataMacStatus::Valid
|
||||
);
|
||||
|
||||
drop(db);
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_f05_zombie_container_repaired_by_upgrade_format() {
|
||||
let path = temp_container_path("f05_zombie_repair");
|
||||
let password = "TestPasswordF05_2!";
|
||||
let salt = generate_salt();
|
||||
let kdf = test_kdf_params();
|
||||
let kek = derive_kek(password, &salt, &kdf).unwrap();
|
||||
let dek = generate_dek();
|
||||
let (wrapped, nonce, tag) = wrap_dek(&kek, &dek).unwrap();
|
||||
|
||||
let db = Database::open(&path).unwrap();
|
||||
db.init_schema(&salt, &kdf, &wrapped, &nonce, &tag).unwrap();
|
||||
db.set_active_slot_and_dek(0, dek.clone());
|
||||
let _ = db.create_node(1, "survivor.dat", false).unwrap();
|
||||
db.update_metadata_mac().unwrap();
|
||||
db.checkpoint().unwrap();
|
||||
|
||||
// Künstlich Zombie-Zustand herstellen: version=3, MAC NULL, restore_nonce NULL
|
||||
{
|
||||
let conn = db.conn();
|
||||
conn.execute(
|
||||
"UPDATE meta SET version = 3, metadata_mac = NULL, metadata_gen = 0, restore_nonce = NULL WHERE slot_id = 0",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
db.checkpoint().unwrap();
|
||||
|
||||
// Vor Reparatur: Status ist Invalid (F-02 Fail-Closed)
|
||||
assert_eq!(
|
||||
db.verify_metadata_mac_status_for_slot(0, &dek).unwrap(),
|
||||
MetadataMacStatus::Invalid
|
||||
);
|
||||
|
||||
// Reparatur via upgrade_to_v3 (bzw. Repair-Pfad)
|
||||
db.upgrade_to_v3(&dek).unwrap();
|
||||
db.checkpoint().unwrap();
|
||||
|
||||
// Nach Reparatur: Status muss Valid sein (ver=2)
|
||||
assert_eq!(
|
||||
db.verify_metadata_mac_status_for_slot(0, &dek).unwrap(),
|
||||
MetadataMacStatus::Valid,
|
||||
"Zombie-Container muss nach upgrade_to_v3 mit neuem MAC repariert sein"
|
||||
);
|
||||
|
||||
// Container muss nun fehlerfrei mountbar sein
|
||||
let auth = ContainerAuth::Password(Zeroizing::new(password.to_string()));
|
||||
let c_path = path.clone();
|
||||
let mount_task = tokio::spawn(async move {
|
||||
mount_container(
|
||||
&c_path,
|
||||
'Y',
|
||||
None,
|
||||
Some(18110),
|
||||
auth,
|
||||
false,
|
||||
false,
|
||||
None,
|
||||
false,
|
||||
false,
|
||||
None,
|
||||
true,
|
||||
false,
|
||||
)
|
||||
.await
|
||||
});
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(250)).await;
|
||||
mount_task.abort();
|
||||
|
||||
drop(db);
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_f05_zombie_repair_aborts_if_restore_nonce_set() {
|
||||
let path = temp_container_path("f05_nonce_guard");
|
||||
let password = "TestPasswordF05_3!";
|
||||
let salt = generate_salt();
|
||||
let kdf = test_kdf_params();
|
||||
let kek = derive_kek(password, &salt, &kdf).unwrap();
|
||||
let dek = generate_dek();
|
||||
let (wrapped, nonce, tag) = wrap_dek(&kek, &dek).unwrap();
|
||||
|
||||
let db = Database::open(&path).unwrap();
|
||||
db.init_schema(&salt, &kdf, &wrapped, &nonce, &tag).unwrap();
|
||||
|
||||
// Setze restore_nonce (simuliert restore_header_backup)
|
||||
let restore_token = [42u8; 32];
|
||||
{
|
||||
let conn = db.conn();
|
||||
conn.execute(
|
||||
"UPDATE meta SET version = 3, metadata_mac = NULL, metadata_gen = 0, restore_nonce = ?1 WHERE slot_id = 0",
|
||||
rusqlite::params![restore_token.as_slice()],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
db.checkpoint().unwrap();
|
||||
|
||||
// repair_v3_mac DARF NICHT auslösen, wenn restore_nonce gesetzt ist (F-02 bleibt zuständig)
|
||||
let res = db.repair_v3_mac(&dek, None);
|
||||
assert!(
|
||||
res.is_err(),
|
||||
"repair_v3_mac muss fehlschlagen, wenn restore_nonce gesetzt ist (kein F-02 Bypass)"
|
||||
);
|
||||
|
||||
drop(db);
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
Reference in new issue
Block a user