Skip to main content

uselesskey_hmac/
secret.rs

1use std::fmt;
2use std::sync::Arc;
3
4use rand_chacha10::ChaCha20Rng;
5use rand_core10::{Rng, SeedableRng};
6use uselesskey_core::Factory;
7#[cfg(feature = "jwk")]
8use uselesskey_core_kid::kid_from_bytes;
9
10use crate::HmacSpec;
11
12/// Cache domain for HMAC secret fixtures.
13///
14/// Keep this stable: changing it changes deterministic outputs.
15pub const DOMAIN_HMAC_SECRET: &str = "uselesskey:hmac:secret";
16
17/// An HMAC secret fixture.
18///
19/// Created via [`HmacFactoryExt::hmac()`]. Provides access to raw secret bytes
20/// and JWK output (with the `jwk` feature).
21///
22/// # Examples
23///
24/// ```
25/// use uselesskey_core::Factory;
26/// use uselesskey_hmac::{HmacFactoryExt, HmacSpec};
27///
28/// let fx = Factory::random();
29/// let secret = fx.hmac("jwt-signing", HmacSpec::hs256());
30///
31/// assert_eq!(secret.secret_bytes().len(), 32);
32/// ```
33#[derive(Clone)]
34pub struct HmacSecret {
35    factory: Factory,
36    label: String,
37    spec: HmacSpec,
38    inner: Arc<Inner>,
39}
40
41struct Inner {
42    secret: Arc<[u8]>,
43}
44
45impl fmt::Debug for HmacSecret {
46    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
47        f.debug_struct("HmacSecret")
48            .field("label", &self.label)
49            .field("spec", &self.spec)
50            .finish_non_exhaustive()
51    }
52}
53
54/// Extension trait to hang HMAC helpers off the core [`Factory`].
55pub trait HmacFactoryExt {
56    /// Generate (or retrieve from cache) an HMAC secret fixture.
57    ///
58    /// The `label` identifies this secret within your test suite.
59    /// In deterministic mode, `seed + label + spec` always produces the same secret.
60    ///
61    /// # Examples
62    ///
63    /// ```
64    /// use uselesskey_core::{Factory, Seed};
65    /// use uselesskey_hmac::{HmacFactoryExt, HmacSpec};
66    ///
67    /// let seed = Seed::from_env_value("test-seed").unwrap();
68    /// let fx = Factory::deterministic(seed);
69    /// let secret = fx.hmac("jwt-signing", HmacSpec::hs256());
70    ///
71    /// // Same seed + label + spec = same secret
72    /// let secret2 = fx.hmac("jwt-signing", HmacSpec::hs256());
73    /// assert_eq!(secret.secret_bytes(), secret2.secret_bytes());
74    /// ```
75    fn hmac(&self, label: impl AsRef<str>, spec: HmacSpec) -> HmacSecret;
76}
77
78impl HmacFactoryExt for Factory {
79    fn hmac(&self, label: impl AsRef<str>, spec: HmacSpec) -> HmacSecret {
80        HmacSecret::new(self.clone(), label.as_ref(), spec)
81    }
82}
83
84impl HmacSecret {
85    fn new(factory: Factory, label: &str, spec: HmacSpec) -> Self {
86        let inner = load_inner(&factory, label, spec, "good");
87        Self {
88            factory,
89            label: label.to_string(),
90            spec,
91            inner,
92        }
93    }
94
95    #[allow(dead_code)]
96    fn load_variant(&self, variant: &str) -> Arc<Inner> {
97        load_inner(&self.factory, &self.label, self.spec, variant)
98    }
99
100    /// Returns the spec used to create this secret.
101    ///
102    /// # Examples
103    ///
104    /// ```
105    /// # use uselesskey_core::Factory;
106    /// # use uselesskey_hmac::{HmacFactoryExt, HmacSpec};
107    /// let fx = Factory::random();
108    /// let secret = fx.hmac("jwt", HmacSpec::hs256());
109    /// assert_eq!(secret.spec(), HmacSpec::hs256());
110    /// ```
111    pub fn spec(&self) -> HmacSpec {
112        self.spec
113    }
114
115    /// Returns the label used to create this secret.
116    ///
117    /// # Examples
118    ///
119    /// ```
120    /// # use uselesskey_core::Factory;
121    /// # use uselesskey_hmac::{HmacFactoryExt, HmacSpec};
122    /// let fx = Factory::random();
123    /// let secret = fx.hmac("my-jwt", HmacSpec::hs256());
124    /// assert_eq!(secret.label(), "my-jwt");
125    /// ```
126    pub fn label(&self) -> &str {
127        &self.label
128    }
129
130    /// Access raw secret bytes.
131    ///
132    /// # Examples
133    ///
134    /// ```
135    /// # use uselesskey_core::{Factory, Seed};
136    /// # use uselesskey_hmac::{HmacFactoryExt, HmacSpec};
137    /// let fx = Factory::deterministic(Seed::from_env_value("test-seed").unwrap());
138    /// let secret = fx.hmac("jwt-signing", HmacSpec::hs256());
139    /// assert_eq!(secret.secret_bytes().len(), 32);
140    ///
141    /// let secret512 = fx.hmac("jwt-signing", HmacSpec::hs512());
142    /// assert_eq!(secret512.secret_bytes().len(), 64);
143    /// ```
144    pub fn secret_bytes(&self) -> &[u8] {
145        &self.inner.secret
146    }
147
148    /// A stable key identifier derived from the secret bytes (base64url blake3 hash prefix).
149    ///
150    /// # Examples
151    ///
152    /// ```
153    /// # use uselesskey_core::Factory;
154    /// # use uselesskey_hmac::{HmacFactoryExt, HmacSpec};
155    /// let fx = Factory::random();
156    /// let secret = fx.hmac("jwt", HmacSpec::hs256());
157    /// let kid = secret.kid();
158    /// assert!(!kid.is_empty());
159    /// ```
160    #[cfg(feature = "jwk")]
161    pub fn kid(&self) -> String {
162        kid_from_bytes(self.secret_bytes())
163    }
164
165    /// HMAC secret as an octet JWK (kty=oct).
166    ///
167    /// Requires the `jwk` feature.
168    ///
169    /// # Examples
170    ///
171    /// ```
172    /// # use uselesskey_core::Factory;
173    /// # use uselesskey_hmac::{HmacFactoryExt, HmacSpec};
174    /// let fx = Factory::random();
175    /// let secret = fx.hmac("jwt", HmacSpec::hs256());
176    /// let jwk = secret.jwk();
177    /// let val = jwk.to_value();
178    /// assert_eq!(val["kty"], "oct");
179    /// assert_eq!(val["alg"], "HS256");
180    /// ```
181    #[cfg(feature = "jwk")]
182    pub fn jwk(&self) -> uselesskey_jwk::PrivateJwk {
183        use base64::Engine as _;
184        use base64::engine::general_purpose::URL_SAFE_NO_PAD;
185        use uselesskey_jwk::{OctJwk, PrivateJwk};
186
187        let k = URL_SAFE_NO_PAD.encode(self.secret_bytes());
188
189        PrivateJwk::Oct(OctJwk {
190            kty: "oct",
191            use_: "sig",
192            alg: self.spec.alg_name(),
193            kid: self.kid(),
194            k,
195        })
196    }
197
198    /// JWKS containing this HMAC secret as an octet key.
199    ///
200    /// Requires the `jwk` feature.
201    ///
202    /// # Examples
203    ///
204    /// ```
205    /// # use uselesskey_core::Factory;
206    /// # use uselesskey_hmac::{HmacFactoryExt, HmacSpec};
207    /// let fx = Factory::random();
208    /// let secret = fx.hmac("jwt", HmacSpec::hs256());
209    /// let jwks = secret.jwks();
210    /// let val = jwks.to_value();
211    /// assert!(val["keys"].is_array());
212    /// ```
213    #[cfg(feature = "jwk")]
214    pub fn jwks(&self) -> uselesskey_jwk::Jwks {
215        use uselesskey_jwk::JwksBuilder;
216
217        let mut builder = JwksBuilder::new();
218        builder.push_private(self.jwk());
219        builder.build()
220    }
221}
222
223fn load_inner(factory: &Factory, label: &str, spec: HmacSpec, variant: &str) -> Arc<Inner> {
224    let spec_bytes = spec.stable_bytes();
225
226    factory.get_or_init(DOMAIN_HMAC_SECRET, label, &spec_bytes, variant, |seed| {
227        let mut rng = ChaCha20Rng::from_seed(*seed.bytes());
228        let mut buf = vec![0u8; spec.byte_len()];
229        rng.fill_bytes(&mut buf);
230        Inner {
231            secret: Arc::from(buf),
232        }
233    })
234}
235
236#[cfg(test)]
237mod tests {
238    use super::*;
239    use uselesskey_core::Seed;
240
241    #[test]
242    fn secret_length_matches_spec() {
243        let fx = Factory::random();
244        let secret = fx.hmac("test", HmacSpec::hs256());
245        assert_eq!(secret.secret_bytes().len(), 32);
246    }
247
248    #[test]
249    fn deterministic_secret_is_stable() {
250        let fx = Factory::deterministic(Seed::from_env_value("hmac-seed").unwrap());
251        let s1 = fx.hmac("issuer", HmacSpec::hs384());
252        let s2 = fx.hmac("issuer", HmacSpec::hs384());
253        assert_eq!(s1.secret_bytes(), s2.secret_bytes());
254    }
255
256    #[test]
257    fn different_variants_produce_different_secrets() {
258        let fx = Factory::deterministic(Seed::from_env_value("hmac-variant").unwrap());
259        let secret = fx.hmac("issuer", HmacSpec::hs256());
260        let other = secret.load_variant("other");
261
262        assert_ne!(secret.secret_bytes(), other.secret.as_ref());
263    }
264
265    #[test]
266    #[cfg(feature = "jwk")]
267    fn jwk_contains_expected_fields() {
268        let fx = Factory::random();
269        let secret = fx.hmac("jwt", HmacSpec::hs512());
270        let jwk = secret.jwk().to_value();
271
272        assert_eq!(jwk["kty"], "oct");
273        assert_eq!(jwk["alg"], "HS512");
274        assert_eq!(jwk["use"], "sig");
275        assert!(jwk["kid"].is_string());
276        assert!(jwk["k"].is_string());
277    }
278
279    #[test]
280    #[cfg(feature = "jwk")]
281    fn jwk_k_is_base64url() {
282        use base64::Engine as _;
283        use base64::engine::general_purpose::URL_SAFE_NO_PAD;
284
285        let fx = Factory::random();
286        let secret = fx.hmac("jwt", HmacSpec::hs256());
287        let jwk = secret.jwk().to_value();
288
289        let k = jwk["k"].as_str().unwrap();
290        let decoded = URL_SAFE_NO_PAD.decode(k).expect("valid base64url");
291        assert_eq!(decoded.len(), HmacSpec::hs256().byte_len());
292    }
293
294    #[test]
295    #[cfg(feature = "jwk")]
296    fn jwks_wraps_jwk() {
297        let fx = Factory::random();
298        let secret = fx.hmac("jwt", HmacSpec::hs256());
299
300        let jwk = secret.jwk().to_value();
301        let jwks = secret.jwks().to_value();
302
303        let keys = jwks["keys"].as_array().expect("keys array");
304        assert_eq!(keys.len(), 1);
305        assert_eq!(keys[0], jwk);
306    }
307
308    #[test]
309    #[cfg(feature = "jwk")]
310    fn kid_is_deterministic() {
311        let fx = Factory::deterministic(Seed::from_env_value("hmac-kid").unwrap());
312        let s1 = fx.hmac("issuer", HmacSpec::hs512());
313        let s2 = fx.hmac("issuer", HmacSpec::hs512());
314        assert_eq!(s1.kid(), s2.kid());
315    }
316
317    #[test]
318    #[cfg(feature = "jwk")]
319    fn kid_is_not_placeholder_for_any_spec() {
320        let fx = Factory::random();
321
322        for spec in [HmacSpec::hs256(), HmacSpec::hs384(), HmacSpec::hs512()] {
323            let secret = fx.hmac("kid-placeholder", spec);
324            assert_ne!(secret.kid(), "xyzzy");
325        }
326    }
327
328    #[test]
329    fn debug_includes_label_and_type() {
330        let fx = Factory::random();
331        let secret = fx.hmac("debug-label", HmacSpec::hs256());
332
333        let dbg = format!("{:?}", secret);
334        assert!(dbg.contains("HmacSecret"));
335        assert!(dbg.contains("debug-label"));
336    }
337}