Skip to main content

quantum_box/
keys.rs

1use base64::Engine;
2use base64::engine::general_purpose::STANDARD_NO_PAD;
3use hpke::{Deserializable, Kem as KemTrait, Serializable};
4use zeroize::{ZeroizeOnDrop, Zeroizing};
5
6use crate::{Error, XKem, rng};
7
8/// The private key used for key encapsulation and encryption.
9///
10/// The X-Wing KEM is used which uses ML-KEM-768 (Kyber) and X25519 under
11/// the hood.
12#[derive(Clone, PartialEq, Eq)]
13pub struct SecretKey(<XKem as KemTrait>::PrivateKey);
14
15/// `SecretKey` wraps hpke's X-Wing `PrivateKey`, which stores an
16/// `x_wing::DecapsulationKey`. That key implements `ZeroizeOnDrop`, so
17/// dropping a `SecretKey` wipes the only secret it holds.
18impl ZeroizeOnDrop for SecretKey {}
19
20/// Statically assert the guarantee the `ZeroizeOnDrop` impl above relies on
21const _: fn() = || {
22    fn assert_zeroize_on_drop<T: ZeroizeOnDrop>() {}
23    assert_zeroize_on_drop::<x_wing::DecapsulationKey>();
24};
25
26/// The public component of the encapsulation key. This is usually the key
27/// of the recipient.
28///
29/// For X-Wing, the key is 1216 bytes (1184 bytes for ML-KEM-768 and 32 bytes for X25519).
30#[derive(Clone, Debug, PartialEq, Eq)]
31pub struct PublicKey(<XKem as KemTrait>::PublicKey);
32
33impl SecretKey {
34    /// Construct a secret key from a raw 32-byte X-Wing seed.
35    #[must_use]
36    pub fn from_seed(seed: &[u8; 32]) -> Self {
37        let Ok(sk) = <XKem as KemTrait>::PrivateKey::from_bytes(seed) else {
38            unreachable!("a 32-byte array is always a valid X-Wing seed")
39        };
40        Self(sk)
41    }
42
43    /// Generate a new secret key from the operating system CSPRNG.
44    ///
45    /// The randomness source is owned by the library rather than caller-supplied:
46    /// a deterministic or repeated RNG would produce a predictable or duplicated
47    /// key seed, exposing the secret key. For deterministic derivation from a
48    /// known seed, use [`SecretKey::from_seed`].
49    ///
50    /// # Errors
51    /// [`Error::Rng`] if the operating system CSPRNG is unavailable.
52    pub fn generate() -> Result<Self, Error> {
53        let mut seed = Zeroizing::new([0u8; 32]);
54        rng::fill(&mut seed[..])?;
55        Ok(Self::from_seed(&seed))
56    }
57
58    pub(crate) fn as_hpke(&self) -> &<XKem as KemTrait>::PrivateKey {
59        &self.0
60    }
61
62    /// Derive the corresponding [`PublicKey`].
63    #[must_use]
64    pub fn public_key(&self) -> PublicKey {
65        PublicKey::new(<XKem as KemTrait>::sk_to_pk(&self.0))
66    }
67}
68
69impl std::fmt::Debug for SecretKey {
70    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
71        f.write_str("SecretKey(REDACTED)")
72    }
73}
74
75impl PublicKey {
76    pub(crate) fn new(pk: <XKem as KemTrait>::PublicKey) -> Self {
77        Self(pk)
78    }
79
80    pub(crate) fn as_hpke(&self) -> &<XKem as KemTrait>::PublicKey {
81        &self.0
82    }
83
84    /// The raw 1216-byte encapsulation key.
85    #[must_use]
86    pub fn to_bytes(&self) -> Vec<u8> {
87        self.0.to_bytes().as_slice().to_vec()
88    }
89
90    /// Parse a raw 1216-byte encapsulation key.
91    ///
92    /// # Errors
93    /// Returns [`Error::KeyFormat`] if the length is wrong or the key is invalid.
94    pub fn from_bytes(bytes: &[u8]) -> Result<Self, Error> {
95        <XKem as KemTrait>::PublicKey::from_bytes(bytes)
96            .map(Self)
97            .map_err(|_| Error::KeyFormat)
98    }
99}
100
101impl std::fmt::Display for PublicKey {
102    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
103        f.write_str(&STANDARD_NO_PAD.encode(self.to_bytes()))
104    }
105}
106
107#[expect(clippy::unwrap_used, reason = "clearer in tests")]
108#[cfg(test)]
109mod tests {
110    use super::{Error, PublicKey, SecretKey};
111    use base64::Engine;
112    use base64::engine::general_purpose::STANDARD_NO_PAD;
113    use std::collections::HashSet;
114
115    /// X-Wing encapsulation key size: ML-KEM-768 (1184) + X25519 (32).
116    const PUBLIC_KEY_LEN: usize = 1216;
117
118    #[test]
119    fn from_seed_is_deterministic() {
120        let a = SecretKey::from_seed(&[5u8; 32]);
121        let b = SecretKey::from_seed(&[5u8; 32]);
122
123        assert_eq!(a, b);
124        assert_eq!(a.public_key(), b.public_key());
125    }
126
127    #[test]
128    fn different_seeds_produce_different_keys() {
129        let a = SecretKey::from_seed(&[1u8; 32]);
130        let b = SecretKey::from_seed(&[2u8; 32]);
131
132        assert_ne!(a.public_key(), b.public_key());
133    }
134
135    #[test]
136    fn generate_produces_distinct_keys() {
137        let mut seen = HashSet::new();
138        for _ in 0..32 {
139            let pk = SecretKey::generate().unwrap().public_key();
140            assert!(seen.insert(pk.to_bytes()), "generated key seed collided");
141        }
142        assert_eq!(seen.len(), 32);
143    }
144
145    #[test]
146    fn public_key_bytes_roundtrip() {
147        let pk = SecretKey::from_seed(&[2u8; 32]).public_key();
148
149        let bytes = pk.to_bytes();
150        assert_eq!(bytes.len(), PUBLIC_KEY_LEN);
151        let Ok(parsed) = PublicKey::from_bytes(&bytes) else {
152            unreachable!("bytes produced by to_bytes must parse back")
153        };
154        assert_eq!(parsed, pk);
155    }
156
157    #[test]
158    fn from_bytes_rejects_wrong_length() {
159        assert_eq!(PublicKey::from_bytes(&[]), Err(Error::KeyFormat));
160        assert_eq!(PublicKey::from_bytes(&[0u8; 32]), Err(Error::KeyFormat));
161        assert_eq!(
162            PublicKey::from_bytes(&[0u8; PUBLIC_KEY_LEN + 1]),
163            Err(Error::KeyFormat)
164        );
165    }
166
167    #[test]
168    fn debug_redacts_secret() {
169        let sk = SecretKey::from_seed(&[42u8; 32]);
170
171        assert_eq!(format!("{sk:?}"), "SecretKey(REDACTED)");
172    }
173
174    #[test]
175    fn display_encodes_public_key_as_base64() {
176        let pk = SecretKey::from_seed(&[1u8; 32]).public_key();
177
178        let Ok(decoded) = STANDARD_NO_PAD.decode(pk.to_string()) else {
179            unreachable!("Display output must be valid base64")
180        };
181
182        assert_eq!(decoded, pk.to_bytes());
183    }
184}