use chacha20poly1305::aead::{Aead, KeyInit, Payload};
use chacha20poly1305::{ChaCha20Poly1305, Key, Nonce};
use crate::error::Error;
pub const KEY: usize = 32;
pub const SALT: usize = 16;
pub const ID: usize = 4;
pub const TAG: u64 = 16;
pub type KeyId = [u8; ID];
pub struct Keyring {
keys: Vec<([u8; KEY], KeyId)>,
}
impl Keyring {
pub fn load(path: &std::path::Path) -> Result<Self, Error> {
let contents = std::fs::read_to_string(path).map_err(|error| {
Error::Storage(std::io::Error::other(format!(
"the encryption key file at {} could not be read: {error}",
path.display()
)))
})?;
Self::parse(&contents)
}
pub(super) fn parse(contents: &str) -> Result<Self, Error> {
let mut keys: Vec<([u8; KEY], KeyId)> = Vec::new();
for line in contents.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let raw = hex::decode(line)
.ok()
.filter(|raw| raw.len() == KEY)
.ok_or(Error::Misconfigured(
"an encryption key must be 32 bytes as 64 hex characters, one key per line",
))?;
let mut key = [0u8; KEY];
key.copy_from_slice(&raw);
let id = identify(&key);
if keys.iter().any(|(_, known)| *known == id) {
return Err(Error::Misconfigured(
"two encryption keys hash to the same id — the same key is probably listed twice",
));
}
keys.push((key, id));
}
if keys.is_empty() {
return Err(Error::Misconfigured(
"the encryption key file holds no keys",
));
}
Ok(Self { keys })
}
pub fn writing(&self) -> ObjectKey {
let (key, id) = &self.keys[0];
ObjectKey::derive(key, *id, random_salt())
}
pub fn reading(&self, id: KeyId, salt: [u8; SALT]) -> Result<ObjectKey, Error> {
self.keys
.iter()
.find(|(_, known)| *known == id)
.map(|(key, id)| ObjectKey::derive(key, *id, salt))
.ok_or(Error::UnknownKey)
}
}
fn identify(key: &[u8; KEY]) -> KeyId {
let mut id = [0u8; ID];
id.copy_from_slice(&blake3::hash(key).as_bytes()[..ID]);
id
}
fn random_salt() -> [u8; SALT] {
let mut salt = [0u8; SALT];
getrandom::fill(&mut salt).expect("the operating system has a random number generator");
salt
}
pub struct ObjectKey {
cipher: ChaCha20Poly1305,
id: KeyId,
salt: [u8; SALT],
}
const CONTEXT: &str = "LFSX 2026-08-16 object encryption key";
impl ObjectKey {
fn derive(master: &[u8; KEY], id: KeyId, salt: [u8; SALT]) -> Self {
let mut hasher = blake3::Hasher::new_derive_key(CONTEXT);
hasher.update(master);
hasher.update(&salt);
let derived = hasher.finalize();
Self {
cipher: ChaCha20Poly1305::new(&Key::from(*derived.as_bytes())),
id,
salt,
}
}
pub fn id(&self) -> KeyId {
self.id
}
pub fn salt(&self) -> [u8; SALT] {
self.salt
}
pub fn seal(&self, frame: u32, last: bool, oid: &str, plain: &[u8]) -> Result<Vec<u8>, Error> {
self.cipher
.encrypt(
&nonce(frame),
Payload {
msg: plain,
aad: &associated(frame, last, oid),
},
)
.map_err(|_| Error::Storage(std::io::Error::other("a frame could not be encrypted")))
}
pub fn open(&self, frame: u32, last: bool, oid: &str, sealed: &[u8]) -> Result<Vec<u8>, Error> {
self.cipher
.decrypt(
&nonce(frame),
Payload {
msg: sealed,
aad: &associated(frame, last, oid),
},
)
.map_err(|_| Error::Tampered)
}
}
fn nonce(frame: u32) -> Nonce {
let mut bytes = [0u8; 12];
bytes[8..].copy_from_slice(&frame.to_be_bytes());
Nonce::from(bytes)
}
fn associated(frame: u32, last: bool, oid: &str) -> Vec<u8> {
let mut aad = Vec::with_capacity(oid.len() + 5);
aad.extend_from_slice(oid.as_bytes());
aad.extend_from_slice(&frame.to_le_bytes());
aad.push(u8::from(last));
aad
}
#[cfg(test)]
mod tests;