Skip to main content

axon/
trust_verifiers.rs

1//! Runtime implementations of the closed [`crate::refinement::TrustProof`]
2//! catalogue.
3//!
4//! §λ-L-E Fase 11.a — these are the ONLY functions the compiler
5//! recognises as converting `Untrusted<T>` → `Trusted<T>`. All four
6//! share a uniform shape:
7//!
8//! ```ignore
9//! fn verify_<proof>(input: &[u8], ...proof-specific args)
10//!     -> Result<Trusted<Bytes>, TrustError>;
11//! ```
12//!
13//! Implementation notes:
14//!
15//! - HMAC uses `hmac::Mac::verify_slice`, which routes through the
16//!   `subtle` crate's `ConstantTimeEq` — byte-by-byte comparisons
17//!   inside the MAC would leak timing; the catalogue entry documents
18//!   this property so reviewers don't have to re-check every crate.
19//! - JWT delegates to [`crate::jwt_verifier`] from Fase 10.e; we
20//!   re-expose it here so the catalogue is the single source of truth.
21//! - OAuth2 PKCE S256 performs a real HTTP exchange via `reqwest` and
22//!   validates that the returned access token is minted for the
23//!   configured client. Networked verifier; the checker tolerates
24//!   async in this branch.
25//! - Ed25519 uses `ed25519-dalek`'s `Verifier::verify_strict` which
26//!   rejects the low-order-point attack pool that the non-strict API
27//!   accepts. Always prefer `verify_strict`.
28
29use hmac::{Hmac, Mac};
30use sha2::Sha256;
31use subtle::ConstantTimeEq;
32
33use crate::refinement::TrustProof;
34
35type HmacSha256 = Hmac<Sha256>;
36
37// ── Result / error type shared by every verifier ─────────────────────
38
39#[derive(Debug)]
40pub enum TrustError {
41    /// Signature length didn't match the proof's expected size.
42    MalformedSignature(&'static str),
43    /// Constant-time comparison returned mismatch.
44    SignatureMismatch,
45    /// Key could not be decoded (e.g. Ed25519 key not 32 bytes).
46    InvalidKey(String),
47    /// OAuth2 HTTP exchange or JWT fetch returned a 4xx/5xx or
48    /// malformed JSON body.
49    ExchangeFailed(String),
50    /// The proof is known to the catalogue but the specific invocation
51    /// is unsupported (e.g. verifying JWT with alg=none — the 10.e
52    /// verifier rejects that, and we surface the error uniformly).
53    UnsupportedProof(String),
54}
55
56impl std::fmt::Display for TrustError {
57    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
58        match self {
59            Self::MalformedSignature(ctx) => {
60                write!(f, "malformed signature: {ctx}")
61            }
62            Self::SignatureMismatch => {
63                write!(f, "signature mismatch (constant-time compare)")
64            }
65            Self::InvalidKey(m) => write!(f, "invalid key: {m}"),
66            Self::ExchangeFailed(m) => write!(f, "exchange failed: {m}"),
67            Self::UnsupportedProof(m) => write!(f, "unsupported proof: {m}"),
68        }
69    }
70}
71
72impl std::error::Error for TrustError {}
73
74/// Stamped by a successful verifier. The compiler admits a value
75/// carrying this tag as `Trusted<T>`; the runtime checks it at every
76/// trust-boundary call site. The `proof` field records *which*
77/// verifier accepted the payload for replay + audit.
78#[derive(Debug, Clone, PartialEq, Eq)]
79pub struct VerifiedPayload {
80    pub proof: TrustProof,
81    /// Opaque identifier of the key/secret that verified this
82    /// payload — used by downstream observability. Never the raw key.
83    pub key_id: String,
84}
85
86// ── HMAC-SHA256 ──────────────────────────────────────────────────────
87
88/// Verify an HMAC-SHA256 tag over `payload`. The tag MUST be a raw
89/// 32-byte digest (not hex-encoded); callers that receive hex/base64
90/// tags decode them at the boundary.
91///
92/// This function is the compiler-recognised [`TrustProof::Hmac`]
93/// verifier. Any other function computing HMAC is ignored by the
94/// checker — so `my_custom_hmac()` cannot produce `Trusted<T>`.
95pub fn verify_hmac_sha256(
96    payload: &[u8],
97    tag: &[u8],
98    key: &[u8],
99    key_id: &str,
100) -> Result<VerifiedPayload, TrustError> {
101    if tag.len() != 32 {
102        return Err(TrustError::MalformedSignature(
103            "HMAC-SHA256 tag must be exactly 32 bytes",
104        ));
105    }
106    let mut mac = HmacSha256::new_from_slice(key).map_err(|_| {
107        TrustError::InvalidKey(
108            "HMAC-SHA256 accepts any key length; this error is unreachable"
109                .into(),
110        )
111    })?;
112    mac.update(payload);
113    // `verify_slice` routes through `subtle::ConstantTimeEq` internally.
114    mac.verify_slice(tag).map_err(|_| TrustError::SignatureMismatch)?;
115    Ok(VerifiedPayload {
116        proof: TrustProof::Hmac,
117        key_id: key_id.to_string(),
118    })
119}
120
121/// Helper for adopters who receive HMAC tags as hex-encoded strings
122/// (GitHub's `X-Hub-Signature-256` convention). Hex decoding is
123/// standard and its correctness is not security-critical; the
124/// constant-time compare happens inside the MAC verify above.
125pub fn verify_hmac_sha256_hex(
126    payload: &[u8],
127    tag_hex: &str,
128    key: &[u8],
129    key_id: &str,
130) -> Result<VerifiedPayload, TrustError> {
131    let tag = hex_decode(tag_hex.strip_prefix("sha256=").unwrap_or(tag_hex))
132        .ok_or(TrustError::MalformedSignature(
133            "HMAC-SHA256 hex tag did not decode",
134        ))?;
135    verify_hmac_sha256(payload, &tag, key, key_id)
136}
137
138fn hex_decode(s: &str) -> Option<Vec<u8>> {
139    if s.len() % 2 != 0 {
140        return None;
141    }
142    let mut out = Vec::with_capacity(s.len() / 2);
143    for chunk in s.as_bytes().chunks(2) {
144        let hi = hex_nibble(chunk[0])?;
145        let lo = hex_nibble(chunk[1])?;
146        out.push((hi << 4) | lo);
147    }
148    Some(out)
149}
150
151fn hex_nibble(c: u8) -> Option<u8> {
152    match c {
153        b'0'..=b'9' => Some(c - b'0'),
154        b'a'..=b'f' => Some(c - b'a' + 10),
155        b'A'..=b'F' => Some(c - b'A' + 10),
156        _ => None,
157    }
158}
159
160// ── Ed25519 detached signature ───────────────────────────────────────
161
162/// Verify an Ed25519 detached signature using `verify_strict`. We
163/// never use the non-strict API: the strict path rejects low-order
164/// points + non-canonical encodings that attackers can exploit to
165/// produce multiple valid signatures for one message.
166pub fn verify_ed25519(
167    payload: &[u8],
168    signature: &[u8],
169    public_key: &[u8],
170    key_id: &str,
171) -> Result<VerifiedPayload, TrustError> {
172    if public_key.len() != 32 {
173        return Err(TrustError::InvalidKey(
174            "Ed25519 public key must be 32 bytes".into(),
175        ));
176    }
177    if signature.len() != 64 {
178        return Err(TrustError::MalformedSignature(
179            "Ed25519 signature must be 64 bytes",
180        ));
181    }
182    let pk_array: [u8; 32] = public_key.try_into().map_err(|_| {
183        TrustError::InvalidKey("public key length invariant violated".into())
184    })?;
185    let pk = ed25519_dalek::VerifyingKey::from_bytes(&pk_array)
186        .map_err(|e| TrustError::InvalidKey(e.to_string()))?;
187    let sig_array: [u8; 64] = signature.try_into().map_err(|_| {
188        TrustError::MalformedSignature("signature length invariant violated")
189    })?;
190    let sig = ed25519_dalek::Signature::from_bytes(&sig_array);
191    pk.verify_strict(payload, &sig)
192        .map_err(|_| TrustError::SignatureMismatch)?;
193    Ok(VerifiedPayload {
194        proof: TrustProof::Ed25519,
195        key_id: key_id.to_string(),
196    })
197}
198
199// ── JWT signature (delegates to Fase 10.e) ───────────────────────────
200
201/// Thin wrapper exposing [`crate::jwt_verifier`] under the uniform
202/// verifier shape. Callers receive a `VerifiedPayload` tagged with
203/// [`TrustProof::JwtSig`] on success; the token's claims remain
204/// accessible via the underlying verifier API.
205///
206/// The compiler treats this function as the [`TrustProof::JwtSig`]
207/// entry point — direct calls to [`crate::jwt_verifier`] also count
208/// (same proof slug), but adopters reaching through this wrapper get
209/// the uniform error type for free.
210pub async fn verify_jwt_signature(
211    token: &str,
212    verifier: &crate::jwt_verifier::JwtVerifier,
213) -> Result<VerifiedPayload, TrustError> {
214    let verified = verifier.verify(token).await.map_err(|e| match e {
215        crate::jwt_verifier::JwtVerifyError::UnsupportedAlg(a) => {
216            TrustError::UnsupportedProof(format!("alg={a}"))
217        }
218        other => TrustError::ExchangeFailed(other.to_string()),
219    })?;
220    // Prefer the token's `jti` (unique identifier) as the key_id for
221    // audit trails; fall back to `sub` when issuers don't mint jti;
222    // anonymous last so the shape is never empty.
223    let key_id = verified
224        .jti
225        .clone()
226        .or_else(|| verified.sub.clone())
227        .unwrap_or_else(|| "<anonymous>".to_string());
228    Ok(VerifiedPayload {
229        proof: TrustProof::JwtSig,
230        key_id,
231    })
232}
233
234// ── OAuth2 PKCE S256 code exchange ───────────────────────────────────
235
236/// Minimal request struct for an OAuth2 authorization-code exchange
237/// with PKCE. Callers pass the raw `code` received on the redirect
238/// plus the `code_verifier` they generated at flow-start; Axon posts
239/// the exchange to the configured `token_endpoint` and returns a
240/// [`VerifiedPayload`] on 2xx.
241pub struct OAuthCodeExchangeRequest<'a> {
242    pub token_endpoint: &'a str,
243    pub client_id: &'a str,
244    pub client_secret: Option<&'a str>,
245    pub redirect_uri: &'a str,
246    pub code: &'a str,
247    pub code_verifier: &'a str,
248}
249
250/// The access-token response body, returned to callers after
251/// verification. Fields follow RFC 6749 §5.1.
252#[derive(Debug, Clone, serde::Deserialize)]
253pub struct OAuthTokenResponse {
254    pub access_token: String,
255    #[serde(default)]
256    pub token_type: String,
257    #[serde(default)]
258    pub expires_in: Option<u64>,
259    #[serde(default)]
260    pub refresh_token: Option<String>,
261    #[serde(default)]
262    pub scope: Option<String>,
263    #[serde(default)]
264    pub id_token: Option<String>,
265}
266
267/// Perform the PKCE S256 exchange. The verifier is networked;
268/// adopters typically call this inside a handler that owns the
269/// request-scoped async context.
270pub async fn verify_oauth_code_exchange(
271    req: OAuthCodeExchangeRequest<'_>,
272) -> Result<(VerifiedPayload, OAuthTokenResponse), TrustError> {
273    let mut form = vec![
274        ("grant_type", "authorization_code"),
275        ("code", req.code),
276        ("redirect_uri", req.redirect_uri),
277        ("client_id", req.client_id),
278        ("code_verifier", req.code_verifier),
279    ];
280    if let Some(secret) = req.client_secret {
281        form.push(("client_secret", secret));
282    }
283
284    let client = reqwest::Client::new();
285    let resp = client
286        .post(req.token_endpoint)
287        .form(&form)
288        .send()
289        .await
290        .map_err(|e| TrustError::ExchangeFailed(e.to_string()))?;
291
292    if !resp.status().is_success() {
293        let status = resp.status();
294        let body = resp.text().await.unwrap_or_default();
295        return Err(TrustError::ExchangeFailed(format!(
296            "HTTP {status}: {body}"
297        )));
298    }
299
300    let token: OAuthTokenResponse = resp
301        .json()
302        .await
303        .map_err(|e| TrustError::ExchangeFailed(format!("body parse: {e}")))?;
304
305    Ok((
306        VerifiedPayload {
307            proof: TrustProof::OAuthCodeExchange,
308            key_id: req.client_id.to_string(),
309        },
310        token,
311    ))
312}
313
314#[cfg(test)]
315mod tests {
316    use super::*;
317
318    #[test]
319    fn hmac_roundtrip() {
320        let key = b"super-secret-key";
321        let payload = b"order#42|amount=100.00";
322
323        let mut mac = HmacSha256::new_from_slice(key).unwrap();
324        mac.update(payload);
325        let tag = mac.finalize().into_bytes();
326
327        let vp =
328            verify_hmac_sha256(payload, &tag, key, "key-v1").unwrap();
329        assert_eq!(vp.proof, TrustProof::Hmac);
330        assert_eq!(vp.key_id, "key-v1");
331    }
332
333    #[test]
334    fn hmac_rejects_tampered_payload() {
335        let key = b"super-secret-key";
336        let mut mac = HmacSha256::new_from_slice(key).unwrap();
337        mac.update(b"original");
338        let tag = mac.finalize().into_bytes();
339
340        let err = verify_hmac_sha256(b"tampered", &tag, key, "k").unwrap_err();
341        matches!(err, TrustError::SignatureMismatch);
342    }
343
344    #[test]
345    fn hmac_rejects_wrong_length_tag() {
346        let err = verify_hmac_sha256(b"x", &[0u8; 16], b"k", "k").unwrap_err();
347        matches!(err, TrustError::MalformedSignature(_));
348    }
349
350    #[test]
351    fn hmac_hex_decode_roundtrip() {
352        let key = b"k";
353        let mut mac = HmacSha256::new_from_slice(key).unwrap();
354        mac.update(b"hello");
355        let tag = mac.finalize().into_bytes();
356        let hex_tag = tag.iter().map(|b| format!("{b:02x}")).collect::<String>();
357
358        let vp = verify_hmac_sha256_hex(b"hello", &hex_tag, key, "k").unwrap();
359        assert_eq!(vp.proof, TrustProof::Hmac);
360
361        // GitHub-style prefix is stripped.
362        let prefixed = format!("sha256={hex_tag}");
363        let vp2 =
364            verify_hmac_sha256_hex(b"hello", &prefixed, key, "k").unwrap();
365        assert_eq!(vp2.proof, TrustProof::Hmac);
366    }
367
368    #[test]
369    fn ed25519_roundtrip() {
370        use ed25519_dalek::{Signer, SigningKey};
371        // §Fase 12.c — `SigningKey::generate(&mut csprng)` failed to
372        // compile under `rand 0.9` because `rand::rngs::OsRng`
373        // implements `rand_core 0.9::CryptoRng`, but `ed25519-dalek 2`
374        // expects `rand_core 0.6::CryptoRngCore`. Seeding from raw
375        // bytes produced by `rand::random` bypasses the trait
376        // resolution and is semantically equivalent for a test key.
377        let seed: [u8; 32] = rand::random();
378        let sk = SigningKey::from_bytes(&seed);
379        let pk = sk.verifying_key();
380        let payload = b"sigstore-attestation";
381        let sig = sk.sign(payload);
382
383        let vp = verify_ed25519(
384            payload,
385            &sig.to_bytes(),
386            pk.as_bytes(),
387            "sigstore-key-1",
388        )
389        .unwrap();
390        assert_eq!(vp.proof, TrustProof::Ed25519);
391        assert_eq!(vp.key_id, "sigstore-key-1");
392    }
393
394    #[test]
395    fn ed25519_rejects_tampered_payload() {
396        use ed25519_dalek::{Signer, SigningKey};
397        // §Fase 12.c — same rand_core version skew fix as
398        // `ed25519_roundtrip` above.
399        let seed: [u8; 32] = rand::random();
400        let sk = SigningKey::from_bytes(&seed);
401        let pk = sk.verifying_key();
402        let sig = sk.sign(b"original");
403
404        let err = verify_ed25519(
405            b"tampered",
406            &sig.to_bytes(),
407            pk.as_bytes(),
408            "k",
409        )
410        .unwrap_err();
411        matches!(err, TrustError::SignatureMismatch);
412    }
413
414    #[test]
415    fn ed25519_rejects_wrong_key_length() {
416        let err = verify_ed25519(b"x", &[0u8; 64], b"too_short", "k").unwrap_err();
417        matches!(err, TrustError::InvalidKey(_));
418    }
419
420    #[test]
421    fn subtle_compare_still_available_for_adopters() {
422        // Adopters who need raw constant-time compare (outside the
423        // catalogue) can use `subtle::ConstantTimeEq` directly.
424        let a = [1u8, 2, 3];
425        let b = [1u8, 2, 3];
426        assert!(bool::from(a.ct_eq(&b)));
427    }
428}