Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a1a1ca6ce | ||
|
|
510cb9b64e | ||
|
|
abcd85524d | ||
|
|
66aa52b8c5 | ||
|
|
2286fad112 | ||
|
|
f0e5d6d26e | ||
|
|
73453a7eb1 |
@@ -0,0 +1,5 @@
|
||||
# Gitleaks ignore file
|
||||
# Test mock constants in unit tests (confirmed non-sensitive test tokens)
|
||||
2af14eef362e718a86bb079c4897f61719398c42:src/mount.rs:generic-api-key:976
|
||||
1cdb30147b3c4ecb7f46db39f530a85113ad590d:tests/mount_security_test.rs:generic-api-key:90
|
||||
436790abf0e904c1aaeef51fb10cb318ce9b8bdc:tests/upgrade_security_test.rs:generic-api-key:42
|
||||
@@ -0,0 +1,98 @@
|
||||
# Sanctum — Projekt-Kontext & Agenten-Leitfaden (AGENTS.md)
|
||||
|
||||
Dieses Dokument dient als primäres Projektgedächtnis für Antigravity-Agenten und Entwickler. Es verknüpft die historische Entwicklungshistorie, dokumentiert die Kernarchitektur, verweist auf alle Sicherheitsdokumente und definiert die verbindlichen Quality Gates.
|
||||
|
||||
---
|
||||
|
||||
## 1. Konversations-Referenz & Historie
|
||||
|
||||
Die gesamte Genese dieses Projekts (Audit-Behebung aller 39 Findings, Carrier V2 Paged Manifest, Disaster Recovery MAC-Fix R-NEW-1, Supply-Chain-Audits und Releases bis v0.9.3) ist in folgender Konversation protokolliert:
|
||||
|
||||
🔗 **[Sanctum Genese, Audit-Härtung & v0.9.3 Release-Konversation](conversation://f8a1e581-6556-4799-b012-fabcae853c09)**
|
||||
*(ID: `f8a1e581-6556-4799-b012-fabcae853c09` — kann in Antigravity per Klick oder im Chat via `@Conversations` geladen werden).*
|
||||
|
||||
---
|
||||
|
||||
## 2. Projektüberblick & Kernarchitektur
|
||||
|
||||
**Sanctum** ist eine speichersichere, hochperformante Userland-CLI in Rust, die verschlüsselte Ein-Datei-Container (`.sanctum`) unter Windows 10/11 und Linux verwaltet — zu 100 % im Userland ohne Administratorrechte oder Kernel-Treiber (kein Dokan, kein WinFsp).
|
||||
|
||||
### 2.1 Kryptografische Säulen (Container-Format V3)
|
||||
- **Format-Version**: Neue Container nutzen Format V3 (`format_version = 3`).
|
||||
- **KDF**: Argon2id ($M=256\,\text{MiB}, T=4, P=4$, M-01) zur KEK-Ableitung mit CSPRNG-Salts (`OsRng`).
|
||||
- **DEK & Wrapping**: 256-Bit DEK, gewrappt per AES-256-GCM.
|
||||
- **Kanonische Metadaten-Authentifizierung (K-01)**:
|
||||
- Inode-Metadaten werden mit einer HMAC-SHA-256 (`SANCTUM_META_MAC_V3`) authentifiziert.
|
||||
- Validierung bei jedem `mount` (`verify_metadata_mac_status_for_slot`).
|
||||
- Disaster-Recovery-Resilienz (R-NEW-1): Rebuild-Fähigkeit via `MetadataMacStatus::PendingRebuild` nach Header-Wiederherstellung.
|
||||
- **Chunk-Replay-Schutz (K-02)**: 24-Byte AAD (`node_id || chunk_index || metadata_gen`).
|
||||
- **Dateinamen-Verschlüsselung**: AES-256-GCM mit Bindung an `parent_id` als AAD (Swap-Schutz). Warnung bei `--legacy-names`.
|
||||
|
||||
### 2.2 Dual-Vault & Carrier-Format V2 (Paged Manifest)
|
||||
- **2-Slot-Architektur**: Slot 0 als Decoy-Vault, Slot 1 als Second Safe (Hidden Vault Modell A).
|
||||
- **Steganografie & Größeninvarianz**: Der Hidden Vault liegt innerhalb einer Alibi-Trägerdatei im Decoy-Vault. Versteckte Schreibvorgänge verändern die Host-Dateigröße um exakt 0 Bytes.
|
||||
- **Paged Manifest V2**:
|
||||
- Trennung von Superblock (redundant auf Block 0 und Block 1, rollierende `manifest_generation`, C-02) und Inode-Seiten (`CarrierInodePage`, ~1 MB Payload, ca. 4.500–7.000 Inodes/Seite).
|
||||
- Keine starre Kapazitätsobergrenze mehr; dynamische Skalierung mit den Trägerblöcken.
|
||||
- Fail-Soft Resilienz (D-01): Isolierung beschädigter Seiten ohne Mount-Abbruch.
|
||||
- In-Memory Sekundärindex (D-02): $O(\text{Geschwister})$ Pfadauflösung.
|
||||
- Manifest-Entkopplung via Dirty-Tracking (C-03): Schnelle I/O-Operationen ohne synchrone 1-MB-Manifest-Neuverschlüsselung.
|
||||
|
||||
### 2.3 OpSec, Anti-Leak & Disaster Recovery
|
||||
- **Anti-Leak Shield**: Blockiert Explorer-Artefakte (`Thumbs.db`, `desktop.ini`, `*.tmp`, NTFS ADS `:Zone.Identifier`).
|
||||
- **Memory Security**: `Zeroize` für alle Schlüssel und Passwörter; `VirtualLock` / `mlock` gegen Swap-Auslagerung.
|
||||
- **Transaktionales Chunk-Shredding**: Logisches Überschreiben von Chunks mit CSPRNG-Rauschen vor dem Löschen.
|
||||
- **Disaster Recovery**: 24-Wort BIP-39 Notfallschlüssel pro Slot, Online-Backups via SQLite Online Backup API.
|
||||
|
||||
---
|
||||
|
||||
## 3. Zentrale Dokumentation im Repository
|
||||
|
||||
| Dokument | Zweck / Inhalt |
|
||||
|---|---|
|
||||
| [`README.md`](README.md) | Hauptdokumentation, Feature-Übersicht, CLI-Verwendung & V1→V2 Migrationsanleitung |
|
||||
| [`SECURITY.md`](SECURITY.md) | Responsible Disclosure Policy, SLAs (48h/5d/90d), Scope, Meldekanal (`security@pansi.eu`) |
|
||||
| [`SECURITY_AUDIT.md`](SECURITY_AUDIT.md) | Vollständiges Log aller 39 behobenen Audit-Findings + R-NEW-1 + Tool-Audits |
|
||||
| [`THREAT_MODEL.md`](THREAT_MODEL.md) | Bedrohungsmodell, Angreiferprofile und Sicherheitsannahmen |
|
||||
| [`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) |
|
||||
| [`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 |
|
||||
|
||||
---
|
||||
|
||||
## 4. Richtlinien für Agenten (Quality Gates)
|
||||
|
||||
Jede Code- oder Konfigurationsänderung **MUSS** vor einem Release oder Commit folgende Gates erfolgreich und ohne Warnungen durchlaufen:
|
||||
|
||||
1. **Formatierung**:
|
||||
```powershell
|
||||
cargo fmt --check
|
||||
```
|
||||
2. **Statische Code-Analyse (Linter)**:
|
||||
```powershell
|
||||
cargo clippy --all-targets -- -D warnings
|
||||
```
|
||||
3. **Vollständige Testsuite** (aktuell 136 Tests):
|
||||
```powershell
|
||||
cargo test --all
|
||||
```
|
||||
4. **Supply-Chain & Lizenz-Governance**:
|
||||
```powershell
|
||||
cargo-deny check
|
||||
```
|
||||
5. **Secret-Leak-Prävention**:
|
||||
```powershell
|
||||
gitleaks detect --verbose
|
||||
```
|
||||
*(Erlaubte Test-Mocks in `.gitleaksignore` pflegen).*
|
||||
|
||||
---
|
||||
|
||||
## 5. Coding-Konventionen
|
||||
- **Keine Panics im Produktivcode**: Verwende `anyhow::Result`, `context(...)` oder `bail!` anstelle von `.unwrap()` oder `.expect()`.
|
||||
- **Pfad- und Knotennamens-Sicherheit**: Alle neuen Dateinamen-Operationen müssen `validate_node_name` / `validate_path_safety` durchlaufen (Abweisung von Windows-Reservierungen wie `CON`, `PRN`, ungültigen Zeichen `<>:"/\|?*` und Steuerzeichen).
|
||||
- **Speichersicherheit**: Alle sensiblen Schlüsselstrukturen müssen `Zeroize` implementieren und mit `Zeroizing<T>` gewrappt werden.
|
||||
- **PowerShell-Syntax**: Befehle in PowerShell mit `;` verketten (nicht `&&`). Keine `cd`-Befehle verwenden.
|
||||
@@ -5,6 +5,56 @@ 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.4] - 2026-09-22
|
||||
|
||||
### Format-V3 Systembindung & Integritäts-Härtung (F-01, F-02, F-03)
|
||||
Dieses Patch-Release schließt kritische Bindungs- und Replay-Schwächen im Format-V3-Kryptosystem. Es behebt den PendingRebuild-Bypass bei Header-Wiederherstellungen (F-02), integriert Chunk-Generationen in das kanonische Metadaten-MAC-Transcript zur Abwehr von Content-Replay-Angriffen gleicher Dateigröße (F-01) und stellt die vollständige Transaktions-Atomarität des Format-Upgrades auf V3 sicher (F-03).
|
||||
|
||||
#### Sicherheits-Fixes
|
||||
- **F-02 (HOCH): Schließung des PendingRebuild-Bypasses bei manipuliertem Header**:
|
||||
- *Problem / Bypass in v0.9.2 und v0.9.3*: Die Prüfung `metadata_gen == 0 && metadata_mac IS NULL -> PendingRebuild` erlaubte einem Angreifer mit Schreibzugriff auf die Containerdatei, `meta` auf `NULL` zu setzen und Decoy-Dateien umzubenennen/zu verschieben. Der nächste legitime `sanctum mount` signierte die Manipulation stillschweigend.
|
||||
- *Fix*: Header-Wiederherstellungen via `restore_header_backup` erzeugen nun ein 32-Byte CSPRNG-Token `restore_nonce` in `meta`. `PendingRebuild` wird ausschließlich akzeptiert, wenn dieses Token vorhanden und `metadata_gen == 0` ist.
|
||||
- *Autorisierungszwang*: `sanctum mount` erfordert nach einem Restore zwingend das neue Flag `--rebuild-mac` oder eine explizite interaktive Eingabe von `JA` mit lauter Sicherheitswarnung. `-y` allein autorisiert keinen Rebuild. `NULL`-MAC ohne Restore-Token wird strikt als `Invalid` abgewiesen (`bail!`). Nach erfolgreichem Rebuild wird `restore_nonce` gelöscht.
|
||||
- **F-01 (HOCH): Chunk-Generation-Replay im Transcript & Transkript-Bindung**:
|
||||
- *Problem*: K-02 band die `generation` lediglich in die AAD der gleichen Zeile ein. Ein Angreifer konnte eine Datei gleicher Größe überschreiben und anschließend eine alte Chunk-Zeile (ct, nonce, tag, generation) zurückkopieren. Da das K-01 Transcript bisher nur `chunk_count` umfasste, blieb der Metadaten-MAC gültig und die AEAD-Entschlüsselung lieferte unbemerkt veralteten Klartext. K-02 ohne Transcript-Bindung war kein vollständiger Speicher-Replay-Schutz.
|
||||
- *Fix*: Das kanonische Transcript pro Vault wurde um sortierte Chunk-Tupel erweitert:
|
||||
`ORDER BY node_id, chunk_index: node_id LE64 ‖ chunk_index LE32 ‖ generation LE64` (kein Ciphertext im MAC).
|
||||
Jede Schreiboperation (`write_chunk_and_update_size`, Truncate, Delete) bindet die neuen Generationen in den Metadaten-MAC ein.
|
||||
- *Migration bestehender V3-Container (§7)*: Ältere v0.9.2/v0.9.3-Container werden gegen das bisherige Knoten-Transcript geprüft. Ist der alte MAC valide (`LegacyValid`), wird das Transcript beim ersten Mounten transparent auf das neue Format mit Chunk-Generationen aktualisiert. Bei ungültigem MAC bricht Sanctum fail-closed ab.
|
||||
- *Test-Präzisierung*: Der bestehende AAD-Test in `src/crypto.rs` wurde in `test_k02_chunk_generation_aead_binding` umbenannt, um klarzustellen, dass er nur die AEAD-Bindung im Header und nicht den Replay-Schutz gegen Zeilen-Ersetzung nachweist. Ein neuer End-to-End-Test verifiziert den Replay-Schutz gegen Zeilen-Ersetzung.
|
||||
- **F-03 (HOCH): upgrade_to_v3 Transaktions-Atomarität & Dual-Slot-Konsistenz**:
|
||||
- *Problem*: `upgrade_to_v3` übersprang fehlerhafte Chunks mit `if let Ok(...)` stumm, aktualisierte nur Slot 0 auf `version = 3`, ließ Slot 1 auf Version 2 zurück und meldete fälschlich Erfolg. Bei Hidden-Mounts führte die inkonsistente Slot-Version zu AAD-Fehlern.
|
||||
- *Fix*: Das gesamte Format-Upgrade läuft nun in einer einzigen atomaren SQLite-Transaktion. Fehler bei `decrypt_chunk` oder `encrypt_chunk` brechen sofort via `?` ab und rollen die Transaktion vollständig zurück (`version` bleibt 2, keine halben V3-Zeilen). Chunks, die nicht mit DEK_0 entschlüsseln, brechen fail-closed ab (bei Legacy-Hidden wird das zweite Passwort verlangt).
|
||||
- *Synchrone Versionierung*: Nach erfolgreichem Durchlauf wird `version = 3` synchron für Slot 0 und Slot 1 (sowie Dummy-Slots) gesetzt. Slot 0 Metadaten-MAC wird aufgebaut; Slot 1 nur, falls echte SQLite-Knoten unter Root 2 existieren. Carrier-Inner bleibt Carrier-AEAD.
|
||||
|
||||
## [0.9.3] - 2026-09-21
|
||||
|
||||
### Sicherheits-Governance, Supply-Chain-Audit & Dokumentationsabgleich
|
||||
Dieses Release etabliert formale Sicherheitsrichtlinien und automatisierte Supply-Chain-Prüfungen vor der breiten Veröffentlichung des Projekts und korrigiert historische Dokumentationsstände der Argon2id-Parameter.
|
||||
|
||||
#### Sicherheitsrichtlinien & Responsible Disclosure
|
||||
- **Einführung von `SECURITY.md`**:
|
||||
- Definition des Responsible-Disclosure-Prozesses mit verbindlichen SLAs (48h Erstkontakt, 5 Werktage Risikobewertung, 90 Tage Embargo).
|
||||
- Bereitstellung dedizierter Meldekanäle via E-Mail (`security@pansi.eu`) und vertraulicher Gitea Security Advisories.
|
||||
- Klare Abgrenzung des Sicherheits-Geltungsbereichs (In/Out of Scope) und Safe-Harbor-Zusicherung für ethische Sicherheitsforscher.
|
||||
- Verlinkung in `README.md`.
|
||||
|
||||
#### Automatisierte Audits & Supply-Chain-Governance
|
||||
- **Dependency & License Governance (`cargo-deny`)**:
|
||||
- Bereitstellung einer strikten `deny.toml` für Lizenzen, Sicherheitswarnungen, Crates-Bans und Repository-Quellen.
|
||||
- Verifikation aller 248 Abhängigkeiten: 0 Advisories, 0 unzulässige Lizenzen.
|
||||
- **Vulnerability-Audit (`cargo-audit`)**:
|
||||
- Vollständiger Abgleich aller Abhängigkeiten gegen die RustSec Advisory Database: 0 Sicherheitslücken.
|
||||
- **Git-Historien-Audit auf Secrets (`gitleaks`)**:
|
||||
- Audit aller 101+ Git-Commits: Keine echten Secrets, privaten Schlüssel oder API-Tokens im Repository.
|
||||
- Konfiguration von `.gitleaksignore` für harmlose Test-Mock-Token in Unittests.
|
||||
- **Erweiterung von `SECURITY_AUDIT.md`**:
|
||||
- Dokumentation aller Tool-Audits in neuem Abschnitt 8.
|
||||
|
||||
#### Dokumentation & CLI-Korrekturen
|
||||
- **Argon2id-Standardparameter (M-01 Synchronisation)**:
|
||||
- Anpassung veralteter Angaben in `README.md` und den CLI-Ausgabetexten von `sanctum init` auf die tatsächlichen M-01 Standardwerte ($M=256\,\text{MiB}, T=4, P=4$).
|
||||
|
||||
## [0.9.2] - 2026-09-21
|
||||
|
||||
### Recovery-MAC-Fix & Vollständiger Release-Workflow
|
||||
|
||||
Generated
+1
-1
@@ -1457,7 +1457,7 @@ checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
|
||||
|
||||
[[package]]
|
||||
name = "sanctum"
|
||||
version = "0.9.2"
|
||||
version = "0.9.4"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"anyhow",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "sanctum"
|
||||
version = "0.9.2"
|
||||
version = "0.9.4"
|
||||
edition = "2021"
|
||||
authors = ["Harald Pansi <harald@pansi.eu>", "Sanctum Engineering Team"]
|
||||
description = "Verschlüsselter Ein-Datei-Container unter Windows im reinen Userland via WebDAV"
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# Antigravity & Gemini Project Context
|
||||
|
||||
Die vollständigen Projektrichtlinien, die Verlinkung zur Entstehungs- und Audit-Konversation, die Architektur-Dokumentation sowie die verbindlichen Quality Gates für Sanctum befinden sich in:
|
||||
|
||||
👉 **[AGENTS.md](AGENTS.md)**
|
||||
@@ -19,22 +19,22 @@ Sanctum ist eine eigenständige, speichersichere und hochperformante CLI-Anwendu
|
||||
- **Disaster Recovery**: 24-Wort BIP-39 Mnemonic Seed Phrases, konsistente Online-Backups via SQLite Online Backup API und kryptografische Vollprüfung (`sanctum verify`).
|
||||
- **Statisches Single-Binary**: Standalone-Executables ohne externe DLL-Abhängigkeiten (`sanctum.exe` für Windows, statisches musl-ELF für Linux).
|
||||
|
||||
> 💡 **Neu bei Sanctum?** Eine kompakte Schritt-für-Schritt-Anleitung findest du in der [Schnellstartanleitung (QUICKSTART.md)](QUICKSTART.md). Hinweise zur Installation via Scoop oder Winget gibt es im [Installations-Guide (INSTALL.md)](INSTALL.md).
|
||||
> 💡 **Neu bei Sanctum?** Eine kompakte Schritt-für-Schritt-Anleitung findest du in der [Schnellstartanleitung (QUICKSTART.md)](QUICKSTART.md). Hinweise zur Installation via Scoop oder Winget gibt es im [Installations-Guide (INSTALL.md)](INSTALL.md). Details zur Sicherheitsrichtlinie und Responsible Disclosure siehe [SECURITY.md](SECURITY.md).
|
||||
|
||||
---
|
||||
|
||||
## 🔐 Kryptografie & Sicherheitsarchitektur
|
||||
|
||||
- **Schlüsselableitung (Argon2id)**:
|
||||
Aus dem Master-Passwort wird mittels `Argon2id` ($M=64\,\text{MB}, T=3, P=4$) ein 256-Bit Key Encryption Key (KEK) abgeleitet.
|
||||
Aus dem Master-Passwort wird mittels `Argon2id` ($M=256\,\text{MiB}, T=4, P=4$) ein 256-Bit Key Encryption Key (KEK) abgeleitet.
|
||||
- **Data Encryption Key (DEK)**:
|
||||
Zufälliger 256-Bit Schlüssel via CSPRNG (`OsRng`). Der DEK wird mit dem KEK via AES-256-GCM verschlüsselt und im Header abgelegt.
|
||||
- **Speichersicherheit (Zeroize & VirtualLock)**:
|
||||
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)**:
|
||||
Dateien werden in Blöcken von 1 MB verschlüsselt.
|
||||
- **Swap-Attack-Schutz**:
|
||||
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.
|
||||
- **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).
|
||||
- **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).
|
||||
@@ -160,10 +160,29 @@ sanctum.exe backup --path "C:\Pfad\tresor.sanctum" --output "D:\Backup\tresor_ba
|
||||
# Container aus Backup wiederherstellen:
|
||||
sanctum.exe restore --path "D:\Backup\tresor_backup.sanctum" --output "C:\Pfad\tresor_restored.sanctum"
|
||||
|
||||
# Vollständige Integritätsprüfung (B-Tree, Knoten und AEAD-Tags aller Chunks):
|
||||
# Header in separate Sicherungsdatei (.sanctum.hdr) sichern:
|
||||
sanctum.exe backup-header --path "C:\Pfad\tresor.sanctum" --output "D:\Backup\tresor.hdr"
|
||||
|
||||
# Header aus Sicherungsdatei (.sanctum.hdr) wiederherstellen:
|
||||
sanctum.exe restore-header --path "C:\Pfad\tresor.sanctum" --header "D:\Backup\tresor.hdr"
|
||||
|
||||
# Nach Header-Wiederherstellung: Metadaten-MAC mit autorisierter Neubindung mounten (F-02):
|
||||
sanctum.exe mount --path "C:\Pfad\tresor.sanctum" --rebuild-mac
|
||||
|
||||
# Vollständige Integritätsprüfung (B-Tree, Knoten, Chunk-AEAD und Metadaten-MAC):
|
||||
sanctum.exe verify --path "C:\Pfad\tresor.sanctum"
|
||||
```
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **Sicherheitsgarantie bei Header-Wiederherstellungen (F-02)**: Nach einem `restore-header` setzt Sanctum ein einmaliges 32-Byte CSPRNG-Token (`restore_nonce`). Der nachfolgende `mount` verlangt zwingend `--rebuild-mac` (oder eine interaktive Bestätigung mit `JA`), um den Metadaten-MAC neu aufzubauen und das Token zu löschen. Ein stillschweigendes Signieren manipulierter Metadaten ist ausgeschlossen; `-y` allein autorisiert keinen Rebuild.
|
||||
|
||||
```powershell
|
||||
# Ältere V2-Container auf Format V3 aktualisieren (atomar & transaktional, F-03):
|
||||
sanctum.exe upgrade-format --path "C:\Pfad\tresor.sanctum"
|
||||
```
|
||||
> [!NOTE]
|
||||
> `upgrade-format` führt die Umschlüsselung aller Chunks von 16-Byte- auf 24-Byte-AAD und die Aktualisierung beider Header-Slots auf `version = 3` in einer **einzigen atomaren SQLite-Transaktion** durch. Bei Fehlern greift ein automatischer Rollback ohne inkonsistente Mischzustände.
|
||||
|
||||
---
|
||||
|
||||
### 6. Storage Compaction (Speicherbereinigung)
|
||||
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
# Sicherheitsrichtlinie & Responsible Disclosure (SECURITY.md)
|
||||
|
||||
Die Sicherheit von **Sanctum** und der Schutz der Daten unserer Anwender haben höchste Priorität. Dieses Dokument definiert die Sicherheitsrichtlinie, den Geltungsbereich und den formalen Prozess zur vertraulichen Meldung von Sicherheitslücken (**Responsible Disclosure**).
|
||||
|
||||
---
|
||||
|
||||
## 1. Unterstützte Versionen
|
||||
|
||||
Sicherheitsupdates und Patches werden jeweils für die neueste Version von Sanctum bereitgestellt. Ältere Versionen werden nicht separat gepflegt; Anwendern wird dringend empfohlen, stets die aktuellste Version einzusetzen (automatisch prüfbar via `sanctum upgrade --check`).
|
||||
|
||||
| Version | Status | Sicherheits-Support |
|
||||
|:---:|:---:|:---:|
|
||||
| **v0.9.x** | **Aktiv (Aktuell: v0.9.4)** | **Vollständig unterstützt** |
|
||||
| <= v0.8.x | Veraltet | Nicht mehr unterstützt (Upgrade empfohlen) |
|
||||
|
||||
---
|
||||
|
||||
## 2. Geltungsbereich (Scope)
|
||||
|
||||
### 2.1 Im Geltungsbereich (In Scope)
|
||||
Schwachstellen in folgenden Kernbereichen fallen unter diese Richtlinie:
|
||||
- **Kryptografische Kernfunktionen**:
|
||||
- Schlüsselableitung (`Argon2id`, KDF-Parametervalidierung, Salt-Erzeugung).
|
||||
- Verschlüsselung, Integrität und Replay-Schutz (`AES-256-GCM`, 24-Byte Generation-AAD K-02, Inode-Metadaten-MAC K-01).
|
||||
- Steganografisches Carrier-Format V2 (Paged Manifest, Superblock-Konsistenz C-02, Dirty-Tracking C-03).
|
||||
- **Speicher- und Prozesssicherheit**:
|
||||
- Schlüsselisolation im Arbeitsspeicher (`Zeroize`, `VirtualLock` / `mlock`).
|
||||
- Schutz vor Auslagerung in Swap/Pagefile oder temporäre Absturz-Dumps.
|
||||
- **Netzwerk- und Dienst-Sicherheit**:
|
||||
- Lokaler WebDAV-Endpunkt (zwingende `127.0.0.1` Loopback-Beschränkung, Host-Header-Validierung).
|
||||
- Session-Token-Schutz (dynamische Zufallserzeugung im RAM, keine Leaks über Prozessliste `argv`, Pfade oder URLs).
|
||||
- **Anti-Forensik & Anti-Leak Shield**:
|
||||
- Unterdrückung von Windows-Explorer-Artefakten (`Thumbs.db`, `desktop.ini`, ADS-Streams).
|
||||
- Transaktionales Schreddern von Datenblöcken mit CSPRNG-Rauschen vor dem Löschen.
|
||||
- **Software-Integrität & Upgrade-Prozess**:
|
||||
- Signaturprüfung von Updates via **Minisign** (Ed25519) und Domain-Beschränkung.
|
||||
- **Disaster Recovery**:
|
||||
- BIP-39 Notfallschlüssel-Ableitung und Header-Wiederherstellung (R-NEW-1 MAC-Rebuild).
|
||||
|
||||
### 2.2 Außerhalb des Geltungsbereichs (Out of Scope)
|
||||
Folgende Szenarien stellen keine Sicherheitslücke in Sanctum dar:
|
||||
- **Kompromittierter Host**: Angriffe, die bereits uneingeschränkte Administrator-/Root-Rechte oder physischen Zugriff auf einen laufenden, entsperrten Rechner voraussetzen (z. B. Kernel-Treiber-Injection, Direct-Memory-Access via Hardware, Keylogger auf OS-Ebene).
|
||||
- **Physikalisches Flash-Wear-Leveling**: Restfragmente gelöschter Blöcke auf Flash-Speichern (SSD, NVMe) infolge des Flash Translation Layers (FTL) der Hardware, sofern Sanctum die logischen Datenblöcke nachweislich kryptografisch geschreddert hat (siehe Hinweis in `README.md` und `THREAT_MODEL.md`).
|
||||
- **Social Engineering & Phishing**: Angriffe, die darauf abzielen, das Master-Passwort oder den Notfallschlüssel direkt vom Anwender zu erpressen oder zu erschleichen.
|
||||
- **Lokales DoS durch Dateilöschung**: Das manuelle Löschen der `.sanctum`-Containerdatei durch einen Nutzer mit Schreibrechten auf dem Hostdateisystem.
|
||||
|
||||
---
|
||||
|
||||
## 3. Vertrauliche Meldung (Responsible Disclosure)
|
||||
|
||||
Wenn Sie eine potenzielle Sicherheitslücke in Sanctum identifiziert haben, bitten wir Sie eindringlich, diese **nicht öffentlich** (z. B. über GitHub/Gitea Public Issues, X/Twitter, Mastodon, Diskussionsforen oder Blogs) bekanntzugeben, bevor wir die Möglichkeit hatten, das Problem zu analysieren, zu beheben und ein Update bereitzustellen.
|
||||
|
||||
### 3.1 Kontaktwege
|
||||
|
||||
Bitte übermitteln Sie Ihren Bericht über einen der folgenden vertraulichen Kanäle:
|
||||
|
||||
1. **Per E-Mail (Primär)**:
|
||||
- **Adresse**: `security@pansi.eu` *(Fallback: `harald@pansi.eu`)*
|
||||
- **Betreff**: `[SECURITY] Schwachstelle in Sanctum: <Kurzbeschreibung>`
|
||||
- Wenn Sie sensible Details (z. B. PoC-Exploits) verschlüsseln möchten, fordern Sie bitte vorab per Mail einen PGP-Schlüssel an oder nutzen Sie den Gitea Security Advisory Kanal.
|
||||
|
||||
2. **Gitea Private Vulnerability Reporting (Web)**:
|
||||
- Wenn Sie ein Gitea-Konto besitzen, können Sie unter folgender URL direkt einen vertraulichen Security Advisory erstellen:
|
||||
- **URL**: [https://gitea.pansi.eu/harald/sanctum/security/advisories](https://gitea.pansi.eu/harald/sanctum/security/advisories)
|
||||
|
||||
### 3.2 Erforderliche Informationen im Bericht
|
||||
Um eine schnelle Untersuchung zu ermöglichen, sollte Ihre Meldung folgende Informationen enthalten:
|
||||
1. **Zusammenfassung**: Eine prägnante Beschreibung der Schwachstelle und der betroffenen Komponente.
|
||||
2. **Version & Umgebung**: Getestete Sanctum-Version (z. B. v0.9.2), Betriebssystem (Windows 11, Linux etc.) und Toolchain.
|
||||
3. **Schritt-für-Schritt-Anleitung**: Genaue Reproduktionsschritte oder ein minimales Proof of Concept (PoC).
|
||||
4. **Schweregrad / Impact**: Ihre Einschätzung der Auswirkung (z. B. Datenverlust, Umgehung der Verschlüsselung, Information Leak).
|
||||
5. **Lösungsvorschlag** *(optional)*: Falls Sie bereits eine Idee oder einen Patch haben, freuen wir uns über Ihren Vorschlag.
|
||||
|
||||
---
|
||||
|
||||
## 4. Reaktionszeiten & Zeitplan (SLA)
|
||||
|
||||
Wir verpflichten uns zu einem transparenten und zügigen Ablauf:
|
||||
|
||||
| Phase | Maximale Reaktionszeit | Beschreibung |
|
||||
|---|---|---|
|
||||
| **Eingangsbestätigung** | **Innerhalb von 48 Stunden** | Wir bestätigen den Empfang Ihrer Meldung und benennen einen Ansprechpartner. |
|
||||
| **Erste Bewertung** | **Innerhalb von 5 Werktagen** | Wir prüfen die Reproduzierbarkeit und bewerten den Schweregrad. |
|
||||
| **Status-Updates** | **Mindestens alle 7 Tage** | Sie werden regelmäßig über den Fortschritt der Behebung informiert. |
|
||||
| **Embargo & Veröffentlichung** | **Standardmäßig 90 Tage** | Gemeinsam stimmen wir den Veröffentlichungstermin ab (spätestens nach 90 Tagen oder unmittelbar nach Bereitstellung des Patches). |
|
||||
|
||||
---
|
||||
|
||||
## 5. Safe Harbor & Anerkennung
|
||||
|
||||
- **Rechtlicher Schutz (Safe Harbor)**: Wenn Sie nach bestem Wissen und Gewissen im Rahmen dieser Richtlinie handeln (keine Daten Dritter kompromittieren, keine Systeme sabotieren und die Vertraulichkeitsfrist einhalten), werden wir keinerlei rechtliche Schritte gegen Sie einleiten.
|
||||
- **Anerkennung**: Sofern von Ihnen gewünscht, nennen wir Sie namentlich (oder mit Alias/Handle) in den offiziellen Release Notes, im `SECURITY_AUDIT.md` sowie im Changelog als Entdecker der Schwachstelle.
|
||||
|
||||
Vielen Dank, dass Sie dazu beitragen, Sanctum für alle Anwender sicher zu halten!
|
||||
+36
-5
@@ -1,17 +1,19 @@
|
||||
# Sanctum Security Audit & Remediation Log (v0.9.2)
|
||||
# Sanctum Security Audit & Remediation Log (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 und Abschnitt 7 den Recovery-MAC-Fix (R-NEW-1) aus v0.9.2.
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## Audit-Zusammenfassung
|
||||
|
||||
- **Zielversion:** Sanctum v0.9.2 (Basis v0.9.1)
|
||||
- **Status:** Vollständig gehärtet & verifiziert
|
||||
- **Zielversion:** Sanctum v0.9.4 (Basis v0.9.3)
|
||||
- **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)
|
||||
- **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).
|
||||
- **Hidden Vault & Storage-Resilienz:** Carrier-Format V2 (Paged Manifest), Dual-Block rollierender Superblock (C-02), Manifest-Entkopplung mit Dirty-Tracking (C-03), Fail-Soft Inode-Isolation (D-01), Sekundärindex (D-02), lückenlose Pfadvalidierung (`validate_node_name`), dynamische Kapazitätsskalierung und Blockwarnung, Fail-Closed Node-Name-Decryption (ST-01), rekursive Fehleraggregation (ST-02) und atomare SQLite-Transaktionen für Baum-Löschungen (ST-03).
|
||||
- **Disaster Recovery Resilienz (R-NEW-1):** Vollständige Aktualisierung und Rebuild-Fähigkeit des Metadaten-MAC nach Wiederherstellung via Notfallschlüssel oder Header-Backup.
|
||||
- **Security Governance & Supply Chain:** `SECURITY.md` Responsible Disclosure Richtlinie, `cargo audit` (0 CVEs), `cargo deny` (strikte Lizenz- & Crate-Governance), `gitleaks` historischer Secret-Scan (sauber).
|
||||
|
||||
---
|
||||
|
||||
@@ -147,8 +149,37 @@ Dieses Dokument fasst alle 39 Findings aus drei umfassenden externen Sicherheits
|
||||
- **Freier Speicherplatz unter Linux (W-1):**
|
||||
- `get_available_disk_space` nutzt unter Unix `libc::statvfs` zur exakten Quota-Ermittlung (`f_bavail * f_frsize`).
|
||||
|
||||
### 8. Automatisierte Sicherheitsprüfungen & Supply-Chain-Audit (cargo audit, cargo deny, gitleaks)
|
||||
- **RustSec Advisory Database (`cargo audit`):**
|
||||
- Vollständige Prüfung aller 248 Abhängigkeiten in `Cargo.lock` gegen die offizielle RustSec Advisory-Datenbank.
|
||||
- **Ergebnis:** 0 bekannte Schwachstellen (0 vulnerabilities, 0 security warnings).
|
||||
- **Supply-Chain- & Dependency-Governance (`cargo deny` via `deny.toml`):**
|
||||
- `advisories ok`: Keine sicherheitskritischen oder zurückgezogenen (yanked) Crates.
|
||||
- `bans ok`: Keine gesperrten Crates, unbedenkliche Duplikate (z. B. `windows-sys` Major-Versionen) innerhalb der Vorgaben.
|
||||
- `licenses ok`: 100 % aller Lizenzen entsprechen den freigegebenen Permissive/OSI-Lizenzen (MIT, Apache-2.0, BSD, CC0, ISC, Unicode-3.0, Zlib, MPL-2.0, LGPL-2.1-or-later).
|
||||
- `sources ok`: Sämtliche Crates stammen verifiziert aus dem offiziellen `crates.io`-Index.
|
||||
- **Git-Historien-Scan auf Secrets (`gitleaks`):**
|
||||
- Vollständiger Audit aller 101 Git-Commits und 1,28 MB Änderungshistorie mit `gitleaks detect`.
|
||||
- **Ergebnis:** Keine privaten Schlüssel (Minisign Secret Keys, SSH-Schlüssel, Passwörter, API-Tokens) in der Git-Historie vorhanden. Drei harmlose Test-Mock-Strings in Unittests (`src/mount.rs`, `tests/mount_security_test.rs`, `tests/upgrade_security_test.rs`) wurden in `.gitleaksignore` gebaselined.
|
||||
- **Responsible Disclosure Policy (`SECURITY.md`):**
|
||||
- Einführung von `SECURITY.md` mit SLA (48h Reaktionszeit, 5 Tage Bewertung), Kontaktadresse (`security@pansi.eu`), Gitea Security Advisories Kanal und 90-Tage Embargo.
|
||||
|
||||
### 9. Sanctum v0.9.4 — Format-V3 Systembindung (F-01, F-02, F-03)
|
||||
- **PendingRebuild-Bypass durch Restore-Token geschlossen (F-02, HIGH):**
|
||||
- Verhindert, dass Angreifer durch Setzen von `metadata_mac = NULL` und `metadata_gen = 0` manipulierte Metadatenbäume stillschweigend beim Mount signieren lassen.
|
||||
- Ein 32-Byte CSPRNG-Token (`restore_nonce`) wird ausschließlich bei `restore_header_backup` in `meta` hinterlegt.
|
||||
- `mount` fordert zwingend `--rebuild-mac` oder eine interaktive Bestätigung (`"JA"` via `is_terminal()`). Ohne Token wird `NULL`-MAC als ungültig zurückgewiesen (`MetadataMacStatus::Invalid`). Nach Rebuild wird das Token gelöscht.
|
||||
- **Chunk-Generation Replay-Attacke behoben (F-01, HIGH):**
|
||||
- Der kanonische HMAC-SHA256 Transkript (`SANCTUM_META_MAC_V3`) bindet nun neben sortierten Knoten (`ORDER BY id ASC`) auch alle sortierten Chunk-Tupel (`ORDER BY node_id, chunk_index: node_id LE64 || chunk_index LE32 || generation LE64`).
|
||||
- Ein Angreifer kann keine alten Chunk-Ciphertexte mit alter Generation wiederherstellen, selbst bei identischer Dateigröße.
|
||||
- Transparente Migration für bestehende Container, deren alter Knoten-Transkript verifiziert (`MetadataMacStatus::LegacyValid`).
|
||||
- **Atomares V2-to-V3 Upgrade (F-03, HIGH):**
|
||||
- `upgrade_to_v3` läuft in einer atomaren SQLite-Transaktion mit Fail-Closed-Verhalten und Rollback bei Chunk-Fehlern.
|
||||
- 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.
|
||||
|
||||
---
|
||||
|
||||
## Verifikationsnachweis
|
||||
|
||||
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.
|
||||
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. Alle automatisierten Supply-Chain- und Secret-Scans (`cargo audit`, `cargo deny`, `gitleaks`) wurden erfolgreich ohne Befunde absolviert.
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
# Threat Model & Sicherheitsarchitektur von Sanctum
|
||||
|
||||
Dieses Dokument beschreibt das Bedrohungsmodell, die Sicherheitsannahmen und die Schutzmechanismen von **Sanctum v0.9.2** im reinen Userland-Betrieb.
|
||||
Dieses Dokument beschreibt das Bedrohungsmodell, die Sicherheitsannahmen und die Schutzmechanismen von **Sanctum v0.9.4** im reinen Userland-Betrieb.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
# This template contains all of the possible sections and their default values
|
||||
|
||||
# Note that all fields that take a lint level have these possible values:
|
||||
# * deny - An error will be produced and the check will fail
|
||||
# * warn - A warning will be produced, but the check will not fail
|
||||
# * allow - No warning or error will be produced, though in some cases a note
|
||||
# will be
|
||||
|
||||
# The values provided in this template are the default values that will be used
|
||||
# when any section or field is not specified in your own configuration
|
||||
|
||||
# Root options
|
||||
|
||||
# The graph table configures how the dependency graph is constructed and thus
|
||||
# which crates the checks are performed against
|
||||
[graph]
|
||||
# If 1 or more target triples (and optionally, target_features) are specified,
|
||||
# only the specified targets will be checked when running `cargo deny check`.
|
||||
# This means, if a particular package is only ever used as a target specific
|
||||
# dependency, such as, for example, the `nix` crate only being used via the
|
||||
# `target_family = "unix"` configuration, that only having windows targets in
|
||||
# this list would mean the nix crate, as well as any of its exclusive
|
||||
# dependencies not shared by any other crates, would be ignored, as the target
|
||||
# list here is effectively saying which targets you are building for.
|
||||
targets = [
|
||||
# The triple can be any string, but only the target triples built in to
|
||||
# rustc (as of 1.40) can be checked against actual config expressions
|
||||
#"x86_64-unknown-linux-musl",
|
||||
# You can also specify which target_features you promise are enabled for a
|
||||
# particular target. target_features are currently not validated against
|
||||
# the actual valid features supported by the target architecture.
|
||||
#{ triple = "wasm32-unknown-unknown", features = ["atomics"] },
|
||||
]
|
||||
# When creating the dependency graph used as the source of truth when checks are
|
||||
# executed, this field can be used to prune crates from the graph, removing them
|
||||
# from the view of cargo-deny. This is an extremely heavy hammer, as if a crate
|
||||
# is pruned from the graph, all of its dependencies will also be pruned unless
|
||||
# they are connected to another crate in the graph that hasn't been pruned,
|
||||
# so it should be used with care. The identifiers are [Package ID Specifications]
|
||||
# (https://doc.rust-lang.org/cargo/reference/pkgid-spec.html)
|
||||
#exclude = []
|
||||
# If true, metadata will be collected with `--all-features`. Note that this can't
|
||||
# be toggled off if true, if you want to conditionally enable `--all-features` it
|
||||
# is recommended to pass `--all-features` on the cmd line instead
|
||||
all-features = false
|
||||
# If true, metadata will be collected with `--no-default-features`. The same
|
||||
# caveat with `all-features` applies
|
||||
no-default-features = false
|
||||
# If set, these feature will be enabled when collecting metadata. If `--features`
|
||||
# is specified on the cmd line they will take precedence over this option.
|
||||
#features = []
|
||||
|
||||
# The output table provides options for how/if diagnostics are outputted
|
||||
[output]
|
||||
# When outputting inclusion graphs in diagnostics that include features, this
|
||||
# option can be used to specify the depth at which feature edges will be added.
|
||||
# This option is included since the graphs can be quite large and the addition
|
||||
# of features from the crate(s) to all of the graph roots can be far too verbose.
|
||||
# This option can be overridden via `--feature-depth` on the cmd line
|
||||
feature-depth = 1
|
||||
|
||||
# This section is considered when running `cargo deny check advisories`
|
||||
# More documentation for the advisories section can be found here:
|
||||
# https://embarkstudios.github.io/cargo-deny/checks/advisories/cfg.html
|
||||
[advisories]
|
||||
# The path where the advisory databases are cloned/fetched into
|
||||
#db-path = "$CARGO_HOME/advisory-dbs"
|
||||
# The url(s) of the advisory databases to use
|
||||
#db-urls = ["https://github.com/rustsec/advisory-db"]
|
||||
# A list of advisory IDs to ignore. Note that ignored advisories will still
|
||||
# output a note when they are encountered.
|
||||
ignore = [
|
||||
#"RUSTSEC-0000-0000",
|
||||
#{ id = "RUSTSEC-0000-0000", reason = "you can specify a reason the advisory is ignored" },
|
||||
#"a-crate-that-is-yanked@0.1.1", # you can also ignore yanked crate versions if you wish
|
||||
#{ crate = "a-crate-that-is-yanked@0.1.1", reason = "you can specify why you are ignoring the yanked crate" },
|
||||
]
|
||||
# If this is true, then cargo deny will use the git executable to fetch advisory database.
|
||||
# If this is false, then it uses a built-in git library.
|
||||
# Setting this to true can be helpful if you have special authentication requirements that cargo-deny does not support.
|
||||
# See Git Authentication for more information about setting up git authentication.
|
||||
#git-fetch-with-cli = true
|
||||
|
||||
# This section is considered when running `cargo deny check licenses`
|
||||
# More documentation for the licenses section can be found here:
|
||||
# https://embarkstudios.github.io/cargo-deny/checks/licenses/cfg.html
|
||||
[licenses]
|
||||
# List of explicitly allowed licenses
|
||||
# See https://spdx.org/licenses/ for list of possible licenses
|
||||
# [possible values: any SPDX 3.11 short identifier (+ optional exception)].
|
||||
allow = [
|
||||
"MIT",
|
||||
"Apache-2.0",
|
||||
"Apache-2.0 WITH LLVM-exception",
|
||||
"BSD-2-Clause",
|
||||
"BSD-3-Clause",
|
||||
"CC0-1.0",
|
||||
"CDLA-Permissive-2.0",
|
||||
"ISC",
|
||||
"Unicode-3.0",
|
||||
"Unlicense",
|
||||
"Zlib",
|
||||
"MPL-2.0",
|
||||
"LGPL-2.1-or-later",
|
||||
]
|
||||
# The confidence threshold for detecting a license from license text.
|
||||
# The higher the value, the more closely the license text must be to the
|
||||
# canonical license text of a valid SPDX license file.
|
||||
# [possible values: any between 0.0 and 1.0].
|
||||
confidence-threshold = 0.8
|
||||
# Allow 1 or more licenses on a per-crate basis, so that particular licenses
|
||||
# aren't accepted for every possible crate as with the normal allow list
|
||||
exceptions = [
|
||||
# Each entry is the crate and version constraint, and its specific allow
|
||||
# list
|
||||
#{ allow = ["Zlib"], crate = "adler32" },
|
||||
]
|
||||
|
||||
# Some crates don't have (easily) machine readable licensing information,
|
||||
# adding a clarification entry for it allows you to manually specify the
|
||||
# licensing information
|
||||
#[[licenses.clarify]]
|
||||
# The package spec the clarification applies to
|
||||
#crate = "ring"
|
||||
# The SPDX expression for the license requirements of the crate
|
||||
#expression = "MIT AND ISC AND OpenSSL"
|
||||
# One or more files in the crate's source used as the "source of truth" for
|
||||
# the license expression. If the contents match, the clarification will be used
|
||||
# when running the license check, otherwise the clarification will be ignored
|
||||
# and the crate will be checked normally, which may produce warnings or errors
|
||||
# depending on the rest of your configuration
|
||||
#license-files = [
|
||||
# Each entry is a crate relative path, and the (opaque) hash of its contents
|
||||
#{ path = "LICENSE", hash = 0xbd0eed23 }
|
||||
#]
|
||||
|
||||
[licenses.private]
|
||||
# If true, ignores workspace crates that aren't published, or are only
|
||||
# published to private registries.
|
||||
# To see how to mark a crate as unpublished (to the official registry),
|
||||
# visit https://doc.rust-lang.org/cargo/reference/manifest.html#the-publish-field.
|
||||
ignore = false
|
||||
# One or more private registries that you might publish crates to, if a crate
|
||||
# is only published to private registries, and ignore is true, the crate will
|
||||
# not have its license(s) checked
|
||||
registries = [
|
||||
#"https://sekretz.com/registry
|
||||
]
|
||||
|
||||
# This section is considered when running `cargo deny check bans`.
|
||||
# More documentation about the 'bans' section can be found here:
|
||||
# https://embarkstudios.github.io/cargo-deny/checks/bans/cfg.html
|
||||
[bans]
|
||||
# Lint level for when multiple versions of the same crate are detected
|
||||
multiple-versions = "warn"
|
||||
# Lint level for when a crate version requirement is `*`
|
||||
wildcards = "allow"
|
||||
# The graph highlighting used when creating dotgraphs for crates
|
||||
# with multiple versions
|
||||
# * lowest-version - The path to the lowest versioned duplicate is highlighted
|
||||
# * simplest-path - The path to the version with the fewest edges is highlighted
|
||||
# * all - Both lowest-version and simplest-path are used
|
||||
highlight = "all"
|
||||
# The default lint level for `default` features for crates that are members of
|
||||
# the workspace that is being checked. This can be overridden by allowing/denying
|
||||
# `default` on a crate-by-crate basis if desired.
|
||||
workspace-default-features = "allow"
|
||||
# The default lint level for `default` features for external crates that are not
|
||||
# members of the workspace. This can be overridden by allowing/denying `default`
|
||||
# on a crate-by-crate basis if desired.
|
||||
external-default-features = "allow"
|
||||
# List of crates that are allowed. Use with care!
|
||||
allow = [
|
||||
#"ansi_term@0.11.0",
|
||||
#{ crate = "ansi_term@0.11.0", reason = "you can specify a reason it is allowed" },
|
||||
]
|
||||
# If true, workspace members are automatically allowed even when using deny-by-default
|
||||
# This is useful for organizations that want to deny all external dependencies by default
|
||||
# but allow their own workspace crates without having to explicitly list them
|
||||
allow-workspace = false
|
||||
# List of crates to deny
|
||||
deny = [
|
||||
#"ansi_term@0.11.0",
|
||||
#{ crate = "ansi_term@0.11.0", reason = "you can specify a reason it is banned" },
|
||||
# Wrapper crates can optionally be specified to allow the crate when it
|
||||
# is a direct dependency of the otherwise banned crate
|
||||
#{ crate = "ansi_term@0.11.0", wrappers = ["this-crate-directly-depends-on-ansi_term"] },
|
||||
]
|
||||
|
||||
# List of features to allow/deny
|
||||
# Each entry the name of a crate and a version range. If version is
|
||||
# not specified, all versions will be matched.
|
||||
#[[bans.features]]
|
||||
#crate = "reqwest"
|
||||
# Features to not allow
|
||||
#deny = ["json"]
|
||||
# Features to allow
|
||||
#allow = [
|
||||
# "rustls",
|
||||
# "__rustls",
|
||||
# "__tls",
|
||||
# "hyper-rustls",
|
||||
# "rustls",
|
||||
# "rustls-pemfile",
|
||||
# "rustls-tls-webpki-roots",
|
||||
# "tokio-rustls",
|
||||
# "webpki-roots",
|
||||
#]
|
||||
# If true, the allowed features must exactly match the enabled feature set. If
|
||||
# this is set there is no point setting `deny`
|
||||
#exact = true
|
||||
|
||||
# Certain crates/versions that will be skipped when doing duplicate detection.
|
||||
skip = [
|
||||
#"ansi_term@0.11.0",
|
||||
#{ crate = "ansi_term@0.11.0", reason = "you can specify a reason why it can't be updated/removed" },
|
||||
]
|
||||
# Similarly to `skip` allows you to skip certain crates during duplicate
|
||||
# detection. Unlike skip, it also includes the entire tree of transitive
|
||||
# dependencies starting at the specified crate, up to a certain depth, which is
|
||||
# by default infinite.
|
||||
skip-tree = [
|
||||
#"ansi_term@0.11.0", # will be skipped along with _all_ of its direct and transitive dependencies
|
||||
#{ crate = "ansi_term@0.11.0", depth = 20 },
|
||||
]
|
||||
|
||||
# This section is considered when running `cargo deny check sources`.
|
||||
# More documentation about the 'sources' section can be found here:
|
||||
# https://embarkstudios.github.io/cargo-deny/checks/sources/cfg.html
|
||||
[sources]
|
||||
# Lint level for what to happen when a crate from a crate registry that is not
|
||||
# in the allow list is encountered
|
||||
unknown-registry = "warn"
|
||||
# Lint level for what to happen when a crate from a git repository that is not
|
||||
# in the allow list is encountered
|
||||
unknown-git = "warn"
|
||||
# List of URLs for allowed crate registries. Defaults to the crates.io index
|
||||
# if not specified. If it is specified but empty, no registries are allowed.
|
||||
allow-registry = ["https://github.com/rust-lang/crates.io-index"]
|
||||
# List of URLs for allowed Git repositories
|
||||
allow-git = []
|
||||
|
||||
[sources.allow-org]
|
||||
# github.com organizations to allow git sources for
|
||||
github = []
|
||||
# gitlab.com organizations to allow git sources for
|
||||
gitlab = []
|
||||
# bitbucket.org organizations to allow git sources for
|
||||
bitbucket = []
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"version": "0.9.2",
|
||||
"version": "0.9.4",
|
||||
"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.2/sanctum-v0.9.2-windows-x86_64.zip",
|
||||
"hash": "5828fca32b7d0581b740ef4f3cfb21c3717f92ee80d2e3f62e953a1ef083ac18",
|
||||
"url": "https://gitea.pansi.eu/harald/sanctum/releases/download/v0.9.4/sanctum-v0.9.4-windows-x86_64.zip",
|
||||
"hash": "28fb0ce43ab167930e8f98424dae263625b71085e3ee65052aba9fa57833e4b1",
|
||||
"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.2
|
||||
PackageVersion: 0.9.4
|
||||
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.2
|
||||
ReleaseNotesUrl: https://gitea.pansi.eu/harald/sanctum/releases/tag/v0.9.4
|
||||
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.2/sanctum-v0.9.2-windows-x86_64.zip
|
||||
InstallerSha256: 5828fca32b7d0581b740ef4f3cfb21c3717f92ee80d2e3f62e953a1ef083ac18
|
||||
InstallerUrl: https://gitea.pansi.eu/harald/sanctum/releases/download/v0.9.4/sanctum-v0.9.4-windows-x86_64.zip
|
||||
InstallerSha256: 28fb0ce43ab167930e8f98424dae263625b71085e3ee65052aba9fa57833e4b1
|
||||
ManifestType: singleton
|
||||
ManifestVersion: 1.6.0
|
||||
|
||||
@@ -81,12 +81,18 @@ $Release = $ExistingReleases | Where-Object { $_.tag_name -eq $TagName }
|
||||
|
||||
if (-not $Release) {
|
||||
$Utf8NoBom = New-Object System.Text.UTF8Encoding($false)
|
||||
$Changelog = [System.IO.File]::ReadAllText((Join-Path $ProjectRoot "CHANGELOG.md"), $Utf8NoBom)
|
||||
$FullChangelog = [System.IO.File]::ReadAllText((Join-Path $ProjectRoot "CHANGELOG.md"), $Utf8NoBom)
|
||||
$VersionPattern = "(?s)## \[$([regex]::Escape($Version))\].*?(?=(?:\r?\n## \[|\z))"
|
||||
if ($FullChangelog -match $VersionPattern) {
|
||||
$ReleaseBody = $matches[0].Trim()
|
||||
} else {
|
||||
$ReleaseBody = $FullChangelog
|
||||
}
|
||||
$PayloadPath = Join-Path $DistDir "release_req.json"
|
||||
$Payload = @{
|
||||
tag_name = $TagName
|
||||
name = "Sanctum $TagName (Windows & Linux x86_64)"
|
||||
body = $Changelog
|
||||
body = $ReleaseBody
|
||||
draft = $false
|
||||
prerelease = $false
|
||||
} | ConvertTo-Json -Depth 5
|
||||
|
||||
+4
-1
@@ -920,8 +920,11 @@ mod tests {
|
||||
.is_err());
|
||||
}
|
||||
|
||||
// F-01: Prüft die kryptografische AEAD-Bindung der Generation im AAD (Fehler bei Generationsabweichung).
|
||||
// Hinweis: Dies beweist die AEAD-Bindung an den AAD-Header, NICHT jedoch den Schutz gegen Zeilen-Ersetzung
|
||||
// im persistenten Speicher (dieser wird durch die Aufnahme der Generationen in das Metadaten-MAC-Transcript in F-01 garantiert).
|
||||
#[test]
|
||||
fn test_k02_chunk_replay_protection_with_generation_aad() {
|
||||
fn test_k02_chunk_generation_aead_binding() {
|
||||
let dek = generate_dek();
|
||||
let plaintext_v1 = b"Original Chunk Data at Generation 1";
|
||||
let plaintext_v2 = b"Overwritten Chunk Data at Generation 2";
|
||||
|
||||
+9
-3
@@ -117,6 +117,10 @@ enum Commands {
|
||||
/// Aktiviert den lautlosen Stealth-Modus (keine Banner, Pfade, URLs oder Fortschrittsausgaben)
|
||||
#[arg(short, long, default_value_t = false)]
|
||||
stealth: bool,
|
||||
|
||||
/// Autorisiert den Neuaufbau des Metadaten-MAC nach einem Header-Restore (F-02)
|
||||
#[arg(long, default_value_t = false)]
|
||||
rebuild_mac: bool,
|
||||
},
|
||||
|
||||
/// Trennt ein eingebundenes Netzlaufwerk manuell
|
||||
@@ -577,7 +581,7 @@ fn handle_init(
|
||||
FORMAT_VERSION
|
||||
);
|
||||
println!(" • Carrier-Datei:{} ({})", carrier_name, carrier_size_str);
|
||||
println!(" • KDF: Argon2id pro Slot (M=64MB, T=3, P=4)");
|
||||
println!(" • KDF: Argon2id pro Slot (M=256MB, T=4, P=4)");
|
||||
println!(" • Kapselung: Hidden Vault liegt in Carrier-Datei im Decoy-Vault");
|
||||
println!(" • Accounting: 100% aller Chunks authentifizieren fehlerfrei unter DEK_0");
|
||||
println!(" • Dateigröße: Feste Trägergröße zur Vermeidung von Größenveränderungen");
|
||||
@@ -625,7 +629,7 @@ fn handle_init(
|
||||
1,
|
||||
4,
|
||||
"🔑",
|
||||
"Leite KEK via Argon2id ab (M=64MB, T=3, P=4)...",
|
||||
"Leite KEK via Argon2id ab (M=256MB, T=4, P=4)...",
|
||||
);
|
||||
let salt = generate_salt();
|
||||
let kdf_params = KdfParams::default();
|
||||
@@ -680,7 +684,7 @@ fn handle_init(
|
||||
" • Format: Version {} (Magic: SANCTUM\\0)",
|
||||
FORMAT_VERSION
|
||||
);
|
||||
println!(" • KDF: Argon2id (M=64MB, T=3, P=4)");
|
||||
println!(" • KDF: Argon2id (M=256MB, T=4, P=4)");
|
||||
println!(" • Cipher: AES-256-GCM + LZ4-Kompression (1-MB Chunks, AEAD)");
|
||||
println!(" • Dual-Vault: Slot 1 mit Zufallsrauschen initialisiert (inaktiv)");
|
||||
println!();
|
||||
@@ -1626,6 +1630,7 @@ async fn run() -> Result<()> {
|
||||
no_anti_leak,
|
||||
anti_leak_list,
|
||||
stealth,
|
||||
rebuild_mac,
|
||||
} => {
|
||||
let drive_char = match drive {
|
||||
Some(ref d) => parse_drive_letter(d)?,
|
||||
@@ -1679,6 +1684,7 @@ async fn run() -> Result<()> {
|
||||
anti_leak,
|
||||
anti_leak_list.as_deref(),
|
||||
stealth,
|
||||
rebuild_mac,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
+41
-4
@@ -1,4 +1,5 @@
|
||||
use std::convert::Infallible;
|
||||
use std::io::IsTerminal;
|
||||
use std::net::SocketAddr;
|
||||
use std::path::Path;
|
||||
#[cfg(unix)]
|
||||
@@ -86,6 +87,7 @@ pub async fn mount_container(
|
||||
anti_leak: bool,
|
||||
anti_leak_list: Option<&Path>,
|
||||
stealth: bool,
|
||||
rebuild_mac: bool,
|
||||
) -> Result<()> {
|
||||
let drive_str = format_drive(drive_letter);
|
||||
let _ = &drive_str;
|
||||
@@ -187,7 +189,7 @@ pub async fn mount_container(
|
||||
// S-06: Advisory Lock setzen, um parallele Mounts und schreibende Sync-Läufe abzuwehren
|
||||
let _advisory_lock = db.acquire_advisory_lock_guard(false)?;
|
||||
|
||||
// K-01 / R-NEW-1: Metadaten-Integrität via HMAC-SHA256 validieren (Format V3)
|
||||
// K-01 / F-01 / F-02: Metadaten-Integrität via HMAC-SHA256 validieren (Format V3)
|
||||
if version >= crate::crypto::FORMAT_VERSION_V3 {
|
||||
match db.verify_metadata_mac_status_for_slot(vault_id, &dek) {
|
||||
Ok(crate::storage::MetadataMacStatus::Valid) => {
|
||||
@@ -197,19 +199,54 @@ pub async fn mount_container(
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(crate::storage::MetadataMacStatus::PendingRebuild) => {
|
||||
Ok(crate::storage::MetadataMacStatus::LegacyValid) => {
|
||||
if !stealth {
|
||||
println!(
|
||||
" • Metadaten-MAC: ℹ Metadaten-MAC nach Wiederherstellung neu aufgebaut"
|
||||
" • Metadaten-MAC: ℹ Format-V3 Metadaten-MAC auf neues Transcript migriert (F-01)"
|
||||
);
|
||||
}
|
||||
db.set_active_slot_and_dek(vault_id, dek.clone());
|
||||
db.update_metadata_mac()?;
|
||||
db.checkpoint()?;
|
||||
}
|
||||
Ok(crate::storage::MetadataMacStatus::PendingRebuild) => {
|
||||
let authorized = if rebuild_mac {
|
||||
true
|
||||
} else if std::io::stdin().is_terminal() {
|
||||
eprintln!();
|
||||
eprintln!(
|
||||
" [!] WARNUNG: METADATEN WERDEN NEU GEBUNDEN; MAC-HISTORIE ERLISCHT."
|
||||
);
|
||||
eprintln!(" Der Container-Header wurde wiederhergestellt. Die Metadaten-Integrität muss");
|
||||
eprintln!(" neu an den Tresor gebunden werden.");
|
||||
eprintln!(" Starten Sie mit '--rebuild-mac' oder bestätigen Sie die Operation interaktiv mit 'JA'.");
|
||||
eprint!(" Bestätigung ('JA' eingeben): ");
|
||||
let _ = std::io::Write::flush(&mut std::io::stderr());
|
||||
let mut input = String::new();
|
||||
std::io::stdin().read_line(&mut input).is_ok() && input.trim() == "JA"
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
if !authorized {
|
||||
bail!(
|
||||
"Metadaten-MAC-Rebuild nicht autorisiert. Starten Sie mit '--rebuild-mac' oder tippen Sie 'JA'."
|
||||
);
|
||||
}
|
||||
|
||||
if !stealth {
|
||||
println!(
|
||||
" • Metadaten-MAC: ℹ Metadaten neu gebunden & MAC erfolgreich aufgebaut (F-02)"
|
||||
);
|
||||
}
|
||||
db.set_active_slot_and_dek(vault_id, dek.clone());
|
||||
db.update_metadata_mac()?;
|
||||
db.clear_restore_nonce(vault_id)?;
|
||||
db.checkpoint()?;
|
||||
}
|
||||
Ok(crate::storage::MetadataMacStatus::Invalid) => {
|
||||
bail!(
|
||||
"Metadaten-MAC-Verifikation fehlgeschlagen: Die Container-Metadaten wurden manipuliert oder sind beschädigt (K-01)."
|
||||
"Metadaten-MAC-Verifikation fehlgeschlagen: Die Container-Metadaten wurden manipuliert oder sind beschädigt (K-01 / F-01 / F-02)."
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
|
||||
+6
-1
@@ -3,6 +3,8 @@ use std::path::Path;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use rand::rngs::OsRng;
|
||||
use rand::RngCore;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::crypto::{
|
||||
@@ -276,7 +278,10 @@ pub fn restore_header_backup(container_path: &Path, backup_path: &Path) -> Resul
|
||||
|
||||
let db = Database::open(container_path).context("Konnte Ziel-Containerdatei nicht öffnen")?;
|
||||
|
||||
db.restore_meta(&meta)
|
||||
let mut restore_nonce = [0u8; 32];
|
||||
OsRng.fill_bytes(&mut restore_nonce);
|
||||
|
||||
db.restore_meta_with_nonce(&meta, Some(&restore_nonce))
|
||||
.context("Fehler beim Wiederherstellen der Header-Tabelle in der Datenbank")?;
|
||||
|
||||
db.checkpoint()
|
||||
|
||||
+220
-53
@@ -208,15 +208,18 @@ fn current_timestamp() -> u64 {
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Status der Metadaten-MAC-Integritätsprüfung (K-01 / R-NEW-1).
|
||||
/// 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 überein.
|
||||
/// Metadaten-MAC ist vorhanden und stimmt mit den kanonischen Metadaten inklusive Chunk-Generationen überein (F-01).
|
||||
Valid,
|
||||
/// Container-Header wurde frisch aus einem Backup restauriert (metadata_gen == 0 && metadata_mac IS NULL).
|
||||
/// Der MAC muss beim Mounten transparent mit dem aktiven DEK neu aufgebaut werden.
|
||||
/// 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).
|
||||
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).
|
||||
PendingRebuild,
|
||||
/// Metadaten-MAC fehlt (bei gen > 0) oder stimmt nicht mit den berechneten Daten überein (Manipulationsverdacht).
|
||||
/// Metadaten-MAC fehlt (ohne gültigen restore_nonce) oder stimmt nicht mit den berechneten Daten überein (Manipulationsverdacht).
|
||||
Invalid,
|
||||
}
|
||||
|
||||
@@ -480,6 +483,9 @@ impl Database {
|
||||
let _ = conn.execute("ALTER TABLE meta ADD COLUMN lock_host TEXT", []);
|
||||
let _ = conn.execute("ALTER TABLE meta ADD COLUMN lock_time INTEGER", []);
|
||||
|
||||
// Spalte restore_nonce in meta (Format V3 / F-02)
|
||||
let _ = conn.execute("ALTER TABLE meta ADD COLUMN restore_nonce BLOB", []);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -646,6 +652,7 @@ impl Database {
|
||||
header_tag BLOB NOT NULL,
|
||||
metadata_mac BLOB,
|
||||
metadata_gen INTEGER NOT NULL DEFAULT 0,
|
||||
restore_nonce BLOB,
|
||||
lock_pid INTEGER,
|
||||
lock_host TEXT,
|
||||
lock_time INTEGER
|
||||
@@ -959,6 +966,7 @@ impl Database {
|
||||
header_tag BLOB NOT NULL,
|
||||
metadata_mac BLOB,
|
||||
metadata_gen INTEGER NOT NULL DEFAULT 0,
|
||||
restore_nonce BLOB,
|
||||
lock_pid INTEGER,
|
||||
lock_host TEXT,
|
||||
lock_time INTEGER
|
||||
@@ -2192,8 +2200,61 @@ impl Database {
|
||||
self.canonical_nodes_bytes_for_vault(0)
|
||||
}
|
||||
|
||||
/// Erzeugt die deterministische kanonische Byterepräsentation für einen spezifischen Vault.
|
||||
/// 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();
|
||||
|
||||
if vault_id == 1 {
|
||||
let mut stmt = conn.prepare(
|
||||
"WITH RECURSIVE vault1(id) AS (
|
||||
SELECT 2
|
||||
UNION ALL
|
||||
SELECT n.id FROM nodes n JOIN vault1 v ON n.parent_id = v.id
|
||||
)
|
||||
SELECT c.node_id, c.chunk_index, c.generation
|
||||
FROM chunks c
|
||||
WHERE c.node_id IN (SELECT id FROM vault1)
|
||||
ORDER BY c.node_id ASC, c.chunk_index ASC",
|
||||
)?;
|
||||
let mut rows = stmt.query([])?;
|
||||
while let Some(row) = rows.next()? {
|
||||
let node_id: i64 = row.get(0)?;
|
||||
let chunk_index: u32 = row.get(1)?;
|
||||
let generation: u64 = row.get(2)?;
|
||||
buf.extend_from_slice(&node_id.to_le_bytes());
|
||||
buf.extend_from_slice(&chunk_index.to_le_bytes());
|
||||
buf.extend_from_slice(&generation.to_le_bytes());
|
||||
}
|
||||
} else {
|
||||
let mut stmt = conn.prepare(
|
||||
"WITH RECURSIVE vault0(id) AS (
|
||||
SELECT 1
|
||||
UNION ALL
|
||||
SELECT n.id FROM nodes n JOIN vault0 v ON n.parent_id = v.id
|
||||
)
|
||||
SELECT c.node_id, c.chunk_index, c.generation
|
||||
FROM chunks c
|
||||
WHERE c.node_id = 2 OR c.node_id IN (SELECT id FROM vault0)
|
||||
ORDER BY c.node_id ASC, c.chunk_index ASC",
|
||||
)?;
|
||||
let mut rows = stmt.query([])?;
|
||||
while let Some(row) = rows.next()? {
|
||||
let node_id: i64 = row.get(0)?;
|
||||
let chunk_index: u32 = row.get(1)?;
|
||||
let generation: u64 = row.get(2)?;
|
||||
buf.extend_from_slice(&node_id.to_le_bytes());
|
||||
buf.extend_from_slice(&chunk_index.to_le_bytes());
|
||||
buf.extend_from_slice(&generation.to_le_bytes());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
/// 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();
|
||||
let mut buf = Vec::new();
|
||||
|
||||
@@ -2271,7 +2332,7 @@ impl Database {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Aktualisiert den Metadaten-MAC des aktiven Slots bei strukturellen Modifikationen (Format V3 / K-01).
|
||||
/// Aktualisiert den Metadaten-MAC des aktiven Slots bei strukturellen Modifikationen (Format V3 / K-01 & F-01).
|
||||
pub fn update_metadata_mac(&self) -> Result<()> {
|
||||
let session_opt = self
|
||||
.active_session
|
||||
@@ -2328,25 +2389,26 @@ 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> {
|
||||
Ok(self.verify_metadata_mac_status_for_slot(slot_id, dek)? == MetadataMacStatus::Valid)
|
||||
let status = self.verify_metadata_mac_status_for_slot(slot_id, dek)?;
|
||||
Ok(status == MetadataMacStatus::Valid || status == MetadataMacStatus::LegacyValid)
|
||||
}
|
||||
|
||||
/// Prüft den detaillierten Integritätsstatus des Metadaten-MAC für einen spezifischen Slot (R-NEW-1).
|
||||
/// Prüft den detaillierten Integritätsstatus des Metadaten-MAC für einen spezifischen Slot (F-01 / F-02).
|
||||
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)> = conn
|
||||
let meta_row: Option<(u32, Option<Vec<u8>>, u64, Option<Vec<u8>>)> = conn
|
||||
.query_row(
|
||||
"SELECT version, metadata_mac, metadata_gen FROM meta WHERE slot_id = ?1 LIMIT 1",
|
||||
"SELECT version, metadata_mac, metadata_gen, restore_nonce 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| Ok((r.get(0)?, r.get(1).ok(), r.get(2).unwrap_or(0), r.get(3).ok())),
|
||||
)
|
||||
.optional()?;
|
||||
|
||||
let Some((version, mac_opt, gen)) = meta_row else {
|
||||
let Some((version, mac_opt, gen, restore_nonce)) = meta_row else {
|
||||
return Ok(MetadataMacStatus::Invalid);
|
||||
};
|
||||
|
||||
@@ -2354,9 +2416,16 @@ impl Database {
|
||||
return Ok(MetadataMacStatus::Valid);
|
||||
}
|
||||
|
||||
// R-NEW-1: Frisch restaurierter Container (gen == 0 && mac_opt IS NULL)
|
||||
if gen == 0 && mac_opt.is_none() {
|
||||
return Ok(MetadataMacStatus::PendingRebuild);
|
||||
// F-02: Wenn metadata_mac fehlt (NULL):
|
||||
// NUR wenn restore_nonce vorhanden ist (32 Bytes) UND gen == 0,
|
||||
// ist der Status PendingRebuild. Andernfalls strikt Invalid (Fail-Closed, K-01 / F-02).
|
||||
if mac_opt.is_none() {
|
||||
if let Some(ref nonce) = restore_nonce {
|
||||
if nonce.len() == 32 && gen == 0 {
|
||||
return Ok(MetadataMacStatus::PendingRebuild);
|
||||
}
|
||||
}
|
||||
return Ok(MetadataMacStatus::Invalid);
|
||||
}
|
||||
|
||||
let Some(mac_bytes) = mac_opt else {
|
||||
@@ -2371,18 +2440,33 @@ impl Database {
|
||||
expected_mac.copy_from_slice(&mac_bytes);
|
||||
drop(conn);
|
||||
|
||||
let canonical = self.canonical_nodes_bytes_for_vault(slot_id)?;
|
||||
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) {
|
||||
Ok(MetadataMacStatus::Valid)
|
||||
} else {
|
||||
Ok(MetadataMacStatus::Invalid)
|
||||
return Ok(MetadataMacStatus::Valid);
|
||||
}
|
||||
|
||||
// F-01 / §7: 2. Altes Transcript prüfen (nur Knoten, ohne Chunk-Generationen) für sichere Migration
|
||||
let canonical_legacy = self.canonical_nodes_bytes_for_vault_legacy(slot_id)?;
|
||||
if verify_metadata_mac(&mac_key, gen, &canonical_legacy, &expected_mac) {
|
||||
return Ok(MetadataMacStatus::LegacyValid);
|
||||
}
|
||||
|
||||
Ok(MetadataMacStatus::Invalid)
|
||||
}
|
||||
|
||||
/// Führt ein Upgrade des Containerformats auf Format V3 durch (Format V3 / K-01 & K-02).
|
||||
/// Führt ein Upgrade des Containerformats auf Format V3 durch (Format V3 / K-01, K-02 & F-03).
|
||||
pub fn upgrade_to_v3(&self, dek: &[u8; 32]) -> Result<()> {
|
||||
let conn = self.conn();
|
||||
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.
|
||||
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",
|
||||
[],
|
||||
@@ -2398,15 +2482,19 @@ impl Database {
|
||||
"ALTER TABLE meta ADD COLUMN metadata_gen INTEGER NOT NULL DEFAULT 0",
|
||||
[],
|
||||
);
|
||||
let _ = conn.execute("ALTER TABLE meta ADD COLUMN restore_nonce BLOB", []);
|
||||
let _ = conn.execute(
|
||||
"ALTER TABLE chunks ADD COLUMN generation INTEGER NOT NULL DEFAULT 0",
|
||||
[],
|
||||
);
|
||||
|
||||
// K-02: Alle bestehenden Chunks von alter 16-Byte-AAD auf Format V3 24-Byte-AAD (generation = 0) umverschlüsseln
|
||||
// F-03: Gesamte Migration 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
|
||||
{
|
||||
let mut chunk_stmt =
|
||||
conn.prepare("SELECT node_id, chunk_index, nonce, tag, ciphertext FROM chunks")?;
|
||||
tx.prepare("SELECT node_id, chunk_index, nonce, tag, ciphertext FROM chunks")?;
|
||||
let chunk_rows: Vec<(i64, u32, [u8; 12], [u8; 16], Vec<u8>)> = chunk_stmt
|
||||
.query_map([], |row| {
|
||||
let node_id: i64 = row.get(0)?;
|
||||
@@ -2428,8 +2516,8 @@ impl Database {
|
||||
drop(chunk_stmt);
|
||||
|
||||
for (node_id, chunk_index, nonce, tag, ct) in chunk_rows {
|
||||
if let Ok(plaintext) = crate::crypto::decrypt_chunk(
|
||||
dek,
|
||||
let (plaintext, chunk_dek) = match crate::crypto::decrypt_chunk(
|
||||
dek_0,
|
||||
node_id,
|
||||
chunk_index,
|
||||
&ct,
|
||||
@@ -2438,39 +2526,93 @@ impl Database {
|
||||
version,
|
||||
0,
|
||||
) {
|
||||
if let Ok((new_ct, new_nonce, new_tag)) = crate::crypto::encrypt_chunk(
|
||||
dek,
|
||||
node_id,
|
||||
chunk_index,
|
||||
&plaintext,
|
||||
FORMAT_VERSION_V3,
|
||||
0,
|
||||
) {
|
||||
conn.execute(
|
||||
"UPDATE chunks SET nonce = ?1, tag = ?2, ciphertext = ?3, generation = 0 WHERE node_id = ?4 AND chunk_index = ?5",
|
||||
params![
|
||||
new_nonce.as_slice(),
|
||||
new_tag.as_slice(),
|
||||
new_ct,
|
||||
Ok(pt) => (pt, dek_0),
|
||||
Err(e0) => {
|
||||
if let Some(d1) = dek_1 {
|
||||
match crate::crypto::decrypt_chunk(
|
||||
d1,
|
||||
node_id,
|
||||
chunk_index
|
||||
],
|
||||
)?;
|
||||
chunk_index,
|
||||
&ct,
|
||||
&nonce,
|
||||
&tag,
|
||||
version,
|
||||
0,
|
||||
) {
|
||||
Ok(pt) => (pt, d1),
|
||||
Err(_) => {
|
||||
bail!(
|
||||
"Upgrade auf Format V3 abgebrochen: Chunk (Node {}, Index {}) konnte weder mit DEK_0 noch mit DEK_1 entschlüsselt werden.",
|
||||
node_id, chunk_index
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
bail!(
|
||||
"Upgrade auf Format V3 abgebrochen: Chunk (Node {}, Index {}) konnte mit DEK_0 nicht entschlüsselt werden ({}). Falls dieser Container einen Legacy-Hidden-Vault enthält, wird das zweite Passwort benötigt.",
|
||||
node_id, chunk_index, e0
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let (new_ct, new_nonce, new_tag) = crate::crypto::encrypt_chunk(
|
||||
chunk_dek,
|
||||
node_id,
|
||||
chunk_index,
|
||||
&plaintext,
|
||||
FORMAT_VERSION_V3,
|
||||
0,
|
||||
)?;
|
||||
|
||||
tx.execute(
|
||||
"UPDATE chunks SET nonce = ?1, tag = ?2, ciphertext = ?3, generation = 0 WHERE node_id = ?4 AND chunk_index = ?5",
|
||||
params![
|
||||
new_nonce.as_slice(),
|
||||
new_tag.as_slice(),
|
||||
new_ct,
|
||||
node_id,
|
||||
chunk_index
|
||||
],
|
||||
)?;
|
||||
}
|
||||
}
|
||||
|
||||
conn.execute(
|
||||
"UPDATE meta SET version = ?1, metadata_gen = 0 WHERE slot_id = 0",
|
||||
// F-03: Aktualisiere version = 3 für Slot 0 UND Slot 1 (inkl. Dummy-Slot)
|
||||
tx.execute(
|
||||
"UPDATE meta SET version = ?1, metadata_gen = 0",
|
||||
[FORMAT_VERSION_V3],
|
||||
)?;
|
||||
|
||||
tx.commit()?;
|
||||
drop(conn);
|
||||
|
||||
self.set_active_dek(Zeroizing::new(*dek));
|
||||
// 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(
|
||||
"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 {
|
||||
self.set_active_slot_and_dek(1, Zeroizing::new(*d1));
|
||||
self.update_metadata_mac()?;
|
||||
}
|
||||
}
|
||||
|
||||
// Aktive Session auf Slot 0 zurücksetzen
|
||||
self.set_active_slot_and_dek(0, Zeroizing::new(*dek_0));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -2542,6 +2684,15 @@ impl Database {
|
||||
|
||||
/// Schreibt oder stellt die Metadaten in der `meta`-Tabelle wieder her (z. B. nach Restore oder Header-Neugenerierung).
|
||||
pub fn restore_meta(&self, meta: &ContainerMeta) -> Result<()> {
|
||||
self.restore_meta_with_nonce(meta, None)
|
||||
}
|
||||
|
||||
/// Schreibt oder stellt die Metadaten in der `meta`-Tabelle mit optionalem restore_nonce Token wieder her (F-02).
|
||||
pub fn restore_meta_with_nonce(
|
||||
&self,
|
||||
meta: &ContainerMeta,
|
||||
restore_nonce: Option<&[u8; 32]>,
|
||||
) -> Result<()> {
|
||||
let conn = self.conn();
|
||||
|
||||
conn.execute_batch(
|
||||
@@ -2556,6 +2707,7 @@ impl Database {
|
||||
header_tag BLOB NOT NULL,
|
||||
metadata_mac BLOB,
|
||||
metadata_gen INTEGER NOT NULL DEFAULT 0,
|
||||
restore_nonce BLOB,
|
||||
lock_pid INTEGER,
|
||||
lock_host TEXT,
|
||||
lock_time INTEGER
|
||||
@@ -2564,6 +2716,8 @@ impl Database {
|
||||
|
||||
conn.execute("DELETE FROM meta", [])?;
|
||||
|
||||
let nonce_bytes = restore_nonce.map(|n| n.as_slice());
|
||||
|
||||
let mut has_slot1 = false;
|
||||
if !meta.slots.is_empty() {
|
||||
for slot in &meta.slots {
|
||||
@@ -2572,8 +2726,8 @@ impl Database {
|
||||
}
|
||||
let params_json = serde_json::to_string(&slot.kdf_params)?;
|
||||
conn.execute(
|
||||
"INSERT INTO meta (slot_id, magic, version, kdf_salt, kdf_params, wrapped_dek, header_nonce, header_tag, metadata_mac, metadata_gen)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, NULL, 0)",
|
||||
"INSERT INTO meta (slot_id, magic, version, kdf_salt, kdf_params, wrapped_dek, header_nonce, header_tag, metadata_mac, metadata_gen, restore_nonce)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, NULL, 0, ?9)",
|
||||
params![
|
||||
slot.slot_id,
|
||||
MAGIC_BYTES.as_slice(),
|
||||
@@ -2583,14 +2737,15 @@ impl Database {
|
||||
slot.wrapped_dek,
|
||||
slot.header_nonce.as_slice(),
|
||||
slot.header_tag.as_slice(),
|
||||
nonce_bytes,
|
||||
],
|
||||
)?;
|
||||
}
|
||||
} else {
|
||||
let params_json = serde_json::to_string(&meta.kdf_params)?;
|
||||
conn.execute(
|
||||
"INSERT INTO meta (slot_id, magic, version, kdf_salt, kdf_params, wrapped_dek, header_nonce, header_tag, metadata_mac, metadata_gen)
|
||||
VALUES (0, ?1, ?2, ?3, ?4, ?5, ?6, ?7, NULL, 0)",
|
||||
"INSERT INTO meta (slot_id, magic, version, kdf_salt, kdf_params, wrapped_dek, header_nonce, header_tag, metadata_mac, metadata_gen, restore_nonce)
|
||||
VALUES (0, ?1, ?2, ?3, ?4, ?5, ?6, ?7, NULL, 0, ?8)",
|
||||
params![
|
||||
MAGIC_BYTES.as_slice(),
|
||||
meta.version,
|
||||
@@ -2599,6 +2754,7 @@ impl Database {
|
||||
meta.wrapped_dek,
|
||||
meta.header_nonce.as_slice(),
|
||||
meta.header_tag.as_slice(),
|
||||
nonce_bytes,
|
||||
],
|
||||
)?;
|
||||
}
|
||||
@@ -2608,8 +2764,8 @@ impl Database {
|
||||
let (dummy_dek, dummy_nonce, dummy_tag, dummy_salt) = generate_dummy_slot();
|
||||
let dummy_params_json = serde_json::to_string(&KdfParams::default())?;
|
||||
conn.execute(
|
||||
"INSERT INTO meta (slot_id, magic, version, kdf_salt, kdf_params, wrapped_dek, header_nonce, header_tag, metadata_mac, metadata_gen)
|
||||
VALUES (1, ?1, ?2, ?3, ?4, ?5, ?6, ?7, NULL, 0)",
|
||||
"INSERT INTO meta (slot_id, magic, version, kdf_salt, kdf_params, wrapped_dek, header_nonce, header_tag, metadata_mac, metadata_gen, restore_nonce)
|
||||
VALUES (1, ?1, ?2, ?3, ?4, ?5, ?6, ?7, NULL, 0, ?8)",
|
||||
params![
|
||||
MAGIC_BYTES.as_slice(),
|
||||
FORMAT_VERSION,
|
||||
@@ -2618,6 +2774,7 @@ impl Database {
|
||||
dummy_dek.as_slice(),
|
||||
dummy_nonce.as_slice(),
|
||||
dummy_tag.as_slice(),
|
||||
nonce_bytes,
|
||||
],
|
||||
)?;
|
||||
}
|
||||
@@ -2625,6 +2782,16 @@ impl Database {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Löscht das Restore-Token nach erfolgreichem MAC-Rebuild (F-02).
|
||||
pub fn clear_restore_nonce(&self, slot_id: u32) -> Result<()> {
|
||||
let conn = self.conn();
|
||||
conn.execute(
|
||||
"UPDATE meta SET restore_nonce = NULL WHERE slot_id = ?1",
|
||||
params![slot_id],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Führt SQLite-eigene Integritäts- und Foreign-Key-Prüfungen aus.
|
||||
pub fn run_sqlite_integrity_check(&self) -> Result<Vec<String>> {
|
||||
let conn = self.conn();
|
||||
|
||||
@@ -0,0 +1,952 @@
|
||||
use rand::rngs::OsRng;
|
||||
use rand::RngCore;
|
||||
use sanctum::crypto::{
|
||||
decrypt_chunk, derive_kek, encrypt_chunk, generate_dek, generate_salt, wrap_dek,
|
||||
wrap_slot0_payload, wrap_slot1_payload, KdfParams, FORMAT_VERSION_V2, FORMAT_VERSION_V3,
|
||||
MAGIC_BYTES, MIN_MEMORY_COST_KIB, MIN_TIME_COST,
|
||||
};
|
||||
use sanctum::mount::{mount_container, ContainerAuth};
|
||||
use sanctum::recovery::{export_header_backup, restore_header_backup};
|
||||
use sanctum::storage::{Database, MetadataMacStatus};
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
fn test_kdf_params() -> KdfParams {
|
||||
KdfParams {
|
||||
memory_cost: MIN_MEMORY_COST_KIB,
|
||||
time_cost: MIN_TIME_COST,
|
||||
parallelism: 1,
|
||||
}
|
||||
}
|
||||
|
||||
fn temp_container_path(prefix: &str) -> PathBuf {
|
||||
let mut path = std::env::temp_dir();
|
||||
let rand_val: u64 = OsRng.next_u64();
|
||||
path.push(format!("sanctum_{prefix}_{rand_val}.sanctum"));
|
||||
path
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// F-02 TESTS: PendingRebuild Token & Rebuild-MAC Autorisation
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_f02_pending_rebuild_without_token_fails() {
|
||||
let path = temp_container_path("f02_no_token");
|
||||
let password = "TestPasswordF02_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 _ = db.create_node(1, "test.txt", false).unwrap();
|
||||
db.update_metadata_mac().unwrap();
|
||||
db.checkpoint().unwrap();
|
||||
drop(db);
|
||||
|
||||
// Angreifer manipuliert meta: metadata_mac = NULL, metadata_gen = 0 (ohne Token)
|
||||
{
|
||||
let conn = rusqlite::Connection::open(&path).unwrap();
|
||||
conn.execute(
|
||||
"UPDATE meta SET metadata_mac = NULL, metadata_gen = 0, restore_nonce = NULL WHERE slot_id = 0",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// 1. Status muss strikt Invalid sein (kein PendingRebuild!)
|
||||
let check_db = Database::open(&path).unwrap();
|
||||
let status = check_db
|
||||
.verify_metadata_mac_status_for_slot(0, &dek)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
status,
|
||||
MetadataMacStatus::Invalid,
|
||||
"NULL-MAC ohne restore_nonce Token muss strikt Invalid sein (F-02 Bypass-Schutz)"
|
||||
);
|
||||
assert!(
|
||||
!check_db.verify_metadata_mac_for_slot(0, &dek).unwrap(),
|
||||
"verify_metadata_mac_for_slot muss false liefern"
|
||||
);
|
||||
drop(check_db);
|
||||
|
||||
// 2. Mount ohne Flag muss fehlschlagen
|
||||
let auth = ContainerAuth::Password(Zeroizing::new(password.to_string()));
|
||||
let res = mount_container(
|
||||
&path,
|
||||
'Z',
|
||||
None,
|
||||
Some(18101),
|
||||
auth,
|
||||
false,
|
||||
false,
|
||||
None,
|
||||
false,
|
||||
false,
|
||||
None,
|
||||
true,
|
||||
false,
|
||||
)
|
||||
.await;
|
||||
assert!(res.is_err(), "Mount muss bei Status Invalid abbrechen");
|
||||
|
||||
// 3. Selbst mit --rebuild-mac darf KEIN Rebuild erfolgen, wenn Status Invalid ist!
|
||||
let auth2 = ContainerAuth::Password(Zeroizing::new(password.to_string()));
|
||||
let res2 = mount_container(
|
||||
&path,
|
||||
'Z',
|
||||
None,
|
||||
Some(18102),
|
||||
auth2,
|
||||
false,
|
||||
false,
|
||||
None,
|
||||
false,
|
||||
false,
|
||||
None,
|
||||
true,
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
res2.is_err(),
|
||||
"Mount mit --rebuild-mac darf Invalid-Metadaten nicht neu signieren"
|
||||
);
|
||||
|
||||
// 4. Metadaten-MAC bleibt weiterhin ungesetzt
|
||||
let verify_db = Database::open(&path).unwrap();
|
||||
let status_after = verify_db
|
||||
.verify_metadata_mac_status_for_slot(0, &dek)
|
||||
.unwrap();
|
||||
assert_eq!(status_after, MetadataMacStatus::Invalid);
|
||||
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_f02_restore_header_without_flag_fails() {
|
||||
let path = temp_container_path("f02_restore_noflag");
|
||||
let backup_path = path.with_extension("hdr");
|
||||
let password = "TestPasswordF02_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, "file.txt", false).unwrap();
|
||||
db.update_metadata_mac().unwrap();
|
||||
db.checkpoint().unwrap();
|
||||
drop(db);
|
||||
|
||||
// Backup exportieren
|
||||
export_header_backup(&path, &backup_path).unwrap();
|
||||
|
||||
// Header zerstören und restaurieren
|
||||
{
|
||||
let conn = rusqlite::Connection::open(&path).unwrap();
|
||||
conn.execute("DELETE FROM meta", []).unwrap();
|
||||
}
|
||||
restore_header_backup(&path, &backup_path).unwrap();
|
||||
|
||||
// Status nach Restore muss PendingRebuild sein
|
||||
{
|
||||
let check_db = Database::open(&path).unwrap();
|
||||
let status = check_db
|
||||
.verify_metadata_mac_status_for_slot(0, &dek)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
status,
|
||||
MetadataMacStatus::PendingRebuild,
|
||||
"Nach restore_header_backup muss Status PendingRebuild sein (restore_nonce gesetzt)"
|
||||
);
|
||||
}
|
||||
|
||||
// Mount ohne --rebuild-mac (und ohne stdin-Interaktion) muss abbrechen
|
||||
let auth = ContainerAuth::Password(Zeroizing::new(password.to_string()));
|
||||
let res = mount_container(
|
||||
&path,
|
||||
'Z',
|
||||
None,
|
||||
Some(18103),
|
||||
auth,
|
||||
false,
|
||||
false,
|
||||
None,
|
||||
false,
|
||||
false,
|
||||
None,
|
||||
true,
|
||||
false,
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
res.is_err(),
|
||||
"Mount ohne --rebuild-mac muss nach Header-Restore fail-closed abbrechen"
|
||||
);
|
||||
|
||||
// Nonce muss weiterhin existieren, MAC noch nicht gesetzt
|
||||
let check_db2 = Database::open(&path).unwrap();
|
||||
assert_eq!(
|
||||
check_db2
|
||||
.verify_metadata_mac_status_for_slot(0, &dek)
|
||||
.unwrap(),
|
||||
MetadataMacStatus::PendingRebuild
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let _ = std::fs::remove_file(&backup_path);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_f02_restore_header_with_flag_succeeds() {
|
||||
let path = temp_container_path("f02_restore_flag");
|
||||
let backup_path = path.with_extension("hdr");
|
||||
let password = "TestPasswordF02_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, "restored_payload.pdf", false).unwrap();
|
||||
db.update_metadata_mac().unwrap();
|
||||
db.checkpoint().unwrap();
|
||||
drop(db);
|
||||
|
||||
export_header_backup(&path, &backup_path).unwrap();
|
||||
|
||||
{
|
||||
let conn = rusqlite::Connection::open(&path).unwrap();
|
||||
conn.execute("DELETE FROM meta", []).unwrap();
|
||||
}
|
||||
restore_header_backup(&path, &backup_path).unwrap();
|
||||
|
||||
// Mount mit rebuild_mac = true
|
||||
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(18104),
|
||||
auth,
|
||||
false,
|
||||
false,
|
||||
None,
|
||||
false,
|
||||
false,
|
||||
None,
|
||||
true,
|
||||
true,
|
||||
)
|
||||
.await
|
||||
});
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(250)).await;
|
||||
mount_task.abort();
|
||||
|
||||
// Nach Mount mit Flag: MAC ist Valid, restore_nonce gelöscht!
|
||||
let check_db = Database::open(&path).unwrap();
|
||||
let status = check_db
|
||||
.verify_metadata_mac_status_for_slot(0, &dek)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
status,
|
||||
MetadataMacStatus::Valid,
|
||||
"Nach Mount mit --rebuild-mac muss Status Valid sein"
|
||||
);
|
||||
assert!(check_db.verify_metadata_mac_for_slot(0, &dek).unwrap());
|
||||
|
||||
// Prüfen, ob restore_nonce gelöscht wurde
|
||||
let conn = check_db.conn();
|
||||
let nonce_opt: Option<Option<Vec<u8>>> = conn
|
||||
.query_row(
|
||||
"SELECT restore_nonce FROM meta WHERE slot_id = 0",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.ok();
|
||||
assert!(
|
||||
nonce_opt.flatten().is_none(),
|
||||
"restore_nonce muss nach erfolgreichem Rebuild gelöscht sein"
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let _ = std::fs::remove_file(&backup_path);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// F-01 TESTS: Chunk-Generation-Replay im Transcript & Migration
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_f01_chunk_generation_replay_attack() {
|
||||
let path = temp_container_path("f01_replay_attack");
|
||||
let password = "TestPasswordF01!";
|
||||
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());
|
||||
|
||||
// 1. Datei schreiben mit Zustand 1 (Generation 1, z. B. 34 Bytes)
|
||||
let file = db.create_node(1, "financial_report.txt", false).unwrap();
|
||||
let data_v1 = b"State 1: Balance is 1000 EUR.";
|
||||
let gen1 = db.next_chunk_generation(file.id, 0).unwrap();
|
||||
assert_eq!(gen1, 1);
|
||||
let (ct1, nonce1, tag1) =
|
||||
encrypt_chunk(&dek, file.id, 0, data_v1, FORMAT_VERSION_V3, gen1).unwrap();
|
||||
db.write_chunk_and_update_size(
|
||||
file.id,
|
||||
0,
|
||||
gen1,
|
||||
&nonce1,
|
||||
&tag1,
|
||||
&ct1,
|
||||
data_v1.len() as u64,
|
||||
1000,
|
||||
)
|
||||
.unwrap();
|
||||
db.checkpoint().unwrap();
|
||||
|
||||
// Gespeicherte Zeile aus chunks sichern
|
||||
let chunk_v1 = db.read_chunk(file.id, 0).unwrap().unwrap();
|
||||
assert_eq!(chunk_v1.generation, 1);
|
||||
assert_eq!(
|
||||
db.verify_metadata_mac_status_for_slot(0, &dek).unwrap(),
|
||||
MetadataMacStatus::Valid
|
||||
);
|
||||
|
||||
// 2. Datei mit exakt gleicher Größe überschreiben mit Zustand 2 (Generation 2, selbe Länge)
|
||||
let data_v2 = b"State 2: Balance is 9999 EUR.";
|
||||
assert_eq!(data_v1.len(), data_v2.len());
|
||||
let gen2 = db.next_chunk_generation(file.id, 0).unwrap();
|
||||
assert_eq!(gen2, 2);
|
||||
let (ct2, nonce2, tag2) =
|
||||
encrypt_chunk(&dek, file.id, 0, data_v2, FORMAT_VERSION_V3, gen2).unwrap();
|
||||
db.write_chunk_and_update_size(
|
||||
file.id,
|
||||
0,
|
||||
gen2,
|
||||
&nonce2,
|
||||
&tag2,
|
||||
&ct2,
|
||||
data_v2.len() as u64,
|
||||
2000,
|
||||
)
|
||||
.unwrap();
|
||||
db.checkpoint().unwrap();
|
||||
|
||||
assert_eq!(
|
||||
db.verify_metadata_mac_status_for_slot(0, &dek).unwrap(),
|
||||
MetadataMacStatus::Valid
|
||||
);
|
||||
drop(db);
|
||||
|
||||
// 3. Angriff: Angreifer restauriert alte Chunk-Zeile (inkl. generation = 1) in SQLite
|
||||
// Größe und chunk_count des Knotens sind unverändert!
|
||||
{
|
||||
let conn = rusqlite::Connection::open(&path).unwrap();
|
||||
conn.execute(
|
||||
"UPDATE chunks SET nonce = ?1, tag = ?2, ciphertext = ?3, generation = ?4 WHERE node_id = ?5 AND chunk_index = 0",
|
||||
rusqlite::params![
|
||||
chunk_v1.nonce.as_slice(),
|
||||
chunk_v1.tag.as_slice(),
|
||||
chunk_v1.ciphertext,
|
||||
chunk_v1.generation,
|
||||
file.id,
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// 4. Verifikation des Replay-Schutzes (F-01):
|
||||
let attack_db = Database::open(&path).unwrap();
|
||||
|
||||
// Hinweis: Die AEAD-Entschlüsselung für sich allein würde hier gelingen,
|
||||
// da die Zeile generation=1 enthält. ABER das Metadaten-Transcript bindet die Generation!
|
||||
let replayed = attack_db.read_chunk(file.id, 0).unwrap().unwrap();
|
||||
let aead_decrypt = decrypt_chunk(
|
||||
&dek,
|
||||
file.id,
|
||||
0,
|
||||
&replayed.ciphertext,
|
||||
&replayed.nonce,
|
||||
&replayed.tag,
|
||||
FORMAT_VERSION_V3,
|
||||
replayed.generation,
|
||||
);
|
||||
assert!(
|
||||
aead_decrypt.is_ok(),
|
||||
"AEAD mit replayed Zeile gelingt, weil generation in der gleichen Zeile liegt"
|
||||
);
|
||||
|
||||
// Genau deshalb MUSS der Metadaten-MAC den Replay-Angriff stoppen:
|
||||
let mac_status = attack_db
|
||||
.verify_metadata_mac_status_for_slot(0, &dek)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
mac_status,
|
||||
MetadataMacStatus::Invalid,
|
||||
"F-01: Metadaten-MAC MUSS den Replay-Angriff durch abweichende Generation abwehren!"
|
||||
);
|
||||
assert!(
|
||||
!attack_db.verify_metadata_mac_for_slot(0, &dek).unwrap(),
|
||||
"verify_metadata_mac_for_slot muss false liefern"
|
||||
);
|
||||
drop(attack_db);
|
||||
|
||||
// 5. Mount bricht fail-closed ab, kein alter Klartext wird offengelegt
|
||||
let auth = ContainerAuth::Password(Zeroizing::new(password.to_string()));
|
||||
let res = mount_container(
|
||||
&path,
|
||||
'Z',
|
||||
None,
|
||||
Some(18105),
|
||||
auth,
|
||||
false,
|
||||
false,
|
||||
None,
|
||||
false,
|
||||
false,
|
||||
None,
|
||||
true,
|
||||
false,
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
res.is_err(),
|
||||
"Mount muss bei manipuliertem Chunk-Replay fail-closed abbrechen"
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_f01_migration_existing_v3_container() {
|
||||
let path = temp_container_path("f01_migration");
|
||||
let password = "TestPasswordF01_Mig!";
|
||||
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 = db.create_node(1, "legacy_v3_doc.txt", false).unwrap();
|
||||
let data = b"V3 Legacy Container Content";
|
||||
let gen = db.next_chunk_generation(file.id, 0).unwrap();
|
||||
let (ct, n, t) = encrypt_chunk(&dek, file.id, 0, data, FORMAT_VERSION_V3, gen).unwrap();
|
||||
db.write_chunk_and_update_size(file.id, 0, gen, &n, &t, &ct, data.len() as u64, 1000)
|
||||
.unwrap();
|
||||
|
||||
// Simuliere v0.9.3 Zustand: Metadaten-MAC wurde NUR über Knoten berechnet (ohne Chunks)
|
||||
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 WHERE slot_id = 0",
|
||||
rusqlite::params![legacy_mac.as_slice()],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
db.checkpoint().unwrap();
|
||||
|
||||
// Status muss LegacyValid sein
|
||||
let status_before = db.verify_metadata_mac_status_for_slot(0, &dek).unwrap();
|
||||
assert_eq!(
|
||||
status_before,
|
||||
MetadataMacStatus::LegacyValid,
|
||||
"Alter V3-Container muss als LegacyValid erkannt werden"
|
||||
);
|
||||
assert!(db.verify_metadata_mac_for_slot(0, &dek).unwrap());
|
||||
drop(db);
|
||||
|
||||
// Mounten migriert transparent auf neues Transcript
|
||||
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(18106),
|
||||
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 nun Valid unter neuem Transcript sein!
|
||||
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 der MAC auf das neue Format-V3.1 Transcript migriert sein"
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// F-03 TESTS: upgrade_to_v3 Atomarität & Konsistenz
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_f03_v2_upgrade_single_file_success() {
|
||||
let path = temp_container_path("f03_single_success");
|
||||
let salt = generate_salt();
|
||||
let kdf = test_kdf_params();
|
||||
let dek = generate_dek();
|
||||
|
||||
// Erstelle manuell einen V2-Container
|
||||
{
|
||||
let conn = rusqlite::Connection::open(&path).unwrap();
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE meta (
|
||||
slot_id INTEGER PRIMARY KEY,
|
||||
magic BLOB NOT NULL,
|
||||
version INTEGER NOT NULL,
|
||||
kdf_salt BLOB NOT NULL,
|
||||
kdf_params TEXT NOT NULL,
|
||||
wrapped_dek BLOB NOT NULL,
|
||||
header_nonce BLOB NOT NULL,
|
||||
header_tag BLOB NOT NULL
|
||||
);
|
||||
CREATE TABLE nodes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
parent_id INTEGER,
|
||||
name TEXT NOT NULL,
|
||||
is_dir INTEGER NOT NULL,
|
||||
size INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL,
|
||||
modified_at INTEGER NOT NULL,
|
||||
is_carrier INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE TABLE chunks (
|
||||
node_id INTEGER NOT NULL,
|
||||
chunk_index INTEGER NOT NULL,
|
||||
nonce BLOB NOT NULL,
|
||||
tag BLOB NOT NULL,
|
||||
ciphertext BLOB NOT NULL,
|
||||
PRIMARY KEY (node_id, chunk_index)
|
||||
);",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let kek = derive_kek("TestV2!", &salt, &kdf).unwrap();
|
||||
let (wrapped, h_nonce, h_tag) = wrap_dek(&kek, &dek).unwrap();
|
||||
let params_json = serde_json::to_string(&kdf).unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO meta VALUES (0, ?1, 2, ?2, ?3, ?4, ?5, ?6)",
|
||||
rusqlite::params![
|
||||
MAGIC_BYTES.as_slice(),
|
||||
salt.as_slice(),
|
||||
params_json,
|
||||
wrapped,
|
||||
h_nonce.as_slice(),
|
||||
h_tag.as_slice(),
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO meta VALUES (1, ?1, 2, ?2, ?3, ?4, ?5, ?6)",
|
||||
rusqlite::params![
|
||||
MAGIC_BYTES.as_slice(),
|
||||
salt.as_slice(),
|
||||
params_json,
|
||||
wrapped,
|
||||
h_nonce.as_slice(),
|
||||
h_tag.as_slice(),
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO nodes VALUES (1, NULL, '', 1, 0, 100, 100, 0), (2, NULL, '', 1, 0, 100, 100, 0), (3, 1, 'file.txt', 0, 12, 100, 100, 0)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Chunk in V2 verschlüsseln (16-Byte AAD, generation wird ignoriert)
|
||||
let plaintext = b"Hello V2 Upgrade!";
|
||||
let (ct, n, t) = encrypt_chunk(&dek, 3, 0, plaintext, FORMAT_VERSION_V2, 0).unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO chunks VALUES (3, 0, ?1, ?2, ?3)",
|
||||
rusqlite::params![n.as_slice(), t.as_slice(), ct],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let db = Database::open(&path).unwrap();
|
||||
let meta_before = db.read_meta().unwrap();
|
||||
assert_eq!(meta_before.version, 2);
|
||||
|
||||
// Upgrade ausführen
|
||||
db.upgrade_to_v3(&dek).unwrap();
|
||||
db.checkpoint().unwrap();
|
||||
|
||||
// 1. Beide Slots in meta müssen version == 3 haben!
|
||||
let conn = db.conn();
|
||||
let v0: u32 = conn
|
||||
.query_row("SELECT version FROM meta WHERE slot_id = 0", [], |r| {
|
||||
r.get(0)
|
||||
})
|
||||
.unwrap();
|
||||
let v1: u32 = conn
|
||||
.query_row("SELECT version FROM meta WHERE slot_id = 1", [], |r| {
|
||||
r.get(0)
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(v0, 3, "Slot 0 version muss 3 sein");
|
||||
assert_eq!(v1, 3, "Slot 1 version muss 3 sein (F-03)");
|
||||
drop(conn);
|
||||
|
||||
// 2. Chunks müssen generation = 0 haben und mit V3 24-Byte AAD entschlüsselbar sein
|
||||
let chunk = db.read_chunk(3, 0).unwrap().unwrap();
|
||||
assert_eq!(chunk.generation, 0);
|
||||
let decrypted = decrypt_chunk(
|
||||
&dek,
|
||||
3,
|
||||
0,
|
||||
&chunk.ciphertext,
|
||||
&chunk.nonce,
|
||||
&chunk.tag,
|
||||
FORMAT_VERSION_V3,
|
||||
chunk.generation,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(decrypted, b"Hello V2 Upgrade!");
|
||||
|
||||
// 3. Metadaten-MAC muss Valid sein
|
||||
assert_eq!(
|
||||
db.verify_metadata_mac_status_for_slot(0, &dek).unwrap(),
|
||||
MetadataMacStatus::Valid
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_f03_v2_upgrade_corrupted_chunk_rollback() {
|
||||
let path = temp_container_path("f03_rollback");
|
||||
let salt = generate_salt();
|
||||
let kdf = test_kdf_params();
|
||||
let dek = generate_dek();
|
||||
|
||||
{
|
||||
let conn = rusqlite::Connection::open(&path).unwrap();
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE meta (
|
||||
slot_id INTEGER PRIMARY KEY,
|
||||
magic BLOB NOT NULL,
|
||||
version INTEGER NOT NULL,
|
||||
kdf_salt BLOB NOT NULL,
|
||||
kdf_params TEXT NOT NULL,
|
||||
wrapped_dek BLOB NOT NULL,
|
||||
header_nonce BLOB NOT NULL,
|
||||
header_tag BLOB NOT NULL
|
||||
);
|
||||
CREATE TABLE nodes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
parent_id INTEGER,
|
||||
name TEXT NOT NULL,
|
||||
is_dir INTEGER NOT NULL,
|
||||
size INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL,
|
||||
modified_at INTEGER NOT NULL,
|
||||
is_carrier INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE TABLE chunks (
|
||||
node_id INTEGER NOT NULL,
|
||||
chunk_index INTEGER NOT NULL,
|
||||
nonce BLOB NOT NULL,
|
||||
tag BLOB NOT NULL,
|
||||
ciphertext BLOB NOT NULL,
|
||||
PRIMARY KEY (node_id, chunk_index)
|
||||
);",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let kek = derive_kek("TestV2!", &salt, &kdf).unwrap();
|
||||
let (wrapped, h_nonce, h_tag) = wrap_dek(&kek, &dek).unwrap();
|
||||
let params_json = serde_json::to_string(&kdf).unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO meta VALUES (0, ?1, 2, ?2, ?3, ?4, ?5, ?6)",
|
||||
rusqlite::params![
|
||||
MAGIC_BYTES.as_slice(),
|
||||
salt.as_slice(),
|
||||
params_json,
|
||||
wrapped,
|
||||
h_nonce.as_slice(),
|
||||
h_tag.as_slice(),
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO meta VALUES (1, ?1, 2, ?2, ?3, ?4, ?5, ?6)",
|
||||
rusqlite::params![
|
||||
MAGIC_BYTES.as_slice(),
|
||||
salt.as_slice(),
|
||||
params_json,
|
||||
wrapped,
|
||||
h_nonce.as_slice(),
|
||||
h_tag.as_slice(),
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO nodes VALUES (1, NULL, '', 1, 0, 100, 100, 0), (2, NULL, '', 1, 0, 100, 100, 0), (3, 1, 'corrupt.txt', 0, 100, 100, 100, 0)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Gültiger Chunk 0
|
||||
let (ct0, n0, t0) =
|
||||
encrypt_chunk(&dek, 3, 0, b"Valid chunk 0", FORMAT_VERSION_V2, 0).unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO chunks VALUES (3, 0, ?1, ?2, ?3)",
|
||||
rusqlite::params![n0.as_slice(), t0.as_slice(), ct0],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Beschädigter Chunk 1 (manipulierter Tag)
|
||||
let (ct1, n1, mut t1) =
|
||||
encrypt_chunk(&dek, 3, 1, b"Valid chunk 1", FORMAT_VERSION_V2, 0).unwrap();
|
||||
t1[0] ^= 0xff; // Tag korrumpieren
|
||||
conn.execute(
|
||||
"INSERT INTO chunks VALUES (3, 1, ?1, ?2, ?3)",
|
||||
rusqlite::params![n1.as_slice(), t1.as_slice(), ct1],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let db = Database::open(&path).unwrap();
|
||||
|
||||
// Upgrade MUSS abbrechen
|
||||
let res = db.upgrade_to_v3(&dek);
|
||||
assert!(
|
||||
res.is_err(),
|
||||
"Upgrade muss bei beschädigtem Chunk abbrechen"
|
||||
);
|
||||
let err_msg = res.unwrap_err().to_string();
|
||||
assert!(
|
||||
err_msg.contains("Node 3") && err_msg.contains("Index 1"),
|
||||
"Fehler muss Node und Index benennen: {err_msg}"
|
||||
);
|
||||
|
||||
// Rollback-Verifikation:
|
||||
let conn = db.conn();
|
||||
let v0: u32 = conn
|
||||
.query_row("SELECT version FROM meta WHERE slot_id = 0", [], |r| {
|
||||
r.get(0)
|
||||
})
|
||||
.unwrap();
|
||||
let v1: u32 = conn
|
||||
.query_row("SELECT version FROM meta WHERE slot_id = 1", [], |r| {
|
||||
r.get(0)
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(v0, 2, "Nach Rollback muss Version 2 bleiben");
|
||||
assert_eq!(v1, 2, "Nach Rollback muss Version 2 bleiben");
|
||||
|
||||
// Chunk 0 darf nicht umverschlüsselt zurückgeblieben sein (muss weiterhin mit V2 entschlüsselbar sein)
|
||||
drop(conn);
|
||||
let chunk0 = db.read_chunk(3, 0).unwrap().unwrap();
|
||||
let dec0 = decrypt_chunk(
|
||||
&dek,
|
||||
3,
|
||||
0,
|
||||
&chunk0.ciphertext,
|
||||
&chunk0.nonce,
|
||||
&chunk0.tag,
|
||||
FORMAT_VERSION_V2,
|
||||
0,
|
||||
);
|
||||
assert!(
|
||||
dec0.is_ok(),
|
||||
"Chunk 0 muss unverändert im V2-Zustand geblieben sein"
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_f03_dual_vault_v2_upgrade_carrier() {
|
||||
let path = temp_container_path("f03_dual_carrier");
|
||||
let pass_decoy = "DecoyPassword2026!";
|
||||
let pass_hidden = "HiddenPassword2026!";
|
||||
let kdf = test_kdf_params();
|
||||
|
||||
let salt_0 = generate_salt();
|
||||
let kek_0 = derive_kek(pass_decoy, &salt_0, &kdf).unwrap();
|
||||
let dek_0 = generate_dek();
|
||||
|
||||
let salt_1 = generate_salt();
|
||||
let kek_1 = derive_kek(pass_hidden, &salt_1, &kdf).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 carrier_size: u64 = 10 * 1024 * 1024; // 10 MB
|
||||
|
||||
let db = Database::open(&path).unwrap();
|
||||
db.init_schema_with_carrier(
|
||||
&salt_0,
|
||||
&kdf,
|
||||
&wrapped_0,
|
||||
&nonce_0,
|
||||
&tag_0,
|
||||
Some((
|
||||
"carrier.dat",
|
||||
carrier_size,
|
||||
&salt_1,
|
||||
&kdf,
|
||||
&wrapped_1,
|
||||
&nonce_1,
|
||||
&tag_1,
|
||||
&dek_0,
|
||||
&dek_1,
|
||||
)),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Simuliere V2-Zustand für beide Slots und Chunks
|
||||
{
|
||||
let conn = db.conn();
|
||||
conn.execute("UPDATE meta SET version = 2", []).unwrap();
|
||||
// Verschlüssele Carrier-Chunks mit V2 (16-Byte AAD) um
|
||||
let mut stmt = conn
|
||||
.prepare("SELECT chunk_index, nonce, tag, ciphertext FROM chunks WHERE node_id = ?1")
|
||||
.unwrap();
|
||||
let chunks: Vec<(u32, [u8; 12], [u8; 16], Vec<u8>)> = stmt
|
||||
.query_map([carrier_node_id], |r| {
|
||||
let idx: u32 = r.get(0)?;
|
||||
let n: Vec<u8> = r.get(1)?;
|
||||
let t: Vec<u8> = r.get(2)?;
|
||||
let c: Vec<u8> = r.get(3)?;
|
||||
let mut n_arr = [0u8; 12];
|
||||
let mut t_arr = [0u8; 16];
|
||||
n_arr.copy_from_slice(&n);
|
||||
t_arr.copy_from_slice(&t);
|
||||
Ok((idx, n_arr, t_arr, c))
|
||||
})
|
||||
.unwrap()
|
||||
.collect::<std::result::Result<Vec<_>, _>>()
|
||||
.unwrap();
|
||||
drop(stmt);
|
||||
|
||||
for (idx, n, t, c) in chunks {
|
||||
let pt = decrypt_chunk(
|
||||
&dek_0,
|
||||
carrier_node_id,
|
||||
idx,
|
||||
&c,
|
||||
&n,
|
||||
&t,
|
||||
FORMAT_VERSION_V3,
|
||||
0,
|
||||
)
|
||||
.unwrap();
|
||||
let (v2_ct, v2_n, v2_t) =
|
||||
encrypt_chunk(&dek_0, carrier_node_id, idx, &pt, FORMAT_VERSION_V2, 0).unwrap();
|
||||
conn.execute(
|
||||
"UPDATE chunks SET nonce = ?1, tag = ?2, ciphertext = ?3, generation = 0 WHERE node_id = ?4 AND chunk_index = ?5",
|
||||
rusqlite::params![v2_n.as_slice(), v2_t.as_slice(), v2_ct, carrier_node_id, idx],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
db.checkpoint().unwrap();
|
||||
|
||||
// Verifiziere V2-Ausgangszustand
|
||||
let meta_v2 = db.read_meta().unwrap();
|
||||
assert_eq!(meta_v2.version, 2);
|
||||
|
||||
// Upgrade mit Decoy-Passwort (DEK_0) durchführen
|
||||
db.upgrade_to_v3(&dek_0).unwrap();
|
||||
db.checkpoint().unwrap();
|
||||
|
||||
// Prüfen: Beide Slots müssen version == 3 sein!
|
||||
{
|
||||
let conn = db.conn();
|
||||
let v0: u32 = conn
|
||||
.query_row("SELECT version FROM meta WHERE slot_id = 0", [], |r| {
|
||||
r.get(0)
|
||||
})
|
||||
.unwrap();
|
||||
let v1: u32 = conn
|
||||
.query_row("SELECT version FROM meta WHERE slot_id = 1", [], |r| {
|
||||
r.get(0)
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(v0, 3, "Slot 0 version muss 3 sein");
|
||||
assert_eq!(v1, 3, "Slot 1 version muss 3 sein (F-03)");
|
||||
}
|
||||
drop(db);
|
||||
|
||||
// Hidden-Mount mit pass_hidden ausführen: Muss Carrier-Chunks fehlerfrei unter DEK_0 mit Version 3 lesen
|
||||
let auth_hidden = ContainerAuth::Password(Zeroizing::new(pass_hidden.to_string()));
|
||||
let c_path = path.clone();
|
||||
let mount_task = tokio::spawn(async move {
|
||||
mount_container(
|
||||
&c_path,
|
||||
'Y',
|
||||
None,
|
||||
Some(18107),
|
||||
auth_hidden,
|
||||
false,
|
||||
false,
|
||||
None,
|
||||
false,
|
||||
false,
|
||||
None,
|
||||
true,
|
||||
false,
|
||||
)
|
||||
.await
|
||||
});
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(300)).await;
|
||||
mount_task.abort();
|
||||
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
@@ -375,6 +375,7 @@ async fn test_mount_rejects_tampered_metadata_mac() {
|
||||
false,
|
||||
None,
|
||||
true,
|
||||
false,
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -472,6 +473,7 @@ async fn test_mount_succeeds_and_rebuilds_mac_after_header_file_restore() {
|
||||
false,
|
||||
None,
|
||||
true,
|
||||
true,
|
||||
)
|
||||
.await
|
||||
});
|
||||
@@ -577,6 +579,7 @@ async fn test_mount_succeeds_after_recovery_key_restore_slot0() {
|
||||
false,
|
||||
None,
|
||||
true,
|
||||
false,
|
||||
)
|
||||
.await
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user