release: v0.7.2 — Security Audit Remediation (SA-01 bis SA-07)
Sanctum Release / Build & Test (Windows x86_64) (push) Waiting to run
Sanctum Release / Sign & Release (push) Blocked by required conditions

- 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 fba7f305e3
28 changed files with 3048 additions and 968 deletions
+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);