release: v0.7.2 — Security Audit Remediation (SA-01 bis SA-07)

- SA-01: Container-DoS / KDF-Amplification Schutz mit Pre-KDF Validierung, max 2 Slots (nur 0 und 1), Slot 0 Pflicht und strikten BLOB-Laengen
- SA-02: Release-Signierung in CI entkoppelt (getrennte build und sign-and-release Jobs, Secret-Isolation)
- SA-03: Pinned Download-Integritaet fuer minisign.exe in CI via SHA-256
- SA-04: Immutable Action-Pinning (@sha) und Toolchain-Pinning (1.85.0) in CI
- SA-05: Session-Token vollstaendig aus URIs verbannt (403 Forbidden bei Vorkommen im Pfad/Query)
- SA-06: Constant-Time Token- und Auth-Vergleiche via subtle::ConstantTimeEq
- SA-07: Dokumentations-Klarstellung bzgl. logischem Shredding vs. physischer SSD/FTL/CoW-Persistenz
This commit is contained in:
2026-09-18 23:40:35 +02:00
parent 1cdb30147b
commit e1dc9c12fb
28 changed files with 3048 additions and 968 deletions
+42 -9
View File
@@ -6,16 +6,18 @@ on:
- 'v*'
jobs:
# SA-02 & SA-04: Entkoppelte Build- & Test-Umgebung ohne Zugriff auf Signatur-Secrets
build:
name: Build & Release (Windows x86_64)
name: Build & Test (Windows x86_64)
runs-on: windows-latest
steps:
- name: Checkout Code
uses: actions/checkout@v4
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de # master commit pinned
with:
toolchain: "1.85.0"
targets: x86_64-pc-windows-msvc
- name: Run Tests
@@ -47,21 +49,54 @@ jobs:
@("$ZipHash ${PackageName}.zip", "$ExeHash sanctum.exe") | Set-Content -Path "${DistDir}/SHA256SUMS.txt" -Encoding utf8
echo "ZIP_FILE=$ZipFile" >> $env:GITHUB_OUTPUT
echo "PACKAGE_NAME=$PackageName" >> $env:GITHUB_OUTPUT
- name: Sign Checksums with Minisign
- name: Upload Build Artifacts
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
with:
name: dist-artifacts
path: dist/
# SA-02, SA-03 & SA-04: Dedizierter Signatur- & Release-Job mit isolierten Secrets und Prüfsummen-Verifikation
sign-and-release:
name: Sign & Release
needs: [build]
runs-on: windows-latest
if: startsWith(github.ref, 'refs/tags/v')
steps:
- name: Download Build Artifacts
uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8
with:
name: dist-artifacts
path: dist
- name: Verify and Sign Checksums with Minisign
shell: pwsh
env:
MINISIGN_SECRET_KEY: ${{ secrets.MINISIGN_SECRET_KEY }}
run: |
# SA-02: Secret-Isolation — MINISIGN_SECRET_KEY ist ausschließlich in diesem dedizierten Signier-Job verfügbar
if (-not $env:MINISIGN_SECRET_KEY) {
Write-Error "Secret MINISIGN_SECRET_KEY ist nicht gesetzt! Release ohne Signatur verboten."
exit 1
}
# SA-03: Pinned Download-Integrität für minisign.exe mit zwingender SHA-256 Validierung
$MinisignUrl = "https://github.com/jedisct1/minisign/releases/download/0.11/minisign-0.11-win64.zip"
$ExpectedMinisignHash = "b9c31c2c3034f81f0e5f5d92cbcc20e67a9671b6e5455661588638848dc58031"
if (-not (Get-Command minisign -ErrorAction SilentlyContinue)) {
Invoke-WebRequest -Uri "https://github.com/jedisct1/minisign/releases/download/0.11/minisign-0.11-win64.zip" -OutFile "minisign.zip"
Write-Host "Lade gepinnten Minisign-Release herunter: $MinisignUrl"
Invoke-WebRequest -Uri $MinisignUrl -OutFile "minisign.zip"
$ActualHash = (Get-FileHash -Path "minisign.zip" -Algorithm SHA256).Hash.ToLower()
if ($ActualHash -ne $ExpectedMinisignHash) {
Write-Error "KRITISCHER SICHERHEITSFEHLER: Minisign SHA-256 Prüfsumme ungültig!`nErwartet: $ExpectedMinisignHash`nErhalten: $ActualHash`nAbbruch."
exit 1
}
Write-Host "Minisign SHA-256 Integrität erfolgreich verifiziert ($ActualHash)."
Expand-Archive -Path "minisign.zip" -DestinationPath "minisign-bin"
$MinisignExe = "minisign-bin/minisign-win64/minisign.exe"
} else {
@@ -81,14 +116,12 @@ jobs:
}
- name: Create Gitea Release
uses: softprops/action-gh-release@v2
if: startsWith(github.ref, 'refs/tags/')
uses: softprops/action-gh-release@c95fe1489396fe8a9eb87c0abf8aa5b2ef267fda # v2.2.1
with:
files: |
dist/*.zip
dist/SHA256SUMS.txt
dist/SHA256SUMS.txt.minisig
target/release/sanctum.exe
body_path: CHANGELOG.md
draft: false
prerelease: false
+26
View File
@@ -5,6 +5,32 @@ 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.7.2] - 2026-09-18
### Security Audit Remediation Release (Findings SA-01 to SA-07)
Dieses Release behebt alle sieben identifizierten Schwachstellen und Härtungsanforderungen (SA-01 bis SA-07) aus dem Security-Audit von v0.7.1.
#### Security & Architecture
- **SA-01: Container-DoS / KDF-Amplification Schutz**:
- Pre-KDF strukturelle Validierung in `Database::open`, `read_slots`, `ContainerMeta::authenticate` und `recovery::to_meta`.
- Striktes Limit: Maximal 2 Slots (`slots.len() <= 2`), nur Slot-IDs 0 und 1 zulässig, Erkennung von Duplikaten, Slot 0 ist Pflicht.
- Strikte Validierung aller BLOB-Längen (Salt: 16 B, Nonce: 12 B, Tag: 16 B, Wrapped DEK: 32/40 B für Slot 0, 32/64/72 B für Slot 1) und KDF-Parametergrenzen vor jeder Argon2id-Ableitung (`derive_kek`).
- **SA-02: Release-Signierung entkoppelt & CI-Secrets isoliert**:
- Trennung des CI/CD-Release-Workflows (`release.yaml`) in separate `build`- und `sign-and-release`-Jobs.
- Das Secret `MINISIGN_SECRET_KEY` ist strikt auf den nachgelagerten Signatur-Job isoliert und für Build-/Test-Runner unzugänglich.
- **SA-03: Pinned Download-Integrität für `minisign.exe`**:
- SHA-256 Integritätsprüfung des offiziellen Minisign v0.11 Downloads (`b9c31c2c3034f81f0e5f5d92cbcc20e67a9671b6e5455661588638848dc58031`) im CI-Workflow vor dem Entpacken und Ausführen (Fail-Closed).
- **SA-04: Immutable Action-Pinning & Toolchain-Pinning**:
- Alle externen GitHub/Gitea-Actions im Release-Workflow auf unveränderliche Commit-SHAs (`@<sha>`) fixiert.
- Rust-Toolchain explizit auf Version `1.85.0` gepinnt.
- **SA-05: Session-Tokens vollständig aus URIs verbannt**:
- Eingehende WebDAV-Requests, deren URI das Session-Token in Pfad oder Query-String enthalten (`/<token>/...` oder `?token=...`), werden sofort mit `403 Forbidden` abgewiesen.
- Authentifizierung wird strikt über HTTP-Header (`Authorization: Basic ...` oder `X-Sanctum-Token: ...`) erzwungen.
- **SA-06: Constant-Time Token-Vergleich**:
- Integration von `subtle::ConstantTimeEq` für zeitinvariante Vergleiche von Basic-Auth-Credentials und Session-Tokens zum Schutz vor Timing-Seitenkanalangriffen.
- **SA-07: Dokumentations-Klarstellung: Logische vs. Physische Datenlöschung**:
- Präzisierung im `README.md` und Code-Docstrings bzgl. der Grenzen des kryptografischen Chunk-Shreddings: Auf Flash-Medien (SSDs/NVMe) können Wear-Leveling und FTL-Blöcke physisch persistieren. Empfehlung zur Nutzung von Full-Disk-Encryption (FDE).
## [0.7.1] - 2026-09-18
### Security Patch Release (Closing Gaps R-01 to R-06)
Generated
+2 -1
View File
@@ -1457,7 +1457,7 @@ checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
[[package]]
name = "sanctum"
version = "0.7.1"
version = "0.7.2"
dependencies = [
"aes-gcm",
"anyhow",
@@ -1483,6 +1483,7 @@ dependencies = [
"serde",
"serde_json",
"sha2",
"subtle",
"tempfile",
"thiserror",
"tokio",
+2 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "sanctum"
version = "0.7.1"
version = "0.7.2"
edition = "2021"
authors = ["Harald Pansi <harald@pansi.eu>", "Sanctum Engineering Team"]
description = "Verschlüsselter Ein-Datei-Container unter Windows im reinen Userland via WebDAV"
@@ -42,6 +42,7 @@ ureq = { version = "2.10", default-features = false, features = ["json", "tls"]
sha2 = "0.10"
semver = "1.0"
minisign-verify = "0.2"
subtle = "2.6"
tempfile = "3"
[target.'cfg(windows)'.dependencies]
+3 -2
View File
@@ -39,8 +39,9 @@ Sanctum ist eine eigenständige, speichersichere und hochperformante CLI-Anwendu
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).
- **Dateinamen-Verschlüsselung**:
Dateinamen im Hidden Vault werden mit frischen CSPRNG-Nonces und AES-256-GCM verschlüsselt in der Datenbank gespeichert (Legacy-Kompatibilität über `--legacy-names`).
- **Kryptografisches Chunk-Shredding**:
Vor jedem Löschen oder Kürzen werden Chunk-Payloads in der SQLite-Datenbank mit CSPRNG-Rauschen überschrieben.
- **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.
---
+11 -18
View File
@@ -10,8 +10,8 @@ use bytes::{Buf, Bytes, BytesMut};
use dav_server::{
davpath::DavPath,
fs::{
DavDirEntry, DavFile, DavFileSystem, DavMetaData, FsError, FsFuture, FsStream,
OpenOptions, ReadDirMeta,
DavDirEntry, DavFile, DavFileSystem, DavMetaData, FsError, FsFuture, FsStream, OpenOptions,
ReadDirMeta,
},
};
use futures_util::stream;
@@ -624,9 +624,7 @@ impl DavFileSystem for CarrierFs {
};
inner.manifest.inodes.insert(new_id, new_dir);
inner
.save_manifest()
.map_err(|_| FsError::GeneralFailure)?;
inner.save_manifest().map_err(|_| FsError::GeneralFailure)?;
Ok(())
})
@@ -659,9 +657,7 @@ impl DavFileSystem for CarrierFs {
}
inner.manifest.inodes.remove(&node.id);
inner
.save_manifest()
.map_err(|_| FsError::GeneralFailure)?;
inner.save_manifest().map_err(|_| FsError::GeneralFailure)?;
Ok(())
})
@@ -690,9 +686,7 @@ impl DavFileSystem for CarrierFs {
}
inner.manifest.inodes.remove(&node.id);
inner
.save_manifest()
.map_err(|_| FsError::GeneralFailure)?;
inner.save_manifest().map_err(|_| FsError::GeneralFailure)?;
Ok(())
})
@@ -748,9 +742,7 @@ impl DavFileSystem for CarrierFs {
inode.modified_at = now;
}
inner
.save_manifest()
.map_err(|_| FsError::GeneralFailure)?;
inner.save_manifest().map_err(|_| FsError::GeneralFailure)?;
Ok(())
})
@@ -1011,9 +1003,7 @@ impl DavFile for CarrierFile {
inode.modified_at = now;
}
inner
.save_manifest()
.map_err(|_| FsError::GeneralFailure)?;
inner.save_manifest().map_err(|_| FsError::GeneralFailure)?;
self.meta.size = self.file_size;
self.meta.modified_at = UNIX_EPOCH + Duration::from_secs(now);
@@ -1025,7 +1015,10 @@ impl DavFile for CarrierFile {
impl Drop for CarrierFile {
fn drop(&mut self) {
if let Err(e) = self.flush_cached_block() {
tracing::warn!("Fehler beim automatischen Flush im CarrierFile::drop: {:?}", e);
tracing::warn!(
"Fehler beim automatischen Flush im CarrierFile::drop: {:?}",
e
);
}
if let Some((_, ref mut data, _)) = self.cached_block {
data.zeroize();
+185 -63
View File
@@ -101,11 +101,7 @@ pub fn check_password_prefix_collision(pass0: &str, pass1: &str) -> Result<()> {
}
/// Leitet aus dem Master-Passwort und dem Salt einen 256-Bit Key Encryption Key (KEK) via Argon2id ab.
pub fn derive_kek(
password: &str,
salt: &[u8],
params: &KdfParams,
) -> Result<Zeroizing<[u8; 32]>> {
pub fn derive_kek(password: &str, salt: &[u8], params: &KdfParams) -> Result<Zeroizing<[u8; 32]>> {
validate_kdf_params(params)?;
let argon2_params = Params::new(
@@ -145,10 +141,7 @@ pub fn generate_salt() -> [u8; 16] {
}
/// Verschlüsselt beliebige Schlüsseldaten (32B DEK, 40B Slot0-Payload oder 72B Slot1-Payload) via AES-256-GCM.
pub fn wrap_key_payload(
kek: &[u8; 32],
payload: &[u8],
) -> Result<(Vec<u8>, [u8; 12], [u8; 16])> {
pub fn wrap_key_payload(kek: &[u8; 32], payload: &[u8]) -> Result<(Vec<u8>, [u8; 12], [u8; 16])> {
let cipher = Aes256Gcm::new_from_slice(kek)
.map_err(|e| anyhow::anyhow!("AES-GCM Initialisierungsfehler: {e}"))?;
@@ -183,17 +176,18 @@ pub fn unwrap_key_payload(
let mut buffer = wrapped_payload.to_vec();
cipher
.decrypt_in_place_detached(nonce, b"SANCTUM_HEADER_DEK", &mut buffer, tag)
.map_err(|_| anyhow::anyhow!("Passwort falsch oder Header beschädigt (AEAD Authentifizierungsfehler)"))?;
.map_err(|_| {
anyhow::anyhow!(
"Passwort falsch oder Header beschädigt (AEAD Authentifizierungsfehler)"
)
})?;
Ok(Zeroizing::new(buffer))
}
/// Verschlüsselt den DEK (32 Bytes) mit dem KEK via AES-256-GCM.
/// Gibt (wrapped_dek_32_bytes, nonce_12_bytes, tag_16_bytes) zurück.
pub fn wrap_dek(
kek: &[u8; 32],
dek: &[u8; 32],
) -> Result<(Vec<u8>, [u8; 12], [u8; 16])> {
pub fn wrap_dek(kek: &[u8; 32], dek: &[u8; 32]) -> Result<(Vec<u8>, [u8; 12], [u8; 16])> {
wrap_key_payload(kek, dek)
}
@@ -206,7 +200,10 @@ pub fn unwrap_dek(
) -> Result<Zeroizing<[u8; 32]>> {
let payload = unwrap_key_payload(kek, wrapped_dek, nonce_bytes, tag_bytes)?;
if payload.len() < 32 {
bail!("Ungültige wrapped_dek Länge: erwartet mindestens 32 Bytes, erhalten {}", payload.len());
bail!(
"Ungültige wrapped_dek Länge: erwartet mindestens 32 Bytes, erhalten {}",
payload.len()
);
}
let mut dek = Zeroizing::new([0u8; 32]);
@@ -265,7 +262,8 @@ pub fn build_name_aad(parent_id: i64) -> [u8; 16] {
aad
}
static ALLOW_LEGACY_NAMES: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
static ALLOW_LEGACY_NAMES: std::sync::atomic::AtomicBool =
std::sync::atomic::AtomicBool::new(false);
/// Aktiviert oder deaktiviert den veralteten AAD-Fallback für Dateinamen (S-10).
pub fn set_allow_legacy_names(allow: bool) {
@@ -289,7 +287,11 @@ pub fn encrypt_node_name(dek: &[u8; 32], parent_id: i64, name: &str) -> String {
};
let mut buffer = name.as_bytes().to_vec();
let aad = build_name_aad(parent_id);
let tag = match cipher.encrypt_in_place_detached(Nonce::from_slice(&nonce_bytes), &aad, &mut buffer) {
let tag = match cipher.encrypt_in_place_detached(
Nonce::from_slice(&nonce_bytes),
&aad,
&mut buffer,
) {
Ok(t) => t,
Err(_) => return String::new(),
};
@@ -303,7 +305,12 @@ pub fn encrypt_node_name(dek: &[u8; 32], parent_id: i64, name: &str) -> String {
/// Entschlüsselt den Dateinamen eines Knotens im Hidden Vault mit AES-256-GCM.
/// Prüft primär die kryptografische Bindung an parent_id; bietet optional Fallback
/// auf die statische AAD für ältere Container, wenn `allow_legacy` aktiv ist (S-10).
pub fn decrypt_node_name_ext(dek: &[u8; 32], parent_id: i64, stored: &str, allow_legacy: bool) -> Option<String> {
pub fn decrypt_node_name_ext(
dek: &[u8; 32],
parent_id: i64,
stored: &str,
allow_legacy: bool,
) -> Option<String> {
// Abwärtskompatibilität für alte v0.2.0 $h$<nonce>$<tag>$<ct> Namen
if let Some(rest) = stored.strip_prefix("$h$") {
let parts: Vec<&str> = rest.split('$').collect();
@@ -423,7 +430,11 @@ pub fn levenshtein_distance(a: &str, b: &str) -> usize {
for i in 1..=m {
for j in 1..=n {
let cost = if a_chars[i - 1] == b_chars[j - 1] { 0 } else { 1 };
let cost = if a_chars[i - 1] == b_chars[j - 1] {
0
} else {
1
};
dp[i][j] = (dp[i - 1][j] + 1)
.min(dp[i][j - 1] + 1)
.min(dp[i - 1][j - 1] + cost);
@@ -471,10 +482,7 @@ pub fn normalize_mnemonic_phrase(phrase: &str) -> Vec<String> {
})
.collect();
cleaned
.split_whitespace()
.map(|s| s.to_string())
.collect()
cleaned.split_whitespace().map(|s| s.to_string()).collect()
}
/// Dekodiert eine 24-Wort BIP-39 Notfall-Wiederherstellungsphrase zurück in den 32-Byte DEK.
@@ -500,9 +508,18 @@ pub fn mnemonic_to_dek(phrase: &str) -> Result<Zeroizing<[u8; 32]>> {
if !word_list.contains(&word.as_str()) {
let suggestion = suggest_bip39_word(word);
let msg = if let Some(sug) = suggestion {
format!("Wort #{} '{}' ist ungültig (Meinten Sie '{}'?)", idx + 1, word, sug)
format!(
"Wort #{} '{}' ist ungültig (Meinten Sie '{}'?)",
idx + 1,
word,
sug
)
} else {
format!("Wort #{} '{}' ist ungültig (nicht im BIP-39 Wörterbuch)", idx + 1, word)
format!(
"Wort #{} '{}' ist ungültig (nicht im BIP-39 Wörterbuch)",
idx + 1,
word
)
};
invalid_words.push(msg);
}
@@ -620,7 +637,11 @@ pub fn decrypt_chunk(
let mut buffer = ciphertext.to_vec();
cipher
.decrypt_in_place_detached(nonce, &aad, &mut buffer, tag)
.map_err(|_| anyhow::anyhow!("Chunk-Integritätsprüfung fehlgeschlagen (AEAD Auth-Fehler oder Swap-Angriff)"))?;
.map_err(|_| {
anyhow::anyhow!(
"Chunk-Integritätsprüfung fehlgeschlagen (AEAD Auth-Fehler oder Swap-Angriff)"
)
})?;
if format_version >= FORMAT_VERSION_V2 {
if buffer.is_empty() {
@@ -654,7 +675,6 @@ pub fn decrypt_chunk(
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -700,26 +720,55 @@ mod tests {
encrypt_chunk(&dek, node_id, chunk_index, plaintext, FORMAT_VERSION_V2).unwrap();
// Reguläre Entschlüsselung (v2)
let decrypted =
decrypt_chunk(&dek, node_id, chunk_index, &ciphertext, &nonce, &tag, FORMAT_VERSION_V2).unwrap();
let decrypted = decrypt_chunk(
&dek,
node_id,
chunk_index,
&ciphertext,
&nonce,
&tag,
FORMAT_VERSION_V2,
)
.unwrap();
assert_eq!(decrypted, plaintext);
// Swap Attack 1: Falsche node_id (Chunk in andere Datei verschoben)
let swap_node_err =
decrypt_chunk(&dek, 99i64, chunk_index, &ciphertext, &nonce, &tag, FORMAT_VERSION_V2);
let swap_node_err = decrypt_chunk(
&dek,
99i64,
chunk_index,
&ciphertext,
&nonce,
&tag,
FORMAT_VERSION_V2,
);
assert!(swap_node_err.is_err());
// Swap Attack 2: Falscher chunk_index (Chunk innerhalb derselben Datei verschoben)
let swap_idx_err =
decrypt_chunk(&dek, node_id, 1u32, &ciphertext, &nonce, &tag, FORMAT_VERSION_V2);
let swap_idx_err = decrypt_chunk(
&dek,
node_id,
1u32,
&ciphertext,
&nonce,
&tag,
FORMAT_VERSION_V2,
);
assert!(swap_idx_err.is_err());
// Manipulation des Ciphertexts
let mut tampered_ct = ciphertext.clone();
tampered_ct[0] ^= 0x01;
assert!(
decrypt_chunk(&dek, node_id, chunk_index, &tampered_ct, &nonce, &tag, FORMAT_VERSION_V2).is_err()
);
assert!(decrypt_chunk(
&dek,
node_id,
chunk_index,
&tampered_ct,
&nonce,
&tag,
FORMAT_VERSION_V2
)
.is_err());
}
#[test]
@@ -742,8 +791,16 @@ mod tests {
plaintext.len()
);
let decrypted =
decrypt_chunk(&dek, node_id, chunk_index, &ciphertext, &nonce, &tag, FORMAT_VERSION_V2).unwrap();
let decrypted = decrypt_chunk(
&dek,
node_id,
chunk_index,
&ciphertext,
&nonce,
&tag,
FORMAT_VERSION_V2,
)
.unwrap();
assert_eq!(decrypted, plaintext);
}
@@ -754,8 +811,7 @@ mod tests {
let mut random_bytes = vec![0u8; 1000];
OsRng.fill_bytes(&mut random_bytes);
let (ct, nonce, tag) =
encrypt_chunk(&dek, 1, 0, &random_bytes, FORMAT_VERSION_V2).unwrap();
let (ct, nonce, tag) = encrypt_chunk(&dek, 1, 0, &random_bytes, FORMAT_VERSION_V2).unwrap();
// Da Kompression keine 64 Bytes spart, wird COMPRESSION_NONE (1 Byte) + Plaintext gespeichert
assert_eq!(ct.len(), random_bytes.len() + 1);
@@ -775,8 +831,16 @@ mod tests {
encrypt_chunk(&dek, node_id, chunk_index, plaintext, FORMAT_VERSION_V1).unwrap();
assert_eq!(ciphertext.len(), plaintext.len());
let decrypted =
decrypt_chunk(&dek, node_id, chunk_index, &ciphertext, &nonce, &tag, FORMAT_VERSION_V1).unwrap();
let decrypted = decrypt_chunk(
&dek,
node_id,
chunk_index,
&ciphertext,
&nonce,
&tag,
FORMAT_VERSION_V1,
)
.unwrap();
assert_eq!(decrypted, plaintext);
}
@@ -788,10 +852,17 @@ mod tests {
assert_eq!(words.len(), 24, "Mnemonic must have exactly 24 words");
let recovered_dek = mnemonic_to_dek(&mnemonic_str).expect("Recover DEK");
assert_eq!(*dek, *recovered_dek, "Recovered DEK must match original DEK");
assert_eq!(
*dek, *recovered_dek,
"Recovered DEK must match original DEK"
);
// Whitespace-Toleranz (z. B. doppelte Leerzeichen, Zeilenumbrüche)
let messy_phrase = format!(" {} \n\t {} ", words[0..12].join(" "), words[12..24].join(" \n "));
let messy_phrase = format!(
" {} \n\t {} ",
words[0..12].join(" "),
words[12..24].join(" \n ")
);
let recovered_messy = mnemonic_to_dek(&messy_phrase).expect("Recover messy");
assert_eq!(*dek, *recovered_messy);
}
@@ -815,9 +886,16 @@ mod tests {
.collect();
// Tausche das letzte Wort gegen ein anderes gültiges BIP-39 Wort
let original_last = words[23].clone();
words[23] = if original_last == "abandon" { "zoo".to_string() } else { "abandon".to_string() };
words[23] = if original_last == "abandon" {
"zoo".to_string()
} else {
"abandon".to_string()
};
let corrupted_phrase = words.join(" ");
assert!(mnemonic_to_dek(&corrupted_phrase).is_err(), "Checksum check must fail");
assert!(
mnemonic_to_dek(&corrupted_phrase).is_err(),
"Checksum check must fail"
);
}
#[test]
@@ -860,8 +938,14 @@ mod tests {
assert_eq!(decrypted, filename);
// Abwärtskompatibilität: Legacy $h$<nonce>$<tag>$<ct> Format muss weiter entschlüsselt werden
let legacy_format = format!("$h${}${}${}", &encrypted[0..24], &encrypted[24..56], &encrypted[56..]);
let decrypted_legacy = decrypt_node_name(&dek, parent_id, &legacy_format).expect("Decrypt legacy $h$ name");
let legacy_format = format!(
"$h${}${}${}",
&encrypted[0..24],
&encrypted[24..56],
&encrypted[56..]
);
let decrypted_legacy =
decrypt_node_name(&dek, parent_id, &legacy_format).expect("Decrypt legacy $h$ name");
assert_eq!(decrypted_legacy, filename);
// Echte statische AAD Legacy-Verschlüsselung (b"SANCTUM_NODE_NAME")
@@ -869,7 +953,11 @@ mod tests {
let mut static_buf = filename.as_bytes().to_vec();
let static_nonce = [42u8; 12];
let static_tag = cipher
.encrypt_in_place_detached(Nonce::from_slice(&static_nonce), b"SANCTUM_NODE_NAME", &mut static_buf)
.encrypt_in_place_detached(
Nonce::from_slice(&static_nonce),
b"SANCTUM_NODE_NAME",
&mut static_buf,
)
.unwrap();
let legacy_static_format = format!(
"$h${}${}${}",
@@ -882,7 +970,8 @@ mod tests {
// Mit aktiviertem Legacy-Flag darf es entschlüsselt werden
set_allow_legacy_names(true);
let decrypted_static = decrypt_node_name(&dek, parent_id, &legacy_static_format).expect("Decrypt legacy static AAD name with flag");
let decrypted_static = decrypt_node_name(&dek, parent_id, &legacy_static_format)
.expect("Decrypt legacy static AAD name with flag");
assert_eq!(decrypted_static, filename);
set_allow_legacy_names(false);
@@ -904,17 +993,33 @@ mod tests {
let enc_folder_a = encrypt_node_name(&dek, 10, "secrets.txt");
let enc_folder_b = encrypt_node_name(&dek, 20, "passwords.txt");
// Gültige parent_ids entschlüsseln erfolgreich
assert_eq!(decrypt_node_name(&dek, 10, &enc_folder_a).unwrap(), "secrets.txt");
assert_eq!(decrypt_node_name(&dek, 20, &enc_folder_b).unwrap(), "passwords.txt");
assert_eq!(
decrypt_node_name(&dek, 10, &enc_folder_a).unwrap(),
"secrets.txt"
);
assert_eq!(
decrypt_node_name(&dek, 20, &enc_folder_b).unwrap(),
"passwords.txt"
);
// Swap-Angriff: Ein Angreifer verschiebt enc_folder_a in Ordner 20
assert!(decrypt_node_name(&dek, 20, &enc_folder_a).is_none(), "Swap in anderen Ordner muss durch AAD fehlschlagen!");
assert!(decrypt_node_name(&dek, 10, &enc_folder_b).is_none(), "Swap in anderen Ordner muss durch AAD fehlschlagen!");
assert!(
decrypt_node_name(&dek, 20, &enc_folder_a).is_none(),
"Swap in anderen Ordner muss durch AAD fehlschlagen!"
);
assert!(
decrypt_node_name(&dek, 10, &enc_folder_b).is_none(),
"Swap in anderen Ordner muss durch AAD fehlschlagen!"
);
}
#[test]
fn test_model_a_slot_payloads() {
let test_kdf = KdfParams { memory_cost: MIN_MEMORY_COST_KIB, time_cost: MIN_TIME_COST, parallelism: 1 };
let test_kdf = KdfParams {
memory_cost: MIN_MEMORY_COST_KIB,
time_cost: MIN_TIME_COST,
parallelism: 1,
};
let kek_0 = derive_kek("DecoyPass123!", &generate_salt(), &test_kdf).unwrap();
let kek_1 = derive_kek("HiddenPass123!", &generate_salt(), &test_kdf).unwrap();
let dek_0 = generate_dek();
@@ -922,7 +1027,8 @@ mod tests {
let carrier_node_id = 42i64;
// Slot 0 Payload: 40 Bytes
let (wrapped_0, nonce_0, tag_0) = wrap_slot0_payload(&kek_0, &dek_0, carrier_node_id).unwrap();
let (wrapped_0, nonce_0, tag_0) =
wrap_slot0_payload(&kek_0, &dek_0, carrier_node_id).unwrap();
assert_eq!(wrapped_0.len(), 40);
let unwrapped_0 = unwrap_key_payload(&kek_0, &wrapped_0, &nonce_0, &tag_0).unwrap();
@@ -932,7 +1038,8 @@ mod tests {
assert_eq!(recovered_cid_0, carrier_node_id);
// Slot 1 Payload: 72 Bytes
let (wrapped_1, nonce_1, tag_1) = wrap_slot1_payload(&kek_1, &dek_1, &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();
assert_eq!(wrapped_1.len(), 72);
let unwrapped_1 = unwrap_key_payload(&kek_1, &wrapped_1, &nonce_1, &tag_1).unwrap();
@@ -964,15 +1071,30 @@ mod tests {
let nonce = Nonce::from_slice(&nonce_bytes);
let mut ct = malicious_plaintext.clone();
let tag = cipher.encrypt_in_place_detached(nonce, &aad, &mut ct).unwrap();
let tag = cipher
.encrypt_in_place_detached(nonce, &aad, &mut ct)
.unwrap();
let tag_bytes: [u8; 16] = tag.as_slice().try_into().unwrap();
// Entschlüsselung muss fehlschlagen, da Dekomprimierungs-Bomb-Schutz greift
let res = decrypt_chunk(&dek, node_id, chunk_index, &ct, &nonce_bytes, &tag_bytes, FORMAT_VERSION_V2);
assert!(res.is_err(), "Dekomprimierungs-Bomb über 1 MB muss abgewiesen werden!");
let res = decrypt_chunk(
&dek,
node_id,
chunk_index,
&ct,
&nonce_bytes,
&tag_bytes,
FORMAT_VERSION_V2,
);
assert!(
res.is_err(),
"Dekomprimierungs-Bomb über 1 MB muss abgewiesen werden!"
);
let err_msg = res.err().unwrap().to_string();
assert!(err_msg.contains("Decompression-Bomb Schutz ausgelöst"), "Fehlermeldung erwartet: {}", err_msg);
assert!(
err_msg.contains("Decompression-Bomb Schutz ausgelöst"),
"Fehlermeldung erwartet: {}",
err_msg
);
}
}
+361 -110
View File
@@ -295,18 +295,30 @@ fn parse_drive_letter(s: &str) -> Result<char> {
pub fn parse_size_string(s: &str) -> Result<u64> {
let trimmed = s.trim().to_uppercase();
if let Some(num_str) = trimmed.strip_suffix("GB") {
let n: u64 = num_str.trim().parse().context("Ungültige Gigabyte-Angabe")?;
let n: u64 = num_str
.trim()
.parse()
.context("Ungültige Gigabyte-Angabe")?;
Ok(n * 1024 * 1024 * 1024)
} else if let Some(num_str) = trimmed.strip_suffix("MB") {
let n: u64 = num_str.trim().parse().context("Ungültige Megabyte-Angabe")?;
let n: u64 = num_str
.trim()
.parse()
.context("Ungültige Megabyte-Angabe")?;
Ok(n * 1024 * 1024)
} else if let Some(num_str) = trimmed.strip_suffix("KB") {
let n: u64 = num_str.trim().parse().context("Ungültige Kilobyte-Angabe")?;
let n: u64 = num_str
.trim()
.parse()
.context("Ungültige Kilobyte-Angabe")?;
Ok(n * 1024)
} else if let Ok(n) = trimmed.parse::<u64>() {
Ok(n)
} else {
bail!("Ungültiges Größenformat: '{}'. Erwartet z. B. '500MB', '1GB', '2GB'", s);
bail!(
"Ungültiges Größenformat: '{}'. Erwartet z. B. '500MB', '1GB', '2GB'",
s
);
}
}
@@ -319,9 +331,14 @@ pub fn parse_size_string(s: &str) -> Result<u64> {
fn resolve_recovery_key(arg: Option<&str>) -> Result<Option<Zeroizing<String>>> {
if let Some(key) = arg {
if key == "-" {
println!(" [{}] Lese 24-Wort Notfallschlüssel von der Standardeingabe (stdin)...", ui::cyan(""));
println!(
" [{}] Lese 24-Wort Notfallschlüssel von der Standardeingabe (stdin)...",
ui::cyan("")
);
let mut line = String::new();
std::io::stdin().read_line(&mut line).context("Fehler beim Einlesen von stdin")?;
std::io::stdin()
.read_line(&mut line)
.context("Fehler beim Einlesen von stdin")?;
let trimmed = line.trim();
if trimmed.is_empty() {
bail!("Der Notfallschlüssel von stdin darf nicht leer sein.");
@@ -342,7 +359,10 @@ fn resolve_recovery_key(arg: Option<&str>) -> Result<Option<Zeroizing<String>>>
std::env::remove_var("SANCTUM_RECOVERY_KEY");
let trimmed = env_key.trim();
if !trimmed.is_empty() {
println!(" [{}] Verwende Notfallschlüssel aus Umgebungsvariable 'SANCTUM_RECOVERY_KEY'.", ui::cyan(""));
println!(
" [{}] Verwende Notfallschlüssel aus Umgebungsvariable 'SANCTUM_RECOVERY_KEY'.",
ui::cyan("")
);
Ok(Some(Zeroizing::new(trimmed.to_string())))
} else {
Ok(None)
@@ -355,8 +375,14 @@ fn resolve_recovery_key(arg: Option<&str>) -> Result<Option<Zeroizing<String>>>
/// Liest den 24-Wort BIP-39 Notfallschlüssel interaktiv und maskiert ein,
/// ohne dass Eingaben in der PowerShell-Historie (ConsoleHost_history.txt) landen.
fn prompt_recovery_key_interactive() -> Result<Zeroizing<String>> {
println!(" [{}] Geben Sie die 24 Notfall-Wörter durch Leerzeichen getrennt ein.", ui::cyan(""));
println!(" [{}] Die Eingabe erfolgt geschützt ohne Aufzeichnung in der Shell-Historie.", ui::dim("OpSec"));
println!(
" [{}] Geben Sie die 24 Notfall-Wörter durch Leerzeichen getrennt ein.",
ui::cyan("")
);
println!(
" [{}] Die Eingabe erfolgt geschützt ohne Aufzeichnung in der Shell-Historie.",
ui::dim("OpSec")
);
let phrase = rpassword::prompt_password("24-Wort Notfallschlüssel: ")
.context("Fehler beim Einlesen des Notfallschlüssels")?;
if phrase.trim().is_empty() {
@@ -383,8 +409,15 @@ fn handle_init(
println!("└─────────────────────────────────────────────────────────────┘");
println!(" Zieldatei: {}", container_path.display());
if with_hidden {
println!(" Modus: Dual-Vault (Modell A: {})", ui::magenta("Carrier-Datei"));
println!(" Carrier: {} ({})", ui::cyan(carrier_name), ui::cyan(carrier_size_str));
println!(
" Modus: Dual-Vault (Modell A: {})",
ui::magenta("Carrier-Datei")
);
println!(
" Carrier: {} ({})",
ui::cyan(carrier_name),
ui::cyan(carrier_size_str)
);
println!(
" [{}] Hinweis zum Dual-Vault: Modell A kapselt den Hidden Vault in einer Datei im Decoy-Vault. Dies schützt vor neugierigen Blicken, widersteht jedoch keinem forensischen Gutachten, da Carrier-Entropie und Schreibmuster analysierbar sind.",
ui::yellow("Info")
@@ -415,7 +448,10 @@ fn handle_init(
println!();
println!(" ─── [2/2] Hidden Vault (Zweiter isolierter Tresor / Dual-Vault) ───");
println!(" [{}] Verwenden Sie ein völlig eigenständiges, separates Passwort!", ui::yellow("WICHTIG"));
println!(
" [{}] Verwenden Sie ein völlig eigenständiges, separates Passwort!",
ui::yellow("WICHTIG")
);
let password_1 = Zeroizing::new(
rpassword::prompt_password("Master-Passwort für Hidden-Vault eingeben: ")
.context("Fehler beim Einlesen des Passworts")?,
@@ -432,7 +468,12 @@ fn handle_init(
}
println!();
ui::step(1, 4, "🔑", "Leite KEKs für Standard- und Hidden-Vault via Argon2id ab...");
ui::step(
1,
4,
"🔑",
"Leite KEKs für Standard- und Hidden-Vault via Argon2id ab...",
);
let salt_0 = generate_salt();
let kdf_params_0 = KdfParams::default();
let kek_0 = derive_kek(&password_0, &salt_0, &kdf_params_0)?;
@@ -445,14 +486,24 @@ fn handle_init(
let dek_0 = generate_dek();
let dek_1 = generate_dek();
ui::step(3, 4, "🔒", "Verschlüssele Slot-Payloads (Dual-Vault Key-Wrapping)...");
ui::step(
3,
4,
"🔒",
"Verschlüssele Slot-Payloads (Dual-Vault Key-Wrapping)...",
);
let carrier_node_id = 3i64;
let (wrapped_dek_0, header_nonce_0, header_tag_0) =
wrap_slot0_payload(&kek_0, &dek_0, carrier_node_id)?;
ui::step(4, 4, "📦", "Erzeuge SQLite-Container & alloziiere Alibi-Carrier-Datei...");
let db = Database::open(container_path)
.context("Konnte SQLite-Containerdatei nicht anlegen")?;
ui::step(
4,
4,
"📦",
"Erzeuge SQLite-Container & alloziiere Alibi-Carrier-Datei...",
);
let db =
Database::open(container_path).context("Konnte SQLite-Containerdatei nicht anlegen")?;
let (wrapped_dek_1, header_nonce_1, header_tag_1) =
wrap_slot1_payload(&kek_1, &dek_1, &dek_0, carrier_node_id)?;
@@ -487,7 +538,10 @@ fn handle_init(
println!("└─────────────────────────────────────────────────────────────┘");
println!();
println!(" • Container: {}", container_path.display());
println!(" • Format: Version {} (Magic: SANCTUM\\0)", FORMAT_VERSION);
println!(
" • Format: Version {} (Magic: SANCTUM\\0)",
FORMAT_VERSION
);
println!(" • Carrier-Datei:{} ({})", carrier_name, carrier_size_str);
println!(" • KDF: Argon2id pro Slot (M=64MB, T=3, P=4)");
println!(" • Kapselung: Hidden Vault liegt in Carrier-Datei im Decoy-Vault");
@@ -495,8 +549,16 @@ fn handle_init(
println!(" • Dateigröße: Feste Trägergröße zur Vermeidung von Größenveränderungen");
println!();
println!(" Befehl zum Einbinden als Netzlaufwerk:");
println!(" {}", ui::cyan(&format!("sanctum mount --path \"{}\"", container_path.display())));
println!(" (Die Eingabe des jeweiligen Passworts bindet automatisch den passenden Vault ein)");
println!(
" {}",
ui::cyan(&format!(
"sanctum mount --path \"{}\"",
container_path.display()
))
);
println!(
" (Die Eingabe des jeweiligen Passworts bindet automatisch den passenden Vault ein)"
);
println!();
println!(" ┌─────────────────────────────────────────────────────────┐");
@@ -525,25 +587,47 @@ fn handle_init(
}
println!();
ui::step(1, 4, "🔑", "Leite KEK via Argon2id ab (M=64MB, T=3, P=4)...");
ui::step(
1,
4,
"🔑",
"Leite KEK via Argon2id ab (M=64MB, T=3, P=4)...",
);
let salt = generate_salt();
let kdf_params = KdfParams::default();
let kek = derive_kek(&password, &salt, &kdf_params)
.context("KDF-Schlüsselableitung fehlgeschlagen")?;
ui::step(2, 4, "🎲", "Erzeuge kryptografisch sicheren DEK via CSPRNG...");
ui::step(
2,
4,
"🎲",
"Erzeuge kryptografisch sicheren DEK via CSPRNG...",
);
let dek = generate_dek();
ui::step(3, 4, "🔒", "Verschlüssele DEK via AES-256-GCM...");
let (wrapped_dek, header_nonce, header_tag) =
wrap_slot0_payload(&kek, &dek, 0).context("DEK-Wrapping fehlgeschlagen")?;
ui::step(4, 4, "📦", "Initialisiere SQLite-Containerstruktur & WAL-Modus...");
let db = Database::open(container_path)
.context("Konnte SQLite-Containerdatei nicht anlegen")?;
ui::step(
4,
4,
"📦",
"Initialisiere SQLite-Containerstruktur & WAL-Modus...",
);
let db =
Database::open(container_path).context("Konnte SQLite-Containerdatei nicht anlegen")?;
db.init_schema_with_carrier(&salt, &kdf_params, &wrapped_dek, &header_nonce, &header_tag, None)
.context("Fehler bei der Schema-Initialisierung")?;
db.init_schema_with_carrier(
&salt,
&kdf_params,
&wrapped_dek,
&header_nonce,
&header_tag,
None,
)
.context("Fehler bei der Schema-Initialisierung")?;
db.checkpoint()
.context("Fehler beim finalen WAL-Checkpoint")?;
@@ -557,13 +641,22 @@ fn handle_init(
println!("└─────────────────────────────────────────────────────────────┘");
println!();
println!(" • Container: {}", container_path.display());
println!(" • Format: Version {} (Magic: SANCTUM\\0)", FORMAT_VERSION);
println!(
" • Format: Version {} (Magic: SANCTUM\\0)",
FORMAT_VERSION
);
println!(" • KDF: Argon2id (M=64MB, T=3, P=4)");
println!(" • Cipher: AES-256-GCM + LZ4-Kompression (1-MB Chunks, AEAD)");
println!(" • Dual-Vault: Slot 1 mit Zufallsrauschen initialisiert (inaktiv)");
println!();
println!(" Befehl zum Einbinden als Netzlaufwerk:");
println!(" {}", ui::cyan(&format!("sanctum mount --path \"{}\"", container_path.display())));
println!(
" {}",
ui::cyan(&format!(
"sanctum mount --path \"{}\"",
container_path.display()
))
);
println!();
ui::print_recovery_phrase_card(&recovery_phrase);
@@ -574,7 +667,10 @@ fn handle_init(
fn handle_compact(container_path: &Path, pages: Option<usize>) -> Result<()> {
if !container_path.exists() {
bail!("Containerdatei '{}' existiert nicht.", container_path.display());
bail!(
"Containerdatei '{}' existiert nicht.",
container_path.display()
);
}
println!("┌─────────────────────────────────────────────────────────────┐");
@@ -583,13 +679,16 @@ fn handle_compact(container_path: &Path, pages: Option<usize>) -> Result<()> {
println!(" Container: {}", container_path.display());
println!();
let db = Database::open(container_path)
.context("Konnte Container-Datenbank nicht öffnen")?;
let db = Database::open(container_path).context("Konnte Container-Datenbank nicht öffnen")?;
let freelist_before = db.freelist_count()
let freelist_before = db
.freelist_count()
.context("Konnte Freelist-Größe nicht ermitteln")?;
println!(" • Freie Seiten in Freelist: {}", ui::cyan(&freelist_before.to_string()));
println!(
" • Freie Seiten in Freelist: {}",
ui::cyan(&freelist_before.to_string())
);
if freelist_before == 0 {
println!(" • Der Container ist bereits optimal kompaktiert. Keine Bereinigung nötig.");
@@ -598,7 +697,8 @@ fn handle_compact(container_path: &Path, pages: Option<usize>) -> Result<()> {
}
ui::step(1, 2, "🧹", "Führe Incremental-Vacuum aus...");
let freed = db.incremental_vacuum(pages)
let freed = db
.incremental_vacuum(pages)
.context("Fehler bei der Speicherbereinigung (Incremental Vacuum)")?;
ui::step(2, 2, "💾", "Führe finalen WAL-Checkpoint durch...");
@@ -612,8 +712,14 @@ fn handle_compact(container_path: &Path, pages: Option<usize>) -> Result<()> {
println!("│ ✔ Container erfolgreich kompaktiert! │");
println!("└─────────────────────────────────────────────────────────────┘");
println!();
println!(" • Freigegebene Seiten: {}", ui::green(&freed.to_string()));
println!(" • Verbleibende Freeseiten: {}", ui::dim(&freelist_after.to_string()));
println!(
" • Freigegebene Seiten: {}",
ui::green(&freed.to_string())
);
println!(
" • Verbleibende Freeseiten: {}",
ui::dim(&freelist_after.to_string())
);
println!();
Ok(())
}
@@ -632,24 +738,29 @@ fn handle_passwd(container_path: &Path, recovery_key: Option<&str>) -> Result<()
println!(" Container: {}", container_path.display());
println!();
let db = Database::open(container_path)
.context("Konnte Container-Datenbank nicht öffnen")?;
let db = Database::open(container_path).context("Konnte Container-Datenbank nicht öffnen")?;
let (dek, target_slot_id, carrier_dek, carrier_node_id) = if let Some(actual_phrase) = resolve_recovery_key(recovery_key)? {
let (dek, target_slot_id, carrier_dek, carrier_node_id) = if let Some(actual_phrase) =
resolve_recovery_key(recovery_key)?
{
ui::step(1, 3, "🔑", "Lese DEK aus 24-Wort Notfallschlüssel...");
let d = mnemonic_to_dek(&actual_phrase).context("Ungültiger 24-Wort Notfallschlüssel")?;
ui::step(2, 3, "🔓", "Notfallschlüssel verifiziert!");
let meta = db.read_meta().context("Konnte Container-Header nicht lesen")?;
let meta = db
.read_meta()
.context("Konnte Container-Header nicht lesen")?;
let detected_slot = detect_recovery_key_slot(&db, &d, meta.version).unwrap_or(0);
if detected_slot == 1 {
println!(" Notfallschlüssel gehört zu Slot 1 (Hidden Vault).");
println!(" Zur Neuverpackung von Slot 1 wird das Passwort des Decoy-Tresors (Slot 0) benötigt.");
let decoy_pw = Zeroizing::new(
rpassword::prompt_password("Passwort für Decoy-Tresor (Slot 0): ")
.context("Fehler beim Einlesen des Decoy-Passworts")?
.context("Fehler beim Einlesen des Decoy-Passworts")?,
);
let decoy_auth = meta.authenticate(&decoy_pw).ok_or_else(|| anyhow::anyhow!("Ungültiges Decoy-Passwort! Authentifizierung fehlgeschlagen."))?;
let decoy_auth = meta.authenticate(&decoy_pw).ok_or_else(|| {
anyhow::anyhow!("Ungültiges Decoy-Passwort! Authentifizierung fehlgeschlagen.")
})?;
let c_dek = decoy_auth.dek().clone();
(d, 1, Some(c_dek), db.find_carrier_node_id()?)
} else {
@@ -671,10 +782,17 @@ fn handle_passwd(container_path: &Path, recovery_key: Option<&str>) -> Result<()
.read_meta()
.context("Konnte Container-Header nicht lesen")?;
ui::step(2, 4, "🔑", "Leite KEK via Argon2id ab & prüfe Passwort (konstante Zeit)...");
let keys = meta
.authenticate(&old_password)
.ok_or_else(|| anyhow::anyhow!("Ungültiges aktuelles Master-Passwort! Authentifizierung fehlgeschlagen."))?;
ui::step(
2,
4,
"🔑",
"Leite KEK via Argon2id ab & prüfe Passwort (konstante Zeit)...",
);
let keys = meta.authenticate(&old_password).ok_or_else(|| {
anyhow::anyhow!(
"Ungültiges aktuelles Master-Passwort! Authentifizierung fehlgeschlagen."
)
})?;
let d = keys.dek().clone();
let slot_id = keys.slot_id();
(d, slot_id, keys.carrier_dek(), keys.carrier_node_id())
@@ -697,14 +815,20 @@ fn handle_passwd(container_path: &Path, recovery_key: Option<&str>) -> Result<()
}
println!();
ui::step(3, 4, "🔒", "Generiere frisches Salt & leite neuen KEK ab...");
ui::step(
3,
4,
"🔒",
"Generiere frisches Salt & leite neuen KEK ab...",
);
let new_salt = generate_salt();
let new_params = KdfParams::default();
let new_kek = derive_kek(&new_password, &new_salt, &new_params)
.context("KDF-Schlüsselableitung mit neuem Passwort fehlgeschlagen")?;
let (new_wrapped_dek, new_nonce, new_tag) = if target_slot_id == 1 {
let c_dek = carrier_dek.ok_or_else(|| anyhow::anyhow!("DEK_0 (Träger-Schlüssel) für Slot 1 fehlt"))?;
let c_dek = carrier_dek
.ok_or_else(|| anyhow::anyhow!("DEK_0 (Träger-Schlüssel) für Slot 1 fehlt"))?;
let c_nid = carrier_node_id.unwrap_or(0);
wrap_slot1_payload(&new_kek, &dek, &c_dek, c_nid)
.context("Slot-1 Wrapping mit neuem KEK fehlgeschlagen")?
@@ -714,7 +838,12 @@ fn handle_passwd(container_path: &Path, recovery_key: Option<&str>) -> Result<()
.context("Slot-0 Wrapping mit neuem KEK fehlgeschlagen")?
};
ui::step(4, 4, "💾", "Aktualisiere Container-Header & führe Checkpoint aus...");
ui::step(
4,
4,
"💾",
"Aktualisiere Container-Header & führe Checkpoint aus...",
);
db.update_slot_keys(
target_slot_id,
&new_salt,
@@ -774,10 +903,16 @@ fn handle_backup_header(container_path: &Path, output_path: Option<&Path>) -> Re
fn handle_backup(container_path: &Path, output_path: &Path) -> Result<()> {
if !container_path.exists() {
bail!("Containerdatei '{}' existiert nicht.", container_path.display());
bail!(
"Containerdatei '{}' existiert nicht.",
container_path.display()
);
}
if output_path.exists() {
bail!("Zieldatei '{}' existiert bereits. Bitte wählen Sie einen anderen Pfad.", output_path.display());
bail!(
"Zieldatei '{}' existiert bereits. Bitte wählen Sie einen anderen Pfad.",
output_path.display()
);
}
println!("┌─────────────────────────────────────────────────────────────┐");
@@ -787,11 +922,20 @@ fn handle_backup(container_path: &Path, output_path: &Path) -> Result<()> {
println!(" Backup-Ziel: {}", output_path.display());
println!();
ui::step(1, 2, "📦", "Öffne Container & initialisiere Online-Backup-Stream...");
let db = Database::open(container_path)
.context("Konnte Container-Datenbank nicht öffnen")?;
ui::step(
1,
2,
"📦",
"Öffne Container & initialisiere Online-Backup-Stream...",
);
let db = Database::open(container_path).context("Konnte Container-Datenbank nicht öffnen")?;
ui::step(2, 2, "💾", "Kopiere Datenbankseiten konsistent via SQLite Backup API...");
ui::step(
2,
2,
"💾",
"Kopiere Datenbankseiten konsistent via SQLite Backup API...",
);
db.online_backup(output_path)
.context("Fehler beim Erstellen des Online-Backups")?;
@@ -801,7 +945,9 @@ fn handle_backup(container_path: &Path, output_path: &Path) -> Result<()> {
println!("└─────────────────────────────────────────────────────────────┘");
println!();
println!(" • Backup-Datei: {}", output_path.display());
println!(" • Status: Konsistent & verifiziert (kann im laufenden Betrieb gesichert werden)");
println!(
" • Status: Konsistent & verifiziert (kann im laufenden Betrieb gesichert werden)"
);
println!();
Ok(())
@@ -826,7 +972,12 @@ fn handle_restore(backup_path: &Path, output_path: &Path) -> Result<()> {
Database::restore_from_backup(backup_path, output_path)
.context("Fehler bei der Container-Wiederherstellung aus dem Backup")?;
ui::step(2, 2, "🔍", "B-Tree Integritätsprüfung (PRAGMA quick_check) erfolgreich!");
ui::step(
2,
2,
"🔍",
"B-Tree Integritätsprüfung (PRAGMA quick_check) erfolgreich!",
);
println!();
println!("┌─────────────────────────────────────────────────────────────┐");
@@ -856,9 +1007,17 @@ fn handle_restore_header(
println!(" Verwende Backup-Datei: {}", hdr.display());
ui::step(1, 2, "📦", "Lese und validiere Header-Backup...");
restore_header_backup(container_path, hdr)?;
ui::step(2, 2, "💾", "Header in Container-Datenbank zurückgeschrieben!");
ui::step(
2,
2,
"💾",
"Header in Container-Datenbank zurückgeschrieben!",
);
println!();
println!("{}", ui::green("✔ Header erfolgreich aus Sicherungsdatei wiederhergestellt!"));
println!(
"{}",
ui::green("✔ Header erfolgreich aus Sicherungsdatei wiederhergestellt!")
);
} else {
let key_str = if let Some(key) = resolve_recovery_key(recovery_key)? {
key
@@ -887,7 +1046,8 @@ fn handle_restore_header(
};
let dek = mnemonic_to_dek(&key_str).context("Ungültiger 24-Wort Notfallschlüssel")?;
let db = Database::open(container_path).context("Konnte Container-Datenbank nicht öffnen")?;
let db =
Database::open(container_path).context("Konnte Container-Datenbank nicht öffnen")?;
let effective_slot = if slot == 0 {
detect_recovery_key_slot(&db, &dek, FORMAT_VERSION).unwrap_or(0)
@@ -895,7 +1055,10 @@ fn handle_restore_header(
slot
};
println!(" Verwende 24-Wort BIP-39 Notfallschlüssel (Ziel-Slot {})...", effective_slot);
println!(
" Verwende 24-Wort BIP-39 Notfallschlüssel (Ziel-Slot {})...",
effective_slot
);
let new_password = Zeroizing::new(
rpassword::prompt_password("Neues Master-Passwort festlegen: ")
.context("Fehler beim Einlesen des Passworts")?,
@@ -916,10 +1079,14 @@ fn handle_restore_header(
println!(" Zur mathematischen Rekonstruktion wird das Passwort des Decoy-Tresors (Slot 0) benötigt.");
let decoy_pw = Zeroizing::new(
rpassword::prompt_password("Passwort für Decoy-Tresor (Slot 0): ")
.context("Fehler beim Einlesen des Decoy-Passworts")?
.context("Fehler beim Einlesen des Decoy-Passworts")?,
);
let meta = db.read_meta().context("Konnte Container-Header nicht lesen")?;
let decoy_auth = meta.authenticate(&decoy_pw).ok_or_else(|| anyhow::anyhow!("Ungültiges Decoy-Passwort! Authentifizierung fehlgeschlagen."))?;
let meta = db
.read_meta()
.context("Konnte Container-Header nicht lesen")?;
let decoy_auth = meta.authenticate(&decoy_pw).ok_or_else(|| {
anyhow::anyhow!("Ungültiges Decoy-Passwort! Authentifizierung fehlgeschlagen.")
})?;
Some(**decoy_auth.dek())
} else {
None
@@ -927,10 +1094,27 @@ fn handle_restore_header(
let dek_0_opt = dek_0_holder.as_ref();
ui::step(1, 2, "🔑", "Dekodiere DEK & leite neuen KEK ab...");
restore_slot_from_recovery_key(container_path, &key_str, &new_password, effective_slot, dek_0_opt)?;
ui::step(2, 2, "💾", &format!("Header für Slot {} mit neuem Passwort neu synthetisiert!", effective_slot));
restore_slot_from_recovery_key(
container_path,
&key_str,
&new_password,
effective_slot,
dek_0_opt,
)?;
ui::step(
2,
2,
"💾",
&format!(
"Header für Slot {} mit neuem Passwort neu synthetisiert!",
effective_slot
),
);
println!();
println!("{}", ui::green("✔ Container-Header via Notfallschlüssel erfolgreich rekonstruiert!"));
println!(
"{}",
ui::green("✔ Container-Header via Notfallschlüssel erfolgreich rekonstruiert!")
);
}
println!();
@@ -939,7 +1123,10 @@ fn handle_restore_header(
fn handle_recovery_key(container_path: &Path) -> Result<()> {
if !container_path.exists() {
bail!("Containerdatei '{}' existiert nicht.", container_path.display());
bail!(
"Containerdatei '{}' existiert nicht.",
container_path.display()
);
}
println!("┌─────────────────────────────────────────────────────────────┐");
@@ -953,9 +1140,10 @@ fn handle_recovery_key(container_path: &Path) -> Result<()> {
.context("Fehler beim Einlesen des Passworts")?,
);
let db = Database::open(container_path)
.context("Konnte Container-Datenbank nicht öffnen")?;
let meta = db.read_meta().context("Konnte Container-Header nicht lesen")?;
let db = Database::open(container_path).context("Konnte Container-Datenbank nicht öffnen")?;
let meta = db
.read_meta()
.context("Konnte Container-Header nicht lesen")?;
let keys = meta
.authenticate(&password)
@@ -970,7 +1158,10 @@ fn handle_recovery_key(container_path: &Path) -> Result<()> {
fn handle_verify(container_path: &Path, full: bool) -> Result<()> {
if !container_path.exists() {
bail!("Containerdatei '{}' existiert nicht.", container_path.display());
bail!(
"Containerdatei '{}' existiert nicht.",
container_path.display()
);
}
println!("┌─────────────────────────────────────────────────────────────┐");
@@ -994,12 +1185,18 @@ fn handle_verify(container_path: &Path, full: bool) -> Result<()> {
Some(keys.dek().clone())
}
None => {
println!(" {} Passwort falsch! Führe nur SQLite- und Strukturprüfung durch.", ui::yellow("⚠️"));
println!(
" {} Passwort falsch! Führe nur SQLite- und Strukturprüfung durch.",
ui::yellow("⚠️")
);
None
}
}
} else {
println!(" {} Kein Passwort angegeben. Führe nur Strukturprüfung durch.", ui::dim(""));
println!(
" {} Kein Passwort angegeben. Führe nur Strukturprüfung durch.",
ui::dim("")
);
None
};
@@ -1026,7 +1223,10 @@ fn handle_sync(
recovery_key: Option<&str>,
) -> Result<()> {
if !container_path.exists() {
bail!("Containerdatei '{}' existiert nicht.", container_path.display());
bail!(
"Containerdatei '{}' existiert nicht.",
container_path.display()
);
}
let auth = if let Some(phrase_str) = resolve_recovery_key(recovery_key)? {
@@ -1042,9 +1242,10 @@ fn handle_sync(
ContainerAuth::Password(Zeroizing::new(pass))
};
let db = Database::open(container_path)
.context("Konnte Container-Datenbank nicht öffnen")?;
let meta = db.read_meta().context("Konnte Container-Header nicht lesen")?;
let db = Database::open(container_path).context("Konnte Container-Datenbank nicht öffnen")?;
let meta = db
.read_meta()
.context("Konnte Container-Header nicht lesen")?;
let (dek, version, vault_id) = match auth {
ContainerAuth::Password(ref password) => {
@@ -1054,8 +1255,7 @@ fn handle_sync(
(keys.dek().clone(), meta.version, keys.slot_id())
}
ContainerAuth::RecoveryKey(ref phrase) => {
let dek = mnemonic_to_dek(phrase)
.context("Ungültiger 24-Wort Notfallschlüssel")?;
let dek = mnemonic_to_dek(phrase).context("Ungültiger 24-Wort Notfallschlüssel")?;
let v_id = detect_recovery_key_slot(&db, &dek, meta.version).unwrap_or(0);
(dek, meta.version, v_id)
}
@@ -1085,34 +1285,35 @@ fn handle_sync(
println!();
if dry_run {
println!(" {} Ausführung im SIMULATIONS-MODUS (-n / --dry-run)!", ui::yellow("[!]"));
println!(
" {} Ausführung im SIMULATIONS-MODUS (-n / --dry-run)!",
ui::yellow("[!]")
);
}
println!(" • Richtung: {}", ui::cyan(dir_str));
println!(" • Quelle: {}", source);
println!(" • Ziel: {}", target);
if delete {
println!(" • Spiegelung: {} (Dateien im Ziel ohne Entsprechung werden gelöscht)", ui::red("--delete aktiv"));
println!(
" • Spiegelung: {} (Dateien im Ziel ohne Entsprechung werden gelöscht)",
ui::red("--delete aktiv")
);
}
if checksum {
println!(" • Modus: Kryptografischer Inhaltsabgleich (--checksum aktiv)");
}
if !options.exclude_patterns.is_empty() {
println!(" • Filter: {} Ausschlussmuster", options.exclude_patterns.len());
println!(
" • Filter: {} Ausschlussmuster",
options.exclude_patterns.len()
);
}
println!();
println!(" {} Starte Synchronisation ...", ui::dim("[-]"));
println!();
}
let stats = sanctum::sync::run_sync(
&db,
vault_id,
&dek,
version,
source,
target,
&options,
)?;
let stats = sanctum::sync::run_sync(&db, vault_id, &dek, version, source, target, &options)?;
if !dry_run {
let _ = db.checkpoint();
@@ -1124,27 +1325,59 @@ fn handle_sync(
println!("┌─────────────────────────────────────────────────────────────┐");
println!(" Dry-Run Simulation erfolgreich abgeschlossen │");
println!("└─────────────────────────────────────────────────────────────┘");
println!(" • Gescannt: {} Dateien/Objekte", stats.files_scanned);
println!(" • Zu übertragen: {} Dateien ({})", stats.files_transferred, ui::format_bytes(stats.bytes_transferred));
println!(" • Bereits aktuell: {} Dateien (übersprungen)", stats.files_skipped);
println!(
" • Gescannt: {} Dateien/Objekte",
stats.files_scanned
);
println!(
" • Zu übertragen: {} Dateien ({})",
stats.files_transferred,
ui::format_bytes(stats.bytes_transferred)
);
println!(
" • Bereits aktuell: {} Dateien (übersprungen)",
stats.files_skipped
);
if delete {
println!(" • Zu löschen: {} Dateien/Ordner", stats.files_deleted);
println!(
" • Zu löschen: {} Dateien/Ordner",
stats.files_deleted
);
}
println!(" • Hinweis: {} Es wurden keine Änderungen vorgenommen.", ui::yellow(""));
println!(
" • Hinweis: {} Es wurden keine Änderungen vorgenommen.",
ui::yellow("")
);
} else {
println!("┌─────────────────────────────────────────────────────────────┐");
println!("│ ✔ Synchronisation erfolgreich abgeschlossen │");
println!("└─────────────────────────────────────────────────────────────┘");
println!(" • Gescannt: {} Dateien/Objekte", stats.files_scanned);
println!(" • Übertragen: {} Dateien ({})", stats.files_transferred, ui::format_bytes(stats.bytes_transferred));
println!(" • Übersprungen: {} Dateien (bereits aktuell)", stats.files_skipped);
println!(
" • Gescannt: {} Dateien/Objekte",
stats.files_scanned
);
println!(
" • Übertragen: {} Dateien ({})",
stats.files_transferred,
ui::format_bytes(stats.bytes_transferred)
);
println!(
" • Übersprungen: {} Dateien (bereits aktuell)",
stats.files_skipped
);
if delete {
println!(" • Gelöscht: {} verwaiste Dateien/Ordner", stats.files_deleted);
println!(
" • Gelöscht: {} verwaiste Dateien/Ordner",
stats.files_deleted
);
}
let secs = stats.elapsed.as_secs_f64();
let mb = (stats.bytes_transferred as f64) / (1024.0 * 1024.0);
let speed = if secs > 0.05 { mb / secs } else { 0.0 };
println!(" • Dauer: {:.2}s (Durchschnitt: {:.2} MB/s)", secs, speed);
println!(
" • Dauer: {:.2}s (Durchschnitt: {:.2} MB/s)",
secs, speed
);
}
println!();
}
@@ -1247,11 +1480,19 @@ async fn run() -> Result<()> {
Commands::Unmount { drive } => {
let drive_char = parse_drive_letter(&drive)?;
let drive_str = format_drive(drive_char);
print!(" {} Trenne Windows-Netzlaufwerk {} ... ", ui::dim("[-]"), drive_str);
print!(
" {} Trenne Windows-Netzlaufwerk {} ... ",
ui::dim("[-]"),
drive_str
);
let _ = std::io::Write::flush(&mut std::io::stdout());
unmount_drive(drive_char)?;
println!("{}", ui::green("OK"));
println!("{} Laufwerk {} erfolgreich getrennt.", ui::green(""), drive_str);
println!(
"{} Laufwerk {} erfolgreich getrennt.",
ui::green(""),
drive_str
);
}
Commands::Passwd { path, recovery_key } => {
handle_passwd(&path, recovery_key.as_deref())?;
@@ -1304,7 +1545,10 @@ async fn run() -> Result<()> {
println!("└─────────────────────────────────────────────────────────────┘");
println!();
sanctum::windows::register_explorer_integration()?;
println!(" {} .sanctum-Dateien wurden erfolgreich im Windows Explorer verknüpft!", ui::green(""));
println!(
" {} .sanctum-Dateien wurden erfolgreich im Windows Explorer verknüpft!",
ui::green("")
);
println!(" • Doppelklick: Öffnet und bindet den Container direkt als Laufwerk ein");
println!(" • Rechtsklick: Bietet Optionen für Integritätsprüfung (FSCK) und Header-Backup");
println!();
@@ -1315,10 +1559,19 @@ async fn run() -> Result<()> {
println!("└─────────────────────────────────────────────────────────────┘");
println!();
sanctum::windows::unregister_explorer_integration()?;
println!(" {} .sanctum Dateiverknüpfungen wurden aus der Registry entfernt.", ui::green(""));
println!(
" {} .sanctum Dateiverknüpfungen wurden aus der Registry entfernt.",
ui::green("")
);
println!();
}
Commands::Upgrade { check, yes, force, url, insecure_url } => {
Commands::Upgrade {
check,
yes,
force,
url,
insecure_url,
} => {
let options = sanctum::upgrade::UpgradeOptions {
check_only: check,
yes,
@@ -1333,7 +1586,6 @@ async fn run() -> Result<()> {
Ok(())
}
#[tokio::main]
async fn main() {
// Windows Konsole für UTF-8 (Code Page 65001) und VT ANSI Processing konfigurieren
@@ -1353,4 +1605,3 @@ async fn main() {
std::process::exit(1);
}
}
+134 -80
View File
@@ -61,9 +61,7 @@ fn run_mount_command(drive_letter: char, port: u16, session_token: &str) -> Resu
#[cfg(unix)]
{
let dav_url = format!("dav://127.0.0.1:{}/", port);
let _ = Command::new("gio")
.args(["mount", &dav_url])
.output();
let _ = Command::new("gio").args(["mount", &dav_url]).output();
let _ = (drive_letter, session_token);
Ok(())
}
@@ -106,8 +104,7 @@ pub async fn mount_container(
println!();
ui::step(1, 4, "📦", "Öffne Container & verifiziere Header...");
}
let db = Database::open(container_path)
.context("Konnte Container-Datenbank nicht öffnen")?;
let db = Database::open(container_path).context("Konnte Container-Datenbank nicht öffnen")?;
let meta = db
.read_meta()
@@ -116,12 +113,22 @@ pub async fn mount_container(
let (dek, carrier_dek, carrier_node_id, version, vault_id) = match auth {
ContainerAuth::Password(ref password) => {
if !stealth {
ui::step(2, 4, "🔑", "Leite KEK via Argon2id ab (konstante Zeit über alle Slots)...");
ui::step(
2,
4,
"🔑",
"Leite KEK via Argon2id ab (konstante Zeit über alle Slots)...",
);
}
match meta.authenticate(password) {
Some(keys) => {
if !stealth {
ui::step(3, 4, "🔓", "Master-Passwort erfolgreich verifiziert & DEK entschlüsselt!");
ui::step(
3,
4,
"🔓",
"Master-Passwort erfolgreich verifiziert & DEK entschlüsselt!",
);
}
let vault_id = keys.slot_id();
let ver = keys.version();
@@ -138,8 +145,7 @@ pub async fn mount_container(
if !stealth {
ui::step(2, 4, "🔑", "Dekodiere DEK aus 24-Wort Notfallschlüssel...");
}
let dek = mnemonic_to_dek(phrase)
.context("Ungültiger 24-Wort Notfallschlüssel")?;
let dek = mnemonic_to_dek(phrase).context("Ungültiger 24-Wort Notfallschlüssel")?;
if !stealth {
ui::step(3, 4, "🔓", "Notfallschlüssel erfolgreich verifiziert!");
}
@@ -152,10 +158,12 @@ pub async fn mount_container(
println!(" [i] Der angegebene Notfallschlüssel gehört zum Hidden-Vault (Slot 1).");
println!(" Für den Zugriff auf die Trägerdatei wird das Passwort des Standard-Vaults benötigt.");
}
let decoy_pass = rpassword::prompt_password("Master-Passwort für Standard-Vault eingeben: ")
.context("Fehler beim Einlesen des Standard-Vault Passworts")?;
let decoy_keys = meta.authenticate(&decoy_pass)
.ok_or_else(|| anyhow::anyhow!("Ungültiges Passwort für Standard-Vault."))?;
let decoy_pass =
rpassword::prompt_password("Master-Passwort für Standard-Vault eingeben: ")
.context("Fehler beim Einlesen des Standard-Vault Passworts")?;
let decoy_keys = meta.authenticate(&decoy_pass).ok_or_else(|| {
anyhow::anyhow!("Ungültiges Passwort für Standard-Vault.")
})?;
(Some(decoy_keys.dek().clone()), Some(carrier_node_id), 1)
} else {
(None, Some(carrier_node_id), 0)
@@ -164,7 +172,13 @@ pub async fn mount_container(
(None, None, 0)
};
(dek, carrier_dek, final_carrier_node_id, meta.version, vault_id)
(
dek,
carrier_dek,
final_carrier_node_id,
meta.version,
vault_id,
)
}
};
@@ -340,7 +354,10 @@ pub async fn mount_container(
println!();
println!(" • Container: {}", container_path.display());
#[cfg(windows)]
println!(" • Netzlaufwerk: {} (im Windows Explorer bereit)", ui::cyan(&drive_str));
println!(
" • Netzlaufwerk: {} (im Windows Explorer bereit)",
ui::cyan(&drive_str)
);
#[cfg(not(windows))]
{
if let Some(mp) = mount_point {
@@ -349,12 +366,22 @@ pub async fn mount_container(
println!(" • Modus: WebDAV Userland-VFS");
}
}
println!(" • WebDAV-URL: http://127.0.0.1:{}/ (lokal geschützt)", bound_port);
println!(
" • WebDAV-URL: http://127.0.0.1:{}/ (lokal geschützt)",
bound_port
);
#[cfg(not(windows))]
{
println!(" • gio Befehl: gio mount dav://127.0.0.1:{}/", bound_port);
println!(
" • gio Befehl: gio mount dav://127.0.0.1:{}/",
bound_port
);
if let Some(mp) = mount_point {
println!(" • davfs2: mount -t davfs http://127.0.0.1:{}/ {}", bound_port, mp.display());
println!(
" • davfs2: mount -t davfs http://127.0.0.1:{}/ {}",
bound_port,
mp.display()
);
}
}
if let Some(secs) = idle_timeout {
@@ -376,9 +403,15 @@ pub async fn mount_container(
}
println!();
#[cfg(windows)]
println!(" [{}] Drücke [Ctrl+C] oder nutze das Tray-Icon zum Beenden.", ui::yellow("Tipp"));
println!(
" [{}] Drücke [Ctrl+C] oder nutze das Tray-Icon zum Beenden.",
ui::yellow("Tipp")
);
#[cfg(not(windows))]
println!(" [{}] Drücke [Ctrl+C] zum sicheren Beenden.", ui::yellow("Tipp"));
println!(
" [{}] Drücke [Ctrl+C] zum sicheren Beenden.",
ui::yellow("Tipp")
);
println!();
}
@@ -386,11 +419,13 @@ pub async fn mount_container(
#[cfg(windows)]
let (console_close_tx, mut console_close_rx) = tokio::sync::mpsc::channel::<()>(1);
#[cfg(windows)]
let _console_guard = crate::windows::start_console_ctrl_monitor(console_close_tx, drive_letter).ok();
let _console_guard =
crate::windows::start_console_ctrl_monitor(console_close_tx, drive_letter).ok();
// Unix Signale (SIGTERM, SIGHUP)
#[cfg(unix)]
let mut sigterm = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()).ok();
let mut sigterm =
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()).ok();
#[cfg(unix)]
let mut sighup = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::hangup()).ok();
@@ -503,7 +538,11 @@ pub async fn mount_container(
} else {
#[cfg(windows)]
{
print!(" {} Trenne Windows-Netzlaufwerk {} ... ", ui::dim("[-]"), drive_str);
print!(
" {} Trenne Windows-Netzlaufwerk {} ... ",
ui::dim("[-]"),
drive_str
);
let _ = std::io::Write::flush(&mut std::io::stdout());
// Automatisches Unmount
@@ -527,7 +566,11 @@ pub async fn mount_container(
// Storage-Kompaktierung (Incremental Vacuum), falls freie Seiten existieren
if let Ok(freelist) = db.freelist_count() {
if freelist > 0 {
print!(" {} Führe Storage-Kompaktierung aus ({} freie Seiten) ... ", ui::dim("[-]"), freelist);
print!(
" {} Führe Storage-Kompaktierung aus ({} freie Seiten) ... ",
ui::dim("[-]"),
freelist
);
let _ = std::io::Write::flush(&mut std::io::stdout());
match db.incremental_vacuum(None) {
Ok(freed) => println!("{} ({} Seiten freigegeben)", ui::green("OK"), freed),
@@ -546,7 +589,10 @@ pub async fn mount_container(
}
println!();
println!("{} Sanctum Container wurde sicher und vollständig geschlossen.", ui::green(""));
println!(
"{} Sanctum Container wurde sicher und vollständig geschlossen.",
ui::green("")
);
println!();
}
@@ -616,6 +662,7 @@ const HTTP_HEADER_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_
/// Hilfsfunktion zur Validierung von HTTP Basic Auth.
/// Unterstützt Format `username:password` (wobei Passwort dem Session-Token entspricht).
/// SA-06: Verwendet strikten Constant-Time-Vergleich gegen Timing-Side-Channel-Angriffe.
pub fn check_basic_auth(auth_header: &str, expected_token: &str) -> bool {
let auth_str = auth_header.trim();
let encoded = if let Some(rest) = auth_str.strip_prefix("Basic ") {
@@ -637,44 +684,36 @@ pub fn check_basic_auth(auth_header: &str, expected_token: &str) -> bool {
Err(_) => return false,
};
use subtle::ConstantTimeEq;
if let Some((_user, pass)) = decoded_str.split_once(':') {
pass == expected_token
pass.as_bytes().ct_eq(expected_token.as_bytes()).into()
} else {
false
}
}
/// Hilfsfunktion zur Validierung des X-Sanctum-Token Headers.
/// SA-06: Verwendet strikten Constant-Time-Vergleich gegen Timing-Side-Channel-Angriffe.
pub fn check_token_header(headers: &hyper::HeaderMap, expected_token: &str) -> bool {
use subtle::ConstantTimeEq;
if let Some(val) = headers.get("X-Sanctum-Token") {
if let Ok(val_str) = val.to_str() {
return val_str.trim() == expected_token;
return val_str
.trim()
.as_bytes()
.ct_eq(expected_token.as_bytes())
.into();
}
}
false
}
/// Schneidet ein Pfad-Präfix /<session_token> aus der Request-URI heraus (Defense-in-Depth Fallback).
pub fn strip_path_prefix(uri: &hyper::Uri, prefix: &str) -> Option<hyper::Uri> {
let path = uri.path();
if !path.starts_with(prefix) {
return None;
/// Prüft, ob eine URI oder deren Query-Parameter das sensible Session-Token enthält (SA-05 / CWE-598).
pub fn uri_contains_token(uri: &hyper::Uri, session_token: &str) -> bool {
if session_token.is_empty() {
return false;
}
let rest = &path[prefix.len()..];
let new_path = if rest.is_empty() || !rest.starts_with('/') {
format!("/{}", rest)
} else {
rest.to_string()
};
let path_and_query = match uri.query() {
Some(q) => format!("{}?{}", new_path, q),
None => new_path,
};
let mut parts = uri.clone().into_parts();
parts.path_and_query = Some(path_and_query.parse().ok()?);
hyper::Uri::from_parts(parts).ok()
uri.path().contains(session_token) || uri.query().unwrap_or("").contains(session_token)
}
/// Erzeugt eine standardkonforme HTTP 401 Unauthorized Antwort mit WWW-Authenticate Header.
@@ -687,15 +726,15 @@ fn unauthorized_response() -> hyper::Response<dav_server::body::Body> {
.unwrap()
}
/// Führt die asynchrone WebDAV HTTP-Server-Schleife mit Multi-Auth & Host-Header Sicherheits-Middleware aus.
/// Führt die asynchrone WebDAV HTTP-Server-Schleife mit Header-basierter Authentifizierung & Host-Header Sicherheits-Middleware aus.
pub async fn serve_webdav_loop(
listener: TcpListener,
dav_server: DavHandler,
session_token: String,
mut shutdown_rx: watch::Receiver<bool>,
) {
let conn_semaphore = std::sync::Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_DAV_CONNECTIONS));
let token_prefix = format!("/{}", session_token);
let conn_semaphore =
std::sync::Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_DAV_CONNECTIONS));
loop {
tokio::select! {
@@ -724,15 +763,13 @@ pub async fn serve_webdav_loop(
let io = TokioIo::new(stream);
let handler = dav_server.clone();
let expected_token = session_token.clone();
let expected_prefix = token_prefix.clone();
tokio::spawn(async move {
let _permit = permit; // Permit wird bei Verbindungsende automatisch freigegeben
let service = service_fn(move |mut req| {
let service = service_fn(move |req| {
let h = handler.clone();
let token = expected_token.clone();
let prefix = expected_prefix.clone();
async move {
// 1. RT-02: Strikte Fail-Closed Host-Header Validierung (Anti-DNS-Rebinding & Anti-Spoofing)
let host_valid = match req.headers().get(hyper::header::HOST) {
@@ -750,15 +787,27 @@ pub async fn serve_webdav_loop(
);
let res = hyper::Response::builder()
.status(hyper::StatusCode::FORBIDDEN)
.header(hyper::header::CONTENT_LENGTH, "0")
.body(dav_server::body::Body::empty())
.unwrap();
return Ok::<_, Infallible>(res);
}
// 2. R-05: Multi-Auth Middleware
// 2. SA-05: Verhindere Session-Token in Request-URI oder Query (CWE-598).
// Session-Tokens dürfen ausschließlich in HTTP-Headern übertragen werden.
if uri_contains_token(req.uri(), &token) {
warn!("Abgewiesener Zugriff: Session-Token in URI/Query übermittelt (SA-05 / CWE-598)");
let res = hyper::Response::builder()
.status(hyper::StatusCode::FORBIDDEN)
.header(hyper::header::CONTENT_LENGTH, "0")
.body(dav_server::body::Body::empty())
.unwrap();
return Ok::<_, Infallible>(res);
}
// 3. SA-05 & SA-06: Header-basierte Authentifizierung mit Constant-Time Token-Vergleich
// a) HTTP Basic Auth (Authorization: Basic ...)
// b) Header X-Sanctum-Token
// c) Pfad-Präfix Fallback (/<session_token>/...)
let mut authenticated = false;
if let Some(auth_val) = req.headers().get(hyper::header::AUTHORIZATION) {
@@ -773,15 +822,6 @@ pub async fn serve_webdav_loop(
authenticated = true;
}
if !authenticated {
if let Some(rewritten_uri) = strip_path_prefix(req.uri(), &prefix) {
*req.uri_mut() = rewritten_uri;
authenticated = true;
}
} else if let Some(rewritten_uri) = strip_path_prefix(req.uri(), &prefix) {
*req.uri_mut() = rewritten_uri;
}
if !authenticated {
debug!("Abgewiesener unauthentifizierter Zugriff auf: {}", req.uri().path());
return Ok::<_, Infallible>(unauthorized_response());
@@ -841,15 +881,24 @@ mod tests {
use base64::Engine;
let token = "deadbeefcafebabe0123456789abcdef";
let creds = format!("sanctum:{}", token);
let header_val = format!("Basic {}", base64::engine::general_purpose::STANDARD.encode(creds));
let header_val = format!(
"Basic {}",
base64::engine::general_purpose::STANDARD.encode(creds)
);
assert!(check_basic_auth(&header_val, token));
// Kleingeschriebenes basic Präfix
let lower_header = format!("basic {}", base64::engine::general_purpose::STANDARD.encode(format!("user:{}", token)));
let lower_header = format!(
"basic {}",
base64::engine::general_purpose::STANDARD.encode(format!("user:{}", token))
);
assert!(check_basic_auth(&lower_header, token));
// Falsches Token
let wrong_token_header = format!("Basic {}", base64::engine::general_purpose::STANDARD.encode("sanctum:wrongtoken"));
let wrong_token_header = format!(
"Basic {}",
base64::engine::general_purpose::STANDARD.encode("sanctum:wrongtoken")
);
assert!(!check_basic_auth(&wrong_token_header, token));
// Ungültiges Base64 oder Format
@@ -873,22 +922,27 @@ mod tests {
}
#[test]
fn test_strip_path_prefix() {
fn test_uri_contains_token_rejection() {
let token = "deadbeefcafebabe0123456789abcdef";
let prefix = format!("/{}", token);
let uri: hyper::Uri = format!("http://127.0.0.1:8443/{}/Photos/vacation.jpg?sort=date", token).parse().unwrap();
let stripped = strip_path_prefix(&uri, &prefix).expect("Should strip prefix");
assert_eq!(stripped.path(), "/Photos/vacation.jpg");
assert_eq!(stripped.query(), Some("sort=date"));
// Token im Pfad
let uri_path: hyper::Uri = format!("http://127.0.0.1:8443/{}/Photos/vacation.jpg", token)
.parse()
.unwrap();
assert!(uri_contains_token(&uri_path, token));
let uri_root: hyper::Uri = format!("http://127.0.0.1:8443/{}", token).parse().unwrap();
let stripped_root = strip_path_prefix(&uri_root, &prefix).expect("Should strip root");
assert_eq!(stripped_root.path(), "/");
// Token im Query-String
let uri_query: hyper::Uri =
format!("http://127.0.0.1:8443/Photos/vacation.jpg?token={}", token)
.parse()
.unwrap();
assert!(uri_contains_token(&uri_query, token));
let uri_no_prefix: hyper::Uri = "http://127.0.0.1:8443/other/path".parse().unwrap();
assert!(strip_path_prefix(&uri_no_prefix, &prefix).is_none());
// Harmloser Pfad ohne Token
let uri_clean: hyper::Uri = "http://127.0.0.1:8443/Photos/vacation.jpg".parse().unwrap();
assert!(!uri_contains_token(&uri_clean, token));
// Leeres Token darf niemals matchen
assert!(!uri_contains_token(&uri_clean, ""));
}
}
+7 -5
View File
@@ -1,4 +1,4 @@
use anyhow::{bail, Result};
use anyhow::{bail, Result};
/// Validiert einen Datei- oder Verzeichnisnamen gegen Path-Traversal, Null-Bytes,
/// unzulässige Steuerzeichen und Windows-reservierte Gerätenamen (S-08, R-03).
@@ -7,7 +7,10 @@ pub fn validate_node_name(name: &str) -> Result<()> {
bail!("Dateiname darf nicht leer sein.");
}
if name == "." || name == ".." {
bail!("Ungültiger Dateiname (Verzeichnisreferenz verboten): '{}'", name);
bail!(
"Ungültiger Dateiname (Verzeichnisreferenz verboten): '{}'",
name
);
}
if name.contains('/') || name.contains('\\') || name.contains('\0') {
bail!(
@@ -29,9 +32,8 @@ pub fn validate_node_name(name: &str) -> Result<()> {
};
let base_upper = base_name.to_ascii_uppercase();
let reserved = [
"CON", "PRN", "AUX", "NUL",
"COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9",
"LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
"CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8",
"COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
];
if reserved.contains(&base_upper.as_str()) {
bail!(
+114 -34
View File
@@ -85,11 +85,31 @@ impl HeaderBackup {
let mut slots = Vec::new();
if !self.slots.is_empty() {
if self.slots.len() > 2 {
bail!(
"Ungültige Slot-Anzahl im Backup: {} (maximal 2 erlaubt)",
self.slots.len()
);
}
let mut seen_ids = std::collections::HashSet::new();
for s in &self.slots {
let salt_bytes = hex::decode(&s.kdf_salt_hex)
.context("Ungültige Hex-Kodierung für KDF-Salt")?;
if s.slot_id > 1 {
bail!("Ungültige Slot-ID {} im Backup: Es sind ausschließlich die Slot-IDs 0 und 1 erlaubt", s.slot_id);
}
if !seen_ids.insert(s.slot_id) {
bail!("Doppelte Slot-ID {} im Backup entdeckt", s.slot_id);
}
crate::crypto::validate_kdf_params(&s.kdf_params).map_err(|e| {
anyhow::anyhow!("KDF-Parameter in Slot {} ungültig: {e}", s.slot_id)
})?;
let salt_bytes =
hex::decode(&s.kdf_salt_hex).context("Ungültige Hex-Kodierung für KDF-Salt")?;
if salt_bytes.len() != 16 {
bail!("Ungültige Salt-Länge im Backup: erwartet 16 Bytes, erhalten {}", salt_bytes.len());
bail!(
"Ungültige Salt-Länge im Backup: erwartet 16 Bytes, erhalten {}",
salt_bytes.len()
);
}
let mut kdf_salt = [0u8; 16];
kdf_salt.copy_from_slice(&salt_bytes);
@@ -97,10 +117,24 @@ impl HeaderBackup {
let wrapped_dek = hex::decode(&s.wrapped_dek_hex)
.context("Ungültige Hex-Kodierung für wrapped_dek")?;
if s.slot_id == 0 {
if wrapped_dek.len() != 40 && wrapped_dek.len() != 32 {
bail!("Ungültige wrapped_dek-Länge in Slot 0: {} Bytes (erwartet: 40 oder 32)", wrapped_dek.len());
}
} else if s.slot_id == 1 {
if wrapped_dek.len() != 72 && wrapped_dek.len() != 64 && wrapped_dek.len() != 32
{
bail!("Ungültige wrapped_dek-Länge in Slot 1: {} Bytes (erwartet: 72, 64 oder 32)", wrapped_dek.len());
}
}
let nonce_bytes = hex::decode(&s.header_nonce_hex)
.context("Ungültige Hex-Kodierung für Header-Nonce")?;
if nonce_bytes.len() != 12 {
bail!("Ungültige Nonce-Länge im Backup: erwartet 12 Bytes, erhalten {}", nonce_bytes.len());
bail!(
"Ungültige Nonce-Länge im Backup: erwartet 12 Bytes, erhalten {}",
nonce_bytes.len()
);
}
let mut header_nonce = [0u8; 12];
header_nonce.copy_from_slice(&nonce_bytes);
@@ -108,7 +142,10 @@ impl HeaderBackup {
let tag_bytes = hex::decode(&s.header_tag_hex)
.context("Ungültige Hex-Kodierung für Header-Tag")?;
if tag_bytes.len() != 16 {
bail!("Ungültige Tag-Länge im Backup: erwartet 16 Bytes, erhalten {}", tag_bytes.len());
bail!(
"Ungültige Tag-Länge im Backup: erwartet 16 Bytes, erhalten {}",
tag_bytes.len()
);
}
let mut header_tag = [0u8; 16];
header_tag.copy_from_slice(&tag_bytes);
@@ -123,12 +160,19 @@ impl HeaderBackup {
header_tag,
});
}
if !seen_ids.contains(&0) {
bail!("Ungültiges Backup: Slot 0 (Standard/Decoy Vault) fehlt");
}
} else {
// Fallback für alte Backups ohne slots-Array
let salt_bytes = hex::decode(&self.kdf_salt_hex)
.context("Ungültige Hex-Kodierung für KDF-Salt")?;
let salt_bytes =
hex::decode(&self.kdf_salt_hex).context("Ungültige Hex-Kodierung für KDF-Salt")?;
if salt_bytes.len() != 16 {
bail!("Ungültige Salt-Länge im Backup: erwartet 16 Bytes, erhalten {}", salt_bytes.len());
bail!(
"Ungültige Salt-Länge im Backup: erwartet 16 Bytes, erhalten {}",
salt_bytes.len()
);
}
let mut kdf_salt = [0u8; 16];
kdf_salt.copy_from_slice(&salt_bytes);
@@ -139,7 +183,10 @@ impl HeaderBackup {
let nonce_bytes = hex::decode(&self.header_nonce_hex)
.context("Ungültige Hex-Kodierung für Header-Nonce")?;
if nonce_bytes.len() != 12 {
bail!("Ungültige Nonce-Länge im Backup: erwartet 12 Bytes, erhalten {}", nonce_bytes.len());
bail!(
"Ungültige Nonce-Länge im Backup: erwartet 12 Bytes, erhalten {}",
nonce_bytes.len()
);
}
let mut header_nonce = [0u8; 12];
header_nonce.copy_from_slice(&nonce_bytes);
@@ -147,7 +194,10 @@ impl HeaderBackup {
let tag_bytes = hex::decode(&self.header_tag_hex)
.context("Ungültige Hex-Kodierung für Header-Tag")?;
if tag_bytes.len() != 16 {
bail!("Ungültige Tag-Länge im Backup: erwartet 16 Bytes, erhalten {}", tag_bytes.len());
bail!(
"Ungültige Tag-Länge im Backup: erwartet 16 Bytes, erhalten {}",
tag_bytes.len()
);
}
let mut header_tag = [0u8; 16];
header_tag.copy_from_slice(&tag_bytes);
@@ -180,19 +230,28 @@ impl HeaderBackup {
/// Sichert die Header-Metadaten eines Containers in eine externe Backup-Datei (.sanctum.hdr).
pub fn export_header_backup(container_path: &Path, backup_path: &Path) -> Result<()> {
if !container_path.exists() {
bail!("Containerdatei '{}' existiert nicht.", container_path.display());
bail!(
"Containerdatei '{}' existiert nicht.",
container_path.display()
);
}
let db = Database::open(container_path)
.context("Konnte Container-Datenbank zum Lesen des Headers nicht öffnen")?;
let meta = db.read_meta().context("Konnte Container-Header nicht lesen")?;
let meta = db
.read_meta()
.context("Konnte Container-Header nicht lesen")?;
let backup = HeaderBackup::from_meta(&meta);
let json_data = serde_json::to_string_pretty(&backup)
.context("Fehler beim Serialisieren des Header-Backups")?;
fs::write(backup_path, json_data)
.with_context(|| format!("Konnte Backup-Datei '{}' nicht schreiben", backup_path.display()))?;
fs::write(backup_path, json_data).with_context(|| {
format!(
"Konnte Backup-Datei '{}' nicht schreiben",
backup_path.display()
)
})?;
Ok(())
}
@@ -203,21 +262,25 @@ pub fn restore_header_backup(container_path: &Path, backup_path: &Path) -> Resul
bail!("Backup-Datei '{}' existiert nicht.", backup_path.display());
}
let content = fs::read_to_string(backup_path)
.with_context(|| format!("Konnte Backup-Datei '{}' nicht lesen", backup_path.display()))?;
let content = fs::read_to_string(backup_path).with_context(|| {
format!(
"Konnte Backup-Datei '{}' nicht lesen",
backup_path.display()
)
})?;
let backup: HeaderBackup = serde_json::from_str(&content)
.context("Ungültiges Backup-Dateiformat (JSON-Parsing fehlgeschlagen)")?;
let meta = backup.to_meta()?;
let db = Database::open(container_path)
.context("Konnte Ziel-Containerdatei nicht öffnen")?;
let db = Database::open(container_path).context("Konnte Ziel-Containerdatei nicht öffnen")?;
db.restore_meta(&meta)
.context("Fehler beim Wiederherstellen der Header-Tabelle in der Datenbank")?;
db.checkpoint().context("Fehler beim WAL-Checkpoint nach Header-Wiederherstellung")?;
db.checkpoint()
.context("Fehler beim WAL-Checkpoint nach Header-Wiederherstellung")?;
Ok(())
}
@@ -256,14 +319,16 @@ pub fn restore_slot_from_recovery_key(
dek_0: Option<&[u8; 32]>,
) -> Result<()> {
if !container_path.exists() {
bail!("Containerdatei '{}' existiert nicht.", container_path.display());
bail!(
"Containerdatei '{}' existiert nicht.",
container_path.display()
);
}
validate_password(new_password)?;
// 1. DEK aus 24-Wort-Phrase dekodieren & validieren
let dek = mnemonic_to_dek(recovery_key)
.context("Ungültiger 24-Wort Notfallschlüssel")?;
let dek = mnemonic_to_dek(recovery_key).context("Ungültiger 24-Wort Notfallschlüssel")?;
// 2. Neuen KEK mit frischem Salt ableiten
let mut salt = [0u8; 16];
@@ -273,8 +338,7 @@ pub fn restore_slot_from_recovery_key(
let kek = derive_kek(new_password, &salt, &kdf_params)
.context("Schlüsselableitung für neues Passwort fehlgeschlagen")?;
let db = Database::open(container_path)
.context("Konnte Container-Datenbank nicht öffnen")?;
let db = Database::open(container_path).context("Konnte Container-Datenbank nicht öffnen")?;
let carrier_node_id = db.find_carrier_node_id()?.unwrap_or(0);
@@ -347,7 +411,8 @@ pub fn restore_slot_from_recovery_key(
db.restore_meta(&meta)
.context("Fehler beim Schreiben des rekonstruierten Headers")?;
db.checkpoint().context("Fehler beim WAL-Checkpoint nach Header-Rekonstruktion")?;
db.checkpoint()
.context("Fehler beim WAL-Checkpoint nach Header-Rekonstruktion")?;
Ok(())
}
@@ -370,8 +435,10 @@ mod tests {
#[test]
fn test_header_backup_and_restore() {
let temp_dir = std::env::temp_dir();
let container_path: PathBuf = temp_dir.join(format!("test_backup_{}.sanctum", std::process::id()));
let backup_path: PathBuf = temp_dir.join(format!("test_backup_{}.sanctum.hdr", std::process::id()));
let container_path: PathBuf =
temp_dir.join(format!("test_backup_{}.sanctum", std::process::id()));
let backup_path: PathBuf =
temp_dir.join(format!("test_backup_{}.sanctum.hdr", std::process::id()));
if container_path.exists() {
let _ = fs::remove_file(&container_path);
@@ -392,7 +459,8 @@ mod tests {
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.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag)
.unwrap();
db.checkpoint().unwrap();
// 1. Export
@@ -416,8 +484,13 @@ mod tests {
let restored_db = Database::open(&container_path).unwrap();
let meta = restored_db.read_meta().expect("Read restored meta");
let restored_kek = derive_kek(password, &meta.kdf_salt, &meta.kdf_params).unwrap();
let active_dek = unwrap_dek(&restored_kek, &meta.wrapped_dek, &meta.header_nonce, &meta.header_tag)
.expect("Unwrap restored DEK");
let active_dek = unwrap_dek(
&restored_kek,
&meta.wrapped_dek,
&meta.header_nonce,
&meta.header_tag,
)
.expect("Unwrap restored DEK");
assert_eq!(*dek, *active_dek);
let _ = fs::remove_file(&container_path);
@@ -427,7 +500,8 @@ mod tests {
#[test]
fn test_restore_from_recovery_key() {
let temp_dir = std::env::temp_dir();
let container_path: PathBuf = temp_dir.join(format!("test_rec_key_{}.sanctum", std::process::id()));
let container_path: PathBuf =
temp_dir.join(format!("test_rec_key_{}.sanctum", std::process::id()));
if container_path.exists() {
let _ = fs::remove_file(&container_path);
@@ -450,7 +524,8 @@ mod tests {
let phrase = crate::crypto::dek_to_mnemonic(&dek).unwrap();
let db = Database::open(&container_path).unwrap();
db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag).unwrap();
db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag)
.unwrap();
db.checkpoint().unwrap();
// Header zerstören
@@ -466,8 +541,13 @@ mod tests {
let rescued_db = Database::open(&container_path).unwrap();
let meta = rescued_db.read_meta().expect("Read rescued meta");
let new_kek = derive_kek(new_password, &meta.kdf_salt, &meta.kdf_params).unwrap();
let unwrapped = unwrap_dek(&new_kek, &meta.wrapped_dek, &meta.header_nonce, &meta.header_tag)
.expect("Unwrap rescued DEK");
let unwrapped = unwrap_dek(
&new_kek,
&meta.wrapped_dek,
&meta.header_nonce,
&meta.header_tag,
)
.expect("Unwrap rescued DEK");
assert_eq!(*dek, *unwrapped);
let _ = fs::remove_file(&container_path);
+352 -194
View File
@@ -9,8 +9,8 @@ use rusqlite::{params, Connection, OptionalExtension};
use crate::crypto::{
decrypt_node_name, derive_kek, encrypt_node_name, generate_dummy_slot, unwrap_key_payload,
validate_kdf_params, KdfParams, CHUNK_SIZE, FORMAT_VERSION, FORMAT_VERSION_V1, FORMAT_VERSION_V2,
MAGIC_BYTES,
validate_kdf_params, KdfParams, CHUNK_SIZE, FORMAT_VERSION, FORMAT_VERSION_V1,
FORMAT_VERSION_V2, MAGIC_BYTES,
};
use zeroize::Zeroizing;
@@ -52,11 +52,11 @@ pub struct SlotMeta {
/// garantiert 100%ige Abwärtskompatibilität zu bestehendem Code (z. B. `auth.0`, `auth.2`).
#[derive(Clone)]
pub struct UnlockedKeys(
pub Zeroizing<[u8; 32]>, // 0: DEK (DEK_0 bei Slot 0, DEK_1 bei Slot 1)
pub u32, // 1: Formatversion
pub u32, // 2: Slot-ID (0 = Decoy/Standard, 1 = Hidden Vault)
pub Zeroizing<[u8; 32]>, // 0: DEK (DEK_0 bei Slot 0, DEK_1 bei Slot 1)
pub u32, // 1: Formatversion
pub u32, // 2: Slot-ID (0 = Decoy/Standard, 1 = Hidden Vault)
pub Option<Zeroizing<[u8; 32]>>, // 3: Carrier DEK_0 (bei Slot 1 im Modell A vorhanden)
pub Option<i64>, // 4: Carrier Node ID (Inode der Alibi-Datei in nodes)
pub Option<i64>, // 4: Carrier Node ID (Inode der Alibi-Datei in nodes)
);
impl UnlockedKeys {
@@ -94,12 +94,39 @@ impl ContainerMeta {
/// Führt für ausnahmslos ALLE vorhandenen Slots die KDF-Ableitung und das DEK-Unwrapping durch.
/// Dadurch ist die Rechenzeit für Decoy und Hidden Vault bit-genau identisch (2x Argon2id).
pub fn authenticate(&self, password: &str) -> Option<UnlockedKeys> {
// SA-01 (HIGH): Strikte strukturelle Vorab-Validierung VOR jeglicher KDF-Berechnung (derive_kek)
// Verhindert KDF-Amplification und DoS durch manipulierte Container-Metadaten
if self.slots.is_empty() || self.slots.len() > 2 {
return None;
}
if !self.slots.iter().any(|s| s.slot_id == 0) {
return None;
}
if self.slots.iter().any(|s| s.slot_id > 1) {
return None;
}
let mut seen_ids = std::collections::HashSet::new();
for slot in &self.slots {
if !seen_ids.insert(slot.slot_id) {
return None;
}
if validate_kdf_params(&slot.kdf_params).is_err() {
return None;
}
}
let mut matching = None;
for slot in &self.slots {
let res = derive_kek(password, &slot.kdf_salt, &slot.kdf_params)
.ok()
.and_then(|kek| {
unwrap_key_payload(&kek, &slot.wrapped_dek, &slot.header_nonce, &slot.header_tag).ok()
unwrap_key_payload(
&kek,
&slot.wrapped_dek,
&slot.header_nonce,
&slot.header_tag,
)
.ok()
});
if let Some(payload) = res {
@@ -109,7 +136,11 @@ impl ContainerMeta {
let carrier_node_id = if payload.len() >= 40 {
dek.copy_from_slice(&payload[0..32]);
let cid = i64::from_le_bytes(payload[32..40].try_into().unwrap());
if cid > 0 { Some(cid) } else { None }
if cid > 0 {
Some(cid)
} else {
None
}
} else if payload.len() >= 32 {
dek.copy_from_slice(&payload[0..32]);
None
@@ -135,7 +166,13 @@ impl ContainerMeta {
} else {
continue;
};
matching = Some(UnlockedKeys(dek_1, slot.version, 1, carrier_dek, carrier_node_id));
matching = Some(UnlockedKeys(
dek_1,
slot.version,
1,
carrier_dek,
carrier_node_id,
));
}
}
}
@@ -242,6 +279,27 @@ impl Database {
path_ref.display()
);
}
// SA-01 (HIGH): Sofortige strukturelle Validierung der Slot-Anzahl bei Database::open (Schutz vor KDF-Amplification/DoS)
let has_meta_table: bool = conn
.query_row(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='meta' LIMIT 1;",
[],
|_| Ok(()),
)
.optional()?
.is_some();
if has_meta_table {
let slot_count: i64 = conn
.query_row("SELECT count(*) FROM meta;", [], |r| r.get(0))
.unwrap_or(0);
if slot_count > 2 {
bail!(
"Ungültige Slot-Anzahl im Container: {} (maximal 2 erlaubt)",
slot_count
);
}
}
}
let db = Self {
@@ -285,17 +343,25 @@ impl Database {
/// Führt automatische, rückwärtskompatible Schema-Upgrades (z. B. Spalte slot_id, auto_vacuum, is_carrier) durch.
pub fn ensure_schema_upgrades(&self) -> Result<()> {
let conn = self.conn.lock().unwrap();
let av: i64 = conn.query_row("PRAGMA auto_vacuum;", [], |r| r.get(0)).unwrap_or(0);
let av: i64 = conn
.query_row("PRAGMA auto_vacuum;", [], |r| r.get(0))
.unwrap_or(0);
if av != 2 {
// Upgrade bestehender Datenbanken auf INCREMENTAL auto_vacuum
let _ = conn.execute_batch("PRAGMA auto_vacuum = INCREMENTAL; VACUUM;");
}
// Spalte slot_id in meta (falls aus v1 migriert)
let _ = conn.execute("ALTER TABLE meta ADD COLUMN slot_id INTEGER NOT NULL DEFAULT 0", []);
let _ = conn.execute(
"ALTER TABLE meta ADD COLUMN slot_id INTEGER NOT NULL DEFAULT 0",
[],
);
// Spalte is_carrier in nodes (S-03 Carrier-Schutz)
let _ = conn.execute("ALTER TABLE nodes ADD COLUMN is_carrier INTEGER NOT NULL DEFAULT 0", []);
let _ = conn.execute(
"ALTER TABLE nodes ADD COLUMN is_carrier INTEGER NOT NULL DEFAULT 0",
[],
);
Ok(())
}
@@ -318,7 +384,9 @@ impl Database {
/// Führt ein inkrementelles Auto-Vacuum aus, um freigegebene Datenbankseiten an das Betriebssystem zurückzugeben.
pub fn incremental_vacuum(&self, pages: Option<usize>) -> Result<usize> {
let conn = self.conn.lock().unwrap();
let before: i64 = conn.query_row("PRAGMA freelist_count;", [], |r| r.get(0)).unwrap_or(0);
let before: i64 = conn
.query_row("PRAGMA freelist_count;", [], |r| r.get(0))
.unwrap_or(0);
if before <= 0 {
return Ok(0);
}
@@ -337,7 +405,9 @@ impl Database {
drop(rows);
drop(stmt);
let after: i64 = conn.query_row("PRAGMA freelist_count;", [], |r| r.get(0)).unwrap_or(0);
let after: i64 = conn
.query_row("PRAGMA freelist_count;", [], |r| r.get(0))
.unwrap_or(0);
let actual_freed = (before - after).max(0) as usize;
Ok(actual_freed.max(stepped))
}
@@ -352,9 +422,7 @@ impl Database {
/// Sucht nach einem existierenden Carrier-Knoten (is_carrier = 1) (S-03).
pub fn find_carrier_node_id(&self) -> Result<Option<i64>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT id FROM nodes WHERE is_carrier = 1 LIMIT 1",
)?;
let mut stmt = conn.prepare("SELECT id FROM nodes WHERE is_carrier = 1 LIMIT 1")?;
let id = stmt.query_row([], |r| r.get::<_, i64>(0)).optional()?;
Ok(id)
}
@@ -380,16 +448,26 @@ impl Database {
)
SELECT 1 FROM sub WHERE id = ?2 LIMIT 1;",
)?;
let exists: Option<i64> = stmt.query_row(params![ancestor_id, node_id], |r| r.get(0)).optional()?;
let exists: Option<i64> = stmt
.query_row(params![ancestor_id, node_id], |r| r.get(0))
.optional()?;
Ok(exists.is_some())
}
/// Überschreibt Chunks eines Knotens vor dem Löschen mit kryptografischem Zufallsrauschen (Chunk Shredding).
///
/// SA-07 / Technischer Hinweis zu sicherem Löschen:
/// Diese Funktion führt eine *logische* Datenbereinigung durch (Überschreiben aller SQLite-Records
/// des Knotens mit CSPRNG-Zufallsrauschen vor dem Löschen). Sie schützt zuverlässig vor
/// logischer Wiederherstellung auf Dateisystem- und Datenbankebene.
/// Auf modernen Solid-State-Drives (SSD, NVMe) oder Copy-on-Write-Dateisystemen (Btrfs, ZFS, APFS, ReFS)
/// kann hierdurch jedoch konstruktionsbedingt keine *physikalische* Datenträgerbereinigung (Media Sanitization)
/// garantiert werden, da der Flash Translation Layer (FTL) und Wear-Leveling-Mechanismen Sektoren
/// neuen Flash-Speicherzellen zuweisen.
pub fn shred_chunks_for_node(&self, node_id: i64) -> Result<()> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT chunk_index, length(ciphertext) FROM chunks WHERE node_id = ?1"
)?;
let mut stmt =
conn.prepare("SELECT chunk_index, length(ciphertext) FROM chunks WHERE node_id = ?1")?;
let chunks: Vec<(u32, usize)> = stmt
.query_map(params![node_id], |row| Ok((row.get(0)?, row.get(1)?)))?
.filter_map(|r| r.ok())
@@ -426,15 +504,15 @@ impl Database {
header_nonce_0: &[u8; 12],
header_tag_0: &[u8; 16],
carrier_config: Option<(
&str, // carrier_name
u64, // carrier_size_bytes
&[u8; 16], // salt_1
&KdfParams, // kdf_params_1
&[u8], // wrapped_dek_1 (72B)
&[u8; 12], // header_nonce_1
&[u8; 16], // header_tag_1
&[u8; 32], // raw DEK_0
&[u8; 32], // raw DEK_1
&str, // carrier_name
u64, // carrier_size_bytes
&[u8; 16], // salt_1
&KdfParams, // kdf_params_1
&[u8], // wrapped_dek_1 (72B)
&[u8; 12], // header_nonce_1
&[u8; 16], // header_tag_1
&[u8; 32], // raw DEK_0
&[u8; 32], // raw DEK_1
)>,
) -> Result<Option<i64>> {
let conn = self.conn.lock().unwrap();
@@ -547,13 +625,8 @@ impl Database {
let manifest = crate::carrier::CarrierManifest::new(total_blocks);
let manifest_bytes = serde_json::to_vec(&manifest)?;
let (inner_ct, inner_nonce, inner_tag) = crate::crypto::encrypt_chunk(
dek_1,
c_id,
0,
&manifest_bytes,
FORMAT_VERSION,
)?;
let (inner_ct, inner_nonce, inner_tag) =
crate::crypto::encrypt_chunk(dek_1, c_id, 0, &manifest_bytes, FORMAT_VERSION)?;
let inner_ct_len = inner_ct.len() as u32;
let mut outer_plaintext = vec![0u8; CHUNK_SIZE];
@@ -568,13 +641,8 @@ impl Database {
}
outer_plaintext[32..ct_end].copy_from_slice(&inner_ct);
let (outer_ct, outer_nonce, outer_tag) = crate::crypto::encrypt_chunk(
dek_0,
c_id,
0,
&outer_plaintext,
FORMAT_VERSION,
)?;
let (outer_ct, outer_nonce, outer_tag) =
crate::crypto::encrypt_chunk(dek_0, c_id, 0, &outer_plaintext, FORMAT_VERSION)?;
conn.execute(
"INSERT INTO chunks (node_id, chunk_index, nonce, tag, ciphertext)
@@ -593,20 +661,9 @@ impl Database {
conn.execute_batch("BEGIN TRANSACTION;")?;
for b in 1..total_blocks {
let (ct, nonce, tag) = crate::crypto::encrypt_chunk(
dek_0,
c_id,
b,
&dummy_noise,
FORMAT_VERSION,
)?;
chunk_stmt.execute(params![
c_id,
b,
nonce.as_slice(),
tag.as_slice(),
ct,
])?;
let (ct, nonce, tag) =
crate::crypto::encrypt_chunk(dek_0, c_id, b, &dummy_noise, FORMAT_VERSION)?;
chunk_stmt.execute(params![c_id, b, nonce.as_slice(), tag.as_slice(), ct,])?;
if b % 500 == 0 {
conn.execute_batch("COMMIT; BEGIN TRANSACTION;")?;
@@ -767,18 +824,43 @@ impl Database {
header_nonce: &[u8; 12],
header_tag: &[u8; 16],
) -> Result<()> {
self.init_schema_with_hidden(salt, kdf_params, wrapped_dek, header_nonce, header_tag, None)
self.init_schema_with_hidden(
salt,
kdf_params,
wrapped_dek,
header_nonce,
header_tag,
None,
)
}
/// Liest alle Header-Slots aus der `meta`-Tabelle aus (Slot 0 = Decoy/Standard, Slot 1 = Hidden Vault oder Dummy-Rauschen).
/// SA-01: Führt strikte Vorab-Validierung der Container-Struktur VOR jeglicher KDF-Berechnung durch.
pub fn read_slots(&self) -> Result<Vec<SlotMeta>> {
let conn = self.conn.lock().unwrap();
// SA-01 (HIGH): Vorab-Prüfung der Gesamtzahl der Slots (Schutz gegen KDF-Amplification / Container DoS)
let slot_count: i64 = conn.query_row("SELECT count(*) FROM meta", [], |r| r.get(0))?;
if slot_count == 0 {
bail!("Container-Header ist leer oder beschädigt");
}
if slot_count > 2 {
bail!(
"Ungültige Slot-Anzahl im Container: {} (maximal 2 erlaubt)",
slot_count
);
}
let mut stmt = conn.prepare(
"SELECT slot_id, version, kdf_salt, kdf_params, wrapped_dek, header_nonce, header_tag
FROM meta ORDER BY slot_id ASC",
)?;
let rows = stmt.query_map([], |row| {
let mut rows = stmt.query([])?;
let mut slots = Vec::new();
let mut seen_slot_ids = std::collections::HashSet::new();
while let Some(row) = rows.next()? {
let slot_id: u32 = row.get(0)?;
let version: u32 = row.get(1)?;
let salt_vec: Vec<u8> = row.get(2)?;
@@ -787,91 +869,69 @@ impl Database {
let nonce_vec: Vec<u8> = row.get(5)?;
let tag_vec: Vec<u8> = row.get(6)?;
let mut kdf_salt = [0u8; 16];
if salt_vec.len() != 16 {
return Err(rusqlite::Error::FromSqlConversionFailure(
2,
rusqlite::types::Type::Blob,
Box::new(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("Ungültige Salt-Länge in Slot {}: {} Bytes (erwartet: 16)", slot_id, salt_vec.len()),
)),
));
// SA-01: Ausschließlich die Slot-IDs 0 und 1 sind zulässig
if slot_id > 1 {
bail!(
"Ungültige Slot-ID {}: Es sind ausschließlich die Slot-IDs 0 und 1 erlaubt",
slot_id
);
}
if !seen_slot_ids.insert(slot_id) {
bail!("Doppelte Slot-ID {} im Container-Header entdeckt", slot_id);
}
if salt_vec.len() != 16 {
bail!(
"Ungültige Salt-Länge in Slot {}: {} Bytes (erwartet: 16)",
slot_id,
salt_vec.len()
);
}
let mut kdf_salt = [0u8; 16];
kdf_salt.copy_from_slice(&salt_vec);
let mut header_nonce = [0u8; 12];
if nonce_vec.len() != 12 {
return Err(rusqlite::Error::FromSqlConversionFailure(
5,
rusqlite::types::Type::Blob,
Box::new(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("Ungültige Nonce-Länge in Slot {}: {} Bytes (erwartet: 12)", slot_id, nonce_vec.len()),
)),
));
bail!(
"Ungültige Nonce-Länge in Slot {}: {} Bytes (erwartet: 12)",
slot_id,
nonce_vec.len()
);
}
let mut header_nonce = [0u8; 12];
header_nonce.copy_from_slice(&nonce_vec);
let mut header_tag = [0u8; 16];
if tag_vec.len() != 16 {
return Err(rusqlite::Error::FromSqlConversionFailure(
6,
rusqlite::types::Type::Blob,
Box::new(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("Ungültige Tag-Länge in Slot {}: {} Bytes (erwartet: 16)", slot_id, tag_vec.len()),
)),
));
bail!(
"Ungültige Tag-Länge in Slot {}: {} Bytes (erwartet: 16)",
slot_id,
tag_vec.len()
);
}
let mut header_tag = [0u8; 16];
header_tag.copy_from_slice(&tag_vec);
// Strikte Validierung der wrapped_dek Länge (DoS- und Manipulationsschutz)
if slot_id == 0 {
if wrapped_dek.len() != 40 && wrapped_dek.len() != 32 {
return Err(rusqlite::Error::FromSqlConversionFailure(
4,
rusqlite::types::Type::Blob,
Box::new(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("Ungültige wrapped_dek-Länge in Slot 0: {} Bytes (erwartet: 40 oder 32)", wrapped_dek.len()),
)),
));
bail!(
"Ungültige wrapped_dek-Länge in Slot 0: {} Bytes (erwartet: 40 oder 32)",
wrapped_dek.len()
);
}
} else if slot_id == 1 {
if wrapped_dek.len() != 72 && wrapped_dek.len() != 64 && wrapped_dek.len() != 32 {
return Err(rusqlite::Error::FromSqlConversionFailure(
4,
rusqlite::types::Type::Blob,
Box::new(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("Ungültige wrapped_dek-Länge in Slot 1: {} Bytes (erwartet: 72, 64 oder 32)", wrapped_dek.len()),
)),
));
bail!("Ungültige wrapped_dek-Länge in Slot 1: {} Bytes (erwartet: 72, 64 oder 32)", wrapped_dek.len());
}
}
let kdf_params: KdfParams = serde_json::from_str(&params_str)
.map_err(|e| rusqlite::Error::FromSqlConversionFailure(
3,
rusqlite::types::Type::Text,
Box::new(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("Ungültiges KdfParams-JSON in Slot {}: {e}", slot_id),
)),
))?;
let kdf_params: KdfParams = serde_json::from_str(&params_str).map_err(|e| {
anyhow::anyhow!("Ungültiges KdfParams-JSON in Slot {}: {e}", slot_id)
})?;
validate_kdf_params(&kdf_params)
.map_err(|e| rusqlite::Error::FromSqlConversionFailure(
3,
rusqlite::types::Type::Text,
Box::new(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("KDF-Parameter in Slot {} ungültig: {e}", slot_id),
)),
))?;
.map_err(|e| anyhow::anyhow!("KDF-Parameter in Slot {} ungültig: {e}", slot_id))?;
Ok(SlotMeta {
slots.push(SlotMeta {
slot_id,
version,
kdf_salt,
@@ -879,13 +939,14 @@ impl Database {
wrapped_dek,
header_nonce,
header_tag,
})
})?;
let mut slots = Vec::new();
for r in rows {
slots.push(r?);
});
}
// SA-01: Slot 0 (Standard/Decoy Vault) ist zwingend erforderlich
if !seen_slot_ids.contains(&0) {
bail!("Ungültiger Container-Header: Slot 0 (Standard/Decoy Vault) fehlt");
}
Ok(slots)
}
@@ -909,7 +970,10 @@ impl Database {
}
if slot0.version != FORMAT_VERSION_V1 && slot0.version != FORMAT_VERSION_V2 {
bail!("Nicht unterstützte Sanctum-Formatversion: {}", slot0.version);
bail!(
"Nicht unterstützte Sanctum-Formatversion: {}",
slot0.version
);
}
Ok(ContainerMeta {
@@ -932,7 +996,14 @@ impl Database {
new_header_nonce: &[u8; 12],
new_header_tag: &[u8; 16],
) -> Result<()> {
self.update_slot_keys(0, new_salt, new_params, new_wrapped_dek, new_header_nonce, new_header_tag)
self.update_slot_keys(
0,
new_salt,
new_params,
new_wrapped_dek,
new_header_nonce,
new_header_tag,
)
}
/// Aktualisiert die kryptografischen Schlüssel eines bestimmten Slots.
@@ -960,7 +1031,10 @@ impl Database {
)?;
if rows_affected == 0 {
bail!("Konnte Container-Header für Slot {} nicht aktualisieren", slot_id);
bail!(
"Konnte Container-Header für Slot {} nicht aktualisieren",
slot_id
);
}
Ok(())
@@ -1059,7 +1133,8 @@ impl Database {
let mut matched_record = None;
for r in rows {
let (id, p_id, enc_name, is_dir, size, c_at, m_at) = r?;
let dec_name = decrypt_node_name(dek, p_id.unwrap_or(0), &enc_name).unwrap_or(enc_name);
let dec_name =
decrypt_node_name(dek, p_id.unwrap_or(0), &enc_name).unwrap_or(enc_name);
if dec_name == *segment {
matched_record = Some(NodeRecord {
id,
@@ -1471,11 +1546,10 @@ impl Database {
/// Erzwingt einen SQLite WAL Checkpoint und leert das Write-Ahead-Log.
pub fn checkpoint(&self) -> Result<()> {
let conn = self.conn.lock().unwrap();
let _res: (i64, i64, i64) = conn.query_row(
"PRAGMA wal_checkpoint(TRUNCATE);",
[],
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
)?;
let _res: (i64, i64, i64) =
conn.query_row("PRAGMA wal_checkpoint(TRUNCATE);", [], |row| {
Ok((row.get(0)?, row.get(1)?, row.get(2)?))
})?;
Ok(())
}
@@ -1650,10 +1724,9 @@ impl Database {
/// Zählt die Anzahl von Verzeichnissen, Dateien und Daten-Chunks im Container.
pub fn count_nodes_and_chunks(&self) -> Result<(usize, usize, usize)> {
let conn = self.conn.lock().unwrap();
let dirs: i64 =
conn.query_row("SELECT COUNT(*) FROM nodes WHERE is_dir = 1", [], |r| {
r.get(0)
})?;
let dirs: i64 = conn.query_row("SELECT COUNT(*) FROM nodes WHERE is_dir = 1", [], |r| {
r.get(0)
})?;
let files: i64 =
conn.query_row("SELECT COUNT(*) FROM nodes WHERE is_dir = 0", [], |r| {
r.get(0)
@@ -1717,7 +1790,8 @@ mod tests {
let nonce = [3u8; 12];
let tag = [4u8; 16];
db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag).unwrap();
db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag)
.unwrap();
// Meta abrufen
let meta = db.read_meta().unwrap();
@@ -1740,19 +1814,29 @@ mod tests {
assert!(!file.is_dir);
// Pfadauflösung testen
let resolved_file = db.resolve_path("/documents/notes.txt").unwrap().expect("File should resolve");
let resolved_file = db
.resolve_path("/documents/notes.txt")
.unwrap()
.expect("File should resolve");
assert_eq!(resolved_file.id, file.id);
let resolved_docs = db.resolve_path("documents").unwrap().expect("Docs should resolve");
let resolved_docs = db
.resolve_path("documents")
.unwrap()
.expect("Docs should resolve");
assert_eq!(resolved_docs.id, docs.id);
// Chunks schreiben & lesen
let test_cipher = b"ENCRYPTED_DATA_BLOCK";
let c_nonce = [7u8; 12];
let c_tag = [8u8; 16];
db.write_chunk(file.id, 0, &c_nonce, &c_tag, test_cipher).unwrap();
db.write_chunk(file.id, 0, &c_nonce, &c_tag, test_cipher)
.unwrap();
let chunk = db.read_chunk(file.id, 0).unwrap().expect("Chunk 0 should exist");
let chunk = db
.read_chunk(file.id, 0)
.unwrap()
.expect("Chunk 0 should exist");
assert_eq!(chunk.ciphertext, test_cipher);
// Truncate
@@ -1782,7 +1866,8 @@ mod tests {
let nonce = [3u8; 12];
let tag = [4u8; 16];
db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag).unwrap();
db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag)
.unwrap();
let root = db.resolve_path("/").unwrap().expect("Root node");
let file = db.create_node(root.id, "large_file.bin", false).unwrap();
@@ -1792,7 +1877,8 @@ mod tests {
let c_nonce = [5u8; 12];
let c_tag = [6u8; 16];
for i in 0..20 {
db.write_chunk(file.id, i, &c_nonce, &c_tag, &payload).unwrap();
db.write_chunk(file.id, i, &c_nonce, &c_tag, &payload)
.unwrap();
}
db.checkpoint().unwrap();
@@ -1801,12 +1887,18 @@ mod tests {
db.checkpoint().unwrap();
let freelist_before = db.freelist_count().unwrap();
assert!(freelist_before > 0, "Freelist sollte nach dem Löschen freie Seiten enthalten");
assert!(
freelist_before > 0,
"Freelist sollte nach dem Löschen freie Seiten enthalten"
);
// Incremental Vacuum ausführen
let freed = db.incremental_vacuum(None).unwrap();
assert!(freed > 0, "Es sollten Seiten freigegeben werden");
assert_eq!(freed, freelist_before, "Alle freien Seiten müssen freigegeben werden");
assert_eq!(
freed, freelist_before,
"Alle freien Seiten müssen freigegeben werden"
);
let freelist_after = db.freelist_count().unwrap();
assert_eq!(freelist_after, 0, "Freelist sollte nach Vacuum 0 sein");
@@ -1823,22 +1915,34 @@ mod tests {
let nonce = [3u8; 12];
let tag = [4u8; 16];
db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag).unwrap();
db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag)
.unwrap();
let root = db.resolve_path("/").unwrap().expect("Root");
let file = db.create_node(root.id, "sensitive.dat", false).unwrap();
let sensitive_payload = b"VERY_SENSITIVE_PLAINTEXT_OR_CIPHERTEXT";
let c_nonce = [10u8; 12];
let c_tag = [11u8; 16];
db.write_chunk(file.id, 0, &c_nonce, &c_tag, sensitive_payload).unwrap();
db.write_chunk(file.id, 0, &c_nonce, &c_tag, sensitive_payload)
.unwrap();
// Shredde Chunks
db.shred_chunks_for_node(file.id).unwrap();
// Prüfe, was sich in der Chunks-Tabelle befindet
let chunk = db.read_chunk(file.id, 0).unwrap().expect("Chunk existiert noch");
assert_ne!(chunk.ciphertext, sensitive_payload, "Ciphertext muss überschrieben sein!");
assert_eq!(chunk.ciphertext.len(), sensitive_payload.len(), "Länge muss identisch sein");
let chunk = db
.read_chunk(file.id, 0)
.unwrap()
.expect("Chunk existiert noch");
assert_ne!(
chunk.ciphertext, sensitive_payload,
"Ciphertext muss überschrieben sein!"
);
assert_eq!(
chunk.ciphertext.len(),
sensitive_payload.len(),
"Länge muss identisch sein"
);
assert_ne!(chunk.nonce, c_nonce, "Nonce muss überschrieben sein");
assert_ne!(chunk.tag, c_tag, "Tag muss überschrieben sein");
}
@@ -1862,9 +1966,14 @@ mod tests {
let dek1 = [0xBBu8; 32];
db.init_schema_with_hidden(
&salt0, &kdf_params0, &wrapped_dek0, &nonce0, &tag0,
&salt0,
&kdf_params0,
&wrapped_dek0,
&nonce0,
&tag0,
Some((&salt1, &kdf_params1, &wrapped_dek1, &nonce1, &tag1)),
).unwrap();
)
.unwrap();
// Slots prüfen
let slots = db.read_slots().unwrap();
@@ -1873,63 +1982,107 @@ mod tests {
assert_eq!(slots[1].slot_id, 1);
// Datei in Vault 0 (Decoy) erstellen
let root0 = db.resolve_path_in_vault("/", 0, &dek0).unwrap().expect("Root 0");
let root0 = db
.resolve_path_in_vault("/", 0, &dek0)
.unwrap()
.expect("Root 0");
assert_eq!(root0.id, 1);
let decoy_file = db.create_node_in_vault(0, root0.id, "public_recipe.txt", false, &dek0).unwrap();
let decoy_file = db
.create_node_in_vault(0, root0.id, "public_recipe.txt", false, &dek0)
.unwrap();
// Datei in Vault 1 (Hidden) erstellen
let root1 = db.resolve_path_in_vault("/", 1, &dek1).unwrap().expect("Root 1");
let root1 = db
.resolve_path_in_vault("/", 1, &dek1)
.unwrap()
.expect("Root 1");
assert_eq!(root1.id, 2);
let hidden_file = db.create_node_in_vault(1, root1.id, "classified_leak.pdf", false, &dek1).unwrap();
let hidden_file = db
.create_node_in_vault(1, root1.id, "classified_leak.pdf", false, &dek1)
.unwrap();
// Auflösen in Vault 0: Sieht nur public_recipe.txt
let res_decoy = db.resolve_path_in_vault("/public_recipe.txt", 0, &dek0).unwrap();
let res_decoy = db
.resolve_path_in_vault("/public_recipe.txt", 0, &dek0)
.unwrap();
assert!(res_decoy.is_some());
assert_eq!(res_decoy.unwrap().id, decoy_file.id);
let res_hidden_in_v0 = db.resolve_path_in_vault("/classified_leak.pdf", 0, &dek0).unwrap();
assert!(res_hidden_in_v0.is_none(), "Vault 0 darf keine Dateien aus Hidden Vault auflösen!");
let res_hidden_in_v0 = db
.resolve_path_in_vault("/classified_leak.pdf", 0, &dek0)
.unwrap();
assert!(
res_hidden_in_v0.is_none(),
"Vault 0 darf keine Dateien aus Hidden Vault auflösen!"
);
// Auflösen in Vault 1: Sieht nur classified_leak.pdf
let res_hidden = db.resolve_path_in_vault("/classified_leak.pdf", 1, &dek1).unwrap();
let res_hidden = db
.resolve_path_in_vault("/classified_leak.pdf", 1, &dek1)
.unwrap();
assert!(res_hidden.is_some());
assert_eq!(res_hidden.unwrap().id, hidden_file.id);
let res_decoy_in_v1 = db.resolve_path_in_vault("/public_recipe.txt", 1, &dek1).unwrap();
assert!(res_decoy_in_v1.is_none(), "Vault 1 darf keine Dateien aus Vault 0 auflösen!");
let res_decoy_in_v1 = db
.resolve_path_in_vault("/public_recipe.txt", 1, &dek1)
.unwrap();
assert!(
res_decoy_in_v1.is_none(),
"Vault 1 darf keine Dateien aus Vault 0 auflösen!"
);
// Forensische Prüfung: Roh-Inspektion der SQLite-Tabellen
let conn = db.conn.lock().unwrap();
let raw_name_v0: String = conn.query_row(
"SELECT name FROM nodes WHERE id = ?1",
params![decoy_file.id],
|r| r.get(0),
).unwrap();
let raw_name_v0: String = conn
.query_row(
"SELECT name FROM nodes WHERE id = ?1",
params![decoy_file.id],
|r| r.get(0),
)
.unwrap();
assert_eq!(raw_name_v0, "public_recipe.txt");
let raw_name_v1: String = conn.query_row(
"SELECT name FROM nodes WHERE id = ?1",
params![hidden_file.id],
|r| r.get(0),
).unwrap();
let raw_name_v1: String = conn
.query_row(
"SELECT name FROM nodes WHERE id = ?1",
params![hidden_file.id],
|r| r.get(0),
)
.unwrap();
// Dual-Vault Isolation: Kein $h$-Präfix, kein Klartext
assert!(!raw_name_v1.starts_with("$h$"), "Hidden Vault Dateiname darf kein $h$-Präfix mehr besitzen");
assert!(!raw_name_v1.contains("classified_leak"), "Plaintext darf keinesfalls in SQLite DB auftauchen");
assert!(
!raw_name_v1.starts_with("$h$"),
"Hidden Vault Dateiname darf kein $h$-Präfix mehr besitzen"
);
assert!(
!raw_name_v1.contains("classified_leak"),
"Plaintext darf keinesfalls in SQLite DB auftauchen"
);
// Keine Spalte `vault_id` in nodes oder chunks
let has_vault_id_nodes: i64 = conn.query_row(
"SELECT count(*) FROM pragma_table_info('nodes') WHERE name = 'vault_id'",
[],
|r| r.get(0),
).unwrap();
assert_eq!(has_vault_id_nodes, 0, "vault_id darf nicht in nodes existieren");
let has_vault_id_nodes: i64 = conn
.query_row(
"SELECT count(*) FROM pragma_table_info('nodes') WHERE name = 'vault_id'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(
has_vault_id_nodes, 0,
"vault_id darf nicht in nodes existieren"
);
let has_vault_id_chunks: i64 = conn.query_row(
"SELECT count(*) FROM pragma_table_info('chunks') WHERE name = 'vault_id'",
[],
|r| r.get(0),
).unwrap();
assert_eq!(has_vault_id_chunks, 0, "vault_id darf nicht in chunks existieren");
let has_vault_id_chunks: i64 = conn
.query_row(
"SELECT count(*) FROM pragma_table_info('chunks') WHERE name = 'vault_id'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(
has_vault_id_chunks, 0,
"vault_id darf nicht in chunks existieren"
);
}
#[test]
@@ -1942,7 +2095,8 @@ mod tests {
[3u8; 12],
[4u8; 16],
);
db.init_schema(&salt, &kdf, &wrapped_dek, &nonce, &tag).unwrap();
db.init_schema(&salt, &kdf, &wrapped_dek, &nonce, &tag)
.unwrap();
let file = db.create_node(1, "crash_test.bin", false).unwrap();
assert_eq!(file.size, 0);
@@ -1961,10 +2115,14 @@ mod tests {
&ciphertext,
new_size,
modified_at,
).expect("Atomic write");
)
.expect("Atomic write");
// Chunk verifizieren
let chunk = db.read_chunk(file.id, 0).unwrap().expect("Chunk must exist");
let chunk = db
.read_chunk(file.id, 0)
.unwrap()
.expect("Chunk must exist");
assert_eq!(chunk.ciphertext, ciphertext);
assert_eq!(chunk.nonce, chunk_nonce);
assert_eq!(chunk.tag, chunk_tag);
+159 -36
View File
@@ -141,7 +141,8 @@ pub fn is_excluded(name: &str, rel_path: &str, patterns: &[String]) -> bool {
// Wildcard-Muster: prefix*
else if p.ends_with('*') && !p[..p.len() - 1].contains('*') {
let prefix = p[..p.len() - 1].to_ascii_lowercase();
if norm_name.starts_with(&prefix) || norm_rel.to_ascii_lowercase().starts_with(&prefix) {
if norm_name.starts_with(&prefix) || norm_rel.to_ascii_lowercase().starts_with(&prefix)
{
return true;
}
}
@@ -151,7 +152,9 @@ pub fn is_excluded(name: &str, rel_path: &str, patterns: &[String]) -> bool {
if norm_name == p_lower {
return true;
}
if norm_rel.trim_start_matches('/').to_ascii_lowercase() == p_lower.trim_start_matches('/') {
if norm_rel.trim_start_matches('/').to_ascii_lowercase()
== p_lower.trim_start_matches('/')
{
return true;
}
}
@@ -176,8 +179,8 @@ fn read_chunk_buffer(file: &mut File, buf: &mut [u8]) -> std::io::Result<usize>
/// Setzt den Modifikationszeitstempel einer lokalen Datei via std::fs::FileTimes.
fn set_local_file_mtime(file: &File, mtime_secs: u64) {
let times = std::fs::FileTimes::new()
.set_modified(UNIX_EPOCH + Duration::from_secs(mtime_secs));
let times =
std::fs::FileTimes::new().set_modified(UNIX_EPOCH + Duration::from_secs(mtime_secs));
let _ = file.set_times(times);
}
@@ -206,7 +209,10 @@ pub fn ensure_vault_dir_tree(
let children = db.list_children_in_vault(current_id, vault_id, dek)?;
if let Some(existing) = children.into_iter().find(|c| c.name == segment) {
if !existing.is_dir {
bail!("Pfad-Konflikt: '{}' existiert im Container bereits als Datei", segment);
bail!(
"Pfad-Konflikt: '{}' existiert im Container bereits als Datei",
segment
);
}
current_id = existing.id;
current_node = existing;
@@ -234,8 +240,12 @@ pub fn sync_single_file_to_vault(
) -> Result<FileTransferResult> {
validate_node_name(file_name)?;
let meta = fs::metadata(local_path)
.with_context(|| format!("Konnte Metadaten für '{}' nicht lesen", local_path.display()))?;
let meta = fs::metadata(local_path).with_context(|| {
format!(
"Konnte Metadaten für '{}' nicht lesen",
local_path.display()
)
})?;
let local_size = meta.len();
let local_mtime = meta
.modified()
@@ -250,7 +260,10 @@ pub fn sync_single_file_to_vault(
if let Some(ref node) = existing_node {
db.assert_not_carrier(node.id)?;
if node.is_dir {
bail!("Pfad-Konflikt: '{}' existiert im Tresor als Ordner", file_name);
bail!(
"Pfad-Konflikt: '{}' existiert im Tresor als Ordner",
file_name
);
}
// Fast Check: Wenn Größe und mtime identisch sind, überspringen (ohne --checksum)
@@ -278,7 +291,8 @@ pub fn sync_single_file_to_vault(
let node_id = match existing_node {
Some(n) => n.id,
None => {
let new_node = db.create_node_in_vault(vault_id, parent_node_id, file_name, false, dek)?;
let new_node =
db.create_node_in_vault(vault_id, parent_node_id, file_name, false, dek)?;
new_node.id
}
};
@@ -370,8 +384,12 @@ pub fn sync_single_file_to_host(
fs::create_dir_all(parent)?;
}
let mut out_file = File::create(local_path)
.with_context(|| format!("Konnte Zieldatei '{}' nicht erstellen", local_path.display()))?;
let mut out_file = File::create(local_path).with_context(|| {
format!(
"Konnte Zieldatei '{}' nicht erstellen",
local_path.display()
)
})?;
let total_chunks = if node.size == 0 {
0
@@ -381,10 +399,22 @@ pub fn sync_single_file_to_host(
for idx in 0..total_chunks {
if let Some(record) = db.read_chunk(node.id, idx)? {
let plaintext = decrypt_chunk(dek, node.id, idx, &record.ciphertext, &record.nonce, &record.tag, version)?;
let plaintext = decrypt_chunk(
dek,
node.id,
idx,
&record.ciphertext,
&record.nonce,
&record.tag,
version,
)?;
out_file.write_all(&plaintext)?;
} else {
bail!("Beschädigte Datei im Tresor: Chunk #{} für Knoten '{}' fehlt", idx, node.name);
bail!(
"Beschädigte Datei im Tresor: Chunk #{} für Knoten '{}' fehlt",
idx,
node.name
);
}
}
@@ -409,10 +439,14 @@ pub fn run_sync(
match options.direction {
SyncDirection::Push => {
sync_push(db, vault_id, dek, version, source_arg, target_arg, options, &mut stats)?;
sync_push(
db, vault_id, dek, version, source_arg, target_arg, options, &mut stats,
)?;
}
SyncDirection::Pull => {
sync_pull(db, vault_id, dek, version, source_arg, target_arg, options, &mut stats)?;
sync_pull(
db, vault_id, dek, version, source_arg, target_arg, options, &mut stats,
)?;
}
}
@@ -436,7 +470,11 @@ fn sync_push(
bail!("Lokale Quelle '{}' existiert nicht.", source_str);
}
let target_vault_dir = if target_str.is_empty() { "/" } else { target_str };
let target_vault_dir = if target_str.is_empty() {
"/"
} else {
target_str
};
if local_source.is_file() {
let file_name = local_source
@@ -469,14 +507,24 @@ fn sync_push(
stats.files_transferred += 1;
stats.bytes_transferred += size;
if !options.quiet {
println!(" {} Übertragen: {} ({})", ui::green("[+]"), file_name, ui::format_bytes(size));
println!(
" {} Übertragen: {} ({})",
ui::green("[+]"),
file_name,
ui::format_bytes(size)
);
}
}
FileTransferResult::DryRunTransferred { size } => {
stats.files_transferred += 1;
stats.bytes_transferred += size;
if !options.quiet {
println!(" {} [DRY-RUN] Würde übertragen: {} ({})", ui::yellow("[~]"), file_name, ui::format_bytes(size));
println!(
" {} [DRY-RUN] Würde übertragen: {} ({})",
ui::yellow("[~]"),
file_name,
ui::format_bytes(size)
);
}
}
FileTransferResult::Skipped { .. } => {
@@ -556,11 +604,20 @@ fn collect_and_push_dir(
if path.is_dir() {
// Ordner im Tresor anlegen falls nötig
let children = db.list_children_in_vault(current_vault_parent_id, vault_id, dek)?;
let sub_dir_node = match children.into_iter().find(|c| c.name == file_name && c.is_dir) {
let sub_dir_node = match children
.into_iter()
.find(|c| c.name == file_name && c.is_dir)
{
Some(n) => n,
None => {
if !options.dry_run {
db.create_node_in_vault(vault_id, current_vault_parent_id, &file_name, true, dek)?
db.create_node_in_vault(
vault_id,
current_vault_parent_id,
&file_name,
true,
dek,
)?
} else {
// Dummy für dry-run
NodeRecord {
@@ -605,14 +662,24 @@ fn collect_and_push_dir(
stats.files_transferred += 1;
stats.bytes_transferred += size;
if !options.quiet {
println!(" {} Übertragen: {} ({})", ui::green("[+]"), rel_path, ui::format_bytes(size));
println!(
" {} Übertragen: {} ({})",
ui::green("[+]"),
rel_path,
ui::format_bytes(size)
);
}
}
FileTransferResult::DryRunTransferred { size } => {
stats.files_transferred += 1;
stats.bytes_transferred += size;
if !options.quiet {
println!(" {} [DRY-RUN] Würde übertragen: {} ({})", ui::yellow("[~]"), rel_path, ui::format_bytes(size));
println!(
" {} [DRY-RUN] Würde übertragen: {} ({})",
ui::yellow("[~]"),
rel_path,
ui::format_bytes(size)
);
}
}
FileTransferResult::Skipped { .. } => {
@@ -644,7 +711,10 @@ pub fn delete_orphans_in_vault(
for child in children {
// S-03 Carrier Guard: Trägerdatei und übergeordnete Verzeichnisse niemals löschen!
if carrier_id > 0 && (child.id == carrier_id || db.is_descendant_of(carrier_id, child.id).unwrap_or(false)) {
if carrier_id > 0
&& (child.id == carrier_id
|| db.is_descendant_of(carrier_id, child.id).unwrap_or(false))
{
continue;
}
@@ -658,7 +728,11 @@ pub fn delete_orphans_in_vault(
stats.files_deleted += 1;
if dry_run {
if !quiet {
println!(" {} [DRY-RUN] Würde aus Tresor löschen: {}", ui::red("[-]"), child_rel);
println!(
" {} [DRY-RUN] Würde aus Tresor löschen: {}",
ui::red("[-]"),
child_rel
);
}
} else {
db.delete_node(child.id)?;
@@ -724,20 +798,34 @@ fn sync_pull(
stats.files_transferred += 1;
stats.bytes_transferred += size;
if !options.quiet {
println!(" {} Wiederhergestellt: {} ({})", ui::green("[+]"), local_file_path.display(), ui::format_bytes(size));
println!(
" {} Wiederhergestellt: {} ({})",
ui::green("[+]"),
local_file_path.display(),
ui::format_bytes(size)
);
}
}
FileTransferResult::DryRunTransferred { size } => {
stats.files_transferred += 1;
stats.bytes_transferred += size;
if !options.quiet {
println!(" {} [DRY-RUN] Würde wiederherstellen: {} ({})", ui::yellow("[~]"), local_file_path.display(), ui::format_bytes(size));
println!(
" {} [DRY-RUN] Würde wiederherstellen: {} ({})",
ui::yellow("[~]"),
local_file_path.display(),
ui::format_bytes(size)
);
}
}
FileTransferResult::Skipped { .. } => {
stats.files_skipped += 1;
if !options.quiet {
println!(" {} Aktuell (übersprungen): {}", ui::dim("[=]"), local_file_path.display());
println!(
" {} Aktuell (übersprungen): {}",
ui::dim("[=]"),
local_file_path.display()
);
}
}
}
@@ -795,7 +883,10 @@ fn collect_and_pull_dir(
for child in children {
// R-02 Carrier Guard: Trägerdatei niemals auf den Host spiegeln / herausziehen
if carrier_id > 0 && (child.id == carrier_id || db.is_descendant_of(carrier_id, child.id).unwrap_or(false)) {
if carrier_id > 0
&& (child.id == carrier_id
|| db.is_descendant_of(carrier_id, child.id).unwrap_or(false))
{
continue;
}
@@ -816,7 +907,10 @@ fn collect_and_pull_dir(
for comp in Path::new(&child_rel).components() {
match comp {
std::path::Component::Normal(_) => {}
_ => bail!("Path traversal Versuch erkannt in relativem Pfad: '{}'", child_rel),
_ => bail!(
"Path traversal Versuch erkannt in relativem Pfad: '{}'",
child_rel
),
}
}
@@ -824,7 +918,10 @@ fn collect_and_pull_dir(
let local_child_path = local_target_base.join(&child_rel.replace('/', "\\"));
if !local_child_path.starts_with(local_target_base) {
bail!("Path traversal Versuch erkannt: '{}' bricht aus Zielverzeichnis aus", child_rel);
bail!(
"Path traversal Versuch erkannt: '{}' bricht aus Zielverzeichnis aus",
child_rel
);
}
if child.is_dir {
@@ -859,14 +956,24 @@ fn collect_and_pull_dir(
stats.files_transferred += 1;
stats.bytes_transferred += size;
if !options.quiet {
println!(" {} Wiederhergestellt: {} ({})", ui::green("[+]"), child_rel, ui::format_bytes(size));
println!(
" {} Wiederhergestellt: {} ({})",
ui::green("[+]"),
child_rel,
ui::format_bytes(size)
);
}
}
FileTransferResult::DryRunTransferred { size } => {
stats.files_transferred += 1;
stats.bytes_transferred += size;
if !options.quiet {
println!(" {} [DRY-RUN] Würde wiederherstellen: {} ({})", ui::yellow("[~]"), child_rel, ui::format_bytes(size));
println!(
" {} [DRY-RUN] Würde wiederherstellen: {} ({})",
ui::yellow("[~]"),
child_rel,
ui::format_bytes(size)
);
}
}
FileTransferResult::Skipped { .. } => {
@@ -908,7 +1015,11 @@ fn delete_orphans_on_host(
if path.is_dir() {
if dry_run {
if !quiet {
println!(" {} [DRY-RUN] Würde lokalen Ordner löschen: {}", ui::red("[-]"), rel_path);
println!(
" {} [DRY-RUN] Würde lokalen Ordner löschen: {}",
ui::red("[-]"),
rel_path
);
}
} else {
fs::remove_dir_all(&path)?;
@@ -919,7 +1030,11 @@ fn delete_orphans_on_host(
} else {
if dry_run {
if !quiet {
println!(" {} [DRY-RUN] Würde lokale Datei löschen: {}", ui::red("[-]"), rel_path);
println!(
" {} [DRY-RUN] Würde lokale Datei löschen: {}",
ui::red("[-]"),
rel_path
);
}
} else {
fs::remove_file(&path)?;
@@ -951,10 +1066,18 @@ mod tests {
];
assert!(is_excluded("file.tmp", "sub/file.tmp", &patterns));
assert!(is_excluded("download.crdownload", "download.crdownload", &patterns));
assert!(is_excluded(
"download.crdownload",
"download.crdownload",
&patterns
));
assert!(is_excluded("Thumbs.db", "Thumbs.db", &patterns));
assert!(is_excluded("backup_2026.tar", "backup_2026.tar", &patterns));
assert!(!is_excluded("important.doc", "sub/important.doc", &patterns));
assert!(!is_excluded(
"important.doc",
"sub/important.doc",
&patterns
));
assert!(!is_excluded("video.mp4", "video.mp4", &patterns));
}
}
+32 -9
View File
@@ -131,13 +131,25 @@ pub fn step(num: u8, total: u8, icon: &str, msg: &str) {
pub fn print_recovery_phrase_card(phrase: &str) {
let words: Vec<&str> = phrase.split_whitespace().collect();
println!();
println!("{}", yellow("┌─────────────────────────────────────────────────────────────┐"));
println!("{}", yellow("│ ⚠️ 24-WORT NOTFALL-WIEDERHERSTELLUNGSSCHLÜSSEL │"));
println!("{}", yellow("─────────────────────────────────────────────────────────────"));
println!(
"{}",
yellow("─────────────────────────────────────────────────────────────")
);
println!(
"{}",
yellow("│ ⚠️ 24-WORT NOTFALL-WIEDERHERSTELLUNGSSCHLÜSSEL │")
);
println!(
"{}",
yellow("├─────────────────────────────────────────────────────────────┤")
);
println!("│ Falls Sie Ihr Master-Passwort vergessen oder der Header │");
println!("│ beschädigt wird, ist dies Ihre EINZIGE Rettung! │");
println!("│ Notieren Sie die Wörter in EXAKTER Reihenfolge auf Papier! │");
println!("{}", yellow("├─────────────────────────────────────────────────────────────┤"));
println!(
"{}",
yellow("├─────────────────────────────────────────────────────────────┤")
);
for row in 0..8 {
let w1 = if row < words.len() {
@@ -158,7 +170,10 @@ pub fn print_recovery_phrase_card(phrase: &str) {
println!("{:<18} {:<18} {:<18}", cyan(&w1), cyan(&w2), cyan(&w3));
}
println!("{}", yellow("└─────────────────────────────────────────────────────────────┘"));
println!(
"{}",
yellow("└─────────────────────────────────────────────────────────────┘")
);
println!();
}
@@ -204,7 +219,10 @@ pub fn print_verification_report(report: &crate::verify::VerificationReport) {
println!(" - Daten-Chunks: {}", report.total_chunks);
if report.total_bytes_decrypted > 0 {
let mb = report.total_bytes_decrypted as f64 / (1024.0 * 1024.0);
println!(" - Verifiziert: {:.2} MB (vollständig entschlüsselt)", mb);
println!(
" - Verifiziert: {:.2} MB (vollständig entschlüsselt)",
mb
);
}
if !report.errors.is_empty() {
@@ -217,10 +235,15 @@ pub fn print_verification_report(report: &crate::verify::VerificationReport) {
println!();
if report.is_healthy() {
println!(" {}", green("✔ Keine Beschädigungen oder Bitrot festgestellt. Der Container ist integer."));
println!(
" {}",
green("✔ Keine Beschädigungen oder Bitrot festgestellt. Der Container ist integer.")
);
} else {
println!(" {}", red("✖ ACHTUNG: Der Container weist Beschädigungen auf! Bitte Backup prüfen."));
println!(
" {}",
red("✖ ACHTUNG: Der Container weist Beschädigungen auf! Bitte Backup prüfen.")
);
}
println!();
}
+69 -25
View File
@@ -65,11 +65,7 @@ impl Default for UpgradeOptions {
}
/// Überprüft eine Minisign-Signatur für gegebene Binärdaten mit dem angegebenen Minisign Public Key (S-02).
pub fn verify_minisign_signature(
data: &[u8],
sig_str: &str,
pubkey_str: &str,
) -> Result<()> {
pub fn verify_minisign_signature(data: &[u8], sig_str: &str, pubkey_str: &str) -> Result<()> {
let pubkey = minisign_verify::PublicKey::from_base64(pubkey_str)
.map_err(|e| anyhow::anyhow!("Ungültiger öffentlicher Minisign-Schlüssel: {e}"))?;
let signature = minisign_verify::Signature::decode(sig_str)
@@ -138,7 +134,10 @@ pub fn fetch_latest_release(base_url: &str) -> Result<GiteaRelease> {
let api_url = format!("{}/api/v1/repos/harald/sanctum/releases/latest", base);
let resp = ureq::get(&api_url)
.set("User-Agent", &format!("sanctum/{}", env!("CARGO_PKG_VERSION")))
.set(
"User-Agent",
&format!("sanctum/{}", env!("CARGO_PKG_VERSION")),
)
.set("Accept", "application/json")
.timeout(Duration::from_secs(15))
.call()
@@ -154,7 +153,10 @@ pub fn fetch_latest_release(base_url: &str) -> Result<GiteaRelease> {
/// Lädt eine Datei von einer URL als Text herunter.
pub fn fetch_text(url: &str) -> Result<String> {
let resp = ureq::get(url)
.set("User-Agent", &format!("sanctum/{}", env!("CARGO_PKG_VERSION")))
.set(
"User-Agent",
&format!("sanctum/{}", env!("CARGO_PKG_VERSION")),
)
.timeout(Duration::from_secs(15))
.call()
.with_context(|| format!("Fehler beim Herunterladen von '{}'", url))?;
@@ -212,7 +214,10 @@ pub fn download_and_verify_binary(
expected_size: u64,
) -> Result<PathBuf> {
let resp = ureq::get(download_url)
.set("User-Agent", &format!("sanctum/{}", env!("CARGO_PKG_VERSION")))
.set(
"User-Agent",
&format!("sanctum/{}", env!("CARGO_PKG_VERSION")),
)
.timeout(Duration::from_secs(120))
.call()
.with_context(|| format!("Fehler beim Starten des Downloads von '{}'", download_url))?;
@@ -285,7 +290,10 @@ pub fn download_and_verify_binary(
);
}
println!(" • SHA-256: {} (verifiziert)", ui::green(&calculated_hash[..16]));
println!(
" • SHA-256: {} (verifiziert)",
ui::green(&calculated_hash[..16])
);
let (_, path) = temp_file
.keep()
@@ -324,12 +332,16 @@ pub fn run_upgrade(options: &UpgradeOptions) -> Result<()> {
println!("┌─────────────────────────────────────────────────────────────┐");
println!("│ Sanctum — In-Place Upgrade & Aktualisierungsdienst │");
println!("└─────────────────────────────────────────────────────────────┘");
println!(" • Aktuelle Version: {}", ui::cyan(&format!("v{}", current_version)));
println!(
" • Aktuelle Version: {}",
ui::cyan(&format!("v{}", current_version))
);
println!(" • Server: {}", options.base_url);
println!();
// S-02: Prüfung auf abweichende Server-URL
let is_official_server = options.base_url.trim_end_matches('/') == OFFICIAL_UPGRADE_URL.trim_end_matches('/');
let is_official_server =
options.base_url.trim_end_matches('/') == OFFICIAL_UPGRADE_URL.trim_end_matches('/');
if !is_official_server {
if !options.insecure_url {
bail!(
@@ -389,12 +401,22 @@ pub fn run_upgrade(options: &UpgradeOptions) -> Result<()> {
}
// 2. Neuestes Release abfragen
println!(" {} Suche nach neuesten Releases auf Gitea ...", ui::dim("[-]"));
println!(
" {} Suche nach neuesten Releases auf Gitea ...",
ui::dim("[-]")
);
let release = fetch_latest_release(&options.base_url)?;
let remote_tag = &release.tag_name;
let is_newer = is_newer_version(current_version, remote_tag);
println!(" • Neueste Version: {}", if is_newer { ui::green(remote_tag) } else { ui::cyan(remote_tag) });
println!(
" • Neueste Version: {}",
if is_newer {
ui::green(remote_tag)
} else {
ui::cyan(remote_tag)
}
);
println!(" • Release-Name: {}", release.name);
if let Some(ref pub_at) = release.published_at {
println!(" • Veröffentlicht: {}", pub_at);
@@ -459,27 +481,46 @@ pub fn run_upgrade(options: &UpgradeOptions) -> Result<()> {
})?;
// 4. Prüfsummen und Signatur herunterladen und kryptografisch prüfen (S-02)
println!(" {} Lade Prüfsummen ({}) herunter ...", ui::dim("[-]"), CHECKSUM_ASSET_NAME);
println!(
" {} Lade Prüfsummen ({}) herunter ...",
ui::dim("[-]"),
CHECKSUM_ASSET_NAME
);
let sums_content = fetch_text(&checksum_asset.browser_download_url)?;
println!(" {} Lade Minisign-Signatur ({}) herunter ...", ui::dim("[-]"), CHECKSUM_SIG_ASSET_NAME);
println!(
" {} Lade Minisign-Signatur ({}) herunter ...",
ui::dim("[-]"),
CHECKSUM_SIG_ASSET_NAME
);
let sig_content = fetch_text(&sig_asset.browser_download_url)?;
println!(" {} Verifiziere Minisign-Signatur gegen eingebetteten Herstellerschlüssel ...", ui::dim("[-]"));
println!(
" {} Verifiziere Minisign-Signatur gegen eingebetteten Herstellerschlüssel ...",
ui::dim("[-]")
);
verify_minisign_signature(sums_content.as_bytes(), &sig_content, SANCTUM_RELEASE_PUBKEY)
.context("Minisign-Signaturprüfung für Prüfsummendatei FEHLGESCHLAGEN! Release ist nicht vertrauenswürdig.")?;
println!(" • Signatur: {} (Minisign Ed25519)", ui::green("Gültig"));
println!(
" • Signatur: {} (Minisign Ed25519)",
ui::green("Gültig")
);
let expected_hash = parse_checksum_for_asset(&sums_content, TARGET_ASSET_NAME).ok_or_else(|| {
anyhow::anyhow!(
"Prüfsummendatei enthält keinen SHA-256 Eintrag für '{}'!",
TARGET_ASSET_NAME
)
})?;
let expected_hash =
parse_checksum_for_asset(&sums_content, TARGET_ASSET_NAME).ok_or_else(|| {
anyhow::anyhow!(
"Prüfsummendatei enthält keinen SHA-256 Eintrag für '{}'!",
TARGET_ASSET_NAME
)
})?;
// 5. Interaktive Bestätigung (sofern nicht -y / --yes)
if !options.yes {
println!(" • Ziel-Binary: {} ({})", target_asset.name, ui::format_bytes(target_asset.size));
println!(
" • Ziel-Binary: {} ({})",
target_asset.name,
ui::format_bytes(target_asset.size)
);
println!(" • Erwarteter Hash: {}...", &expected_hash[..16]);
println!();
print!(
@@ -513,7 +554,10 @@ pub fn run_upgrade(options: &UpgradeOptions) -> Result<()> {
println!();
println!("┌─────────────────────────────────────────────────────────────┐");
println!("│ ✔ Sanctum wurde erfolgreich auf {} aktualisiert! │", remote_tag);
println!(
"│ ✔ Sanctum wurde erfolgreich auf {} aktualisiert! │",
remote_tag
);
println!("└─────────────────────────────────────────────────────────────┘");
println!(" • Pfad: {}", updated_exe.display());
println!(" • Neue Version: {}", ui::green(remote_tag));
+62 -21
View File
@@ -43,11 +43,13 @@ pub fn verify_container(
full_chunks: bool,
) -> Result<VerificationReport> {
if !container_path.exists() {
bail!("Containerdatei '{}' existiert nicht.", container_path.display());
bail!(
"Containerdatei '{}' existiert nicht.",
container_path.display()
);
}
let db = Database::open(container_path)
.context("Konnte Container-Datenbank nicht öffnen")?;
let db = Database::open(container_path).context("Konnte Container-Datenbank nicht öffnen")?;
let mut report = VerificationReport {
container_path: container_path.display().to_string(),
@@ -67,7 +69,8 @@ pub fn verify_container(
};
// 1. SQLite B-Tree & Foreign Key Prüfung
let sqlite_issues = db.run_sqlite_integrity_check()
let sqlite_issues = db
.run_sqlite_integrity_check()
.context("Fehler bei der Ausführung des SQLite integrity_check")?;
if !sqlite_issues.is_empty() {
report.sqlite_ok = false;
@@ -96,13 +99,16 @@ pub fn verify_container(
};
// 3. Node-Hierarchie & Strukturprüfung
let (dirs, files, chunks_count) = db.count_nodes_and_chunks()
let (dirs, files, chunks_count) = db
.count_nodes_and_chunks()
.context("Fehler beim Zählen der Knoten und Chunks")?;
report.total_dirs = dirs;
report.total_files = files;
report.total_chunks = chunks_count;
let all_nodes = db.list_all_nodes().context("Fehler beim Laden der Knotenliste")?;
let all_nodes = db
.list_all_nodes()
.context("Fehler beim Laden der Knotenliste")?;
report.total_nodes = all_nodes.len();
let mut node_map = HashMap::new();
@@ -114,23 +120,33 @@ pub fn verify_container(
match node_map.get(&1) {
Some(root) => {
if !root.is_dir {
report.errors.push("Root-Knoten (id=1) ist nicht als Verzeichnis markiert!".to_string());
report
.errors
.push("Root-Knoten (id=1) ist nicht als Verzeichnis markiert!".to_string());
}
if root.parent_id.is_some() {
report.errors.push("Root-Knoten (id=1) darf keinen Parent haben!".to_string());
report
.errors
.push("Root-Knoten (id=1) darf keinen Parent haben!".to_string());
}
}
None => {
report.errors.push("Root-Knoten (id=1) fehlt in der nodes-Tabelle!".to_string());
report
.errors
.push("Root-Knoten (id=1) fehlt in der nodes-Tabelle!".to_string());
}
}
if let Some(root2) = node_map.get(&2) {
if !root2.is_dir {
report.errors.push("Root-Knoten (id=2) ist nicht als Verzeichnis markiert!".to_string());
report
.errors
.push("Root-Knoten (id=2) ist nicht als Verzeichnis markiert!".to_string());
}
if root2.parent_id.is_some() {
report.errors.push("Root-Knoten (id=2) darf keinen Parent haben!".to_string());
report
.errors
.push("Root-Knoten (id=2) darf keinen Parent haben!".to_string());
}
}
@@ -232,10 +248,14 @@ pub fn verify_container(
};
// 4. Kryptografische Chunk- & AEAD-Authentifizierungsprüfung
let chunk_headers = db.list_all_chunk_headers()
let chunk_headers = db
.list_all_chunk_headers()
.context("Fehler beim Abrufen der Chunk-Liste")?;
let format_version = meta.as_ref().map(|m| m.version).unwrap_or(FORMAT_VERSION_V2);
let format_version = meta
.as_ref()
.map(|m| m.version)
.unwrap_or(FORMAT_VERSION_V2);
for (node_id, chunk_index) in chunk_headers {
if !node_map.contains_key(&node_id) {
@@ -319,7 +339,8 @@ mod tests {
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.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag)
.unwrap();
// Verzeichnis & Datei anlegen
let folder = db.create_node(1, "photos", true).unwrap();
@@ -334,12 +355,21 @@ mod tests {
let (ct1, n1, t1) = encrypt_chunk(&dek, file.id, 1, chunk1_data, FORMAT_VERSION).unwrap();
db.write_chunk(file.id, 1, &n1, &t1, &ct1).unwrap();
db.update_node_size_and_time(file.id, (chunk0_data.len() + chunk1_data.len()) as u64, 1000).unwrap();
db.update_node_size_and_time(
file.id,
(chunk0_data.len() + chunk1_data.len()) as u64,
1000,
)
.unwrap();
db.checkpoint().unwrap();
// Verifizieren
let report = verify_container(&container_path, Some(&dek), true).expect("Verify container");
assert!(report.is_healthy(), "Container must be healthy, report: {:?}", report);
assert!(
report.is_healthy(),
"Container must be healthy, report: {:?}",
report
);
assert_eq!(report.total_files, 1);
assert_eq!(report.total_dirs, 3); // Root 1 + Root 2 (Dual-Vault) + photos
assert_eq!(report.total_chunks, 2);
@@ -371,7 +401,8 @@ mod tests {
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.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag)
.unwrap();
let file = db.create_node(1, "document.pdf", false).unwrap();
let chunk_data = b"Vital documents that must not be corrupted";
@@ -387,14 +418,24 @@ mod tests {
conn.execute(
"UPDATE chunks SET ciphertext = ?1 WHERE node_id = ?2 AND chunk_index = 0",
rusqlite::params![corrupted_ct, file.id],
).unwrap();
)
.unwrap();
drop(conn);
// Verifizieren: Muss Bitrot via AEAD Tag-Fehler sofort entlarven!
let report = verify_container(&container_path, Some(&dek), true).expect("Verify container");
assert!(!report.is_healthy(), "Container must report unhealthy due to bitrot");
assert_eq!(report.corrupted_chunks, 1, "Must detect exactly 1 corrupted chunk");
assert!(report.errors.iter().any(|e| e.contains("AEAD/Integritätsfehler")));
assert!(
!report.is_healthy(),
"Container must report unhealthy due to bitrot"
);
assert_eq!(
report.corrupted_chunks, 1,
"Must detect exactly 1 corrupted chunk"
);
assert!(report
.errors
.iter()
.any(|e| e.contains("AEAD/Integritätsfehler")));
let _ = fs::remove_file(&container_path);
}
+75 -62
View File
@@ -26,14 +26,8 @@ use crate::storage::{Database, NodeRecord};
pub fn is_leak_file(filename: &str) -> bool {
let lower = filename.trim().to_ascii_lowercase();
match lower.as_str() {
"thumbs.db"
| "ehthumbs.db"
| "ehthumbs_vista.db"
| "desktop.ini"
| "folder.jpg"
| "albumartsmall.jpg"
| "autorun.inf"
| ".ds_store" => true,
"thumbs.db" | "ehthumbs.db" | "ehthumbs_vista.db" | "desktop.ini" | "folder.jpg"
| "albumartsmall.jpg" | "autorun.inf" | ".ds_store" => true,
_ => {
if lower.starts_with("albumart") && (lower.ends_with(".jpg") || lower.ends_with(".ini"))
{
@@ -167,7 +161,6 @@ impl SanctumFile {
self.last_activity.store(now, Ordering::Relaxed);
}
/// Schreibt den aktuell im RAM gehaltenen Chunk verschlüsselt in die SQLite-Datenbank zurück
/// und aktualisiert Dateigröße und Modifikationszeitstempel atomar in einer Transaktion (CHAOS-01).
/// Bei Fehlern (z. B. Disk Full) wird der Cache sauber invalidiert (CHAOS-03).
@@ -179,10 +172,12 @@ impl SanctumFile {
if let Some((idx, ref data, true)) = self.cached_chunk {
let (ciphertext, nonce, tag) =
encrypt_chunk(&self.dek, self.node_id, idx, data, self.format_version).map_err(|e| {
error!("Verschlüsselungsfehler beim Chunk-Flush: {e}");
FsError::GeneralFailure
})?;
encrypt_chunk(&self.dek, self.node_id, idx, data, self.format_version).map_err(
|e| {
error!("Verschlüsselungsfehler beim Chunk-Flush: {e}");
FsError::GeneralFailure
},
)?;
if let Err(e) = self.db.write_chunk_and_update_size(
self.node_id,
@@ -207,7 +202,10 @@ impl SanctumFile {
self.meta.modified_at = UNIX_EPOCH + Duration::from_secs(now);
} else if self.meta.size != self.file_size {
// Falls kein Chunk dirty war, aber sich z. B. die Dateigröße durch Truncate geändert hat
if let Err(e) = self.db.update_node_size_and_time(self.node_id, self.file_size, now) {
if let Err(e) = self
.db
.update_node_size_and_time(self.node_id, self.file_size, now)
{
error!("Fehler beim Aktualisieren der Knotengröße: {e}");
return Err(FsError::GeneralFailure);
}
@@ -263,7 +261,10 @@ impl SanctumFile {
impl Drop for SanctumFile {
fn drop(&mut self) {
if let Err(e) = self.flush_cached_chunk_and_size() {
warn!("Fehler beim automatischen Flush im SanctumFile::drop: {:?}", e);
warn!(
"Fehler beim automatischen Flush im SanctumFile::drop: {:?}",
e
);
}
if let Some((_, ref mut data, _)) = self.cached_chunk {
data.zeroize();
@@ -354,7 +355,12 @@ impl DavFile for SanctumFile {
}
// Wenn der Chunk exakt 1 MB erreicht hat, sofort flushen, um RAM zu schonen
if self.cached_chunk.as_ref().map(|(_, d, _)| d.len() >= CHUNK_SIZE).unwrap_or(false) {
if self
.cached_chunk
.as_ref()
.map(|(_, d, _)| d.len() >= CHUNK_SIZE)
.unwrap_or(false)
{
self.flush_cached_chunk_and_size()?;
}
@@ -633,14 +639,19 @@ impl DavFileSystem for SanctumFs {
}
self.touch();
debug!("VFS open aufgerufen: path='{}', options={:?}", path_str, options);
debug!(
"VFS open aufgerufen: path='{}', options={:?}",
path_str, options
);
let existing_node = self.resolve_path(&path_str)?;
let node = match existing_node {
Some(n) => {
// Schutz der Trägerdatei im Decoy Vault: Keine Schreib- oder Truncate-Operationen erlaubt!
if self.carrier_node_id == Some(n.id) && (options.write || options.truncate || options.append) {
if self.carrier_node_id == Some(n.id)
&& (options.write || options.truncate || options.append)
{
return Err(FsError::Forbidden);
}
@@ -674,9 +685,7 @@ impl DavFileSystem for SanctumFs {
}
None => {
if options.create || options.create_new {
let parent = self
.resolve_path(parent_path)?
.ok_or(FsError::NotFound)?;
let parent = self.resolve_path(parent_path)?.ok_or(FsError::NotFound)?;
if !parent.is_dir {
return Err(FsError::Forbidden);
@@ -712,9 +721,7 @@ impl DavFileSystem for SanctumFs {
Box::pin(async move {
let path_str = Self::path_to_str(path);
let node = self
.resolve_path(&path_str)?
.ok_or(FsError::NotFound)?;
let node = self.resolve_path(&path_str)?.ok_or(FsError::NotFound)?;
if !node.is_dir {
return Err(FsError::Forbidden);
@@ -749,9 +756,7 @@ impl DavFileSystem for SanctumFs {
Box::pin(async move {
let path_str = Self::path_to_str(path);
let node = self
.resolve_path(&path_str)?
.ok_or(FsError::NotFound)?;
let node = self.resolve_path(&path_str)?.ok_or(FsError::NotFound)?;
let meta = SanctumMetaData {
is_dir: node.is_dir,
@@ -789,9 +794,7 @@ impl DavFileSystem for SanctumFs {
return Err(FsError::Exists);
}
let parent = self
.resolve_path(parent_path)?
.ok_or(FsError::NotFound)?;
let parent = self.resolve_path(parent_path)?.ok_or(FsError::NotFound)?;
if !parent.is_dir {
return Err(FsError::Forbidden);
@@ -811,9 +814,7 @@ impl DavFileSystem for SanctumFs {
Box::pin(async move {
self.touch();
let path_str = Self::path_to_str(path);
let node = self
.resolve_path(&path_str)?
.ok_or(FsError::NotFound)?;
let node = self.resolve_path(&path_str)?.ok_or(FsError::NotFound)?;
if !node.is_dir {
return Err(FsError::Forbidden);
@@ -826,7 +827,11 @@ impl DavFileSystem for SanctumFs {
// Schutz der Trägerdatei im Decoy Vault: Verzeichnis darf nicht gelöscht werden, wenn es den Carrier enthält!
if let Some(carrier_id) = self.carrier_node_id {
if self.db.is_descendant_of(carrier_id, node.id).map_err(|_| FsError::GeneralFailure)? {
if self
.db
.is_descendant_of(carrier_id, node.id)
.map_err(|_| FsError::GeneralFailure)?
{
return Err(FsError::Forbidden);
}
}
@@ -847,9 +852,7 @@ impl DavFileSystem for SanctumFs {
Box::pin(async move {
self.touch();
let path_str = Self::path_to_str(path);
let node = self
.resolve_path(&path_str)?
.ok_or(FsError::NotFound)?;
let node = self.resolve_path(&path_str)?.ok_or(FsError::NotFound)?;
if node.is_dir {
return Err(FsError::Forbidden);
@@ -868,11 +871,7 @@ impl DavFileSystem for SanctumFs {
})
}
fn rename<'a>(
&'a self,
from: &'a DavPath,
to: &'a DavPath,
) -> FsFuture<'a, ()> {
fn rename<'a>(&'a self, from: &'a DavPath, to: &'a DavPath) -> FsFuture<'a, ()> {
if let Some(ref cfs) = self.carrier_fs {
return cfs.rename(from, to);
}
@@ -882,9 +881,7 @@ impl DavFileSystem for SanctumFs {
let from_str = Self::path_to_str(from);
let to_str = Self::path_to_str(to);
let node = self
.resolve_path(&from_str)?
.ok_or(FsError::NotFound)?;
let node = self.resolve_path(&from_str)?.ok_or(FsError::NotFound)?;
// Schutz der Trägerdatei im Decoy Vault: Umbenennen verboten!
if self.carrier_node_id == Some(node.id) {
@@ -925,19 +922,13 @@ impl DavFileSystem for SanctumFs {
})
}
fn copy<'a>(
&'a self,
from: &'a DavPath,
to: &'a DavPath,
) -> FsFuture<'a, ()> {
fn copy<'a>(&'a self, from: &'a DavPath, to: &'a DavPath) -> FsFuture<'a, ()> {
Box::pin(async move {
self.touch();
let from_str = Self::path_to_str(from);
let to_str = Self::path_to_str(to);
let node = self
.resolve_path(&from_str)?
.ok_or(FsError::NotFound)?;
let node = self.resolve_path(&from_str)?.ok_or(FsError::NotFound)?;
if node.is_dir {
return Err(FsError::NotImplemented);
@@ -991,9 +982,14 @@ impl DavFileSystem for SanctumFs {
)
.map_err(|_| FsError::GeneralFailure)?;
let (new_ct, new_nonce, new_tag) =
encrypt_chunk(&self.dek, dest_node.id, idx, &plaintext, self.format_version)
.map_err(|_| FsError::GeneralFailure)?;
let (new_ct, new_nonce, new_tag) = encrypt_chunk(
&self.dek,
dest_node.id,
idx,
&plaintext,
self.format_version,
)
.map_err(|_| FsError::GeneralFailure)?;
self.db
.write_chunk(dest_node.id, idx, &new_nonce, &new_tag, &new_ct)
@@ -1025,7 +1021,9 @@ impl DavFileSystem for SanctumFs {
#[cfg(test)]
mod tests {
use super::*;
use crate::crypto::{derive_kek, generate_dek, generate_salt, wrap_dek, KdfParams, FORMAT_VERSION};
use crate::crypto::{
derive_kek, generate_dek, generate_salt, wrap_dek, KdfParams, FORMAT_VERSION,
};
use dav_server::fs::OpenOptions;
use futures_util::StreamExt;
@@ -1078,7 +1076,8 @@ mod tests {
pub struct TempDir(PathBuf);
impl TempDir {
pub fn new() -> Self {
let p = std::env::temp_dir().join(format!("sanctum_test_{}", rand::random::<u64>()));
let p =
std::env::temp_dir().join(format!("sanctum_test_{}", rand::random::<u64>()));
std::fs::create_dir_all(&p).unwrap();
Self(p)
}
@@ -1137,7 +1136,10 @@ mod tests {
// read_dir mit anti_leak = true darf Thumbs.db NICHT anzeigen
let root_path = DavPath::new("/").unwrap();
let mut stream = fs_shielded.read_dir(&root_path, ReadDirMeta::None).await.unwrap();
let mut stream = fs_shielded
.read_dir(&root_path, ReadDirMeta::None)
.await
.unwrap();
let mut names = Vec::new();
while let Some(entry) = stream.next().await {
let entry = entry.unwrap();
@@ -1154,7 +1156,10 @@ mod tests {
FORMAT_VERSION,
false,
);
let mut stream_unshielded = fs_unshielded.read_dir(&root_path, ReadDirMeta::None).await.unwrap();
let mut stream_unshielded = fs_unshielded
.read_dir(&root_path, ReadDirMeta::None)
.await
.unwrap();
let mut names_unshielded = Vec::new();
while let Some(entry) = stream_unshielded.next().await {
let entry = entry.unwrap();
@@ -1263,7 +1268,13 @@ mod tests {
// 1. Null-Byte im Pfad
let null_path = DavPath::new("/bad\0file.txt");
assert!(null_path.is_err() || fs.open(&null_path.unwrap(), OpenOptions::default()).await.is_err());
assert!(
null_path.is_err()
|| fs
.open(&null_path.unwrap(), OpenOptions::default())
.await
.is_err()
);
assert!(validate_path_safety("/bad\0file.txt").is_err());
// 2. Steuerzeichen < 0x20
@@ -1288,7 +1299,9 @@ mod tests {
// 1. Schreibe 500 Bytes
let payload = Bytes::from(vec![42u8; 500]);
file.write_buf(Box::new(std::io::Cursor::new(payload))).await.unwrap();
file.write_buf(Box::new(std::io::Cursor::new(payload)))
.await
.unwrap();
// 2. Expliziter Flush: muss Chunk & Dateigröße atomar persistieren
file.flush().await.unwrap();
+50 -45
View File
@@ -44,7 +44,9 @@ pub fn open_in_explorer(drive_char: char) -> Result<()> {
Command::new("explorer.exe")
.arg(&drive_path)
.spawn()
.with_context(|| format!("Konnte Windows Explorer für '{}' nicht öffnen", drive_path))?;
.with_context(|| {
format!("Konnte Windows Explorer für '{}' nicht öffnen", drive_path)
})?;
Ok(())
}
#[cfg(target_os = "macos")]
@@ -73,15 +75,19 @@ pub fn open_in_file_manager(path: &std::path::Path) -> Result<()> {
Command::new("explorer.exe")
.arg(path)
.spawn()
.with_context(|| format!("Konnte Windows Explorer für '{}' nicht öffnen", path.display()))?;
.with_context(|| {
format!(
"Konnte Windows Explorer für '{}' nicht öffnen",
path.display()
)
})?;
Ok(())
}
#[cfg(target_os = "macos")]
{
Command::new("open")
.arg(path)
.spawn()
.with_context(|| format!("Konnte macOS Finder für '{}' nicht öffnen", path.display()))?;
Command::new("open").arg(path).spawn().with_context(|| {
format!("Konnte macOS Finder für '{}' nicht öffnen", path.display())
})?;
Ok(())
}
#[cfg(all(unix, not(target_os = "macos")))]
@@ -89,7 +95,12 @@ pub fn open_in_file_manager(path: &std::path::Path) -> Result<()> {
Command::new("xdg-open")
.arg(path)
.spawn()
.with_context(|| format!("Konnte Dateimanager via xdg-open für '{}' nicht öffnen", path.display()))?;
.with_context(|| {
format!(
"Konnte Dateimanager via xdg-open für '{}' nicht öffnen",
path.display()
)
})?;
Ok(())
}
#[cfg(not(any(windows, unix)))]
@@ -129,8 +140,7 @@ pub fn notify_shell_associations_changed() {
let _ = Command::new("update-desktop-database").spawn();
}
#[cfg(not(any(windows, all(unix, not(target_os = "macos")))))]
{
}
{}
}
/// Registriert `.sanctum`-Containerdateien im Windows Explorer für den aktuellen Benutzer bzw. unter Linux via Freedesktop.
@@ -143,11 +153,7 @@ pub fn register_explorer_integration() -> Result<()> {
let reg_commands = [
// 1. .sanctum Erweiterung mit ProgID verknüpfen
(
r"HKCU\Software\Classes\.sanctum",
"",
"Sanctum.Container",
),
(r"HKCU\Software\Classes\.sanctum", "", "Sanctum.Container"),
// 2. ProgID Metadaten & Beschreibung
(
r"HKCU\Software\Classes\Sanctum.Container",
@@ -205,7 +211,9 @@ pub fn register_explorer_integration() -> Result<()> {
}
cmd.arg("/d").arg(val_data).arg("/f");
let output = cmd.output().with_context(|| format!("Fehler beim Ausführen von 'reg add {key}'"))?;
let output = cmd
.output()
.with_context(|| format!("Fehler beim Ausführen von 'reg add {key}'"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("Registry-Fehler beim Anlegen von {key}: {stderr}");
@@ -246,9 +254,7 @@ pub fn unregister_explorer_integration() -> Result<()> {
];
for key in keys_to_delete {
let _ = Command::new("reg")
.args(["delete", key, "/f"])
.output();
let _ = Command::new("reg").args(["delete", key, "/f"]).output();
}
notify_shell_associations_changed();
@@ -258,8 +264,11 @@ pub fn unregister_explorer_integration() -> Result<()> {
{
if let Some(home) = std::env::var_os("HOME") {
let home_path = std::path::PathBuf::from(home);
let _ = std::fs::remove_file(home_path.join(".local/share/applications/sanctum.desktop"));
let _ = std::fs::remove_file(home_path.join(".local/share/mime/packages/application-x-sanctum.xml"));
let _ =
std::fs::remove_file(home_path.join(".local/share/applications/sanctum.desktop"));
let _ = std::fs::remove_file(
home_path.join(".local/share/mime/packages/application-x-sanctum.xml"),
);
notify_shell_associations_changed();
}
Ok(())
@@ -496,9 +505,9 @@ pub fn start_session_lock_monitor(
})
.context("Konnte Windows Session-Monitor-Thread nicht starten")?;
let hwnd = hwnd_rx
.recv()
.map_err(|e| anyhow::anyhow!("Session-Monitor-Thread initialisierte nicht rechtzeitig: {e}"))?;
let hwnd = hwnd_rx.recv().map_err(|e| {
anyhow::anyhow!("Session-Monitor-Thread initialisierte nicht rechtzeitig: {e}")
})?;
if hwnd == 0 {
bail!("Win32-Nachrichtenfenster für Session-Lock konnte nicht erstellt werden");
@@ -522,7 +531,8 @@ pub fn start_session_lock_monitor(
// ─────────────────────────────────────────────────────────────
#[cfg(windows)]
static CONSOLE_CTRL_TX: std::sync::Mutex<Option<tokio::sync::mpsc::Sender<()>>> = std::sync::Mutex::new(None);
static CONSOLE_CTRL_TX: std::sync::Mutex<Option<tokio::sync::mpsc::Sender<()>>> =
std::sync::Mutex::new(None);
#[cfg(windows)]
static CONSOLE_CTRL_DRIVE: std::sync::Mutex<Option<char>> = std::sync::Mutex::new(None);
@@ -550,11 +560,7 @@ extern "system" {
dwFlags: u32,
) -> u32;
fn WNetCancelConnection2W(
lpName: *const u16,
dwFlags: u32,
fForce: i32,
) -> u32;
fn WNetCancelConnection2W(lpName: *const u16, dwFlags: u32, fForce: i32) -> u32;
}
#[cfg(windows)]
@@ -571,10 +577,8 @@ unsafe extern "system" fn console_ctrl_routine(ctrl_type: u32) -> i32 {
if let Ok(guard) = CONSOLE_CTRL_DRIVE.lock() {
if let Some(dl) = *guard {
let drive_str = format!("{}:", dl.to_ascii_uppercase());
let wide_drive: Vec<u16> = drive_str
.encode_utf16()
.chain(std::iter::once(0))
.collect();
let wide_drive: Vec<u16> =
drive_str.encode_utf16().chain(std::iter::once(0)).collect();
unsafe {
WNetCancelConnection2W(wide_drive.as_ptr(), 0, 1);
}
@@ -724,9 +728,15 @@ pub fn mount_drive_wnet(drive_letter: char, port: u16, session_token: &str) -> R
let drive_str = format!("{}:", drive_letter.to_ascii_uppercase());
let mut local_name: Vec<u16> = drive_str.encode_utf16().chain(std::iter::once(0)).collect();
let remote_url = format!("http://127.0.0.1:{}/", port);
let mut remote_name: Vec<u16> = remote_url.encode_utf16().chain(std::iter::once(0)).collect();
let mut remote_name: Vec<u16> = remote_url
.encode_utf16()
.chain(std::iter::once(0))
.collect();
let username: Vec<u16> = "sanctum".encode_utf16().chain(std::iter::once(0)).collect();
let password: Vec<u16> = session_token.encode_utf16().chain(std::iter::once(0)).collect();
let password: Vec<u16> = session_token
.encode_utf16()
.chain(std::iter::once(0))
.collect();
let nr = NETRESOURCEW {
dwScope: 0,
@@ -750,18 +760,14 @@ pub fn mount_drive_wnet(drive_letter: char, port: u16, session_token: &str) -> R
// PRR-01: Selbstreparatur bei verwaister Zuordnung nach unsauberem Vorläufer (Systemfehler 85 / 1202)
if res == 85 || res == 1202 {
debug!("Verwaiste Zuordnung für {} entdeckt — führe automatische Bereinigung durch...", drive_str);
debug!(
"Verwaiste Zuordnung für {} entdeckt — führe automatische Bereinigung durch...",
drive_str
);
unsafe {
WNetCancelConnection2W(local_name.as_ptr(), 0, 1);
}
res = unsafe {
WNetAddConnection2W(
&nr,
password.as_ptr(),
username.as_ptr(),
0,
)
};
res = unsafe { WNetAddConnection2W(&nr, password.as_ptr(), username.as_ptr(), 0) };
}
if res != 0 {
@@ -849,4 +855,3 @@ mod tests {
drop(monitor);
}
}
+140 -35
View File
@@ -7,8 +7,8 @@ use rand::rngs::OsRng;
use rand::RngCore;
use sanctum::crypto::{
derive_kek, generate_dek, generate_salt, wrap_slot0_payload,
wrap_slot1_payload, KdfParams, CHUNK_SIZE, MIN_MEMORY_COST_KIB, MIN_TIME_COST,
derive_kek, generate_dek, generate_salt, wrap_slot0_payload, wrap_slot1_payload, KdfParams,
CHUNK_SIZE, MIN_MEMORY_COST_KIB, MIN_TIME_COST,
};
use sanctum::storage::Database;
use sanctum::verify::verify_container;
@@ -45,8 +45,7 @@ async fn test_model_a_carrier_filesystem_and_accounting_attack_resistance() {
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_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();
@@ -103,7 +102,10 @@ async fn test_model_a_carrier_filesystem_and_accounting_attack_resistance() {
let carrier_path = DavPath::new("/system_backup.dat").unwrap();
// Metadaten der Alibi-Datei im Decoy prüfen
let carrier_meta = decoy_fs.metadata(&carrier_path).await.expect("Carrier meta");
let carrier_meta = decoy_fs
.metadata(&carrier_path)
.await
.expect("Carrier meta");
assert_eq!(carrier_meta.len(), carrier_size_bytes);
assert!(!carrier_meta.is_dir());
@@ -113,20 +115,29 @@ async fn test_model_a_carrier_filesystem_and_accounting_attack_resistance() {
..Default::default()
};
assert!(
matches!(decoy_fs.open(&carrier_path, write_opts).await, Err(FsError::Forbidden)),
matches!(
decoy_fs.open(&carrier_path, write_opts).await,
Err(FsError::Forbidden)
),
"Alibi-Datei darf nicht zum Schreiben geöffnet werden"
);
// Alibi-Datei darf im Decoy-Mount NICHT gelöscht werden
assert!(
matches!(decoy_fs.remove_file(&carrier_path).await, Err(FsError::Forbidden)),
matches!(
decoy_fs.remove_file(&carrier_path).await,
Err(FsError::Forbidden)
),
"Alibi-Datei darf nicht gelöscht werden"
);
// Alibi-Datei darf im Decoy-Mount NICHT umbenannt werden
let new_name = DavPath::new("/renamed.iso").unwrap();
assert!(
matches!(decoy_fs.rename(&carrier_path, &new_name).await, Err(FsError::Forbidden)),
matches!(
decoy_fs.rename(&carrier_path, &new_name).await,
Err(FsError::Forbidden)
),
"Alibi-Datei darf nicht umbenannt werden"
);
@@ -135,8 +146,14 @@ async fn test_model_a_carrier_filesystem_and_accounting_attack_resistance() {
read: true,
..Default::default()
};
let mut file_reader = decoy_fs.open(&carrier_path, read_opts).await.expect("Open read");
let first_mb = file_reader.read_bytes(CHUNK_SIZE).await.expect("Read first chunk");
let mut file_reader = decoy_fs
.open(&carrier_path, read_opts)
.await
.expect("Open read");
let first_mb = file_reader
.read_bytes(CHUNK_SIZE)
.await
.expect("Read first chunk");
assert_eq!(first_mb.len(), CHUNK_SIZE);
// 3. Hidden Mount: Dateisystem-Operationen innerhalb des Alibi-Carriers
@@ -161,11 +178,17 @@ async fn test_model_a_carrier_filesystem_and_accounting_attack_resistance() {
while let Some(item) = stream.next().await {
entries.push(item.unwrap().name());
}
assert!(entries.is_empty(), "Hidden Vault Wurzelverzeichnis muss anfangs leer sein");
assert!(
entries.is_empty(),
"Hidden Vault Wurzelverzeichnis muss anfangs leer sein"
);
// Ordner erstellen
let secret_dir = DavPath::new("/Classified").unwrap();
hidden_fs.create_dir(&secret_dir).await.expect("Create Classified dir");
hidden_fs
.create_dir(&secret_dir)
.await
.expect("Create Classified dir");
// Datei im Ordner anlegen und schreiben
let secret_file_path = DavPath::new("/Classified/passwords.txt").unwrap();
@@ -196,14 +219,24 @@ async fn test_model_a_carrier_filesystem_and_accounting_attack_resistance() {
.open(&secret_file_path, read_opts)
.await
.expect("Open secret file for read");
let read_data = read_handle.read_bytes(1024).await.expect("Read secret bytes");
let read_data = read_handle
.read_bytes(1024)
.await
.expect("Read secret bytes");
assert_eq!(&read_data[..], secret_content);
drop(read_handle);
// Größere Binärdatei schreiben (über 2 MB = 2 Blöcke)
let big_file_path = DavPath::new("/Classified/payload.bin").unwrap();
let mut big_file = hidden_fs
.open(&big_file_path, OpenOptions { create: true, write: true, ..Default::default() })
.open(
&big_file_path,
OpenOptions {
create: true,
write: true,
..Default::default()
},
)
.await
.expect("Create big file");
@@ -211,28 +244,46 @@ async fn test_model_a_carrier_filesystem_and_accounting_attack_resistance() {
let mut payload = vec![0u8; payload_size];
OsRng.fill_bytes(&mut payload);
big_file.write_bytes(Bytes::copy_from_slice(&payload)).await.expect("Write big payload");
big_file
.write_bytes(Bytes::copy_from_slice(&payload))
.await
.expect("Write big payload");
big_file.flush().await.expect("Flush big file");
drop(big_file);
// Datei zurücklesen und Bit-für-Bit verifizieren
let mut read_big = hidden_fs
.open(&big_file_path, OpenOptions { read: true, ..Default::default() })
.open(
&big_file_path,
OpenOptions {
read: true,
..Default::default()
},
)
.await
.expect("Open big file");
let read_big_bytes = read_big.read_bytes(payload_size + 100).await.expect("Read big file bytes");
let read_big_bytes = read_big
.read_bytes(payload_size + 100)
.await
.expect("Read big file bytes");
assert_eq!(read_big_bytes.len(), payload_size);
assert_eq!(&read_big_bytes[..], &payload[..]);
drop(read_big);
// Datei umbenennen
let renamed_path = DavPath::new("/Classified/renamed_payload.bin").unwrap();
hidden_fs.rename(&big_file_path, &renamed_path).await.expect("Rename file");
hidden_fs
.rename(&big_file_path, &renamed_path)
.await
.expect("Rename file");
assert!(hidden_fs.metadata(&big_file_path).await.is_err());
assert!(hidden_fs.metadata(&renamed_path).await.is_ok());
// Datei löschen (Blöcke werden geshreddert und freigegeben)
hidden_fs.remove_file(&renamed_path).await.expect("Remove file");
hidden_fs
.remove_file(&renamed_path)
.await
.expect("Remove file");
assert!(hidden_fs.metadata(&renamed_path).await.is_err());
// Checkpoint SQLite
@@ -284,8 +335,7 @@ async fn test_model_a_container_file_size_invariance() {
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_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();
@@ -315,7 +365,10 @@ async fn test_model_a_container_file_size_invariance() {
// Initiale Dateigröße messen
let initial_file_size = std::fs::metadata(&path).unwrap().len();
assert!(initial_file_size >= carrier_size_bytes, "Containergröße muss mindestens 10 MB betragen");
assert!(
initial_file_size >= carrier_size_bytes,
"Containergröße muss mindestens 10 MB betragen"
);
// Hidden Mount öffnen und 4 MB geheime Daten schreiben
let meta = db.read_meta().unwrap();
@@ -333,13 +386,23 @@ async fn test_model_a_container_file_size_invariance() {
let test_file = DavPath::new("/large_confidential.pdf").unwrap();
let mut handle = hidden_fs
.open(&test_file, OpenOptions { create: true, write: true, ..Default::default() })
.open(
&test_file,
OpenOptions {
create: true,
write: true,
..Default::default()
},
)
.await
.expect("Open file");
let mut random_data = vec![0u8; 4 * 1024 * 1024]; // 4 MB
OsRng.fill_bytes(&mut random_data);
handle.write_bytes(Bytes::copy_from_slice(&random_data)).await.expect("Write 4MB");
handle
.write_bytes(Bytes::copy_from_slice(&random_data))
.await
.expect("Write 4MB");
handle.flush().await.expect("Flush 4MB");
drop(handle);
@@ -383,8 +446,7 @@ async fn test_carrier_file_drop_and_append_mode() {
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_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();
@@ -428,44 +490,87 @@ async fn test_carrier_file_drop_and_append_mode() {
// 1. TEST CarrierFile::drop: Write bytes OHNE expliziten flush(), dann drop(handle)
let test_file = DavPath::new("/drop_flush_test.txt").unwrap();
let mut write_handle = hidden_fs
.open(&test_file, OpenOptions { create: true, write: true, ..Default::default() })
.open(
&test_file,
OpenOptions {
create: true,
write: true,
..Default::default()
},
)
.await
.expect("Open file for write");
let initial_data = b"Hello from unflushed write!";
write_handle.write_bytes(Bytes::from_static(initial_data)).await.expect("Write initial data");
write_handle
.write_bytes(Bytes::from_static(initial_data))
.await
.expect("Write initial data");
// WICHTIG: KEIN write_handle.flush()! Nur drop:
drop(write_handle);
// Jetzt Datei wieder lesend öffnen und prüfen, ob Daten durch Drop persistiert wurden
let mut read_handle = hidden_fs
.open(&test_file, OpenOptions { read: true, ..Default::default() })
.open(
&test_file,
OpenOptions {
read: true,
..Default::default()
},
)
.await
.expect("Open file for read");
let read_back = read_handle.read_bytes(100).await.expect("Read data back");
assert_eq!(&read_back[..], initial_data, "Drop muss ungeflushte Datenblöcke und Inode automatisch sichern");
assert_eq!(
&read_back[..],
initial_data,
"Drop muss ungeflushte Datenblöcke und Inode automatisch sichern"
);
drop(read_handle);
// 2. TEST O_APPEND: Im Append-Modus öffnen und weitere Daten anhängen
let append_data = b" - Appended data at EOF!";
let mut append_handle = hidden_fs
.open(&test_file, OpenOptions { write: true, append: true, ..Default::default() })
.open(
&test_file,
OpenOptions {
write: true,
append: true,
..Default::default()
},
)
.await
.expect("Open file for append");
append_handle.write_bytes(Bytes::from_static(append_data)).await.expect("Write appended data");
append_handle
.write_bytes(Bytes::from_static(append_data))
.await
.expect("Write appended data");
drop(append_handle); // Drop sichert auch hier
// Prüfe den vollständigen Dateiinhalt nach Append
let mut read_handle_2 = hidden_fs
.open(&test_file, OpenOptions { read: true, ..Default::default() })
.open(
&test_file,
OpenOptions {
read: true,
..Default::default()
},
)
.await
.expect("Open file for read after append");
let full_content = read_handle_2.read_bytes(200).await.expect("Read full content");
let full_content = read_handle_2
.read_bytes(200)
.await
.expect("Read full content");
let mut expected = Vec::new();
expected.extend_from_slice(initial_data);
expected.extend_from_slice(append_data);
assert_eq!(&full_content[..], &expected[..], "O_APPEND muss Daten am Dateiende anhängen");
assert_eq!(
&full_content[..],
&expected[..],
"O_APPEND muss Daten am Dateiende anhängen"
);
drop(read_handle_2);
let _ = std::fs::remove_file(&path);
+506 -132
View File
File diff suppressed because it is too large Load Diff
+10 -3
View File
@@ -20,7 +20,10 @@ use sanctum::{
#[tokio::test]
async fn test_live_crash_and_recovery_stress() {
let temp_dir = std::env::temp_dir();
let container_path: PathBuf = temp_dir.join(format!("sanctum_live_stress_{}.sanctum", std::process::id()));
let container_path: PathBuf = temp_dir.join(format!(
"sanctum_live_stress_{}.sanctum",
std::process::id()
));
if container_path.exists() {
let _ = std::fs::remove_file(&container_path);
}
@@ -105,13 +108,17 @@ async fn test_live_crash_and_recovery_stress() {
// 4. Recovery & Integritätsprüfung nach Crash
// Das System muss die SQLite WAL-Datei automatisch erkennen und verarbeiten
let verify_result = verify_container(&container_path, Some(&dek), false).expect("Verify post-crash");
let verify_result =
verify_container(&container_path, Some(&dek), false).expect("Verify post-crash");
assert!(
verify_result.is_healthy(),
"Container muss nach Crash vollkommen konsistent sein! Fehler: {:?}",
verify_result.errors
);
assert_eq!(verify_result.corrupted_chunks, 0, "Keine korrupten Chunks erlaubt");
assert_eq!(
verify_result.corrupted_chunks, 0,
"Keine korrupten Chunks erlaubt"
);
// 5. Konsistentes Weiterarbeiten nach dem Absturz
let db_recovered = Database::open(&container_path).expect("Open database after crash");
+56 -15
View File
@@ -1,10 +1,10 @@
use std::path::PathBuf;
use sanctum::crypto::{
derive_kek, generate_dek, generate_salt, wrap_dek, KdfParams, MIN_MEMORY_COST_KIB,
MIN_TIME_COST,
};
use sanctum::mount::is_loopback_host;
use sanctum::storage::Database;
use std::path::PathBuf;
#[test]
fn test_is_loopback_host_comprehensive() {
@@ -45,7 +45,8 @@ fn test_is_loopback_host_comprehensive() {
#[test]
fn test_wal_and_shm_cleanup_on_close() {
let temp_dir = std::env::temp_dir();
let container_path: PathBuf = temp_dir.join(format!("test_wal_cleanup_{}.sanctum", std::process::id()));
let container_path: PathBuf =
temp_dir.join(format!("test_wal_cleanup_{}.sanctum", std::process::id()));
if container_path.exists() {
let _ = std::fs::remove_file(&container_path);
}
@@ -61,7 +62,8 @@ fn test_wal_and_shm_cleanup_on_close() {
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.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag)
.unwrap();
// Erstelle Knoten, um Schreibaktivität im WAL zu erzeugen
let _ = db.create_node(1, "test_file.txt", false).unwrap();
@@ -77,24 +79,33 @@ fn test_wal_and_shm_cleanup_on_close() {
shm.push("-shm");
let _ = std::fs::remove_file(&shm);
assert!(!PathBuf::from(wal).exists(), "WAL-Datei darf nach sauberem Unmount nicht zurückbleiben");
assert!(!PathBuf::from(shm).exists(), "SHM-Datei darf nach sauberem Unmount nicht zurückbleiben");
assert!(
!PathBuf::from(wal).exists(),
"WAL-Datei darf nach sauberem Unmount nicht zurückbleiben"
);
assert!(
!PathBuf::from(shm).exists(),
"SHM-Datei darf nach sauberem Unmount nicht zurückbleiben"
);
let _ = std::fs::remove_file(&container_path);
}
#[test]
fn test_mount_security_multi_auth_and_no_token_in_url() {
use sanctum::mount::{check_basic_auth, check_token_header, strip_path_prefix};
use sanctum::mount::{check_basic_auth, check_token_header, uri_contains_token};
let session_token = "4f8a12bc90de45f187a23456789abcde";
let port = 8443;
let remote_url = format!("http://127.0.0.1:{}/", port);
// R-05: Die Remote-URL für Mount-Befehle darf niemals das Session-Token enthalten!
assert!(!remote_url.contains(session_token), "Remote URL darf niemals das Session-Token enthalten");
// R-05 & SA-05: Die Remote-URL für Mount-Befehle darf niemals das Session-Token enthalten!
assert!(
!remote_url.contains(session_token),
"Remote URL darf niemals das Session-Token enthalten"
);
// 1. Basic Auth Prüfung
// 1. Basic Auth Prüfung (Constant-Time)
use base64::Engine;
let auth_header = format!(
"Basic {}",
@@ -102,14 +113,44 @@ fn test_mount_security_multi_auth_and_no_token_in_url() {
);
assert!(check_basic_auth(&auth_header, session_token));
// 2. Token Header Prüfung
let wrong_auth = format!(
"Basic {}",
base64::engine::general_purpose::STANDARD.encode("sanctum:wrong_token_1234567890abcdef")
);
assert!(!check_basic_auth(&wrong_auth, session_token));
// 2. Token Header Prüfung (Constant-Time)
let mut headers = hyper::HeaderMap::new();
headers.insert("X-Sanctum-Token", session_token.parse().unwrap());
assert!(check_token_header(&headers, session_token));
// 3. Path-Prefix Fallback
let prefix = format!("/{}", session_token);
let uri: hyper::Uri = format!("http://127.0.0.1:8443/{}/test.txt", session_token).parse().unwrap();
let stripped = strip_path_prefix(&uri, &prefix).expect("Strip prefix");
assert_eq!(stripped.path(), "/test.txt");
let mut wrong_headers = hyper::HeaderMap::new();
wrong_headers.insert(
"X-Sanctum-Token",
"wrong_token_1234567890abcdef".parse().unwrap(),
);
assert!(!check_token_header(&wrong_headers, session_token));
// 3. SA-05: Verifikation, dass Token in URI (Pfad oder Query) erkannt und strikt abgewiesen wird
let uri_path: hyper::Uri = format!("http://127.0.0.1:8443/{}/test.txt", session_token)
.parse()
.unwrap();
assert!(
uri_contains_token(&uri_path, session_token),
"Token im Pfad muss erkannt werden"
);
let uri_query: hyper::Uri = format!("http://127.0.0.1:8443/test.txt?token={}", session_token)
.parse()
.unwrap();
assert!(
uri_contains_token(&uri_query, session_token),
"Token im Query-String muss erkannt werden"
);
let uri_clean: hyper::Uri = "http://127.0.0.1:8443/test.txt".parse().unwrap();
assert!(
!uri_contains_token(&uri_clean, session_token),
"Saubere URI darf kein Token enthalten"
);
}
+40 -10
View File
@@ -27,9 +27,25 @@ fn test_validate_node_name_rejections() {
// 6. Windows reservierte Gerätenamen
let reserved_names = [
"CON", "con", "prn", "PRN", "aux", "AUX", "nul", "NUL",
"COM1", "com2", "COM9", "lpt1", "LPT2", "LPT9",
"con.txt", "aux.dat", "NUL.tar.gz", "com1.log", "prn.pdf",
"CON",
"con",
"prn",
"PRN",
"aux",
"AUX",
"nul",
"NUL",
"COM1",
"com2",
"COM9",
"lpt1",
"LPT2",
"LPT9",
"con.txt",
"aux.dat",
"NUL.tar.gz",
"com1.log",
"prn.pdf",
];
for res in reserved_names {
assert!(
@@ -64,7 +80,10 @@ fn test_validate_node_name_accepted() {
#[test]
fn test_storage_create_and_rename_reject_invalid_names() {
let mut path = std::env::temp_dir();
path.push(format!("sanctum_test_pathutil_{}.sanctum", std::process::id()));
path.push(format!(
"sanctum_test_pathutil_{}.sanctum",
std::process::id()
));
if path.exists() {
let _ = std::fs::remove_file(&path);
}
@@ -80,20 +99,31 @@ fn test_storage_create_and_rename_reject_invalid_names() {
let (wrapped, nonce, tag) = sanctum::crypto::wrap_dek(&kek, &dek).unwrap();
let db = sanctum::storage::Database::open(&path).unwrap();
db.init_schema(&salt, &kdf_params, &wrapped, &nonce, &tag).unwrap();
db.init_schema(&salt, &kdf_params, &wrapped, &nonce, &tag)
.unwrap();
// create_node_in_vault mit ungültigem Namen muss scheitern
assert!(db.create_node_in_vault(0, 1, "..", false, &dek).is_err());
assert!(db.create_node_in_vault(0, 1, "CON", false, &dek).is_err());
assert!(db.create_node_in_vault(0, 1, "sub/dir", false, &dek).is_err());
assert!(db
.create_node_in_vault(0, 1, "sub/dir", false, &dek)
.is_err());
// Gültige Datei erstellen
let valid_node = db.create_node_in_vault(0, 1, "valid.txt", false, &dek).unwrap();
let valid_node = db
.create_node_in_vault(0, 1, "valid.txt", false, &dek)
.unwrap();
// rename_node_in_vault mit ungültigem Namen muss scheitern
assert!(db.rename_node_in_vault(valid_node.id, 1, "..", 0, &dek).is_err());
assert!(db.rename_node_in_vault(valid_node.id, 1, "AUX", 0, &dek).is_err());
assert!(db.rename_node_in_vault(valid_node.id, 1, "bad\\name", 0, &dek).is_err());
assert!(db
.rename_node_in_vault(valid_node.id, 1, "..", 0, &dek)
.is_err());
assert!(db
.rename_node_in_vault(valid_node.id, 1, "AUX", 0, &dek)
.is_err());
assert!(db
.rename_node_in_vault(valid_node.id, 1, "bad\\name", 0, &dek)
.is_err());
let _ = std::fs::remove_file(&path);
}
+68 -23
View File
@@ -1,11 +1,11 @@
use std::collections::HashSet;
use std::path::PathBuf;
use rand::rngs::OsRng;
use rand::RngCore;
use std::collections::HashSet;
use std::path::PathBuf;
use sanctum::crypto::{
derive_kek, generate_dek, generate_salt, wrap_slot0_payload,
wrap_slot1_payload, KdfParams, MIN_MEMORY_COST_KIB, MIN_TIME_COST,
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 sanctum::sync::{delete_orphans_in_vault, SyncStats};
@@ -41,8 +41,7 @@ fn test_carrier_protection_in_storage_and_sync() {
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_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();
@@ -74,18 +73,33 @@ fn test_carrier_protection_in_storage_and_sync() {
// 1. Direkter Löschversuch der Trägerdatei muss fail-closed abgewehrt werden
let del_res = db.delete_node(carrier_node_id);
assert!(del_res.is_err(), "Löschen der Trägerdatei muss fehlschlagen");
assert!(
del_res.is_err(),
"Löschen der Trägerdatei muss fehlschlagen"
);
assert!(del_res.unwrap_err().to_string().contains("Carrier-Schutz"));
// 2. Umbenennung der Trägerdatei muss fail-closed abgewehrt werden
let rename_res = db.rename_node_in_vault(carrier_node_id, 1, "renamed.iso", 0, &dek_0);
assert!(rename_res.is_err(), "Umbenennen der Trägerdatei muss fehlschlagen");
assert!(rename_res.unwrap_err().to_string().contains("Carrier-Schutz"));
assert!(
rename_res.is_err(),
"Umbenennen der Trägerdatei muss fehlschlagen"
);
assert!(rename_res
.unwrap_err()
.to_string()
.contains("Carrier-Schutz"));
// 3. Truncate der Trägerdatei muss fail-closed abgewehrt werden
let trunc_res = db.truncate_chunks_after(carrier_node_id, 0);
assert!(trunc_res.is_err(), "Truncate der Trägerdatei muss fehlschlagen");
assert!(trunc_res.unwrap_err().to_string().contains("Carrier-Schutz"));
assert!(
trunc_res.is_err(),
"Truncate der Trägerdatei muss fehlschlagen"
);
assert!(trunc_res
.unwrap_err()
.to_string()
.contains("Carrier-Schutz"));
// 4. delete_orphans_in_vault mit leerem lokalem Pfad-Set:
// Der Carrier darf unter keinen Umständen gelöscht werden!
@@ -102,16 +116,26 @@ fn test_carrier_protection_in_storage_and_sync() {
true, // quiet
&mut stats,
);
assert!(orphan_res.is_ok(), "delete_orphans_in_vault muss ohne Fehler durchlaufen");
assert_eq!(stats.files_deleted, 0, "Carrier-Datei darf nicht gelöscht worden sein");
assert!(
orphan_res.is_ok(),
"delete_orphans_in_vault muss ohne Fehler durchlaufen"
);
assert_eq!(
stats.files_deleted, 0,
"Carrier-Datei darf nicht gelöscht worden sein"
);
// Sicherstellen, dass Carrier nach wie vor in der DB existiert
let carrier_rec = db.get_node_by_id(carrier_node_id).unwrap();
assert!(carrier_rec.is_some(), "Carrier-Knoten muss nach Sync unversehrt vorhanden sein");
assert!(
carrier_rec.is_some(),
"Carrier-Knoten muss nach Sync unversehrt vorhanden sein"
);
// 5. R-02: sync_single_file_to_host muss Carrier-Pull strikt ablehnen
let carrier_node = db.get_node_by_id(carrier_node_id).unwrap().unwrap();
let local_dest_file = std::env::temp_dir().join(format!("carrier_leak_{}.iso", std::process::id()));
let local_dest_file =
std::env::temp_dir().join(format!("carrier_leak_{}.iso", std::process::id()));
let pull_single_res = sanctum::sync::sync_single_file_to_host(
&db,
0,
@@ -122,11 +146,18 @@ fn test_carrier_protection_in_storage_and_sync() {
false,
false,
);
assert!(pull_single_res.is_err(), "sync_single_file_to_host auf Carrier muss fehlschlagen");
assert!(!local_dest_file.exists(), "Trägerdatei darf niemals auf den Host geschrieben werden");
assert!(
pull_single_res.is_err(),
"sync_single_file_to_host auf Carrier muss fehlschlagen"
);
assert!(
!local_dest_file.exists(),
"Trägerdatei darf niemals auf den Host geschrieben werden"
);
// 6. R-02: run_sync mit Pull muss die Trägerdatei überspringen
let local_pull_dir = std::env::temp_dir().join(format!("sanctum_pull_test_{}", std::process::id()));
let local_pull_dir =
std::env::temp_dir().join(format!("sanctum_pull_test_{}", std::process::id()));
let sync_options = sanctum::sync::SyncOptions {
direction: sanctum::sync::SyncDirection::Pull,
delete: false,
@@ -144,9 +175,15 @@ fn test_carrier_protection_in_storage_and_sync() {
&local_pull_dir.to_string_lossy(),
&sync_options,
);
assert!(pull_res.is_ok(), "run_sync pull muss erfolgreich durchlaufen");
assert!(
pull_res.is_ok(),
"run_sync pull muss erfolgreich durchlaufen"
);
let pulled_carrier = local_pull_dir.join(carrier_name);
assert!(!pulled_carrier.exists(), "Trägerdatei darf bei recursive pull nicht auf den Host kopiert werden");
assert!(
!pulled_carrier.exists(),
"Trägerdatei darf bei recursive pull nicht auf den Host kopiert werden"
);
let _ = std::fs::remove_dir_all(&local_pull_dir);
// 7. R-02: VFS Copy Schutz
@@ -166,14 +203,22 @@ fn test_carrier_protection_in_storage_and_sync() {
let to_copy = DavPath::new("/carrier_copy.iso").unwrap();
let rt = tokio::runtime::Runtime::new().unwrap();
let copy_src_res = rt.block_on(fs.copy(&from_carrier, &to_copy));
assert!(copy_src_res.is_err(), "SanctumFs::copy mit Carrier als Quelle muss FsError::Forbidden liefern");
assert!(
copy_src_res.is_err(),
"SanctumFs::copy mit Carrier als Quelle muss FsError::Forbidden liefern"
);
// Copy Ziel = Carrier
let _regular_file = db.create_node_in_vault(0, 1, "regular.txt", false, &dek_0).unwrap();
let _regular_file = db
.create_node_in_vault(0, 1, "regular.txt", false, &dek_0)
.unwrap();
let from_regular = DavPath::new("/regular.txt").unwrap();
let to_carrier = DavPath::new(&format!("/{}", carrier_name)).unwrap();
let copy_dst_res = rt.block_on(fs.copy(&from_regular, &to_carrier));
assert!(copy_dst_res.is_err(), "SanctumFs::copy mit Carrier als Ziel muss FsError::Forbidden liefern");
assert!(
copy_dst_res.is_err(),
"SanctumFs::copy mit Carrier als Ziel muss FsError::Forbidden liefern"
);
let _ = std::fs::remove_file(&path);
}
+48 -18
View File
@@ -22,13 +22,15 @@ fn create_test_container(path: &Path) -> (Database, [u8; 32]) {
let kek = derive_kek("sync_password", &salt, &kdf_params).unwrap();
let dek = generate_dek();
let (wrapped_dek, header_nonce, header_tag) = wrap_dek(&kek, &dek).unwrap();
db.init_schema(&salt, &kdf_params, &wrapped_dek, &header_nonce, &header_tag).unwrap();
db.init_schema(&salt, &kdf_params, &wrapped_dek, &header_nonce, &header_tag)
.unwrap();
(db, *dek)
}
#[test]
fn test_sync_push_and_pull_basic() {
let temp_root = std::env::temp_dir().join(format!("sanctum_sync_test_{}", rand::random::<u64>()));
let temp_root =
std::env::temp_dir().join(format!("sanctum_sync_test_{}", rand::random::<u64>()));
let container_path = temp_root.join("test.sanctum");
let source_dir = temp_root.join("source");
let target_dir = temp_root.join("restored");
@@ -38,7 +40,11 @@ fn test_sync_push_and_pull_basic() {
// Testdateien anlegen
fs::write(source_dir.join("file1.txt"), b"Hello Sanctum Sync!").unwrap();
fs::write(source_dir.join("sub").join("file2.bin"), vec![0x42u8; 100_000]).unwrap();
fs::write(
source_dir.join("sub").join("file2.bin"),
vec![0x42u8; 100_000],
)
.unwrap();
let (db, dek) = create_test_container(&container_path);
@@ -55,7 +61,8 @@ fn test_sync_push_and_pull_basic() {
source_dir.to_str().unwrap(),
"/Backup",
&opts,
).expect("Sync push");
)
.expect("Sync push");
assert_eq!(stats.files_scanned, 2);
assert_eq!(stats.files_transferred, 2);
@@ -70,7 +77,8 @@ fn test_sync_push_and_pull_basic() {
source_dir.to_str().unwrap(),
"/Backup",
&opts,
).expect("Sync push delta");
)
.expect("Sync push delta");
assert_eq!(stats_delta.files_transferred, 0);
assert_eq!(stats_delta.files_skipped, 2);
@@ -88,13 +96,16 @@ fn test_sync_push_and_pull_basic() {
source_dir.to_str().unwrap(),
"/Backup",
&dry_opts,
).expect("Sync push dry-run");
)
.expect("Sync push dry-run");
assert_eq!(stats_dry.files_transferred, 1);
assert_eq!(stats_dry.files_skipped, 2);
// Verifizieren, dass new_file.txt im Tresor tatsächlich NICHT existiert
let node = db.resolve_path_in_vault("/Backup/new_file.txt", 0, &dek).unwrap();
let node = db
.resolve_path_in_vault("/Backup/new_file.txt", 0, &dek)
.unwrap();
assert!(node.is_none());
// 4. Pull
@@ -110,7 +121,8 @@ fn test_sync_push_and_pull_basic() {
"/Backup",
target_dir.to_str().unwrap(),
&pull_opts,
).expect("Sync pull");
)
.expect("Sync pull");
assert_eq!(stats_pull.files_transferred, 2);
@@ -126,7 +138,8 @@ fn test_sync_push_and_pull_basic() {
#[test]
fn test_sync_multi_megabyte_large_file() {
let temp_root = std::env::temp_dir().join(format!("sanctum_sync_large_{}", rand::random::<u64>()));
let temp_root =
std::env::temp_dir().join(format!("sanctum_sync_large_{}", rand::random::<u64>()));
let container_path = temp_root.join("test_large.sanctum");
let source_dir = temp_root.join("source");
let target_dir = temp_root.join("restored");
@@ -159,10 +172,14 @@ fn test_sync_multi_megabyte_large_file() {
source_dir.to_str().unwrap(),
"/LargeTest",
&opts,
).expect("Sync push large");
)
.expect("Sync push large");
assert_eq!(stats.files_transferred, 1);
assert_eq!(stats.bytes_transferred, 3 * (CHUNK_SIZE as u64) + 512 * 1024);
assert_eq!(
stats.bytes_transferred,
3 * (CHUNK_SIZE as u64) + 512 * 1024
);
// Pull
let mut pull_opts = SyncOptions::default();
@@ -177,7 +194,8 @@ fn test_sync_multi_megabyte_large_file() {
"/LargeTest",
target_dir.to_str().unwrap(),
&pull_opts,
).expect("Sync pull large");
)
.expect("Sync pull large");
let restored = fs::read(target_dir.join("large_payload.bin")).unwrap();
let original = fs::read(&large_file_path).unwrap();
@@ -189,7 +207,8 @@ fn test_sync_multi_megabyte_large_file() {
#[test]
fn test_sync_delete_and_exclude_flags() {
let temp_root = std::env::temp_dir().join(format!("sanctum_sync_flags_{}", rand::random::<u64>()));
let temp_root =
std::env::temp_dir().join(format!("sanctum_sync_flags_{}", rand::random::<u64>()));
let container_path = temp_root.join("test_flags.sanctum");
let source_dir = temp_root.join("source");
@@ -214,10 +233,14 @@ fn test_sync_delete_and_exclude_flags() {
source_dir.to_str().unwrap(),
"/Files",
&opts,
).unwrap();
)
.unwrap();
assert_eq!(stats.files_transferred, 2); // keep.txt und remove_me.txt
assert!(db.resolve_path_in_vault("/Files/ignore.tmp", 0, &dek).unwrap().is_none());
assert!(db
.resolve_path_in_vault("/Files/ignore.tmp", 0, &dek)
.unwrap()
.is_none());
// 2. Lokale Datei löschen und Sync mit --delete ausführen
fs::remove_file(source_dir.join("remove_me.txt")).unwrap();
@@ -231,11 +254,18 @@ fn test_sync_delete_and_exclude_flags() {
source_dir.to_str().unwrap(),
"/Files",
&opts,
).unwrap();
)
.unwrap();
assert_eq!(stats_del.files_deleted, 1);
assert!(db.resolve_path_in_vault("/Files/remove_me.txt", 0, &dek).unwrap().is_none());
assert!(db.resolve_path_in_vault("/Files/keep.txt", 0, &dek).unwrap().is_some());
assert!(db
.resolve_path_in_vault("/Files/remove_me.txt", 0, &dek)
.unwrap()
.is_none());
assert!(db
.resolve_path_in_vault("/Files/keep.txt", 0, &dek)
.unwrap()
.is_some());
let _ = fs::remove_dir_all(&temp_root);
}
+473 -10
View File
@@ -1,9 +1,9 @@
use std::path::PathBuf;
use sanctum::crypto::{
derive_kek, generate_dek, generate_salt, wrap_dek, KdfParams, MIN_MEMORY_COST_KIB,
MIN_TIME_COST,
};
use sanctum::storage::Database;
use std::path::PathBuf;
#[test]
fn test_reject_arbitrary_sqlite_database() {
@@ -16,12 +16,10 @@ fn test_reject_arbitrary_sqlite_database() {
// Erstelle eine fremde SQLite-Datenbank mit beliebigen Daten
{
let conn = rusqlite::Connection::open(&db_path).unwrap();
conn.execute(
"CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)",
[],
)
.unwrap();
conn.execute("INSERT INTO users (name) VALUES ('Alice')", []).unwrap();
conn.execute("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)", [])
.unwrap();
conn.execute("INSERT INTO users (name) VALUES ('Alice')", [])
.unwrap();
}
// Sanctum Database::open muss die Datei sofort fail-closed ablehnen
@@ -114,7 +112,8 @@ fn test_reject_out_of_bounds_kdf_params() {
let (wrapped_dek, nonce, tag) = wrap_dek(&kek, &dek).unwrap();
let db = Database::open(&db_path).unwrap();
db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag).unwrap();
db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag)
.unwrap();
db.checkpoint().unwrap();
drop(db);
@@ -169,7 +168,8 @@ fn test_reject_out_of_bounds_kdf_params() {
#[test]
fn test_reject_corrupted_slot_lengths() {
let temp_dir = std::env::temp_dir();
let db_path: PathBuf = temp_dir.join(format!("test_slot_lengths_{}.sanctum", std::process::id()));
let db_path: PathBuf =
temp_dir.join(format!("test_slot_lengths_{}.sanctum", std::process::id()));
if db_path.exists() {
let _ = std::fs::remove_file(&db_path);
}
@@ -185,7 +185,8 @@ fn test_reject_corrupted_slot_lengths() {
let (wrapped_dek, nonce, tag) = wrap_dek(&kek, &dek).unwrap();
let db = Database::open(&db_path).unwrap();
db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag).unwrap();
db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag)
.unwrap();
db.checkpoint().unwrap();
drop(db);
@@ -214,3 +215,465 @@ fn test_reject_corrupted_slot_lengths() {
let _ = std::fs::remove_file(&db_path);
}
#[test]
fn test_reject_slot_amplification_and_excessive_slots_dos() {
let temp_dir = std::env::temp_dir();
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("TestPassword2026!", &salt, &kdf_params).unwrap();
let dek = generate_dek();
let (wrapped_dek, nonce, tag) = wrap_dek(&kek, &dek).unwrap();
let params_json = serde_json::to_string(&kdf_params).unwrap();
// 1. Fall: 0 Slots (leere meta-Tabelle)
{
let db_path = temp_dir.join(format!("test_0_slots_{}.sanctum", std::process::id()));
let _ = std::fs::remove_file(&db_path);
{
let conn = rusqlite::Connection::open(&db_path).unwrap();
conn.execute(
"CREATE TABLE meta (
slot_id INTEGER PRIMARY KEY, magic BLOB NOT NULL, version INTEGER NOT NULL,
kdf_salt BLOB NOT NULL, kdf_params TEXT NOT NULL, wrapped_dek BLOB NOT NULL,
header_nonce BLOB NOT NULL, header_tag BLOB NOT NULL
);",
[],
)
.unwrap();
conn.execute("CREATE TABLE nodes (id INTEGER PRIMARY KEY);", [])
.unwrap();
conn.execute("CREATE TABLE chunks (node_id INTEGER);", [])
.unwrap();
}
let db = Database::open(&db_path).unwrap();
let res = db.read_slots();
assert!(res.is_err());
let err_msg = res.err().unwrap().to_string();
assert!(
err_msg.contains("leer oder beschädigt"),
"Fehlermeldung: {err_msg}"
);
let _ = std::fs::remove_file(&db_path);
}
// 2. Fall: Genau 1 Slot (Slot 0 Decoy/Standard) -> MUSS erfolgreich sein
{
let db_path = temp_dir.join(format!("test_1_slot_{}.sanctum", std::process::id()));
let _ = std::fs::remove_file(&db_path);
let db = Database::open(&db_path).unwrap();
db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag)
.unwrap();
// Lösche Dummy-Slot 1, um reinen 1-Slot Container zu simulieren
{
let conn = rusqlite::Connection::open(&db_path).unwrap();
conn.execute("DELETE FROM meta WHERE slot_id = 1", [])
.unwrap();
}
let db_reopened = Database::open(&db_path).unwrap();
let slots = db_reopened
.read_slots()
.expect("1 gültiger Slot (Slot 0) muss erfolgreich gelesen werden");
assert_eq!(slots.len(), 1);
assert_eq!(slots[0].slot_id, 0);
let _ = std::fs::remove_file(&db_path);
}
// 3. Fall: Genau 2 Slots (Slot 0 & Slot 1) -> MUSS erfolgreich sein
{
let db_path = temp_dir.join(format!("test_2_slots_{}.sanctum", std::process::id()));
let _ = std::fs::remove_file(&db_path);
let db = Database::open(&db_path).unwrap();
db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag)
.unwrap();
let slots = db
.read_slots()
.expect("2 gültige Slots müssen erfolgreich gelesen werden");
assert_eq!(slots.len(), 2);
assert_eq!(slots[0].slot_id, 0);
assert_eq!(slots[1].slot_id, 1);
let _ = std::fs::remove_file(&db_path);
}
// 4. Fall: 3 Slots (Überschreitung des Limits von 2) -> MUSS fail-closed abgewehrt werden
{
let db_path = temp_dir.join(format!("test_3_slots_{}.sanctum", std::process::id()));
let _ = std::fs::remove_file(&db_path);
{
let conn = rusqlite::Connection::open(&db_path).unwrap();
conn.execute(
"CREATE TABLE meta (
slot_id INTEGER, magic BLOB NOT NULL, version INTEGER NOT NULL,
kdf_salt BLOB NOT NULL, kdf_params TEXT NOT NULL, wrapped_dek BLOB NOT NULL,
header_nonce BLOB NOT NULL, header_tag BLOB NOT NULL
);",
[],
)
.unwrap();
for i in 0..3 {
conn.execute(
"INSERT INTO meta VALUES (?1, ?2, 2, ?3, ?4, ?5, ?6, ?7)",
rusqlite::params![
i,
b"SANCTUM\0",
&salt[..],
&params_json,
&wrapped_dek[..],
&nonce[..],
&tag[..]
],
)
.unwrap();
}
}
let open_res = Database::open(&db_path);
assert!(
open_res.is_err(),
"Database::open muss 3 Slots sofort abweisen"
);
let err_msg = open_res.err().unwrap().to_string();
assert!(
err_msg.contains("Ungültige Slot-Anzahl"),
"Fehlermeldung: {err_msg}"
);
let _ = std::fs::remove_file(&db_path);
}
// 5. Fall: 100 Slots (KDF-Amplification DoS Angriff) -> MUSS sofort ohne KDF abgewehrt werden
{
let db_path = temp_dir.join(format!("test_100_slots_{}.sanctum", std::process::id()));
let _ = std::fs::remove_file(&db_path);
{
let conn = rusqlite::Connection::open(&db_path).unwrap();
conn.execute(
"CREATE TABLE meta (
slot_id INTEGER, magic BLOB NOT NULL, version INTEGER NOT NULL,
kdf_salt BLOB NOT NULL, kdf_params TEXT NOT NULL, wrapped_dek BLOB NOT NULL,
header_nonce BLOB NOT NULL, header_tag BLOB NOT NULL
);",
[],
)
.unwrap();
for i in 0..100 {
conn.execute(
"INSERT INTO meta VALUES (?1, ?2, 2, ?3, ?4, ?5, ?6, ?7)",
rusqlite::params![
i,
b"SANCTUM\0",
&salt[..],
&params_json,
&wrapped_dek[..],
&nonce[..],
&tag[..]
],
)
.unwrap();
}
}
let open_res = Database::open(&db_path);
assert!(
open_res.is_err(),
"100 Slots müssen sofort abgewiesen werden"
);
let _ = std::fs::remove_file(&db_path);
}
// 6. Fall: 1000 Slots (Massiver DoS-Angriff) -> MUSS sofort abgewehrt werden
{
let db_path = temp_dir.join(format!("test_1000_slots_{}.sanctum", std::process::id()));
let _ = std::fs::remove_file(&db_path);
{
let conn = rusqlite::Connection::open(&db_path).unwrap();
conn.execute(
"CREATE TABLE meta (
slot_id INTEGER, magic BLOB NOT NULL, version INTEGER NOT NULL,
kdf_salt BLOB NOT NULL, kdf_params TEXT NOT NULL, wrapped_dek BLOB NOT NULL,
header_nonce BLOB NOT NULL, header_tag BLOB NOT NULL
);",
[],
)
.unwrap();
let mut stmt = conn
.prepare("INSERT INTO meta VALUES (?1, ?2, 2, ?3, ?4, ?5, ?6, ?7)")
.unwrap();
for i in 0..1000 {
stmt.execute(rusqlite::params![
i,
b"SANCTUM\0",
&salt[..],
&params_json,
&wrapped_dek[..],
&nonce[..],
&tag[..]
])
.unwrap();
}
}
let open_res = Database::open(&db_path);
assert!(
open_res.is_err(),
"1000 Slots müssen sofort abgewiesen werden"
);
let err_msg = open_res.err().unwrap().to_string();
assert!(
err_msg.contains("Ungültige Slot-Anzahl"),
"Fehlermeldung: {err_msg}"
);
let _ = std::fs::remove_file(&db_path);
}
}
#[test]
fn test_reject_duplicate_and_invalid_slot_ids() {
let temp_dir = std::env::temp_dir();
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("TestPassword2026!", &salt, &kdf_params).unwrap();
let dek = generate_dek();
let (wrapped_dek, nonce, tag) = wrap_dek(&kek, &dek).unwrap();
let params_json = serde_json::to_string(&kdf_params).unwrap();
// 1. Fall: Doppelte Slot-ID (zwei Slots mit slot_id = 0)
{
let db_path = temp_dir.join(format!("test_dup_slot_0_{}.sanctum", std::process::id()));
let _ = std::fs::remove_file(&db_path);
{
let conn = rusqlite::Connection::open(&db_path).unwrap();
conn.execute(
"CREATE TABLE meta (
slot_id INTEGER, magic BLOB NOT NULL, version INTEGER NOT NULL,
kdf_salt BLOB NOT NULL, kdf_params TEXT NOT NULL, wrapped_dek BLOB NOT NULL,
header_nonce BLOB NOT NULL, header_tag BLOB NOT NULL
);",
[],
)
.unwrap();
conn.execute(
"INSERT INTO meta VALUES (0, ?1, 2, ?2, ?3, ?4, ?5, ?6)",
rusqlite::params![
b"SANCTUM\0",
&salt[..],
&params_json,
&wrapped_dek[..],
&nonce[..],
&tag[..]
],
)
.unwrap();
conn.execute(
"INSERT INTO meta VALUES (0, ?1, 2, ?2, ?3, ?4, ?5, ?6)",
rusqlite::params![
b"SANCTUM\0",
&salt[..],
&params_json,
&wrapped_dek[..],
&nonce[..],
&tag[..]
],
)
.unwrap();
}
let db = Database::open(&db_path).unwrap();
let res = db.read_slots();
assert!(res.is_err(), "Doppelte Slot-ID 0 muss abgewiesen werden");
let err_msg = res.err().unwrap().to_string();
assert!(
err_msg.contains("Doppelte Slot-ID 0"),
"Fehlermeldung: {err_msg}"
);
let _ = std::fs::remove_file(&db_path);
}
// 2. Fall: Ungültige Slot-ID (z. B. slot_id = 5 statt 0 oder 1)
{
let db_path = temp_dir.join(format!(
"test_invalid_slot_id_{}.sanctum",
std::process::id()
));
let _ = std::fs::remove_file(&db_path);
{
let conn = rusqlite::Connection::open(&db_path).unwrap();
conn.execute(
"CREATE TABLE meta (
slot_id INTEGER, magic BLOB NOT NULL, version INTEGER NOT NULL,
kdf_salt BLOB NOT NULL, kdf_params TEXT NOT NULL, wrapped_dek BLOB NOT NULL,
header_nonce BLOB NOT NULL, header_tag BLOB NOT NULL
);",
[],
)
.unwrap();
conn.execute(
"INSERT INTO meta VALUES (5, ?1, 2, ?2, ?3, ?4, ?5, ?6)",
rusqlite::params![
b"SANCTUM\0",
&salt[..],
&params_json,
&wrapped_dek[..],
&nonce[..],
&tag[..]
],
)
.unwrap();
}
let db = Database::open(&db_path).unwrap();
let res = db.read_slots();
assert!(res.is_err(), "Ungültige Slot-ID 5 muss abgewiesen werden");
let err_msg = res.err().unwrap().to_string();
assert!(
err_msg.contains("Ungültige Slot-ID 5"),
"Fehlermeldung: {err_msg}"
);
let _ = std::fs::remove_file(&db_path);
}
// 3. Fall: Fehlender Slot 0 (nur Slot 1 vorhanden)
{
let db_path = temp_dir.join(format!(
"test_missing_slot_0_{}.sanctum",
std::process::id()
));
let _ = std::fs::remove_file(&db_path);
{
let conn = rusqlite::Connection::open(&db_path).unwrap();
conn.execute(
"CREATE TABLE meta (
slot_id INTEGER, magic BLOB NOT NULL, version INTEGER NOT NULL,
kdf_salt BLOB NOT NULL, kdf_params TEXT NOT NULL, wrapped_dek BLOB NOT NULL,
header_nonce BLOB NOT NULL, header_tag BLOB NOT NULL
);",
[],
)
.unwrap();
conn.execute(
"INSERT INTO meta VALUES (1, ?1, 2, ?2, ?3, ?4, ?5, ?6)",
rusqlite::params![
b"SANCTUM\0",
&salt[..],
&params_json,
&wrapped_dek[..],
&nonce[..],
&tag[..]
],
)
.unwrap();
}
let db = Database::open(&db_path).unwrap();
let res = db.read_slots();
assert!(res.is_err(), "Fehlender Slot 0 muss abgewiesen werden");
let err_msg = res.err().unwrap().to_string();
assert!(
err_msg.contains("Slot 0 (Standard/Decoy Vault) fehlt"),
"Fehlermeldung: {err_msg}"
);
let _ = std::fs::remove_file(&db_path);
}
}
#[test]
fn test_container_meta_authenticate_defense_in_depth() {
use sanctum::storage::{ContainerMeta, SlotMeta};
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("TestPassword2026!", &salt, &kdf_params).unwrap();
let dek = generate_dek();
let (wrapped_dek, nonce, tag) = wrap_dek(&kek, &dek).unwrap();
let valid_slot_0 = SlotMeta {
slot_id: 0,
version: 2,
kdf_salt: salt,
kdf_params: kdf_params.clone(),
wrapped_dek: wrapped_dek.clone(),
header_nonce: nonce,
header_tag: tag,
};
let valid_slot_1 = SlotMeta {
slot_id: 1,
version: 2,
kdf_salt: salt,
kdf_params: kdf_params.clone(),
wrapped_dek: wrapped_dek.clone(),
header_nonce: nonce,
header_tag: tag,
};
// A. Leere Slots -> sofort None
let meta_empty = ContainerMeta {
version: 2,
kdf_salt: salt,
kdf_params: kdf_params.clone(),
wrapped_dek: wrapped_dek.clone(),
header_nonce: nonce,
header_tag: tag,
slots: vec![],
};
assert!(meta_empty.authenticate("TestPassword2026!").is_none());
// B. Mehr als 2 Slots -> sofort None (keine KDF-Berechnung!)
let meta_3_slots = ContainerMeta {
version: 2,
kdf_salt: salt,
kdf_params: kdf_params.clone(),
wrapped_dek: wrapped_dek.clone(),
header_nonce: nonce,
header_tag: tag,
slots: vec![
valid_slot_0.clone(),
valid_slot_1.clone(),
valid_slot_0.clone(),
],
};
assert!(meta_3_slots.authenticate("TestPassword2026!").is_none());
// C. Fehlender Slot 0 (nur Slot 1) -> sofort None
let meta_no_slot0 = ContainerMeta {
version: 2,
kdf_salt: salt,
kdf_params: kdf_params.clone(),
wrapped_dek: wrapped_dek.clone(),
header_nonce: nonce,
header_tag: tag,
slots: vec![valid_slot_1.clone()],
};
assert!(meta_no_slot0.authenticate("TestPassword2026!").is_none());
// D. Ungültige Slot-ID (> 1) -> sofort None
let mut invalid_slot = valid_slot_0.clone();
invalid_slot.slot_id = 99;
let meta_invalid_id = ContainerMeta {
version: 2,
kdf_salt: salt,
kdf_params: kdf_params.clone(),
wrapped_dek: wrapped_dek.clone(),
header_nonce: nonce,
header_tag: tag,
slots: vec![valid_slot_0.clone(), invalid_slot],
};
assert!(meta_invalid_id.authenticate("TestPassword2026!").is_none());
// E. Korrekter 1-Slot Container -> Erfolgreiche Authentifizierung
let meta_valid_1 = ContainerMeta {
version: 2,
kdf_salt: salt,
kdf_params: kdf_params.clone(),
wrapped_dek: wrapped_dek.clone(),
header_nonce: nonce,
header_tag: tag,
slots: vec![valid_slot_0.clone()],
};
assert!(meta_valid_1.authenticate("TestPassword2026!").is_some());
}
+18 -5
View File
@@ -1,4 +1,4 @@
use sanctum::upgrade::{
use sanctum::upgrade::{
run_upgrade, verify_minisign_signature, UpgradeOptions, SANCTUM_RELEASE_PUBKEY,
};
@@ -12,7 +12,11 @@ trusted comment: timestamp:1789750897\tfile:test_payload.txt\thashed\n\
0l7KsNJkDTo2uOKL8UAiaJOWuhwhGTVZ5fn0JFMdxXE9riYixQO9YnBpSVqz8iCDhWrz8hJBnmUrIWnaRaXVCA==\n";
let res = verify_minisign_signature(payload, sig_text, SANCTUM_RELEASE_PUBKEY);
assert!(res.is_ok(), "Gültige Minisign-Signatur muss erfolgreich verifiziert werden: {:?}", res.err());
assert!(
res.is_ok(),
"Gültige Minisign-Signatur muss erfolgreich verifiziert werden: {:?}",
res.err()
);
}
#[test]
@@ -25,7 +29,10 @@ trusted comment: timestamp:1789750897\tfile:test_payload.txt\thashed\n\
0l7KsNJkDTo2uOKL8UAiaJOWuhwhGTVZ5fn0JFMdxXE9riYixQO9YnBpSVqz8iCDhWrz8hJBnmUrIWnaRaXVCA==\n";
let res = verify_minisign_signature(tampered_payload, sig_text, SANCTUM_RELEASE_PUBKEY);
assert!(res.is_err(), "Manipulierte Binärdaten müssen die Minisign-Prüfung verfehlen");
assert!(
res.is_err(),
"Manipulierte Binärdaten müssen die Minisign-Prüfung verfehlen"
);
assert!(res.unwrap_err().to_string().contains("FEHLGESCHLAGEN"));
}
@@ -41,7 +48,10 @@ trusted comment: timestamp:1789750897\tfile:test_payload.txt\thashed\n\
// Ein anderer, unautorisierter Public Key
let other_key = "RWSb0a70vF1X8qIjc7xi28Gmw+cIWbMipOy4L6ToJEUPkWDw3c0qIjc7";
let res = verify_minisign_signature(payload, sig_text, other_key);
assert!(res.is_err(), "Signaturprüfung mit fremdem Schlüssel muss fehlschlagen");
assert!(
res.is_err(),
"Signaturprüfung mit fremdem Schlüssel muss fehlschlagen"
);
}
#[test]
@@ -55,7 +65,10 @@ fn test_upgrade_blocks_unofficial_url_without_insecure_flag() {
};
let res = run_upgrade(&options);
assert!(res.is_err(), "Inoffizielle Server-URL ohne --insecure-url muss blockiert werden");
assert!(
res.is_err(),
"Inoffizielle Server-URL ohne --insecure-url muss blockiert werden"
);
let err_msg = res.unwrap_err().to_string();
assert!(
err_msg.contains("Sicherheitsverstoß") || err_msg.contains("nicht vertrauenswürdig"),
+2 -1
View File
@@ -150,7 +150,8 @@ fn test_json_deserialization_of_gitea_release() {
]
}"##;
let release: GiteaRelease = serde_json::from_str(gitea_json).expect("Deserialisierung erfolgreich");
let release: GiteaRelease =
serde_json::from_str(gitea_json).expect("Deserialisierung erfolgreich");
assert_eq!(release.id, 27);
assert_eq!(release.tag_name, "v0.5.0");
assert_eq!(release.assets.len(), 3);