Skip to main content

uselesskey_hmac/
secret.rs

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