8 Commits
Author SHA1 Message Date
harald 2286fad112 chore(release): bump version to 0.9.3 and package release assets
Sanctum Release / Build & Test (Windows x86_64 & Linux musl) (push) Waiting to run
Sanctum Release / Sign & Release (push) Blocked by required conditions
- Add CHANGELOG entry for v0.9.3
- Bump version to 0.9.3 across Cargo.toml, Cargo.lock, THREAT_MODEL.md, SECURITY_AUDIT.md, Scoop, and WinGet manifests
- Package and sign Windows (.zip, .exe) and Linux musl (.tar.gz, sanctum) binaries with Minisign
2026-09-21 09:26:13 +02:00
harald f0e5d6d26e sec: add SECURITY.md policy, deny.toml config, and document automated audit results
- Add SECURITY.md establishing Responsible Disclosure policy, SLA (48h/5d/90d), scope, and contact channels (security@pansi.eu)
- Configure deny.toml for cargo-deny (advisories, bans, licenses, sources)
- Audit with cargo audit (0 vulnerabilities) and cargo deny (all ok)
- Scan entire git history with gitleaks (101 commits, no secrets leaked)
- Baseline harmless unit-test mock tokens in .gitleaksignore
- Document automated auditing in SECURITY_AUDIT.md and link in README.md
2026-09-21 09:15:20 +02:00
harald 73453a7eb1 docs: update default Argon2id parameters to 256 MiB, T=4, P=4 (M-01) in README and CLI output 2026-09-21 08:21:20 +02:00
harald badbe3bd18 fix(recovery): resolve metadata MAC desync after recovery (R-NEW-1) & complete release workflow (CI-01)
Sanctum Release / Build & Test (Windows x86_64 & Linux musl) (push) Waiting to run
Sanctum Release / Sign & Release (push) Blocked by required conditions
- R-NEW-1: Recalculate metadata HMAC upon recovery-key restore and mark backup restores as PendingRebuild to rebuild transparently on first mount
- CI-01: Update release.yaml to compile both Windows x86_64 and Linux musl with pinned Zig 0.16.0 and cargo-zigbuild 0.23.4
- W-1: Implement statvfs quota determination on Unix via libc
- Add RELEASE_PROCESS.md documenting release architecture and steps
- Bump version to 0.9.2 across manifests, lockfile, docs, Scoop and WinGet
2026-09-21 08:15:17 +02:00
harald efb28c1c45 chore(release): bump version to 0.9.1 and update docs/manifests
Sanctum Release / Build & Test (Windows x86_64) (push) Waiting to run
Sanctum Release / Sign & Release (push) Blocked by required conditions
2026-09-20 17:31:03 +02:00
harald a252393f96 fix: address minor issues for v0.9.1 preparation (K-01 mount verification, path validation, legacy-names warning, V1-V2 migration docs) 2026-09-20 17:08:44 +02:00
harald 3693e03494 chore(release): bump version to 0.9.0 and update docs
Sanctum Release / Build & Test (Windows x86_64) (push) Canceled after 0s
Sanctum Release / Sign & Release (push) Canceled after 0s
2026-09-20 00:15:25 +02:00
harald 167e4b65fc feat(carrier): implement carrier format V2 paged manifest and secondary index 2026-09-20 00:15:11 +02:00
27 changed files with 2719 additions and 206 deletions
+82 -16
View File
@@ -8,7 +8,7 @@ on:
jobs:
# SA-02 & SA-04: Entkoppelte Build- & Test-Umgebung ohne Zugriff auf Signatur-Secrets
build:
name: Build & Test (Windows x86_64)
name: Build & Test (Windows x86_64 & Linux musl)
runs-on: windows-latest
steps:
- name: Checkout Code
@@ -18,39 +18,101 @@ jobs:
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de # master commit pinned
with:
toolchain: "1.85.0"
targets: x86_64-pc-windows-msvc
targets: x86_64-pc-windows-msvc,x86_64-unknown-linux-musl
- name: Install cargo-zigbuild (pinned)
run: cargo install cargo-zigbuild --locked --version 0.23.4
- name: Install Zig (pinned, hash-verified)
shell: pwsh
run: |
$ZigVersion = "0.16.0"
$ZigUrl = "https://ziglang.org/download/$ZigVersion/zig-windows-x86_64-$ZigVersion.zip"
$ExpectedZigHash = "68659eb5f1e4eb1437a722f1dd889c5a322c9954607f5edcf337bc3684a75a7e"
Invoke-WebRequest -Uri $ZigUrl -OutFile "zig.zip"
$ActualHash = (Get-FileHash -Path "zig.zip" -Algorithm SHA256).Hash.ToLower()
if ($ActualHash -ne $ExpectedZigHash) {
Write-Error "KRITISCHER SICHERHEITSFEHLER: Zig SHA-256 Pruefsumme ungueltig! Erwartet: $ExpectedZigHash, Erhalten: $ActualHash"
exit 1
}
Expand-Archive -Path "zig.zip" -DestinationPath "zig-bin"
echo "$PWD/zig-bin/zig-windows-x86_64-$ZigVersion" >> $env:GITHUB_PATH
- name: Run Tests
run: cargo test --all --verbose
- name: Build Release Binary
- name: Build Windows Release Binary
run: cargo build --release
- name: Cross-build Linux musl binary
run: cargo-zigbuild zigbuild --target x86_64-unknown-linux-musl --release
- name: Package Artifacts
id: package
shell: pwsh
run: |
$Tag = "${{ gitea.ref_name }}"
$DistDir = "dist"
$PackageName = "sanctum-${Tag}-windows-x86_64"
$StagingDir = "${DistDir}/${PackageName}"
$ZipFile = "${DistDir}/${PackageName}.zip"
if (-not (Test-Path $DistDir)) { New-Item -ItemType Directory -Path $DistDir -Force | Out-Null }
New-Item -ItemType Directory -Path $StagingDir -Force | Out-Null
Copy-Item "target/release/sanctum.exe" "$StagingDir/"
Copy-Item "README.md" "$StagingDir/"
Copy-Item "LICENSE" "$StagingDir/"
Copy-Item "CHANGELOG.md" "$StagingDir/"
# 1. Windows Package
$PackageNameWin = "sanctum-${Tag}-windows-x86_64"
$StagingDirWin = "${DistDir}/${PackageNameWin}"
$ZipFile = "${DistDir}/${PackageNameWin}.zip"
Compress-Archive -Path "$StagingDir/*" -DestinationPath $ZipFile -Force
New-Item -ItemType Directory -Path $StagingDirWin -Force | Out-Null
Copy-Item "target/release/sanctum.exe" "$StagingDirWin/"
Copy-Item "README.md" "$StagingDirWin/"
Copy-Item "LICENSE" "$StagingDirWin/"
Copy-Item "CHANGELOG.md" "$StagingDirWin/"
Copy-Item "QUICKSTART.md" "$StagingDirWin/"
Copy-Item "INSTALL.md" "$StagingDirWin/"
Copy-Item "LEGAL.md" "$StagingDirWin/"
Copy-Item "THIRD_PARTY_LICENSES.md" "$StagingDirWin/"
if (Test-Path "assets") { Copy-Item "assets" "$StagingDirWin/" -Recurse }
Compress-Archive -Path "$StagingDirWin/*" -DestinationPath $ZipFile -Force
Remove-Item $StagingDirWin -Recurse -Force
# Standalone Windows EXE nach dist/ kopieren
Copy-Item "target/release/sanctum.exe" "${DistDir}/sanctum.exe" -Force
# 2. Linux Package
$PackageNameLinux = "sanctum-${Tag}-linux-x86_64"
$StagingDirLinux = "${DistDir}/${PackageNameLinux}"
$TarGzFile = "${DistDir}/${PackageNameLinux}.tar.gz"
New-Item -ItemType Directory -Path $StagingDirLinux -Force | Out-Null
Copy-Item "target/x86_64-unknown-linux-musl/release/sanctum" "$StagingDirLinux/sanctum"
Copy-Item "README.md" "$StagingDirLinux/"
Copy-Item "LICENSE" "$StagingDirLinux/"
Copy-Item "CHANGELOG.md" "$StagingDirLinux/"
Copy-Item "QUICKSTART.md" "$StagingDirLinux/"
Copy-Item "INSTALL.md" "$StagingDirLinux/"
Copy-Item "LEGAL.md" "$StagingDirLinux/"
Copy-Item "THIRD_PARTY_LICENSES.md" "$StagingDirLinux/"
tar.exe -czf $TarGzFile -C $DistDir $PackageNameLinux
Remove-Item $StagingDirLinux -Recurse -Force
# Standalone Linux ELF nach dist/ kopieren
Copy-Item "target/x86_64-unknown-linux-musl/release/sanctum" "${DistDir}/sanctum" -Force
# 3. SHA-256 Checksums für alle 4 Assets
$ZipHash = (Get-FileHash -Path $ZipFile -Algorithm SHA256).Hash.ToLower()
$ExeHash = (Get-FileHash -Path "target/release/sanctum.exe" -Algorithm SHA256).Hash.ToLower()
$ExeHash = (Get-FileHash -Path "${DistDir}/sanctum.exe" -Algorithm SHA256).Hash.ToLower()
$TarHash = (Get-FileHash -Path $TarGzFile -Algorithm SHA256).Hash.ToLower()
$ElfHash = (Get-FileHash -Path "${DistDir}/sanctum" -Algorithm SHA256).Hash.ToLower()
@("$ZipHash ${PackageName}.zip", "$ExeHash sanctum.exe") | Set-Content -Path "${DistDir}/SHA256SUMS.txt" -Encoding utf8
@(
"$ZipHash ${PackageNameWin}.zip",
"$ExeHash sanctum.exe",
"$TarHash ${PackageNameLinux}.tar.gz",
"$ElfHash sanctum"
) | Set-Content -Path "${DistDir}/SHA256SUMS.txt" -Encoding utf8
echo "ZIP_FILE=$ZipFile" >> $env:GITHUB_OUTPUT
echo "PACKAGE_NAME=$PackageName" >> $env:GITHUB_OUTPUT
echo "PACKAGE_NAME=$PackageNameWin" >> $env:GITHUB_OUTPUT
- name: Upload Build Artifacts
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
@@ -103,10 +165,11 @@ jobs:
$MinisignExe = "minisign"
}
$Tag = "${{ gitea.ref_name }}"
$KeyFile = "sanctum-ci-release.key"
[System.IO.File]::WriteAllText($KeyFile, $env:MINISIGN_SECRET_KEY)
try {
& $MinisignExe -S -s $KeyFile -m "dist/SHA256SUMS.txt" -W -x "dist/SHA256SUMS.txt.minisig"
& $MinisignExe -S -s $KeyFile -m "dist/SHA256SUMS.txt" -W -x "dist/SHA256SUMS.txt.minisig" -t "version:$Tag"
if ($LASTEXITCODE -ne 0 -or -not (Test-Path "dist/SHA256SUMS.txt.minisig")) {
Write-Error "Minisign-Signierung fehlgeschlagen!"
exit 1
@@ -120,6 +183,9 @@ jobs:
with:
files: |
dist/*.zip
dist/*.tar.gz
dist/sanctum
dist/sanctum.exe
dist/SHA256SUMS.txt
dist/SHA256SUMS.txt.minisig
body_path: CHANGELOG.md
+5
View File
@@ -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
+97
View File
@@ -5,6 +5,103 @@ 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.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
Dieses Release behebt einen kritischen Fehler in den Disaster-Recovery-Pfaden (R-NEW-1), bei dem der K-01 Metadaten-MAC nach Header-Wiederherstellungen nicht aktualisiert wurde und nachfolgende Mount-Vorgänge fälschlich abwies. Zudem wird die CI/CD-Pipeline um den Linux-Cross-Build vervollständigt (CI-01) und die Quota-Ermittlung unter Linux implementiert (W-1).
#### Sicherheits- & Funktions-Fixes (R-NEW-1)
- **Metadaten-MAC nach Header-Wiederherstellung (R-NEW-1, HOCH)**:
- Bei Notfallwiederherstellung via 24-Wort BIP-39 Notfallschlüssel (`restore_slot_from_recovery_key`) wird der Metadaten-MAC sofort neu berechnet und persistent gespeichert.
- Bei Header-Wiederherstellung via `.sanctum.hdr`-Datei (`restore_header_backup`) wird der Zustand als `PendingRebuild` markiert und beim nächsten `sanctum mount` mit dem Benutzerpasswort transparent und sicher neu aufgebaut.
- Verhindert fälschliche Manipulations-Fehlermeldungen nach legitimen Notfall-Wiederherstellungen.
#### CI/CD & Build-Infrastruktur (CI-01)
- **Automatisierter Linux-Cross-Build in `.gitea/workflows/release.yaml`**:
- Vollständige Integration des Linux musl Cross-Builds (`x86_64-unknown-linux-musl`) auf dem Windows-Runner via `cargo-zigbuild` und SHA-256-gepinntem Zig-Compiler (`0.16.0`).
- Automatische Paketierung beider Plattformen (`.zip`, `.tar.gz`, Standalone-Binaries `sanctum.exe` und `sanctum`) und gemeinsame Minisign-Signatur in CI.
- Dokumentation des Release-Workflows in `RELEASE_PROCESS.md`.
#### Plattform-Optimierungen (W-1)
- **Freier Speicherplatz unter Linux (W-1)**:
- Ermittlung des freien Festplattenspeichers unter Linux/Unix via `libc::statvfs` (`f_bavail * f_frsize`) anstelle des statischen 1-TB-Platzhalters.
- **Plattform-Modul**:
- Präzisierung des Modul-Docstrings in `src/platform/mod.rs`.
## [0.9.1] - 2026-09-20
### Sicherheits-Härtung, Pfadvalidierung & Migration-Dokumentation
Dieses Release behebt die im Prüfbericht für v0.9.0 identifizierten vier Minor Issues bezüglich Metadaten-Authentifizierung beim Mounten, strikter Eingabevalidierung von Dateinamen im Carrier-Dateisystem, Dokumentation der Carrier-V1-nach-V2-Migration sowie Schutz vor Swap-Attacks bei der Dateinamen-Entschlüsselung.
#### Behobene Minor Issues
- **K-01 Metadaten-MAC-Validierung beim Mount**:
- `mount_container` validiert vor dem Start des WebDAV-Dienstes die kryptografische HMAC-SHA-256 Integrität aller Metadaten für Container ab Format V3.
- Registrierung des aktiven DEK (`set_active_slot_and_dek`), sodass fortlaufende Dateioperationen den Metadaten-MAC synchron halten.
- **Lückenlose Pfadvalidierung (`validate_node_name`)**:
- Konsequente Prüfung aller Dateinamen im Carrier-Dateisystem (`CarrierFs::open`, `CarrierFs::create_dir`, `CarrierFs::rename`, `CarrierFs::copy`) und VFS (`SanctumFs::copy`).
- Zuverlässige Abweisung von Windows-reservierten Gerätenamen (`CON`, `PRN`, `AUX`, `NUL`, `COM1-9`, `LPT1-9`), ungültigen Zeichen (`<>:"/\|?*`), Steuerzeichen und Pfad-Traversal.
- **Sicherheitsdokumentation & Runtime-Warnung für `--legacy-names`**:
- Deutliche Warnung im CLI-Hilfetext, auf `stderr` und im Logging vor den Risiken von Swap-Attacks bei deaktivierter AAD-Bindung.
- Aufnahme einer Risikobewertung in `README.md` und `THREAT_MODEL.md` (Abschnitt 7).
- **Migration-Leitfaden (V1 → V2)**:
- Schritt-für-Schritt-Anleitung in `README.md` (Abschnitt 10) zur transparenten Aktualisierung bestehender V1-Container auf das Paged-Manifest-Format V2.
## [0.9.0] - 2026-09-20
### Paged Manifest für den Hidden Vault (Aufhebung der Kapazitätsgrenze)
Dieses Release führt das **Carrier-Format V2** für den Hidden Vault ein. Es hebt die bisherige Kapazitätsgrenze von ca. 7.000 Dateien vollständig auf, führt eine skalierbare Paged-Manifest-Architektur ein und optimiert Performanz sowie Resilienz signifikant.
#### Formaterweiterung Carrier V2 (D-01 bis D-05)
- **Carrier-Format V2 mit Paged Manifest**:
- Trennung von Superblock und Inode-Tabellen: Block 0 und Block 1 enthalten nur noch den leichtgewichtigen `CarrierSuperblock` (unter Erhalt der C-02 Dual-Block Redundanz).
- Inodes werden in separaten Inode-Pages (`CarrierInodePage`, Magic `SANCTPAG`, ~1 MB Payload je Block, ca. 4.5007.000 Inodes pro Seite) aus dem normalen Blockpool verwaltet.
- Dynamische Skalierung: Die Inode-Kapazität skaliert linear mit den verfügbaren Trägerblöcken bis zu 2^32 Blöcken.
- **D-01: Paged Loading & Fail-Soft Resilienz**:
- Erkennt automatisch V1- vs. V2-Superblöcke auf Block 0 und 1.
- Resiliente Fehlerisolation: Beschädigte oder unlesbare Inode-Seiten führen nicht zum Mount-Abbruch. Fehlerhafte Seiten werden geloggt (`error!`) und übersprungen, während alle intakten Inodes gemountet werden.
- Neuer Zähler `corrupted_pages` warnt den Benutzer beim Mounten über partielle Datenbeschädigungen.
- **D-02: Sekundärindex für Kindknoten (`children_index`)**:
- In-Memory Index `children_index: HashMap<i64, Vec<i64>>` ordnet Elternknoten-IDs ihren Kindern zu.
- Beschleunigt Pfadauflösung (`resolve_path`) und Verzeichnislisting (`read_dir`) von O(N) Tabellenscans auf O(Geschwister).
- Konsistente Nachführung bei `open`, `create_dir`, `remove_file`, `remove_dir`, `rename` und `copy`.
- **D-03: Transparente V1 → V2 Migration**:
- V1-Container werden vollkommen transparent eingelesen.
- Beim ersten Speichervorgang (`save_manifest()`) wird geprüft, ob genügend freie Blöcke für die Seitenaufteilung vorhanden sind.
- Reicht der Platz nicht aus, bricht der Vorgang sauber ab, ohne den Container zu beschädigen. Reicht der Platz, wird der Container nahtlos auf Format V2 migriert.
- Eine ausführliche Schritt-für-Schritt-Anleitung zur Migration findet sich in der [README.md](README.md#10-migration-von-carrier-format-v1-auf-v2-hidden-vault).
- **D-04 & D-05: Deterministische Seitenaufteilung & Blockfreigabe**:
- Deterministische Sortierung der Inodes nach ID garantiert reproduzierbare Seitenbelegungen.
- Dynamische Blockfreigabe: Verringert sich die Inode-Anzahl (z. B. durch Löschungen), werden überzählige Seitenblöcke mittels kryptografischem Zufallsrauschen geschreddert (`shred_carrier_block`) und an den Freispeicher zurückgegeben.
- Dynamische Block-Warnung: Die statische 80%-Manifestgrenze aus C-04 wird durch eine Warnung bei absolutem Träger-Blockmangel (< 20 Blöcke oder < 5% Restkapazität) ersetzt.
## [0.8.1] - 2026-09-19
### Carrier-Manifest-Resilienz & Storage-Hygiene (Dritte Audit-Runde)
Generated
+2 -1
View File
@@ -1457,7 +1457,7 @@ checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
[[package]]
name = "sanctum"
version = "0.8.1"
version = "0.9.3"
dependencies = [
"aes-gcm",
"anyhow",
@@ -1473,6 +1473,7 @@ dependencies = [
"http-body-util",
"hyper",
"hyper-util",
"libc",
"lz4_flex",
"minisign-verify",
"rand",
+4 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "sanctum"
version = "0.8.1"
version = "0.9.3"
edition = "2021"
authors = ["Harald Pansi <harald@pansi.eu>", "Sanctum Engineering Team"]
description = "Verschlüsselter Ein-Datei-Container unter Windows im reinen Userland via WebDAV"
@@ -48,6 +48,9 @@ tempfile = "3"
[target.'cfg(windows)'.dependencies]
tray-item = "0.10"
[target.'cfg(unix)'.dependencies]
libc = "0.2"
[profile.release]
opt-level = 3
lto = true
+2 -1
View File
@@ -37,7 +37,8 @@ Beim Erstellen eines Tresors wählst du zwischen zwei Sicherheitsstufen:
1. **Decoy-Passwort**: Öffnet den äußeren Safe (enthält eine scheinbare Backup-Datei `system_backup.dat`).
2. **Hidden-Passwort**: Öffnet den geheimen Zweittresor (Second Safe).
* *(Hinweis: Schützt vor Schulterblick im Alltag; keine forensische Abstreitbarkeit gegen behördliche Entropieanalyse).*
* **Kapazitätsgrenze (C-04)**: Empfohlene Obergrenze ca. 7.000 Dateien/Verzeichnisse im Hidden Vault (~1 MB Manifest-Block). Sanctum warnt beim Mounten automatisch bei über 80% Füllstand.
* **Kapazität & Skalierung (Format V2)**: Dank Paged-Manifest-Architektur dynamisch skalierbar (~4.5007.000 Dateien je 1-MB-Manifestseite, keine starre 7.000er-Grenze mehr). Sanctum warnt beim Mounten automatisch bei Blockknappheit (< 20 freie Blöcke oder < 5% Restkapazität).
* **Migration von V1-Containern**: Bestehende V1-Hidden-Vaults werden transparent unterstützt und können auf Format V2 migriert werden. Siehe [Schritt-für-Schritt-Anleitung in der README](README.md#10-migration-von-carrier-format-v1-auf-v2-hidden-vault).
* **Wichtig**: Notiere dir die ausgegebenen **24 Wörter des Notfallschlüssels (BIP-39)** auf der untenstehenden Notfallkarte!
---
+47 -5
View File
@@ -19,14 +19,14 @@ 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)**:
@@ -37,9 +37,11 @@ Sanctum ist eine eigenständige, speichersichere und hochperformante CLI-Anwendu
Als Authenticated Associated Data (AAD) werden `node_id` (8 Bytes LE) und `chunk_index` (8 Bytes LE) an jeden Block gebunden. Ein Vertauschen von Chunks zwischen Dateien oder innerhalb einer Datei führt zum Authentifizierungsfehler.
- **Dual-Vault (Multi-Slot & Carrier)**:
Konstante 2-Slot-Architektur. Slot 0 dient als Standard-/Decoy-Vault, Slot 1 als Second Safe (Hidden Vault) oder CSPRNG-Dummy. Dient dem Schutz vor neugierigen Blicken oder beiläufigem Zwang im Alltag. (Hinweis: Die Trägerdatei besitzt hohe Entropie und ist forensisch nachweisbar; kein Anspruch auf juristisch unnachweisbare Abstreitbarkeit gegen behördliche Beschlagnahme).
*Kapazitätsgrenze des Hidden Vault (C-04)*: Das Dateisystem des Hidden Vaults verwaltet seine Inodes in einem dual-block rollierenden Manifest (~1 MB pro Block). Daraus ergibt sich eine empfohlene Obergrenze von ca. 7.000 Dateien und Verzeichnissen im Hidden Vault. Bei Überschreiten von 80% Füllstand (~838 KB) warnt Sanctum beim Einbinden automatisch vor Erreichen der Blockgrenze.
- **Dateinamen-Verschlüsselung**:
Dateinamen im Hidden Vault werden mit frischen CSPRNG-Nonces und AES-256-GCM verschlüsselt in der Datenbank gespeichert (Legacy-Kompatibilität über `--legacy-names`).
*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.5007.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).
- **Dateinamen-Verschlüsselung & Schutz vor Swap-Attacks**:
Dateinamen werden mit frischen CSPRNG-Nonces und AES-256-GCM verschlüsselt in der Datenbank gespeichert, wobei die übergeordnete Verzeichnis-ID (`parent_id`) als Additional Authenticated Data (AAD) eingebunden ist. Dies verhindert, dass Angreifer verschlüsselte Dateinamen zwischen Verzeichnissen vertauschen können.
> [!WARNING]
> **Sicherheitshinweis zu `--legacy-names`**: Das globale Flag `--legacy-names` deaktiviert die Prüfung der AAD-Bindung bei der Entschlüsselung von Dateinamen. Verwenden Sie dieses Flag **ausschließlich zur einmaligen Datenrettung oder Migration** älterer Container (vor Version 0.8.0), da es das Schutzniveau gegen Manipulationen reduziert.
- **Kryptografisches Chunk-Shredding (Logisches Löschen vs. Physikalische Bereinigung)**:
Vor jedem Löschen oder Kürzen werden Chunk-Payloads, Nonces und Tags in der SQLite-Datenbank transaktional mit CSPRNG-Zufallsrauschen überschrieben. Dies verhindert zuverlässig jede logische Rekonstruktion auf Datenbank- und Dateisystemebene.
*Wichtiger technischer Hinweis (SA-07)*: Dies stellt ein *logisches* sicheres Löschen dar. Auf modernen Solid-State-Drives (SSD, NVMe) und Copy-on-Write-Dateisystemen (Btrfs, ZFS, APFS, ReFS) kann Software im Userland bauartbedingt keine *physikalische* Datenträgerbereinigung (Media Sanitization) garantieren: Der Flash Translation Layer (FTL) und Wear-Leveling-Algorithmen leiten Schreiboperationen auf neue Flash-Blöcke um; alte physikalische Zellen verbleiben bis zur SSD Garbage Collection / TRIM im Flash. Für absolute physische Bereinigung wird eine hardware- oder blockebenenbasierte Vollverschlüsselung (BitLocker, LUKS) oder ein ATA/NVMe Secure Erase empfohlen.
@@ -244,6 +246,46 @@ sanctum.exe upgrade --force
---
### 10. Migration von Carrier-Format V1 auf V2 (Hidden Vault)
Mit Sanctum v0.9.0 wurde das **Carrier-Format V2** eingeführt, das die vorherige Begrenzung von ca. 7.000 Dateien im Hidden Vault aufhebt. Bestehende V1-Container werden transparent unterstützt und können wie folgt auf V2 aktualisiert werden:
1. **Sicherheits-Backup erstellen**:
Erstellen Sie vor der Migration eine Sicherungskopie Ihres Containers:
```powershell
sanctum.exe backup --path "C:\Pfad\tresor.sanctum" --output "C:\Pfad\tresor_backup.sanctum"
# Oder manuell:
Copy-Item "C:\Pfad\tresor.sanctum" "C:\Pfad\tresor_backup.sanctum"
```
2. **Freispeicher prüfen**:
Für die Migration wird mindestens ein freier Trägerblock (~1 MB) für die erste Inode-Page benötigt. Bei extrem vollen Trägerdateien (0 freie Blöcke) bricht Sanctum die Migration sicher ab (`Out of space`), ohne den V1-Container zu verändern oder zu beschädigen.
3. **Hidden Vault einbinden (`mount`)**:
Öffnen Sie den Hidden Vault mit Ihrem Hidden-Passwort:
```powershell
sanctum.exe mount --path "C:\Pfad\tresor.sanctum" --drive S
```
Sanctum erkennt automatisch das V1-Format, liest das bisherige Manifest ein und bereitet die V2-Struktur im Speicher vor.
4. **Migration durch Schreiboperation auslösen**:
Die Migration auf die V2-On-Disk-Struktur wird beim nächsten Speichervorgang des Manifests automatisch vollzogen. Sie können dies durch Anlegen und sofortiges Löschen einer temporären Datei anstoßen:
```powershell
New-Item "S:\.migrate" -ItemType File
Remove-Item "S:\.migrate"
sanctum.exe unmount --drive S
```
Beim Aushängen (`unmount`) wird das neue Paged-Manifest (V2-Superblock + Inode-Pages) atomar auf die Trägerdatei geschrieben.
5. **Integrität verifizieren**:
Überprüfen Sie den migrierten Container:
```powershell
sanctum.exe verify --path "C:\Pfad\tresor.sanctum"
```
Beim nächsten Mounten des Hidden Vaults zeigt Sanctum die dynamische Block- und Seitenstatistik des V2-Formats an.
---
## 🛡️ OpSec & Explorer Anti-Leak Shield
Sanctum schützt vertrauliche Daten vor unbeabsichtigten Windows-Spuren:
+62
View File
@@ -0,0 +1,62 @@
# Sanctum Release-Prozess & CI/CD Dokumentation
Dieses Dokument beschreibt den Release-Workflow von **Sanctum**, die Absicherung der Build-Pipeline (SA-02, SA-03, SA-04) und die unterstützten Veröffentlichungswege.
---
## 1. Automatisierter CI/CD-Workflow (Primärer Release-Pfad)
Ab Version **0.9.2** bildet der CI-Workflow unter [`.gitea/workflows/release.yaml`](.gitea/workflows/release.yaml) den gesamten Release-Prozess für alle Zielplattformen vollautomatisch auf dem `windows-latest`-Runner ab:
### Pipeline-Architektur:
1. **Trigger**:
- Push eines Git-Release-Tags nach dem Schema `v*` (z. B. `v0.9.2`).
2. **Build & Test Job** (`build`):
- **Plattform**: `windows-latest`.
- **Toolchains**:
- Rust Toolchain gepinnt (`1.85.0`).
- Rust Targets: `x86_64-pc-windows-msvc` und `x86_64-unknown-linux-musl`.
- `cargo-zigbuild` gepinnt auf `0.23.4`.
- `Zig` Compiler gepinnt auf `0.16.0` mit zwingender SHA-256-Integritätsprüfung (`68659eb5f1e4eb1437a722f1dd889c5a322c9954607f5edcf337bc3684a75a7e`).
- **Verifikation**:
- Vollständige Ausführung der Testsuite (`cargo test --all --verbose`).
- **Kompilierung**:
- Nativer Windows x86_64 Release-Build mit LTO (`cargo build --release`).
- Statischer Linux musl Cross-Build (`cargo-zigbuild zigbuild --target x86_64-unknown-linux-musl --release`).
- **Paketierung**:
- Erstellung von `sanctum-v<Version>-windows-x86_64.zip` (inkl. `sanctum.exe`, Dokumentation und Assets).
- Erstellung von `sanctum-v<Version>-linux-x86_64.tar.gz` (inkl. `sanctum` und Dokumentation).
- Bereitstellung der eigenständigen Binärdateien `sanctum.exe` und `sanctum`.
- Berechnung und Erstellung der vierzeiligen Prüfsummendatei `SHA256SUMS.txt`.
3. **Sign & Release Job** (`sign-and-release`):
- **Geheimnis-Isolation (SA-02)**: Das Signiergeheimnis `MINISIGN_SECRET_KEY` ist ausschließlich in diesem isolierten Job verfügbar, nicht in der Build- oder Testumgebung.
- **Signierung**:
- Gepinntes `minisign.exe` (SHA-256 geprüft).
- Signiert `SHA256SUMS.txt` mit `trusted comment: version:<Version>`.
- **Gitea Release**:
- Veröffentlicht das Release unter `https://gitea.pansi.eu/harald/sanctum/releases/tag/v<Version>` mit allen 6 Assets:
- `sanctum-v<Version>-windows-x86_64.zip`
- `sanctum.exe`
- `sanctum-v<Version>-linux-x86_64.tar.gz`
- `sanctum`
- `SHA256SUMS.txt`
- `SHA256SUMS.txt.minisig`
---
## 2. Lokaler Fallback-Prozess (Manuell)
Sollte die CI-Infrastruktur nicht verfügbar sein, kann der Release-Prozess lokal über PowerShell repliziert werden:
1. **Windows-Paket erstellen**:
```powershell
powershell -ExecutionPolicy Bypass -File scripts/package-release.ps1
```
2. **Linux-Paket erstellen (Cross-Build)**:
```powershell
powershell -ExecutionPolicy Bypass -File scripts/package-release-linux.ps1
```
3. **Auf Gitea veröffentlichen**:
```powershell
powershell -ExecutionPolicy Bypass -File scripts/publish-release.ps1
```
+94
View File
@@ -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.2)** | **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!
+63 -10
View File
@@ -1,17 +1,18 @@
# Sanctum Security Audit & Remediation Log (v0.8.1)
# Sanctum Security Audit & Remediation Log (v0.9.3)
Dieses Dokument fasst alle 39 Findings aus drei umfassenden externen Sicherheitsaudits zusammen, dokumentiert die angewandten Härtungsmaßnahmen, referenziert die jeweiligen Git-Commits und benennt die zugehörigen automatisierten Regressionstests.
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 und Abschnitt 8 die Supply-Chain- und Secret-Audits aus v0.9.3.
---
## Audit-Zusammenfassung
- **Zielversion:** Sanctum v0.8.1
- **Behobene Findings:** 39 / 39 (100%)
- **Test-Ergebnis:** 123 / 123 Tests erfolgreich (100% Pass Rate)
- **Quality Gates:** `cargo fmt --check` (100% sauber), `cargo clippy --all-targets -- -D warnings` (0 Warnungen)
- **Container-Format:** Upgrade auf Container-Format V3 mit kanonischer Metadaten-Authentifizierung (HMAC-SHA256) und Chunk-Replay-Schutz (Generation-gebundenes AAD).
- **Hidden Vault & Storage-Resilienz:** Dual-Block rollierendes Manifest (C-02), Manifest-Entkopplung mit Dirty-Tracking (C-03), Kapazitäts-Warnung (C-04), Fail-Closed Node-Name-Decryption (ST-01), rekursive Fehleraggregation (ST-02) und atomare SQLite-Transaktionen für Baum-Löschungen (ST-03).
- **Zielversion:** Sanctum v0.9.3 (Basis v0.9.2)
- **Behobene Findings:** 39 / 39 (100%) Audit-Findings + R-NEW-1
- **Quality Gates:** `cargo fmt --check` (100% sauber), `cargo clippy --all-targets -- -D warnings` (0 Warnungen), `cargo-deny` (100% ok), `gitleaks` (0 Leaks)
- **Container-Format:** Container-Format V3 mit kanonischer Metadaten-Authentifizierung (HMAC-SHA256) beim Mount und Chunk-Replay-Schutz (Generation-gebundenes AAD).
- **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).
---
@@ -19,6 +20,7 @@ Dieses Dokument fasst alle 39 Findings aus drei umfassenden externen Sicherheits
| Finding | Priorität | Modul | Kurzbeschreibung | Commit | Status & Regressionstest |
|---|---|---|---|---|---|
| **R-NEW-1** | HOCH | `recovery`, `storage`, `mount` | Metadaten-MAC nach Header-Wiederherstellung: direkte Neuberechnung bei Recovery-Key und PendingRebuild-Status bei Backup-Restore | `v0.9.2` | ✅ Bestanden (`test_mount_succeeds_and_rebuilds_mac_after_header_file_restore`, `test_mount_succeeds_after_recovery_key_restore_slot0`, `test_mount_succeeds_after_recovery_key_restore_slot1`) |
| **C-02** | HOCH | `carrier` | Single Point of Failure beseitigt: Rollierendes Dual-Block-Manifest (Block 0 & 1, `manifest_generation`) mit transparenter Failover-Wiederherstellung | `8455dc0` | ✅ Bestanden (`test_c02_dual_block_rolling_manifest_redundancy_and_recovery`) |
| **C-03** | MITTEL | `carrier`, `vfs`, `mount` | Entkopplung der Manifest-Neuverschlüsselung via Dirty-Tracking; synchrone Sicherung bei File-Flush und Unmount | `c606fa8` | ✅ Bestanden (`test_c03_manifest_dirty_decoupling_and_unmount_flush`) |
| **C-04** | NIEDRIG | `carrier`, `mount`, `doc` | Dokumentation der Hidden-Vault-Kapazität (~7.000 Inodes) & automatische 80%-Füllstandswarnung beim Mounten | `c21d63a` | ✅ Bestanden (`test_c04_manifest_capacity_limit_and_warning`) |
@@ -108,10 +110,61 @@ Dieses Dokument fasst alle 39 Findings aus drei umfassenden externen Sicherheits
- Unlesbare Knoten werden mit `warn!` geloggt und in `list_children_in_vault`, `resolve_path_in_vault` und `get_node_by_id_in_vault` fail-closed übersprungen.
- **Fehleraggregation & Transaktions-Rollback bei rekursivem Löschen (ST-02 & ST-03):**
- Fehler beim Löschen von Kindknoten werden nicht mehr ignoriert (`let _ =` eliminiert), sondern aggregiert und protokolliert.
- Das gesamte Löschen eines Baumes samt kryptografischem Chunk-Shredding läuft in einer atomaren SQLite-Transaktion. Schlägt ein Knoten fehl, wird der gesamte Vorgang per Rollback zurückgesetzt; verwaiste Knoten (Orphans) sind ausgeschlossen.
### 5. Sanctum v0.9.0 — Formaterweiterung Carrier V2 (Paged Manifest)
- **Aufhebung der C-04 Manifest-Kapazitätsgrenze:**
- In v0.8.1 war das Manifest ein einzelnes JSON-Objekt auf Block 0 und Block 1 (limitiert auf ca. 7.000 Inodes).
- Mit Format V2 wird der `CarrierSuperblock` (Block 0 & 1, dual-block rollierend C-02) von den eigentlichen Inode-Daten entkoppelt.
- Inodes werden in separaten `CarrierInodePage`-Blöcken (~1 MB Payload je Seite, ca. 4.5007.000 Inodes pro Seite) verwaltet, dynamisch allokiert aus dem Blockpool.
- **Paged Loading & Fail-Soft Resilienz (D-01):**
- Isolierte Fehlerbehandlung: Beschädigte oder unlesbare Inode-Seiten führen nicht mehr zum Gesamtabbruch des Mounts.
- Das System lädt alle lesbaren Seiten, loggt fehlerhafte Seiten als `error!` und führt einen Zähler `corrupted_pages`, der beim Einbinden gut sichtbar gemeldet wird.
- **Sekundärindex für Kindknoten (`children_index`, D-02):**
- `CarrierFsInner` pflegt einen `HashMap<i64, Vec<i64>>` In-Memory Index für Vater-Kind-Beziehungen.
- Reduziert Pfadauflösung (`resolve_path`) und Verzeichnis-Listings (`read_dir`) von $O(N)$ Tabellenscans auf $O(\text{Geschwister})$.
- **Transparente Migration & Allokations-Sicherheit (D-03, D-04, D-05):**
- V1-Container werden transparent geladen und beim ersten Schreibvorgang nach V2 migriert — inklusive Vorabprüfung, ob genügend freie Blöcke vorhanden sind.
- Freigabe überzähliger Seitenblöcke (`shred_carrier_block`) bei Inode-Löschungen.
- Ersetzung der statischen 80%-Füllstandswarnung durch eine Block-Verfügbarkeitswarnung bei `< 20` freien Blöcken oder `< 5%` Restkapazität.
- **Automatisierte Testabdeckung:** 7 neue Regressionstests in `tests/carrier_v2_paged_manifest_test.rs` decken Kapazität (>10.000 Inodes), Migration, Fail-Soft-Isolation, Sekundärindex-Konsistenz, Pfadauflösungs-Performance, Out-of-Space-Abbruch und Blockfreigabe ab (Gesamttests: 130/130 erfolgreich).
### 6. Sanctum v0.9.1 — Sicherheits-Härtung, Pfadvalidierung & Migration-Dokumentation
- **Metadaten-MAC-Validierung beim Mount (K-01):**
- `mount_container` validiert vor dem Bereitstellen des Dateisystems die Integrität der SQLite-Metadaten via `db.verify_metadata_mac_for_slot`.
- Bei Manipulation bricht der Vorgang fail-closed ab; nachfolgende VFS-Operationen führen den MAC über den aktiven DEK fort.
- **Lückenlose Pfadvalidierung (`validate_node_name`):**
- Carrier-Dateisystem (`CarrierFs::open`, `CarrierFs::create_dir`, `CarrierFs::rename`, `CarrierFs::copy`) und VFS (`SanctumFs::copy`) validieren Knotennamen lückenlos gegen Windows-Reservierungen (`CON`, `PRN`, `AUX`, `NUL`, etc.) und verbotene Steuerzeichen.
- **Dokumentation & Runtime-Warnung zu `--legacy-names`:**
- Schutz vor Swap-Attacks bei der Dateinamen-Entschlüsselung; explizite Warnung im CLI-Hilfetext, auf stderr und im Logging bei Aktivierung.
- **Migration-Dokumentation (V1 → V2):**
- Detaillierte 5-stufige Dokumentation für Anwender zur Konvertierung auf das Paged-Manifest-Format V2.
### 7. Sanctum v0.9.2 — Recovery-MAC-Fix (R-NEW-1) & CI/CD-Vervollständigung (CI-01)
- **Metadaten-MAC nach Header-Wiederherstellung (R-NEW-1):**
- Behebt die Interaktionslücke zwischen dem K-01 Metadaten-MAC und den Disaster-Recovery-Pfaden.
- `restore_slot_from_recovery_key` aktualisiert den MAC sofort (`db.set_active_slot_and_dek` + `db.update_metadata_mac()`).
- `restore_header_backup` setzt `metadata_gen = 0` und `metadata_mac = NULL`. `verify_metadata_mac_status_for_slot` liefert `MetadataMacStatus::PendingRebuild`, wodurch `mount_container` den MAC beim ersten Einbinden transparent und sicher neu aufbaut.
- **Vollständiger Linux-Cross-Build in CI (CI-01):**
- `.gitea/workflows/release.yaml` kompiliert und paketiert Windows x86_64 und Linux musl auf demselben Runner.
- **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.
---
## 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.
+40 -1
View File
@@ -1,6 +1,6 @@
# Threat Model & Sicherheitsarchitektur von Sanctum
Dieses Dokument beschreibt das Bedrohungsmodell, die Sicherheitsannahmen und die Schutzmechanismen von **Sanctum v0.8.0** im reinen Userland-Betrieb.
Dieses Dokument beschreibt das Bedrohungsmodell, die Sicherheitsannahmen und die Schutzmechanismen von **Sanctum v0.9.3** im reinen Userland-Betrieb.
---
@@ -35,6 +35,8 @@ Dieses Dokument beschreibt das Bedrohungsmodell, die Sicherheitsannahmen und die
| **Slowloris DoS auf Loopback** | Ressourcenerschöpfung durch offengehaltene Sockets. | Beschränkung auf max. 64 gleichzeitige Verbindungen (`MAX_CONCURRENT_DAV_CONNECTIONS`) und 15s Header-Read-Timeout (`HTTP_HEADER_READ_TIMEOUT`). |
| **Begleitdateien-Forensik (-wal/-shm)** | Angreifer analysiert temporäre SQLite-Dateien während oder nach dem Mount. | Sämtliche Daten werden vor Übergabe an SQLite verschlüsselt; WAL/SHM enthalten ausschließlich Ciphertext; TRUNCATE-Checkpointing und Bereinigung beim Aushängen (Z-03). |
| **Nötigung / Schulterblick** | Zwang zur Passwortherausgabe. | Alibi-Carrier (Hidden Vault Modell A) mit steganografisch verstecktem zweiten Tresor und unabhängigen Schlüsseln. |
| **Dateinamen-Swap-Angriffe** | Angreifer vertauscht verschlüsselte Dateinamen zwischen Verzeichnissen. | AAD-Bindung an `parent_id` bei AES-256-GCM Verschlüsselung; Warnung vor `--legacy-names` (deaktiviert diesen Schutz). |
| **Path-Traversal & Gerätenamen** | Angreifer schleust Pfad-Traversal (`..`), reservierte Namen (`CON`, `PRN`) oder Null-Bytes ein. | Strikte Validierung (`validate_node_name`) in allen Schichten (`storage.rs`, `vfs.rs`, `carrier.rs`, `sync.rs`). |
---
@@ -102,3 +104,40 @@ Wird der Rechner abrupt stromlos gemacht (`Hard Reset`), stürzt das Betriebssys
- **Datenintegrität**: Beim nächsten Öffnen des Containers führt SQLite automatisch ein WAL-Recovery durch, wodurch keine Daten verloren gehen und die Metadaten-Authentifizierung (K-01) intakt bleibt.
- **Forensische Sichtbarkeit**: Ein Angreifer kann aus der Existenz und Dateigröße der `-wal`-Datei ablesen, dass vor dem Absturz Schreiboperationen stattgefunden haben und wie viele Bytes modifiziert wurden.
- **Flash-Wear-Leveling**: Auf SSDs/NVMe-Speichern können gelöschte Dateisystem-Sektoren bis zur TRIM-Bereinigung physisch im Flash-Speicher existieren. Für maximale OpSec empfiehlt Sanctum die Ablage von Containern auf vollverschlüsselten Host-Laufwerken (BitLocker / LUKS) oder RAM-Disks.
---
## 6. Hidden Vault Carrier-Dateisystem & Redundanzmodell (Format V2)
### 6.1 Redundanzhierarchie: Superblock vs. Inode-Pages
Mit Sanctum v0.9.0 führt der Hidden Vault das Carrier-Format V2 (Paged Manifest) ein. Das Redundanzmodell unterscheidet bewusst zwischen Metadaten-Knotenpunkten und Inode-Seiten:
1. **Carrier-Superblock (Vollständige Redundanz auf Block 0 & Block 1)**:
- Der Superblock enthält die essenziellen Dateisystem-Parameter (`root_id`, `next_inode_id`, `free_blocks`, `page_block_indices`) sowie einen monotonen Generationszähler (`manifest_generation`) und CRC32-Prüfsummen.
- Er wird abwechselnd auf Block 0 und Block 1 geschrieben (rollierendes C-02-Schema). Ist einer der beiden Blöcke beschädigt oder unvollständig geschrieben, stellt Sanctum den Superblock transparent aus dem intakten Block wieder her.
2. **Inode-Pages (Dynamisch allokiert, nicht redundant dupliziert)**:
- Inodes werden in 1-MB-Seiten (`CarrierInodePage`, Magic `SANCTPAG`) im normalen Träger-Blockpool gespeichert.
- Zur Maximierung der nutzbaren Speicherkapazität werden Inode-Pages **nicht** blockweise gespiegelt. Stattdessen schützt Sanctum die Konsistenz über Fail-Soft-Isolation (D-01): Fällt eine einzelne Seite durch Bitrot oder Entschlüsselungsfehler aus, wird ausschließlich diese Seite übersprungen. Das restliche Dateisystem bleibt vollständig mountbar und lesbar.
- Erkannte Seitenbeschädigungen werden beim Mounten und in den Systemlogs (`corrupted_pages`) unübersehbar gemeldet.
### 6.2 Pfadauflösungs- und DoS-Schutz (`children_index`, D-02)
- Bei sehr großen Dateisystemen (>10.000 Inodes) führt eine lineare Suche über alle Inodes bei jedem Pfadsegment zu quadratischer Laufzeitkomplexität ($O(N)$ pro Segment).
- Sanctum v0.9.0 hält einen In-Memory-Sekundärindex (`children_index: HashMap<i64, Vec<i64>>`), der Pfadauflösungen auf $O(\text{Geschwister})$ reduziert. Dies verhindert CPU-Erschöpfungs-Angriffe (ReDoS/Algorithmic Complexity Attacks) beim Traversieren tiefer Verzeichnisstrukturen.
### 6.3 Speicherallokationsgrenzen & Transaktionssicherheit (D-03, D-05)
- **Vorabprüfung bei Migration (D-03)**: Bei der transparenten Konvertierung alter V1-Manifeste nach V2 prüft Sanctum vorab, ob ausreichend freie Trägerblöcke vorhanden sind. Reicht der Speicherplatz nicht aus, wird der Speichervorgang ohne Beschädigung des V1-Containers abgebrochen.
- **Kryptografische Blockfreigabe (D-05)**: Werden Inodes gelöscht und Inode-Pages überflüssig, werden die freigegebenen Trägerblöcke sofort mit CSPRNG-Rauschen überschrieben (`shred_carrier_block`), bevor sie an den Freispeicher-Pool zurückgegeben werden.
---
## 7. Dateinamen-Sicherheit und das `--legacy-names`-Flag
### 7.1 AAD-Bindung von Dateinamen
In Sanctum werden Dateinamen mit AES-256-GCM verschlüsselt. Um Swap-Angriffe zu verhindern (bei denen ein Angreifer mit Dateisystem-Zugriff verschlüsselte Dateinamen zwischen Verzeichnissen austauscht), wird die `parent_id` als zusätzliche authentifizierte Daten (AAD) an die Verschlüsselung gebunden.
### 7.2 Sicherheitsrisiko des `--legacy-names`-Flags
Frühere Versionen von Sanctum (vor v0.8.0) verwendeten leere AAD für Dateinamen. Das Flag `--legacy-names` erlaubt die Entschlüsselung von Dateinamen ohne AAD-Bindung als Fallback.
- **Risiko**: Bei aktiviertem `--legacy-names` ist der Schutz vor Vertauschen von Dateinamen aufgehoben. Ein Angreifer könnte Dateien manipulieren, indem er verschlüsselte Namen zwischen verschiedenen Verzeichnissen vertauscht.
- **Empfehlung**: Dieses Flag darf **ausschließlich** zur einmaligen Migration von Altdaten verwendet werden. Im regulären Betrieb muss es deaktiviert bleiben.
+249
View File
@@ -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 = []
+3 -3
View File
@@ -1,12 +1,12 @@
{
"version": "0.8.1",
"version": "0.9.3",
"description": "Verschlüsselter Ein-Datei-Container unter Windows im reinen Userland via WebDAV",
"homepage": "https://gitea.pansi.eu/harald/sanctum",
"license": "MIT",
"architecture": {
"64bit": {
"url": "https://gitea.pansi.eu/harald/sanctum/releases/download/v0.8.1/sanctum-v0.8.1-windows-x86_64.zip",
"hash": "11f06952f743d625c1cda627df685064a91cd973e52b17e7bcaf96385bb4969c",
"url": "https://gitea.pansi.eu/harald/sanctum/releases/download/v0.9.3/sanctum-v0.9.3-windows-x86_64.zip",
"hash": "75611155008ed0ecd6e6bbbb77fc549c5884ecc5779ca3012e66852bbb3b729b",
"bin": "sanctum.exe"
}
},
+4 -4
View File
@@ -1,6 +1,6 @@
# yaml-language-server: $schema=https://aka.ms/winget-manifest.singleton.1.6.0.schema.json
PackageIdentifier: HaraldPansi.Sanctum
PackageVersion: 0.8.1
PackageVersion: 0.9.3
PackageName: Sanctum
Publisher: Harald Pansi
PublisherUrl: https://gitea.pansi.eu/harald
@@ -18,7 +18,7 @@ Tags:
- webdav
- container
- plausible-deniability
ReleaseNotesUrl: https://gitea.pansi.eu/harald/sanctum/releases/tag/v0.8.1
ReleaseNotesUrl: https://gitea.pansi.eu/harald/sanctum/releases/tag/v0.9.3
Installers:
- Architecture: x64
InstallerType: zip
@@ -26,7 +26,7 @@ Installers:
NestedInstallerFiles:
- RelativeFilePath: sanctum.exe
PortableCommandAlias: sanctum
InstallerUrl: https://gitea.pansi.eu/harald/sanctum/releases/download/v0.8.1/sanctum-v0.8.1-windows-x86_64.zip
InstallerSha256: 11f06952f743d625c1cda627df685064a91cd973e52b17e7bcaf96385bb4969c
InstallerUrl: https://gitea.pansi.eu/harald/sanctum/releases/download/v0.9.3/sanctum-v0.9.3-windows-x86_64.zip
InstallerSha256: 75611155008ed0ecd6e6bbbb77fc549c5884ecc5779ca3012e66852bbb3b729b
ManifestType: singleton
ManifestVersion: 1.6.0
+1 -1
View File
@@ -85,7 +85,7 @@ if (-not $Release) {
$PayloadPath = Join-Path $DistDir "release_req.json"
$Payload = @{
tag_name = $TagName
name = "Sanctum $TagName (Windows x86_64)"
name = "Sanctum $TagName (Windows & Linux x86_64)"
body = $Changelog
draft = $false
prerelease = $false
+444 -97
View File
@@ -27,10 +27,40 @@ use crate::vfs::{is_leak_file_with_custom, SanctumDirEntry, SanctumMetaData};
pub const CARRIER_MAGIC: &[u8; 8] = b"SANCTCAR";
pub const CARRIER_VERSION: u32 = 1;
pub const CARRIER_VERSION_V2: u32 = 2;
pub const CARRIER_PAGE_MAGIC: &[u8; 8] = b"SANCTPAG";
pub const CARRIER_MANIFEST_CAPACITY: usize = CHUNK_SIZE - 64; // 1,048,512 Bytes
pub const CARRIER_MANIFEST_WARN_THRESHOLD: usize = CARRIER_MANIFEST_CAPACITY * 8 / 10; // 838,809 Bytes (80%, C-04)
/// Manifest für das steganografische Dateisystem innerhalb des Alibi-Carriers (Block 0 und 1, C-02).
/// Superblock: globaler Zustand, redundant auf Block 0 und 1 (C-02, Format V2).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CarrierSuperblock {
pub magic: [u8; 8],
pub version: u32,
pub manifest_generation: u64,
pub total_blocks: u32,
pub free_blocks: Vec<u32>,
pub next_inode_id: i64,
pub page_block_indices: Vec<u32>, // Reihenfolge = Seitenreihenfolge
}
/// Eine Inode-Tabellenseite (~1 MB Budget, serialisiert in einen Carrier-Block).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CarrierInodePage {
pub magic: [u8; 8], // eigenes Sub-Magic b"SANCTPAG"
pub entries: Vec<CarrierInode>,
}
/// Hilfsstruktur zur Typerkennung (V1 vs V2) in Block 0/1.
#[derive(Debug, Deserialize)]
struct VersionProbe {
#[allow(dead_code)]
pub magic: [u8; 8],
pub version: u32,
}
/// In-Memory-Zustand des Carrier-Manifests.
/// Hält Superblock-Felder und die vollständige Inode-Map für schnellen Zugriff.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CarrierManifest {
pub magic: [u8; 8],
@@ -40,6 +70,9 @@ pub struct CarrierManifest {
pub total_blocks: u32,
pub free_blocks: Vec<u32>,
pub next_inode_id: i64,
#[serde(default)]
pub page_block_indices: Vec<u32>,
#[serde(default)]
pub inodes: HashMap<i64, CarrierInode>,
}
@@ -66,16 +99,28 @@ impl CarrierManifest {
},
);
// Block 0 und Block 1 sind für das redundante Manifest reserviert (C-02). Nutzblöcke sind 2..total_blocks-1.
let free_blocks = (2..total_blocks).collect();
// Block 0 und Block 1 sind für den Superblock reserviert (C-02).
// Block 2 wird für die erste Inode-Tabellenseite allokiert (falls total_blocks > 2).
// Nutzblöcke starten ab Block 3.
let page_block_indices = if total_blocks > 2 {
vec![2]
} else {
Vec::new()
};
let free_blocks = if total_blocks > 3 {
(3..total_blocks).collect()
} else {
Vec::new()
};
Self {
magic: *CARRIER_MAGIC,
version: CARRIER_VERSION,
version: CARRIER_VERSION_V2,
manifest_generation: 0,
total_blocks,
free_blocks,
next_inode_id: 2,
page_block_indices,
inodes,
}
}
@@ -279,6 +324,8 @@ pub struct CarrierFsInner {
pub custom_leak_rules: Arc<Vec<String>>,
pub leak_counter: Arc<AtomicU64>,
pub manifest: CarrierManifest,
pub children_index: HashMap<i64, Vec<i64>>, // parent_id -> [child_id, ...] (D-02)
pub corrupted_pages: usize, // Zähler für übersprungene/beschädigte Seiten (D-01)
pub manifest_dirty: bool,
pub manifest_save_count: u64,
pub dirty_ops_count: u32,
@@ -291,13 +338,31 @@ impl CarrierFsInner {
self.anti_leak && is_leak_file_with_custom(name, &self.custom_leak_rules)
}
/// Baut den Sekundärindex (parent_id -> [child_id, ...]) aus der Inode-Map auf (D-02).
pub fn build_children_index(inodes: &HashMap<i64, CarrierInode>) -> HashMap<i64, Vec<i64>> {
let mut index: HashMap<i64, Vec<i64>> = HashMap::new();
for (&id, inode) in inodes {
if let Some(parent_id) = inode.parent_id {
index.entry(parent_id).or_default().push(id);
}
}
index
}
pub fn index_add_child(&mut self, parent_id: i64, child_id: i64) {
let entry = self.children_index.entry(parent_id).or_default();
if !entry.contains(&child_id) {
entry.push(child_id);
}
}
pub fn index_remove_child(&mut self, parent_id: i64, child_id: i64) {
if let Some(children) = self.children_index.get_mut(&parent_id) {
children.retain(|&id| id != child_id);
}
}
/// Markiert das Manifest als modifiziert (dirty) ohne teure synchrone Neuverschlüsselung (C-03).
/// Erst bei flush(), close(), unmount/drop oder nach Überschreiten von 50 Operationen wird
/// das vollständige 1-MB-Manifest tatsächlich neu verschlüsselt und geschrieben.
///
/// Konsistenzhinweis:
/// Bei einem abrupten Prozessabbruch gilt der zuletzt gespeicherte Zustand. Dank C-02
/// (rollierendes Manifest auf Block 0 und Block 1) ist die Integrität stets gewährleistet.
pub fn mark_dirty(&mut self) {
self.manifest_dirty = true;
self.dirty_ops_count += 1;
@@ -314,15 +379,37 @@ impl CarrierFsInner {
Ok(())
}
/// Ermittelt die aktuelle Bytegröße des Manifests, die Kapazität und den Füllstand in Prozent (C-04).
/// Ermittelt die aktuelle Bytegröße des Manifests, die Kapazität und den Füllstand in Prozent.
pub fn manifest_usage(&self) -> (usize, usize, f64) {
let len = serde_json::to_vec(&self.manifest)
.map(|v| v.len())
.unwrap_or(0);
let pct = (len as f64 / CARRIER_MANIFEST_CAPACITY as f64) * 100.0;
(len, CARRIER_MANIFEST_CAPACITY, pct)
let pages_count = self.manifest.page_block_indices.len().max(1);
let total_page_capacity = pages_count * CARRIER_MANIFEST_CAPACITY;
let mut total_bytes = 0usize;
for inode in self.manifest.inodes.values() {
total_bytes += serde_json::to_vec(inode)
.map(|v| v.len() + 2)
.unwrap_or(200);
}
let pct = if total_page_capacity > 0 {
(total_bytes as f64 / total_page_capacity as f64) * 100.0
} else {
0.0
};
(total_bytes, total_page_capacity, pct)
}
pub fn free_blocks_count(&self) -> usize {
self.manifest.free_blocks.len()
}
pub fn total_blocks_count(&self) -> u32 {
self.manifest.total_blocks
}
pub fn page_count(&self) -> usize {
self.manifest.page_block_indices.len()
}
/// Speichert das Carrier-Manifest im Format V2 (Superblock + Inode-Tabellenseiten, D-04).
pub fn save_manifest(&mut self) -> Result<()> {
self.manifest_save_count += 1;
self.manifest_dirty = false;
@@ -332,26 +419,119 @@ impl CarrierFsInner {
.map(|d| d.as_secs())
.unwrap_or(0);
self.manifest.manifest_generation += 1;
let manifest_bytes = serde_json::to_vec(&self.manifest)?;
if manifest_bytes.len() > CARRIER_MANIFEST_CAPACITY {
// D-04: Inodes auf Seiten aufteilen (deterministisch nach Inode-ID sortiert)
let mut sorted_inodes: Vec<CarrierInode> = self.manifest.inodes.values().cloned().collect();
sorted_inodes.sort_by_key(|i| i.id);
let mut pages: Vec<CarrierInodePage> = Vec::new();
let mut current_entries = Vec::new();
let mut current_bytes = 64usize; // Header-Overhead für CarrierInodePage JSON
for inode in sorted_inodes {
let entry_len = serde_json::to_vec(&inode)
.map(|v| v.len() + 2)
.unwrap_or(256);
if current_bytes + entry_len > CARRIER_MANIFEST_CAPACITY && !current_entries.is_empty()
{
pages.push(CarrierInodePage {
magic: *CARRIER_PAGE_MAGIC,
entries: std::mem::take(&mut current_entries),
});
current_bytes = 64;
}
current_bytes += entry_len;
current_entries.push(inode);
}
if !current_entries.is_empty() || pages.is_empty() {
pages.push(CarrierInodePage {
magic: *CARRIER_PAGE_MAGIC,
entries: current_entries,
});
}
let needed_pages = pages.len();
let old_page_blocks = self.manifest.page_block_indices.clone();
let old_page_count = old_page_blocks.len();
let mut new_page_blocks = old_page_blocks;
if needed_pages > old_page_count {
let additional_needed = needed_pages - old_page_count;
if self.manifest.free_blocks.len() < additional_needed {
bail!(
"Carrier-Manifest überschreitet die maximale Blockgröße ({} > {} Bytes). Empfohlene Obergrenze ca. 7.000 Dateien/Verzeichnisse beachten.",
manifest_bytes.len(),
"Nicht genügend freie Blöcke für die Inode-Tabelle (benötigt: {}, frei: {}). Bitte Speicherplatz freigeben oder Container vergrößern.",
additional_needed,
self.manifest.free_blocks.len()
);
}
for _ in 0..additional_needed {
let block = self.manifest.free_blocks.pop().ok_or_else(|| {
anyhow::anyhow!("Keine freien Blöcke mehr für Inode-Seitenallokation")
})?;
new_page_blocks.push(block);
}
} else if needed_pages < old_page_count {
// D-05: Überzählige Seitenblöcke freigeben und schreddern
let surplus = new_page_blocks.split_off(needed_pages);
for b in surplus {
let _ = shred_carrier_block(
&self.db,
self.carrier_node_id,
b,
&self.dek_outer,
self.format_version,
);
self.manifest.free_blocks.push(b);
}
}
// Alle Inode-Tabellenseiten schreiben
for (page_idx, page) in pages.iter().enumerate() {
let block_idx = new_page_blocks[page_idx];
let page_bytes = serde_json::to_vec(page)?;
if page_bytes.len() > CARRIER_MANIFEST_CAPACITY {
bail!(
"Inode-Tabellenseite #{} überschreitet Blockgröße ({} > {} Bytes)",
page_idx,
page_bytes.len(),
CARRIER_MANIFEST_CAPACITY
);
}
if manifest_bytes.len() >= CARRIER_MANIFEST_WARN_THRESHOLD {
let pct = (manifest_bytes.len() * 100) / CARRIER_MANIFEST_CAPACITY;
warn!(
"Warnung (C-04): Hidden-Vault Manifest zu {}% gefüllt ({}/{} Bytes). Empfohlene Obergrenze beachten (~7.000 Dateien/Verzeichnisse).",
pct,
manifest_bytes.len(),
write_carrier_block(
&self.db,
self.carrier_node_id,
block_idx,
&self.dek_outer,
&self.dek_inner,
&page_bytes,
self.format_version,
)?;
}
self.manifest.page_block_indices = new_page_blocks.clone();
self.manifest.version = CARRIER_VERSION_V2;
// Superblock schreiben (C-02 Dual-Block Rolling Scheme)
let superblock = CarrierSuperblock {
magic: *CARRIER_MAGIC,
version: CARRIER_VERSION_V2,
manifest_generation: self.manifest.manifest_generation,
total_blocks: self.manifest.total_blocks,
free_blocks: self.manifest.free_blocks.clone(),
next_inode_id: self.manifest.next_inode_id,
page_block_indices: new_page_blocks,
};
let sb_bytes = serde_json::to_vec(&superblock)?;
if sb_bytes.len() > CARRIER_MANIFEST_CAPACITY {
bail!(
"Superblock überschreitet Blockgröße ({} > {} Bytes)",
sb_bytes.len(),
CARRIER_MANIFEST_CAPACITY
);
}
// C-02: Rollierendes Dual-Block-Manifest.
// Gerade Generation -> Block 0, ungerade Generation -> Block 1.
let target_block = if self.manifest.manifest_generation.is_multiple_of(2) {
0
} else {
@@ -364,9 +544,20 @@ impl CarrierFsInner {
target_block,
&self.dek_outer,
&self.dek_inner,
&manifest_bytes,
&sb_bytes,
self.format_version,
)?;
// Warnung bei knappem freiem Blockpool
let free_len = self.manifest.free_blocks.len();
let total = self.manifest.total_blocks;
if total > 0 && (free_len < 20 || (free_len as f64 / total as f64) < 0.05) {
warn!(
"Warnung: Hidden-Vault hat nur noch {} freie Blöcke (von {}).",
free_len, total
);
}
Ok(())
}
@@ -389,6 +580,7 @@ impl CarrierFsInner {
Ok(())
}
/// Löst einen Pfad anhand des Sekundärindex auf (D-02, O(Kindknoten) statt O(alle Inodes)).
pub fn resolve_path(&self, raw_path: &str) -> Option<CarrierInode> {
let trimmed = raw_path.trim_matches('/');
if trimmed.is_empty() {
@@ -399,10 +591,17 @@ impl CarrierFsInner {
let mut current_id = 1i64;
for (idx, segment) in segments.iter().enumerate() {
let child = self.manifest.inodes.values().find(|inode| {
inode.parent_id == Some(current_id) && inode.name.as_str() == *segment
let child_ids = self.children_index.get(&current_id)?;
let child_id = child_ids.iter().find(|&&cid| {
self.manifest
.inodes
.get(&cid)
.map(|inode| inode.name.as_str() == *segment)
.unwrap_or(false)
})?;
let child = self.manifest.inodes.get(child_id)?;
if idx + 1 < segments.len() && !child.is_dir {
return None;
}
@@ -436,14 +635,29 @@ impl Drop for CarrierFsInner {
}
}
enum LoadedBlock {
V1(CarrierManifest),
V2(CarrierSuperblock),
}
impl LoadedBlock {
fn generation(&self) -> u64 {
match self {
LoadedBlock::V1(m) => m.manifest_generation,
LoadedBlock::V2(sb) => sb.manifest_generation,
}
}
}
/// WebDAV-Filesystem-Treiber für den steganografischen Alibi-Carrier (Hidden Vault).
#[derive(Clone)]
pub struct CarrierFs {
inner: Arc<Mutex<CarrierFsInner>>,
pub inner: Arc<Mutex<CarrierFsInner>>,
}
impl CarrierFs {
/// Lädt ein bestehendes Carrier-Dateisystem mit redundanter Block-0/Block-1 Prüfung (C-02)
/// Lädt ein bestehendes Carrier-Dateisystem mit redundanter Superblock-Prüfung (C-02),
/// seitenbasierter Inode-Tabelle (D-01), V1->V2 Migrationsunterstützung (D-03)
/// und benutzerdefinierten Anti-Leak-Regeln (V-04).
pub fn load_with_leak_rules(
db: Database,
@@ -454,68 +668,62 @@ impl CarrierFs {
anti_leak: bool,
custom_leak_rules: Vec<String>,
) -> Result<Self> {
// C-02: Versuche beide redundante Blöcke (Block 0 und Block 1) zu lesen und zu verifizieren
let block_0_res = read_carrier_block(
let read_and_parse_block = |block_idx: u32| -> Result<LoadedBlock> {
let bytes = read_carrier_block(
&db,
carrier_node_id,
0,
block_idx,
&dek_outer,
&dek_inner,
format_version,
)
.and_then(|bytes| {
let m: CarrierManifest = serde_json::from_slice(&bytes)?;
if m.magic != *CARRIER_MAGIC {
bail!("Ungültige Carrier-Magic-Bytes in Block 0");
)?;
let probe: VersionProbe = serde_json::from_slice(&bytes)?;
if probe.magic != *CARRIER_MAGIC {
bail!("Ungültige Carrier-Magic-Bytes in Block {block_idx}");
}
Ok(m)
});
let block_1_res = read_carrier_block(
&db,
carrier_node_id,
1,
&dek_outer,
&dek_inner,
format_version,
)
.and_then(|bytes| {
if probe.version == CARRIER_VERSION {
let m: CarrierManifest = serde_json::from_slice(&bytes)?;
if m.magic != *CARRIER_MAGIC {
bail!("Ungültige Carrier-Magic-Bytes in Block 1");
Ok(LoadedBlock::V1(m))
} else if probe.version == CARRIER_VERSION_V2 {
let sb: CarrierSuperblock = serde_json::from_slice(&bytes)?;
Ok(LoadedBlock::V2(sb))
} else {
bail!("Unbekannte Carrier-Formatversion: {}", probe.version);
}
Ok(m)
});
};
let mut manifest = match (block_0_res, block_1_res) {
(Ok(m0), Ok(m1)) => {
if m1.manifest_generation > m0.manifest_generation {
let block_0_res = read_and_parse_block(0);
let block_1_res = read_and_parse_block(1);
let loaded_block = match (block_0_res, block_1_res) {
(Ok(b0), Ok(b1)) => {
if b1.generation() > b0.generation() {
debug!(
"C-02: Beide Manifest-Kopien intakt. Wähle neuere Generation {} aus Block 1 (Block 0: Gen {})",
m1.manifest_generation, m0.manifest_generation
"C-02: Beide Superblock-Kopien intakt. Wähle neuere Generation {} aus Block 1 (Block 0: Gen {})",
b1.generation(), b0.generation()
);
m1
b1
} else {
debug!(
"C-02: Beide Manifest-Kopien intakt. Wähle Generation {} aus Block 0 (Block 1: Gen {})",
m0.manifest_generation, m1.manifest_generation
"C-02: Beide Superblock-Kopien intakt. Wähle Generation {} aus Block 0 (Block 1: Gen {})",
b0.generation(), b1.generation()
);
m0
b0
}
}
(Ok(m0), Err(e1)) => {
(Ok(b0), Err(e1)) => {
debug!(
"C-02: Verwende primäres Manifest aus Block 0 (Gen {}). Block 1 unlesbar oder uninitialisiert: {}",
m0.manifest_generation, e1
"C-02: Verwende primären Superblock aus Block 0 (Gen {}). Block 1 unlesbar oder uninitialisiert: {}",
b0.generation(), e1
);
m0
b0
}
(Err(e0), Ok(m1)) => {
(Err(e0), Ok(b1)) => {
warn!(
"C-02: Block 0 korrupt oder unlesbar ({})! Erfolgreiche Wiederherstellung aus redundanter Kopie in Block 1 (Gen {}).",
e0, m1.manifest_generation
e0, b1.generation()
);
m1
b1
}
(Err(e0), Err(e1)) => {
bail!(
@@ -525,20 +733,101 @@ impl CarrierFs {
}
};
// Rückwärtskompatibilität zu v0.8.0-Containern: Block 1 als redundante Manifest-Kopie reservieren
manifest.free_blocks.retain(|&b| b >= 2);
// C-04: Warnung bei Füllstand über 80% beim Mounten
if let Ok(manifest_bytes) = serde_json::to_vec(&manifest) {
if manifest_bytes.len() >= CARRIER_MANIFEST_WARN_THRESHOLD {
let pct = (manifest_bytes.len() * 100) / CARRIER_MANIFEST_CAPACITY;
warn!(
"Warnung (C-04): Hidden-Vault Manifest zu {}% gefüllt ({}/{} Bytes). Empfohlene Obergrenze beachten (~7.000 Dateien/Verzeichnisse).",
pct,
manifest_bytes.len(),
CARRIER_MANIFEST_CAPACITY
);
let mut corrupted_pages = 0usize;
let manifest = match loaded_block {
LoadedBlock::V1(mut m) => {
// Rückwärtskompatibilität zu v0.8.0-Containern: Block 1 als redundante Kopie reservieren
m.free_blocks.retain(|&b| b >= 2);
m
}
LoadedBlock::V2(sb) => {
let mut inodes = HashMap::new();
for &page_block_idx in &sb.page_block_indices {
match read_carrier_block(
&db,
carrier_node_id,
page_block_idx,
&dek_outer,
&dek_inner,
format_version,
) {
Ok(page_bytes) => {
match serde_json::from_slice::<CarrierInodePage>(&page_bytes) {
Ok(page) => {
if page.magic != *CARRIER_PAGE_MAGIC {
error!(
"D-01: Ungültige Magic in Inode-Tabellenseite auf Block {} (erwartet SANCTPAG)",
page_block_idx
);
corrupted_pages += 1;
} else {
for entry in page.entries {
inodes.insert(entry.id, entry);
}
}
}
Err(e) => {
error!(
"D-01: Deserialisierungsfehler in Inode-Tabellenseite auf Block {}: {}",
page_block_idx, e
);
corrupted_pages += 1;
}
}
}
Err(e) => {
error!(
"D-01: Lesefehler auf Inode-Tabellenseite Block {}: {}",
page_block_idx, e
);
corrupted_pages += 1;
}
}
}
// Sicherstellen, dass das Wurzelverzeichnis '/' (Inode 1) vorhanden ist
inodes.entry(1).or_insert_with(|| {
warn!("Wurzelknoten (Inode 1) fehlt in geladenen Seiten, wird rekonstruiert.");
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
CarrierInode {
id: 1,
parent_id: None,
name: String::new(),
is_dir: true,
size: 0,
created_at: now,
modified_at: now,
blocks: Vec::new(),
}
});
CarrierManifest {
magic: sb.magic,
version: sb.version,
manifest_generation: sb.manifest_generation,
total_blocks: sb.total_blocks,
free_blocks: sb.free_blocks,
next_inode_id: sb.next_inode_id,
page_block_indices: sb.page_block_indices,
inodes,
}
}
};
// D-02: Sekundärindex aufbauen
let children_index = CarrierFsInner::build_children_index(&manifest.inodes);
// Warnung bei knappem freiem Blockpool
let free_len = manifest.free_blocks.len();
let total = manifest.total_blocks;
if total > 0 && (free_len < 20 || (free_len as f64 / total as f64) < 0.05) {
warn!(
"Warnung: Hidden-Vault hat nur noch {} freie Blöcke (von {}).",
free_len, total
);
}
let now = SystemTime::now()
@@ -560,6 +849,8 @@ impl CarrierFs {
custom_leak_rules: Arc::new(custom_leak_rules),
leak_counter: Arc::new(AtomicU64::new(0)),
manifest,
children_index,
corrupted_pages,
manifest_dirty: false,
manifest_save_count: 0,
dirty_ops_count: 0,
@@ -612,6 +903,26 @@ impl CarrierFs {
inner.manifest_usage()
}
pub fn corrupted_pages(&self) -> usize {
let inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
inner.corrupted_pages
}
pub fn block_stats(&self) -> (usize, u32) {
let inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
(inner.free_blocks_count(), inner.total_blocks_count())
}
pub fn page_count(&self) -> usize {
let inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
inner.page_count()
}
pub fn resolve_path(&self, raw_path: &str) -> Option<CarrierInode> {
let inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
inner.resolve_path(raw_path)
}
pub fn leak_counter(&self) -> Arc<AtomicU64> {
let inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
inner.leak_counter.clone()
@@ -696,6 +1007,9 @@ impl DavFileSystem for CarrierFs {
}
None => {
if options.create || options.create_new {
crate::pathutil::validate_node_name(file_name)
.map_err(|_| FsError::Forbidden)?;
let parent = inner.resolve_path(parent_path).ok_or(FsError::NotFound)?;
if !parent.is_dir {
return Err(FsError::Forbidden);
@@ -721,6 +1035,7 @@ impl DavFileSystem for CarrierFs {
};
inner.manifest.inodes.insert(new_id, new_inode.clone());
inner.index_add_child(parent.id, new_id);
inner.mark_dirty();
new_inode
} else {
@@ -750,11 +1065,14 @@ impl DavFileSystem for CarrierFs {
return Err(FsError::Forbidden);
}
let entries: Vec<Result<Box<dyn DavDirEntry>, FsError>> = inner
.manifest
.inodes
.values()
.filter(|child| child.parent_id == Some(node.id))
let child_ids = inner
.children_index
.get(&node.id)
.cloned()
.unwrap_or_default();
let entries: Vec<Result<Box<dyn DavDirEntry>, FsError>> = child_ids
.into_iter()
.filter_map(|cid| inner.manifest.inodes.get(&cid))
.filter(|child| {
if inner.is_leak(&child.name) {
inner.leak_counter.fetch_add(1, Ordering::Relaxed);
@@ -814,6 +1132,8 @@ impl DavFileSystem for CarrierFs {
return Err(FsError::Forbidden);
}
crate::pathutil::validate_node_name(dir_name).map_err(|_| FsError::Forbidden)?;
if inner.resolve_path(&path_str).is_some() {
return Err(FsError::Exists);
}
@@ -843,6 +1163,7 @@ impl DavFileSystem for CarrierFs {
};
inner.manifest.inodes.insert(new_id, new_dir);
inner.index_add_child(parent.id, new_id);
inner.mark_dirty();
Ok(())
@@ -864,17 +1185,20 @@ impl DavFileSystem for CarrierFs {
return Err(FsError::Forbidden);
}
// Prüfe, ob das Verzeichnis leer ist
// Prüfe, ob das Verzeichnis leer ist (via Sekundärindex)
let has_children = inner
.manifest
.inodes
.values()
.any(|child| child.parent_id == Some(node.id));
.children_index
.get(&node.id)
.is_some_and(|children| !children.is_empty());
if has_children {
return Err(FsError::Forbidden);
}
if let Some(p_id) = node.parent_id {
inner.index_remove_child(p_id, node.id);
}
inner.children_index.remove(&node.id);
inner.manifest.inodes.remove(&node.id);
inner.mark_dirty();
@@ -904,6 +1228,9 @@ impl DavFileSystem for CarrierFs {
inner.manifest.free_blocks.push(b);
}
if let Some(p_id) = node.parent_id {
inner.index_remove_child(p_id, node.id);
}
inner.manifest.inodes.remove(&node.id);
inner.mark_dirty();
@@ -926,6 +1253,8 @@ impl DavFileSystem for CarrierFs {
return Err(FsError::Forbidden);
}
crate::pathutil::validate_node_name(to_name).map_err(|_| FsError::Forbidden)?;
let to_parent = inner
.resolve_path(to_parent_path)
.ok_or(FsError::NotFound)?;
@@ -948,6 +1277,10 @@ impl DavFileSystem for CarrierFs {
);
inner.manifest.free_blocks.push(b);
}
if let Some(p_id) = dest.parent_id {
inner.index_remove_child(p_id, dest.id);
}
inner.children_index.remove(&dest.id);
inner.manifest.inodes.remove(&dest.id);
}
@@ -956,8 +1289,15 @@ impl DavFileSystem for CarrierFs {
.map(|d| d.as_secs())
.unwrap_or(0);
let old_parent_id = node.parent_id;
let new_parent_id = to_parent.id;
if let Some(old_p) = old_parent_id {
inner.index_remove_child(old_p, node.id);
}
inner.index_add_child(new_parent_id, node.id);
if let Some(inode) = inner.manifest.inodes.get_mut(&node.id) {
inode.parent_id = Some(to_parent.id);
inode.parent_id = Some(new_parent_id);
inode.name = to_name.to_string();
inode.modified_at = now;
}
@@ -991,6 +1331,8 @@ impl DavFileSystem for CarrierFs {
return Err(FsError::Forbidden);
}
crate::pathutil::validate_node_name(to_name).map_err(|_| FsError::Forbidden)?;
let to_parent = inner
.resolve_path(to_parent_path)
.ok_or(FsError::NotFound)?;
@@ -1013,6 +1355,10 @@ impl DavFileSystem for CarrierFs {
);
inner.manifest.free_blocks.push(b);
}
if let Some(p_id) = dest.parent_id {
inner.index_remove_child(p_id, dest.id);
}
inner.children_index.remove(&dest.id);
inner.manifest.inodes.remove(&dest.id);
}
@@ -1091,6 +1437,7 @@ impl DavFileSystem for CarrierFs {
};
inner.manifest.inodes.insert(new_id, new_inode);
inner.index_add_child(to_parent.id, new_id);
inner.mark_dirty();
Ok(())
+6 -4
View File
@@ -25,7 +25,7 @@ use sanctum::verify::verify_container;
#[command(about = "Sanctum: Verschlüsselter Ein-Datei-Container unter Windows im reinen Userland", long_about = None)]
#[command(version)]
struct Cli {
/// Erlaubt Legacy-Namensentschlüsselung ohne deterministisches AAD (S-07)
/// SICHERHEITSHINWEIS: Unsicher! Deaktiviert die kryptografische Bindung (AAD) von Dateinamen (Gefahr von Swap-Attacks/Manipulation). Nur zur temporären Migration alter Container verwenden!
#[arg(long, global = true, default_value_t = false)]
legacy_names: bool,
@@ -577,7 +577,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 +625,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 +680,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!();
@@ -1591,6 +1591,8 @@ async fn run() -> Result<()> {
if cli.legacy_names {
set_allow_legacy_names(true);
eprintln!("WARNUNG: Das Flag '--legacy-names' ist aktiv. Die kryptografische Authentifizierung von Dateinamen ist geschwächt (Gefahr von Swap-Attacks). Verwenden Sie dieses Flag nur zur Datenmigration!");
tracing::warn!("Flag --legacy-names ist aktiv. Authenticated Associated Data (AAD) fuer Dateinamen ist abgeschwaecht.");
}
match cli.command {
+47 -8
View File
@@ -187,6 +187,38 @@ 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)
if version >= crate::crypto::FORMAT_VERSION_V3 {
match db.verify_metadata_mac_status_for_slot(vault_id, &dek) {
Ok(crate::storage::MetadataMacStatus::Valid) => {
if !stealth {
println!(
" • Metadaten-MAC: ✔ Integrität erfolgreich verifiziert (HMAC-SHA256)"
);
}
}
Ok(crate::storage::MetadataMacStatus::PendingRebuild) => {
if !stealth {
println!(
" • Metadaten-MAC: Metadaten-MAC nach Wiederherstellung neu aufgebaut"
);
}
db.set_active_slot_and_dek(vault_id, dek.clone());
db.update_metadata_mac()?;
db.checkpoint()?;
}
Ok(crate::storage::MetadataMacStatus::Invalid) => {
bail!(
"Metadaten-MAC-Verifikation fehlgeschlagen: Die Container-Metadaten wurden manipuliert oder sind beschädigt (K-01)."
);
}
Err(e) => {
bail!("Fehler bei der Metadaten-MAC-Verifikation: {e}");
}
}
}
db.set_active_slot_and_dek(vault_id, dek.clone());
// 128-Bit Session-Token für Loopback-Schutz (CWE-306) & Anti-CSRF generieren
let mut token_bytes = [0u8; 16];
rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut token_bytes);
@@ -423,17 +455,24 @@ pub async fn mount_container(
println!(" • System-Tray: Icon aktiv (Rechtsklick für Explorer/Trennen)");
}
if vault_id == 1 {
if let Some((len, cap, pct)) = fs_clone.carrier_manifest_usage() {
if len >= crate::carrier::CARRIER_MANIFEST_WARN_THRESHOLD {
if let Some(corrupted) = fs_clone.carrier_corrupted_pages() {
if corrupted > 0 {
println!(
" {} Warnung (C-04): Hidden-Vault Manifest zu {:.1}% gefüllt ({}/{} Bytes).",
ui::yellow("[!]"),
pct,
len,
cap
" {} Warnung (D-01): {} beschädigte Inode-Tabellenseite(n) im Hidden Vault übersprungen!",
ui::red("[!]"),
corrupted
);
}
}
if let Some((free_blocks, total_blocks)) = fs_clone.carrier_block_stats() {
if total_blocks > 0
&& (free_blocks < 20 || (free_blocks as f64 / total_blocks as f64) < 0.05)
{
println!(
" Empfohlene Obergrenze beachten: ca. 7.000 Dateien/Verzeichnisse im Hidden Vault."
" {} Warnung: Hidden Vault hat nur noch {} freie Blöcke (von {}).",
ui::yellow("[!]"),
free_blocks,
total_blocks
);
}
}
+3 -3
View File
@@ -1,7 +1,7 @@
//! Plattform-Abstraktionsschicht für Sanctum.
//!
//! Dieses Modul bündelt alle betriebssystemspezifischen Funktionen
//! (Speichersperren, Dateimanager-Aufrufe, Signal-Monitoring, Shell-Integration)
//! für Windows, Linux und macOS unter einer einheitlichen, speichersicheren Schnittstelle.
//! Re-Exportiert die plattformspezifischen Implementierungen (Speichersperren,
//! Prozessüberwachung, Session-Lock, Plattenplatzprüfung und Shell-Integration)
//! aus `crate::windows` (welches via `#[cfg]`-Gates Windows-, Linux- und POSIX-Code bereitstellt).
pub use crate::windows::*;
+28
View File
@@ -412,6 +412,11 @@ pub fn restore_slot_from_recovery_key(
db.restore_meta(&meta)
.context("Fehler beim Schreiben des rekonstruierten Headers")?;
// R-NEW-1: Metadaten-MAC für den wiederhergestellten Slot unmittelbar berechnen und persistieren
db.set_active_slot_and_dek(target_slot_id, dek.clone());
db.update_metadata_mac()
.context("Fehler beim Aktualisieren des Metadaten-MAC nach Header-Rekonstruktion")?;
db.checkpoint()
.context("Fehler beim WAL-Checkpoint nach Header-Rekonstruktion")?;
@@ -561,6 +566,16 @@ mod tests {
.expect("Unwrap restored DEK");
assert_eq!(*dek, *active_dek);
// R-NEW-1: Nach restore_header_backup muss der MAC-Status PendingRebuild sein
let mac_status = restored_db
.verify_metadata_mac_status_for_slot(0, &active_dek)
.expect("Verify MAC status");
assert_eq!(
mac_status,
crate::storage::MetadataMacStatus::PendingRebuild,
"Nach Header-Restore ohne Plaintext-DEK muss MAC-Status PendingRebuild sein"
);
let _ = fs::remove_file(&container_path);
let _ = fs::remove_file(&backup_path);
}
@@ -618,6 +633,19 @@ mod tests {
.expect("Unwrap rescued DEK");
assert_eq!(*dek, *unwrapped);
// R-NEW-1: Nach recovery key restore muss der MAC sofort gültig und persistent sein
let mac_status = rescued_db
.verify_metadata_mac_status_for_slot(0, &unwrapped)
.expect("Verify MAC status");
assert_eq!(
mac_status,
crate::storage::MetadataMacStatus::Valid,
"Nach Recovery-Key-Restore muss MAC sofort Valid sein"
);
assert!(rescued_db
.verify_metadata_mac_for_slot(0, &unwrapped)
.unwrap());
let _ = fs::remove_file(&container_path);
}
}
+113 -29
View File
@@ -208,6 +208,18 @@ fn current_timestamp() -> u64 {
.unwrap_or(0)
}
/// Status der Metadaten-MAC-Integritätsprüfung (K-01 / R-NEW-1).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MetadataMacStatus {
/// Metadaten-MAC ist vorhanden und stimmt mit den kanonischen Metadaten überein.
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.
PendingRebuild,
/// Metadaten-MAC fehlt (bei gen > 0) oder stimmt nicht mit den berechneten Daten überein (Manipulationsverdacht).
Invalid,
}
impl Database {
/// Öffnet oder erstellt die Container-Datenbank und initialisiert die Pragmas.
pub fn open<P: AsRef<Path>>(path: P) -> Result<Self> {
@@ -713,8 +725,8 @@ impl Database {
)?;
let c_id = conn.last_insert_rowid();
// Berechne Blockanzahl (min. 2 Blöcke: Block 0 für Manifest, Block 1+ für Nutzdaten)
let total_blocks = c_size.div_ceil(CHUNK_SIZE as u64).max(2) as u32;
// Berechne Blockanzahl (min. 3 Blöcke: Block 0/1 für Superblock, Block 2 für Inode-Seite)
let total_blocks = c_size.div_ceil(CHUNK_SIZE as u64).max(3) as u32;
// Slot 1 (Hidden Vault) einfügen
let params_json_1 = serde_json::to_string(h_params)?;
@@ -732,13 +744,67 @@ impl Database {
],
)?;
// Initialisiere CarrierManifest für redundante Blöcke 0 und 1 (C-02)
// Initialisiere Carrier-Format V2 (Superblock auf Block 0/1, Inode-Seite auf Block 2)
let manifest = crate::carrier::CarrierManifest::new(total_blocks);
let manifest_bytes = serde_json::to_vec(&manifest)?;
// 1. Block 0 schreiben (Primärkopie, Gen 0)
// 1. Block 2 schreiben: Erste Inode-Tabellenseite mit Wurzelverzeichnis '/'
let root_page = crate::carrier::CarrierInodePage {
magic: *crate::carrier::CARRIER_PAGE_MAGIC,
entries: manifest.inodes.values().cloned().collect(),
};
let root_page_bytes = serde_json::to_vec(&root_page)?;
let (inner_ct_2, inner_nonce_2, inner_tag_2) =
crate::crypto::encrypt_chunk(dek_1, c_id, 2, &root_page_bytes, FORMAT_VERSION, 0)?;
let inner_ct_len_2 = inner_ct_2.len() as u32;
let mut outer_plaintext_2 = vec![0u8; CHUNK_SIZE];
OsRng.fill_bytes(&mut outer_plaintext_2);
outer_plaintext_2[0..12].copy_from_slice(&inner_nonce_2);
outer_plaintext_2[12..28].copy_from_slice(&inner_tag_2);
outer_plaintext_2[28..32].copy_from_slice(&inner_ct_len_2.to_le_bytes());
let ct_end_2 = 32 + inner_ct_2.len();
if ct_end_2 > CHUNK_SIZE {
bail!("Inode-Page-Payload zu groß für Block 2");
}
outer_plaintext_2[32..ct_end_2].copy_from_slice(&inner_ct_2);
let (outer_ct_2, outer_nonce_2, outer_tag_2) = crate::crypto::encrypt_chunk(
dek_0,
c_id,
2,
&outer_plaintext_2,
FORMAT_VERSION,
0,
)?;
conn.execute(
"INSERT INTO chunks (node_id, chunk_index, generation, nonce, tag, ciphertext)
VALUES (?1, 2, 0, ?2, ?3, ?4)",
params![
c_id,
outer_nonce_2.as_slice(),
outer_tag_2.as_slice(),
outer_ct_2
],
)?;
// 2. Superblock (Block 0 & 1, redundante Zweitkopie C-02, Format V2)
let superblock = crate::carrier::CarrierSuperblock {
magic: *crate::carrier::CARRIER_MAGIC,
version: crate::carrier::CARRIER_VERSION_V2,
manifest_generation: 0,
total_blocks,
free_blocks: manifest.free_blocks.clone(),
next_inode_id: manifest.next_inode_id,
page_block_indices: vec![2],
};
let sb_bytes = serde_json::to_vec(&superblock)?;
// Block 0 schreiben (Primärkopie)
let (inner_ct_0, inner_nonce_0, inner_tag_0) =
crate::crypto::encrypt_chunk(dek_1, c_id, 0, &manifest_bytes, FORMAT_VERSION, 0)?;
crate::crypto::encrypt_chunk(dek_1, c_id, 0, &sb_bytes, FORMAT_VERSION, 0)?;
let inner_ct_len_0 = inner_ct_0.len() as u32;
let mut outer_plaintext_0 = vec![0u8; CHUNK_SIZE];
@@ -749,7 +815,7 @@ impl Database {
outer_plaintext_0[28..32].copy_from_slice(&inner_ct_len_0.to_le_bytes());
let ct_end_0 = 32 + inner_ct_0.len();
if ct_end_0 > CHUNK_SIZE {
bail!("Manifest-Payload zu groß für Block 0");
bail!("Superblock-Payload zu groß für Block 0");
}
outer_plaintext_0[32..ct_end_0].copy_from_slice(&inner_ct_0);
@@ -773,9 +839,9 @@ impl Database {
],
)?;
// 2. Block 1 schreiben (Redundante Zweitkopie, Gen 0, C-02)
// Block 1 schreiben (Redundante Zweitkopie, C-02)
let (inner_ct_1, inner_nonce_1, inner_tag_1) =
crate::crypto::encrypt_chunk(dek_1, c_id, 1, &manifest_bytes, FORMAT_VERSION, 0)?;
crate::crypto::encrypt_chunk(dek_1, c_id, 1, &sb_bytes, FORMAT_VERSION, 0)?;
let inner_ct_len_1 = inner_ct_1.len() as u32;
let mut outer_plaintext_1 = vec![0u8; CHUNK_SIZE];
@@ -786,7 +852,7 @@ impl Database {
outer_plaintext_1[28..32].copy_from_slice(&inner_ct_len_1.to_le_bytes());
let ct_end_1 = 32 + inner_ct_1.len();
if ct_end_1 > CHUNK_SIZE {
bail!("Manifest-Payload zu groß für Block 1");
bail!("Superblock-Payload zu groß für Block 1");
}
outer_plaintext_1[32..ct_end_1].copy_from_slice(&inner_ct_1);
@@ -810,7 +876,7 @@ impl Database {
],
)?;
// Blöcke 2..total_blocks-1 mit DEK_0 vorallokieren (C-02)
// 3. Blöcke 3..total_blocks-1 mit DEK_0 vorallokieren
let mut chunk_stmt = conn.prepare(
"INSERT INTO chunks (node_id, chunk_index, generation, nonce, tag, ciphertext)
VALUES (?1, ?2, 0, ?3, ?4, ?5)",
@@ -820,7 +886,7 @@ impl Database {
OsRng.fill_bytes(&mut dummy_noise);
conn.execute_batch("BEGIN TRANSACTION;")?;
for b in 2..total_blocks {
for b in 3..total_blocks {
let (ct, nonce, tag) =
crate::crypto::encrypt_chunk(dek_0, c_id, b, &dummy_noise, FORMAT_VERSION, 0)?;
chunk_stmt.execute(params![c_id, b, nonce.as_slice(), tag.as_slice(), ct,])?;
@@ -2262,6 +2328,15 @@ 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)
}
/// Prüft den detaillierten Integritätsstatus des Metadaten-MAC für einen spezifischen Slot (R-NEW-1).
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
.query_row(
@@ -2272,19 +2347,24 @@ impl Database {
.optional()?;
let Some((version, mac_opt, gen)) = meta_row else {
return Ok(false);
return Ok(MetadataMacStatus::Invalid);
};
if version < FORMAT_VERSION_V3 {
return Ok(true);
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);
}
let Some(mac_bytes) = mac_opt else {
return Ok(false);
return Ok(MetadataMacStatus::Invalid);
};
if mac_bytes.len() != 32 {
return Ok(false);
return Ok(MetadataMacStatus::Invalid);
}
let mut expected_mac = [0u8; 32];
@@ -2293,12 +2373,11 @@ impl Database {
let canonical = self.canonical_nodes_bytes_for_vault(slot_id)?;
let mac_key = derive_metadata_mac_key(dek);
Ok(verify_metadata_mac(
&mac_key,
gen,
&canonical,
&expected_mac,
))
if verify_metadata_mac(&mac_key, gen, &canonical, &expected_mac) {
Ok(MetadataMacStatus::Valid)
} else {
Ok(MetadataMacStatus::Invalid)
}
}
/// Führt ein Upgrade des Containerformats auf Format V3 durch (Format V3 / K-01 & K-02).
@@ -2474,7 +2553,12 @@ impl Database {
kdf_params TEXT NOT NULL,
wrapped_dek BLOB NOT NULL,
header_nonce BLOB NOT NULL,
header_tag BLOB NOT NULL
header_tag BLOB NOT NULL,
metadata_mac BLOB,
metadata_gen INTEGER NOT NULL DEFAULT 0,
lock_pid INTEGER,
lock_host TEXT,
lock_time INTEGER
);",
)?;
@@ -2488,8 +2572,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)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
"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)",
params![
slot.slot_id,
MAGIC_BYTES.as_slice(),
@@ -2505,8 +2589,8 @@ impl Database {
} 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)
VALUES (0, ?1, ?2, ?3, ?4, ?5, ?6, ?7)",
"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)",
params![
MAGIC_BYTES.as_slice(),
meta.version,
@@ -2524,8 +2608,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)
VALUES (1, ?1, ?2, ?3, ?4, ?5, ?6, ?7)",
"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)",
params![
MAGIC_BYTES.as_slice(),
FORMAT_VERSION,
+15
View File
@@ -799,6 +799,18 @@ impl SanctumFs {
self.carrier_fs.as_ref().map(|cfs| cfs.manifest_usage())
}
pub fn carrier_corrupted_pages(&self) -> Option<usize> {
self.carrier_fs.as_ref().map(|cfs| cfs.corrupted_pages())
}
pub fn carrier_block_stats(&self) -> Option<(usize, u32)> {
self.carrier_fs.as_ref().map(|cfs| cfs.block_stats())
}
pub fn carrier_page_count(&self) -> Option<usize> {
self.carrier_fs.as_ref().map(|cfs| cfs.page_count())
}
pub fn leak_count(&self) -> u64 {
self.leak_counter().load(Ordering::Relaxed)
}
@@ -1233,6 +1245,9 @@ impl DavFileSystem for SanctumFs {
return Err(FsError::Forbidden);
}
validate_path_safety(to_name)?;
crate::pathutil::validate_node_name(to_name).map_err(|_| FsError::Forbidden)?;
let to_parent = self
.resolve_path(to_parent_path)?
.ok_or(FsError::NotFound)?;
+26 -1
View File
@@ -890,7 +890,32 @@ pub fn get_available_disk_space<P: AsRef<std::path::Path>>(path: P) -> Option<u6
None
}
}
#[cfg(not(windows))]
#[cfg(unix)]
{
use std::ffi::CString;
use std::os::unix::ffi::OsStrExt;
let p = path.as_ref();
let dir = if p.is_dir() {
p.to_path_buf()
} else {
p.parent()
.map(|parent| parent.to_path_buf())
.unwrap_or_else(|| p.to_path_buf())
};
let Ok(c_path) = CString::new(dir.as_os_str().as_bytes()) else {
return None;
};
unsafe {
let mut stat: libc::statvfs = std::mem::zeroed();
if libc::statvfs(c_path.as_ptr(), &mut stat) == 0 {
let free = (stat.f_bavail as u64).saturating_mul(stat.f_frsize as u64);
Some(free)
} else {
None
}
}
}
#[cfg(not(any(windows, unix)))]
{
let _ = path;
Some(1024 * 1024 * 1024 * 1024)
+18 -11
View File
@@ -1436,21 +1436,28 @@ async fn test_c04_manifest_capacity_limit_and_warning() {
}
}
// sync_manifest() MUSS jetzt mit Fehler abbrechen
let err = cfs_loaded.sync_manifest().unwrap_err();
let err_str = err.to_string();
// In Format V2 (v0.9.0): Die 1-MB-Grenze bricht NICHT mehr ab, sondern allokiert eine zusätzliche Seite
cfs_loaded
.sync_manifest()
.expect("sync_manifest with > 1 MB must succeed in V2 via paged manifest");
assert!(
err_str.contains("Carrier-Manifest überschreitet die maximale Blockgröße"),
"Error message must specify carrier capacity limit: {}",
err_str
);
assert!(
err_str.contains("7.000 Dateien/Verzeichnisse"),
"Error message must recommend file limit: {}",
err_str
cfs_loaded.page_count() >= 2,
"Page count must expand to at least 2 pages"
);
// Reloaden und prüfen, ob beide Seiten geladen werden
drop(cfs_loaded);
let cfs_paged = sanctum::carrier::CarrierFs::load(
db.clone(),
carrier_node_id,
std::sync::Arc::new(auth_hidden.carrier_dek().unwrap().clone()),
std::sync::Arc::new(auth_hidden.dek().clone()),
auth_hidden.version(),
true,
)
.expect("CarrierFs load paged manifest");
assert!(cfs_paged.page_count() >= 2);
drop(cfs_paged);
drop(db);
let _ = std::fs::remove_file(&path);
}
+686
View File
@@ -0,0 +1,686 @@
use std::collections::HashMap;
use std::path::PathBuf;
use std::time::Instant;
use bytes::Bytes;
use dav_server::davpath::DavPath;
use dav_server::fs::{DavFileSystem, OpenOptions};
use rand::rngs::OsRng;
use rand::RngCore;
use sanctum::carrier::{
write_carrier_block, CarrierFs, CarrierFsInner, CarrierInode, CarrierManifest, CARRIER_MAGIC,
CARRIER_VERSION, CARRIER_VERSION_V2,
};
use sanctum::crypto::{
derive_kek, generate_dek, generate_salt, wrap_slot0_payload, wrap_slot1_payload, KdfParams,
MIN_MEMORY_COST_KIB, MIN_TIME_COST,
};
use sanctum::storage::{Database, UnlockedKeys};
fn temp_db_path(prefix: &str) -> PathBuf {
let mut path = std::env::temp_dir();
let id: u64 = OsRng.next_u64();
path.push(format!("sanctum_test_v2_{}_{}.sanctum", prefix, id));
path
}
struct TestEnv {
pub path: PathBuf,
pub db: Database,
pub carrier_node_id: i64,
pub auth_hidden: UnlockedKeys,
}
impl Drop for TestEnv {
fn drop(&mut self) {
let _ = std::fs::remove_file(&self.path);
}
}
fn setup_carrier_env(prefix: &str, size_mb: usize) -> TestEnv {
let path = temp_db_path(prefix);
let carrier_size_bytes = (size_mb * 1024 * 1024) as u64;
let carrier_name = "carrier_v2.dat";
let pass_decoy = "DecoyPassword2026!";
let pass_hidden = "SuperSecretHiddenPassword2026!";
let kdf_params = KdfParams {
memory_cost: MIN_MEMORY_COST_KIB,
time_cost: MIN_TIME_COST,
parallelism: 1,
};
let salt_0 = generate_salt();
let kek_0 = derive_kek(pass_decoy, &salt_0, &kdf_params).unwrap();
let dek_0 = generate_dek();
let salt_1 = generate_salt();
let kek_1 = derive_kek(pass_hidden, &salt_1, &kdf_params).unwrap();
let dek_1 = generate_dek();
let carrier_node_id = 3i64;
let (wrapped_0, nonce_0, tag_0) = wrap_slot0_payload(&kek_0, &dek_0, carrier_node_id).unwrap();
let (wrapped_1, nonce_1, tag_1) =
wrap_slot1_payload(&kek_1, &dek_1, &dek_0, carrier_node_id).unwrap();
let db = Database::open(&path).expect("Open database");
db.init_schema_with_carrier(
&salt_0,
&kdf_params,
&wrapped_0,
&nonce_0,
&tag_0,
Some((
carrier_name,
carrier_size_bytes,
&salt_1,
&kdf_params,
&wrapped_1,
&nonce_1,
&tag_1,
&dek_0,
&dek_1,
)),
)
.expect("Init carrier schema");
db.checkpoint().unwrap();
let meta = db.read_meta().unwrap();
let auth_hidden = meta.authenticate(pass_hidden).expect("Auth hidden");
TestEnv {
path,
db,
carrier_node_id,
auth_hidden,
}
}
fn load_carrier_fs(env: &TestEnv) -> CarrierFs {
CarrierFs::load(
env.db.clone(),
env.carrier_node_id,
std::sync::Arc::new(env.auth_hidden.carrier_dek().unwrap().clone()),
std::sync::Arc::new(env.auth_hidden.dek().clone()),
env.auth_hidden.version(),
true,
)
.expect("Load CarrierFs")
}
/// 1. Kapazitätstest: >10.000 Inodes künstlich erzeugen, über mehrere Seiten speichern und neu laden
#[tokio::test]
async fn test_capacity_large_inode_table() {
let env = setup_carrier_env("capacity_large", 25);
let cfs = load_carrier_fs(&env);
// Initial hat ein frischer Container 1 Seite (Block 2)
assert_eq!(cfs.page_count(), 1);
// 10.000 Inodes direkt in den Speicher einfügen
{
let mut inner = cfs.inner.lock().unwrap();
for i in 0..10_000 {
let id = inner.manifest.next_inode_id;
inner.manifest.next_inode_id += 1;
let inode = CarrierInode {
id,
parent_id: Some(1),
name: format!("file_{:05}.txt", i),
is_dir: false,
size: i % 500,
created_at: 1_700_000_000 + i,
modified_at: 1_700_000_000 + i,
blocks: Vec::new(),
};
inner.manifest.inodes.insert(id, inode);
inner.index_add_child(1, id);
}
inner.mark_dirty();
}
// Speichern erzwingen
cfs.sync_manifest().expect("Sync 10.000 inodes");
// In V2 müssen die 10.000 Inodes auf mindestens 2 Seiten aufgeteilt worden sein
let pages_after_save = cfs.page_count();
assert!(
pages_after_save >= 2,
"10.000 Inodes müssen auf mindestens 2 Seiten aufgeteilt werden, tatsächlich: {}",
pages_after_save
);
drop(cfs);
// Neu aus Trägerdatei laden
let cfs_reloaded = load_carrier_fs(&env);
assert_eq!(cfs_reloaded.page_count(), pages_after_save);
assert_eq!(cfs_reloaded.corrupted_pages(), 0);
// Prüfe, dass beliebige Dateien korrekt existieren und Pfade auflösen
let test_file_1 = cfs_reloaded.resolve_path("/file_00000.txt");
assert!(test_file_1.is_some());
assert_eq!(test_file_1.unwrap().name, "file_00000.txt");
let test_file_mid = cfs_reloaded.resolve_path("/file_04999.txt");
assert!(test_file_mid.is_some());
assert_eq!(test_file_mid.unwrap().name, "file_04999.txt");
let test_file_end = cfs_reloaded.resolve_path("/file_09999.txt");
assert!(test_file_end.is_some());
assert_eq!(test_file_end.unwrap().name, "file_09999.txt");
let nonexistent = cfs_reloaded.resolve_path("/file_10000.txt");
assert!(nonexistent.is_none());
}
/// 2. Migrationstest: Einen V1-Container laden, modifizieren, als V2 speichern und verifizieren
#[tokio::test]
async fn test_v1_to_v2_migration() {
let env = setup_carrier_env("migration_v1_v2", 10);
// Einen V1-Container konstruieren: Block 0 und Block 1 halten CarrierManifest mit version = 1
let mut v1_manifest = CarrierManifest {
magic: *CARRIER_MAGIC,
version: CARRIER_VERSION, // 1
manifest_generation: 1,
total_blocks: 10,
free_blocks: (2..10).collect(),
next_inode_id: 3,
page_block_indices: Vec::new(),
inodes: HashMap::new(),
};
v1_manifest.inodes.insert(
1,
CarrierInode {
id: 1,
parent_id: None,
name: String::new(),
is_dir: true,
size: 0,
created_at: 1000,
modified_at: 1000,
blocks: Vec::new(),
},
);
v1_manifest.inodes.insert(
2,
CarrierInode {
id: 2,
parent_id: Some(1),
name: "original_v1.txt".to_string(),
is_dir: false,
size: 128,
created_at: 1000,
modified_at: 1000,
blocks: Vec::new(),
},
);
let v1_bytes = serde_json::to_vec(&v1_manifest).unwrap();
let dek_outer = env.auth_hidden.carrier_dek().unwrap();
let dek_inner = env.auth_hidden.dek();
write_carrier_block(
&env.db,
env.carrier_node_id,
0,
&dek_outer,
&dek_inner,
&v1_bytes,
env.auth_hidden.version(),
)
.unwrap();
write_carrier_block(
&env.db,
env.carrier_node_id,
1,
&dek_outer,
&dek_inner,
&v1_bytes,
env.auth_hidden.version(),
)
.unwrap();
// 1. Unter v0.9.0 laden: V1-Format wird transparent erkannt
let cfs = load_carrier_fs(&env);
{
let inner = cfs.inner.lock().unwrap();
assert_eq!(inner.manifest.version, CARRIER_VERSION);
}
let orig = cfs.resolve_path("/original_v1.txt");
assert!(orig.is_some());
assert_eq!(orig.unwrap().id, 2);
// 2. Änderung vornehmen: Datei hinzufügen (löst Dirty-Tracking aus)
let new_file_path = DavPath::new("/new_v2.txt").unwrap();
let mut file = cfs
.open(
&new_file_path,
OpenOptions {
create_new: true,
write: true,
..Default::default()
},
)
.await
.expect("Create new file in migrated container");
file.write_bytes(Bytes::from_static(b"migrated to v2"))
.await
.unwrap();
file.flush().await.unwrap();
drop(file);
// Explizit synchronisieren
cfs.sync_manifest().expect("Sync manifest during migration");
drop(cfs);
// 3. Neu laden: Jetzt muss das Dateisystem im Format V2 vorliegen
let cfs_reloaded = load_carrier_fs(&env);
{
let inner = cfs_reloaded.inner.lock().unwrap();
assert_eq!(inner.manifest.version, CARRIER_VERSION_V2);
assert!(!inner.manifest.page_block_indices.is_empty());
}
// Beide Dateien müssen vorhanden sein
assert!(cfs_reloaded.resolve_path("/original_v1.txt").is_some());
assert!(cfs_reloaded.resolve_path("/new_v2.txt").is_some());
}
/// 3. Teilausfalltest (D-01 Fail-Soft): Beschädigung einer einzelnen Inode-Seite führt nicht zum Totalverlust
#[tokio::test]
async fn test_partial_page_corruption_resilience() {
let env = setup_carrier_env("partial_corruption", 20);
let cfs = load_carrier_fs(&env);
// Genügend Inodes erzeugen, um 2 Seiten zu belegen
{
let mut inner = cfs.inner.lock().unwrap();
for i in 0..1500 {
let id = inner.manifest.next_inode_id;
inner.manifest.next_inode_id += 1;
let name = if i < 750 {
format!("alpha_{:04}_{}", i, "a".repeat(800))
} else {
format!("omega_{:04}_{}", i, "z".repeat(800))
};
let inode = CarrierInode {
id,
parent_id: Some(1),
name,
is_dir: false,
size: 10,
created_at: 2000,
modified_at: 2000,
blocks: Vec::new(),
};
inner.manifest.inodes.insert(id, inode);
inner.index_add_child(1, id);
}
inner.mark_dirty();
}
cfs.sync_manifest().expect("Sync 2 pages");
let page_blocks = {
let inner = cfs.inner.lock().unwrap();
inner.manifest.page_block_indices.clone()
};
assert!(
page_blocks.len() >= 2,
"Benötigt mindestens 2 Seiten, hat: {}",
page_blocks.len()
);
let second_page_block = page_blocks[1];
drop(cfs);
// 2. Gezielte Beschädigung: Zweite Seite in SQLite mit Rauschen überschreiben
{
let conn = rusqlite::Connection::open(&env.path).unwrap();
let mut noise = vec![0u8; 500];
OsRng.fill_bytes(&mut noise);
conn.execute(
"UPDATE chunks SET ciphertext = ?1 WHERE node_id = ?2 AND chunk_index = ?3",
rusqlite::params![noise, env.carrier_node_id, second_page_block],
)
.unwrap();
}
// 3. Neu laden: Darf NICHT abbrechen (Fail-Soft), sondern überspringt die defekte Seite
let cfs_reloaded = load_carrier_fs(&env);
assert_eq!(
cfs_reloaded.corrupted_pages(),
1,
"Muss genau 1 beschädigte Seite protokollieren"
);
// Wurzelverzeichnis '/' muss intakt sein
assert!(cfs_reloaded.resolve_path("/").is_some());
// Einträge der ersten, unbeschädigten Seite müssen weiterhin vollständig auffindbar sein
let alpha_0 = cfs_reloaded.resolve_path(&format!("/alpha_0000_{}", "a".repeat(800)));
assert!(
alpha_0.is_some(),
"Dateien aus der intakten Seite 0 müssen lesbar bleiben"
);
}
/// 4. Index-Konsistenztest (D-02): Operationsfolge gegen frisch aufgebauten Referenzindex abgleichen
#[tokio::test]
async fn test_children_index_consistency() {
let env = setup_carrier_env("children_consistency", 10);
let cfs = load_carrier_fs(&env);
// 1. Verzeichnisse anlegen
cfs.create_dir(&DavPath::new("/docs").unwrap())
.await
.unwrap();
cfs.create_dir(&DavPath::new("/docs/work").unwrap())
.await
.unwrap();
cfs.create_dir(&DavPath::new("/photos").unwrap())
.await
.unwrap();
// 2. Dateien anlegen
let opt = OpenOptions {
create_new: true,
write: true,
..Default::default()
};
let mut f1 = cfs
.open(&DavPath::new("/docs/report.txt").unwrap(), opt.clone())
.await
.unwrap();
f1.write_bytes(Bytes::from_static(b"report")).await.unwrap();
f1.flush().await.unwrap();
drop(f1);
let mut f2 = cfs
.open(&DavPath::new("/docs/work/notes.txt").unwrap(), opt.clone())
.await
.unwrap();
f2.write_bytes(Bytes::from_static(b"notes")).await.unwrap();
f2.flush().await.unwrap();
drop(f2);
let mut f3 = cfs
.open(&DavPath::new("/photos/pic.jpg").unwrap(), opt.clone())
.await
.unwrap();
f3.write_bytes(Bytes::from_static(b"pic")).await.unwrap();
f3.flush().await.unwrap();
drop(f3);
// 3. Verschieben/Umbenennen
cfs.rename(
&DavPath::new("/docs/work/notes.txt").unwrap(),
&DavPath::new("/photos/notes_moved.txt").unwrap(),
)
.await
.unwrap();
// 4. Datei löschen
cfs.remove_file(&DavPath::new("/docs/report.txt").unwrap())
.await
.unwrap();
// 5. Verzeichnis löschen
cfs.remove_dir(&DavPath::new("/docs/work").unwrap())
.await
.unwrap();
// 6. Abgleich: children_index gegen frisch aufgebauten Referenzindex prüfen
{
let inner = cfs.inner.lock().unwrap();
let reference_index = CarrierFsInner::build_children_index(&inner.manifest.inodes);
for (parent_id, ref_children) in &reference_index {
let actual_children = inner.children_index.get(parent_id);
assert!(
actual_children.is_some(),
"Parent {} fehlt im children_index",
parent_id
);
let mut ref_sorted = ref_children.clone();
ref_sorted.sort();
let mut act_sorted = actual_children.unwrap().clone();
act_sorted.sort();
assert_eq!(
ref_sorted, act_sorted,
"Inkonsistenz der Kinder für Parent {}",
parent_id
);
}
// Auch die Umkehrung prüfen (keine Geister-Einträge)
for (parent_id, actual_children) in &inner.children_index {
if actual_children.is_empty() {
continue;
}
assert!(
reference_index.contains_key(parent_id),
"children_index enthält verwaisten Parent {}",
parent_id
);
}
}
}
/// 5. Performance-Sanity-Check: Pfadauflösung bei 5.000 Geschwistern muss sub-millisekündlich sein (D-02)
#[tokio::test]
async fn test_secondary_index_path_resolution_speed() {
let env = setup_carrier_env("perf_resolution", 15);
let cfs = load_carrier_fs(&env);
cfs.create_dir(&DavPath::new("/big_dir").unwrap())
.await
.unwrap();
let big_dir_node = cfs.resolve_path("/big_dir").expect("big_dir node");
// 5.000 Kindknoten in /big_dir anlegen
{
let mut inner = cfs.inner.lock().unwrap();
for i in 0..5_000 {
let id = inner.manifest.next_inode_id;
inner.manifest.next_inode_id += 1;
let inode = CarrierInode {
id,
parent_id: Some(big_dir_node.id),
name: format!("child_{:04}.txt", i),
is_dir: false,
size: 10,
created_at: 3000,
modified_at: 3000,
blocks: Vec::new(),
};
inner.manifest.inodes.insert(id, inode);
inner.index_add_child(big_dir_node.id, id);
}
inner.mark_dirty();
}
// Messung: Pfadauflösung für den letzten Eintrag
let start = Instant::now();
let target = cfs.resolve_path("/big_dir/child_4999.txt");
let elapsed = start.elapsed();
assert!(target.is_some());
assert_eq!(target.unwrap().name, "child_4999.txt");
// Der Sekundärindex muss den Eintrag in unter 50 Millisekunden finden (typisch < 0.2 ms, O(1) statt O(N))
assert!(
elapsed.as_millis() < 50,
"Pfadauflösung dauerte zu lange: {:?}",
elapsed
);
}
/// 6. Platzmangel-Migrationstest: Migration eines Containers ohne freie Blöcke bricht sauber ab (D-03)
#[tokio::test]
async fn test_out_of_space_migration_aborts_cleanly() {
let env = setup_carrier_env("migration_no_space", 5);
// V1-Manifest mit 0 freien Blöcken (free_blocks ist leer)
let mut v1_manifest = CarrierManifest {
magic: *CARRIER_MAGIC,
version: CARRIER_VERSION,
manifest_generation: 1,
total_blocks: 2,
free_blocks: Vec::new(), // Keine freien Blöcke!
next_inode_id: 2,
page_block_indices: Vec::new(),
inodes: HashMap::new(),
};
v1_manifest.inodes.insert(
1,
CarrierInode {
id: 1,
parent_id: None,
name: String::new(),
is_dir: true,
size: 0,
created_at: 1000,
modified_at: 1000,
blocks: Vec::new(),
},
);
let v1_bytes = serde_json::to_vec(&v1_manifest).unwrap();
let dek_outer = env.auth_hidden.carrier_dek().unwrap();
let dek_inner = env.auth_hidden.dek();
write_carrier_block(
&env.db,
env.carrier_node_id,
0,
&dek_outer,
&dek_inner,
&v1_bytes,
env.auth_hidden.version(),
)
.unwrap();
write_carrier_block(
&env.db,
env.carrier_node_id,
1,
&dek_outer,
&dek_inner,
&v1_bytes,
env.auth_hidden.version(),
)
.unwrap();
let cfs = load_carrier_fs(&env);
// Versuch zu speichern muss mit klarer Fehlermeldung fehlschlagen
let res = {
let mut inner = cfs.inner.lock().unwrap();
inner.mark_dirty();
inner.save_manifest()
};
assert!(res.is_err());
let err_msg = res.unwrap_err().to_string();
assert!(
err_msg.contains("Nicht genügend freie Blöcke"),
"Fehlermeldung muss auf Speichermangel hinweisen: {}",
err_msg
);
drop(cfs);
// Trägerdatei muss weiterhin intakt als V1 ladbar sein
let cfs_recheck = load_carrier_fs(&env);
{
let inner = cfs_recheck.inner.lock().unwrap();
assert_eq!(inner.manifest.version, CARRIER_VERSION);
}
}
/// 7. Seitenfreigabe (D-05): Überzählige Seitenblöcke werden bei Schrumpfung geschreddert und freigegeben
#[tokio::test]
async fn test_page_block_reclaiming_on_shrink() {
let env = setup_carrier_env("page_reclaiming", 25);
let cfs = load_carrier_fs(&env);
let initial_free = {
let inner = cfs.inner.lock().unwrap();
inner.manifest.free_blocks.len()
};
// 1. Viele Inodes erzeugen, sodass mindestens 3 Seiten belegt werden
let mut added_ids = Vec::new();
{
let mut inner = cfs.inner.lock().unwrap();
for i in 0..2500 {
let id = inner.manifest.next_inode_id;
inner.manifest.next_inode_id += 1;
added_ids.push(id);
let inode = CarrierInode {
id,
parent_id: Some(1),
name: format!("large_item_{:04}_{}", i, "x".repeat(800)),
is_dir: false,
size: 10,
created_at: 4000,
modified_at: 4000,
blocks: Vec::new(),
};
inner.manifest.inodes.insert(id, inode);
inner.index_add_child(1, id);
}
inner.mark_dirty();
}
cfs.sync_manifest().expect("Sync 3 pages");
let pages_before = cfs.page_count();
assert!(
pages_before >= 3,
"Muss mindestens 3 Seiten belegen, hat: {}",
pages_before
);
let free_after_expand = {
let inner = cfs.inner.lock().unwrap();
inner.manifest.free_blocks.len()
};
assert_eq!(free_after_expand, initial_free - (pages_before - 1));
// 2. Fast alle Inodes entfernen, sodass nur noch 1 Seite benötigt wird
{
let mut inner = cfs.inner.lock().unwrap();
for id in added_ids {
inner.manifest.inodes.remove(&id);
inner.index_remove_child(1, id);
}
inner.mark_dirty();
}
cfs.sync_manifest().expect("Sync after shrink");
// 3. Verifizieren: page_count ist wieder 1, freie Blöcke wurden zurückgegeben
let pages_after = cfs.page_count();
assert_eq!(
pages_after, 1,
"Nach Schrumpfung darf nur noch 1 Seite belegt sein"
);
let free_after_shrink = {
let inner = cfs.inner.lock().unwrap();
inner.manifest.free_blocks.len()
};
assert_eq!(
free_after_shrink, initial_free,
"Alle überzähligen Seitenblöcke müssen an free_blocks zurückgegeben worden sein"
);
drop(cfs);
// Trägerdatei neu laden und Integrität prüfen
let cfs_reloaded = load_carrier_fs(&env);
assert_eq!(cfs_reloaded.page_count(), 1);
assert_eq!(cfs_reloaded.corrupted_pages(), 0);
assert!(cfs_reloaded.resolve_path("/").is_some());
}
+371
View File
@@ -309,3 +309,374 @@ async fn test_z04_webdav_quota_report() {
drop(db);
let _ = std::fs::remove_file(&container_path);
}
#[tokio::test]
async fn test_mount_rejects_tampered_metadata_mac() {
let temp_dir = std::env::temp_dir();
let container_path: PathBuf = temp_dir.join(format!(
"test_mount_tampered_mac_{}.sanctum",
std::process::id()
));
if container_path.exists() {
let _ = std::fs::remove_file(&container_path);
}
let password = "TestTamperedMacPassword2026!";
let salt = generate_salt();
let kdf_params = KdfParams {
memory_cost: MIN_MEMORY_COST_KIB,
time_cost: MIN_TIME_COST,
parallelism: 1,
};
let kek = derive_kek(password, &salt, &kdf_params).unwrap();
let dek = generate_dek();
let (wrapped_dek, nonce, tag) = wrap_dek(&kek, &dek).unwrap();
let db = Database::open(&container_path).unwrap();
db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag)
.unwrap();
db.set_active_slot_and_dek(0, dek.clone());
// Erstelle einen Testknoten
let node = db.create_node(1, "original.txt", false).unwrap();
db.update_metadata_mac().unwrap();
// Vor Manipulation: verify_metadata_mac muss erfolgreich sein
assert!(db.verify_metadata_mac_for_slot(0, &dek).unwrap());
// Manipuliere SQLite-Metadaten direkt (Böswilliger Angreifer)
{
let conn = rusqlite::Connection::open(&container_path).unwrap();
conn.execute(
"UPDATE nodes SET name = 'hacked.exe' WHERE id = ?1",
[node.id],
)
.unwrap();
}
// Nach Manipulation: Direkte Verifikation meldet 'false' (Integritätsbruch)
assert!(!db.verify_metadata_mac_for_slot(0, &dek).unwrap());
drop(db);
// Aufruf von mount_container muss fail-closed abbrechen
let auth =
sanctum::mount::ContainerAuth::Password(zeroize::Zeroizing::new(password.to_string()));
let res = sanctum::mount::mount_container(
&container_path,
'Z',
None,
Some(18943),
auth,
false,
false,
None,
false,
false,
None,
true,
)
.await;
assert!(
res.is_err(),
"Mount mit manipulierten Metadaten muss fehlschlagen!"
);
let err_msg = res.unwrap_err().to_string();
assert!(
err_msg.contains("Metadaten-MAC-Verifikation fehlgeschlagen") || err_msg.contains("K-01"),
"Erwartete Fehlermeldung zu K-01 Metadaten-MAC, erhalten: {}",
err_msg
);
let _ = std::fs::remove_file(&container_path);
}
#[tokio::test]
async fn test_mount_succeeds_and_rebuilds_mac_after_header_file_restore() {
use sanctum::recovery::{export_header_backup, restore_header_backup};
use sanctum::storage::MetadataMacStatus;
let temp_dir = std::env::temp_dir();
let id = std::process::id();
let container_path: PathBuf = temp_dir.join(format!("test_hdr_restore_mount_{}.sanctum", id));
let backup_path: PathBuf = temp_dir.join(format!("test_hdr_restore_mount_{}.sanctum.hdr", id));
if container_path.exists() {
let _ = std::fs::remove_file(&container_path);
}
if backup_path.exists() {
let _ = std::fs::remove_file(&backup_path);
}
let password = "RestoreHeaderPassword2026!";
let salt = generate_salt();
let kdf_params = KdfParams {
memory_cost: MIN_MEMORY_COST_KIB,
time_cost: MIN_TIME_COST,
parallelism: 1,
};
let kek = derive_kek(password, &salt, &kdf_params).unwrap();
let dek = generate_dek();
let (wrapped_dek, nonce, tag) = wrap_dek(&kek, &dek).unwrap();
let db = Database::open(&container_path).unwrap();
db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag)
.unwrap();
db.set_active_slot_and_dek(0, dek.clone());
let _ = db.create_node(1, "important_doc.pdf", false).unwrap();
db.update_metadata_mac().unwrap();
db.checkpoint().unwrap();
drop(db);
// 1. Header-Backup exportieren
export_header_backup(&container_path, &backup_path).expect("Export header");
// 2. Header mutwillig zerstören
{
let conn = rusqlite::Connection::open(&container_path).unwrap();
conn.execute("DELETE FROM meta", []).unwrap();
}
// 3. Header aus Backup wiederherstellen
restore_header_backup(&container_path, &backup_path).expect("Restore header");
// 4. Status vor dem Mount prüfen: muss PendingRebuild sein
{
let check_db = Database::open(&container_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"
);
}
// 5. Echter Mount-Aufruf muss gelingen und den MAC transparent neu aufbauen
let auth =
sanctum::mount::ContainerAuth::Password(zeroize::Zeroizing::new(password.to_string()));
let c_path = container_path.clone();
let mount_task = tokio::spawn(async move {
sanctum::mount::mount_container(
&c_path,
'Y',
None,
Some(19482),
auth,
false,
false,
None,
false,
false,
None,
true,
)
.await
});
// Kurz warten, bis mount_container die Vorabprüfung & den MAC-Rebuild vollzogen hat
tokio::time::sleep(tokio::time::Duration::from_millis(250)).await;
mount_task.abort();
// 6. Nach dem Mount: MAC muss nun Valid und persistent gespeichert sein!
let verified_db = Database::open(&container_path).unwrap();
let new_status = verified_db
.verify_metadata_mac_status_for_slot(0, &dek)
.unwrap();
assert_eq!(
new_status,
MetadataMacStatus::Valid,
"Nach Mount muss der MAC erfolgreich neu aufgebaut und Valid sein"
);
assert!(verified_db.verify_metadata_mac_for_slot(0, &dek).unwrap());
let _ = std::fs::remove_file(&container_path);
let _ = std::fs::remove_file(&backup_path);
}
#[tokio::test]
async fn test_mount_succeeds_after_recovery_key_restore_slot0() {
use sanctum::crypto::dek_to_mnemonic;
use sanctum::recovery::restore_header_from_recovery_key;
use sanctum::storage::MetadataMacStatus;
let temp_dir = std::env::temp_dir();
let id = std::process::id();
let container_path: PathBuf = temp_dir.join(format!("test_rec_mount_slot0_{}.sanctum", id));
if container_path.exists() {
let _ = std::fs::remove_file(&container_path);
}
let old_password = "ForgottenOldPassword2026!";
let new_password = "RescuedNewPassword2026!";
let salt = generate_salt();
let kdf_params = KdfParams {
memory_cost: MIN_MEMORY_COST_KIB,
time_cost: MIN_TIME_COST,
parallelism: 1,
};
let kek = derive_kek(old_password, &salt, &kdf_params).unwrap();
let dek = generate_dek();
let (wrapped_dek, nonce, tag) = wrap_dek(&kek, &dek).unwrap();
let phrase = dek_to_mnemonic(&dek).unwrap();
let db = Database::open(&container_path).unwrap();
db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag)
.unwrap();
db.set_active_slot_and_dek(0, dek.clone());
let _ = db.create_node(1, "my_secrets.txt", false).unwrap();
db.update_metadata_mac().unwrap();
db.checkpoint().unwrap();
drop(db);
// Header zerstören
{
let conn = rusqlite::Connection::open(&container_path).unwrap();
conn.execute("DELETE FROM meta", []).unwrap();
}
// Mit 24-Wort Notfallschlüssel und neuem Passwort wiederherstellen
restore_header_from_recovery_key(&container_path, &phrase, new_password)
.expect("Restore from recovery key");
// R-NEW-1: Unmittelbar nach restore_from_recovery_key MUSS der MAC Valid sein!
let restored_db = Database::open(&container_path).unwrap();
let status = restored_db
.verify_metadata_mac_status_for_slot(0, &dek)
.unwrap();
assert_eq!(
status,
MetadataMacStatus::Valid,
"Nach restore_from_recovery_key muss MAC sofort Valid sein"
);
assert!(
restored_db.verify_metadata_mac_for_slot(0, &dek).unwrap(),
"verify_metadata_mac_for_slot muss direkt Ok(true) liefern (R-NEW-1 Fix)"
);
drop(restored_db);
// Mount mit neuem Passwort ausführen
let auth =
sanctum::mount::ContainerAuth::Password(zeroize::Zeroizing::new(new_password.to_string()));
let c_path = container_path.clone();
let mount_task = tokio::spawn(async move {
sanctum::mount::mount_container(
&c_path,
'Y',
None,
Some(19483),
auth,
false,
false,
None,
false,
false,
None,
true,
)
.await
});
tokio::time::sleep(tokio::time::Duration::from_millis(250)).await;
mount_task.abort();
let _ = std::fs::remove_file(&container_path);
}
#[tokio::test]
async fn test_mount_succeeds_after_recovery_key_restore_slot1() {
use rand::RngCore;
use sanctum::crypto::{dek_to_mnemonic, wrap_slot0_payload, wrap_slot1_payload};
use sanctum::recovery::restore_slot_from_recovery_key;
use sanctum::storage::MetadataMacStatus;
let temp_dir = std::env::temp_dir();
let id: u64 = rand::rngs::OsRng.next_u64();
let container_path: PathBuf = temp_dir.join(format!("test_rec_mount_slot1_{}.sanctum", id));
if container_path.exists() {
let _ = std::fs::remove_file(&container_path);
}
let pass_decoy = "DecoyPassword2026!";
let pass_hidden_old = "HiddenPasswordOld2026!";
let pass_hidden_new = "HiddenPasswordNew2026!";
let kdf_params = KdfParams {
memory_cost: MIN_MEMORY_COST_KIB,
time_cost: MIN_TIME_COST,
parallelism: 1,
};
let salt_0 = generate_salt();
let kek_0 = derive_kek(pass_decoy, &salt_0, &kdf_params).unwrap();
let dek_0 = generate_dek();
let salt_1 = generate_salt();
let kek_1 = derive_kek(pass_hidden_old, &salt_1, &kdf_params).unwrap();
let dek_1 = generate_dek();
let phrase_1 = dek_to_mnemonic(&dek_1).unwrap();
let carrier_node_id = 3i64;
let (wrapped_0, nonce_0, tag_0) = wrap_slot0_payload(&kek_0, &dek_0, carrier_node_id).unwrap();
let (wrapped_1, nonce_1, tag_1) =
wrap_slot1_payload(&kek_1, &dek_1, &dek_0, carrier_node_id).unwrap();
let db = Database::open(&container_path).unwrap();
db.init_schema_with_carrier(
&salt_0,
&kdf_params,
&wrapped_0,
&nonce_0,
&tag_0,
Some((
"carrier.dat",
10 * 1024 * 1024,
&salt_1,
&kdf_params,
&wrapped_1,
&nonce_1,
&tag_1,
&dek_0,
&dek_1,
)),
)
.unwrap();
db.set_active_slot_and_dek(1, dek_1.clone());
db.update_metadata_mac().unwrap();
db.checkpoint().unwrap();
drop(db);
// Slot 1 Header zerstören
{
let conn = rusqlite::Connection::open(&container_path).unwrap();
conn.execute("DELETE FROM meta WHERE slot_id = 1", [])
.unwrap();
}
// Slot 1 mit 24-Wort Schlüssel und neuem Passwort wiederherstellen (dek_0 vorhanden)
restore_slot_from_recovery_key(&container_path, &phrase_1, pass_hidden_new, 1, Some(&dek_0))
.expect("Restore slot 1 from recovery key");
// R-NEW-1: Unmittelbar nach restore_slot_from_recovery_key MUSS Slot 1 MAC Valid sein!
let restored_db = Database::open(&container_path).unwrap();
let status_1 = restored_db
.verify_metadata_mac_status_for_slot(1, &dek_1)
.unwrap();
assert_eq!(
status_1,
MetadataMacStatus::Valid,
"Nach restore_slot_from_recovery_key muss Slot 1 MAC Valid sein"
);
assert!(
restored_db.verify_metadata_mac_for_slot(1, &dek_1).unwrap(),
"verify_metadata_mac_for_slot(1) muss direkt Ok(true) liefern (R-NEW-1 Fix)"
);
drop(restored_db);
let _ = std::fs::remove_file(&container_path);
}
+197
View File
@@ -127,3 +127,200 @@ fn test_storage_create_and_rename_reject_invalid_names() {
let _ = std::fs::remove_file(&path);
}
#[tokio::test]
async fn test_carrier_fs_rejects_invalid_names() {
use dav_server::davpath::DavPath;
use dav_server::fs::{DavFileSystem, FsError, OpenOptions};
use rand::rngs::OsRng;
use rand::RngCore;
use sanctum::carrier::CarrierFs;
use sanctum::crypto::{
derive_kek, generate_dek, generate_salt, wrap_slot0_payload, wrap_slot1_payload, KdfParams,
MIN_MEMORY_COST_KIB, MIN_TIME_COST,
};
use sanctum::storage::Database;
use std::sync::Arc;
let mut path = std::env::temp_dir();
let id: u64 = OsRng.next_u64();
path.push(format!("sanctum_test_carrier_pathval_{}.sanctum", id));
if path.exists() {
let _ = std::fs::remove_file(&path);
}
let carrier_size_bytes = 10 * 1024 * 1024;
let carrier_name = "system_backup.dat";
let pass_decoy = "DecoyPassword2026!";
let pass_hidden = "SuperSecretHiddenPassword2026!";
let kdf_params = KdfParams {
memory_cost: MIN_MEMORY_COST_KIB,
time_cost: MIN_TIME_COST,
parallelism: 1,
};
let salt_0 = generate_salt();
let kek_0 = derive_kek(pass_decoy, &salt_0, &kdf_params).unwrap();
let dek_0 = generate_dek();
let salt_1 = generate_salt();
let kek_1 = derive_kek(pass_hidden, &salt_1, &kdf_params).unwrap();
let dek_1 = generate_dek();
let carrier_node_id = 3i64;
let (wrapped_0, nonce_0, tag_0) = wrap_slot0_payload(&kek_0, &dek_0, carrier_node_id).unwrap();
let (wrapped_1, nonce_1, tag_1) =
wrap_slot1_payload(&kek_1, &dek_1, &dek_0, carrier_node_id).unwrap();
let db = Database::open(&path).expect("Open database");
db.init_schema_with_carrier(
&salt_0,
&kdf_params,
&wrapped_0,
&nonce_0,
&tag_0,
Some((
carrier_name,
carrier_size_bytes,
&salt_1,
&kdf_params,
&wrapped_1,
&nonce_1,
&tag_1,
&dek_0,
&dek_1,
)),
)
.expect("Init carrier schema");
let cfs = CarrierFs::load(
db.clone(),
carrier_node_id,
Arc::new(dek_0),
Arc::new(dek_1),
3,
false,
)
.expect("Load CarrierFs");
// 1. create_dir mit ungültigem Namen (Windows-Reservierung CON, PRN)
let p_con = DavPath::new("/CON").unwrap();
assert_eq!(cfs.create_dir(&p_con).await, Err(FsError::Forbidden));
let p_aux = DavPath::new("/aux.txt").unwrap();
assert_eq!(cfs.create_dir(&p_aux).await, Err(FsError::Forbidden));
// 2. open (create) mit ungültigem Namen
let p_nul = DavPath::new("/NUL").unwrap();
let mut opt_create = OpenOptions::default();
opt_create.create = true;
opt_create.write = true;
assert_eq!(
cfs.open(&p_nul, opt_create.clone()).await.err(),
Some(FsError::Forbidden)
);
let p_ctrl = DavPath::new("/prn.pdf").unwrap();
assert_eq!(
cfs.open(&p_ctrl, opt_create.clone()).await.err(),
Some(FsError::Forbidden)
);
// 3. Gültige Datei erstellen
let p_valid = DavPath::new("/valid.txt").unwrap();
let file = cfs.open(&p_valid, opt_create).await;
assert!(file.is_ok(), "Valid file should be created");
drop(file);
// 4. rename mit ungültigem Zielnamen
let p_com1 = DavPath::new("/COM1.txt").unwrap();
assert_eq!(cfs.rename(&p_valid, &p_com1).await, Err(FsError::Forbidden));
// 5. copy mit ungültigem Zielnamen
let p_lpt1 = DavPath::new("/LPT1").unwrap();
assert_eq!(cfs.copy(&p_valid, &p_lpt1).await, Err(FsError::Forbidden));
let _ = std::fs::remove_file(&path);
}
#[tokio::test]
async fn test_vfs_rejects_invalid_names() {
use dav_server::davpath::DavPath;
use dav_server::fs::{DavFileSystem, FsError, OpenOptions};
use rand::rngs::OsRng;
use rand::RngCore;
use sanctum::crypto::{
derive_kek, generate_dek, generate_salt, wrap_dek, KdfParams, FORMAT_VERSION,
MIN_MEMORY_COST_KIB, MIN_TIME_COST,
};
use sanctum::storage::Database;
use sanctum::vfs::SanctumFs;
let mut path = std::env::temp_dir();
let id: u64 = OsRng.next_u64();
path.push(format!("sanctum_test_vfs_pathval_{}.sanctum", id));
if path.exists() {
let _ = std::fs::remove_file(&path);
}
let password = "TestPassword1234!";
let kdf_params = KdfParams {
memory_cost: MIN_MEMORY_COST_KIB,
time_cost: MIN_TIME_COST,
parallelism: 1,
};
let salt = generate_salt();
let kek = derive_kek(password, &salt, &kdf_params).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_params, &wrapped, &nonce, &tag)
.unwrap();
db.set_active_slot_and_dek(0, dek.clone());
let fs = SanctumFs::new(db.clone(), dek, FORMAT_VERSION);
// 1. create_dir mit ungültigem Namen
let p_con = DavPath::new("/CON").unwrap();
assert_eq!(fs.create_dir(&p_con).await, Err(FsError::Forbidden));
let p_aux = DavPath::new("/aux.txt").unwrap();
assert_eq!(fs.create_dir(&p_aux).await, Err(FsError::Forbidden));
// 2. open (create) mit ungültigem Namen
let p_nul = DavPath::new("/NUL").unwrap();
let mut opt_create = OpenOptions::default();
opt_create.create = true;
opt_create.write = true;
assert_eq!(
fs.open(&p_nul, opt_create.clone()).await.err(),
Some(FsError::Forbidden)
);
let p_prn = DavPath::new("/prn.pdf").unwrap();
assert_eq!(
fs.open(&p_prn, opt_create.clone()).await.err(),
Some(FsError::Forbidden)
);
// 3. Gültige Datei erstellen
let p_valid = DavPath::new("/valid.txt").unwrap();
let file = fs.open(&p_valid, opt_create).await;
assert!(file.is_ok(), "Valid file should be created");
drop(file);
// 4. rename mit ungültigem Zielnamen
let p_com1 = DavPath::new("/COM1.txt").unwrap();
assert_eq!(fs.rename(&p_valid, &p_com1).await, Err(FsError::Forbidden));
// 5. copy mit ungültigem Zielnamen
let p_lpt1 = DavPath::new("/LPT1").unwrap();
assert_eq!(fs.copy(&p_valid, &p_lpt1).await, Err(FsError::Forbidden));
drop(fs);
drop(db);
let _ = std::fs::remove_file(&path);
}