Initial commit: Sanctum encrypted single-file container for Windows
This commit is contained in:
+534
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user