Initial commit: Sanctum encrypted single-file container for Windows

This commit is contained in:
2026-09-07 15:40:58 +02:00
commit 9115596763
11 changed files with 4020 additions and 0 deletions
+534
View File
@@ -0,0 +1,534 @@
use std::path::Path;
use std::sync::{Arc, Mutex};
use std::time::{SystemTime, UNIX_EPOCH};
use anyhow::{bail, Context, Result};
use rusqlite::{params, Connection, OptionalExtension};
use crate::crypto::{KdfParams, FORMAT_VERSION, MAGIC_BYTES};
#[allow(dead_code)]
#[derive(Debug, Clone)]
pub struct NodeRecord {
pub id: i64,
pub parent_id: Option<i64>,
pub name: String,
pub is_dir: bool,
pub size: u64,
pub created_at: u64,
pub modified_at: u64,
}
#[allow(dead_code)]
#[derive(Debug, Clone)]
pub struct ChunkRecord {
pub node_id: i64,
pub chunk_index: u32,
pub nonce: [u8; 12],
pub tag: [u8; 16],
pub ciphertext: Vec<u8>,
}
#[allow(dead_code)]
#[derive(Debug, Clone)]
pub struct ContainerMeta {
pub version: u32,
pub kdf_salt: [u8; 16],
pub kdf_params: KdfParams,
pub wrapped_dek: Vec<u8>,
pub header_nonce: [u8; 12],
pub header_tag: [u8; 16],
}
#[derive(Clone)]
pub struct Database {
conn: Arc<Mutex<Connection>>,
}
fn current_timestamp() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
impl Database {
/// Öffnet oder erstellt die Container-Datenbank und initialisiert die Pragmas.
pub fn open<P: AsRef<Path>>(path: P) -> Result<Self> {
let conn = Connection::open(path)?;
let db = Self {
conn: Arc::new(Mutex::new(conn)),
};
db.init_pragmas()?;
Ok(db)
}
/// Öffnet eine In-Memory-Datenbank (vorwiegend für Tests).
#[cfg(test)]
pub fn open_in_memory() -> Result<Self> {
let conn = Connection::open_in_memory()?;
let db = Self {
conn: Arc::new(Mutex::new(conn)),
};
db.init_pragmas()?;
Ok(db)
}
/// Setzt die vorgeschriebenen SQLite3-Pragmas: WAL, NORMAL synchronous, 8192 Page-Size.
pub fn init_pragmas(&self) -> Result<()> {
let conn = self.conn.lock().unwrap();
conn.execute_batch(
"PRAGMA page_size = 8192;
PRAGMA journal_mode = WAL;
PRAGMA synchronous = NORMAL;
PRAGMA foreign_keys = ON;
PRAGMA busy_timeout = 5000;",
)?;
Ok(())
}
/// Initialisiert das Datenbankschema für einen neuen Container.
pub fn init_schema(
&self,
salt: &[u8; 16],
kdf_params: &KdfParams,
wrapped_dek: &[u8],
header_nonce: &[u8; 12],
header_tag: &[u8; 16],
) -> Result<()> {
let conn = self.conn.lock().unwrap();
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS meta (
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 IF NOT EXISTS 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,
FOREIGN KEY(parent_id) REFERENCES nodes(id) ON DELETE CASCADE
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_nodes_parent_name ON nodes(COALESCE(parent_id, 0), name);
CREATE TABLE IF NOT EXISTS 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),
FOREIGN KEY(node_id) REFERENCES nodes(id) ON DELETE CASCADE
);",
)?;
// Metadaten einfügen
let params_json = serde_json::to_string(kdf_params)?;
conn.execute(
"INSERT INTO meta (magic, version, kdf_salt, kdf_params, wrapped_dek, header_nonce, header_tag)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
params![
MAGIC_BYTES.as_slice(),
FORMAT_VERSION,
salt.as_slice(),
params_json,
wrapped_dek,
header_nonce.as_slice(),
header_tag.as_slice(),
],
)?;
// Root-Verzeichnis '/' mit id = 1 anlegen
let now = current_timestamp();
conn.execute(
"INSERT OR IGNORE INTO nodes (id, parent_id, name, is_dir, size, created_at, modified_at)
VALUES (1, NULL, '', 1, 0, ?1, ?2)",
params![now, now],
)?;
Ok(())
}
/// Liest die Metadaten des Containers aus der `meta`-Tabelle aus und verifiziert die Magic Bytes.
pub fn read_meta(&self) -> Result<ContainerMeta> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT magic, version, kdf_salt, kdf_params, wrapped_dek, header_nonce, header_tag FROM meta LIMIT 1"
)?;
let meta = stmt.query_row([], |row| {
let magic: Vec<u8> = row.get(0)?;
let version: u32 = row.get(1)?;
let kdf_salt: Vec<u8> = row.get(2)?;
let kdf_params_str: String = row.get(3)?;
let wrapped_dek: Vec<u8> = row.get(4)?;
let header_nonce: Vec<u8> = row.get(5)?;
let header_tag: Vec<u8> = row.get(6)?;
Ok((
magic,
version,
kdf_salt,
kdf_params_str,
wrapped_dek,
header_nonce,
header_tag,
))
})?;
if meta.0.as_slice() != MAGIC_BYTES.as_slice() {
bail!("Ungültige Sanctum-Containerdatei: Magic Bytes stimmen nicht überein");
}
if meta.1 != FORMAT_VERSION {
bail!("Nicht unterstützte Sanctum-Formatversion: {}", meta.1);
}
if meta.2.len() != 16 {
bail!("Ungültige Salt-Länge im Header");
}
let mut salt = [0u8; 16];
salt.copy_from_slice(&meta.2);
let kdf_params: KdfParams = serde_json::from_str(&meta.3)
.context("KDF-Parameter im Header konnten nicht deserialisiert werden")?;
if meta.5.len() != 12 {
bail!("Ungültige Header-Nonce-Länge");
}
let mut header_nonce = [0u8; 12];
header_nonce.copy_from_slice(&meta.5);
if meta.6.len() != 16 {
bail!("Ungültige Header-Tag-Länge");
}
let mut header_tag = [0u8; 16];
header_tag.copy_from_slice(&meta.6);
Ok(ContainerMeta {
version: meta.1,
kdf_salt: salt,
kdf_params,
wrapped_dek: meta.4,
header_nonce,
header_tag,
})
}
/// Löst einen hierarchischen Pfad (z. B. "/ordner/datei.txt") in den entsprechenden NodeRecord auf.
pub fn resolve_path(&self, raw_path: &str) -> Result<Option<NodeRecord>> {
let normalized = raw_path.trim_matches('/');
if normalized.is_empty() {
return self.get_node_by_id(1);
}
let segments: Vec<&str> = normalized.split('/').filter(|s| !s.is_empty()).collect();
let conn = self.conn.lock().unwrap();
let mut current_id = 1i64;
let mut last_record = None;
for (idx, segment) in segments.iter().enumerate() {
let mut stmt = conn.prepare(
"SELECT id, parent_id, name, is_dir, size, created_at, modified_at
FROM nodes
WHERE parent_id = ?1 AND name = ?2",
)?;
let record: Option<NodeRecord> = stmt
.query_row(params![current_id, segment], |row| {
Ok(NodeRecord {
id: row.get(0)?,
parent_id: row.get(1)?,
name: row.get(2)?,
is_dir: row.get::<_, i32>(3)? != 0,
size: row.get::<_, i64>(4)? as u64,
created_at: row.get::<_, i64>(5)? as u64,
modified_at: row.get::<_, i64>(6)? as u64,
})
})
.optional()?;
match record {
Some(rec) => {
if idx + 1 < segments.len() && !rec.is_dir {
// Zwischenelement ist kein Verzeichnis
return Ok(None);
}
current_id = rec.id;
last_record = Some(rec);
}
None => return Ok(None),
}
}
Ok(last_record)
}
pub fn get_node_by_id(&self, id: i64) -> Result<Option<NodeRecord>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT id, parent_id, name, is_dir, size, created_at, modified_at
FROM nodes WHERE id = ?1",
)?;
let record = stmt
.query_row(params![id], |row| {
Ok(NodeRecord {
id: row.get(0)?,
parent_id: row.get(1)?,
name: row.get(2)?,
is_dir: row.get::<_, i32>(3)? != 0,
size: row.get::<_, i64>(4)? as u64,
created_at: row.get::<_, i64>(5)? as u64,
modified_at: row.get::<_, i64>(6)? as u64,
})
})
.optional()?;
Ok(record)
}
/// Listet alle direkten Kinder eines Verzeichnisknotens auf.
pub fn list_children(&self, parent_id: i64) -> Result<Vec<NodeRecord>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT id, parent_id, name, is_dir, size, created_at, modified_at
FROM nodes
WHERE parent_id = ?1
ORDER BY is_dir DESC, name ASC",
)?;
let rows = stmt.query_map(params![parent_id], |row| {
Ok(NodeRecord {
id: row.get(0)?,
parent_id: row.get(1)?,
name: row.get(2)?,
is_dir: row.get::<_, i32>(3)? != 0,
size: row.get::<_, i64>(4)? as u64,
created_at: row.get::<_, i64>(5)? as u64,
modified_at: row.get::<_, i64>(6)? as u64,
})
})?;
let mut entries = Vec::new();
for r in rows {
entries.push(r?);
}
Ok(entries)
}
/// Erstellt einen neuen Datei- oder Ordnerknoten.
pub fn create_node(&self, parent_id: i64, name: &str, is_dir: bool) -> Result<NodeRecord> {
let now = current_timestamp();
let conn = self.conn.lock().unwrap();
conn.execute(
"INSERT INTO nodes (parent_id, name, is_dir, size, created_at, modified_at)
VALUES (?1, ?2, ?3, 0, ?4, ?5)",
params![parent_id, name, if is_dir { 1 } else { 0 }, now, now],
)?;
let new_id = conn.last_insert_rowid();
// Aktualisiere das Änderungsdatum des Elternordners
let _ = conn.execute(
"UPDATE nodes SET modified_at = ?1 WHERE id = ?2",
params![now, parent_id],
);
Ok(NodeRecord {
id: new_id,
parent_id: Some(parent_id),
name: name.to_string(),
is_dir,
size: 0,
created_at: now,
modified_at: now,
})
}
/// Aktualisiert Dateigröße und Modifikationszeitstempel eines Knotens.
pub fn update_node_size_and_time(&self, id: i64, size: u64, modified_at: u64) -> Result<()> {
let conn = self.conn.lock().unwrap();
conn.execute(
"UPDATE nodes SET size = ?1, modified_at = ?2 WHERE id = ?3",
params![size as i64, modified_at as i64, id],
)?;
Ok(())
}
/// Löscht einen Knoten und alle assoziierten Chunks atomar.
pub fn delete_node(&self, id: i64) -> Result<()> {
let conn = self.conn.lock().unwrap();
// Foreign Key Cascade löscht Chunks und Unterordner, wir stellen es explizit sicher:
conn.execute("DELETE FROM chunks WHERE node_id = ?1", params![id])?;
conn.execute("DELETE FROM nodes WHERE id = ?1", params![id])?;
Ok(())
}
/// Benennt einen Knoten um und/oder verschiebt ihn in ein anderes Verzeichnis.
pub fn rename_node(&self, id: i64, new_parent_id: i64, new_name: &str) -> Result<()> {
let now = current_timestamp();
let conn = self.conn.lock().unwrap();
conn.execute(
"UPDATE nodes SET parent_id = ?1, name = ?2, modified_at = ?3 WHERE id = ?4",
params![new_parent_id, new_name, now, id],
)?;
Ok(())
}
/// Liest einen verschlüsselten Chunk aus der Datenbank.
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",
)?;
let record = stmt
.query_row(params![node_id, chunk_index], |row| {
let nonce_vec: Vec<u8> = row.get(0)?;
let tag_vec: Vec<u8> = row.get(1)?;
let ciphertext: Vec<u8> = row.get(2)?;
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(ChunkRecord {
node_id,
chunk_index,
nonce,
tag,
ciphertext,
})
})
.optional()?;
Ok(record)
}
/// Schreibt oder aktualisiert einen verschlüsselten Chunk in der Datenbank.
pub fn write_chunk(
&self,
node_id: i64,
chunk_index: u32,
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)
ON CONFLICT(node_id, chunk_index) DO UPDATE SET
nonce = excluded.nonce,
tag = excluded.tag,
ciphertext = excluded.ciphertext",
params![
node_id,
chunk_index,
nonce.as_slice(),
tag.as_slice(),
ciphertext,
],
)?;
Ok(())
}
/// Schneidet überzählige Chunks ab (z. B. beim Truncate oder Überschreiben mit kleinerer Datei).
pub fn truncate_chunks_after(&self, node_id: i64, max_chunk_index: u32) -> Result<()> {
let conn = self.conn.lock().unwrap();
conn.execute(
"DELETE FROM chunks WHERE node_id = ?1 AND chunk_index > ?2",
params![node_id, max_chunk_index],
)?;
Ok(())
}
/// Erzwingt einen SQLite WAL Checkpoint und leert das Write-Ahead-Log.
pub fn checkpoint(&self) -> Result<()> {
let conn = self.conn.lock().unwrap();
conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);")?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_storage_schema_and_path_resolution() {
let db = Database::open_in_memory().unwrap();
let salt = [1u8; 16];
let kdf_params = KdfParams::default();
let wrapped_dek = vec![2u8; 32];
let nonce = [3u8; 12];
let tag = [4u8; 16];
db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag).unwrap();
// Meta abrufen
let meta = db.read_meta().unwrap();
assert_eq!(meta.version, FORMAT_VERSION);
assert_eq!(meta.kdf_salt, salt);
assert_eq!(meta.wrapped_dek, wrapped_dek);
// Root prüfen
let root = db.resolve_path("/").unwrap().expect("Root node must exist");
assert_eq!(root.id, 1);
assert!(root.is_dir);
// Ordner und Datei erstellen
let docs = db.create_node(root.id, "documents", true).unwrap();
assert_eq!(docs.name, "documents");
assert!(docs.is_dir);
let file = db.create_node(docs.id, "notes.txt", false).unwrap();
assert_eq!(file.name, "notes.txt");
assert!(!file.is_dir);
// Pfadauflösung testen
let resolved_file = db.resolve_path("/documents/notes.txt").unwrap().expect("File should resolve");
assert_eq!(resolved_file.id, file.id);
let resolved_docs = db.resolve_path("documents").unwrap().expect("Docs should resolve");
assert_eq!(resolved_docs.id, docs.id);
// Chunks schreiben & lesen
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).unwrap();
let chunk = db.read_chunk(file.id, 0).unwrap().expect("Chunk 0 should exist");
assert_eq!(chunk.ciphertext, test_cipher);
// Truncate
db.truncate_chunks_after(file.id, 0).unwrap();
let chunk_after = db.read_chunk(file.id, 0).unwrap();
assert!(chunk_after.is_some());
// Löschen
db.delete_node(file.id).unwrap();
let deleted_res = db.resolve_path("/documents/notes.txt").unwrap();
assert!(deleted_res.is_none());
assert!(db.read_chunk(file.id, 0).unwrap().is_none());
}
}