use crate::oplog::Scope;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::path::Path;
use chacha20poly1305::aead::{Aead, AeadCore, KeyInit, OsRng};
use chacha20poly1305::{ChaCha20Poly1305, Key, Nonce};
pub const ALG_CHACHA20POLY1305: &str = "chacha20poly1305";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Envelope {
pub car_enc: String,
pub nonce: String,
pub ct: String,
}
impl Envelope {
pub fn is_envelope(v: &Value) -> bool {
v.get("car_enc").and_then(Value::as_str) == Some(ALG_CHACHA20POLY1305)
&& v.get("nonce").is_some()
&& v.get("ct").is_some()
}
}
#[derive(Debug)]
pub enum CryptoError {
Json(serde_json::Error),
BadEnvelope(String),
Decrypt,
Key(String),
Io(std::io::Error),
}
impl std::fmt::Display for CryptoError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
CryptoError::Json(e) => write!(f, "crypto payload json error: {e}"),
CryptoError::BadEnvelope(d) => write!(f, "crypto envelope malformed: {d}"),
CryptoError::Decrypt => {
write!(f, "crypto decrypt failed (wrong key or tampered ciphertext)")
}
CryptoError::Key(d) => write!(f, "crypto key error: {d}"),
CryptoError::Io(e) => write!(f, "crypto io error: {e}"),
}
}
}
impl std::error::Error for CryptoError {}
impl From<serde_json::Error> for CryptoError {
fn from(e: serde_json::Error) -> Self {
CryptoError::Json(e)
}
}
impl From<std::io::Error> for CryptoError {
fn from(e: std::io::Error) -> Self {
CryptoError::Io(e)
}
}
pub trait PayloadCipher: Send + Sync {
fn encrypt(&self, plaintext: &Value) -> Result<Value, CryptoError>;
fn decrypt(&self, envelope: &Value) -> Result<Value, CryptoError>;
}
#[derive(Clone)]
pub struct LocalKeyCipher {
key: [u8; 32],
}
impl std::fmt::Debug for LocalKeyCipher {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("LocalKeyCipher").finish_non_exhaustive()
}
}
impl LocalKeyCipher {
pub fn from_key(key: [u8; 32]) -> Self {
Self { key }
}
pub fn generate() -> Self {
let key = ChaCha20Poly1305::generate_key(&mut OsRng);
Self { key: key.into() }
}
pub fn key_hex(&self) -> String {
to_hex(&self.key)
}
pub fn from_key_hex(hex: &str) -> Result<Self, CryptoError> {
let bytes = from_hex(hex).map_err(CryptoError::Key)?;
let key: [u8; 32] = bytes
.try_into()
.map_err(|_| CryptoError::Key("key must be 32 bytes (64 hex chars)".into()))?;
Ok(Self { key })
}
pub fn load_or_generate(path: &Path) -> Result<Self, CryptoError> {
if path.exists() {
let hex = std::fs::read_to_string(path)?;
return Self::from_key_hex(hex.trim());
}
let cipher = Self::generate();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
#[cfg(unix)]
{
use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
let mut f = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(path)?;
f.write_all(cipher.key_hex().as_bytes())?;
f.sync_all()?;
}
#[cfg(not(unix))]
{
std::fs::write(path, cipher.key_hex())?;
}
Ok(cipher)
}
fn aead(&self) -> ChaCha20Poly1305 {
ChaCha20Poly1305::new(Key::from_slice(&self.key))
}
}
impl PayloadCipher for LocalKeyCipher {
fn encrypt(&self, plaintext: &Value) -> Result<Value, CryptoError> {
let bytes = serde_json::to_vec(plaintext)?;
let nonce = ChaCha20Poly1305::generate_nonce(&mut OsRng);
let ct = self
.aead()
.encrypt(&nonce, bytes.as_ref())
.map_err(|_| CryptoError::Decrypt)?;
let env = Envelope {
car_enc: ALG_CHACHA20POLY1305.to_string(),
nonce: to_hex(nonce.as_slice()),
ct: to_hex(&ct),
};
Ok(serde_json::to_value(env)?)
}
fn decrypt(&self, envelope: &Value) -> Result<Value, CryptoError> {
let env: Envelope = serde_json::from_value(envelope.clone())
.map_err(|e| CryptoError::BadEnvelope(e.to_string()))?;
if env.car_enc != ALG_CHACHA20POLY1305 {
return Err(CryptoError::BadEnvelope(format!(
"unknown algorithm tag {:?}",
env.car_enc
)));
}
let nonce_bytes = from_hex(&env.nonce).map_err(CryptoError::BadEnvelope)?;
if nonce_bytes.len() != 12 {
return Err(CryptoError::BadEnvelope("nonce must be 12 bytes".into()));
}
let ct = from_hex(&env.ct).map_err(CryptoError::BadEnvelope)?;
let nonce = Nonce::from_slice(&nonce_bytes);
let pt = self
.aead()
.decrypt(nonce, ct.as_ref())
.map_err(|_| CryptoError::Decrypt)?;
Ok(serde_json::from_slice(&pt)?)
}
}
pub fn encryption_audience(scope: &Scope) -> String {
match scope {
Scope::Personal => "personal".to_string(),
Scope::Shared { org } => format!("org:{org}"),
}
}
fn to_hex(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
fn from_hex(s: &str) -> Result<Vec<u8>, String> {
if !s.len().is_multiple_of(2) {
return Err("hex length must be even".into());
}
(0..s.len())
.step_by(2)
.map(|i| u8::from_str_radix(&s[i..i + 2], 16).map_err(|e| e.to_string()))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::oplog::{logical_clock, verify_log, DeviceLog, Surface};
use serde_json::json;
#[test]
fn local_key_cipher_round_trips() {
let cipher = LocalKeyCipher::generate();
let plaintext = json!({"id": "f1", "secret": "the launch codes", "n": 42});
let env = cipher.encrypt(&plaintext).unwrap();
assert!(Envelope::is_envelope(&env));
assert_eq!(cipher.decrypt(&env).unwrap(), plaintext);
let env2 = cipher.encrypt(&plaintext).unwrap();
assert_ne!(env, env2, "each encryption uses a fresh nonce");
assert_eq!(cipher.decrypt(&env2).unwrap(), plaintext);
}
#[test]
fn encrypted_op_chain_verifies_and_relay_sees_only_ciphertext() {
let cipher = LocalKeyCipher::generate();
let mut dev = DeviceLog::new("mac-a");
dev.set_wall_clock(logical_clock());
let secret1 = json!({"id": "f1", "body": "the sky is blue"});
let secret2 = json!({"id": "f2", "body": "water is wet"});
let op1 = dev.append(
Scope::Personal,
Surface::Knowledge,
cipher.encrypt(&secret1).unwrap(),
);
let op2 = dev.append(
Scope::Personal,
Surface::Knowledge,
cipher.encrypt(&secret2).unwrap(),
);
verify_log(&[op1.clone(), op2.clone()]).unwrap();
assert!(op1.id_valid());
for op in [&op1, &op2] {
assert!(Envelope::is_envelope(&op.payload));
assert!(op.payload.get("body").is_none());
assert!(op.payload.get("id").is_none());
}
assert_eq!(cipher.decrypt(&op1.payload).unwrap(), secret1);
assert_eq!(cipher.decrypt(&op2.payload).unwrap(), secret2);
}
#[test]
fn tampered_ciphertext_is_rejected() {
let cipher = LocalKeyCipher::generate();
let env = cipher.encrypt(&json!({"x": 1})).unwrap();
let mut tampered = env.clone();
let ct = tampered["ct"].as_str().unwrap().to_string();
let flipped: String = {
let mut chars: Vec<char> = ct.chars().collect();
chars[0] = if chars[0] == '0' { '1' } else { '0' };
chars.into_iter().collect()
};
tampered["ct"] = json!(flipped);
assert!(matches!(cipher.decrypt(&tampered), Err(CryptoError::Decrypt)));
}
#[test]
fn wrong_key_cannot_decrypt() {
let cipher = LocalKeyCipher::generate();
let other = LocalKeyCipher::generate();
let env = cipher.encrypt(&json!({"x": 1})).unwrap();
assert!(matches!(other.decrypt(&env), Err(CryptoError::Decrypt)));
}
#[test]
fn load_or_generate_persists_and_reloads_the_same_key() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("sync").join("personal.key");
let a = LocalKeyCipher::load_or_generate(&path).unwrap();
assert!(path.exists());
let b = LocalKeyCipher::load_or_generate(&path).unwrap();
assert_eq!(a.key_hex(), b.key_hex(), "the persisted key reloads identically");
let env = a.encrypt(&json!({"k": "v"})).unwrap();
assert_eq!(b.decrypt(&env).unwrap(), json!({"k": "v"}));
}
#[test]
fn scope_maps_to_a_single_encryption_audience() {
assert_eq!(encryption_audience(&Scope::Personal), "personal");
assert_eq!(
encryption_audience(&Scope::Shared { org: "acme".into() }),
"org:acme"
);
}
}