Skip to main content

cratefield_core/
signer.rs

1//! The HMAC-SHA256 reference `Signer` (ADR 0006, issue #3).
2//!
3//! Token format: `base64url(json).base64url(mac)` where the MAC is
4//! computed over the **encoded** payload string — the exact bytes between
5//! the dots — so a token has exactly one valid encoding. Key rotation:
6//! `HARNESS_SECRET` plus optional `HARNESS_SECRET_PREVIOUS`; tokens name
7//! their `kid`, verification tries the named key then the other, and MAC
8//! comparison is constant-time (`subtle`).
9
10use base64::Engine;
11use base64::engine::general_purpose::URL_SAFE_NO_PAD;
12use hmac::{Hmac, KeyInit, Mac};
13use serde::{Deserialize, Serialize};
14use sha2::Sha256;
15use subtle::ConstantTimeEq;
16
17use crate::ports::signer::{Kid, Payload, Signer};
18
19type HmacSha256 = Hmac<Sha256>;
20
21/// Minimum secret length. `HARNESS_SECRET` must be at least 32 bytes.
22pub const MIN_SECRET_BYTES: usize = 32;
23
24/// Errors from constructing an [`HmacSigner`].
25#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
26pub enum SignerError {
27    #[error("harness secret must be at least {MIN_SECRET_BYTES} bytes")]
28    SecretTooShort,
29}
30
31#[derive(Serialize, Deserialize)]
32struct PayloadJson {
33    purpose: String,
34    subject: String,
35    #[serde(skip_serializing_if = "Option::is_none", default)]
36    exp: Option<u64>,
37    kid: String,
38}
39
40/// HMAC-SHA256 signer over `HARNESS_SECRET` (+ optional previous).
41#[derive(Debug, Clone)]
42pub struct HmacSigner {
43    cur: Vec<u8>,
44    prev: Option<Vec<u8>>,
45}
46
47impl HmacSigner {
48    /// # Errors
49    ///
50    /// [`SignerError::SecretTooShort`] when the current secret is shorter
51    /// than [`MIN_SECRET_BYTES`].
52    pub fn new(
53        current_secret: impl Into<String>,
54        previous_secret: Option<String>,
55    ) -> Result<Self, SignerError> {
56        let cur: Vec<u8> = current_secret.into().into_bytes();
57        if cur.len() < MIN_SECRET_BYTES {
58            return Err(SignerError::SecretTooShort);
59        }
60        Ok(Self {
61            cur,
62            prev: previous_secret.map(String::into_bytes),
63        })
64    }
65
66    fn mac(key: &[u8], encoded_payload: &str) -> [u8; 32] {
67        let mut mac =
68            <HmacSha256 as KeyInit>::new_from_slice(key).expect("HMAC accepts any key length");
69        mac.update(encoded_payload.as_bytes());
70        let mut out = [0u8; 32];
71        out.copy_from_slice(&mac.finalize().into_bytes());
72        out
73    }
74
75    fn key_for(&self, kid: Kid) -> (&[u8], Option<Kid>) {
76        match (kid, self.prev.as_deref()) {
77            (Kid::Cur, Some(_)) => (&self.cur, Some(Kid::Prev)),
78            (Kid::Prev, Some(prev)) => (prev, Some(Kid::Cur)),
79            (Kid::Cur | Kid::Prev, None) => (&self.cur, None),
80        }
81    }
82}
83
84impl Signer for HmacSigner {
85    fn sign(&self, payload: &Payload) -> String {
86        // `Kid::Prev` without a configured previous secret signs with the
87        // current key and is labelled `cur`.
88        let (kid, key) = match (payload.kid, self.prev.as_deref()) {
89            (Kid::Prev, Some(prev)) => (Kid::Prev, prev),
90            _ => (Kid::Cur, &self.cur[..]),
91        };
92        let json = PayloadJson {
93            purpose: payload.purpose.clone(),
94            subject: payload.subject.clone(),
95            exp: payload.exp,
96            kid: kid_name(kid).to_string(),
97        };
98        let encoded =
99            URL_SAFE_NO_PAD.encode(serde_json::to_string(&json).expect("payload serializes"));
100        let mac = Self::mac(key, &encoded);
101        format!("{encoded}.{}", URL_SAFE_NO_PAD.encode(mac))
102    }
103
104    fn verify(&self, token: &str, expected_purpose: &str) -> Option<Payload> {
105        let (encoded_payload, encoded_mac) = token.split_once('.')?;
106        if encoded_payload.is_empty() || encoded_mac.is_empty() {
107            return None;
108        }
109        if token.matches('.').count() != 1 {
110            return None;
111        }
112
113        let mac: [u8; 32] = URL_SAFE_NO_PAD.decode(encoded_mac).ok()?.try_into().ok()?;
114        let json = URL_SAFE_NO_PAD
115            .decode(encoded_payload)
116            .ok()
117            .and_then(|bytes| String::from_utf8(bytes).ok())?;
118        let payload: PayloadJson = serde_json::from_str(&json).ok()?;
119        let kid = parse_kid(&payload.kid)?;
120
121        // MAC over the encoded payload string (ADR 0006): the exact bytes
122        // of `encoded_payload`, so a re-encoded (e.g. padded) payload
123        // cannot reuse a MAC.
124        let (named_key, fallback_kid) = self.key_for(kid);
125        let named_ok = bool::from(mac.ct_eq(&Self::mac(named_key, encoded_payload)));
126        let verified = named_ok
127            || match fallback_kid {
128                Some(other) => {
129                    let (other_key, _) = self.key_for(other);
130                    bool::from(mac.ct_eq(&Self::mac(other_key, encoded_payload)))
131                }
132                None => false,
133            };
134        if !verified {
135            return None;
136        }
137
138        if payload.purpose != expected_purpose {
139            return None;
140        }
141        if let Some(exp) = payload.exp {
142            let now = time::OffsetDateTime::now_utc()
143                .unix_timestamp()
144                .max(0)
145                .cast_unsigned();
146            if now >= exp {
147                return None;
148            }
149        }
150        Some(Payload {
151            purpose: payload.purpose,
152            subject: payload.subject,
153            exp: payload.exp,
154            kid,
155        })
156    }
157}
158
159fn kid_name(kid: Kid) -> &'static str {
160    match kid {
161        Kid::Cur => "cur",
162        Kid::Prev => "prev",
163    }
164}
165
166fn parse_kid(name: &str) -> Option<Kid> {
167    match name {
168        "cur" => Some(Kid::Cur),
169        "prev" => Some(Kid::Prev),
170        _ => None,
171    }
172}