use aes_gcm::aead::{Aead, AeadCore, KeyInit, OsRng};
use aes_gcm::{Aes256Gcm, Nonce};
use async_trait::async_trait;
use super::{file_name, DirEntry, Filesystem, Metadata, SharedFilesystem, WalkEntry};
use crate::error::{Error, Result};
pub const MAGIC: [u8; 4] = *b"LHE1";
const NONCE_LEN: usize = 12;
const TAG_LEN: usize = 16;
const MIN_SEALED_LEN: usize = MAGIC.len() + NONCE_LEN + TAG_LEN;
pub const EXEMPT_FILES: &[&str] = &[
".lh_wallet",
".lh_owner",
".lh_linked_owner",
".lh_device_key",
".lh_local_model.safetensors",
".lh_local_tokenizer.json",
".lh_notif_pending.json",
".lh_notif_inbox.json",
];
pub struct EncryptedFilesystem {
inner: SharedFilesystem,
cipher: Aes256Gcm,
}
impl std::fmt::Debug for EncryptedFilesystem {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("EncryptedFilesystem")
.field("inner", &self.inner)
.field("key", &"<redacted>")
.finish()
}
}
impl EncryptedFilesystem {
pub fn new(inner: SharedFilesystem, key: &[u8; 32]) -> Self {
Self {
inner,
cipher: Aes256Gcm::new(key.into()),
}
}
pub fn is_exempt(path: &str) -> bool {
EXEMPT_FILES.contains(&file_name(path))
}
pub fn looks_sealed(bytes: &[u8]) -> bool {
bytes.len() >= MIN_SEALED_LEN && bytes[..MAGIC.len()] == MAGIC
}
fn seal(&self, plaintext: &[u8]) -> Result<Vec<u8>> {
let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
let ct = self
.cipher
.encrypt(&nonce, plaintext)
.map_err(|_| Error::other("at-rest encrypt failed"))?;
let mut out = Vec::with_capacity(MAGIC.len() + NONCE_LEN + ct.len());
out.extend_from_slice(&MAGIC);
out.extend_from_slice(&nonce);
out.extend_from_slice(&ct);
Ok(out)
}
fn open(&self, path: &str, sealed: &[u8]) -> Result<Vec<u8>> {
let nonce_start = MAGIC.len();
let ct_start = nonce_start + NONCE_LEN;
let mut nonce = [0u8; NONCE_LEN];
nonce.copy_from_slice(&sealed[nonce_start..ct_start]);
let nonce = Nonce::from(nonce);
self.cipher.decrypt(&nonce, &sealed[ct_start..]).map_err(|_| {
Error::other(format!(
"at-rest decrypt failed for '{path}': wrong key or tampered ciphertext (GCM auth)"
))
})
}
}
#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
impl Filesystem for EncryptedFilesystem {
async fn read(&self, path: &str) -> Result<Vec<u8>> {
let bytes = self.inner.read(path).await?;
if Self::looks_sealed(&bytes) {
self.open(path, &bytes)
} else {
Ok(bytes)
}
}
async fn write_atomic(&self, path: &str, bytes: &[u8]) -> Result<()> {
if Self::is_exempt(path) {
return self.inner.write_atomic(path, bytes).await;
}
let sealed = self.seal(bytes)?;
self.inner.write_atomic(path, &sealed).await
}
async fn metadata(&self, path: &str) -> Result<Option<Metadata>> {
self.inner.metadata(path).await
}
async fn read_dir(&self, path: &str) -> Result<Vec<DirEntry>> {
self.inner.read_dir(path).await
}
async fn walk(&self, path: &str, max_depth: Option<usize>) -> Result<Vec<WalkEntry>> {
self.inner.walk(path, max_depth).await
}
async fn delete(&self, path: &str) -> Result<()> {
self.inner.delete(path).await
}
async fn rename(&self, from: &str, to: &str) -> Result<()> {
self.inner.rename(from, to).await
}
}
#[cfg(all(test, feature = "native"))]
mod tests {
use std::sync::Arc;
use super::*;
use crate::filesystem::NativeFilesystem;
const KEY: [u8; 32] = [7u8; 32];
fn setup() -> (tempfile::TempDir, EncryptedFilesystem, Arc<NativeFilesystem>) {
let dir = tempfile::tempdir().expect("tempdir");
let raw = Arc::new(NativeFilesystem::new());
let enc = EncryptedFilesystem::new(raw.clone(), &KEY);
(dir, enc, raw)
}
fn p(dir: &tempfile::TempDir, name: &str) -> String {
dir.path().join(name).to_string_lossy().into_owned()
}
#[tokio::test]
async fn round_trip_seals_at_rest_and_reads_back() {
let (dir, enc, raw) = setup();
let path = p(&dir, ".lh_history.json");
let plain = b"the conversation history nobody should read at rest";
enc.write_atomic(&path, plain).await.unwrap();
let on_disk = raw.read(&path).await.unwrap();
assert!(EncryptedFilesystem::looks_sealed(&on_disk), "missing LHE1 framing");
assert!(
!on_disk
.windows(plain.len())
.any(|w| w == plain.as_slice()),
"plaintext leaked into the at-rest bytes"
);
assert_eq!(on_disk.len(), MAGIC.len() + NONCE_LEN + plain.len() + TAG_LEN);
assert_eq!(enc.read(&path).await.unwrap(), plain);
}
#[tokio::test]
async fn legacy_plaintext_reads_through_unchanged() {
let (dir, enc, raw) = setup();
let path = p(&dir, ".lh_system_prompt.txt");
let legacy = b"You are a helpful agent.";
raw.write_atomic(&path, legacy).await.unwrap();
assert_eq!(enc.read(&path).await.unwrap(), legacy);
}
#[tokio::test]
async fn short_magic_prefixed_plaintext_passes_through() {
let (dir, enc, raw) = setup();
let path = p(&dir, "notes.txt");
let almost = b"LHE1 but actually just a short note"; let tiny = b"LHE1tiny";
assert!(tiny.len() < MIN_SEALED_LEN);
raw.write_atomic(&path, tiny).await.unwrap();
assert_eq!(enc.read(&path).await.unwrap(), tiny);
raw.write_atomic(&path, almost).await.unwrap();
assert!(enc.read(&path).await.is_err());
}
#[tokio::test]
async fn tampered_ciphertext_is_rejected_with_clear_error() {
let (dir, enc, raw) = setup();
let path = p(&dir, "secret.txt");
enc.write_atomic(&path, b"integrity matters").await.unwrap();
let mut sealed = raw.read(&path).await.unwrap();
let last = sealed.len() - 1;
sealed[last] ^= 0x01;
raw.write_atomic(&path, &sealed).await.unwrap();
let err = enc.read(&path).await.expect_err("tamper must not decrypt");
let msg = err.to_string();
assert!(
msg.contains("at-rest decrypt failed") && msg.contains("secret.txt"),
"unclear tamper error: {msg}"
);
}
#[tokio::test]
async fn wrong_key_is_rejected_not_garbage() {
let (dir, enc, raw) = setup();
let path = p(&dir, "secret.txt");
enc.write_atomic(&path, b"sealed under key A").await.unwrap();
let other = EncryptedFilesystem::new(raw.clone(), &[8u8; 32]);
assert!(other.read(&path).await.is_err());
}
#[tokio::test]
async fn exempt_identity_files_stay_plaintext_on_disk() {
let (dir, enc, raw) = setup();
for name in EXEMPT_FILES {
let path = p(&dir, name);
let body = format!("contents of {name}");
enc.write_atomic(&path, body.as_bytes()).await.unwrap();
assert_eq!(
raw.read(&path).await.unwrap(),
body.as_bytes(),
"{name} must NEVER be encrypted at rest"
);
assert_eq!(enc.read(&path).await.unwrap(), body.as_bytes());
}
}
#[test]
fn exempt_list_is_pinned() {
assert_eq!(
EXEMPT_FILES,
&[
".lh_wallet",
".lh_owner",
".lh_linked_owner",
".lh_device_key",
".lh_local_model.safetensors",
".lh_local_tokenizer.json",
".lh_notif_pending.json",
".lh_notif_inbox.json",
],
"exemption list changed — verify the boot path + seed safety before re-pinning"
);
assert!(
EncryptedFilesystem::is_exempt("some/dir/.lh_wallet"),
"exemption must match on the file name regardless of directory"
);
assert!(!EncryptedFilesystem::is_exempt(".lh_history.json"));
}
#[tokio::test]
async fn rename_preserves_decryptability() {
let (dir, enc, raw) = setup();
let from = p(&dir, "draft.txt");
let to = p(&dir, "final.txt");
enc.write_atomic(&from, b"movable secret").await.unwrap();
enc.rename(&from, &to).await.unwrap();
assert!(EncryptedFilesystem::looks_sealed(&raw.read(&to).await.unwrap()));
assert_eq!(enc.read(&to).await.unwrap(), b"movable secret");
}
#[tokio::test]
async fn fresh_nonce_per_write() {
let (dir, enc, raw) = setup();
let a = p(&dir, "a.txt");
let b = p(&dir, "b.txt");
enc.write_atomic(&a, b"same plaintext").await.unwrap();
enc.write_atomic(&b, b"same plaintext").await.unwrap();
assert_ne!(raw.read(&a).await.unwrap(), raw.read(&b).await.unwrap());
}
#[test]
fn debug_redacts_key() {
let raw = Arc::new(NativeFilesystem::new());
let enc = EncryptedFilesystem::new(raw, &KEY);
let dbg = format!("{enc:?}");
assert!(dbg.contains("<redacted>"));
assert!(!dbg.contains("7, 7, 7"));
}
}