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