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
+248
View File
@@ -0,0 +1,248 @@
use aes_gcm::{
aead::{AeadInPlace, KeyInit},
Aes256Gcm, Nonce, Tag,
};
use anyhow::{bail, Result};
use argon2::{Algorithm, Argon2, Params, Version};
use rand::rngs::OsRng;
use rand::RngCore;
use serde::{Deserialize, Serialize};
use zeroize::Zeroizing;
pub const MAGIC_BYTES: &[u8; 8] = b"SANCTUM\0";
pub const FORMAT_VERSION: u32 = 1;
pub const CHUNK_SIZE: usize = 1024 * 1024; // 1 MB
pub const DEFAULT_MEMORY_COST_KIB: u32 = 64 * 1024; // 64 MB
pub const DEFAULT_TIME_COST: u32 = 3;
pub const DEFAULT_PARALLELISM: u32 = 4;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct KdfParams {
pub memory_cost: u32,
pub time_cost: u32,
pub parallelism: u32,
}
impl Default for KdfParams {
fn default() -> Self {
Self {
memory_cost: DEFAULT_MEMORY_COST_KIB,
time_cost: DEFAULT_TIME_COST,
parallelism: DEFAULT_PARALLELISM,
}
}
}
/// Leitet aus dem Master-Passwort und dem Salt einen 256-Bit Key Encryption Key (KEK) via Argon2id ab.
pub fn derive_kek(
password: &str,
salt: &[u8],
params: &KdfParams,
) -> Result<Zeroizing<[u8; 32]>> {
let argon2_params = Params::new(
params.memory_cost,
params.time_cost,
params.parallelism,
Some(32),
)
.map_err(|e| anyhow::anyhow!("Ungültige Argon2-Parameter: {e}"))?;
let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, argon2_params);
let mut kek = Zeroizing::new([0u8; 32]);
argon2
.hash_password_into(password.as_bytes(), salt, &mut *kek)
.map_err(|e| anyhow::anyhow!("Argon2id KDF-Berechnung fehlgeschlagen: {e}"))?;
Ok(kek)
}
/// Generiert einen kryptografisch sicheren 256-Bit Data Encryption Key (DEK).
pub fn generate_dek() -> Zeroizing<[u8; 32]> {
let mut dek = Zeroizing::new([0u8; 32]);
OsRng.fill_bytes(&mut *dek);
dek
}
/// Generiert ein kryptografisch sicheres 16-Byte KDF-Salt.
pub fn generate_salt() -> [u8; 16] {
let mut salt = [0u8; 16];
OsRng.fill_bytes(&mut salt);
salt
}
/// Verschlüsselt den DEK mit dem KEK via AES-256-GCM.
/// Gibt (wrapped_dek_32_bytes, nonce_12_bytes, tag_16_bytes) zurück.
pub fn wrap_dek(
kek: &[u8; 32],
dek: &[u8; 32],
) -> Result<(Vec<u8>, [u8; 12], [u8; 16])> {
let cipher = Aes256Gcm::new_from_slice(kek)
.map_err(|e| anyhow::anyhow!("AES-GCM Initialisierungsfehler: {e}"))?;
let mut nonce_bytes = [0u8; 12];
OsRng.fill_bytes(&mut nonce_bytes);
let nonce = Nonce::from_slice(&nonce_bytes);
let mut buffer = dek.to_vec();
let tag = cipher
.encrypt_in_place_detached(nonce, b"SANCTUM_HEADER_DEK", &mut buffer)
.map_err(|e| anyhow::anyhow!("DEK-Wrapping fehlgeschlagen: {e}"))?;
let mut tag_bytes = [0u8; 16];
tag_bytes.copy_from_slice(tag.as_slice());
Ok((buffer, nonce_bytes, tag_bytes))
}
/// Entschlüsselt den DEK mit dem KEK via AES-256-GCM und validiert die Authentizität.
pub fn unwrap_dek(
kek: &[u8; 32],
wrapped_dek: &[u8],
nonce_bytes: &[u8; 12],
tag_bytes: &[u8; 16],
) -> Result<Zeroizing<[u8; 32]>> {
if wrapped_dek.len() != 32 {
bail!("Ungültige wrapped_dek Länge: erwartet 32 Bytes, erhalten {}", wrapped_dek.len());
}
let cipher = Aes256Gcm::new_from_slice(kek)
.map_err(|e| anyhow::anyhow!("AES-GCM Initialisierungsfehler: {e}"))?;
let nonce = Nonce::from_slice(nonce_bytes);
let tag = Tag::from_slice(tag_bytes);
let mut buffer = wrapped_dek.to_vec();
cipher
.decrypt_in_place_detached(nonce, b"SANCTUM_HEADER_DEK", &mut buffer, tag)
.map_err(|_| anyhow::anyhow!("Passwort falsch oder Header beschädigt (AEAD Authentifizierungsfehler)"))?;
let mut dek = Zeroizing::new([0u8; 32]);
dek.copy_from_slice(&buffer);
Ok(dek)
}
/// 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]
pub fn build_chunk_aad(node_id: i64, chunk_index: u32) -> [u8; 16] {
let mut aad = [0u8; 16];
aad[..8].copy_from_slice(&node_id.to_le_bytes());
aad[8..].copy_from_slice(&(chunk_index as u64).to_le_bytes());
aad
}
/// Verschlüsselt einen Payload-Chunk mit dem DEK via AES-256-GCM unter Einbindung von AAD.
/// Gibt (ciphertext, nonce_12_bytes, tag_16_bytes) zurück.
pub fn encrypt_chunk(
dek: &[u8; 32],
node_id: i64,
chunk_index: u32,
plaintext: &[u8],
) -> Result<(Vec<u8>, [u8; 12], [u8; 16])> {
let cipher = Aes256Gcm::new_from_slice(dek)
.map_err(|e| anyhow::anyhow!("AES-GCM Initialisierungsfehler: {e}"))?;
let mut nonce_bytes = [0u8; 12];
OsRng.fill_bytes(&mut nonce_bytes);
let nonce = Nonce::from_slice(&nonce_bytes);
let aad = build_chunk_aad(node_id, chunk_index);
let mut buffer = plaintext.to_vec();
let tag = cipher
.encrypt_in_place_detached(nonce, &aad, &mut buffer)
.map_err(|e| anyhow::anyhow!("Chunk-Verschlüsselung fehlgeschlagen: {e}"))?;
let mut tag_bytes = [0u8; 16];
tag_bytes.copy_from_slice(tag.as_slice());
Ok((buffer, nonce_bytes, tag_bytes))
}
/// Entschlüsselt und authentifiziert einen Payload-Chunk mit dem DEK via AES-256-GCM.
pub fn decrypt_chunk(
dek: &[u8; 32],
node_id: i64,
chunk_index: u32,
ciphertext: &[u8],
nonce_bytes: &[u8; 12],
tag_bytes: &[u8; 16],
) -> Result<Vec<u8>> {
let cipher = Aes256Gcm::new_from_slice(dek)
.map_err(|e| anyhow::anyhow!("AES-GCM Initialisierungsfehler: {e}"))?;
let nonce = Nonce::from_slice(nonce_bytes);
let tag = Tag::from_slice(tag_bytes);
let aad = build_chunk_aad(node_id, chunk_index);
let mut buffer = ciphertext.to_vec();
cipher
.decrypt_in_place_detached(nonce, &aad, &mut buffer, tag)
.map_err(|_| anyhow::anyhow!("Chunk-Integritätsprüfung fehlgeschlagen (AEAD Auth-Fehler oder Swap-Angriff)"))?;
Ok(buffer)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_kdf_and_dek_wrapping() {
let password = "SuperSecretMasterPassword123!";
let salt = generate_salt();
let params = KdfParams {
memory_cost: 1024, // Schneller für Unit-Test
time_cost: 1,
parallelism: 1,
};
let kek = derive_kek(password, &salt, &params).unwrap();
let dek = generate_dek();
let (wrapped, nonce, tag) = wrap_dek(&kek, &dek).unwrap();
assert_eq!(wrapped.len(), 32);
// Erfolgreiche Entschlüsselung
let unwrapped = unwrap_dek(&kek, &wrapped, &nonce, &tag).unwrap();
assert_eq!(*dek, *unwrapped);
// Falscher KEK schlägt fehl
let wrong_kek = derive_kek("WrongPassword!", &salt, &params).unwrap();
assert!(unwrap_dek(&wrong_kek, &wrapped, &nonce, &tag).is_err());
// Manipulierter Tag schlägt fehl
let mut tampered_tag = tag;
tampered_tag[0] ^= 0xFF;
assert!(unwrap_dek(&kek, &wrapped, &nonce, &tampered_tag).is_err());
}
#[test]
fn test_chunk_encryption_and_swap_protection() {
let dek = generate_dek();
let plaintext = b"Hello, Sanctum Encrypted Storage World!";
let node_id = 42i64;
let chunk_index = 0u32;
let (ciphertext, nonce, tag) = encrypt_chunk(&dek, node_id, chunk_index, plaintext).unwrap();
// Reguläre Entschlüsselung
let decrypted = decrypt_chunk(&dek, node_id, chunk_index, &ciphertext, &nonce, &tag).unwrap();
assert_eq!(decrypted, plaintext);
// Swap Attack 1: Falsche node_id (Chunk in andere Datei verschoben)
let swap_node_err = decrypt_chunk(&dek, 99i64, chunk_index, &ciphertext, &nonce, &tag);
assert!(swap_node_err.is_err());
// Swap Attack 2: Falscher chunk_index (Chunk innerhalb derselben Datei verschoben)
let swap_idx_err = decrypt_chunk(&dek, node_id, 1u32, &ciphertext, &nonce, &tag);
assert!(swap_idx_err.is_err());
// Manipulation des Ciphertexts
let mut tampered_ct = ciphertext.clone();
tampered_ct[0] ^= 0x01;
assert!(decrypt_chunk(&dek, node_id, chunk_index, &tampered_ct, &nonce, &tag).is_err());
}
}