fix(format-v3): harden system binding against replay and bypass attacks (F-01, F-02, F-03)

- F-02: Require restore_nonce token and explicit confirmation (--rebuild-mac) for PendingRebuild
- F-01: Extend canonical MAC transcript to include chunk generation tuples (node_id, chunk_index, generation) and support transparent legacy migration
- F-03: Make V2-to-V3 container upgrade atomic with transactional rollback and dual-slot version update
- Bump version to 0.9.4 and update changelog and security docs
This commit is contained in:
2026-09-22 16:48:12 +02:00
parent abcd85524d
commit 510cb9b64e
12 changed files with 1281 additions and 67 deletions
+220 -53
View File
@@ -208,15 +208,18 @@ fn current_timestamp() -> u64 {
.unwrap_or(0)
}
/// Status der Metadaten-MAC-Integritätsprüfung (K-01 / R-NEW-1).
/// Status der Metadaten-MAC-Integritätsprüfung (K-01 / F-01 / F-02).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MetadataMacStatus {
/// Metadaten-MAC ist vorhanden und stimmt mit den kanonischen Metadaten überein.
/// Metadaten-MAC ist vorhanden und stimmt mit den kanonischen Metadaten inklusive Chunk-Generationen überein (F-01).
Valid,
/// Container-Header wurde frisch aus einem Backup restauriert (metadata_gen == 0 && metadata_mac IS NULL).
/// Der MAC muss beim Mounten transparent mit dem aktiven DEK neu aufgebaut werden.
/// Metadaten-MAC stimmt mit dem älteren V3-Transcript (nur Knoten-Metadaten, ohne Chunk-Generationen) überein.
/// Wird beim Mounten transparent auf das neue V3.1-Transcript gebunden (F-01 Migration).
LegacyValid,
/// Container-Header wurde aus einem Backup restauriert (restore_nonce vorhanden und metadata_gen == 0 && metadata_mac IS NULL).
/// Der MAC muss beim Mounten mit --rebuild-mac (oder interaktivem 'JA') neu aufgebaut werden (F-02).
PendingRebuild,
/// Metadaten-MAC fehlt (bei gen > 0) oder stimmt nicht mit den berechneten Daten überein (Manipulationsverdacht).
/// Metadaten-MAC fehlt (ohne gültigen restore_nonce) oder stimmt nicht mit den berechneten Daten überein (Manipulationsverdacht).
Invalid,
}
@@ -480,6 +483,9 @@ impl Database {
let _ = conn.execute("ALTER TABLE meta ADD COLUMN lock_host TEXT", []);
let _ = conn.execute("ALTER TABLE meta ADD COLUMN lock_time INTEGER", []);
// Spalte restore_nonce in meta (Format V3 / F-02)
let _ = conn.execute("ALTER TABLE meta ADD COLUMN restore_nonce BLOB", []);
Ok(())
}
@@ -646,6 +652,7 @@ impl Database {
header_tag BLOB NOT NULL,
metadata_mac BLOB,
metadata_gen INTEGER NOT NULL DEFAULT 0,
restore_nonce BLOB,
lock_pid INTEGER,
lock_host TEXT,
lock_time INTEGER
@@ -959,6 +966,7 @@ impl Database {
header_tag BLOB NOT NULL,
metadata_mac BLOB,
metadata_gen INTEGER NOT NULL DEFAULT 0,
restore_nonce BLOB,
lock_pid INTEGER,
lock_host TEXT,
lock_time INTEGER
@@ -2192,8 +2200,61 @@ impl Database {
self.canonical_nodes_bytes_for_vault(0)
}
/// Erzeugt die deterministische kanonische Byterepräsentation für einen spezifischen Vault.
/// Erzeugt die deterministische kanonische Byterepräsentation für einen spezifischen Vault
/// inklusive sortierter Chunk-Generationen (Format V3.1 / F-01).
pub fn canonical_nodes_bytes_for_vault(&self, vault_id: u32) -> Result<Vec<u8>> {
let mut buf = self.canonical_nodes_bytes_for_vault_legacy(vault_id)?;
let conn = self.conn();
if vault_id == 1 {
let mut stmt = conn.prepare(
"WITH RECURSIVE vault1(id) AS (
SELECT 2
UNION ALL
SELECT n.id FROM nodes n JOIN vault1 v ON n.parent_id = v.id
)
SELECT c.node_id, c.chunk_index, c.generation
FROM chunks c
WHERE c.node_id IN (SELECT id FROM vault1)
ORDER BY c.node_id ASC, c.chunk_index ASC",
)?;
let mut rows = stmt.query([])?;
while let Some(row) = rows.next()? {
let node_id: i64 = row.get(0)?;
let chunk_index: u32 = row.get(1)?;
let generation: u64 = row.get(2)?;
buf.extend_from_slice(&node_id.to_le_bytes());
buf.extend_from_slice(&chunk_index.to_le_bytes());
buf.extend_from_slice(&generation.to_le_bytes());
}
} else {
let mut stmt = conn.prepare(
"WITH RECURSIVE vault0(id) AS (
SELECT 1
UNION ALL
SELECT n.id FROM nodes n JOIN vault0 v ON n.parent_id = v.id
)
SELECT c.node_id, c.chunk_index, c.generation
FROM chunks c
WHERE c.node_id = 2 OR c.node_id IN (SELECT id FROM vault0)
ORDER BY c.node_id ASC, c.chunk_index ASC",
)?;
let mut rows = stmt.query([])?;
while let Some(row) = rows.next()? {
let node_id: i64 = row.get(0)?;
let chunk_index: u32 = row.get(1)?;
let generation: u64 = row.get(2)?;
buf.extend_from_slice(&node_id.to_le_bytes());
buf.extend_from_slice(&chunk_index.to_le_bytes());
buf.extend_from_slice(&generation.to_le_bytes());
}
}
Ok(buf)
}
/// Erzeugt die Legacy-Byterepräsentation ohne Chunk-Generationen (zur Migration bestehender V3-Container, F-01).
pub fn canonical_nodes_bytes_for_vault_legacy(&self, vault_id: u32) -> Result<Vec<u8>> {
let conn = self.conn();
let mut buf = Vec::new();
@@ -2271,7 +2332,7 @@ impl Database {
Ok(())
}
/// Aktualisiert den Metadaten-MAC des aktiven Slots bei strukturellen Modifikationen (Format V3 / K-01).
/// Aktualisiert den Metadaten-MAC des aktiven Slots bei strukturellen Modifikationen (Format V3 / K-01 & F-01).
pub fn update_metadata_mac(&self) -> Result<()> {
let session_opt = self
.active_session
@@ -2328,25 +2389,26 @@ impl Database {
/// Prüft die Integrität des Metadaten-MAC für einen spezifischen Slot (0: Decoy, 1: Hidden).
pub fn verify_metadata_mac_for_slot(&self, slot_id: u32, dek: &[u8; 32]) -> Result<bool> {
Ok(self.verify_metadata_mac_status_for_slot(slot_id, dek)? == MetadataMacStatus::Valid)
let status = self.verify_metadata_mac_status_for_slot(slot_id, dek)?;
Ok(status == MetadataMacStatus::Valid || status == MetadataMacStatus::LegacyValid)
}
/// Prüft den detaillierten Integritätsstatus des Metadaten-MAC für einen spezifischen Slot (R-NEW-1).
/// Prüft den detaillierten Integritätsstatus des Metadaten-MAC für einen spezifischen Slot (F-01 / F-02).
pub fn verify_metadata_mac_status_for_slot(
&self,
slot_id: u32,
dek: &[u8; 32],
) -> Result<MetadataMacStatus> {
let conn = self.conn();
let meta_row: Option<(u32, Option<Vec<u8>>, u64)> = conn
let meta_row: Option<(u32, Option<Vec<u8>>, u64, Option<Vec<u8>>)> = conn
.query_row(
"SELECT version, metadata_mac, metadata_gen FROM meta WHERE slot_id = ?1 LIMIT 1",
"SELECT version, metadata_mac, metadata_gen, restore_nonce FROM meta WHERE slot_id = ?1 LIMIT 1",
params![slot_id],
|r| Ok((r.get(0)?, r.get(1).ok(), r.get(2).unwrap_or(0))),
|r| Ok((r.get(0)?, r.get(1).ok(), r.get(2).unwrap_or(0), r.get(3).ok())),
)
.optional()?;
let Some((version, mac_opt, gen)) = meta_row else {
let Some((version, mac_opt, gen, restore_nonce)) = meta_row else {
return Ok(MetadataMacStatus::Invalid);
};
@@ -2354,9 +2416,16 @@ impl Database {
return Ok(MetadataMacStatus::Valid);
}
// R-NEW-1: Frisch restaurierter Container (gen == 0 && mac_opt IS NULL)
if gen == 0 && mac_opt.is_none() {
return Ok(MetadataMacStatus::PendingRebuild);
// F-02: Wenn metadata_mac fehlt (NULL):
// NUR wenn restore_nonce vorhanden ist (32 Bytes) UND gen == 0,
// ist der Status PendingRebuild. Andernfalls strikt Invalid (Fail-Closed, K-01 / F-02).
if mac_opt.is_none() {
if let Some(ref nonce) = restore_nonce {
if nonce.len() == 32 && gen == 0 {
return Ok(MetadataMacStatus::PendingRebuild);
}
}
return Ok(MetadataMacStatus::Invalid);
}
let Some(mac_bytes) = mac_opt else {
@@ -2371,18 +2440,33 @@ impl Database {
expected_mac.copy_from_slice(&mac_bytes);
drop(conn);
let canonical = self.canonical_nodes_bytes_for_vault(slot_id)?;
let mac_key = derive_metadata_mac_key(dek);
// F-01: 1. Neues kanonisches Transcript prüfen (inklusive Chunk-Generationen)
let canonical = self.canonical_nodes_bytes_for_vault(slot_id)?;
if verify_metadata_mac(&mac_key, gen, &canonical, &expected_mac) {
Ok(MetadataMacStatus::Valid)
} else {
Ok(MetadataMacStatus::Invalid)
return Ok(MetadataMacStatus::Valid);
}
// F-01 / §7: 2. Altes Transcript prüfen (nur Knoten, ohne Chunk-Generationen) für sichere Migration
let canonical_legacy = self.canonical_nodes_bytes_for_vault_legacy(slot_id)?;
if verify_metadata_mac(&mac_key, gen, &canonical_legacy, &expected_mac) {
return Ok(MetadataMacStatus::LegacyValid);
}
Ok(MetadataMacStatus::Invalid)
}
/// Führt ein Upgrade des Containerformats auf Format V3 durch (Format V3 / K-01 & K-02).
/// Führt ein Upgrade des Containerformats auf Format V3 durch (Format V3 / K-01, K-02 & F-03).
pub fn upgrade_to_v3(&self, dek: &[u8; 32]) -> Result<()> {
let conn = self.conn();
self.upgrade_to_v3_ext(dek, None)
}
/// Führt ein transaktionales Upgrade des Containerformats auf Format V3 durch (F-03).
/// Läuft in einer einzigen atomaren SQLite-Transaktion. Bei jeglichem Chunk-Fehler
/// wird ein Rollback durchgeführt und der Fehler fail-closed propagiert.
pub fn upgrade_to_v3_ext(&self, dek_0: &[u8; 32], dek_1: Option<&[u8; 32]>) -> Result<()> {
let mut conn = self.conn();
let version: u32 = conn.query_row(
"SELECT version FROM meta WHERE slot_id = 0 LIMIT 1",
[],
@@ -2398,15 +2482,19 @@ impl Database {
"ALTER TABLE meta ADD COLUMN metadata_gen INTEGER NOT NULL DEFAULT 0",
[],
);
let _ = conn.execute("ALTER TABLE meta ADD COLUMN restore_nonce BLOB", []);
let _ = conn.execute(
"ALTER TABLE chunks ADD COLUMN generation INTEGER NOT NULL DEFAULT 0",
[],
);
// K-02: Alle bestehenden Chunks von alter 16-Byte-AAD auf Format V3 24-Byte-AAD (generation = 0) umverschlüsseln
// F-03: Gesamte Migration in einer einzigen atomaren SQLite-Transaktion
let tx = conn.transaction()?;
// Alle bestehenden Chunks von alter 16-Byte-AAD auf Format V3 24-Byte-AAD (generation = 0) umverschlüsseln
{
let mut chunk_stmt =
conn.prepare("SELECT node_id, chunk_index, nonce, tag, ciphertext FROM chunks")?;
tx.prepare("SELECT node_id, chunk_index, nonce, tag, ciphertext FROM chunks")?;
let chunk_rows: Vec<(i64, u32, [u8; 12], [u8; 16], Vec<u8>)> = chunk_stmt
.query_map([], |row| {
let node_id: i64 = row.get(0)?;
@@ -2428,8 +2516,8 @@ impl Database {
drop(chunk_stmt);
for (node_id, chunk_index, nonce, tag, ct) in chunk_rows {
if let Ok(plaintext) = crate::crypto::decrypt_chunk(
dek,
let (plaintext, chunk_dek) = match crate::crypto::decrypt_chunk(
dek_0,
node_id,
chunk_index,
&ct,
@@ -2438,39 +2526,93 @@ impl Database {
version,
0,
) {
if let Ok((new_ct, new_nonce, new_tag)) = crate::crypto::encrypt_chunk(
dek,
node_id,
chunk_index,
&plaintext,
FORMAT_VERSION_V3,
0,
) {
conn.execute(
"UPDATE chunks SET nonce = ?1, tag = ?2, ciphertext = ?3, generation = 0 WHERE node_id = ?4 AND chunk_index = ?5",
params![
new_nonce.as_slice(),
new_tag.as_slice(),
new_ct,
Ok(pt) => (pt, dek_0),
Err(e0) => {
if let Some(d1) = dek_1 {
match crate::crypto::decrypt_chunk(
d1,
node_id,
chunk_index
],
)?;
chunk_index,
&ct,
&nonce,
&tag,
version,
0,
) {
Ok(pt) => (pt, d1),
Err(_) => {
bail!(
"Upgrade auf Format V3 abgebrochen: Chunk (Node {}, Index {}) konnte weder mit DEK_0 noch mit DEK_1 entschlüsselt werden.",
node_id, chunk_index
);
}
}
} else {
bail!(
"Upgrade auf Format V3 abgebrochen: Chunk (Node {}, Index {}) konnte mit DEK_0 nicht entschlüsselt werden ({}). Falls dieser Container einen Legacy-Hidden-Vault enthält, wird das zweite Passwort benötigt.",
node_id, chunk_index, e0
);
}
}
}
};
let (new_ct, new_nonce, new_tag) = crate::crypto::encrypt_chunk(
chunk_dek,
node_id,
chunk_index,
&plaintext,
FORMAT_VERSION_V3,
0,
)?;
tx.execute(
"UPDATE chunks SET nonce = ?1, tag = ?2, ciphertext = ?3, generation = 0 WHERE node_id = ?4 AND chunk_index = ?5",
params![
new_nonce.as_slice(),
new_tag.as_slice(),
new_ct,
node_id,
chunk_index
],
)?;
}
}
conn.execute(
"UPDATE meta SET version = ?1, metadata_gen = 0 WHERE slot_id = 0",
// F-03: Aktualisiere version = 3 für Slot 0 UND Slot 1 (inkl. Dummy-Slot)
tx.execute(
"UPDATE meta SET version = ?1, metadata_gen = 0",
[FORMAT_VERSION_V3],
)?;
tx.commit()?;
drop(conn);
self.set_active_dek(Zeroizing::new(*dek));
// Slot 0 Metadaten-MAC berechnen und persistieren
self.set_active_slot_and_dek(0, Zeroizing::new(*dek_0));
self.update_metadata_mac()?;
// Falls echte SQLite-Knoten unter Root 2 existieren (Legacy Hidden Vault) und dek_1 vorliegt,
// auch Slot 1 MAC aktualisieren. (Bei Modell A bleibt Carrier-Inner Carrier-AEAD).
let has_vault1_nodes: bool = {
let conn = self.conn();
conn.query_row(
"SELECT 1 FROM nodes WHERE parent_id = 2 LIMIT 1",
[],
|_| Ok(true),
)
.optional()?
.unwrap_or(false)
};
if has_vault1_nodes {
if let Some(d1) = dek_1 {
self.set_active_slot_and_dek(1, Zeroizing::new(*d1));
self.update_metadata_mac()?;
}
}
// Aktive Session auf Slot 0 zurücksetzen
self.set_active_slot_and_dek(0, Zeroizing::new(*dek_0));
Ok(())
}
@@ -2542,6 +2684,15 @@ impl Database {
/// Schreibt oder stellt die Metadaten in der `meta`-Tabelle wieder her (z. B. nach Restore oder Header-Neugenerierung).
pub fn restore_meta(&self, meta: &ContainerMeta) -> Result<()> {
self.restore_meta_with_nonce(meta, None)
}
/// Schreibt oder stellt die Metadaten in der `meta`-Tabelle mit optionalem restore_nonce Token wieder her (F-02).
pub fn restore_meta_with_nonce(
&self,
meta: &ContainerMeta,
restore_nonce: Option<&[u8; 32]>,
) -> Result<()> {
let conn = self.conn();
conn.execute_batch(
@@ -2556,6 +2707,7 @@ impl Database {
header_tag BLOB NOT NULL,
metadata_mac BLOB,
metadata_gen INTEGER NOT NULL DEFAULT 0,
restore_nonce BLOB,
lock_pid INTEGER,
lock_host TEXT,
lock_time INTEGER
@@ -2564,6 +2716,8 @@ impl Database {
conn.execute("DELETE FROM meta", [])?;
let nonce_bytes = restore_nonce.map(|n| n.as_slice());
let mut has_slot1 = false;
if !meta.slots.is_empty() {
for slot in &meta.slots {
@@ -2572,8 +2726,8 @@ impl Database {
}
let params_json = serde_json::to_string(&slot.kdf_params)?;
conn.execute(
"INSERT INTO meta (slot_id, magic, version, kdf_salt, kdf_params, wrapped_dek, header_nonce, header_tag, metadata_mac, metadata_gen)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, NULL, 0)",
"INSERT INTO meta (slot_id, magic, version, kdf_salt, kdf_params, wrapped_dek, header_nonce, header_tag, metadata_mac, metadata_gen, restore_nonce)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, NULL, 0, ?9)",
params![
slot.slot_id,
MAGIC_BYTES.as_slice(),
@@ -2583,14 +2737,15 @@ impl Database {
slot.wrapped_dek,
slot.header_nonce.as_slice(),
slot.header_tag.as_slice(),
nonce_bytes,
],
)?;
}
} else {
let params_json = serde_json::to_string(&meta.kdf_params)?;
conn.execute(
"INSERT INTO meta (slot_id, magic, version, kdf_salt, kdf_params, wrapped_dek, header_nonce, header_tag, metadata_mac, metadata_gen)
VALUES (0, ?1, ?2, ?3, ?4, ?5, ?6, ?7, NULL, 0)",
"INSERT INTO meta (slot_id, magic, version, kdf_salt, kdf_params, wrapped_dek, header_nonce, header_tag, metadata_mac, metadata_gen, restore_nonce)
VALUES (0, ?1, ?2, ?3, ?4, ?5, ?6, ?7, NULL, 0, ?8)",
params![
MAGIC_BYTES.as_slice(),
meta.version,
@@ -2599,6 +2754,7 @@ impl Database {
meta.wrapped_dek,
meta.header_nonce.as_slice(),
meta.header_tag.as_slice(),
nonce_bytes,
],
)?;
}
@@ -2608,8 +2764,8 @@ impl Database {
let (dummy_dek, dummy_nonce, dummy_tag, dummy_salt) = generate_dummy_slot();
let dummy_params_json = serde_json::to_string(&KdfParams::default())?;
conn.execute(
"INSERT INTO meta (slot_id, magic, version, kdf_salt, kdf_params, wrapped_dek, header_nonce, header_tag, metadata_mac, metadata_gen)
VALUES (1, ?1, ?2, ?3, ?4, ?5, ?6, ?7, NULL, 0)",
"INSERT INTO meta (slot_id, magic, version, kdf_salt, kdf_params, wrapped_dek, header_nonce, header_tag, metadata_mac, metadata_gen, restore_nonce)
VALUES (1, ?1, ?2, ?3, ?4, ?5, ?6, ?7, NULL, 0, ?8)",
params![
MAGIC_BYTES.as_slice(),
FORMAT_VERSION,
@@ -2618,6 +2774,7 @@ impl Database {
dummy_dek.as_slice(),
dummy_nonce.as_slice(),
dummy_tag.as_slice(),
nonce_bytes,
],
)?;
}
@@ -2625,6 +2782,16 @@ impl Database {
Ok(())
}
/// Löscht das Restore-Token nach erfolgreichem MAC-Rebuild (F-02).
pub fn clear_restore_nonce(&self, slot_id: u32) -> Result<()> {
let conn = self.conn();
conn.execute(
"UPDATE meta SET restore_nonce = NULL WHERE slot_id = ?1",
params![slot_id],
)?;
Ok(())
}
/// Führt SQLite-eigene Integritäts- und Foreign-Key-Prüfungen aus.
pub fn run_sqlite_integrity_check(&self) -> Result<Vec<String>> {
let conn = self.conn();