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}