use std::fs;
use std::io::Write as _;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use hmac::{Hmac, Mac};
use sha2::Sha256;
use crate::encryption::{EncryptionEngine, EncryptionKey, derive_subkey, generate_key};
use sochdb_core::{Result, SochDBError};
type HmacSha256 = Hmac<Sha256>;
const KEYRING_FORMAT_VERSION: u32 = 1;
pub const KEYRING_FILE_NAME: &str = "keyring.json";
const CANARY_TOKEN: &[u8] = b"sochdb-keyring-canary-v1";
const INFO_WRAP: &[u8] = b"sochdb/keyring/wrap/v1";
const INFO_MAC: &[u8] = b"sochdb/keyring/mac/v1";
pub enum EncryptionState {
Plaintext,
Encrypted(ActiveEncryption),
}
impl std::fmt::Debug for EncryptionState {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
EncryptionState::Plaintext => write!(f, "EncryptionState::Plaintext"),
EncryptionState::Encrypted(a) => write!(
f,
"EncryptionState::Encrypted {{ db_uuid: {}, key_epoch: {} }}",
hex::encode(a.db_uuid),
a.key_epoch
),
}
}
}
impl EncryptionState {
pub fn is_encrypted(&self) -> bool {
matches!(self, EncryptionState::Encrypted(_))
}
pub fn engine(&self) -> Arc<EncryptionEngine> {
match self {
EncryptionState::Plaintext => Arc::new(EncryptionEngine::disabled()),
EncryptionState::Encrypted(a) => a.engine.clone(),
}
}
pub fn db_uuid(&self) -> [u8; 16] {
match self {
EncryptionState::Plaintext => [0u8; 16],
EncryptionState::Encrypted(a) => a.db_uuid,
}
}
pub fn key_epoch(&self) -> u32 {
match self {
EncryptionState::Plaintext => 0,
EncryptionState::Encrypted(a) => a.key_epoch,
}
}
}
pub struct ActiveEncryption {
pub engine: Arc<EncryptionEngine>,
pub db_uuid: [u8; 16],
pub key_epoch: u32,
}
#[derive(serde::Serialize, serde::Deserialize)]
struct KeyringFile {
format_version: u32,
encrypted: bool,
db_uuid: String,
kek_source_id: String,
key_epoch: u32,
salt: String,
wrapped_dek: String,
canary: String,
mac: String,
}
pub fn load_or_init(
db_dir: &Path,
kek: Option<&EncryptionKey>,
source_id: &str,
allow_create: bool,
) -> Result<EncryptionState> {
let path = keyring_path(db_dir);
if path.exists() {
let file: KeyringFile = read_keyring(&path)?;
if file.format_version != KEYRING_FORMAT_VERSION {
return Err(SochDBError::Encryption(format!(
"unsupported keyring format version {} (expected {})",
file.format_version, KEYRING_FORMAT_VERSION
)));
}
let kek = kek.ok_or_else(|| {
SochDBError::Encryption(
"database has a keyring (encryption configured) but no \
encryption key was provided (set the KEK, e.g. \
SOCHDB_ENCRYPTION_KEY); refusing to open"
.to_string(),
)
})?;
verify_mac(&file, kek)?;
if !file.encrypted {
return Ok(EncryptionState::Plaintext);
}
open_encrypted(file, kek)
} else if let Some(kek) = kek {
if !allow_create {
return Err(SochDBError::Encryption(
"an encryption key was provided for a database that has no \
keyring (existing plaintext data must be migrated explicitly, \
not encrypted in place); refusing to open"
.to_string(),
));
}
create_encrypted(db_dir, &path, kek, source_id)
} else {
Ok(EncryptionState::Plaintext)
}
}
fn keyring_path(db_dir: &Path) -> PathBuf {
db_dir.join(KEYRING_FILE_NAME)
}
fn read_keyring(path: &Path) -> Result<KeyringFile> {
let bytes = fs::read(path)?;
serde_json::from_slice(&bytes)
.map_err(|e| SochDBError::Encryption(format!("malformed keyring: {e}")))
}
fn mac_input(file: &KeyringFile) -> Vec<u8> {
let mut out = Vec::new();
let mut push = |b: &[u8]| {
out.extend_from_slice(&(b.len() as u32).to_le_bytes());
out.extend_from_slice(b);
};
push(&file.format_version.to_le_bytes());
push(&[file.encrypted as u8]);
push(file.db_uuid.as_bytes());
push(file.kek_source_id.as_bytes());
push(&file.key_epoch.to_le_bytes());
push(file.salt.as_bytes());
push(file.wrapped_dek.as_bytes());
push(file.canary.as_bytes());
out
}
fn compute_mac(mac_key: &EncryptionKey, file: &KeyringFile) -> Vec<u8> {
let mut mac = <HmacSha256 as Mac>::new_from_slice(mac_key.as_bytes())
.expect("HMAC accepts any key length");
mac.update(&mac_input(file));
mac.finalize().into_bytes().to_vec()
}
fn wrap_aad(db_uuid: &[u8; 16], epoch: u32, source_id: &str) -> Vec<u8> {
let mut aad = Vec::with_capacity(16 + 4 + source_id.len());
aad.extend_from_slice(db_uuid);
aad.extend_from_slice(&epoch.to_le_bytes());
aad.extend_from_slice(source_id.as_bytes());
aad
}
fn canary_aad(db_uuid: &[u8; 16], epoch: u32) -> Vec<u8> {
let mut aad = Vec::with_capacity(16 + 4);
aad.extend_from_slice(db_uuid);
aad.extend_from_slice(&epoch.to_le_bytes());
aad
}
fn create_encrypted(
db_dir: &Path,
path: &Path,
kek: &EncryptionKey,
source_id: &str,
) -> Result<EncryptionState> {
let mut db_uuid = [0u8; 16];
{
use rand::RngCore;
rand::rngs::OsRng.fill_bytes(&mut db_uuid);
}
let mut salt = [0u8; 16];
{
use rand::RngCore;
rand::rngs::OsRng.fill_bytes(&mut salt);
}
let epoch: u32 = 0;
let dek = EncryptionKey::new(generate_key());
let wrap_key = derive_subkey(kek.as_bytes(), &salt, INFO_WRAP);
let wrap_engine = EncryptionEngine::from_key(&wrap_key);
let wrapped_dek =
wrap_engine.encrypt_with_aad(dek.as_bytes(), &wrap_aad(&db_uuid, epoch, source_id))?;
let dek_engine = EncryptionEngine::from_key(&dek);
let canary = dek_engine.encrypt_with_aad(CANARY_TOKEN, &canary_aad(&db_uuid, epoch))?;
let mut file = KeyringFile {
format_version: KEYRING_FORMAT_VERSION,
encrypted: true,
db_uuid: hex::encode(db_uuid),
kek_source_id: source_id.to_string(),
key_epoch: epoch,
salt: hex::encode(salt),
wrapped_dek: hex::encode(&wrapped_dek),
canary: hex::encode(&canary),
mac: String::new(),
};
let mac_key = derive_subkey(kek.as_bytes(), &salt, INFO_MAC);
file.mac = hex::encode(compute_mac(&mac_key, &file));
write_keyring_atomic(db_dir, path, &file)?;
Ok(EncryptionState::Encrypted(ActiveEncryption {
engine: Arc::new(dek_engine),
db_uuid,
key_epoch: epoch,
}))
}
fn verify_mac(file: &KeyringFile, kek: &EncryptionKey) -> Result<()> {
let salt = decode_fixed::<16>(&file.salt, "salt")?;
let mac_key = derive_subkey(kek.as_bytes(), &salt, INFO_MAC);
let expected = compute_mac(&mac_key, file);
let actual = hex::decode(&file.mac)
.map_err(|_| SochDBError::Encryption("malformed keyring mac".into()))?;
if !constant_time_eq(&expected, &actual) {
return Err(SochDBError::Encryption(
"keyring authentication failed: wrong encryption key or tampered \
keyring; refusing to open"
.to_string(),
));
}
Ok(())
}
fn open_encrypted(file: KeyringFile, kek: &EncryptionKey) -> Result<EncryptionState> {
let salt = decode_fixed::<16>(&file.salt, "salt")?;
let db_uuid = decode_fixed::<16>(&file.db_uuid, "db_uuid")?;
let epoch = file.key_epoch;
let wrap_key = derive_subkey(kek.as_bytes(), &salt, INFO_WRAP);
let wrap_engine = EncryptionEngine::from_key(&wrap_key);
let wrapped_dek = hex::decode(&file.wrapped_dek)
.map_err(|_| SochDBError::Encryption("malformed wrapped_dek".into()))?;
let dek_bytes = wrap_engine
.decrypt_with_aad(
&wrapped_dek,
&wrap_aad(&db_uuid, epoch, &file.kek_source_id),
)
.map_err(|_| {
SochDBError::Encryption(
"failed to unwrap data key: wrong encryption key; refusing to open".into(),
)
})?;
if dek_bytes.len() != 32 {
return Err(SochDBError::Encryption(
"unwrapped DEK is not 32 bytes".into(),
));
}
let mut dek_arr = [0u8; 32];
dek_arr.copy_from_slice(&dek_bytes);
let dek = EncryptionKey::new(dek_arr);
let dek_engine = EncryptionEngine::from_key(&dek);
let canary = hex::decode(&file.canary)
.map_err(|_| SochDBError::Encryption("malformed canary".into()))?;
let token = dek_engine
.decrypt_with_aad(&canary, &canary_aad(&db_uuid, epoch))
.map_err(|_| {
SochDBError::Encryption(
"canary decryption failed: wrong encryption key; refusing to open".into(),
)
})?;
if token != CANARY_TOKEN {
return Err(SochDBError::Encryption(
"canary token mismatch; refusing to open".into(),
));
}
Ok(EncryptionState::Encrypted(ActiveEncryption {
engine: Arc::new(dek_engine),
db_uuid,
key_epoch: epoch,
}))
}
fn decode_fixed<const N: usize>(hexstr: &str, what: &str) -> Result<[u8; N]> {
let v = hex::decode(hexstr)
.map_err(|_| SochDBError::Encryption(format!("malformed keyring {what}")))?;
if v.len() != N {
return Err(SochDBError::Encryption(format!(
"keyring {what} wrong length: {} != {N}",
v.len()
)));
}
let mut a = [0u8; N];
a.copy_from_slice(&v);
Ok(a)
}
fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
if a.len() != b.len() {
return false;
}
let mut diff = 0u8;
for (x, y) in a.iter().zip(b.iter()) {
diff |= x ^ y;
}
diff == 0
}
fn write_keyring_atomic(db_dir: &Path, path: &Path, file: &KeyringFile) -> Result<()> {
fs::create_dir_all(db_dir)?;
let json = serde_json::to_vec_pretty(file)
.map_err(|e| SochDBError::Encryption(format!("serialize keyring: {e}")))?;
let tmp = path.with_extension("json.tmp");
{
let mut f = fs::File::create(&tmp)?;
f.write_all(&json)?;
f.sync_all()?;
}
fs::rename(&tmp, path)?;
if let Ok(dir) = fs::File::open(db_dir) {
let _ = dir.sync_all();
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
fn kek(seed: u8) -> EncryptionKey {
EncryptionKey::new([seed; 32])
}
#[test]
fn plaintext_when_no_key_and_no_file() {
let dir = tempdir().unwrap();
let st = load_or_init(dir.path(), None, "test", true).unwrap();
assert!(!st.is_encrypted());
assert!(!dir.path().join(KEYRING_FILE_NAME).exists());
}
#[test]
fn create_then_reopen_roundtrips_dek() {
let dir = tempdir().unwrap();
let st = load_or_init(dir.path(), Some(&kek(7)), "env", true).unwrap();
assert!(st.is_encrypted());
let uuid1 = st.db_uuid();
let ct = st.engine().encrypt(b"secret").unwrap();
assert_ne!(ct, b"secret");
let st2 = load_or_init(dir.path(), Some(&kek(7)), "env", false).unwrap();
assert!(st2.is_encrypted());
assert_eq!(st2.db_uuid(), uuid1);
assert_eq!(st2.engine().decrypt(&ct).unwrap(), b"secret");
}
#[test]
fn reopen_with_wrong_key_fails_closed() {
let dir = tempdir().unwrap();
load_or_init(dir.path(), Some(&kek(1)), "env", true).unwrap();
let err = load_or_init(dir.path(), Some(&kek(2)), "env", false).unwrap_err();
assert!(matches!(err, SochDBError::Encryption(_)));
}
#[test]
fn reopen_encrypted_without_key_fails_closed() {
let dir = tempdir().unwrap();
load_or_init(dir.path(), Some(&kek(1)), "env", true).unwrap();
let err = load_or_init(dir.path(), None, "env", true).unwrap_err();
assert!(matches!(err, SochDBError::Encryption(_)));
}
#[test]
fn forging_encrypted_false_is_rejected_by_mac() {
let dir = tempdir().unwrap();
load_or_init(dir.path(), Some(&kek(9)), "env", true).unwrap();
let path = dir.path().join(KEYRING_FILE_NAME);
let mut file: KeyringFile = serde_json::from_slice(&fs::read(&path).unwrap()).unwrap();
file.encrypted = false;
fs::write(&path, serde_json::to_vec_pretty(&file).unwrap()).unwrap();
let err = load_or_init(dir.path(), Some(&kek(9)), "env", false).unwrap_err();
assert!(matches!(err, SochDBError::Encryption(_)));
}
#[test]
fn keyring_present_but_no_key_fails_even_if_flag_says_plaintext() {
let dir = tempdir().unwrap();
load_or_init(dir.path(), Some(&kek(4)), "env", true).unwrap();
let path = dir.path().join(KEYRING_FILE_NAME);
let mut file: KeyringFile = serde_json::from_slice(&fs::read(&path).unwrap()).unwrap();
file.encrypted = false; fs::write(&path, serde_json::to_vec_pretty(&file).unwrap()).unwrap();
let err = load_or_init(dir.path(), None, "env", false).unwrap_err();
assert!(matches!(err, SochDBError::Encryption(_)));
}
#[test]
fn tampering_authenticated_field_is_rejected() {
let dir = tempdir().unwrap();
load_or_init(dir.path(), Some(&kek(5)), "env", true).unwrap();
let path = dir.path().join(KEYRING_FILE_NAME);
let mut file: KeyringFile = serde_json::from_slice(&fs::read(&path).unwrap()).unwrap();
file.key_epoch = 999;
fs::write(&path, serde_json::to_vec_pretty(&file).unwrap()).unwrap();
let err = load_or_init(dir.path(), Some(&kek(5)), "env", false).unwrap_err();
assert!(matches!(err, SochDBError::Encryption(_)));
}
#[test]
fn key_provided_for_existing_plaintext_db_without_create_fails() {
let dir = tempdir().unwrap();
fs::write(dir.path().join("wal.log"), b"legacy").unwrap();
let err = load_or_init(dir.path(), Some(&kek(3)), "env", false).unwrap_err();
assert!(matches!(err, SochDBError::Encryption(_)));
}
}