fix(crypto): K-01 — format v3 canonical metadata authentication

This commit is contained in:
2026-09-19 00:26:27 +02:00
parent 151fdba09d
commit ad531d8393
7 changed files with 609 additions and 12 deletions
+111 -1
View File
@@ -7,12 +7,15 @@ use argon2::{Algorithm, Argon2, Params, Version};
use rand::rngs::OsRng;
use rand::RngCore;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use subtle::ConstantTimeEq;
use zeroize::Zeroizing;
pub const MAGIC_BYTES: &[u8; 8] = b"SANCTUM\0";
pub const FORMAT_VERSION_V1: u32 = 1;
pub const FORMAT_VERSION_V2: u32 = 2;
pub const FORMAT_VERSION: u32 = FORMAT_VERSION_V2;
pub const FORMAT_VERSION_V3: u32 = 3;
pub const FORMAT_VERSION: u32 = FORMAT_VERSION_V3;
pub const CHUNK_SIZE: usize = 1024 * 1024; // 1 MB
/// Kompressions-Flags für Chunk-Payloads in Formatversion >= 2
@@ -554,6 +557,81 @@ pub fn mnemonic_to_dek(phrase: &str) -> Result<Zeroizing<[u8; 32]>> {
Ok(dek)
}
/// Berechnet HMAC-SHA256 gemäß RFC 2104.
pub fn hmac_sha256(key: &[u8], data: &[u8]) -> [u8; 32] {
let mut key_block = [0u8; 64];
if key.len() > 64 {
let mut hasher = Sha256::new();
hasher.update(key);
let hash = hasher.finalize();
key_block[..32].copy_from_slice(&hash);
} else {
key_block[..key.len()].copy_from_slice(key);
}
let mut k_ipad = [0u8; 64];
let mut k_opad = [0u8; 64];
for i in 0..64 {
k_ipad[i] = key_block[i] ^ 0x36;
k_opad[i] = key_block[i] ^ 0x5c;
}
let mut inner_hasher = Sha256::new();
inner_hasher.update(&k_ipad);
inner_hasher.update(data);
let inner_hash = inner_hasher.finalize();
let mut outer_hasher = Sha256::new();
outer_hasher.update(&k_opad);
outer_hasher.update(&inner_hash);
let out = outer_hasher.finalize();
let mut result = [0u8; 32];
result.copy_from_slice(&out);
result
}
/// Leitet einen 32-Byte Schlüssel mittels HKDF-SHA256 (RFC 5869) aus IKM und Info-String ab.
pub fn hkdf_sha256(ikm: &[u8], info: &[u8]) -> [u8; 32] {
let salt = [0u8; 32];
let prk = hmac_sha256(&salt, ikm);
let mut expand_input = Vec::with_capacity(info.len() + 1);
expand_input.extend_from_slice(info);
expand_input.push(0x01);
hmac_sha256(&prk, &expand_input)
}
/// Leitet den dedizierten Metadaten-MAC-Schlüssel aus dem DEK ab (Format V3 / K-01).
pub fn derive_metadata_mac_key(dek: &[u8; 32]) -> [u8; 32] {
hkdf_sha256(dek, b"SANCTUM_META_MAC_V3")
}
/// Berechnet den Metadaten-MAC über kanonisch serialisierte Knoten und Generation (Format V3 / K-01).
pub fn compute_metadata_mac(
mac_key: &[u8; 32],
metadata_gen: u64,
canonical_nodes: &[u8],
) -> [u8; 32] {
let mut data = Vec::with_capacity(24 + canonical_nodes.len());
data.extend_from_slice(b"SANCTUM_META_V3\0");
data.extend_from_slice(&metadata_gen.to_le_bytes());
data.extend_from_slice(canonical_nodes);
hmac_sha256(mac_key, &data)
}
/// Prüft in konstanter Zeit, ob der gegebene Metadaten-MAC gültig ist (Format V3 / K-01).
pub fn verify_metadata_mac(
mac_key: &[u8; 32],
metadata_gen: u64,
canonical_nodes: &[u8],
expected_mac: &[u8; 32],
) -> bool {
let computed = compute_metadata_mac(mac_key, metadata_gen, canonical_nodes);
computed.ct_eq(expected_mac).into()
}
/// Erzeugt die 16-Byte Associated Data (AAD) für einen Chunk, um Swap-Angriffe zu verhindern:
/// node_id (8 Bytes Little-Endian) || chunk_index (8 Bytes Little-Endian).
#[inline]
@@ -1097,4 +1175,36 @@ mod tests {
err_msg
);
}
#[test]
fn test_hmac_sha256_rfc4231_test_case_1() {
// RFC 4231 Test Case 1: Key = 20x 0x0b, Data = "Hi There"
let key = [0x0b; 20];
let data = b"Hi There";
let mac = hmac_sha256(&key, data);
let expected_hex = "b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7";
assert_eq!(hex::encode(mac), expected_hex);
}
#[test]
fn test_metadata_mac_verification() {
let dek = generate_dek();
let mac_key = derive_metadata_mac_key(&dek);
let canonical_nodes = b"node_canonical_bytes_mock_12345";
let gen = 0u64;
let mac = compute_metadata_mac(&mac_key, gen, canonical_nodes);
assert!(verify_metadata_mac(&mac_key, gen, canonical_nodes, &mac));
// Manipulierte Generation -> ungültig
assert!(!verify_metadata_mac(&mac_key, gen + 1, canonical_nodes, &mac));
// Manipulierte Knoten-Bytes -> ungültig
assert!(!verify_metadata_mac(&mac_key, gen, b"tampered_nodes", &mac));
// Falscher Schlüssel -> ungültig
let wrong_dek = generate_dek();
let wrong_key = derive_metadata_mac_key(&wrong_dek);
assert!(!verify_metadata_mac(&wrong_key, gen, canonical_nodes, &mac));
}
}
+54 -1
View File
@@ -8,7 +8,7 @@ use zeroize::Zeroizing;
use sanctum::crypto::{
check_password_prefix_collision, dek_to_mnemonic, derive_kek, generate_dek, generate_salt,
mnemonic_to_dek, set_allow_legacy_names, validate_password, wrap_slot0_payload,
wrap_slot1_payload, KdfParams, FORMAT_VERSION,
wrap_slot1_payload, KdfParams, FORMAT_VERSION, FORMAT_VERSION_V3,
};
use sanctum::mount::{format_drive, mount_container, unmount_drive, ContainerAuth};
use sanctum::recovery::{
@@ -280,6 +280,13 @@ enum Commands {
#[arg(long, default_value_t = false)]
insecure_url: bool,
},
/// Migriert einen bestehenden Container auf Format V3 (Metadaten-Authentifizierung & Replay-Schutz)
UpgradeFormat {
/// Pfad zur .sanctum Containerdatei
#[arg(short, long)]
path: PathBuf,
},
}
fn parse_drive_letter(s: &str) -> Result<char> {
@@ -623,6 +630,7 @@ fn handle_init(
let db =
Database::open(container_path).context("Konnte SQLite-Containerdatei nicht anlegen")?;
db.set_active_dek(dek.clone());
db.init_schema_with_carrier(
&salt,
&kdf_params,
@@ -1214,6 +1222,48 @@ fn handle_verify(container_path: &Path, full: bool) -> Result<()> {
Ok(())
}
fn handle_upgrade_format(container_path: &Path) -> Result<()> {
if !container_path.exists() {
bail!(
"Containerdatei '{}' existiert nicht.",
container_path.display()
);
}
println!("┌─────────────────────────────────────────────────────────────┐");
println!("│ Sanctum — Format V3 Upgrade │");
println!("└─────────────────────────────────────────────────────────────┘");
println!(" Container: {}", container_path.display());
println!();
let password = rpassword::prompt_password("Master-Passwort: ")
.context("Fehler beim Einlesen des Passworts")?;
let db = Database::open(container_path).context("Konnte Container-Datenbank nicht öffnen")?;
let keys = match db.authenticate_password(&password)? {
Some(k) => k,
None => {
bail!("Authentifizierung fehlgeschlagen: Falsches Passwort");
}
};
if keys.version() >= FORMAT_VERSION_V3 {
println!(
" • Container ist bereits Format V{} (keine Migration erforderlich).",
keys.version()
);
return Ok(());
}
db.upgrade_to_v3(keys.dek())?;
db.checkpoint()?;
println!(" ✔ Container erfolgreich auf Format V3 aktualisiert!");
println!(" • Metadaten-Authentifizierung (HMAC-SHA256) aktiviert.");
println!(" • Replay-Schutz für Chunks (24-Byte AAD mit Generation) aktiviert.");
Ok(())
}
fn handle_sync(
container_path: &Path,
source: &str,
@@ -1520,6 +1570,9 @@ async fn run() -> Result<()> {
Commands::Verify { path, full } => {
handle_verify(&path, full)?;
}
Commands::UpgradeFormat { path } => {
handle_upgrade_format(&path)?;
}
Commands::Sync {
path,
source,
+218 -6
View File
@@ -8,9 +8,10 @@ use rand::RngCore;
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,
compute_metadata_mac, decrypt_node_name, derive_kek, derive_metadata_mac_key,
encrypt_node_name, generate_dummy_slot, unwrap_key_payload, validate_kdf_params,
verify_metadata_mac, KdfParams, CHUNK_SIZE, FORMAT_VERSION, FORMAT_VERSION_V1,
FORMAT_VERSION_V2, FORMAT_VERSION_V3, MAGIC_BYTES,
};
use zeroize::Zeroizing;
@@ -184,6 +185,7 @@ impl ContainerMeta {
#[derive(Clone)]
pub struct Database {
conn: Arc<Mutex<Connection>>,
active_dek: Arc<Mutex<Option<Zeroizing<[u8; 32]>>>>,
}
fn current_timestamp() -> u64 {
@@ -304,6 +306,7 @@ impl Database {
let db = Self {
conn: Arc::new(Mutex::new(conn)),
active_dek: Arc::new(Mutex::new(None)),
};
db.init_pragmas()?;
if table_count > 0 {
@@ -318,6 +321,7 @@ impl Database {
let conn = Connection::open_in_memory()?;
let db = Self {
conn: Arc::new(Mutex::new(conn)),
active_dek: Arc::new(Mutex::new(None)),
};
db.init_pragmas()?;
db.ensure_schema_upgrades()?;
@@ -328,11 +332,22 @@ impl Database {
self.conn.lock().unwrap()
}
/// Setzt den aktiven DEK für automatische Metadaten-Authentifizierung (K-01).
pub fn set_active_dek(&self, dek: Zeroizing<[u8; 32]>) {
*self.active_dek.lock().unwrap() = Some(dek);
}
/// Gibt den aktuellen aktiven DEK zurück, falls gesetzt.
pub fn active_dek(&self) -> Option<Zeroizing<[u8; 32]>> {
self.active_dek.lock().unwrap().clone()
}
/// Authentifiziert ein Master-Passwort gegen den Container in konstanter Zeit.
pub fn authenticate_password(&self, password: &str) -> Result<Option<UnlockedKeys>> {
let meta = self.read_meta()?;
let res = meta.authenticate(password);
if let Some(ref keys) = res {
self.set_active_dek(keys.dek().clone());
if let Some(cid) = keys.carrier_node_id() {
let _ = self.mark_carrier_node_id(cid);
}
@@ -363,6 +378,15 @@ impl Database {
[],
);
// Spalte metadata_mac in meta (Format V3 / K-01)
let _ = conn.execute("ALTER TABLE meta ADD COLUMN metadata_mac BLOB", []);
// Spalte metadata_gen in meta (Format V3 / K-01)
let _ = conn.execute(
"ALTER TABLE meta ADD COLUMN metadata_gen INTEGER NOT NULL DEFAULT 0",
[],
);
Ok(())
}
@@ -526,7 +550,9 @@ impl Database {
kdf_params TEXT NOT NULL,
wrapped_dek BLOB NOT NULL,
header_nonce BLOB NOT NULL,
header_tag BLOB NOT NULL
header_tag BLOB NOT NULL,
metadata_mac BLOB,
metadata_gen INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS nodes (
@@ -671,6 +697,7 @@ impl Database {
}
conn.execute_batch("COMMIT;")?;
self.set_active_dek(Zeroizing::new(*dek_0));
Some(c_id)
} else {
// Slot 1 mit CSPRNG-Zufallsdaten gleicher Struktur und Entropie (72 Bytes für Modell A)
@@ -693,6 +720,9 @@ impl Database {
None
};
drop(conn);
let _ = self.update_metadata_mac();
Ok(carrier_node_id)
}
@@ -721,7 +751,9 @@ impl Database {
kdf_params TEXT NOT NULL,
wrapped_dek BLOB NOT NULL,
header_nonce BLOB NOT NULL,
header_tag BLOB NOT NULL
header_tag BLOB NOT NULL,
metadata_mac BLOB,
metadata_gen INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS nodes (
@@ -812,6 +844,9 @@ impl Database {
)?;
}
drop(conn);
let _ = self.update_metadata_mac();
Ok(())
}
@@ -969,7 +1004,10 @@ impl Database {
bail!("Ungültige Sanctum-Containerdatei: Magic Bytes stimmen nicht überein");
}
if slot0.version != FORMAT_VERSION_V1 && slot0.version != FORMAT_VERSION_V2 {
if slot0.version != FORMAT_VERSION_V1
&& slot0.version != FORMAT_VERSION_V2
&& slot0.version != FORMAT_VERSION_V3
{
bail!(
"Nicht unterstützte Sanctum-Formatversion: {}",
slot0.version
@@ -1289,6 +1327,9 @@ impl Database {
params![now, parent_id],
);
drop(conn);
let _ = self.update_metadata_mac();
Ok(NodeRecord {
id: new_id,
parent_id: Some(parent_id),
@@ -1312,6 +1353,8 @@ impl Database {
"UPDATE nodes SET size = ?1, modified_at = ?2 WHERE id = ?3",
params![size as i64, modified_at as i64, id],
)?;
drop(conn);
let _ = self.update_metadata_mac();
Ok(())
}
@@ -1362,6 +1405,8 @@ impl Database {
let conn = self.conn.lock().unwrap();
conn.execute("DELETE FROM chunks WHERE node_id = ?1", params![id])?;
conn.execute("DELETE FROM nodes WHERE id = ?1", params![id])?;
drop(conn);
let _ = self.update_metadata_mac();
Ok(())
}
@@ -1388,6 +1433,8 @@ impl Database {
"UPDATE nodes SET parent_id = ?1, name = ?2, modified_at = ?3 WHERE id = ?4",
params![new_parent_id, stored_name, now, id],
)?;
drop(conn);
let _ = self.update_metadata_mac();
Ok(())
}
@@ -1493,6 +1540,8 @@ impl Database {
params![new_size as i64, modified_at as i64, node_id],
)?;
tx.commit()?;
drop(conn);
let _ = self.update_metadata_mac();
Ok(())
}
@@ -1540,6 +1589,169 @@ impl Database {
params![node_id, max_chunk_index],
)?;
tx.commit()?;
drop(conn);
let _ = self.update_metadata_mac();
Ok(())
}
/// Erzeugt die deterministische kanonische Byterepräsentation aller Knoten für den Metadaten-MAC (K-01).
pub fn canonical_nodes_bytes(&self) -> Result<Vec<u8>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT n.id, n.parent_id, n.name, n.is_dir, n.size, n.created_at, n.modified_at, n.is_carrier,
(SELECT COUNT(*) FROM chunks c WHERE c.node_id = n.id) as chunk_count
FROM nodes n
ORDER BY n.id ASC",
)?;
let mut rows = stmt.query([])?;
let mut buf = Vec::new();
while let Some(row) = rows.next()? {
let id: i64 = row.get(0)?;
let parent_id: Option<i64> = row.get(1)?;
let name: String = row.get(2)?;
let is_dir: i64 = row.get(3)?;
let size: i64 = row.get(4)?;
let created_at: i64 = row.get(5)?;
let modified_at: i64 = row.get(6)?;
let is_carrier: i64 = row.get(7)?;
let chunk_count: i64 = row.get(8)?;
buf.extend_from_slice(&id.to_le_bytes());
match parent_id {
Some(pid) => {
buf.push(1u8);
buf.extend_from_slice(&pid.to_le_bytes());
}
None => {
buf.push(0u8);
buf.extend_from_slice(&0i64.to_le_bytes());
}
}
let name_bytes = name.as_bytes();
buf.extend_from_slice(&(name_bytes.len() as u32).to_le_bytes());
buf.extend_from_slice(name_bytes);
buf.push(if is_dir != 0 { 1u8 } else { 0u8 });
buf.extend_from_slice(&size.to_le_bytes());
buf.extend_from_slice(&created_at.to_le_bytes());
buf.extend_from_slice(&modified_at.to_le_bytes());
buf.push(if is_carrier != 0 { 1u8 } else { 0u8 });
buf.extend_from_slice(&chunk_count.to_le_bytes());
}
Ok(buf)
}
/// Aktualisiert den Metadaten-MAC in Slot 0 bei strukturellen Modifikationen (Format V3 / K-01).
pub fn update_metadata_mac(&self) -> Result<()> {
let dek_opt = self.active_dek.lock().unwrap().clone();
let Some(dek) = dek_opt else {
return Ok(());
};
let conn = self.conn.lock().unwrap();
let version_and_gen: Option<(u32, u64)> = conn
.query_row(
"SELECT version, metadata_gen FROM meta WHERE slot_id = 0 LIMIT 1",
[],
|r| Ok((r.get(0)?, r.get(1).unwrap_or(0))),
)
.optional()?;
let Some((version, current_gen)) = version_and_gen else {
return Ok(());
};
if version < FORMAT_VERSION_V3 {
return Ok(());
}
drop(conn);
let next_gen = current_gen + 1;
let canonical = self.canonical_nodes_bytes()?;
let mac_key = derive_metadata_mac_key(&dek);
let new_mac = compute_metadata_mac(&mac_key, next_gen, &canonical);
let conn = self.conn.lock().unwrap();
conn.execute(
"UPDATE meta SET metadata_mac = ?1, metadata_gen = ?2 WHERE slot_id = 0",
params![new_mac.as_slice(), next_gen],
)?;
Ok(())
}
/// Prüft die Integrität des Metadaten-MAC gegen den gegebenen DEK (Format V3 / K-01).
pub fn verify_metadata_mac(&self, dek: &[u8; 32]) -> Result<bool> {
let conn = self.conn.lock().unwrap();
let meta_row: Option<(u32, Option<Vec<u8>>, u64)> = conn
.query_row(
"SELECT version, metadata_mac, metadata_gen FROM meta WHERE slot_id = 0 LIMIT 1",
[],
|r| Ok((r.get(0)?, r.get(1).ok(), r.get(2).unwrap_or(0))),
)
.optional()?;
let Some((version, mac_opt, gen)) = meta_row else {
return Ok(true);
};
if version < FORMAT_VERSION_V3 {
return Ok(true);
}
let Some(mac_bytes) = mac_opt else {
return Ok(false);
};
if mac_bytes.len() != 32 {
return Ok(false);
}
let mut expected_mac = [0u8; 32];
expected_mac.copy_from_slice(&mac_bytes);
drop(conn);
let canonical = self.canonical_nodes_bytes()?;
let mac_key = derive_metadata_mac_key(dek);
Ok(verify_metadata_mac(&mac_key, gen, &canonical, &expected_mac))
}
/// Führt ein Upgrade des Containerformats auf Format V3 durch (Format V3 / K-01 & K-02).
pub fn upgrade_to_v3(&self, dek: &[u8; 32]) -> Result<()> {
let conn = self.conn.lock().unwrap();
let version: u32 = conn.query_row(
"SELECT version FROM meta WHERE slot_id = 0 LIMIT 1",
[],
|r| r.get(0),
)?;
if version >= FORMAT_VERSION_V3 {
return Ok(());
}
let _ = conn.execute("ALTER TABLE meta ADD COLUMN metadata_mac BLOB", []);
let _ = conn.execute(
"ALTER TABLE meta ADD COLUMN metadata_gen INTEGER NOT NULL DEFAULT 0",
[],
);
let _ = conn.execute(
"ALTER TABLE chunks ADD COLUMN generation INTEGER NOT NULL DEFAULT 0",
[],
);
conn.execute(
"UPDATE meta SET version = ?1, metadata_gen = 0 WHERE slot_id = 0",
[FORMAT_VERSION_V3],
)?;
drop(conn);
self.set_active_dek(Zeroizing::new(*dek));
self.update_metadata_mac()?;
Ok(())
}
+2
View File
@@ -439,6 +439,8 @@ pub fn run_sync(
let start_time = Instant::now();
let mut stats = SyncStats::default();
db.set_active_dek(zeroize::Zeroizing::new(*dek));
match options.direction {
SyncDirection::Push => {
sync_push(
+28 -2
View File
@@ -4,7 +4,9 @@ use std::path::Path;
use anyhow::{bail, Context, Result};
use zeroize::Zeroizing;
use crate::crypto::{decrypt_chunk, FORMAT_VERSION_V1, FORMAT_VERSION_V2};
use crate::crypto::{
decrypt_chunk, FORMAT_VERSION_V1, FORMAT_VERSION_V2, FORMAT_VERSION_V3,
};
use crate::storage::Database;
/// Bericht über das Ergebnis einer Container-Integritätsprüfung.
@@ -81,7 +83,10 @@ pub fn verify_container(
let meta = match db.read_meta() {
Ok(m) => {
report.format_version = m.version;
if m.version != FORMAT_VERSION_V1 && m.version != FORMAT_VERSION_V2 {
if m.version != FORMAT_VERSION_V1
&& m.version != FORMAT_VERSION_V2
&& m.version != FORMAT_VERSION_V3
{
report.header_ok = false;
report.header_error = Some(format!("Unbekannte Formatversion: {}", m.version));
}
@@ -247,6 +252,25 @@ pub fn verify_container(
None
};
// 3b. Metadaten-Authentifizierung (HMAC-SHA256) für Format V3 (K-01)
if let Some(active_dek) = dek {
if let Some(ref m) = meta {
if m.version >= FORMAT_VERSION_V3 {
match db.verify_metadata_mac(active_dek) {
Ok(true) => {}
Ok(false) => {
report.errors.push(
"Kritischer Metadaten-Authentifizierungsfehler (K-01): Metadaten-MAC ungültig. Manipulationsversuch an Dateinamen, Größen oder Verzeichnisstruktur in der SQLite-Datenbank erkannt.".to_string(),
);
}
Err(e) => {
report.errors.push(format!("Fehler bei der Metadaten-MAC-Prüfung: {e}"));
}
}
}
}
}
// 4. Kryptografische Chunk- & AEAD-Authentifizierungsprüfung
let chunk_headers = db
.list_all_chunk_headers()
@@ -339,6 +363,7 @@ mod tests {
let (wrapped_dek, nonce, tag) = wrap_dek(&kek, &dek).unwrap();
let db = Database::open(&container_path).unwrap();
db.set_active_dek(dek.clone());
db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag)
.unwrap();
@@ -401,6 +426,7 @@ mod tests {
let (wrapped_dek, nonce, tag) = wrap_dek(&kek, &dek).unwrap();
let db = Database::open(&container_path).unwrap();
db.set_active_dek(dek.clone());
db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag)
.unwrap();
+2
View File
@@ -490,6 +490,8 @@ impl SanctumFs {
let dek_arc = Arc::new(dek);
let carrier_dek_arc = carrier_dek.map(Arc::new);
db.set_active_dek((*dek_arc).clone());
// 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(|| {
+194 -2
View File
@@ -1,8 +1,9 @@
use sanctum::crypto::{
derive_kek, generate_dek, generate_salt, wrap_dek, KdfParams, MIN_MEMORY_COST_KIB,
MIN_TIME_COST,
derive_kek, generate_dek, generate_salt, wrap_dek, wrap_slot0_payload, KdfParams,
MIN_MEMORY_COST_KIB, MIN_TIME_COST,
};
use sanctum::storage::Database;
use sanctum::verify::verify_container;
use std::path::PathBuf;
#[test]
@@ -677,3 +678,194 @@ fn test_container_meta_authenticate_defense_in_depth() {
};
assert!(meta_valid_1.authenticate("TestPassword2026!").is_some());
}
#[test]
fn test_k01_metadata_tampering_detected_by_verify() {
let temp_dir = std::env::temp_dir();
let db_path: PathBuf = temp_dir.join(format!("test_k01_meta_{}.sanctum", std::process::id()));
if db_path.exists() {
let _ = std::fs::remove_file(&db_path);
}
let password = "SecretMasterPassword2026!";
let kdf_params = KdfParams {
memory_cost: MIN_MEMORY_COST_KIB,
time_cost: MIN_TIME_COST,
parallelism: 1,
};
let salt = generate_salt();
let kek = derive_kek(password, &salt, &kdf_params).unwrap();
let dek = generate_dek();
let (wrapped_dek, nonce, tag) = wrap_slot0_payload(&kek, &dek, 0).unwrap();
let db = Database::open(&db_path).unwrap();
db.set_active_dek(dek.clone());
db.init_schema_with_carrier(&salt, &kdf_params, &wrapped_dek, &nonce, &tag, None)
.unwrap();
// Knoten anlegen
let node = db
.create_node(1, "secret_financials.xlsx", false)
.unwrap();
db.update_node_size_and_time(node.id, 50000, 1000).unwrap();
db.checkpoint().unwrap();
// 1. Initialer Zustand: Verifikation muss erfolgreich sein
let report = verify_container(&db_path, Some(&dek), true).unwrap();
assert!(
report.is_healthy(),
"Initialer V3-Container muss gesund sein, aber Fehler: {:?}",
report.errors
);
// 2. Angreifer manipuliert Dateigröße direkt in SQLite (ohne MAC aktualisieren zu können)
{
let conn = rusqlite::Connection::open(&db_path).unwrap();
conn.execute(
"UPDATE nodes SET size = 999999 WHERE name = 'secret_financials.xlsx';",
[],
)
.unwrap();
}
// Verifikation MUSS fehlschlagen und den K-01 Metadatenfehler melden!
let tampered_report = verify_container(&db_path, Some(&dek), true).unwrap();
assert!(
!tampered_report.is_healthy(),
"Container mit manipulierter Knotengröße darf nicht als gesund eingestuft werden!"
);
assert!(
tampered_report
.errors
.iter()
.any(|e| e.contains("K-01") || e.contains("Metadaten-MAC")),
"Fehlermeldung zu Metadaten-MAC erwartet, erhalten: {:?}",
tampered_report.errors
);
// 3. Angreifer manipuliert Dateinamen direkt in SQLite
{
let conn = rusqlite::Connection::open(&db_path).unwrap();
conn.execute(
"UPDATE nodes SET name = 'backdoor.exe' WHERE id = ?1;",
[node.id],
)
.unwrap();
}
let tampered_name_report = verify_container(&db_path, Some(&dek), true).unwrap();
assert!(
!tampered_name_report.is_healthy(),
"Container mit manipuliertem Knotennamen darf nicht als gesund eingestuft werden!"
);
assert!(
tampered_name_report
.errors
.iter()
.any(|e| e.contains("K-01") || e.contains("Metadaten-MAC")),
"Fehlermeldung zu Metadaten-MAC erwartet, erhalten: {:?}",
tampered_name_report.errors
);
let _ = std::fs::remove_file(&db_path);
}
#[test]
fn test_k01_upgrade_format_v2_to_v3() {
let temp_dir = std::env::temp_dir();
let db_path: PathBuf = temp_dir.join(format!("test_k01_upgrade_{}.sanctum", std::process::id()));
if db_path.exists() {
let _ = std::fs::remove_file(&db_path);
}
let password = "UpgradeMasterPassword2026!";
let kdf_params = KdfParams {
memory_cost: MIN_MEMORY_COST_KIB,
time_cost: MIN_TIME_COST,
parallelism: 1,
};
let salt = generate_salt();
let kek = derive_kek(password, &salt, &kdf_params).unwrap();
let dek = generate_dek();
let (wrapped_dek, nonce, tag) = wrap_slot0_payload(&kek, &dek, 0).unwrap();
// Erzeuge bewusst einen V2-Container ohne metadata_mac
{
let conn = rusqlite::Connection::open(&db_path).unwrap();
conn.execute_batch(
"CREATE TABLE meta (
slot_id INTEGER NOT NULL PRIMARY KEY,
magic BLOB NOT NULL,
version INTEGER NOT NULL,
kdf_salt BLOB NOT NULL,
kdf_params TEXT NOT NULL,
wrapped_dek BLOB NOT NULL,
header_nonce BLOB NOT NULL,
header_tag BLOB NOT NULL
);
CREATE TABLE nodes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
parent_id INTEGER,
name TEXT NOT NULL,
is_dir INTEGER NOT NULL,
size INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
modified_at INTEGER NOT NULL,
is_carrier INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE chunks (
node_id INTEGER NOT NULL,
chunk_index INTEGER NOT NULL,
nonce BLOB NOT NULL,
tag BLOB NOT NULL,
ciphertext BLOB NOT NULL,
PRIMARY KEY (node_id, chunk_index)
);",
)
.unwrap();
let params_json = serde_json::to_string(&kdf_params).unwrap();
conn.execute(
"INSERT INTO meta (slot_id, magic, version, kdf_salt, kdf_params, wrapped_dek, header_nonce, header_tag)
VALUES (0, ?1, 2, ?2, ?3, ?4, ?5, ?6)",
rusqlite::params![
sanctum::crypto::MAGIC_BYTES.as_slice(),
salt.as_slice(),
params_json,
wrapped_dek,
nonce.as_slice(),
tag.as_slice(),
],
)
.unwrap();
conn.execute(
"INSERT INTO nodes (id, parent_id, name, is_dir, size, created_at, modified_at)
VALUES (1, NULL, '', 1, 0, 100, 100), (2, NULL, '', 1, 0, 100, 100);",
[],
)
.unwrap();
}
let db = Database::open(&db_path).unwrap();
let meta_before = db.read_meta().unwrap();
assert_eq!(meta_before.version, 2);
// Upgrade auf Format V3 durchführen
db.upgrade_to_v3(&dek).unwrap();
db.checkpoint().unwrap();
let meta_after = db.read_meta().unwrap();
assert_eq!(meta_after.version, 3);
// V3-Container muss nach dem Upgrade integer und gesund sein
let report = verify_container(&db_path, Some(&dek), true).unwrap();
assert!(
report.is_healthy(),
"Container nach upgrade_to_v3 muss gesund sein: {:?}",
report.errors
);
let _ = std::fs::remove_file(&db_path);
}