use joy_crypt::identity::{Keypair, PublicKey};
use joy_crypt::pairwise::pairwise_kek;
use joy_crypt::wrap;
use rand::RngCore;
use zeroize::Zeroizing;
use crate::error::JoyError;
pub const DEFAULT_ZONE: &str = "default";
pub const FILTER_MAGIC: &[u8; 8] = b"JOYCRYPT";
pub const FILTER_VERSION: u8 = 1;
pub fn encrypt_blob(zone_name: &str, zone_key: &ZoneKey, plaintext: &[u8]) -> Vec<u8> {
let zone_bytes = zone_name.as_bytes();
assert!(
zone_bytes.len() <= 255,
"zone name too long for blob format"
);
let mut nonce = [0u8; 12];
rand::thread_rng().fill_bytes(&mut nonce);
let aad = aad_for(zone_bytes);
let ct = joy_crypt::aead::seal(zone_key.as_bytes(), &nonce, &aad, plaintext)
.expect("AES-256-GCM seal with valid 32-byte key never fails");
let mut out = Vec::with_capacity(8 + 1 + 1 + zone_bytes.len() + 12 + ct.len());
out.extend_from_slice(FILTER_MAGIC);
out.push(FILTER_VERSION);
out.push(zone_bytes.len() as u8);
out.extend_from_slice(zone_bytes);
out.extend_from_slice(&nonce);
out.extend_from_slice(&ct);
out
}
pub fn decrypt_blob(
zone_key_lookup: impl Fn(&str) -> Option<ZoneKey>,
blob: &[u8],
) -> Result<(String, Vec<u8>), JoyError> {
if blob.len() < 8 + 1 + 1 + 12 + 16 || &blob[..8] != FILTER_MAGIC {
return Err(JoyError::AuthFailed("not a Crypt blob".into()));
}
let version = blob[8];
if version != FILTER_VERSION {
return Err(JoyError::AuthFailed(format!(
"unsupported Crypt blob version: {}",
version
)));
}
let zone_len = blob[9] as usize;
let zone_start = 10;
let zone_end = zone_start + zone_len;
if blob.len() < zone_end + 12 + 16 {
return Err(JoyError::AuthFailed("truncated Crypt blob".into()));
}
let zone_name = std::str::from_utf8(&blob[zone_start..zone_end])
.map_err(|_| JoyError::AuthFailed("invalid zone name in Crypt blob".into()))?
.to_string();
let nonce_start = zone_end;
let nonce_end = nonce_start + 12;
let mut nonce = [0u8; 12];
nonce.copy_from_slice(&blob[nonce_start..nonce_end]);
let ct = &blob[nonce_end..];
let zone_key = zone_key_lookup(&zone_name).ok_or_else(|| JoyError::ZoneAccessDenied {
zone: zone_name.clone(),
})?;
let aad = aad_for(zone_name.as_bytes());
let plaintext = joy_crypt::aead::open(zone_key.as_bytes(), &nonce, &aad, ct)
.map_err(|_| JoyError::AuthFailed(format!("failed to decrypt zone '{}'", zone_name)))?;
Ok((zone_name, plaintext))
}
fn aad_for(zone_bytes: &[u8]) -> Vec<u8> {
let mut aad = Vec::with_capacity(8 + zone_bytes.len());
aad.extend_from_slice(b"JOYBLOB:");
aad.extend_from_slice(zone_bytes);
aad
}
pub fn looks_like_blob(bytes: &[u8]) -> bool {
bytes.len() >= FILTER_MAGIC.len() && &bytes[..FILTER_MAGIC.len()] == FILTER_MAGIC
}
use std::cell::RefCell;
use std::collections::BTreeMap;
thread_local! {
static ACTIVE_ZONE_KEYS: RefCell<BTreeMap<String, [u8; 32]>> =
const { RefCell::new(BTreeMap::new()) };
}
pub fn set_active_zone_keys(keys: BTreeMap<String, [u8; 32]>) {
ACTIVE_ZONE_KEYS.with(|c| *c.borrow_mut() = keys);
}
pub fn clear_active_zone_keys() {
ACTIVE_ZONE_KEYS.with(|c| c.borrow_mut().clear());
}
pub fn active_zone_key(zone: &str) -> Option<ZoneKey> {
ACTIVE_ZONE_KEYS.with(|c| {
c.borrow()
.get(zone)
.map(|bytes| ZoneKey::from_bytes(*bytes))
})
}
pub fn has_active_zone_keys() -> bool {
ACTIVE_ZONE_KEYS.with(|c| !c.borrow().is_empty())
}
pub struct ZoneKey(Zeroizing<[u8; 32]>);
impl std::fmt::Debug for ZoneKey {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("ZoneKey(***)")
}
}
impl ZoneKey {
pub fn generate() -> Self {
let mut bytes = Zeroizing::new([0u8; 32]);
rand::thread_rng().fill_bytes(bytes.as_mut());
Self(bytes)
}
pub fn from_bytes(bytes: [u8; 32]) -> Self {
Self(Zeroizing::new(bytes))
}
pub fn as_bytes(&self) -> &[u8; 32] {
&self.0
}
}
fn wrap_info(zone_name: &str) -> Vec<u8> {
let mut info = Vec::with_capacity(11 + zone_name.len());
info.extend_from_slice(b"crypt-zone:");
info.extend_from_slice(zone_name.as_bytes());
info
}
pub fn wrap_for_member(
zone_key: &ZoneKey,
zone_name: &str,
granter_seed: &[u8; 32],
granter_verify_key: &PublicKey,
recipient_verify_key: &PublicKey,
) -> String {
let granter_kp = Keypair::from_seed(granter_seed);
let granter_secret = granter_kp.to_x25519_secret_bytes();
let recipient_x25519 = recipient_verify_key.to_x25519_public_bytes();
let info = wrap_info(zone_name);
let kek = pairwise_kek(&granter_secret, &recipient_x25519, &info);
let inner = wrap::wrap(&kek, zone_key.as_bytes());
let mut out = Vec::with_capacity(32 + inner.len());
out.extend_from_slice(&granter_verify_key.as_bytes());
out.extend_from_slice(&inner);
hex::encode(out)
}
pub fn wrap_for_self(zone_key: &ZoneKey, zone_name: &str, member_seed: &[u8; 32]) -> String {
let kp = Keypair::from_seed(member_seed);
let pk = kp.public_key();
wrap_for_member(zone_key, zone_name, member_seed, &pk, &pk)
}
pub fn unwrap_for_member(
wrap_hex: &str,
zone_name: &str,
recipient_seed: &[u8; 32],
) -> Result<ZoneKey, JoyError> {
let bytes = hex::decode(wrap_hex)
.map_err(|e| JoyError::AuthFailed(format!("invalid crypt wrap: {e}")))?;
if bytes.len() < 32 {
return Err(JoyError::AuthFailed(
"crypt wrap too short to contain granter prefix".into(),
));
}
let mut granter_pk_bytes = [0u8; 32];
granter_pk_bytes.copy_from_slice(&bytes[..32]);
let granter_pk = PublicKey::from_hex(&hex::encode(granter_pk_bytes))?;
let granter_x25519 = granter_pk.to_x25519_public_bytes();
let recipient_kp = Keypair::from_seed(recipient_seed);
let recipient_secret = recipient_kp.to_x25519_secret_bytes();
let info = wrap_info(zone_name);
let kek = pairwise_kek(&recipient_secret, &granter_x25519, &info);
let plain = wrap::unwrap(&kek, &bytes[32..])
.map_err(|_| JoyError::AuthFailed(format!("failed to unwrap zone {zone_name}")))?;
let arr: [u8; 32] = plain.try_into().map_err(|v: Vec<u8>| {
JoyError::AuthFailed(format!("zone key has wrong length: {}", v.len()))
})?;
Ok(ZoneKey::from_bytes(arr))
}
#[cfg(test)]
mod tests {
use super::*;
use joy_crypt::identity::Keypair;
#[test]
fn self_wrap_roundtrip() {
let zk = ZoneKey::generate();
let seed = [42u8; 32];
let wrap_hex = wrap_for_self(&zk, DEFAULT_ZONE, &seed);
let recovered = unwrap_for_member(&wrap_hex, DEFAULT_ZONE, &seed).unwrap();
assert_eq!(zk.as_bytes(), recovered.as_bytes());
}
#[test]
fn cross_member_wrap_roundtrip() {
let zk = ZoneKey::generate();
let granter_seed = [1u8; 32];
let recipient_seed = [2u8; 32];
let granter_pk = Keypair::from_seed(&granter_seed).public_key();
let recipient_pk = Keypair::from_seed(&recipient_seed).public_key();
let wrap_hex =
wrap_for_member(&zk, DEFAULT_ZONE, &granter_seed, &granter_pk, &recipient_pk);
let recovered = unwrap_for_member(&wrap_hex, DEFAULT_ZONE, &recipient_seed).unwrap();
assert_eq!(zk.as_bytes(), recovered.as_bytes());
}
#[test]
fn third_member_cannot_unwrap() {
let zk = ZoneKey::generate();
let granter_seed = [1u8; 32];
let recipient_seed = [2u8; 32];
let intruder_seed = [9u8; 32];
let granter_pk = Keypair::from_seed(&granter_seed).public_key();
let recipient_pk = Keypair::from_seed(&recipient_seed).public_key();
let wrap_hex =
wrap_for_member(&zk, DEFAULT_ZONE, &granter_seed, &granter_pk, &recipient_pk);
let err = unwrap_for_member(&wrap_hex, DEFAULT_ZONE, &intruder_seed).unwrap_err();
assert!(matches!(err, JoyError::AuthFailed(_)));
}
#[test]
fn wrong_zone_rejected() {
let zk = ZoneKey::generate();
let seed = [42u8; 32];
let wrap_hex = wrap_for_self(&zk, "default", &seed);
let err = unwrap_for_member(&wrap_hex, "customer-x", &seed).unwrap_err();
assert!(matches!(err, JoyError::AuthFailed(_)));
}
#[test]
fn truncated_wrap_rejected() {
let bytes = vec![0u8; 16];
let err = unwrap_for_member(&hex::encode(&bytes), DEFAULT_ZONE, &[1u8; 32]).unwrap_err();
assert!(matches!(err, JoyError::AuthFailed(_)));
}
#[test]
fn blob_roundtrip() {
let zk = ZoneKey::generate();
let pt = b"id: JOY-0123\ntitle: secret\n";
let blob = encrypt_blob("default", &zk, pt);
assert!(looks_like_blob(&blob));
let (zone, recovered) = decrypt_blob(
|name| {
if name == "default" {
Some(ZoneKey::from_bytes(*zk.as_bytes()))
} else {
None
}
},
&blob,
)
.unwrap();
assert_eq!(zone, "default");
assert_eq!(recovered, pt);
}
#[test]
fn blob_rejects_wrong_zone_key() {
let zk = ZoneKey::generate();
let blob = encrypt_blob("default", &zk, b"x");
let other = ZoneKey::generate();
let err =
decrypt_blob(|_| Some(ZoneKey::from_bytes(*other.as_bytes())), &blob).unwrap_err();
assert!(matches!(err, JoyError::AuthFailed(_)));
}
#[test]
fn blob_rejects_tampered_zone_name() {
let zk = ZoneKey::generate();
let mut blob = encrypt_blob("default", &zk, b"x");
blob[10] ^= 1;
let zk_clone = ZoneKey::from_bytes(*zk.as_bytes());
let err =
decrypt_blob(|_| Some(ZoneKey::from_bytes(*zk_clone.as_bytes())), &blob).unwrap_err();
assert!(matches!(err, JoyError::AuthFailed(_)));
}
#[test]
fn looks_like_blob_detects_magic() {
assert!(looks_like_blob(b"JOYCRYPT\x01\x07default..."));
assert!(!looks_like_blob(b"id: JOY-0123\n"));
assert!(!looks_like_blob(b""));
}
#[test]
fn passphrase_change_does_not_invalidate_wrap() {
let zk = ZoneKey::generate();
let seed = [7u8; 32];
let wrap_hex = wrap_for_self(&zk, DEFAULT_ZONE, &seed);
let a = unwrap_for_member(&wrap_hex, DEFAULT_ZONE, &seed).unwrap();
let b = unwrap_for_member(&wrap_hex, DEFAULT_ZONE, &seed).unwrap();
assert_eq!(a.as_bytes(), b.as_bytes());
}
}