Skip to main content

ff_core/
crypto.rs

1//! Cryptographic primitives shared across backend implementations.
2//!
3//! RFC-023 Phase 2b.2.1: the Postgres + SQLite backends both need
4//! Rust-side HMAC sign/verify for waitpoint tokens (the Valkey backend
5//! signs inside Lua, so it does not consume this module). Extracted
6//! from `ff-backend-postgres/src/signal.rs` with zero behaviour change
7//! — same output bytes, same token wire shape, same error taxonomy.
8//!
9//! See [`hmac`] for the primitives.
10
11pub mod hmac {
12    //! HMAC-SHA256 sign/verify for `kid:hex` tokens.
13    //!
14    //! Token shape is `<kid>:<hex-digest>` where the digest is
15    //! `HMAC-SHA256(secret, kid || ":" || message)`. Verification is
16    //! constant-time via [`::hmac::Mac::verify_slice`].
17
18    use ::hmac::{Hmac, Mac};
19    use sha2::Sha256;
20
21    /// HMAC-SHA256 signature over `kid || ":" || message`. Returns a
22    /// `kid:hex` token.
23    ///
24    /// # Kid constraints
25    ///
26    /// The wire shape is `<kid>:<hex>`; a kid containing `':'` would
27    /// collapse to an ambiguous `split_once(':')` at verify time. The
28    /// backend seed / rotate entry points validate kid shape before
29    /// minting secrets (backend-postgres `rotate_waitpoint_hmac_secret_all_impl`,
30    /// backend-sqlite `suspend_ops::validate_kid`), so every secret
31    /// reaching this function should already carry a colon-free kid.
32    /// This primitive does not re-validate for performance — callers
33    /// that build tokens from unvalidated input must run their own
34    /// shape check first.
35    pub fn hmac_sign(secret: &[u8], kid: &str, message: &[u8]) -> String {
36        let mut mac = <Hmac<Sha256> as Mac>::new_from_slice(secret)
37            .expect("HMAC-SHA256 accepts any key length");
38        mac.update(kid.as_bytes());
39        mac.update(b":");
40        mac.update(message);
41        let out = mac.finalize().into_bytes();
42        format!("{kid}:{}", hex::encode(out))
43    }
44
45    /// Verify a `kid:hex` token. Returns `Ok(())` iff the digest
46    /// matches `secret` over `message`. Constant-time comparison.
47    pub fn hmac_verify(
48        secret: &[u8],
49        kid: &str,
50        message: &[u8],
51        token: &str,
52    ) -> Result<(), HmacVerifyError> {
53        let (tok_kid, tok_hex) =
54            token.split_once(':').ok_or(HmacVerifyError::Malformed)?;
55        if tok_kid != kid {
56            return Err(HmacVerifyError::WrongKid {
57                expected: kid.to_owned(),
58                actual: tok_kid.to_owned(),
59            });
60        }
61        let expected = hex::decode(tok_hex).map_err(|_| HmacVerifyError::Malformed)?;
62        let mut mac = <Hmac<Sha256> as Mac>::new_from_slice(secret)
63            .map_err(|_| HmacVerifyError::Malformed)?;
64        mac.update(kid.as_bytes());
65        mac.update(b":");
66        mac.update(message);
67        mac.verify_slice(&expected)
68            .map_err(|_| HmacVerifyError::SignatureMismatch)
69    }
70
71    /// Errors from [`hmac_verify`]. Backend callers map these onto
72    /// `EngineError::Validation(InvalidInput)` at the trait boundary.
73    #[derive(Debug, thiserror::Error)]
74    pub enum HmacVerifyError {
75        #[error("token malformed; expected kid:hex shape")]
76        Malformed,
77        #[error("token kid mismatch; expected {expected}, got {actual}")]
78        WrongKid { expected: String, actual: String },
79        #[error("HMAC signature mismatch")]
80        SignatureMismatch,
81    }
82
83    #[cfg(test)]
84    mod tests {
85        use super::*;
86
87        #[test]
88        fn sign_then_verify_round_trip() {
89            let secret = b"super-secret-key";
90            let tok = hmac_sign(secret, "kid1", b"exec-id:wp-id");
91            assert!(tok.starts_with("kid1:"));
92            hmac_verify(secret, "kid1", b"exec-id:wp-id", &tok).expect("verify ok");
93        }
94
95        #[test]
96        fn verify_rejects_tampered_message() {
97            let secret = b"s";
98            let tok = hmac_sign(secret, "k", b"msg");
99            let err = hmac_verify(secret, "k", b"tampered", &tok).unwrap_err();
100            assert!(matches!(err, HmacVerifyError::SignatureMismatch));
101        }
102
103        #[test]
104        fn verify_rejects_wrong_kid() {
105            let secret = b"s";
106            let tok = hmac_sign(secret, "k1", b"msg");
107            let err = hmac_verify(secret, "k2", b"msg", &tok).unwrap_err();
108            assert!(matches!(err, HmacVerifyError::WrongKid { .. }));
109        }
110
111        #[test]
112        fn verify_rejects_malformed() {
113            assert!(matches!(
114                hmac_verify(b"s", "k", b"msg", "no-colon-token"),
115                Err(HmacVerifyError::Malformed)
116            ));
117            assert!(matches!(
118                hmac_verify(b"s", "k", b"msg", "k:not-hex-zzzz"),
119                Err(HmacVerifyError::Malformed)
120            ));
121        }
122
123        #[test]
124        fn sign_is_deterministic() {
125            // Same inputs → same output bytes (no nonce / salt).
126            let a = hmac_sign(b"k", "kid", b"msg");
127            let b = hmac_sign(b"k", "kid", b"msg");
128            assert_eq!(a, b);
129        }
130    }
131}