Skip to main content

joy_core/
crypt.rs

1// Copyright (c) 2026 Joydev GmbH (joydev.com)
2// SPDX-License-Identifier: MIT
3
4//! Crypt zone keys and per-member wraps (ADR-038, Crypt.md).
5//!
6//! Each Crypt zone has one random AES-256-GCM key. Per-member access is
7//! granted by wrapping that key under a KEK derived from a pairwise
8//! X25519 ECDH between the granter's identity and the recipient's
9//! identity (ADR-038, JOY-0157-86). Going through identity material
10//! that is stable across passphrase rotation (ADR-039) means
11//! passphrase changes do not invalidate Crypt access.
12//!
13//! Wrap format on disk: hex-encoded
14//! `granter_verify_key (32 bytes) || nonce (12) || ciphertext || tag (16)`.
15//!
16//! Self-wrap (auto-create on `joy crypt add`) is the special case where
17//! granter and recipient are the same member; the wrap format is
18//! identical so the unwrap path is uniform.
19
20use 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
28/// Conventional name of the implicit default zone.
29pub const DEFAULT_ZONE: &str = "default";
30
31/// Magic prefix for Crypt-encrypted file blobs (Git filter format).
32pub const FILTER_MAGIC: &[u8; 8] = b"JOYCRYPT";
33/// On-disk blob format version. Bump on incompatible changes.
34pub const FILTER_VERSION: u8 = 1;
35
36/// Encrypt content for a zone in the Git-filter blob format
37/// (JOY-014B-09):
38///
39/// `JOYCRYPT || version (1) || zone-name-len (1) || zone-name ||
40///  nonce (12) || ciphertext+tag`.
41///
42/// The magic header is required so the filter (`joy crypt-filter clean`)
43/// can refuse to operate on plaintext that was already encrypted, and
44/// the textconv path can short-circuit on already-plaintext history
45/// objects.
46pub 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
68/// Inverse of `encrypt_blob`. Returns `(zone_name, plaintext)`.
69pub 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
107/// AAD binds the ciphertext to its zone-name so a wrap from one zone
108/// can't be replayed under another.
109fn 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
116/// Quick check for whether a byte slice begins with the Crypt blob
117/// magic. Read paths use this to short-circuit when content is
118/// already plaintext (item not encrypted, or file outside any zone).
119pub fn looks_like_blob(bytes: &[u8]) -> bool {
120    bytes.len() >= FILTER_MAGIC.len() && &bytes[..FILTER_MAGIC.len()] == FILTER_MAGIC
121}
122
123// =====================================================================
124// Active-session zone-key context (ADR-040)
125//
126// Joy CLI commands that authenticate up front (passphrase prompt or
127// JOY_PASSPHRASE) populate a thread-local map of decrypted zone keys
128// before reading items. joy-core's read paths consult this map when
129// they encounter a JOYCRYPT blob; when the relevant zone is absent,
130// the call returns JoyError::ZoneAccessDenied. Secrets are wiped on
131// `clear_active_zone_keys` (typically a Drop guard at end of command).
132// =====================================================================
133
134use 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
142/// Replace the thread-local active zone-keys with the given map.
143/// Typically called once per joy command after passphrase verification.
144pub fn set_active_zone_keys(keys: BTreeMap<String, [u8; 32]>) {
145    ACTIVE_ZONE_KEYS.with(|c| *c.borrow_mut() = keys);
146}
147
148/// Wipe the thread-local active zone-keys. Call at the end of a
149/// command to ensure no plaintext key material outlives the process
150/// (Drop in main.rs covers normal exit).
151pub fn clear_active_zone_keys() {
152    ACTIVE_ZONE_KEYS.with(|c| c.borrow_mut().clear());
153}
154
155/// Look up an active zone key. Used by joy-core's read path when
156/// decrypting a JOYCRYPT blob.
157pub 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
165/// Whether any zone key is currently active. Useful for joy-cli to
166/// decide whether to prompt for passphrase before reading items.
167pub fn has_active_zone_keys() -> bool {
168    ACTIVE_ZONE_KEYS.with(|c| !c.borrow().is_empty())
169}
170
171/// 32-byte AES-256-GCM key for a Crypt zone.
172pub 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
196/// Derive the HKDF info string for a zone-key wrap. Binds the wrap to
197/// the zone name so distinct zones produce distinct KEKs.
198fn 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
205/// Wrap a zone key for a recipient. The granter contributes their
206/// X25519 secret (derived from their identity seed); the recipient is
207/// addressed by their Ed25519 verify_key. Self-wrap is a special case
208/// where granter and recipient identify the same member.
209///
210/// Returns the hex-encoded wrap.
211pub 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
231/// Convenience wrapper: self-wrap produced by a member for themselves.
232pub 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
238/// Unwrap a zone key. Reads the granter's verify_key from the wrap
239/// header, derives the same pairwise KEK on the recipient side, and
240/// decrypts the inner blob.
241pub 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        // Flip a byte inside the zone name (offset 10).
366        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        // The wrap is keyed by the recipient's seed, which under
383        // ADR-039 is stable across passphrase rotation. Simulate by
384        // unwrapping with the same seed twice.
385        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}