1use joy_crypt::identity::{Keypair, PublicKey};
21use joy_crypt::pairwise::pairwise_kek;
22use joy_crypt::wrap;
23use rand::RngCore;
24use zeroize::Zeroizing;
25
26use crate::error::JoyError;
27
28pub const DEFAULT_ZONE: &str = "default";
30
31pub const FILTER_MAGIC: &[u8; 8] = b"JOYCRYPT";
33pub const FILTER_VERSION: u8 = 1;
35
36pub fn encrypt_blob(zone_name: &str, zone_key: &ZoneKey, plaintext: &[u8]) -> Vec<u8> {
47 let zone_bytes = zone_name.as_bytes();
48 assert!(
49 zone_bytes.len() <= 255,
50 "zone name too long for blob format"
51 );
52 let mut nonce = [0u8; 12];
53 rand::thread_rng().fill_bytes(&mut nonce);
54 let aad = aad_for(zone_bytes);
55 let ct = joy_crypt::aead::seal(zone_key.as_bytes(), &nonce, &aad, plaintext)
56 .expect("AES-256-GCM seal with valid 32-byte key never fails");
57
58 let mut out = Vec::with_capacity(8 + 1 + 1 + zone_bytes.len() + 12 + ct.len());
59 out.extend_from_slice(FILTER_MAGIC);
60 out.push(FILTER_VERSION);
61 out.push(zone_bytes.len() as u8);
62 out.extend_from_slice(zone_bytes);
63 out.extend_from_slice(&nonce);
64 out.extend_from_slice(&ct);
65 out
66}
67
68pub fn decrypt_blob(
70 zone_key_lookup: impl Fn(&str) -> Option<ZoneKey>,
71 blob: &[u8],
72) -> Result<(String, Vec<u8>), JoyError> {
73 if blob.len() < 8 + 1 + 1 + 12 + 16 || &blob[..8] != FILTER_MAGIC {
74 return Err(JoyError::AuthFailed("not a Crypt blob".into()));
75 }
76 let version = blob[8];
77 if version != FILTER_VERSION {
78 return Err(JoyError::AuthFailed(format!(
79 "unsupported Crypt blob version: {}",
80 version
81 )));
82 }
83 let zone_len = blob[9] as usize;
84 let zone_start = 10;
85 let zone_end = zone_start + zone_len;
86 if blob.len() < zone_end + 12 + 16 {
87 return Err(JoyError::AuthFailed("truncated Crypt blob".into()));
88 }
89 let zone_name = std::str::from_utf8(&blob[zone_start..zone_end])
90 .map_err(|_| JoyError::AuthFailed("invalid zone name in Crypt blob".into()))?
91 .to_string();
92 let nonce_start = zone_end;
93 let nonce_end = nonce_start + 12;
94 let mut nonce = [0u8; 12];
95 nonce.copy_from_slice(&blob[nonce_start..nonce_end]);
96 let ct = &blob[nonce_end..];
97
98 let zone_key = zone_key_lookup(&zone_name).ok_or_else(|| JoyError::ZoneAccessDenied {
99 zone: zone_name.clone(),
100 })?;
101 let aad = aad_for(zone_name.as_bytes());
102 let plaintext = joy_crypt::aead::open(zone_key.as_bytes(), &nonce, &aad, ct)
103 .map_err(|_| JoyError::AuthFailed(format!("failed to decrypt zone '{}'", zone_name)))?;
104 Ok((zone_name, plaintext))
105}
106
107fn aad_for(zone_bytes: &[u8]) -> Vec<u8> {
110 let mut aad = Vec::with_capacity(8 + zone_bytes.len());
111 aad.extend_from_slice(b"JOYBLOB:");
112 aad.extend_from_slice(zone_bytes);
113 aad
114}
115
116pub fn looks_like_blob(bytes: &[u8]) -> bool {
120 bytes.len() >= FILTER_MAGIC.len() && &bytes[..FILTER_MAGIC.len()] == FILTER_MAGIC
121}
122
123use std::cell::RefCell;
135use std::collections::BTreeMap;
136
137thread_local! {
138 static ACTIVE_ZONE_KEYS: RefCell<BTreeMap<String, [u8; 32]>> =
139 const { RefCell::new(BTreeMap::new()) };
140}
141
142pub fn set_active_zone_keys(keys: BTreeMap<String, [u8; 32]>) {
145 ACTIVE_ZONE_KEYS.with(|c| *c.borrow_mut() = keys);
146}
147
148pub fn clear_active_zone_keys() {
152 ACTIVE_ZONE_KEYS.with(|c| c.borrow_mut().clear());
153}
154
155pub fn active_zone_key(zone: &str) -> Option<ZoneKey> {
158 ACTIVE_ZONE_KEYS.with(|c| {
159 c.borrow()
160 .get(zone)
161 .map(|bytes| ZoneKey::from_bytes(*bytes))
162 })
163}
164
165pub fn has_active_zone_keys() -> bool {
168 ACTIVE_ZONE_KEYS.with(|c| !c.borrow().is_empty())
169}
170
171pub struct ZoneKey(Zeroizing<[u8; 32]>);
173
174impl std::fmt::Debug for ZoneKey {
175 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
176 f.write_str("ZoneKey(***)")
177 }
178}
179
180impl ZoneKey {
181 pub fn generate() -> Self {
182 let mut bytes = Zeroizing::new([0u8; 32]);
183 rand::thread_rng().fill_bytes(bytes.as_mut());
184 Self(bytes)
185 }
186
187 pub fn from_bytes(bytes: [u8; 32]) -> Self {
188 Self(Zeroizing::new(bytes))
189 }
190
191 pub fn as_bytes(&self) -> &[u8; 32] {
192 &self.0
193 }
194}
195
196fn wrap_info(zone_name: &str) -> Vec<u8> {
199 let mut info = Vec::with_capacity(11 + zone_name.len());
200 info.extend_from_slice(b"crypt-zone:");
201 info.extend_from_slice(zone_name.as_bytes());
202 info
203}
204
205pub fn wrap_for_member(
212 zone_key: &ZoneKey,
213 zone_name: &str,
214 granter_seed: &[u8; 32],
215 granter_verify_key: &PublicKey,
216 recipient_verify_key: &PublicKey,
217) -> String {
218 let granter_kp = Keypair::from_seed(granter_seed);
219 let granter_secret = granter_kp.to_x25519_secret_bytes();
220 let recipient_x25519 = recipient_verify_key.to_x25519_public_bytes();
221 let info = wrap_info(zone_name);
222 let kek = pairwise_kek(&granter_secret, &recipient_x25519, &info);
223
224 let inner = wrap::wrap(&kek, zone_key.as_bytes());
225 let mut out = Vec::with_capacity(32 + inner.len());
226 out.extend_from_slice(&granter_verify_key.as_bytes());
227 out.extend_from_slice(&inner);
228 hex::encode(out)
229}
230
231pub fn wrap_for_self(zone_key: &ZoneKey, zone_name: &str, member_seed: &[u8; 32]) -> String {
233 let kp = Keypair::from_seed(member_seed);
234 let pk = kp.public_key();
235 wrap_for_member(zone_key, zone_name, member_seed, &pk, &pk)
236}
237
238pub fn unwrap_for_member(
242 wrap_hex: &str,
243 zone_name: &str,
244 recipient_seed: &[u8; 32],
245) -> Result<ZoneKey, JoyError> {
246 let bytes = hex::decode(wrap_hex)
247 .map_err(|e| JoyError::AuthFailed(format!("invalid crypt wrap: {e}")))?;
248 if bytes.len() < 32 {
249 return Err(JoyError::AuthFailed(
250 "crypt wrap too short to contain granter prefix".into(),
251 ));
252 }
253 let mut granter_pk_bytes = [0u8; 32];
254 granter_pk_bytes.copy_from_slice(&bytes[..32]);
255 let granter_pk = PublicKey::from_hex(&hex::encode(granter_pk_bytes))?;
256 let granter_x25519 = granter_pk.to_x25519_public_bytes();
257
258 let recipient_kp = Keypair::from_seed(recipient_seed);
259 let recipient_secret = recipient_kp.to_x25519_secret_bytes();
260 let info = wrap_info(zone_name);
261 let kek = pairwise_kek(&recipient_secret, &granter_x25519, &info);
262
263 let plain = wrap::unwrap(&kek, &bytes[32..])
264 .map_err(|_| JoyError::AuthFailed(format!("failed to unwrap zone {zone_name}")))?;
265 let arr: [u8; 32] = plain.try_into().map_err(|v: Vec<u8>| {
266 JoyError::AuthFailed(format!("zone key has wrong length: {}", v.len()))
267 })?;
268 Ok(ZoneKey::from_bytes(arr))
269}
270
271#[cfg(test)]
272mod tests {
273 use super::*;
274 use joy_crypt::identity::Keypair;
275
276 #[test]
277 fn self_wrap_roundtrip() {
278 let zk = ZoneKey::generate();
279 let seed = [42u8; 32];
280 let wrap_hex = wrap_for_self(&zk, DEFAULT_ZONE, &seed);
281 let recovered = unwrap_for_member(&wrap_hex, DEFAULT_ZONE, &seed).unwrap();
282 assert_eq!(zk.as_bytes(), recovered.as_bytes());
283 }
284
285 #[test]
286 fn cross_member_wrap_roundtrip() {
287 let zk = ZoneKey::generate();
288 let granter_seed = [1u8; 32];
289 let recipient_seed = [2u8; 32];
290 let granter_pk = Keypair::from_seed(&granter_seed).public_key();
291 let recipient_pk = Keypair::from_seed(&recipient_seed).public_key();
292
293 let wrap_hex =
294 wrap_for_member(&zk, DEFAULT_ZONE, &granter_seed, &granter_pk, &recipient_pk);
295 let recovered = unwrap_for_member(&wrap_hex, DEFAULT_ZONE, &recipient_seed).unwrap();
296 assert_eq!(zk.as_bytes(), recovered.as_bytes());
297 }
298
299 #[test]
300 fn third_member_cannot_unwrap() {
301 let zk = ZoneKey::generate();
302 let granter_seed = [1u8; 32];
303 let recipient_seed = [2u8; 32];
304 let intruder_seed = [9u8; 32];
305 let granter_pk = Keypair::from_seed(&granter_seed).public_key();
306 let recipient_pk = Keypair::from_seed(&recipient_seed).public_key();
307
308 let wrap_hex =
309 wrap_for_member(&zk, DEFAULT_ZONE, &granter_seed, &granter_pk, &recipient_pk);
310 let err = unwrap_for_member(&wrap_hex, DEFAULT_ZONE, &intruder_seed).unwrap_err();
311 assert!(matches!(err, JoyError::AuthFailed(_)));
312 }
313
314 #[test]
315 fn wrong_zone_rejected() {
316 let zk = ZoneKey::generate();
317 let seed = [42u8; 32];
318 let wrap_hex = wrap_for_self(&zk, "default", &seed);
319 let err = unwrap_for_member(&wrap_hex, "customer-x", &seed).unwrap_err();
320 assert!(matches!(err, JoyError::AuthFailed(_)));
321 }
322
323 #[test]
324 fn truncated_wrap_rejected() {
325 let bytes = vec![0u8; 16];
326 let err = unwrap_for_member(&hex::encode(&bytes), DEFAULT_ZONE, &[1u8; 32]).unwrap_err();
327 assert!(matches!(err, JoyError::AuthFailed(_)));
328 }
329
330 #[test]
331 fn blob_roundtrip() {
332 let zk = ZoneKey::generate();
333 let pt = b"id: JOY-0123\ntitle: secret\n";
334 let blob = encrypt_blob("default", &zk, pt);
335 assert!(looks_like_blob(&blob));
336 let (zone, recovered) = decrypt_blob(
337 |name| {
338 if name == "default" {
339 Some(ZoneKey::from_bytes(*zk.as_bytes()))
340 } else {
341 None
342 }
343 },
344 &blob,
345 )
346 .unwrap();
347 assert_eq!(zone, "default");
348 assert_eq!(recovered, pt);
349 }
350
351 #[test]
352 fn blob_rejects_wrong_zone_key() {
353 let zk = ZoneKey::generate();
354 let blob = encrypt_blob("default", &zk, b"x");
355 let other = ZoneKey::generate();
356 let err =
357 decrypt_blob(|_| Some(ZoneKey::from_bytes(*other.as_bytes())), &blob).unwrap_err();
358 assert!(matches!(err, JoyError::AuthFailed(_)));
359 }
360
361 #[test]
362 fn blob_rejects_tampered_zone_name() {
363 let zk = ZoneKey::generate();
364 let mut blob = encrypt_blob("default", &zk, b"x");
365 blob[10] ^= 1;
367 let zk_clone = ZoneKey::from_bytes(*zk.as_bytes());
368 let err =
369 decrypt_blob(|_| Some(ZoneKey::from_bytes(*zk_clone.as_bytes())), &blob).unwrap_err();
370 assert!(matches!(err, JoyError::AuthFailed(_)));
371 }
372
373 #[test]
374 fn looks_like_blob_detects_magic() {
375 assert!(looks_like_blob(b"JOYCRYPT\x01\x07default..."));
376 assert!(!looks_like_blob(b"id: JOY-0123\n"));
377 assert!(!looks_like_blob(b""));
378 }
379
380 #[test]
381 fn passphrase_change_does_not_invalidate_wrap() {
382 let zk = ZoneKey::generate();
386 let seed = [7u8; 32];
387 let wrap_hex = wrap_for_self(&zk, DEFAULT_ZONE, &seed);
388 let a = unwrap_for_member(&wrap_hex, DEFAULT_ZONE, &seed).unwrap();
389 let b = unwrap_for_member(&wrap_hex, DEFAULT_ZONE, &seed).unwrap();
390 assert_eq!(a.as_bytes(), b.as_bytes());
391 }
392}