fix(crypto): K-02 — format v3 chunk replay protection with generation aad

This commit is contained in:
2026-09-19 00:52:13 +02:00
parent ad531d8393
commit 36a4094336
9 changed files with 553 additions and 99 deletions
+244 -75
View File
@@ -32,6 +32,7 @@ pub struct NodeRecord {
pub struct ChunkRecord {
pub node_id: i64,
pub chunk_index: u32,
pub generation: u64,
pub nonce: [u8; 12],
pub tag: [u8; 16],
pub ciphertext: Vec<u8>,
@@ -185,7 +186,7 @@ impl ContainerMeta {
#[derive(Clone)]
pub struct Database {
conn: Arc<Mutex<Connection>>,
active_dek: Arc<Mutex<Option<Zeroizing<[u8; 32]>>>>,
active_session: Arc<Mutex<Option<(u32, Zeroizing<[u8; 32]>)>>>,
}
fn current_timestamp() -> u64 {
@@ -306,7 +307,7 @@ impl Database {
let db = Self {
conn: Arc::new(Mutex::new(conn)),
active_dek: Arc::new(Mutex::new(None)),
active_session: Arc::new(Mutex::new(None)),
};
db.init_pragmas()?;
if table_count > 0 {
@@ -321,7 +322,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)),
active_session: Arc::new(Mutex::new(None)),
};
db.init_pragmas()?;
db.ensure_schema_upgrades()?;
@@ -332,14 +333,31 @@ impl Database {
self.conn.lock().unwrap()
}
/// Setzt den aktiven DEK für automatische Metadaten-Authentifizierung (K-01).
/// Erzeugt eine geklonte Instanz mit einer isolierten aktiven Session (Slot & DEK).
pub fn with_session(&self, slot_id: u32, dek: Zeroizing<[u8; 32]>) -> Self {
Self {
conn: self.conn.clone(),
active_session: Arc::new(Mutex::new(Some((slot_id, dek)))),
}
}
/// Setzt den aktiven DEK für automatische Metadaten-Authentifizierung (K-01) auf Slot 0.
pub fn set_active_dek(&self, dek: Zeroizing<[u8; 32]>) {
*self.active_dek.lock().unwrap() = Some(dek);
self.set_active_slot_and_dek(0, dek);
}
/// Setzt den aktiven Slot und DEK für automatische Metadaten-Authentifizierung (K-01).
pub fn set_active_slot_and_dek(&self, slot_id: u32, dek: Zeroizing<[u8; 32]>) {
*self.active_session.lock().unwrap() = Some((slot_id, 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()
self.active_session
.lock()
.unwrap()
.as_ref()
.map(|(_, dek)| dek.clone())
}
/// Authentifiziert ein Master-Passwort gegen den Container in konstanter Zeit.
@@ -347,7 +365,7 @@ impl Database {
let meta = self.read_meta()?;
let res = meta.authenticate(password);
if let Some(ref keys) = res {
self.set_active_dek(keys.dek().clone());
self.set_active_slot_and_dek(keys.slot_id(), keys.dek().clone());
if let Some(cid) = keys.carrier_node_id() {
let _ = self.mark_carrier_node_id(cid);
}
@@ -387,6 +405,12 @@ impl Database {
[],
);
// Spalte generation in chunks (Format V3 / K-02)
let _ = conn.execute(
"ALTER TABLE chunks ADD COLUMN generation INTEGER NOT NULL DEFAULT 0",
[],
);
Ok(())
}
@@ -571,6 +595,7 @@ impl Database {
CREATE TABLE IF NOT EXISTS chunks (
node_id INTEGER NOT NULL,
chunk_index INTEGER NOT NULL,
generation INTEGER NOT NULL DEFAULT 0,
nonce BLOB NOT NULL,
tag BLOB NOT NULL,
ciphertext BLOB NOT NULL,
@@ -652,7 +677,7 @@ impl Database {
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)?;
crate::crypto::encrypt_chunk(dek_1, c_id, 0, &manifest_bytes, FORMAT_VERSION, 0)?;
let inner_ct_len = inner_ct.len() as u32;
let mut outer_plaintext = vec![0u8; CHUNK_SIZE];
@@ -668,18 +693,18 @@ 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)?;
crate::crypto::encrypt_chunk(dek_0, c_id, 0, &outer_plaintext, FORMAT_VERSION, 0)?;
conn.execute(
"INSERT INTO chunks (node_id, chunk_index, nonce, tag, ciphertext)
VALUES (?1, 0, ?2, ?3, ?4)",
"INSERT INTO chunks (node_id, chunk_index, generation, nonce, tag, ciphertext)
VALUES (?1, 0, 0, ?2, ?3, ?4)",
params![c_id, outer_nonce.as_slice(), outer_tag.as_slice(), outer_ct],
)?;
// Blöcke 1..total_blocks-1 mit DEK_0 vorallokieren
let mut chunk_stmt = conn.prepare(
"INSERT INTO chunks (node_id, chunk_index, nonce, tag, ciphertext)
VALUES (?1, ?2, ?3, ?4, ?5)",
"INSERT INTO chunks (node_id, chunk_index, generation, nonce, tag, ciphertext)
VALUES (?1, ?2, 0, ?3, ?4, ?5)",
)?;
let mut dummy_noise = vec![0u8; CHUNK_SIZE];
@@ -688,7 +713,7 @@ 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)?;
crate::crypto::encrypt_chunk(dek_0, c_id, b, &dummy_noise, FORMAT_VERSION, 0)?;
chunk_stmt.execute(params![c_id, b, nonce.as_slice(), tag.as_slice(), ct,])?;
if b % 500 == 0 {
@@ -696,9 +721,14 @@ impl Database {
}
}
conn.execute_batch("COMMIT;")?;
self.set_active_dek(Zeroizing::new(*dek_0));
Some(c_id)
drop(chunk_stmt);
self.set_active_slot_and_dek(0, Zeroizing::new(*dek_0));
drop(conn);
let _ = self.update_metadata_mac();
self.set_active_slot_and_dek(1, Zeroizing::new(*dek_1));
let _ = self.update_metadata_mac();
self.set_active_slot_and_dek(0, Zeroizing::new(*dek_0));
return Ok(Some(c_id));
} else {
// Slot 1 mit CSPRNG-Zufallsdaten gleicher Struktur und Entropie (72 Bytes für Modell A)
let (dummy_dek, dummy_nonce, dummy_tag, dummy_salt) = generate_dummy_slot();
@@ -772,6 +802,7 @@ impl Database {
CREATE TABLE IF NOT EXISTS chunks (
node_id INTEGER NOT NULL,
chunk_index INTEGER NOT NULL,
generation INTEGER NOT NULL DEFAULT 0,
nonce BLOB NOT NULL,
tag BLOB NOT NULL,
ciphertext BLOB NOT NULL,
@@ -1446,7 +1477,7 @@ impl Database {
pub fn read_chunk(&self, node_id: i64, chunk_index: u32) -> Result<Option<ChunkRecord>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT nonce, tag, ciphertext FROM chunks WHERE node_id = ?1 AND chunk_index = ?2",
"SELECT nonce, tag, ciphertext, generation FROM chunks WHERE node_id = ?1 AND chunk_index = ?2",
)?;
let record = stmt
@@ -1454,6 +1485,7 @@ impl Database {
let nonce_vec: Vec<u8> = row.get(0)?;
let tag_vec: Vec<u8> = row.get(1)?;
let ciphertext: Vec<u8> = row.get(2)?;
let generation: i64 = row.get(3).unwrap_or(0);
let mut nonce = [0u8; 12];
let mut tag = [0u8; 16];
@@ -1467,6 +1499,7 @@ impl Database {
Ok(ChunkRecord {
node_id,
chunk_index,
generation: generation as u64,
nonce,
tag,
ciphertext,
@@ -1477,26 +1510,52 @@ impl Database {
Ok(record)
}
/// Ermittelt die nächste Generation für einen Chunk (K-02 Chunk-Replay-Schutz).
/// Garantiert eine strikt monoton steigende Generation containerweit.
pub fn next_chunk_generation(&self, node_id: i64, chunk_index: u32) -> Result<u64> {
let conn = self.conn.lock().unwrap();
let current_gen: Option<i64> = conn
.query_row(
"SELECT generation FROM chunks WHERE node_id = ?1 AND chunk_index = ?2",
params![node_id, chunk_index],
|r| r.get(0),
)
.optional()?;
let max_gen: i64 = conn
.query_row("SELECT COALESCE(MAX(generation), 0) FROM chunks", [], |r| {
r.get(0)
})
.unwrap_or(0);
let next = match current_gen {
Some(g) => (g + 1).max(max_gen + 1),
None => max_gen + 1,
};
Ok(next as u64)
}
/// Schreibt oder aktualisiert einen verschlüsselten Chunk in der Datenbank.
pub fn write_chunk(
&self,
node_id: i64,
chunk_index: u32,
generation: u64,
nonce: &[u8; 12],
tag: &[u8; 16],
ciphertext: &[u8],
) -> Result<()> {
let conn = self.conn.lock().unwrap();
conn.execute(
"INSERT INTO chunks (node_id, chunk_index, nonce, tag, ciphertext)
VALUES (?1, ?2, ?3, ?4, ?5)
"INSERT INTO chunks (node_id, chunk_index, generation, nonce, tag, ciphertext)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)
ON CONFLICT(node_id, chunk_index) DO UPDATE SET
generation = excluded.generation,
nonce = excluded.nonce,
tag = excluded.tag,
ciphertext = excluded.ciphertext",
params![
node_id,
chunk_index,
generation as i64,
nonce.as_slice(),
tag.as_slice(),
ciphertext,
@@ -1511,6 +1570,7 @@ impl Database {
&self,
node_id: i64,
chunk_index: u32,
generation: u64,
nonce: &[u8; 12],
tag: &[u8; 16],
ciphertext: &[u8],
@@ -1521,15 +1581,17 @@ impl Database {
let mut conn = self.conn.lock().unwrap();
let tx = conn.transaction()?;
tx.execute(
"INSERT INTO chunks (node_id, chunk_index, nonce, tag, ciphertext)
VALUES (?1, ?2, ?3, ?4, ?5)
"INSERT INTO chunks (node_id, chunk_index, generation, nonce, tag, ciphertext)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)
ON CONFLICT(node_id, chunk_index) DO UPDATE SET
generation = excluded.generation,
nonce = excluded.nonce,
tag = excluded.tag,
ciphertext = excluded.ciphertext",
params![
node_id,
chunk_index,
generation as i64,
nonce.as_slice(),
tag.as_slice(),
ciphertext,
@@ -1596,65 +1658,100 @@ impl Database {
/// 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",
)?;
self.canonical_nodes_bytes_for_vault(0)
}
let mut rows = stmt.query([])?;
/// Erzeugt die deterministische kanonische Byterepräsentation für einen spezifischen Vault.
pub fn canonical_nodes_bytes_for_vault(&self, vault_id: u32) -> Result<Vec<u8>> {
let conn = self.conn.lock().unwrap();
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());
}
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 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
WHERE n.id IN (SELECT id FROM vault1)
ORDER BY n.id ASC",
)?;
let mut rows = stmt.query([])?;
while let Some(row) = rows.next()? {
Self::serialize_node_row(&row, &mut buf)?;
}
} 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 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
WHERE n.id = 2 OR n.id IN (SELECT id FROM vault0)
ORDER BY n.id ASC",
)?;
let mut rows = stmt.query([])?;
while let Some(row) = rows.next()? {
Self::serialize_node_row(&row, &mut buf)?;
}
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).
fn serialize_node_row(row: &rusqlite::Row, buf: &mut Vec<u8>) -> Result<()> {
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(())
}
/// Aktualisiert den Metadaten-MAC des aktiven Slots 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 {
let session_opt = self.active_session.lock().unwrap().clone();
let Some((slot_id, dek)) = session_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",
[],
"SELECT version, metadata_gen FROM meta WHERE slot_id = ?1 LIMIT 1",
params![slot_id],
|r| Ok((r.get(0)?, r.get(1).unwrap_or(0))),
)
.optional()?;
@@ -1670,14 +1767,14 @@ impl Database {
drop(conn);
let next_gen = current_gen + 1;
let canonical = self.canonical_nodes_bytes()?;
let canonical = self.canonical_nodes_bytes_for_vault(slot_id)?;
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],
"UPDATE meta SET metadata_mac = ?1, metadata_gen = ?2 WHERE slot_id = ?3",
params![new_mac.as_slice(), next_gen, slot_id],
)?;
Ok(())
@@ -1685,17 +1782,28 @@ impl Database {
/// 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> {
if self.verify_metadata_mac_for_slot(0, dek)? {
return Ok(true);
}
if self.verify_metadata_mac_for_slot(1, dek)? {
return Ok(true);
}
Ok(false)
}
/// 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> {
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",
[],
"SELECT version, metadata_mac, metadata_gen 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))),
)
.optional()?;
let Some((version, mac_opt, gen)) = meta_row else {
return Ok(true);
return Ok(false);
};
if version < FORMAT_VERSION_V3 {
@@ -1714,7 +1822,7 @@ impl Database {
expected_mac.copy_from_slice(&mac_bytes);
drop(conn);
let canonical = self.canonical_nodes_bytes()?;
let canonical = self.canonical_nodes_bytes_for_vault(slot_id)?;
let mac_key = derive_metadata_mac_key(dek);
Ok(verify_metadata_mac(&mac_key, gen, &canonical, &expected_mac))
}
@@ -1742,6 +1850,65 @@ impl Database {
[],
);
// K-02: 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",
)?;
let chunk_rows: Vec<(i64, u32, [u8; 12], [u8; 16], Vec<u8>)> = chunk_stmt
.query_map([], |row| {
let node_id: i64 = row.get(0)?;
let chunk_index: u32 = row.get(1)?;
let nonce_vec: Vec<u8> = row.get(2)?;
let tag_vec: Vec<u8> = row.get(3)?;
let ciphertext: Vec<u8> = row.get(4)?;
let mut nonce = [0u8; 12];
let mut tag = [0u8; 16];
if nonce_vec.len() == 12 {
nonce.copy_from_slice(&nonce_vec);
}
if tag_vec.len() == 16 {
tag.copy_from_slice(&tag_vec);
}
Ok((node_id, chunk_index, nonce, tag, ciphertext))
})?
.collect::<std::result::Result<Vec<_>, _>>()?;
drop(chunk_stmt);
for (node_id, chunk_index, nonce, tag, ct) in chunk_rows {
if let Ok(plaintext) = crate::crypto::decrypt_chunk(
dek,
node_id,
chunk_index,
&ct,
&nonce,
&tag,
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,
node_id,
chunk_index
],
)?;
}
}
}
}
conn.execute(
"UPDATE meta SET version = ?1, metadata_gen = 0 WHERE slot_id = 0",
[FORMAT_VERSION_V3],
@@ -1762,6 +1929,7 @@ impl Database {
conn.query_row("PRAGMA wal_checkpoint(TRUNCATE);", [], |row| {
Ok((row.get(0)?, row.get(1)?, row.get(2)?))
})?;
let _ = conn.execute_batch("PRAGMA incremental_vacuum;");
Ok(())
}
@@ -2042,7 +2210,7 @@ mod tests {
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)
db.write_chunk(file.id, 0, 0, &c_nonce, &c_tag, test_cipher)
.unwrap();
let chunk = db
@@ -2089,7 +2257,7 @@ 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)
db.write_chunk(file.id, i, 0, &c_nonce, &c_tag, &payload)
.unwrap();
}
db.checkpoint().unwrap();
@@ -2135,7 +2303,7 @@ mod tests {
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)
db.write_chunk(file.id, 0, 0, &c_nonce, &c_tag, sensitive_payload)
.unwrap();
// Shredde Chunks
@@ -2322,6 +2490,7 @@ mod tests {
db.write_chunk_and_update_size(
file.id,
0,
0,
&chunk_nonce,
&chunk_tag,
&ciphertext,