use crate::oplog::{canonical_json, Scope};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::path::Path;
use chacha20poly1305::aead::{Aead, AeadCore, KeyInit, OsRng, Payload};
use chacha20poly1305::{ChaCha20Poly1305, Key, Nonce};
use ed25519_dalek::{Signature, Signer, SigningKey, VerifyingKey};
use sha2::Digest;
use x25519_dalek::{PublicKey, StaticSecret};
use zeroize::{Zeroize, Zeroizing};
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,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub kid: Option<u64>,
}
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 Drop for LocalKeyCipher {
fn drop(&mut self) {
self.key.zeroize();
}
}
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())?;
car_secrets::harden_owner_only(path);
}
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),
kid: None,
};
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)?)
}
}
fn org_payload_aad(car_enc: &str, audience: &str, kid: u64) -> Vec<u8> {
let mut a = b"car-sync:payload:v2\0".to_vec();
let mut lp = |field: &[u8]| {
a.extend_from_slice(&(field.len() as u64).to_le_bytes());
a.extend_from_slice(field);
};
lp(car_enc.as_bytes());
lp(audience.as_bytes());
a.extend_from_slice(&kid.to_le_bytes());
a
}
pub struct MultiEpochOrgCipher {
audience: String,
keys: std::collections::BTreeMap<u64, Zeroizing<[u8; 32]>>,
}
impl std::fmt::Debug for MultiEpochOrgCipher {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("MultiEpochOrgCipher")
.field("audience", &self.audience)
.field("epochs", &self.keys.keys().collect::<Vec<_>>())
.finish_non_exhaustive()
}
}
impl MultiEpochOrgCipher {
pub fn new(
audience: impl Into<String>,
keys: std::collections::BTreeMap<u64, Zeroizing<[u8; 32]>>,
) -> Self {
Self {
audience: audience.into(),
keys,
}
}
fn aead_for(key: &[u8; 32]) -> ChaCha20Poly1305 {
ChaCha20Poly1305::new(Key::from_slice(key))
}
}
impl PayloadCipher for MultiEpochOrgCipher {
fn encrypt(&self, plaintext: &Value) -> Result<Value, CryptoError> {
let (&kid, key) = self
.keys
.last_key_value()
.ok_or_else(|| CryptoError::Key("org cipher has no epoch keys".into()))?;
let bytes = serde_json::to_vec(plaintext)?;
let nonce = ChaCha20Poly1305::generate_nonce(&mut OsRng);
let aad = org_payload_aad(ALG_CHACHA20POLY1305, &self.audience, kid);
let ct = Self::aead_for(key)
.encrypt(
&nonce,
Payload {
msg: bytes.as_ref(),
aad: &aad,
},
)
.map_err(|_| CryptoError::Decrypt)?;
let env = Envelope {
car_enc: ALG_CHACHA20POLY1305.to_string(),
nonce: to_hex(nonce.as_slice()),
ct: to_hex(&ct),
kid: Some(kid),
};
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 kid = env
.kid
.ok_or_else(|| CryptoError::BadEnvelope("org envelope missing epoch kid".into()))?;
let key = self
.keys
.get(&kid)
.ok_or_else(|| CryptoError::Key(format!("no org key held for epoch {kid}")))?;
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 aad = org_payload_aad(ALG_CHACHA20POLY1305, &self.audience, kid);
let pt = Self::aead_for(key)
.decrypt(
nonce,
Payload {
msg: ct.as_ref(),
aad: &aad,
},
)
.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
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum KdfProfile {
Argon2idV1,
IssuedHkdfV1,
}
const ARGON2_M_COST_KIB: u32 = 65536; const ARGON2_T_COST: u32 = 3;
const ARGON2_P_COST: u32 = 1;
const KDF_ARGON2_SALT_PREFIX: &[u8] = b"car-sync/v1/argon2-salt/user:";
fn argon2_salt(user_id: &str) -> [u8; 16] {
let mut h = sha2::Sha256::new();
h.update(KDF_ARGON2_SALT_PREFIX);
h.update(user_id.as_bytes());
let digest = h.finalize();
let mut salt = [0u8; 16];
salt.copy_from_slice(&digest[..16]);
salt
}
pub struct StretchedMaster {
bytes: Zeroizing<[u8; 32]>,
profile: KdfProfile,
}
impl std::fmt::Debug for StretchedMaster {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("StretchedMaster")
.field("profile", &self.profile)
.finish_non_exhaustive()
}
}
impl StretchedMaster {
pub fn from_passphrase(passphrase: &[u8], user_id: &str) -> Self {
let salt = argon2_salt(user_id);
let params = argon2::Params::new(ARGON2_M_COST_KIB, ARGON2_T_COST, ARGON2_P_COST, Some(32))
.expect("fixed Argon2idV1 params are valid");
let argon =
argon2::Argon2::new(argon2::Algorithm::Argon2id, argon2::Version::V0x13, params);
let mut bytes = Zeroizing::new([0u8; 32]);
argon
.hash_password_into(passphrase, &salt, bytes.as_mut_slice())
.expect("argon2 with valid params + 32-byte output does not fail");
Self {
bytes,
profile: KdfProfile::Argon2idV1,
}
}
pub fn from_issued_high_entropy(secret: &[u8], user_id: &str) -> Self {
Self {
bytes: Zeroizing::new(derive_key(secret, &format!("user/{user_id}"))),
profile: KdfProfile::IssuedHkdfV1,
}
}
pub fn profile(&self) -> KdfProfile {
self.profile
}
fn as_bytes(&self) -> &[u8; 32] {
&self.bytes
}
}
pub trait SyncKeyProvider: Send + Sync {
fn cipher_for(&self, scope: &Scope) -> std::sync::Arc<dyn PayloadCipher>;
}
pub struct DerivedKeyProvider {
master: Zeroizing<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: Zeroizing::new(master.into()),
cache: std::sync::Mutex::new(std::collections::HashMap::new()),
}
}
pub fn from_master(master: &StretchedMaster) -> Self {
Self::new(master.as_bytes().to_vec())
}
pub fn from_login_secret(login_secret: &[u8], user_id: &str) -> Self {
Self::from_master(&StretchedMaster::from_issued_high_entropy(
login_secret,
user_id,
))
}
pub fn from_passphrase(passphrase: &str, user_id: &str) -> Self {
Self::from_master(&StretchedMaster::from_passphrase(
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.as_slice(),
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))
}
}
pub(crate) fn to_hex(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
pub(crate) 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()
}
const KDF_INFO_X25519_ID: &[u8] = b"car-sync/v1/x25519-identity/v1/user:";
const KDF_INFO_ED25519_ID: &[u8] = b"car-sync/v1/ed25519-identity/v1/user:";
pub const ALG_ORG_KEY_WRAP: &str = "org-key-wrap/v2";
pub fn derive_x25519_identity(master: &StretchedMaster, user_id: &str) -> StaticSecret {
let hk = hkdf::Hkdf::<sha2::Sha256>::new(Some(KDF_SALT), master.as_bytes());
let mut info = KDF_INFO_X25519_ID.to_vec();
info.extend_from_slice(user_id.as_bytes());
let mut sk = [0u8; 32];
hk.expand(&info, &mut sk)
.expect("32 is a valid HKDF-SHA256 output length");
let secret = StaticSecret::from(sk);
sk.zeroize();
secret
}
pub fn x25519_public(secret: &StaticSecret) -> PublicKey {
PublicKey::from(secret)
}
pub fn derive_ed25519_identity(master: &StretchedMaster, user_id: &str) -> SigningKey {
let hk = hkdf::Hkdf::<sha2::Sha256>::new(Some(KDF_SALT), master.as_bytes());
let mut info = KDF_INFO_ED25519_ID.to_vec();
info.extend_from_slice(user_id.as_bytes());
let mut seed = [0u8; 32];
hk.expand(&info, &mut seed)
.expect("32 is a valid HKDF-SHA256 output length");
let signing = SigningKey::from_bytes(&seed);
seed.zeroize();
signing
}
pub fn ed25519_verifying(signing: &SigningKey) -> VerifyingKey {
signing.verifying_key()
}
pub fn require_canonical_org(org: &str) -> Result<(), CryptoError> {
if org.is_empty() {
return Err(CryptoError::Key("org id is empty".into()));
}
if !org
.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'))
{
return Err(CryptoError::Key(format!(
"org id {org:?} is not a canonical ASCII slug [A-Za-z0-9._-]"
)));
}
Ok(())
}
fn org_wrap_info(
org: &str,
epoch: u64,
recipient_user_id: &str,
e_pub: &PublicKey,
p_pub: &PublicKey,
) -> String {
format!(
"org-key-wrap/v1|org={}:{}|epoch={}|recipient={}:{}|E={}|P={}",
org.len(),
org,
epoch,
recipient_user_id.len(),
recipient_user_id,
to_hex(e_pub.as_bytes()),
to_hex(p_pub.as_bytes()),
)
}
fn wrap_sign_transcript(
org: &str,
epoch: u64,
recipient_user_id: &str,
publisher_user_id: &str,
e_pub: &PublicKey,
p_pub: &PublicKey,
envelope: &Value,
) -> Vec<u8> {
fn lp(buf: &mut Vec<u8>, field: &[u8]) {
buf.extend_from_slice(&(field.len() as u64).to_le_bytes());
buf.extend_from_slice(field);
}
let mut t = Vec::new();
lp(&mut t, ALG_ORG_KEY_WRAP.as_bytes());
lp(&mut t, org.as_bytes());
t.extend_from_slice(&epoch.to_le_bytes());
lp(&mut t, recipient_user_id.as_bytes());
lp(&mut t, publisher_user_id.as_bytes());
lp(&mut t, e_pub.as_bytes());
lp(&mut t, p_pub.as_bytes());
lp(&mut t, canonical_json(envelope).as_bytes());
t
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct WrappedOrgKey {
pub car_wrap: String,
pub org: String,
pub epoch: u64,
pub recipient: String,
pub publisher: String,
pub ephemeral_pub: String,
pub recipient_pub: String,
pub envelope: Value,
pub signature: String,
}
pub fn generate_org_key() -> Zeroizing<[u8; 32]> {
Zeroizing::new(ChaCha20Poly1305::generate_key(&mut OsRng).into())
}
pub fn wrap_org_key(
k_org: &[u8; 32],
org: &str,
epoch: u64,
recipient_user_id: &str,
recipient_pub: &PublicKey,
publisher_user_id: &str,
signer: &SigningKey,
) -> Result<WrappedOrgKey, CryptoError> {
require_canonical_org(org)?;
let mut e_bytes: [u8; 32] = ChaCha20Poly1305::generate_key(&mut OsRng).into();
let e_secret = StaticSecret::from(e_bytes);
e_bytes.zeroize();
let e_pub = PublicKey::from(&e_secret);
let shared = e_secret.diffie_hellman(recipient_pub);
if !shared.was_contributory() {
return Err(CryptoError::Key(
"recipient public key is low-order (non-contributory DH)".into(),
));
}
let wrap_key = zeroize::Zeroizing::new(derive_key(
shared.as_bytes(),
&org_wrap_info(org, epoch, recipient_user_id, &e_pub, recipient_pub),
));
let envelope = LocalKeyCipher::from_key(*wrap_key).encrypt(&serde_json::json!({
"k_org": to_hex(k_org),
}))?;
let signature = signer.sign(&wrap_sign_transcript(
org,
epoch,
recipient_user_id,
publisher_user_id,
&e_pub,
recipient_pub,
&envelope,
));
Ok(WrappedOrgKey {
car_wrap: ALG_ORG_KEY_WRAP.to_string(),
org: org.to_string(),
epoch,
recipient: recipient_user_id.to_string(),
publisher: publisher_user_id.to_string(),
ephemeral_pub: to_hex(e_pub.as_bytes()),
recipient_pub: to_hex(recipient_pub.as_bytes()),
envelope,
signature: to_hex(&signature.to_bytes()),
})
}
pub fn unwrap_org_key(
wrapped: &WrappedOrgKey,
my_secret: &StaticSecret,
my_user_id: &str,
trusted: &[VerifyingKey],
) -> Result<[u8; 32], CryptoError> {
if wrapped.car_wrap != ALG_ORG_KEY_WRAP {
return Err(CryptoError::BadEnvelope(format!(
"unknown wrap tag {:?} (expected {ALG_ORG_KEY_WRAP})",
wrapped.car_wrap
)));
}
require_canonical_org(&wrapped.org)?;
let e_pub = parse_x25519_pub(&wrapped.ephemeral_pub)?;
let recipient_pub = parse_x25519_pub(&wrapped.recipient_pub)?;
let sig_bytes: [u8; 64] = from_hex(&wrapped.signature)
.map_err(CryptoError::Key)?
.try_into()
.map_err(|_| CryptoError::Key("signature must be 64 bytes".into()))?;
let signature = Signature::from_bytes(&sig_bytes);
let transcript = wrap_sign_transcript(
&wrapped.org,
wrapped.epoch,
my_user_id,
&wrapped.publisher,
&e_pub,
&recipient_pub,
&wrapped.envelope,
);
let authenticated = trusted
.iter()
.any(|vk| vk.verify_strict(&transcript, &signature).is_ok());
if !authenticated {
return Err(CryptoError::Key(
"wrap is not signed by any trusted holder — refusing (possible key substitution)"
.into(),
));
}
let shared = my_secret.diffie_hellman(&e_pub);
if !shared.was_contributory() {
return Err(CryptoError::Key(
"ephemeral public key is low-order (non-contributory DH)".into(),
));
}
let wrap_key = zeroize::Zeroizing::new(derive_key(
shared.as_bytes(),
&org_wrap_info(
&wrapped.org,
wrapped.epoch,
my_user_id,
&e_pub,
&recipient_pub,
),
));
let pt = LocalKeyCipher::from_key(*wrap_key).decrypt(&wrapped.envelope)?;
let k_hex = pt
.get("k_org")
.and_then(Value::as_str)
.ok_or_else(|| CryptoError::BadEnvelope("wrapped payload missing k_org".into()))?;
let bytes = from_hex(k_hex).map_err(CryptoError::Key)?;
bytes
.try_into()
.map_err(|_| CryptoError::Key("k_org must be 32 bytes".into()))
}
pub fn parse_x25519_pub(hex: &str) -> Result<PublicKey, CryptoError> {
let bytes = from_hex(hex).map_err(CryptoError::Key)?;
let arr: [u8; 32] = bytes
.try_into()
.map_err(|_| CryptoError::Key("x25519 public key must be 32 bytes".into()))?;
Ok(PublicKey::from(arr))
}
pub fn parse_ed25519_verifying(hex: &str) -> Result<VerifyingKey, CryptoError> {
let bytes = from_hex(hex).map_err(CryptoError::Key)?;
let arr: [u8; 32] = bytes
.try_into()
.map_err(|_| CryptoError::Key("ed25519 verifying key must be 32 bytes".into()))?;
VerifyingKey::from_bytes(&arr)
.map_err(|e| CryptoError::Key(format!("invalid ed25519 verifying key: {e}")))
}
#[cfg(test)]
mod org_key_tests {
use super::*;
use serde_json::json;
fn x25519_id(secret: &[u8], user: &str) -> StaticSecret {
crate::crypto::derive_x25519_identity(
&StretchedMaster::from_issued_high_entropy(secret, user),
user,
)
}
fn ed25519_id(secret: &[u8], user: &str) -> SigningKey {
crate::crypto::derive_ed25519_identity(
&StretchedMaster::from_issued_high_entropy(secret, user),
user,
)
}
fn granter() -> SigningKey {
ed25519_id(b"granter-login-secret", "acc_granter")
}
fn trusted() -> Vec<VerifyingKey> {
vec![ed25519_verifying(&granter())]
}
fn wrap_by_granter(
k_org: &[u8; 32],
org: &str,
epoch: u64,
recipient: &str,
recipient_pub: &PublicKey,
) -> WrappedOrgKey {
wrap_org_key(
k_org,
org,
epoch,
recipient,
recipient_pub,
"acc_granter",
&granter(),
)
.unwrap()
}
#[test]
fn org_key_wrap_round_trips_for_the_recipient() {
let alice = x25519_id(b"alice-login-secret", "acc_alice");
let k_org = [7u8; 32];
let wrapped = wrap_by_granter(&k_org, "acme", 1, "acc_alice", &x25519_public(&alice));
assert_eq!(
unwrap_org_key(&wrapped, &alice, "acc_alice", &trusted()).unwrap(),
k_org
);
}
#[test]
fn substitution_by_untrusted_publisher_is_refused() {
let alice = x25519_id(b"alice", "acc_alice");
let mallory = ed25519_id(b"mallory-login", "acc_mallory");
let k_org_evil = [0xEEu8; 32];
let poisoned = wrap_org_key(
&k_org_evil,
"acme",
1,
"acc_alice",
&x25519_public(&alice),
"acc_mallory",
&mallory,
)
.unwrap();
let err = unwrap_org_key(&poisoned, &alice, "acc_alice", &trusted()).unwrap_err();
assert!(matches!(err, CryptoError::Key(_)));
let mallory_trusted = vec![ed25519_verifying(&mallory)];
assert_eq!(
unwrap_org_key(&poisoned, &alice, "acc_alice", &mallory_trusted).unwrap(),
k_org_evil
);
}
#[test]
fn wrap_for_a_different_recipient_cannot_be_replayed() {
let alice = x25519_id(b"alice", "acc_alice");
let bob = x25519_id(b"bob", "acc_bob");
let for_bob = wrap_by_granter(&[9u8; 32], "acme", 1, "acc_bob", &x25519_public(&bob));
assert!(unwrap_org_key(&for_bob, &alice, "acc_alice", &trusted()).is_err());
}
#[test]
fn org_key_does_not_unwrap_for_a_different_member_or_id() {
let alice = x25519_id(b"alice-secret", "acc_alice");
let bob = x25519_id(b"bob-secret", "acc_bob");
let k_org = [9u8; 32];
let wrapped = wrap_by_granter(&k_org, "acme", 1, "acc_alice", &x25519_public(&alice));
assert!(unwrap_org_key(&wrapped, &bob, "acc_bob", &trusted()).is_err());
assert!(unwrap_org_key(&wrapped, &alice, "acc_bob", &trusted()).is_err());
}
#[test]
fn org_key_unwrap_rejects_tampered_transcript_and_ciphertext() {
let alice = x25519_id(b"alice-secret", "acc_alice");
let k_org = [3u8; 32];
let base = wrap_by_granter(&k_org, "acme", 1, "acc_alice", &x25519_public(&alice));
let bob_pub = x25519_public(&x25519_id(b"bob", "acc_bob"));
let unwrap = |t: &WrappedOrgKey| unwrap_org_key(t, &alice, "acc_alice", &trusted());
let mut t = base.clone();
let ct = t.envelope["ct"].as_str().unwrap().to_string();
let mut chars: Vec<char> = ct.chars().collect();
let last = chars.len() - 1;
chars[last] = if chars[last] == '0' { '1' } else { '0' };
t.envelope["ct"] = json!(chars.into_iter().collect::<String>());
assert!(unwrap(&t).is_err());
let mut t = base.clone();
t.ephemeral_pub = to_hex(bob_pub.as_bytes());
assert!(unwrap(&t).is_err());
let mut t = base.clone();
t.recipient_pub = to_hex(bob_pub.as_bytes());
assert!(unwrap(&t).is_err());
let mut t = base.clone();
t.epoch = 2;
assert!(unwrap(&t).is_err());
let mut t = base.clone();
t.org = "evil".into();
assert!(unwrap(&t).is_err());
let mut t = base.clone();
t.publisher = "acc_mallory".into();
assert!(unwrap(&t).is_err());
let mut t = base.clone();
let mut sig: Vec<char> = t.signature.chars().collect();
sig[0] = if sig[0] == '0' { '1' } else { '0' };
t.signature = sig.into_iter().collect();
assert!(unwrap(&t).is_err());
}
#[test]
fn identities_are_deterministic_and_domain_separated() {
let a = x25519_id(b"same-login", "acc_x");
let b = x25519_id(b"same-login", "acc_x");
assert_eq!(x25519_public(&a).as_bytes(), x25519_public(&b).as_bytes());
let s1 = ed25519_id(b"same-login", "acc_x");
let s2 = ed25519_id(b"same-login", "acc_x");
assert_eq!(
ed25519_verifying(&s1).to_bytes(),
ed25519_verifying(&s2).to_bytes()
);
assert_ne!(
x25519_public(&a).as_bytes(),
x25519_public(&x25519_id(b"same-login", "acc_y")).as_bytes()
);
assert_ne!(
ed25519_verifying(&s1).to_bytes(),
ed25519_verifying(&ed25519_id(b"other-login", "acc_x")).to_bytes()
);
assert_ne!(
x25519_public(&a).as_bytes(),
&ed25519_verifying(&s1).to_bytes()
);
}
#[test]
fn argon2id_stretch_is_deterministic_and_domain_separated() {
let a = StretchedMaster::from_passphrase(b"correct horse battery staple", "acc_u1");
let b = StretchedMaster::from_passphrase(b"correct horse battery staple", "acc_u1");
assert_eq!(a.profile(), KdfProfile::Argon2idV1);
assert_eq!(
x25519_public(&derive_x25519_identity(&a, "acc_u1")).as_bytes(),
x25519_public(&derive_x25519_identity(&b, "acc_u1")).as_bytes(),
"same passphrase+user must derive the same identity on every device"
);
let diff_pass = StretchedMaster::from_passphrase(b"hunter2", "acc_u1");
assert_ne!(
x25519_public(&derive_x25519_identity(&a, "acc_u1")).as_bytes(),
x25519_public(&derive_x25519_identity(&diff_pass, "acc_u1")).as_bytes()
);
let diff_user = StretchedMaster::from_passphrase(b"correct horse battery staple", "acc_u2");
assert_ne!(
x25519_public(&derive_x25519_identity(&a, "acc_u1")).as_bytes(),
x25519_public(&derive_x25519_identity(&diff_user, "acc_u2")).as_bytes(),
"the per-user Argon2id salt + HKDF info domain-separate users"
);
let issued =
StretchedMaster::from_issued_high_entropy(b"correct horse battery staple", "acc_u1");
assert_eq!(issued.profile(), KdfProfile::IssuedHkdfV1);
assert_ne!(
x25519_public(&derive_x25519_identity(&a, "acc_u1")).as_bytes(),
x25519_public(&derive_x25519_identity(&issued, "acc_u1")).as_bytes()
);
}
#[test]
fn wrap_rejects_low_order_recipient_key() {
let low_order = PublicKey::from([0u8; 32]);
assert!(wrap_org_key(
&[1u8; 32],
"acme",
1,
"acc_x",
&low_order,
"acc_granter",
&granter()
)
.is_err());
}
#[test]
fn non_canonical_org_is_rejected_on_wrap_and_unwrap() {
let alice = x25519_id(b"alice", "acc_alice");
for bad in ["Acme corp", "org/evil", "acmé", ""] {
assert!(
wrap_org_key(
&[1u8; 32],
bad,
1,
"acc_alice",
&x25519_public(&alice),
"acc_granter",
&granter()
)
.is_err(),
"wrap must reject non-canonical org {bad:?}"
);
}
let mut t = wrap_by_granter(&[1u8; 32], "acme", 1, "acc_alice", &x25519_public(&alice));
t.org = "Acme".into();
assert!(unwrap_org_key(&t, &alice, "acc_alice", &trusted()).is_err());
}
#[test]
fn org_wrap_info_is_injective_under_delimiter_injection() {
let e = x25519_public(&x25519_id(b"e", "e"));
let p = x25519_public(&x25519_id(b"p", "p"));
let a = org_wrap_info("acme/epoch:1/recipient:mallory", 1, "acc_alice", &e, &p);
let b = org_wrap_info("acme", 1, "mallory/epoch:1/recipient:acc_alice", &e, &p);
assert_ne!(a, b, "delimiter injection must not collide the transcript");
}
#[test]
fn all_members_derive_the_same_org_audience_key() {
let alice = x25519_id(b"alice", "acc_alice");
let bob = x25519_id(b"bob", "acc_bob");
let k_org = [42u8; 32];
let ka = unwrap_org_key(
&wrap_by_granter(&k_org, "acme", 1, "acc_alice", &x25519_public(&alice)),
&alice,
"acc_alice",
&trusted(),
)
.unwrap();
let kb = unwrap_org_key(
&wrap_by_granter(&k_org, "acme", 1, "acc_bob", &x25519_public(&bob)),
&bob,
"acc_bob",
&trusted(),
)
.unwrap();
assert_eq!(ka, kb);
assert_eq!(
derive_key(&ka, "org:acme/epoch:1"),
derive_key(&kb, "org:acme/epoch:1")
);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::oplog::{logical_clock, verify_log, DeviceLog, Surface};
use serde_json::json;
#[test]
fn personal_envelope_omits_kid_on_the_wire() {
let cipher = LocalKeyCipher::generate();
let env = cipher.encrypt(&json!({"x": 1})).unwrap();
let obj = env.as_object().unwrap();
assert!(!obj.contains_key("kid"), "personal envelope must omit kid");
assert_eq!(
obj.keys()
.cloned()
.collect::<std::collections::BTreeSet<_>>(),
["car_enc", "ct", "nonce"]
.into_iter()
.map(String::from)
.collect::<std::collections::BTreeSet<_>>(),
"exactly the legacy three fields"
);
}
#[test]
fn multi_epoch_org_cipher_selects_by_kid_and_fails_closed() {
let mut keys = std::collections::BTreeMap::new();
keys.insert(1u64, Zeroizing::new([1u8; 32]));
keys.insert(2u64, Zeroizing::new([2u8; 32]));
let cipher = MultiEpochOrgCipher::new("org:acme", keys);
let msg = json!({"shared": "brain"});
let env = cipher.encrypt(&msg).unwrap();
assert_eq!(
env.get("kid").and_then(|v| v.as_u64()),
Some(2),
"encrypts under the newest epoch"
);
assert_eq!(cipher.decrypt(&env).unwrap(), msg);
let mut only1 = std::collections::BTreeMap::new();
only1.insert(1u64, Zeroizing::new([1u8; 32]));
let e1 = MultiEpochOrgCipher::new("org:acme", only1);
assert!(matches!(e1.decrypt(&env), Err(CryptoError::Key(_))));
}
#[test]
fn org_cipher_aad_binds_epoch_and_audience() {
let mut keys = std::collections::BTreeMap::new();
keys.insert(5u64, Zeroizing::new([5u8; 32]));
let acme = MultiEpochOrgCipher::new("org:acme", keys.clone());
let env = acme.encrypt(&json!({"m": 1})).unwrap();
let globex = MultiEpochOrgCipher::new("org:globex", keys);
assert!(matches!(globex.decrypt(&env), Err(CryptoError::Decrypt)));
let mut two = std::collections::BTreeMap::new();
two.insert(5u64, Zeroizing::new([5u8; 32]));
two.insert(6u64, Zeroizing::new([5u8; 32])); let acme2 = MultiEpochOrgCipher::new("org:acme", two);
let mut tampered = env.clone();
tampered["kid"] = json!(6);
assert!(matches!(
acme2.decrypt(&tampered),
Err(CryptoError::Decrypt)
));
}
#[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)
));
}
}