Skip to main content

agentplane/push/
sign.rs

1//! Proving the **body** of a delivery, rather than the sender's token.
2//!
3//! # What a bearer header does not say
4//!
5//! [`PushAuthentication`](super::PushAuthentication) produces `Authorization:
6//! <scheme> <credentials>`, and what a receiver learns from it is that whoever
7//! opened the connection held a token. It says nothing about the bytes that
8//! followed. That gap is not theoretical in a deployment: the token transits
9//! every hop between here and the receiver — a TLS-terminating ingress, a mesh
10//! sidecar, a proxy that logs headers — and each of those hops handles the body
11//! afterwards. A receiver checking only the header cannot tell the event this
12//! plane wrote from the same event with a field edited in transit, and it cannot
13//! tell either from an event minted by anything that ever read the token.
14//!
15//! A body signature is a different claim, and a narrower one.
16//!
17//! # [Standard Webhooks], rather than a house convention
18//!
19//! Three headers ride with every delivery this plane signs:
20//!
21//! * `webhook-id` — the message's own identity, stable across retries. It is
22//!   the receiver's idempotency key, and it is sent whether or not a
23//!   destination is signed, because at-least-once delivery makes duplicates
24//!   ordinary rather than exceptional.
25//! * `webhook-timestamp` — Unix seconds, the instant *this attempt* was made.
26//! * `webhook-signature` — `v1,<base64>` of `HMAC-SHA256(key, "{id}.{timestamp}.{body}")`.
27//!
28//! The id and the timestamp are inside the signed content, which is the point
29//! of the construction: a signature over the body alone is replayable forever,
30//! because a captured POST stays a genuine body genuinely signed. With both
31//! bound in, a receiver that refuses timestamps outside a tolerance window and
32//! deduplicates on `webhook-id` has a delivery that expires. Neither half works
33//! alone — the window bounds how long a replay is useful, the id stops it
34//! inside the window.
35//!
36//! Choosing the published spelling over a house one is what lets a receiver
37//! verify with a library it did not write. The alternative shape — `sha256=`
38//! hex over the bare body, the convention several vendors ship — is a signature
39//! this plane could produce and no off-the-shelf verifier could check against a
40//! replay.
41//!
42//! # What it still does not prove
43//!
44//! * **Who.** The key is symmetric and shared, so it proves the writer was *a*
45//!   holder of it — this plane, the receiver itself, or anything holding the
46//!   configuration. It is not a signature in the public-key sense and cannot be
47//!   shown to a third party as evidence of origin.
48//!
49//! * **Confidentiality.** The body still travels in whatever the URL's scheme
50//!   provides. Signing a plaintext delivery makes it unforgeable, not private.
51//!
52//! * **That a destination was signed at all.** A receiver must *require* the
53//!   header. A missing signature is only a refusal if the receiver refuses it;
54//!   a receiver that verifies when the header is present and accepts when it is
55//!   absent has bought nothing, because an attacker simply omits it.
56//!
57//! # One algorithm, and no enum to say so
58//!
59//! `HMAC-SHA256` under the `v1` label. There is deliberately no algorithm enum
60//! with one variant and no `X-…-Algorithm` header: both would be declarations
61//! that decide nothing today, and the wire format already carries the label. A
62//! second algorithm arrives as a second label a receiver can dispatch on, which
63//! is exactly what the label is for — the spec's own `v1a` (Ed25519) is that
64//! door.
65//!
66//! [Standard Webhooks]: https://www.standardwebhooks.com/
67
68use hmac::{KeyInit, Mac, SimpleHmac};
69use sha2::Sha256;
70use zeroize::Zeroizing;
71
72use crate::core::Secret;
73use crate::core::secret::constant_time_eq;
74
75/// A signing secret this deployment wrote about itself that cannot be used.
76///
77/// Configuration errors, decidable the moment the secret is read. Typed as well
78/// as panicked because a deployment reads its configuration inside its own
79/// builder, where `RuntimeBuilder::try_build` sets the precedent: refuse to
80/// start with a diagnostic rather than abort from underneath the caller.
81#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
82#[non_exhaustive]
83pub enum SigningKeyError {
84    /// A `whsec_`-prefixed secret whose remainder is not base64.
85    ///
86    /// The prefix is a claim about the encoding, so the two disagreeing is not
87    /// a key this can guess at: signing the prefixed text would produce a MAC
88    /// every conformant verifier rejects, and the failure would surface only as
89    /// a receiver refusing everything.
90    #[error(
91        "a push signing secret beginning '{SYMMETRIC_KEY_PREFIX}' names base64 of the key, \
92         and this one does not decode — every delivery would carry a MAC the receiver's \
93         library rejects"
94    )]
95    NotBase64,
96
97    /// Shorter than [`MIN_KEY_BYTES`], which Standard Webhooks requires.
98    ///
99    /// A MAC key an attacker can search is a check that can be defeated, and a
100    /// check that can be defeated reads exactly like one that means something.
101    #[error(
102        "a push signing key of {bytes} bytes is shorter than the {MIN_KEY_BYTES} \
103         Standard Webhooks requires: a MAC key an attacker can search is a check \
104         that reads exactly like one that means something"
105    )]
106    TooShort { bytes: usize },
107
108    /// A rotation secret with no primary to rotate from.
109    ///
110    /// Refused rather than promoted: a rotation secret signing alone would let
111    /// load order decide which key every receiver has to hold.
112    #[error(
113        "a rotation secret was configured before any primary — sign with the \
114         primary first; 'also' without a first key is a wiring mistake worth \
115         naming where it is written"
116    )]
117    NoPrimary,
118}
119
120/// Why a delivery was not accepted.
121///
122/// Every variant is a refusal; none is a reason to process the body anyway.
123/// They are told apart because an operator needs to know which is happening — a
124/// drifting clock and a replayed capture both present as [`Stale`](Self::Stale).
125///
126/// None carries a hint about how close a signature was: the comparison is
127/// all-or-nothing, and quantifying the miss would be an oracle.
128#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
129#[non_exhaustive]
130pub enum WebhookRejected {
131    /// A required header was absent.
132    ///
133    /// Including the signature itself — the refusal a receiver most often
134    /// forgets, since verifying only when the header is present buys nothing.
135    #[error("the delivery carried no '{0}' header, so there is nothing to verify")]
136    MissingHeader(&'static str),
137
138    /// The timestamp header was not Unix seconds.
139    #[error("'{HEADER_TIMESTAMP}' is not Unix seconds: {0}")]
140    MalformedTimestamp(String),
141
142    /// The timestamp is outside the tolerance window, in either direction.
143    ///
144    /// Both directions: a delivery from the future is a clock this receiver
145    /// cannot reason about, and accepting it would let whoever captured a POST
146    /// choose a timestamp far enough ahead to stay valid indefinitely.
147    #[error(
148        "'{HEADER_TIMESTAMP}' is {skew_secs}s from now, outside the {tolerance_secs}s \
149         tolerance — a signature proves the bytes, and only the window bounds how \
150         long a captured POST stays useful"
151    )]
152    Stale { skew_secs: i64, tolerance_secs: u64 },
153
154    /// The signature header carried no version this build can check.
155    ///
156    /// A sender deployed ahead of its receivers is a rollout to finish, not a
157    /// key to investigate, so it is not reported as a mismatch.
158    #[error(
159        "'{HEADER_SIGNATURE}' carried no '{SCHEME}' signature — this build verifies \
160         '{SCHEME}' (HMAC-SHA256) only, so a header with other labels means a sender \
161         was deployed ahead of its receivers"
162    )]
163    NoSupportedScheme,
164
165    /// No configured key produced any of the signatures offered.
166    ///
167    /// Which of the possible causes it is cannot be determined from here.
168    #[error(
169        "no configured key verifies this delivery — the bytes, the id or the \
170         timestamp are not what was signed, or the writer did not hold the key"
171    )]
172    SignatureMismatch,
173}
174
175/// The message's own identity, and the receiver's idempotency key.
176pub const HEADER_ID: &str = "webhook-id";
177/// Unix seconds at which this attempt was made.
178pub const HEADER_TIMESTAMP: &str = "webhook-timestamp";
179/// `v1,<base64>` over `{id}.{timestamp}.{body}` — one element per configured
180/// key, space-separated.
181pub const HEADER_SIGNATURE: &str = "webhook-signature";
182/// A2A's opaque per-task token, echoed on every delivery for the receiver
183/// that registered it to validate against.
184///
185/// The spelling is the **reference SDK's**, not the specification's: A2A 1.0
186/// kept the `token` field on the push configuration and dropped the 0.3-era
187/// header definition without naming a replacement, so the spec leaves the
188/// token with no defined carriage at all. The maintained `a2a-python` sender
189/// still emits exactly this header, which makes it the one spelling a
190/// receiver built on the official SDKs actually checks — and a token that
191/// never rides is a secret with no purpose. Distinct from
192/// `AuthenticationInfo`, which alone forms the `Authorization` header.
193pub const HEADER_A2A_TOKEN: &str = "x-a2a-notification-token";
194
195/// The prefix Standard Webhooks gives a base64-encoded symmetric key.
196const SYMMETRIC_KEY_PREFIX: &str = "whsec_";
197
198/// The one signature label this build produces and checks.
199pub const SCHEME: &str = "v1";
200
201/// How far a delivery's timestamp may sit from now, in either direction.
202///
203/// Five minutes, the spec's recommendation. A receiver behind a queue that
204/// buffers for longer widens it with [`WebhookVerifier::within`] rather than
205/// discovering its deliveries fail at the far end of a backlog — and widening
206/// it is not a preference, it is deciding how long a captured POST stays useful.
207pub const DEFAULT_TOLERANCE: std::time::Duration = std::time::Duration::from_secs(300);
208
209/// The shortest key this accepts, in bytes.
210///
211/// Public because a deployment choosing a signing secret is the party the bound
212/// applies to, and a number reachable only from an error message is one they
213/// find out about after they got it wrong.
214///
215/// The spec's range is 24–64 bytes. The floor is enforced and the ceiling is
216/// not: a key shorter than this is a MAC an attacker can search, while a longer
217/// one is only wasteful — HMAC hashes a key past the block size, which is a
218/// documented branch and not a weakness.
219pub const MIN_KEY_BYTES: usize = 24;
220
221/// RFC 2104 over SHA-256, as `hmac` implements it.
222///
223/// A key longer than the block is replaced by its own hash and a shorter one is
224/// zero-padded — handled by the crate, and the branch a hand-written HMAC most
225/// often omits, whereupon every long key silently produces a MAC no other
226/// implementation agrees with.
227fn hmac_sha256(key: &[u8], message: &[u8]) -> [u8; 32] {
228    // `SimpleHmac` rather than `Hmac`: the latter needs `Sha256: EagerHash`,
229    // and nothing here benefits from the specialised path — this runs once per
230    // delivery, not per byte of a stream.
231    let mut mac = <SimpleHmac<Sha256> as KeyInit>::new_from_slice(key)
232        .expect("HMAC accepts a key of any length");
233    mac.update(message);
234    mac.finalize().into_bytes().into()
235}
236
237/// A shared signing key.
238///
239/// Constructed through [`Destination::signed_with`](super::Destination::signed_with)
240/// in the ordinary case. The key is held as raw bytes rather than as the
241/// configured string, because a `whsec_`-prefixed secret names base64 of the
242/// key and not the key — signing the prefixed text would produce a MAC that
243/// every conformant verifier rejects, and the failure would surface only as a
244/// receiver refusing everything.
245#[derive(Clone)]
246pub struct BodySigning {
247    /// Every key a delivery is signed under, in configuration order.
248    ///
249    /// More than one only mid-rotation: Standard Webhooks makes
250    /// `webhook-signature` a space-separated list precisely so a sender can
251    /// sign under the old and the new key at once, and a receiver holding
252    /// either verifies. A sender that can hold only one key turns every
253    /// rotation into a flag day for the one party the mechanism was designed
254    /// to spare.
255    ///
256    /// [`Zeroizing`] rather than a wipe written by hand: a plain store loop
257    /// into a buffer that is about to be freed is a dead store the optimizer
258    /// may delete, so the hand-written version is a control that compiles and
259    /// might never run. Same reason [`Secret`] and the key ring use it.
260    keys: Vec<Zeroizing<Vec<u8>>>,
261}
262
263impl std::fmt::Debug for BodySigning {
264    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
265        f.debug_struct("BodySigning")
266            .field("keys", &"<redacted>")
267            .finish()
268    }
269}
270
271impl BodySigning {
272    /// Sign with `secret`.
273    ///
274    /// A secret spelled `whsec_<base64>` — Standard Webhooks' own form, and
275    /// what a receiver's library will be handed — is decoded, so both ends use
276    /// the same bytes. Any other string is the key itself.
277    ///
278    /// # Panics
279    ///
280    /// If the key is shorter than 24 bytes, or a `whsec_` secret is not
281    /// base64. Both are configuration this deployment wrote about itself, and
282    /// both would otherwise fail at the far end of a run: a short MAC key is a
283    /// signature that can be searched, and a check that can be defeated reads
284    /// exactly like one that means something.
285    ///
286    /// [`try_new`](Self::try_new) is the same check reported rather than thrown.
287    #[must_use]
288    pub fn new(secret: &Secret) -> Self {
289        Self::try_new(secret).unwrap_or_else(|e| panic!("{e}"))
290    }
291
292    /// Sign with `secret`, reporting a bad key rather than aborting.
293    ///
294    /// For a deployment reading this out of its own configuration: a panic
295    /// inside somebody's `build()` takes the process down before it reaches the
296    /// diagnostic every other configuration error produces.
297    ///
298    /// # Errors
299    ///
300    /// [`SigningKeyError`] — a `whsec_` secret that is not base64, or a key
301    /// under [`MIN_KEY_BYTES`].
302    pub fn try_new(secret: &Secret) -> Result<Self, SigningKeyError> {
303        Ok(Self {
304            keys: vec![Self::key_bytes(secret)?],
305        })
306    }
307
308    /// Sign under `secret` as well — the mid-rotation form.
309    ///
310    /// # Errors
311    ///
312    /// As [`try_new`](Self::try_new).
313    pub fn try_also_with(mut self, secret: &Secret) -> Result<Self, SigningKeyError> {
314        self.keys.push(Self::key_bytes(secret)?);
315        Ok(self)
316    }
317
318    /// [`try_also_with`](Self::try_also_with), panicking on a bad key — for
319    /// configuration written in code, as [`new`](Self::new) is.
320    #[must_use]
321    pub fn also_with(self, secret: &Secret) -> Self {
322        self.try_also_with(secret).unwrap_or_else(|e| panic!("{e}"))
323    }
324
325    fn key_bytes(secret: &Secret) -> Result<Zeroizing<Vec<u8>>, SigningKeyError> {
326        let raw = secret.expose();
327        let key = Zeroizing::new(match raw.strip_prefix(SYMMETRIC_KEY_PREFIX) {
328            Some(encoded) => crate::core::b64::decode(encoded).ok_or(SigningKeyError::NotBase64)?,
329            None => raw.as_bytes().to_vec(),
330        });
331        if key.len() < MIN_KEY_BYTES {
332            return Err(SigningKeyError::TooShort { bytes: key.len() });
333        }
334        Ok(key)
335    }
336
337    /// The `webhook-signature` value for one delivery.
338    ///
339    /// `id` and `at` are inside the signed content, not merely beside it — a
340    /// receiver that compares them against the headers is what makes a captured
341    /// POST expire. One `v1,<b64>` element per configured key, space-separated
342    /// as the spec writes the list.
343    pub(super) fn value_for(&self, id: &str, at: u64, body: &[u8]) -> String {
344        let mut content = Vec::with_capacity(id.len() + 24 + body.len());
345        content.extend_from_slice(id.as_bytes());
346        content.push(b'.');
347        content.extend_from_slice(at.to_string().as_bytes());
348        content.push(b'.');
349        content.extend_from_slice(body);
350        self.keys
351            .iter()
352            .map(|key| {
353                let mac = hmac_sha256(key, &content);
354                format!("v1,{}", crate::core::b64::encode(mac))
355            })
356            .collect::<Vec<_>>()
357            .join(" ")
358    }
359}
360
361/// A delivery that verified.
362#[derive(Debug, Clone, PartialEq, Eq)]
363pub struct VerifiedDelivery {
364    /// The message's own identity, stable across the emitter's retries.
365    ///
366    /// **The receiver's idempotency key**, and why this type exists rather than
367    /// the verifier returning `Ok(())`: a verified signature says the bytes are
368    /// genuine, not that they have not already been acted on.
369    ///
370    /// Hand it to
371    /// [`Runtime::run_correlated_once`](crate::runtime::Runtime::run_correlated_once)
372    /// and the duplicate is refused by the store rather than by a map in this
373    /// process — deduplicating across a fleet rather than until the next
374    /// restart.
375    pub id: String,
376    /// The instant the sender stamped on this attempt, Unix seconds.
377    ///
378    /// Already checked against the tolerance window; carried so a receiver can
379    /// log the skew it is running with and see a clock drift before deliveries
380    /// start being refused.
381    pub timestamp: u64,
382}
383
384/// The check that [`BodySigning`] is one half of.
385///
386/// # Why this ships beside the signer
387///
388/// The interesting half of verification is not the HMAC — it is the two things
389/// around it. A signature authenticates **bytes**, not freshness, so a captured
390/// POST replays forever unless a stale timestamp is refused; and at-least-once
391/// delivery makes duplicates ordinary, so genuine bytes arrive twice unless the
392/// receiver deduplicates on the id. Both are what a second implementation omits,
393/// because omitting them looks like working software: every test passes, every
394/// delivery verifies, and the failure is a replay nobody sees.
395///
396/// This refuses the first and hands back the id for the second — see
397/// [`VerifiedDelivery::id`].
398///
399/// It does not parse the body. Verification is over the exact bytes received; a
400/// verifier that deserialized first would check a signature against a
401/// re-serialization, which is what makes a whitespace-insensitive parser a
402/// signature bypass.
403#[derive(Debug, Clone)]
404pub struct WebhookVerifier {
405    keys: Vec<BodySigning>,
406    tolerance: std::time::Duration,
407}
408
409impl WebhookVerifier {
410    /// Accept deliveries signed with `secret`.
411    ///
412    /// # Errors
413    ///
414    /// [`SigningKeyError`], as [`BodySigning::try_new`].
415    pub fn new(secret: &Secret) -> Result<Self, SigningKeyError> {
416        Ok(Self {
417            keys: vec![BodySigning::try_new(secret)?],
418            tolerance: DEFAULT_TOLERANCE,
419        })
420    }
421
422    /// Also accept deliveries signed with `secret`.
423    ///
424    /// What makes a key rotation possible without an outage: a sender cannot
425    /// switch keys at the same instant as its receivers, so both are in flight
426    /// for a window. Every key is tried against every offered signature, with no
427    /// early exit, so a refusal's duration does not leak which matched.
428    ///
429    /// # Errors
430    ///
431    /// [`SigningKeyError`], as [`BodySigning::try_new`].
432    pub fn also_accepting(mut self, secret: &Secret) -> Result<Self, SigningKeyError> {
433        self.keys.push(BodySigning::try_new(secret)?);
434        Ok(self)
435    }
436
437    /// Widen or narrow the freshness window from [`DEFAULT_TOLERANCE`].
438    #[must_use]
439    pub const fn within(mut self, tolerance: std::time::Duration) -> Self {
440        self.tolerance = tolerance;
441        self
442    }
443
444    /// Verify a delivery from its headers and the **raw** body.
445    ///
446    /// `headers` is anything that iterates `(name, value)` — an
447    /// `http::HeaderMap` mapped to strings, an axum extractor, a `Vec`. Names
448    /// are matched case-insensitively, as HTTP defines them, so a proxy that
449    /// normalises case does not refuse every delivery.
450    ///
451    /// `now` is passed in rather than read, as every other clock in this crate
452    /// is.
453    ///
454    /// # Errors
455    ///
456    /// [`WebhookRejected`].
457    pub fn verify<'a, I>(
458        &self,
459        headers: I,
460        body: &[u8],
461        now: crate::core::Timestamp,
462    ) -> Result<VerifiedDelivery, WebhookRejected>
463    where
464        I: IntoIterator<Item = (&'a str, &'a str)>,
465    {
466        let mut id = None;
467        let mut timestamp = None;
468        let mut signature = None;
469        for (name, value) in headers {
470            // Matched without allocating a lowercase copy per header: a
471            // delivery carries a dozen of them and this runs per request.
472            if name.eq_ignore_ascii_case(HEADER_ID) {
473                id = Some(value);
474            } else if name.eq_ignore_ascii_case(HEADER_TIMESTAMP) {
475                timestamp = Some(value);
476            } else if name.eq_ignore_ascii_case(HEADER_SIGNATURE) {
477                signature = Some(value);
478            }
479        }
480        self.verify_parts(
481            id.ok_or(WebhookRejected::MissingHeader(HEADER_ID))?,
482            timestamp.ok_or(WebhookRejected::MissingHeader(HEADER_TIMESTAMP))?,
483            signature.ok_or(WebhookRejected::MissingHeader(HEADER_SIGNATURE))?,
484            body,
485            now,
486        )
487    }
488
489    /// [`verify`](Self::verify) with the three header values already in hand.
490    ///
491    /// The function the header form is written in terms of, so there is one
492    /// implementation of the check rather than two that can drift.
493    ///
494    /// # Errors
495    ///
496    /// [`WebhookRejected`], as [`verify`](Self::verify).
497    pub fn verify_parts(
498        &self,
499        id: &str,
500        timestamp: &str,
501        signature: &str,
502        body: &[u8],
503        now: crate::core::Timestamp,
504    ) -> Result<VerifiedDelivery, WebhookRejected> {
505        let at: u64 = timestamp
506            .trim()
507            .parse()
508            .map_err(|_| WebhookRejected::MalformedTimestamp(timestamp.to_owned()))?;
509
510        // Freshness before the MAC, deliberately. A stale delivery is refused
511        // whether or not its signature is good — that is the entire point of
512        // binding the timestamp into the signed content — and checking it first
513        // means a flood of replayed captures costs a subtraction each rather
514        // than an HMAC each.
515        // Saturating rather than bare, and **not** because an overflow is
516        // reachable: `now` is a unix second within the calendar's own range, so
517        // the clamped operand cannot take the difference past `i64` from any
518        // instant this type can name. Written this way so a reader does not
519        // have to do that arithmetic to be sure, and because the clamp feeding
520        // a bare `-` is the shape that reaches an overflow by way of the line
521        // written to prevent one — here it does not, and it is one edit away
522        // from doing so. No test pins it, because none can construct the
523        // difference; what the tests pin is that a timestamp at either edge of
524        // its type is refused as stale.
525        let skew = now
526            .unix_timestamp()
527            .saturating_sub(i64::try_from(at).unwrap_or(i64::MAX));
528        let tolerance_secs = self.tolerance.as_secs();
529        if skew.unsigned_abs() > tolerance_secs {
530            return Err(WebhookRejected::Stale {
531                skew_secs: skew,
532                tolerance_secs,
533            });
534        }
535
536        // Standard Webhooks allows a space-delimited list, which is how a
537        // sender mid-rotation offers the same body under two keys. A receiver
538        // that read only the first would refuse every delivery signed with the
539        // new key for as long as the old one led the list.
540        let offered: Vec<&str> = signature
541            .split_whitespace()
542            .filter_map(|part| part.strip_prefix(SCHEME).and_then(|r| r.strip_prefix(',')))
543            .collect();
544        if offered.is_empty() {
545            return Err(WebhookRejected::NoSupportedScheme);
546        }
547
548        // Every key against every offered signature, with no early exit on a
549        // match. Short-circuiting would make the refusal's duration depend on
550        // which key and which signature matched, which is the timing channel
551        // the constant-time comparison below exists to close — reintroduced by
552        // control flow, exactly as it would be by `==`.
553        let mut verified = false;
554        for key in &self.keys {
555            let expected = key.value_for(id, at, body);
556            for candidate in &offered {
557                verified |= constant_time_eq(
558                    expected.as_bytes(),
559                    format!("{SCHEME},{candidate}").as_bytes(),
560                );
561            }
562        }
563        if !verified {
564            return Err(WebhookRejected::SignatureMismatch);
565        }
566
567        Ok(VerifiedDelivery {
568            id: id.to_owned(),
569            timestamp: at,
570        })
571    }
572}
573
574#[cfg(test)]
575mod tests {
576    use super::*;
577
578    /// Mid-rotation, one delivery is signed under every configured key.
579    ///
580    /// Standard Webhooks makes `webhook-signature` a space-separated list so
581    /// a receiver holding either the old or the new secret verifies; a sender
582    /// that can hold only one key turns every rotation into a flag day.
583    #[test]
584    fn a_rotating_sender_signs_under_both_keys_in_one_header() {
585        let old = Secret::new("whsec_C2FVsBQIhrscChlQIMV+b5sSYspob7oD");
586        let new = Secret::new("this-is-a-brand-new-signing-secret");
587        let both = BodySigning::new(&old).also_with(&new);
588        let value = both.value_for("msg_p5jXN8AQM9LWM0D4loKWxJek", 1_614_265_330, b"{}");
589
590        let parts: Vec<&str> = value.split(' ').collect();
591        assert_eq!(parts.len(), 2, "one element per key: {value}");
592        assert_eq!(
593            parts[0],
594            BodySigning::new(&old).value_for("msg_p5jXN8AQM9LWM0D4loKWxJek", 1_614_265_330, b"{}")
595        );
596        assert_eq!(
597            parts[1],
598            BodySigning::new(&new).value_for("msg_p5jXN8AQM9LWM0D4loKWxJek", 1_614_265_330, b"{}")
599        );
600        for part in parts {
601            assert!(part.starts_with("v1,"), "spec spelling per element: {part}");
602        }
603    }
604
605    fn signing(secret: &str) -> BodySigning {
606        BodySigning::new(&Secret::new(secret))
607    }
608
609    /// RFC 4231's published vectors, which is the outside authority the
610    /// construction has. Case 1 is the ordinary path, case 2 a short text key,
611    /// and case 6 the longer-than-block-size key that exercises the
612    /// hash-the-key branch.
613    #[test]
614    fn the_construction_matches_rfc_4231() {
615        assert_eq!(
616            hex::encode(hmac_sha256(&[0x0b; 20], b"Hi There")),
617            "b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7",
618            "RFC 4231 test case 1"
619        );
620        assert_eq!(
621            hex::encode(hmac_sha256(b"Jefe", b"what do ya want for nothing?")),
622            "5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843",
623            "RFC 4231 test case 2"
624        );
625        assert_eq!(
626            hex::encode(hmac_sha256(
627                &[0xaa; 131],
628                b"Test Using Larger Than Block-Size Key - Hash Key First"
629            )),
630            "60e431591ee0b67f0d8a26aacbf5b77f8e0bc6213728c5140546040f0ee37f54",
631            "RFC 4231 test case 6: a key longer than the block must be hashed first"
632        );
633    }
634
635    /// Standard Webhooks' own published example, which is what makes this
636    /// interoperable rather than merely self-consistent: a receiver using any
637    /// of the spec's libraries computes this value.
638    #[test]
639    fn the_signature_matches_the_standard_webhooks_example() {
640        let signing = signing("whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw");
641        assert_eq!(
642            signing.value_for(
643                "msg_p5jXN8AQM9LWM0D4loKWxJek",
644                1_614_265_330,
645                b"{\"test\": 2432232314}"
646            ),
647            "v1,g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLJ1OE=",
648            "the spec's example verifies with every Standard Webhooks library, and \
649             a value of our own verifies with none of them"
650        );
651    }
652
653    /// Every input to the MAC changes it, including the two that make a
654    /// captured delivery expire.
655    #[test]
656    fn the_signature_follows_the_body_the_id_and_the_instant() {
657        let signing = signing("whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw");
658        let value = signing.value_for("msg-1", 1_700_000_000, br#"{"a":1}"#);
659        assert!(
660            value.starts_with("v1,"),
661            "the version label is how a receiver dispatches: {value}"
662        );
663        assert_ne!(
664            value,
665            signing.value_for("msg-1", 1_700_000_000, br#"{"a":2}"#),
666            "one byte of the body changed and the signature did not"
667        );
668        assert_ne!(
669            value,
670            signing.value_for("msg-2", 1_700_000_000, br#"{"a":1}"#),
671            "the id is not covered, so a replay under another id verifies"
672        );
673        assert_ne!(
674            value,
675            signing.value_for("msg-1", 1_700_000_001, br#"{"a":1}"#),
676            "the instant is not covered, so a captured delivery never expires"
677        );
678        assert_ne!(
679            value,
680            BodySigning::new(&Secret::new(
681                "whsec_bm90LXRoZS1zYW1lLWtleS1hdC1hbGwtaGVyZQ=="
682            ))
683            .value_for("msg-1", 1_700_000_000, br#"{"a":1}"#),
684            "a different key produced the same signature"
685        );
686    }
687
688    /// The key must not print itself.
689    #[test]
690    fn a_signing_key_is_redacted() {
691        let signing = signing("whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw");
692        let shown = format!("{signing:#?}");
693        assert!(!shown.contains("MfKQ"), "{shown}");
694    }
695
696    #[test]
697    #[should_panic(expected = "shorter than the 24")]
698    fn a_short_signing_key_is_refused_at_configuration() {
699        let _ = signing("too-short");
700    }
701
702    #[test]
703    #[should_panic(expected = "does not decode")]
704    fn a_whsec_secret_that_is_not_base64_is_refused_at_configuration() {
705        let _ = signing("whsec_not base64 at all !!!");
706    }
707
708    /// The same two refusals, reported rather than thrown.
709    ///
710    /// The pair matters: a `try_` variant that accepted what `new` panics on
711    /// would be a second door with a weaker rule.
712    #[test]
713    fn the_fallible_constructor_refuses_exactly_what_the_panicking_one_does() {
714        assert_eq!(
715            BodySigning::try_new(&Secret::new("too-short")).unwrap_err(),
716            SigningKeyError::TooShort { bytes: 9 }
717        );
718        assert_eq!(
719            BodySigning::try_new(&Secret::new("whsec_not base64 at all !!!")).unwrap_err(),
720            SigningKeyError::NotBase64
721        );
722        assert!(
723            BodySigning::try_new(&Secret::new("whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw")).is_ok(),
724            "the spec's own example key must be accepted"
725        );
726    }
727
728    // ── Verification ────────────────────────────────────────────────────────
729
730    const KEY: &str = "whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw";
731    const BODY: &[u8] = br#"{"claim":"C-1","amount":900}"#;
732    const AT: u64 = 1_700_000_000;
733
734    fn verifier() -> WebhookVerifier {
735        WebhookVerifier::new(&Secret::new(KEY)).expect("the spec's own key")
736    }
737
738    fn now(secs: i64) -> crate::core::Timestamp {
739        crate::core::Timestamp::from_unix_timestamp(secs).expect("representable")
740    }
741
742    fn headers(id: &str, at: u64, sig: &str) -> Vec<(String, String)> {
743        vec![
744            (HEADER_ID.to_owned(), id.to_owned()),
745            (HEADER_TIMESTAMP.to_owned(), at.to_string()),
746            (HEADER_SIGNATURE.to_owned(), sig.to_owned()),
747        ]
748    }
749
750    fn verify(
751        v: &WebhookVerifier,
752        h: &[(String, String)],
753        body: &[u8],
754        at: i64,
755    ) -> Result<VerifiedDelivery, WebhookRejected> {
756        v.verify(
757            h.iter().map(|(k, val)| (k.as_str(), val.as_str())),
758            body,
759            now(at),
760        )
761    }
762
763    /// What this crate signs, this crate verifies.
764    ///
765    /// The round trip is the property a receiver actually depends on, and it is
766    /// the one a signer shipped without a verifier can never state: both halves
767    /// existing in one place is what makes "the scheme is implemented
768    /// correctly" a testable claim rather than an intention.
769    #[test]
770    fn a_delivery_this_plane_signed_verifies_and_yields_its_dedup_key() {
771        let sig = signing(KEY).value_for("msg-1", AT, BODY);
772        let ok = verify(
773            &verifier(),
774            &headers("msg-1", AT, &sig),
775            BODY,
776            AT.cast_signed(),
777        )
778        .expect("a delivery we just signed");
779        assert_eq!(
780            ok,
781            VerifiedDelivery {
782                id: "msg-1".to_owned(),
783                timestamp: AT
784            },
785            "the id must come back: it is the receiver's idempotency key, and handing it over is the point of returning a value at all"
786        );
787    }
788
789    /// Every part of the signed content is actually checked.
790    ///
791    /// A verifier that recomputed over the body alone would pass the first of
792    /// these and fail the rest — and would be exactly the ad-hoc scheme this
793    /// one replaced, wearing the spec's header names.
794    #[test]
795    fn a_delivery_whose_body_id_or_instant_was_edited_is_refused() {
796        let v = verifier();
797        let sig = signing(KEY).value_for("msg-1", AT, BODY);
798        let at = AT.cast_signed();
799
800        for (what, headers, body) in [
801            (
802                "the body",
803                headers("msg-1", AT, &sig),
804                br#"{"claim":"C-1","amount":9000}"#.as_slice(),
805            ),
806            ("the id", headers("msg-2", AT, &sig), BODY),
807            ("the instant", headers("msg-1", AT + 1, &sig), BODY),
808        ] {
809            assert_eq!(
810                verify(&v, &headers, body, at).unwrap_err(),
811                WebhookRejected::SignatureMismatch,
812                "{what} was edited in transit and the delivery still verified"
813            );
814        }
815    }
816
817    /// A captured POST expires, which is the whole reason the instant is signed.
818    ///
819    /// Both directions. A delivery from the future is not a replay, but it is a
820    /// clock this receiver cannot reason about — and accepting it would let
821    /// whoever captured a POST choose a timestamp far enough ahead to stay
822    /// valid indefinitely, which is the unbounded replay window closed by the
823    /// front door.
824    #[test]
825    fn a_captured_delivery_stops_verifying_once_it_is_stale() {
826        let v = verifier();
827        let sig = signing(KEY).value_for("msg-1", AT, BODY);
828        let h = headers("msg-1", AT, &sig);
829        let at = AT.cast_signed();
830
831        assert!(
832            verify(&v, &h, BODY, at + 299).is_ok(),
833            "inside the window a genuine delivery must still be accepted"
834        );
835        assert!(
836            matches!(
837                verify(&v, &h, BODY, at + 301).unwrap_err(),
838                WebhookRejected::Stale { .. }
839            ),
840            "a delivery older than the tolerance is a replay this receiver cannot tell from the original"
841        );
842        assert!(
843            matches!(
844                verify(&v, &h, BODY, at - 301).unwrap_err(),
845                WebhookRejected::Stale { .. }
846            ),
847            "a delivery from the future is a clock nobody can reason about"
848        );
849        assert!(
850            verify(
851                &v.within(std::time::Duration::from_secs(3600)),
852                &h,
853                BODY,
854                at + 3000
855            )
856            .is_ok(),
857            "a receiver behind a slow queue must be able to widen the window deliberately rather than discover it at the far end of a backlog"
858        );
859    }
860
861    /// **A timestamp at the edge of its type is stale, not an overflow.**
862    ///
863    /// `webhook-timestamp` is whatever the sender wrote, and the freshness
864    /// check is the first thing a flood of replayed captures reaches — it runs
865    /// before the MAC precisely so it is cheap. A clamp feeding a bare `-` is
866    /// the shape that reaches an overflow by way of the line written to prevent
867    /// one, so both ends of the range are asserted rather than the plausible
868    /// middle.
869    #[test]
870    fn a_timestamp_at_the_edge_of_its_type_is_stale() {
871        let v = verifier();
872        let sig = signing(KEY).value_for("msg-1", AT, BODY);
873        let now = AT.cast_signed();
874
875        for hostile in [u64::MAX, i64::MAX.unsigned_abs(), 0] {
876            let h = headers("msg-1", hostile, &sig);
877            assert!(
878                matches!(
879                    verify(&v, &h, BODY, now).unwrap_err(),
880                    WebhookRejected::Stale { .. }
881                ),
882                "a timestamp of {hostile} must be refused as stale"
883            );
884        }
885    }
886
887    /// A missing signature header is a refusal, not a pass.
888    ///
889    /// The failure a receiver most often ships: verifying when the header is
890    /// present and accepting when it is absent buys nothing at all, because an
891    /// attacker simply omits it.
892    #[test]
893    fn a_delivery_with_no_signature_is_refused_rather_than_waved_through() {
894        let v = verifier();
895        let sig = signing(KEY).value_for("msg-1", AT, BODY);
896        let at = AT.cast_signed();
897        let full = headers("msg-1", AT, &sig);
898
899        for (missing, name) in [
900            (HEADER_SIGNATURE, HEADER_SIGNATURE),
901            (HEADER_ID, HEADER_ID),
902            (HEADER_TIMESTAMP, HEADER_TIMESTAMP),
903        ] {
904            let without: Vec<_> = full.iter().filter(|(k, _)| k != missing).cloned().collect();
905            assert_eq!(
906                verify(&v, &without, BODY, at).unwrap_err(),
907                WebhookRejected::MissingHeader(name)
908            );
909        }
910    }
911
912    /// Header names are matched the way HTTP defines them.
913    ///
914    /// A receiver behind a proxy that normalises case would otherwise refuse
915    /// every delivery, for a reason with nothing to do with the signature —
916    /// and the operator would be hunting a key mismatch that was never there.
917    #[test]
918    fn header_names_are_matched_case_insensitively() {
919        let sig = signing(KEY).value_for("msg-1", AT, BODY);
920        let shouting = vec![
921            ("Webhook-Id".to_owned(), "msg-1".to_owned()),
922            ("WEBHOOK-TIMESTAMP".to_owned(), AT.to_string()),
923            ("Webhook-Signature".to_owned(), sig),
924        ];
925        assert!(verify(&verifier(), &shouting, BODY, AT.cast_signed()).is_ok());
926    }
927
928    /// A rotation works from both ends: several keys accepted, several
929    /// signatures offered.
930    ///
931    /// A sender cannot switch keys at the same instant as its receivers, so
932    /// there is always a window with both in flight. A verifier holding one key
933    /// makes that window zero, and the usual answer to an impossible
934    /// requirement is that the rotation never happens.
935    #[test]
936    fn a_key_rotation_verifies_from_both_directions() {
937        let old = "whsec_bm90LXRoZS1zYW1lLWtleS1hdC1hbGwtaGVyZQ==";
938        let v = verifier()
939            .also_accepting(&Secret::new(old))
940            .expect("a valid second key");
941        let at = AT.cast_signed();
942
943        // Signed with the second key alone: the receiver accepts both.
944        let by_old = signing(old).value_for("msg-1", AT, BODY);
945        assert!(verify(&v, &headers("msg-1", AT, &by_old), BODY, at).is_ok());
946
947        // Signed with both and offered space-delimited, as the spec allows. A
948        // receiver reading only the first would refuse for as long as the other
949        // key led the list, so both orderings are checked.
950        let by_new = signing(KEY).value_for("msg-1", AT, BODY);
951        let only_new = verifier();
952        for pair in [format!("{by_old} {by_new}"), format!("{by_new} {by_old}")] {
953            assert!(
954                verify(&only_new, &headers("msg-1", AT, &pair), BODY, at).is_ok(),
955                "a receiver holding one key must find its signature anywhere in the offered list: {pair}"
956            );
957        }
958    }
959
960    /// A header carrying only labels this build cannot check says so.
961    ///
962    /// Distinct from a mismatch, and the distinction is operational: a sender
963    /// deployed ahead of its receivers is a rollout to finish, while a mismatch
964    /// is a key or a body to investigate. Reporting the first as the second
965    /// sends somebody hunting the wrong thing.
966    #[test]
967    fn an_unknown_scheme_is_told_apart_from_a_bad_signature() {
968        let v = verifier();
969        assert_eq!(
970            verify(
971                &v,
972                &headers("msg-1", AT, "v1a,c29tZXRoaW5nCg=="),
973                BODY,
974                AT.cast_signed()
975            )
976            .unwrap_err(),
977            WebhookRejected::NoSupportedScheme
978        );
979        assert_eq!(
980            verify(
981                &v,
982                &headers("msg-1", AT, "not-a-scheme"),
983                BODY,
984                AT.cast_signed()
985            )
986            .unwrap_err(),
987            WebhookRejected::NoSupportedScheme
988        );
989    }
990
991    /// A timestamp that is not a number is refused before any MAC is computed.
992    #[test]
993    fn a_malformed_timestamp_is_refused_by_name() {
994        let sig = signing(KEY).value_for("msg-1", AT, BODY);
995        let h = vec![
996            (HEADER_ID.to_owned(), "msg-1".to_owned()),
997            (HEADER_TIMESTAMP.to_owned(), "yesterday".to_owned()),
998            (HEADER_SIGNATURE.to_owned(), sig),
999        ];
1000        assert!(matches!(
1001            verify(&verifier(), &h, BODY, AT.cast_signed()).unwrap_err(),
1002            WebhookRejected::MalformedTimestamp(_)
1003        ));
1004    }
1005
1006    /// The comparison does not short-circuit on the first differing byte.
1007    ///
1008    /// Checked structurally rather than by timing, which is what a unit test can
1009    /// honestly assert: a wall-clock measurement on a shared runner is noise,
1010    /// and a test that passes on noise is worse than none.
1011    #[test]
1012    fn the_comparison_is_constant_time_in_shape() {
1013        assert!(constant_time_eq(b"abcdef", b"abcdef"));
1014        assert!(!constant_time_eq(b"abcdef", b"abcdeg"));
1015        assert!(
1016            !constant_time_eq(b"abcdef", b"abcde"),
1017            "a length difference is not a match"
1018        );
1019        assert!(
1020            !constant_time_eq(b"zbcdef", b"abcdef"),
1021            "differing in the first byte is refused exactly as differing in the last"
1022        );
1023    }
1024}