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}"),
}
}
const KDF_INFO_PREFIX: &[u8] = b"car-sync/v1/aead/";
const KDF_SALT: &[u8] = b"car-sync/v1/salt";
pub fn derive_key(master: &[u8], audience: &str) -> [u8; 32] {
let hk = hkdf::Hkdf::<sha2::Sha256>::new(Some(KDF_SALT), master);
let mut info = KDF_INFO_PREFIX.to_vec();
info.extend_from_slice(audience.as_bytes());
let mut okm = [0u8; 32];
hk.expand(&info, &mut okm)
.expect("32 bytes is a valid HKDF-SHA256 output length");
okm
}
pub trait SyncKeyProvider: Send + Sync {
fn cipher_for(&self, scope: &Scope) -> std::sync::Arc<dyn PayloadCipher>;
}
pub struct DerivedKeyProvider {
master: Vec<u8>,
cache: std::sync::Mutex<std::collections::HashMap<String, std::sync::Arc<LocalKeyCipher>>>,
}
impl std::fmt::Debug for DerivedKeyProvider {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("DerivedKeyProvider").finish_non_exhaustive()
}
}
impl DerivedKeyProvider {
pub fn new(master: impl Into<Vec<u8>>) -> Self {
Self {
master: master.into(),
cache: std::sync::Mutex::new(std::collections::HashMap::new()),
}
}
pub fn from_login_secret(login_secret: &[u8], user_id: &str) -> Self {
Self::new(derive_key(login_secret, &format!("user/{user_id}")).to_vec())
}
pub fn from_passphrase(passphrase: &str, user_id: &str) -> Self {
Self::from_login_secret(passphrase.as_bytes(), user_id)
}
fn cipher_for_audience(&self, audience: &str) -> std::sync::Arc<dyn PayloadCipher> {
let mut cache = self.cache.lock().expect("key cache poisoned");
if let Some(c) = cache.get(audience) {
return c.clone();
}
let cipher =
std::sync::Arc::new(LocalKeyCipher::from_key(derive_key(&self.master, audience)));
cache.insert(audience.to_string(), cipher.clone());
cipher
}
}
impl SyncKeyProvider for DerivedKeyProvider {
fn cipher_for(&self, scope: &Scope) -> std::sync::Arc<dyn PayloadCipher> {
self.cipher_for_audience(&encryption_audience(scope))
}
}
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"
);
}
#[test]
fn same_login_master_derives_interoperable_keys_across_devices() {
let master = b"parslee-issued-per-user-sync-secret";
let mac = DerivedKeyProvider::new(master.to_vec());
let phone = DerivedKeyProvider::new(master.to_vec());
let secret = json!({"messaging_allowlist": ["+15551234567"]});
let env = mac.cipher_for(&Scope::Personal).encrypt(&secret).unwrap();
assert_eq!(
phone.cipher_for(&Scope::Personal).decrypt(&env).unwrap(),
secret
);
}
#[test]
fn personal_and_org_audiences_are_cryptographically_isolated() {
let p = DerivedKeyProvider::new(b"master".to_vec());
let env = p
.cipher_for(&Scope::Personal)
.encrypt(&json!({"x": 1}))
.unwrap();
assert!(matches!(
p.cipher_for(&Scope::Shared { org: "acme".into() })
.decrypt(&env),
Err(CryptoError::Decrypt)
));
}
#[test]
fn passphrase_derives_the_same_keys_on_every_device_zero_knowledge() {
let mac = DerivedKeyProvider::from_passphrase("correct horse battery staple", "user-1");
let phone = DerivedKeyProvider::from_passphrase("correct horse battery staple", "user-1");
let env = mac
.cipher_for(&Scope::Personal)
.encrypt(&json!({"s": 1}))
.unwrap();
assert_eq!(
phone.cipher_for(&Scope::Personal).decrypt(&env).unwrap(),
json!({"s": 1})
);
let wrong = DerivedKeyProvider::from_passphrase("hunter2", "user-1");
assert!(matches!(
wrong.cipher_for(&Scope::Personal).decrypt(&env),
Err(CryptoError::Decrypt)
));
}
#[test]
fn a_different_login_cannot_decrypt() {
let mine = DerivedKeyProvider::new(b"my-secret".to_vec());
let theirs = DerivedKeyProvider::new(b"their-secret".to_vec());
let env = mine
.cipher_for(&Scope::Personal)
.encrypt(&json!({"x": 1}))
.unwrap();
assert!(matches!(
theirs.cipher_for(&Scope::Personal).decrypt(&env),
Err(CryptoError::Decrypt)
));
}
#[test]
fn from_login_secret_is_stable_per_user_and_distinct_across_users() {
let raw = b"raw-oauth-derived-material";
let a = DerivedKeyProvider::from_login_secret(raw, "user-1");
let b = DerivedKeyProvider::from_login_secret(raw, "user-1");
let env = a
.cipher_for(&Scope::Personal)
.encrypt(&json!({"k": "v"}))
.unwrap();
assert_eq!(
b.cipher_for(&Scope::Personal).decrypt(&env).unwrap(),
json!({"k": "v"})
);
let other = DerivedKeyProvider::from_login_secret(raw, "user-2");
assert!(matches!(
other.cipher_for(&Scope::Personal).decrypt(&env),
Err(CryptoError::Decrypt)
));
}
}