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_jwk::srp::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(
96        dead_code,
97        reason = "reserved for future variant-based negative fixtures"
98    )]
99    fn load_variant(&self, variant: &str) -> Arc<Inner> {
100        load_inner(&self.factory, &self.label, self.spec, variant)
101    }
102
103    /// Returns the spec used to create this secret.
104    ///
105    /// # Examples
106    ///
107    /// ```
108    /// # use uselesskey_core::Factory;
109    /// # use uselesskey_hmac::{HmacFactoryExt, HmacSpec};
110    /// let fx = Factory::random();
111    /// let secret = fx.hmac("jwt", HmacSpec::hs256());
112    /// assert_eq!(secret.spec(), HmacSpec::hs256());
113    /// ```
114    pub fn spec(&self) -> HmacSpec {
115        self.spec
116    }
117
118    /// Returns the label used to create this secret.
119    ///
120    /// # Examples
121    ///
122    /// ```
123    /// # use uselesskey_core::Factory;
124    /// # use uselesskey_hmac::{HmacFactoryExt, HmacSpec};
125    /// let fx = Factory::random();
126    /// let secret = fx.hmac("my-jwt", HmacSpec::hs256());
127    /// assert_eq!(secret.label(), "my-jwt");
128    /// ```
129    pub fn label(&self) -> &str {
130        &self.label
131    }
132
133    /// Access raw secret bytes.
134    ///
135    /// # Examples
136    ///
137    /// ```
138    /// # use uselesskey_core::{Factory, Seed};
139    /// # use uselesskey_hmac::{HmacFactoryExt, HmacSpec};
140    /// let fx = Factory::deterministic(Seed::from_env_value("test-seed").unwrap());
141    /// let secret = fx.hmac("jwt-signing", HmacSpec::hs256());
142    /// assert_eq!(secret.secret_bytes().len(), 32);
143    ///
144    /// let secret512 = fx.hmac("jwt-signing", HmacSpec::hs512());
145    /// assert_eq!(secret512.secret_bytes().len(), 64);
146    /// ```
147    pub fn secret_bytes(&self) -> &[u8] {
148        &self.inner.secret
149    }
150
151    /// A stable key identifier derived from the secret bytes (base64url blake3 hash prefix).
152    ///
153    /// # Examples
154    ///
155    /// ```
156    /// # use uselesskey_core::Factory;
157    /// # use uselesskey_hmac::{HmacFactoryExt, HmacSpec};
158    /// let fx = Factory::random();
159    /// let secret = fx.hmac("jwt", HmacSpec::hs256());
160    /// let kid = secret.kid();
161    /// assert!(!kid.is_empty());
162    /// ```
163    #[cfg(feature = "jwk")]
164    pub fn kid(&self) -> String {
165        kid_from_bytes(self.secret_bytes())
166    }
167
168    /// HMAC secret as an octet JWK (kty=oct).
169    ///
170    /// Requires the `jwk` feature.
171    ///
172    /// # Examples
173    ///
174    /// ```
175    /// # use uselesskey_core::Factory;
176    /// # use uselesskey_hmac::{HmacFactoryExt, HmacSpec};
177    /// let fx = Factory::random();
178    /// let secret = fx.hmac("jwt", HmacSpec::hs256());
179    /// let jwk = secret.jwk();
180    /// let val = jwk.to_value();
181    /// assert_eq!(val["kty"], "oct");
182    /// assert_eq!(val["alg"], "HS256");
183    /// ```
184    #[cfg(feature = "jwk")]
185    pub fn jwk(&self) -> uselesskey_jwk::PrivateJwk {
186        use base64::Engine as _;
187        use base64::engine::general_purpose::URL_SAFE_NO_PAD;
188        use uselesskey_jwk::{OctJwk, PrivateJwk};
189
190        let k = URL_SAFE_NO_PAD.encode(self.secret_bytes());
191
192        PrivateJwk::Oct(OctJwk {
193            kty: "oct",
194            use_: "sig",
195            alg: self.spec.alg_name(),
196            kid: self.kid(),
197            k,
198        })
199    }
200
201    /// JWKS containing this HMAC secret as an octet key.
202    ///
203    /// Requires the `jwk` feature.
204    ///
205    /// # Examples
206    ///
207    /// ```
208    /// # use uselesskey_core::Factory;
209    /// # use uselesskey_hmac::{HmacFactoryExt, HmacSpec};
210    /// let fx = Factory::random();
211    /// let secret = fx.hmac("jwt", HmacSpec::hs256());
212    /// let jwks = secret.jwks();
213    /// let val = jwks.to_value();
214    /// assert!(val["keys"].is_array());
215    /// ```
216    #[cfg(feature = "jwk")]
217    pub fn jwks(&self) -> uselesskey_jwk::Jwks {
218        use uselesskey_jwk::JwksBuilder;
219
220        let mut builder = JwksBuilder::new();
221        builder.push_private(self.jwk());
222        builder.build()
223    }
224}
225
226fn load_inner(factory: &Factory, label: &str, spec: HmacSpec, variant: &str) -> Arc<Inner> {
227    let spec_bytes = spec.stable_bytes();
228
229    factory.get_or_init(DOMAIN_HMAC_SECRET, label, &spec_bytes, variant, |seed| {
230        let mut rng = ChaCha20Rng::from_seed(*seed.bytes());
231        let mut buf = vec![0u8; spec.byte_len()];
232        rng.fill_bytes(&mut buf);
233        Inner {
234            secret: Arc::from(buf),
235        }
236    })
237}
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242    use uselesskey_core::Seed;
243
244    #[test]
245    fn secret_length_matches_spec() {
246        let fx = Factory::random();
247        let secret = fx.hmac("test", HmacSpec::hs256());
248        assert_eq!(secret.secret_bytes().len(), 32);
249    }
250
251    #[test]
252    fn deterministic_secret_is_stable() {
253        let fx = Factory::deterministic(Seed::from_env_value("hmac-seed").unwrap());
254        let s1 = fx.hmac("issuer", HmacSpec::hs384());
255        let s2 = fx.hmac("issuer", HmacSpec::hs384());
256        assert_eq!(s1.secret_bytes(), s2.secret_bytes());
257    }
258
259    #[test]
260    fn different_variants_produce_different_secrets() {
261        let fx = Factory::deterministic(Seed::from_env_value("hmac-variant").unwrap());
262        let secret = fx.hmac("issuer", HmacSpec::hs256());
263        let other = secret.load_variant("other");
264
265        assert_ne!(secret.secret_bytes(), other.secret.as_ref());
266    }
267
268    #[test]
269    #[cfg(feature = "jwk")]
270    fn jwk_contains_expected_fields() {
271        let fx = Factory::random();
272        let secret = fx.hmac("jwt", HmacSpec::hs512());
273        let jwk = secret.jwk().to_value();
274
275        assert_eq!(jwk["kty"], "oct");
276        assert_eq!(jwk["alg"], "HS512");
277        assert_eq!(jwk["use"], "sig");
278        assert!(jwk["kid"].is_string());
279        assert!(jwk["k"].is_string());
280    }
281
282    #[test]
283    #[cfg(feature = "jwk")]
284    fn jwk_k_is_base64url() {
285        use base64::Engine as _;
286        use base64::engine::general_purpose::URL_SAFE_NO_PAD;
287
288        let fx = Factory::random();
289        let secret = fx.hmac("jwt", HmacSpec::hs256());
290        let jwk = secret.jwk().to_value();
291
292        let k = jwk["k"].as_str().unwrap();
293        let decoded = URL_SAFE_NO_PAD.decode(k).expect("valid base64url");
294        assert_eq!(decoded.len(), HmacSpec::hs256().byte_len());
295    }
296
297    #[test]
298    #[cfg(feature = "jwk")]
299    fn jwks_wraps_jwk() {
300        let fx = Factory::random();
301        let secret = fx.hmac("jwt", HmacSpec::hs256());
302
303        let jwk = secret.jwk().to_value();
304        let jwks = secret.jwks().to_value();
305
306        let keys = jwks["keys"].as_array().expect("keys array");
307        assert_eq!(keys.len(), 1);
308        assert_eq!(keys[0], jwk);
309    }
310
311    #[test]
312    #[cfg(feature = "jwk")]
313    fn kid_is_deterministic() {
314        let fx = Factory::deterministic(Seed::from_env_value("hmac-kid").unwrap());
315        let s1 = fx.hmac("issuer", HmacSpec::hs512());
316        let s2 = fx.hmac("issuer", HmacSpec::hs512());
317        assert_eq!(s1.kid(), s2.kid());
318    }
319
320    #[test]
321    #[cfg(feature = "jwk")]
322    fn kid_is_not_placeholder_for_any_spec() {
323        let fx = Factory::random();
324
325        for spec in [HmacSpec::hs256(), HmacSpec::hs384(), HmacSpec::hs512()] {
326            let secret = fx.hmac("kid-placeholder", spec);
327            assert_ne!(secret.kid(), "xyzzy");
328        }
329    }
330
331    #[test]
332    fn debug_includes_label_and_type() {
333        let fx = Factory::random();
334        let secret = fx.hmac("debug-label", HmacSpec::hs256());
335
336        let dbg = format!("{:?}", secret);
337        assert!(dbg.contains("HmacSecret"));
338        assert!(dbg.contains("debug-label"));
339    }
340}