feat(opsec-ux): implement HF-01 to HF-04 and VFS carrier protection

- HF-01: eliminate visual leaks between decoy and hidden vaults during mount
- HF-02: add secure interactive BIP-39 recovery prompt avoiding shell history
- HF-03: implement BIP-39 normalization, word index error pinpointing, and Levenshtein typo suggestions
- HF-04: add --stealth mode for silent mounting in high-risk environments
- VFS: enforce write, truncate, delete, rename, and directory removal protection for carrier node in decoy vault
This commit is contained in:
2026-09-10 13:27:01 +02:00
parent 99813ae2a3
commit 541190cff4
5 changed files with 464 additions and 129 deletions
+100
View File
@@ -446,6 +446,16 @@ impl SanctumFs {
let dek_arc = Arc::new(dek);
let carrier_dek_arc = carrier_dek.map(Arc::new);
// Im Decoy-Vault (Slot 0): Stelle sicher, dass carrier_node_id stets bekannt ist,
// um die Trägerdatei vor versehentlichem Löschen oder Überschreiben zu schützen.
let carrier_node_id = carrier_node_id.or_else(|| {
if vault_id == 0 {
db.find_carrier_node_id().ok().flatten()
} else {
None
}
});
let carrier_fs = if vault_id == 1 {
if let (Some(ref c_dek), Some(c_nid)) = (&carrier_dek_arc, carrier_node_id) {
match CarrierFs::load(
@@ -787,6 +797,13 @@ impl DavFileSystem for SanctumFs {
return Err(FsError::Forbidden);
}
// 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)? {
return Err(FsError::Forbidden);
}
}
self.db
.delete_node(node.id)
.map_err(|_| FsError::GeneralFailure)?;
@@ -866,6 +883,10 @@ impl DavFileSystem for SanctumFs {
if dest.is_dir {
return Err(FsError::Forbidden);
}
// Schutz der Trägerdatei im Decoy Vault: Überschreiben durch Rename verboten!
if self.carrier_node_id == Some(dest.id) {
return Err(FsError::Forbidden);
}
self.db
.delete_node(dest.id)
.map_err(|_| FsError::GeneralFailure)?;
@@ -901,6 +922,13 @@ impl DavFileSystem for SanctumFs {
return Err(FsError::Forbidden);
}
// Schutz der Trägerdatei im Decoy Vault: Überschreiben durch Copy verboten!
if let Some(dest) = self.resolve_path(&to_str)? {
if self.carrier_node_id == Some(dest.id) {
return Err(FsError::Forbidden);
}
}
let to_parent = self
.resolve_path(to_parent_path)?
.ok_or(FsError::NotFound)?;
@@ -1124,4 +1152,76 @@ mod tests {
let new_time = act_arc.load(Ordering::Relaxed);
assert!(new_time > 1000);
}
#[tokio::test]
async fn test_carrier_protection_in_decoy_vault() {
let (fs, _dir) = create_test_fs(true);
// Erstelle eine Carrier-Datei "system_backup.dat"
let carrier_node = fs.db.create_node(1, "system_backup.dat", false).unwrap();
let carrier_id = carrier_node.id;
// Erstelle FS mit bekanntem carrier_node_id
let protected_fs = SanctumFs::with_carrier(
fs.db.clone(),
(*fs.dek).clone(),
None,
Some(carrier_id),
FORMAT_VERSION,
true,
0,
);
let carrier_path = DavPath::new("/system_backup.dat").unwrap();
// 1. Lesen muss erlaubt sein
let mut read_opts = OpenOptions::default();
read_opts.read = true;
assert!(protected_fs.open(&carrier_path, read_opts).await.is_ok());
// 2. Schreiben / Truncate muss verboten sein (FsError::Forbidden)
let mut write_opts = OpenOptions::default();
write_opts.write = true;
assert!(matches!(
protected_fs.open(&carrier_path, write_opts).await,
Err(FsError::Forbidden)
));
let mut trunc_opts = OpenOptions::default();
trunc_opts.truncate = true;
assert!(matches!(
protected_fs.open(&carrier_path, trunc_opts).await,
Err(FsError::Forbidden)
));
// 3. Löschen der Carrier-Datei muss verboten sein
assert!(matches!(
protected_fs.remove_file(&carrier_path).await,
Err(FsError::Forbidden)
));
// 4. Umbenennen der Carrier-Datei muss verboten sein
let rename_target = DavPath::new("/renamed_backup.dat").unwrap();
assert!(matches!(
protected_fs.rename(&carrier_path, &rename_target).await,
Err(FsError::Forbidden)
));
// 5. Überschreiben der Carrier-Datei durch Rename einer anderen Datei muss verboten sein
let other_path = DavPath::new("/other.txt").unwrap();
let mut other_opts = OpenOptions::default();
other_opts.write = true;
other_opts.create_new = true;
protected_fs.open(&other_path, other_opts).await.unwrap();
assert!(matches!(
protected_fs.rename(&other_path, &carrier_path).await,
Err(FsError::Forbidden)
));
// 6. Überschreiben der Carrier-Datei durch Copy einer anderen Datei muss verboten sein
assert!(matches!(
protected_fs.copy(&other_path, &carrier_path).await,
Err(FsError::Forbidden)
));
}
}