Skip to main content

joy_crypt/
wrap.rs

1//! Seed and key wrapping under a KEK.
2//!
3//! `wrap(kek, plaintext)` generates a random 12-byte nonce, encrypts via
4//! AES-256-GCM with empty AAD, and returns `nonce || ciphertext || tag`.
5//! `unwrap` reads the nonce prefix and decrypts. Used for the wrapped-seed
6//! Auth model (per ADR-039) and per-member zone-key wraps.
7
8use rand::RngCore;
9
10use crate::aead;
11use crate::Error;
12
13const NONCE_LEN: usize = 12;
14const TAG_LEN: usize = 16;
15
16/// Wrap a plaintext blob under a KEK. Output layout: 12-byte nonce ||
17/// ciphertext || 16-byte tag (the tag is appended by AES-GCM, so the total
18/// length is `12 + plaintext.len() + 16`).
19pub fn wrap(kek: &[u8; 32], plaintext: &[u8]) -> Vec<u8> {
20    let mut nonce = [0u8; NONCE_LEN];
21    rand::thread_rng().fill_bytes(&mut nonce);
22    let ct = aead::seal(kek, &nonce, &[], plaintext)
23        .expect("AES-256-GCM seal with valid key never fails");
24    let mut out = Vec::with_capacity(NONCE_LEN + ct.len());
25    out.extend_from_slice(&nonce);
26    out.extend_from_slice(&ct);
27    out
28}
29
30/// Unwrap a `wrap`ped blob. Verifies the auth tag and returns the
31/// plaintext on success.
32pub fn unwrap(kek: &[u8; 32], wrapped: &[u8]) -> Result<Vec<u8>, Error> {
33    if wrapped.len() < NONCE_LEN + TAG_LEN {
34        return Err(Error::InvalidLength {
35            expected: NONCE_LEN + TAG_LEN,
36            got: wrapped.len(),
37        });
38    }
39    let (nonce_bytes, ct) = wrapped.split_at(NONCE_LEN);
40    let nonce: [u8; NONCE_LEN] = nonce_bytes
41        .try_into()
42        .expect("split_at(NONCE_LEN) yields NONCE_LEN bytes");
43    aead::open(kek, &nonce, &[], ct)
44}
45
46#[cfg(test)]
47mod tests {
48    use super::*;
49
50    fn kek() -> [u8; 32] {
51        [11u8; 32]
52    }
53
54    #[test]
55    fn roundtrip() {
56        let k = kek();
57        let pt = b"identity seed material";
58        let w = wrap(&k, pt);
59        let unwrapped = unwrap(&k, &w).unwrap();
60        assert_eq!(unwrapped, pt);
61    }
62
63    #[test]
64    fn nonce_is_random_each_call() {
65        let k = kek();
66        let a = wrap(&k, b"same plaintext");
67        let b = wrap(&k, b"same plaintext");
68        assert_ne!(a, b, "random nonce should produce distinct ciphertexts");
69    }
70
71    #[test]
72    fn wrong_kek_rejected() {
73        let pt = b"secret";
74        let w = wrap(&[1u8; 32], pt);
75        assert!(matches!(unwrap(&[2u8; 32], &w).unwrap_err(), Error::Aead));
76    }
77
78    #[test]
79    fn truncated_wrap_rejected() {
80        let short = vec![0u8; NONCE_LEN + TAG_LEN - 1];
81        assert!(matches!(
82            unwrap(&kek(), &short).unwrap_err(),
83            Error::InvalidLength { .. }
84        ));
85    }
86
87    #[test]
88    fn tampered_ciphertext_rejected() {
89        let k = kek();
90        let mut w = wrap(&k, b"secret");
91        let last = w.len() - 1;
92        w[last] ^= 0x01;
93        assert!(matches!(unwrap(&k, &w).unwrap_err(), Error::Aead));
94    }
95
96    #[test]
97    fn empty_plaintext_roundtrips() {
98        let k = kek();
99        let w = wrap(&k, b"");
100        let pt = unwrap(&k, &w).unwrap();
101        assert!(pt.is_empty());
102    }
103}