Skip to main content

harn_vm/connectors/
hmac.rs

1use std::collections::BTreeMap;
2
3use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
4use base64::Engine;
5use hmac::{Hmac, KeyInit, Mac};
6use serde_json::json;
7use sha2::{Digest, Sha256};
8use subtle::ConstantTimeEq;
9use time::{Duration, OffsetDateTime};
10
11use crate::event_log::{EventLog, LogEvent, Topic};
12use crate::triggers::ProviderId;
13
14use super::ConnectorError;
15
16pub const SIGNATURE_VERIFY_AUDIT_TOPIC: &str = "audit.signature_verify";
17pub const DEFAULT_GITHUB_SIGNATURE_HEADER: &str = "x-hub-signature-256";
18pub const DEFAULT_LINEAR_SIGNATURE_HEADER: &str = "linear-signature";
19pub const DEFAULT_NOTION_SIGNATURE_HEADER: &str = "x-notion-signature";
20pub const DEFAULT_SLACK_SIGNATURE_HEADER: &str = "x-slack-signature";
21pub const DEFAULT_SLACK_TIMESTAMP_HEADER: &str = "x-slack-request-timestamp";
22pub const DEFAULT_STRIPE_SIGNATURE_HEADER: &str = "stripe-signature";
23pub const DEFAULT_STANDARD_WEBHOOKS_ID_HEADER: &str = "webhook-id";
24pub const DEFAULT_STANDARD_WEBHOOKS_SIGNATURE_HEADER: &str = "webhook-signature";
25pub const DEFAULT_STANDARD_WEBHOOKS_TIMESTAMP_HEADER: &str = "webhook-timestamp";
26pub const DEFAULT_CANONICAL_AUTHORIZATION_HEADER: &str = "authorization";
27pub const DEFAULT_CANONICAL_HMAC_SCHEME: &str = "HMAC-SHA256";
28
29/// Supported HMAC signature header conventions for inbound webhook providers.
30#[derive(Clone, Copy, Debug, PartialEq, Eq)]
31pub enum HmacSignatureStyle<'a> {
32    GitHub {
33        signature_header: &'a str,
34        prefix: &'a str,
35    },
36    Linear {
37        signature_header: &'a str,
38    },
39    Notion {
40        signature_header: &'a str,
41        prefix: &'a str,
42    },
43    Slack {
44        signature_header: &'a str,
45        timestamp_header: &'a str,
46        version: &'a str,
47    },
48    Stripe {
49        signature_header: &'a str,
50        version: &'a str,
51    },
52    StandardWebhooks {
53        id_header: &'a str,
54        signature_header: &'a str,
55        timestamp_header: &'a str,
56        version: &'a str,
57    },
58    CanonicalRequest {
59        authorization_header: &'a str,
60        scheme: &'a str,
61    },
62}
63
64impl<'a> HmacSignatureStyle<'a> {
65    pub fn github() -> Self {
66        Self::GitHub {
67            signature_header: DEFAULT_GITHUB_SIGNATURE_HEADER,
68            prefix: "sha256=",
69        }
70    }
71
72    pub fn stripe() -> Self {
73        Self::Stripe {
74            signature_header: DEFAULT_STRIPE_SIGNATURE_HEADER,
75            version: "v1",
76        }
77    }
78
79    pub fn linear() -> Self {
80        Self::Linear {
81            signature_header: DEFAULT_LINEAR_SIGNATURE_HEADER,
82        }
83    }
84
85    pub fn notion() -> Self {
86        Self::Notion {
87            signature_header: DEFAULT_NOTION_SIGNATURE_HEADER,
88            prefix: "sha256=",
89        }
90    }
91
92    pub fn slack() -> Self {
93        Self::Slack {
94            signature_header: DEFAULT_SLACK_SIGNATURE_HEADER,
95            timestamp_header: DEFAULT_SLACK_TIMESTAMP_HEADER,
96            version: "v0",
97        }
98    }
99
100    pub fn standard_webhooks() -> Self {
101        Self::StandardWebhooks {
102            id_header: DEFAULT_STANDARD_WEBHOOKS_ID_HEADER,
103            signature_header: DEFAULT_STANDARD_WEBHOOKS_SIGNATURE_HEADER,
104            timestamp_header: DEFAULT_STANDARD_WEBHOOKS_TIMESTAMP_HEADER,
105            version: "v1",
106        }
107    }
108
109    pub fn canonical_request() -> Self {
110        Self::CanonicalRequest {
111            authorization_header: DEFAULT_CANONICAL_AUTHORIZATION_HEADER,
112            scheme: DEFAULT_CANONICAL_HMAC_SCHEME,
113        }
114    }
115
116    fn label(self) -> &'static str {
117        match self {
118            Self::GitHub { .. } => "github",
119            Self::Linear { .. } => "linear",
120            Self::Notion { .. } => "notion",
121            Self::Slack { .. } => "slack",
122            Self::Stripe { .. } => "stripe",
123            Self::StandardWebhooks { .. } => "standard_webhooks",
124            Self::CanonicalRequest { .. } => "canonical_request",
125        }
126    }
127}
128
129/// Verify an HMAC-signed raw request body against one of the supported webhook
130/// header conventions.
131pub async fn verify_hmac_signed<L: EventLog + ?Sized>(
132    event_log: &L,
133    provider: &ProviderId,
134    style: HmacSignatureStyle<'_>,
135    body: &[u8],
136    headers: &BTreeMap<String, String>,
137    secret: &str,
138    timestamp_window: Option<Duration>,
139    now: OffsetDateTime,
140) -> Result<(), ConnectorError> {
141    match style {
142        HmacSignatureStyle::GitHub {
143            signature_header,
144            prefix,
145        }
146        | HmacSignatureStyle::Notion {
147            signature_header,
148            prefix,
149        } => {
150            verify_github(
151                event_log,
152                provider,
153                style,
154                signature_header,
155                prefix,
156                body,
157                headers,
158                secret,
159                now,
160            )
161            .await
162        }
163        HmacSignatureStyle::Linear { signature_header } => {
164            verify_linear(
165                event_log,
166                provider,
167                style,
168                signature_header,
169                body,
170                headers,
171                secret,
172                timestamp_window,
173                now,
174            )
175            .await
176        }
177        HmacSignatureStyle::Slack {
178            signature_header,
179            timestamp_header,
180            version,
181        } => {
182            verify_slack(
183                event_log,
184                provider,
185                style,
186                signature_header,
187                timestamp_header,
188                version,
189                body,
190                headers,
191                secret,
192                timestamp_window,
193                now,
194            )
195            .await
196        }
197        HmacSignatureStyle::Stripe {
198            signature_header,
199            version,
200        } => {
201            verify_stripe(
202                event_log,
203                provider,
204                style,
205                signature_header,
206                version,
207                body,
208                headers,
209                secret,
210                timestamp_window,
211                now,
212            )
213            .await
214        }
215        HmacSignatureStyle::StandardWebhooks {
216            id_header,
217            signature_header,
218            timestamp_header,
219            version,
220        } => {
221            verify_standard_webhooks(
222                event_log,
223                provider,
224                style,
225                id_header,
226                signature_header,
227                timestamp_header,
228                version,
229                body,
230                headers,
231                secret,
232                timestamp_window,
233                now,
234            )
235            .await
236        }
237        HmacSignatureStyle::CanonicalRequest { .. } => {
238            let error = ConnectorError::Unsupported(
239                "canonical-request verification requires method + path context".to_string(),
240            );
241            reject(
242                event_log,
243                provider,
244                style,
245                &error,
246                now,
247                None,
248                timestamp_window,
249            )
250            .await
251        }
252    }
253}
254
255/// Verify an `Authorization: HMAC-SHA256 ...` header against a canonical
256/// request string built from method, path, timestamp, and a SHA-256 body hash.
257#[allow(clippy::too_many_arguments)]
258pub async fn verify_hmac_authorization<L: EventLog + ?Sized>(
259    event_log: &L,
260    provider: &ProviderId,
261    method: &str,
262    path: &str,
263    body: &[u8],
264    headers: &BTreeMap<String, String>,
265    secret: &str,
266    timestamp_window: Duration,
267    now: OffsetDateTime,
268) -> Result<(), ConnectorError> {
269    let style = HmacSignatureStyle::canonical_request();
270    let HmacSignatureStyle::CanonicalRequest {
271        authorization_header,
272        scheme,
273    } = style
274    else {
275        unreachable!("canonical_request constructor must return CanonicalRequest");
276    };
277    verify_canonical_request(
278        event_log,
279        provider,
280        style,
281        authorization_header,
282        scheme,
283        method,
284        path,
285        body,
286        headers,
287        secret,
288        timestamp_window,
289        now,
290    )
291    .await
292}
293
294#[allow(clippy::too_many_arguments)]
295async fn verify_github<L: EventLog + ?Sized>(
296    event_log: &L,
297    provider: &ProviderId,
298    style: HmacSignatureStyle<'_>,
299    signature_header: &str,
300    prefix: &str,
301    body: &[u8],
302    headers: &BTreeMap<String, String>,
303    secret: &str,
304    now: OffsetDateTime,
305) -> Result<(), ConnectorError> {
306    let header = required_header(headers, signature_header).map_err(|error| {
307        ConnectorError::MissingHeader(match error {
308            ConnectorError::MissingHeader(name) => name,
309            other => other.to_string(),
310        })
311    });
312    let header = match header {
313        Ok(value) => value,
314        Err(error) => return reject(event_log, provider, style, &error, now, None, None).await,
315    };
316
317    let encoded = match header.strip_prefix(prefix) {
318        Some(value) => value,
319        None => {
320            let error = ConnectorError::InvalidHeader {
321                name: signature_header.to_string(),
322                detail: format!("expected `{prefix}` prefix"),
323            };
324            return reject(event_log, provider, style, &error, now, None, None).await;
325        }
326    };
327    let provided = match hex::decode(encoded) {
328        Ok(value) => value,
329        Err(error) => {
330            let error = ConnectorError::InvalidHeader {
331                name: signature_header.to_string(),
332                detail: error.to_string(),
333            };
334            return reject(event_log, provider, style, &error, now, None, None).await;
335        }
336    };
337
338    let expected = hmac_sha256(secret.as_bytes(), body);
339    if secure_eq(&expected, &provided) {
340        Ok(())
341    } else {
342        let error =
343            ConnectorError::invalid_signature("signature did not match the raw request body");
344        reject(event_log, provider, style, &error, now, None, None).await
345    }
346}
347
348#[allow(clippy::too_many_arguments)]
349async fn verify_linear<L: EventLog + ?Sized>(
350    event_log: &L,
351    provider: &ProviderId,
352    style: HmacSignatureStyle<'_>,
353    signature_header: &str,
354    body: &[u8],
355    headers: &BTreeMap<String, String>,
356    secret: &str,
357    timestamp_window: Option<Duration>,
358    now: OffsetDateTime,
359) -> Result<(), ConnectorError> {
360    let window = match timestamp_window {
361        Some(window) => window,
362        None => {
363            let error = ConnectorError::Unsupported(
364                "linear-style signature verification requires a timestamp window".to_string(),
365            );
366            return reject(event_log, provider, style, &error, now, None, None).await;
367        }
368    };
369
370    let payload: serde_json::Value = match serde_json::from_slice(body) {
371        Ok(payload) => payload,
372        Err(error) => {
373            let error = ConnectorError::Json(error.to_string());
374            return reject(event_log, provider, style, &error, now, None, Some(window)).await;
375        }
376    };
377    let timestamp_ms = match payload.get("webhookTimestamp").and_then(parse_json_i64ish) {
378        Some(timestamp_ms) => timestamp_ms,
379        None => {
380            let error = ConnectorError::InvalidHeader {
381                name: "webhookTimestamp".to_string(),
382                detail: "missing from payload body".to_string(),
383            };
384            return reject(event_log, provider, style, &error, now, None, Some(window)).await;
385        }
386    };
387    let timestamp = match offset_from_unix_millis(timestamp_ms) {
388        Ok(timestamp) => timestamp,
389        Err(error) => {
390            let error = ConnectorError::InvalidHeader {
391                name: "webhookTimestamp".to_string(),
392                detail: error,
393            };
394            return reject(event_log, provider, style, &error, now, None, Some(window)).await;
395        }
396    };
397    ensure_timestamp_within_window(event_log, provider, style, timestamp, window, now).await?;
398
399    let header = match required_header(headers, signature_header) {
400        Ok(value) => value,
401        Err(error) => {
402            return reject(
403                event_log,
404                provider,
405                style,
406                &error,
407                now,
408                Some(timestamp),
409                Some(window),
410            )
411            .await;
412        }
413    };
414    let provided = match hex::decode(header.trim()) {
415        Ok(value) => value,
416        Err(error) => {
417            let error = ConnectorError::InvalidHeader {
418                name: signature_header.to_string(),
419                detail: error.to_string(),
420            };
421            return reject(
422                event_log,
423                provider,
424                style,
425                &error,
426                now,
427                Some(timestamp),
428                Some(window),
429            )
430            .await;
431        }
432    };
433
434    let expected = hmac_sha256(secret.as_bytes(), body);
435    if secure_eq(&expected, &provided) {
436        Ok(())
437    } else {
438        let error =
439            ConnectorError::invalid_signature("signature did not match the raw request body");
440        reject(
441            event_log,
442            provider,
443            style,
444            &error,
445            now,
446            Some(timestamp),
447            Some(window),
448        )
449        .await
450    }
451}
452
453#[allow(clippy::too_many_arguments)]
454async fn verify_slack<L: EventLog + ?Sized>(
455    event_log: &L,
456    provider: &ProviderId,
457    style: HmacSignatureStyle<'_>,
458    signature_header: &str,
459    timestamp_header: &str,
460    version: &str,
461    body: &[u8],
462    headers: &BTreeMap<String, String>,
463    secret: &str,
464    timestamp_window: Option<Duration>,
465    now: OffsetDateTime,
466) -> Result<(), ConnectorError> {
467    let window = match timestamp_window {
468        Some(window) => window,
469        None => {
470            let error = ConnectorError::Unsupported(
471                "slack-style signature verification requires a timestamp window".to_string(),
472            );
473            return reject(event_log, provider, style, &error, now, None, None).await;
474        }
475    };
476
477    let timestamp_raw = match required_header(headers, timestamp_header) {
478        Ok(value) => value,
479        Err(error) => {
480            return reject(event_log, provider, style, &error, now, None, Some(window)).await;
481        }
482    };
483    let timestamp = match timestamp_raw.parse::<i64>() {
484        Ok(raw) => match OffsetDateTime::from_unix_timestamp(raw) {
485            Ok(timestamp) => timestamp,
486            Err(error) => {
487                let error = ConnectorError::InvalidHeader {
488                    name: timestamp_header.to_string(),
489                    detail: error.to_string(),
490                };
491                return reject(event_log, provider, style, &error, now, None, Some(window)).await;
492            }
493        },
494        Err(error) => {
495            let error = ConnectorError::InvalidHeader {
496                name: timestamp_header.to_string(),
497                detail: error.to_string(),
498            };
499            return reject(event_log, provider, style, &error, now, None, Some(window)).await;
500        }
501    };
502    ensure_timestamp_within_window(event_log, provider, style, timestamp, window, now).await?;
503
504    let header = match required_header(headers, signature_header) {
505        Ok(value) => value,
506        Err(error) => {
507            return reject(
508                event_log,
509                provider,
510                style,
511                &error,
512                now,
513                Some(timestamp),
514                Some(window),
515            )
516            .await;
517        }
518    };
519    let prefix = format!("{version}=");
520    let encoded = match header.strip_prefix(prefix.as_str()) {
521        Some(value) => value,
522        None => {
523            let error = ConnectorError::InvalidHeader {
524                name: signature_header.to_string(),
525                detail: format!("expected `{prefix}` prefix"),
526            };
527            return reject(
528                event_log,
529                provider,
530                style,
531                &error,
532                now,
533                Some(timestamp),
534                Some(window),
535            )
536            .await;
537        }
538    };
539    let provided = match hex::decode(encoded) {
540        Ok(value) => value,
541        Err(error) => {
542            let error = ConnectorError::InvalidHeader {
543                name: signature_header.to_string(),
544                detail: error.to_string(),
545            };
546            return reject(
547                event_log,
548                provider,
549                style,
550                &error,
551                now,
552                Some(timestamp),
553                Some(window),
554            )
555            .await;
556        }
557    };
558
559    let mut signed = Vec::with_capacity(version.len() + timestamp_raw.len() + body.len() + 2);
560    signed.extend_from_slice(version.as_bytes());
561    signed.push(b':');
562    signed.extend_from_slice(timestamp_raw.as_bytes());
563    signed.push(b':');
564    signed.extend_from_slice(body);
565    let expected = hmac_sha256(secret.as_bytes(), &signed);
566    if secure_eq(&expected, &provided) {
567        Ok(())
568    } else {
569        let error = ConnectorError::invalid_signature("slack signature mismatch");
570        reject(
571            event_log,
572            provider,
573            style,
574            &error,
575            now,
576            Some(timestamp),
577            Some(window),
578        )
579        .await
580    }
581}
582
583#[allow(clippy::too_many_arguments)]
584async fn verify_stripe<L: EventLog + ?Sized>(
585    event_log: &L,
586    provider: &ProviderId,
587    style: HmacSignatureStyle<'_>,
588    signature_header: &str,
589    version: &str,
590    body: &[u8],
591    headers: &BTreeMap<String, String>,
592    secret: &str,
593    timestamp_window: Option<Duration>,
594    now: OffsetDateTime,
595) -> Result<(), ConnectorError> {
596    let window = match timestamp_window {
597        Some(window) => window,
598        None => {
599            let error = ConnectorError::Unsupported(
600                "stripe-style signature verification requires a timestamp window".to_string(),
601            );
602            return reject(event_log, provider, style, &error, now, None, None).await;
603        }
604    };
605
606    let header = match required_header(headers, signature_header) {
607        Ok(value) => value,
608        Err(error) => {
609            return reject(event_log, provider, style, &error, now, None, Some(window)).await
610        }
611    };
612
613    let mut timestamp = None;
614    let mut provided = Vec::new();
615    for part in header.split(',') {
616        let (key, value) = match part.split_once('=') {
617            Some(pair) => pair,
618            None => continue,
619        };
620        if key == "t" {
621            match value.parse::<i64>() {
622                Ok(raw) => match OffsetDateTime::from_unix_timestamp(raw) {
623                    Ok(parsed) => timestamp = Some(parsed),
624                    Err(error) => {
625                        let error = ConnectorError::InvalidHeader {
626                            name: signature_header.to_string(),
627                            detail: error.to_string(),
628                        };
629                        return reject(event_log, provider, style, &error, now, None, Some(window))
630                            .await;
631                    }
632                },
633                Err(error) => {
634                    let error = ConnectorError::InvalidHeader {
635                        name: signature_header.to_string(),
636                        detail: error.to_string(),
637                    };
638                    return reject(event_log, provider, style, &error, now, None, Some(window))
639                        .await;
640                }
641            }
642        } else if key == version {
643            match hex::decode(value) {
644                Ok(signature) => provided.push(signature),
645                Err(error) => {
646                    let error = ConnectorError::InvalidHeader {
647                        name: signature_header.to_string(),
648                        detail: error.to_string(),
649                    };
650                    return reject(event_log, provider, style, &error, now, None, Some(window))
651                        .await;
652                }
653            }
654        }
655    }
656
657    let timestamp = match timestamp {
658        Some(value) => value,
659        None => {
660            let error = ConnectorError::InvalidHeader {
661                name: signature_header.to_string(),
662                detail: "missing `t=` timestamp component".to_string(),
663            };
664            return reject(event_log, provider, style, &error, now, None, Some(window)).await;
665        }
666    };
667    ensure_timestamp_within_window(event_log, provider, style, timestamp, window, now).await?;
668
669    if provided.is_empty() {
670        let error = ConnectorError::InvalidHeader {
671            name: signature_header.to_string(),
672            detail: format!("missing `{version}=` signature component"),
673        };
674        return reject(
675            event_log,
676            provider,
677            style,
678            &error,
679            now,
680            Some(timestamp),
681            Some(window),
682        )
683        .await;
684    }
685
686    let mut signed = timestamp.unix_timestamp().to_string().into_bytes();
687    signed.push(b'.');
688    signed.extend_from_slice(body);
689    let expected = hmac_sha256(secret.as_bytes(), &signed);
690    if provided
691        .iter()
692        .any(|signature| secure_eq(&expected, signature))
693    {
694        Ok(())
695    } else {
696        let error =
697            ConnectorError::invalid_signature("no stripe signature matched the raw request body");
698        reject(
699            event_log,
700            provider,
701            style,
702            &error,
703            now,
704            Some(timestamp),
705            Some(window),
706        )
707        .await
708    }
709}
710
711#[allow(clippy::too_many_arguments)]
712async fn verify_standard_webhooks<L: EventLog + ?Sized>(
713    event_log: &L,
714    provider: &ProviderId,
715    style: HmacSignatureStyle<'_>,
716    id_header: &str,
717    signature_header: &str,
718    timestamp_header: &str,
719    version: &str,
720    body: &[u8],
721    headers: &BTreeMap<String, String>,
722    secret: &str,
723    timestamp_window: Option<Duration>,
724    now: OffsetDateTime,
725) -> Result<(), ConnectorError> {
726    let window = match timestamp_window {
727        Some(window) => window,
728        None => {
729            let error = ConnectorError::Unsupported(
730                "standard-webhooks verification requires a timestamp window".to_string(),
731            );
732            return reject(event_log, provider, style, &error, now, None, None).await;
733        }
734    };
735
736    let message_id = match required_header(headers, id_header) {
737        Ok(value) => value,
738        Err(error) => {
739            return reject(event_log, provider, style, &error, now, None, Some(window)).await
740        }
741    };
742    let signature_header_value = match required_header(headers, signature_header) {
743        Ok(value) => value,
744        Err(error) => {
745            return reject(event_log, provider, style, &error, now, None, Some(window)).await
746        }
747    };
748    let timestamp_raw = match required_header(headers, timestamp_header) {
749        Ok(value) => value,
750        Err(error) => {
751            return reject(event_log, provider, style, &error, now, None, Some(window)).await
752        }
753    };
754
755    let timestamp = match timestamp_raw.parse::<i64>() {
756        Ok(raw) => match OffsetDateTime::from_unix_timestamp(raw) {
757            Ok(timestamp) => timestamp,
758            Err(error) => {
759                let error = ConnectorError::InvalidHeader {
760                    name: timestamp_header.to_string(),
761                    detail: error.to_string(),
762                };
763                return reject(event_log, provider, style, &error, now, None, Some(window)).await;
764            }
765        },
766        Err(error) => {
767            let error = ConnectorError::InvalidHeader {
768                name: timestamp_header.to_string(),
769                detail: error.to_string(),
770            };
771            return reject(event_log, provider, style, &error, now, None, Some(window)).await;
772        }
773    };
774    ensure_timestamp_within_window(event_log, provider, style, timestamp, window, now).await?;
775
776    let signing_key = match decode_standard_webhooks_secret(secret) {
777        Ok(secret) => secret,
778        Err(error) => {
779            return reject(
780                event_log,
781                provider,
782                style,
783                &error,
784                now,
785                Some(timestamp),
786                Some(window),
787            )
788            .await
789        }
790    };
791
792    let mut signed = message_id.as_bytes().to_vec();
793    signed.push(b'.');
794    signed.extend_from_slice(timestamp_raw.as_bytes());
795    signed.push(b'.');
796    signed.extend_from_slice(body);
797    let expected = hmac_sha256(&signing_key, &signed);
798
799    let mut any_v1 = false;
800    for versioned in signature_header_value.split_ascii_whitespace() {
801        let Some((current_version, encoded_signature)) = versioned.split_once(',') else {
802            continue;
803        };
804        if current_version != version {
805            continue;
806        }
807        any_v1 = true;
808        let provided = match BASE64_STANDARD.decode(encoded_signature) {
809            Ok(signature) => signature,
810            Err(error) => {
811                let error = ConnectorError::InvalidHeader {
812                    name: signature_header.to_string(),
813                    detail: error.to_string(),
814                };
815                return reject(
816                    event_log,
817                    provider,
818                    style,
819                    &error,
820                    now,
821                    Some(timestamp),
822                    Some(window),
823                )
824                .await;
825            }
826        };
827        if secure_eq(&expected, &provided) {
828            return Ok(());
829        }
830    }
831
832    let error = if any_v1 {
833        ConnectorError::invalid_signature(
834            "no standard-webhooks signature matched the raw request body",
835        )
836    } else {
837        ConnectorError::InvalidHeader {
838            name: signature_header.to_string(),
839            detail: format!("missing `{version},` signature entry"),
840        }
841    };
842    reject(
843        event_log,
844        provider,
845        style,
846        &error,
847        now,
848        Some(timestamp),
849        Some(window),
850    )
851    .await
852}
853
854#[allow(clippy::too_many_arguments)]
855async fn verify_canonical_request<L: EventLog + ?Sized>(
856    event_log: &L,
857    provider: &ProviderId,
858    style: HmacSignatureStyle<'_>,
859    authorization_header: &str,
860    scheme: &str,
861    method: &str,
862    path: &str,
863    body: &[u8],
864    headers: &BTreeMap<String, String>,
865    secret: &str,
866    timestamp_window: Duration,
867    now: OffsetDateTime,
868) -> Result<(), ConnectorError> {
869    let authorization = match required_header(headers, authorization_header) {
870        Ok(value) => value,
871        Err(error) => {
872            return reject(
873                event_log,
874                provider,
875                style,
876                &error,
877                now,
878                None,
879                Some(timestamp_window),
880            )
881            .await
882        }
883    };
884
885    let params = authorization
886        .strip_prefix(scheme)
887        .map(str::trim_start)
888        .ok_or_else(|| ConnectorError::InvalidHeader {
889            name: authorization_header.to_string(),
890            detail: format!("expected `{scheme}` authorization scheme"),
891        });
892    let params = match params {
893        Ok(value) if !value.is_empty() => value,
894        Ok(_) => {
895            let error = ConnectorError::InvalidHeader {
896                name: authorization_header.to_string(),
897                detail: "missing signature parameters".to_string(),
898            };
899            return reject(
900                event_log,
901                provider,
902                style,
903                &error,
904                now,
905                None,
906                Some(timestamp_window),
907            )
908            .await;
909        }
910        Err(error) => {
911            return reject(
912                event_log,
913                provider,
914                style,
915                &error,
916                now,
917                None,
918                Some(timestamp_window),
919            )
920            .await
921        }
922    };
923
924    let mut timestamp_raw = None;
925    let mut signature = None;
926    for part in params.split(',') {
927        let Some((key, value)) = part.trim().split_once('=') else {
928            continue;
929        };
930        match key.trim() {
931            "timestamp" => timestamp_raw = Some(value.trim()),
932            "signature" => signature = Some(value.trim()),
933            "key-id" => {}
934            _ => {}
935        }
936    }
937
938    let timestamp_raw = match timestamp_raw {
939        Some(value) if !value.is_empty() => value,
940        _ => {
941            let error = ConnectorError::InvalidHeader {
942                name: authorization_header.to_string(),
943                detail: "missing `timestamp=` parameter".to_string(),
944            };
945            return reject(
946                event_log,
947                provider,
948                style,
949                &error,
950                now,
951                None,
952                Some(timestamp_window),
953            )
954            .await;
955        }
956    };
957    let timestamp = match timestamp_raw.parse::<i64>() {
958        Ok(raw) => match OffsetDateTime::from_unix_timestamp(raw) {
959            Ok(parsed) => parsed,
960            Err(error) => {
961                let error = ConnectorError::InvalidHeader {
962                    name: authorization_header.to_string(),
963                    detail: error.to_string(),
964                };
965                return reject(
966                    event_log,
967                    provider,
968                    style,
969                    &error,
970                    now,
971                    None,
972                    Some(timestamp_window),
973                )
974                .await;
975            }
976        },
977        Err(error) => {
978            let error = ConnectorError::InvalidHeader {
979                name: authorization_header.to_string(),
980                detail: error.to_string(),
981            };
982            return reject(
983                event_log,
984                provider,
985                style,
986                &error,
987                now,
988                None,
989                Some(timestamp_window),
990            )
991            .await;
992        }
993    };
994    ensure_timestamp_within_window(event_log, provider, style, timestamp, timestamp_window, now)
995        .await?;
996
997    let signature = match signature {
998        Some(value) if !value.is_empty() => value,
999        _ => {
1000            let error = ConnectorError::InvalidHeader {
1001                name: authorization_header.to_string(),
1002                detail: "missing `signature=` parameter".to_string(),
1003            };
1004            return reject(
1005                event_log,
1006                provider,
1007                style,
1008                &error,
1009                now,
1010                Some(timestamp),
1011                Some(timestamp_window),
1012            )
1013            .await;
1014        }
1015    };
1016    let provided = match decode_base64(signature, authorization_header) {
1017        Ok(bytes) => bytes,
1018        Err(error) => {
1019            return reject(
1020                event_log,
1021                provider,
1022                style,
1023                &error,
1024                now,
1025                Some(timestamp),
1026                Some(timestamp_window),
1027            )
1028            .await
1029        }
1030    };
1031
1032    let signed = canonical_request_message(method, path, timestamp_raw, body);
1033    let expected = hmac_sha256(secret.as_bytes(), signed.as_bytes());
1034    if secure_eq(&expected, &provided) {
1035        Ok(())
1036    } else {
1037        let error =
1038            ConnectorError::invalid_signature("signature did not match the canonical request");
1039        reject(
1040            event_log,
1041            provider,
1042            style,
1043            &error,
1044            now,
1045            Some(timestamp),
1046            Some(timestamp_window),
1047        )
1048        .await
1049    }
1050}
1051
1052async fn ensure_timestamp_within_window<L: EventLog + ?Sized>(
1053    event_log: &L,
1054    provider: &ProviderId,
1055    style: HmacSignatureStyle<'_>,
1056    timestamp: OffsetDateTime,
1057    window: Duration,
1058    now: OffsetDateTime,
1059) -> Result<(), ConnectorError> {
1060    if now - timestamp > window || timestamp - now > window {
1061        let error = ConnectorError::TimestampOutOfWindow {
1062            timestamp,
1063            now,
1064            window,
1065        };
1066        return reject(
1067            event_log,
1068            provider,
1069            style,
1070            &error,
1071            now,
1072            Some(timestamp),
1073            Some(window),
1074        )
1075        .await;
1076    }
1077    Ok(())
1078}
1079
1080fn required_header<'a>(
1081    headers: &'a BTreeMap<String, String>,
1082    name: &str,
1083) -> Result<&'a str, ConnectorError> {
1084    header_value(headers, name).ok_or_else(|| ConnectorError::MissingHeader(name.to_string()))
1085}
1086
1087fn parse_json_i64ish(value: &serde_json::Value) -> Option<i64> {
1088    value
1089        .as_i64()
1090        .or_else(|| value.as_u64().and_then(|raw| i64::try_from(raw).ok()))
1091        .or_else(|| value.as_str().and_then(|raw| raw.parse::<i64>().ok()))
1092}
1093
1094fn offset_from_unix_millis(raw: i64) -> Result<OffsetDateTime, String> {
1095    let seconds = raw.div_euclid(1_000);
1096    let millis = raw.rem_euclid(1_000);
1097    let timestamp =
1098        OffsetDateTime::from_unix_timestamp(seconds).map_err(|error| error.to_string())?;
1099    timestamp
1100        .checked_add(Duration::milliseconds(millis))
1101        .ok_or_else(|| "timestamp overflow".to_string())
1102}
1103
1104fn header_value<'a>(headers: &'a BTreeMap<String, String>, name: &str) -> Option<&'a str> {
1105    headers
1106        .iter()
1107        .find(|(key, _)| key.eq_ignore_ascii_case(name))
1108        .map(|(_, value)| value.as_str())
1109}
1110
1111fn decode_standard_webhooks_secret(secret: &str) -> Result<Vec<u8>, ConnectorError> {
1112    let normalized = secret.strip_prefix("whsec_").unwrap_or(secret);
1113    let mut padded = normalized.to_string();
1114    let remainder = padded.len() % 4;
1115    if remainder != 0 {
1116        padded.push_str(&"=".repeat(4 - remainder));
1117    }
1118    BASE64_STANDARD
1119        .decode(padded)
1120        .map_err(|error| ConnectorError::InvalidHeader {
1121            name: "webhook-secret".to_string(),
1122            detail: error.to_string(),
1123        })
1124}
1125
1126fn decode_base64(value: &str, header_name: &str) -> Result<Vec<u8>, ConnectorError> {
1127    let mut padded = value.trim().to_string();
1128    let remainder = padded.len() % 4;
1129    if remainder != 0 {
1130        padded.push_str(&"=".repeat(4 - remainder));
1131    }
1132    BASE64_STANDARD
1133        .decode(padded)
1134        .map_err(|error| ConnectorError::InvalidHeader {
1135            name: header_name.to_string(),
1136            detail: error.to_string(),
1137        })
1138}
1139
1140fn canonical_request_message(method: &str, path: &str, timestamp: &str, body: &[u8]) -> String {
1141    format!(
1142        "{}\n{}\n{}\n{}",
1143        method.trim().to_ascii_uppercase(),
1144        path.trim(),
1145        timestamp.trim(),
1146        sha256_hex(body)
1147    )
1148}
1149
1150fn sha256_hex(data: &[u8]) -> String {
1151    let digest = Sha256::digest(data);
1152    let mut encoded = String::with_capacity(digest.len() * 2);
1153    for byte in digest {
1154        encoded.push_str(&format!("{byte:02x}"));
1155    }
1156    encoded
1157}
1158
1159pub(crate) fn hmac_sha256(secret: &[u8], data: &[u8]) -> Vec<u8> {
1160    let mut mac = Hmac::<Sha256>::new_from_slice(secret).expect("HMAC accepts any key length");
1161    mac.update(data);
1162    mac.finalize().into_bytes().to_vec()
1163}
1164
1165pub(crate) fn hmac_sha1(secret: &[u8], data: &[u8]) -> Vec<u8> {
1166    let mut mac = Hmac::<sha1::Sha1>::new_from_slice(secret).expect("HMAC accepts any key length");
1167    mac.update(data);
1168    mac.finalize().into_bytes().to_vec()
1169}
1170
1171pub(crate) fn secure_eq(expected: &[u8], provided: &[u8]) -> bool {
1172    expected.ct_eq(provided).into()
1173}
1174
1175async fn reject<L: EventLog + ?Sized>(
1176    event_log: &L,
1177    provider: &ProviderId,
1178    style: HmacSignatureStyle<'_>,
1179    error: &ConnectorError,
1180    now: OffsetDateTime,
1181    signed_at: Option<OffsetDateTime>,
1182    window: Option<Duration>,
1183) -> Result<(), ConnectorError> {
1184    audit_rejection(event_log, provider, style, error, now, signed_at, window).await;
1185    Err(match error {
1186        ConnectorError::DuplicateProvider(value) => {
1187            ConnectorError::DuplicateProvider(value.clone())
1188        }
1189        ConnectorError::DuplicateDelivery(value) => {
1190            ConnectorError::DuplicateDelivery(value.clone())
1191        }
1192        ConnectorError::UnknownProvider(value) => ConnectorError::UnknownProvider(value.clone()),
1193        ConnectorError::MissingHeader(value) => ConnectorError::MissingHeader(value.clone()),
1194        ConnectorError::InvalidHeader { name, detail } => ConnectorError::InvalidHeader {
1195            name: name.clone(),
1196            detail: detail.clone(),
1197        },
1198        ConnectorError::InvalidSignature(value) => ConnectorError::InvalidSignature(value.clone()),
1199        ConnectorError::TimestampOutOfWindow {
1200            timestamp,
1201            now,
1202            window,
1203        } => ConnectorError::TimestampOutOfWindow {
1204            timestamp: *timestamp,
1205            now: *now,
1206            window: *window,
1207        },
1208        ConnectorError::Json(value) => ConnectorError::Json(value.clone()),
1209        ConnectorError::Secret(value) => ConnectorError::Secret(value.clone()),
1210        ConnectorError::EventLog(value) => ConnectorError::EventLog(value.clone()),
1211        ConnectorError::HarnRuntime(value) => ConnectorError::HarnRuntime(value.clone()),
1212        ConnectorError::Client(value) => ConnectorError::Client(value.clone()),
1213        ConnectorError::Unsupported(value) => ConnectorError::Unsupported(value.clone()),
1214        ConnectorError::Activation(value) => ConnectorError::Activation(value.clone()),
1215    })
1216}
1217
1218async fn audit_rejection<L: EventLog + ?Sized>(
1219    event_log: &L,
1220    provider: &ProviderId,
1221    style: HmacSignatureStyle<'_>,
1222    error: &ConnectorError,
1223    now: OffsetDateTime,
1224    signed_at: Option<OffsetDateTime>,
1225    window: Option<Duration>,
1226) {
1227    let payload = json!({
1228        "provider": provider.as_str(),
1229        "style": style.label(),
1230        "reason": error.to_string(),
1231        "observed_at": now.format(&time::format_description::well_known::Rfc3339).ok(),
1232        "signed_at": signed_at.and_then(|value| value.format(&time::format_description::well_known::Rfc3339).ok()),
1233        "window_seconds": window.map(|value| value.whole_seconds()),
1234    });
1235    let topic = Topic::new(SIGNATURE_VERIFY_AUDIT_TOPIC).expect("audit topic is valid");
1236    if let Err(error) = event_log
1237        .append(&topic, LogEvent::new("signature_rejected", payload))
1238        .await
1239    {
1240        crate::events::log_warn(
1241            "connectors.signature_verify.audit",
1242            &format!("failed to append signature verification audit event: {error}"),
1243        );
1244    }
1245}
1246
1247#[cfg(test)]
1248mod tests {
1249    use super::*;
1250
1251    use crate::event_log::{EventLog, MemoryEventLog};
1252
1253    fn log() -> std::sync::Arc<MemoryEventLog> {
1254        std::sync::Arc::new(MemoryEventLog::new(16))
1255    }
1256
1257    async fn audit_events(
1258        log: &std::sync::Arc<MemoryEventLog>,
1259    ) -> Vec<(u64, crate::event_log::LogEvent)> {
1260        let topic = Topic::new(SIGNATURE_VERIFY_AUDIT_TOPIC).unwrap();
1261        log.read_range(&topic, None, 32).await.unwrap()
1262    }
1263
1264    #[test]
1265    fn hmac_sha256_matches_rfc4231_vectors() {
1266        assert_eq!(
1267            hex::encode(hmac_sha256(&[0x0b; 20], b"Hi There")),
1268            "b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7"
1269        );
1270        assert_eq!(
1271            hex::encode(hmac_sha256(
1272                &[0xaa; 131],
1273                b"Test Using Larger Than Block-Size Key - Hash Key First",
1274            )),
1275            "60e431591ee0b67f0d8a26aacbf5b77f8e0bc6213728c5140546040f0ee37f54"
1276        );
1277    }
1278
1279    #[test]
1280    fn hmac_sha1_matches_rfc2202_vectors() {
1281        assert_eq!(
1282            hex::encode(hmac_sha1(&[0x0b; 20], b"Hi There")),
1283            "b617318655057264e28bc0b6fb378c8ef146be00"
1284        );
1285        assert_eq!(
1286            hex::encode(hmac_sha1(
1287                &[0xaa; 80],
1288                b"Test Using Larger Than Block-Size Key - Hash Key First",
1289            )),
1290            "aa4ae5e15272d00e95705637ce8a3b55ed402112"
1291        );
1292    }
1293
1294    #[tokio::test]
1295    async fn verifies_github_signature_using_official_docs_vector() {
1296        let log = log();
1297        let mut headers = BTreeMap::new();
1298        headers.insert(
1299            "X-Hub-Signature-256".to_string(),
1300            "sha256=757107ea0eb2509fc211221cce984b8a37570b6d7586c22c46f4379c8b043e17".to_string(),
1301        );
1302
1303        verify_hmac_signed(
1304            log.as_ref(),
1305            &ProviderId::from("github"),
1306            HmacSignatureStyle::github(),
1307            b"Hello, World!",
1308            &headers,
1309            "It's a Secret to Everybody",
1310            None,
1311            OffsetDateTime::from_unix_timestamp(1_700_000_000).unwrap(),
1312        )
1313        .await
1314        .unwrap();
1315
1316        assert!(audit_events(&log).await.is_empty());
1317    }
1318
1319    #[tokio::test]
1320    async fn verifies_slack_signature_using_official_docs_vector() {
1321        let log = log();
1322        let headers = BTreeMap::from([
1323            (
1324                "X-Slack-Signature".to_string(),
1325                "v0=a2114d57b48eac39b9ad189dd8316235a7b4a8d21a10bd27519666489c69b503".to_string(),
1326            ),
1327            (
1328                "X-Slack-Request-Timestamp".to_string(),
1329                "1531420618".to_string(),
1330            ),
1331        ]);
1332        let body = b"token=xyzz0WbapA4vBCDEFasx0q6G&team_id=T1DC2JH3J&team_domain=testteamnow&channel_id=G8PSS9T3V&channel_name=foobar&user_id=U2CERLKJA&user_name=roadrunner&command=%2Fwebhook-collect&text=&response_url=https%3A%2F%2Fhooks.slack.com%2Fcommands%2FT1DC2JH3J%2F397700885554%2F96rGlfmibIGlgcZRskXaIFfN&trigger_id=398738663015.47445629121.803a0bc887a14d10d2c447fce8b6703c";
1333
1334        verify_hmac_signed(
1335            log.as_ref(),
1336            &ProviderId::from("slack"),
1337            HmacSignatureStyle::slack(),
1338            body,
1339            &headers,
1340            "8f742231b10e8888abcd99yyyzzz85a5",
1341            Some(Duration::minutes(5)),
1342            OffsetDateTime::from_unix_timestamp(1_531_420_618).unwrap(),
1343        )
1344        .await
1345        .unwrap();
1346
1347        assert!(audit_events(&log).await.is_empty());
1348    }
1349
1350    #[tokio::test]
1351    async fn verifies_standard_webhooks_using_vendor_test_vector() {
1352        let log = log();
1353        let headers = BTreeMap::from([
1354            (
1355                "webhook-id".to_string(),
1356                "msg_p5jXN8AQM9LWM0D4loKWxJek".to_string(),
1357            ),
1358            (
1359                "webhook-signature".to_string(),
1360                "v1,g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLJ1OE=".to_string(),
1361            ),
1362            ("webhook-timestamp".to_string(), "1614265330".to_string()),
1363        ]);
1364        let now = OffsetDateTime::from_unix_timestamp(1_614_265_330).unwrap();
1365
1366        verify_hmac_signed(
1367            log.as_ref(),
1368            &ProviderId::from("webhook"),
1369            HmacSignatureStyle::standard_webhooks(),
1370            br#"{"test": 2432232314}"#,
1371            &headers,
1372            "whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw",
1373            Some(Duration::minutes(5)),
1374            now,
1375        )
1376        .await
1377        .unwrap();
1378
1379        assert!(audit_events(&log).await.is_empty());
1380    }
1381
1382    #[tokio::test]
1383    async fn verifies_stripe_signature_using_vendor_fixture_shape() {
1384        let log = log();
1385        let headers = BTreeMap::from([(
1386            "Stripe-Signature".to_string(),
1387            "t=12345,v1=2672d138c9a412830f3bfe2ecc5bfb3277cf6f5b49d0119d77dd6cb64da1257e"
1388                .to_string(),
1389        )]);
1390        let body = b"{\n  \"id\": \"evt_test_webhook\",\n  \"object\": \"event\"\n}";
1391
1392        verify_hmac_signed(
1393            log.as_ref(),
1394            &ProviderId::from("stripe"),
1395            HmacSignatureStyle::stripe(),
1396            body,
1397            &headers,
1398            "whsec_test_secret",
1399            Some(Duration::seconds(30)),
1400            OffsetDateTime::from_unix_timestamp(12_350).unwrap(),
1401        )
1402        .await
1403        .unwrap();
1404
1405        assert!(audit_events(&log).await.is_empty());
1406    }
1407
1408    #[tokio::test]
1409    async fn rejects_bad_signature_and_audits_failure() {
1410        let log = log();
1411        let headers = BTreeMap::from([(
1412            "X-Hub-Signature-256".to_string(),
1413            "sha256=0000000000000000000000000000000000000000000000000000000000000000".to_string(),
1414        )]);
1415
1416        let error = verify_hmac_signed(
1417            log.as_ref(),
1418            &ProviderId::from("github"),
1419            HmacSignatureStyle::github(),
1420            b"Hello, World!",
1421            &headers,
1422            "It's a Secret to Everybody",
1423            None,
1424            OffsetDateTime::from_unix_timestamp(1_700_000_000).unwrap(),
1425        )
1426        .await
1427        .unwrap_err();
1428
1429        assert!(matches!(error, ConnectorError::InvalidSignature(_)));
1430        assert_eq!(audit_events(&log).await.len(), 1);
1431    }
1432
1433    #[tokio::test]
1434    async fn rejects_wrong_body_even_with_valid_github_header() {
1435        let log = log();
1436        let headers = BTreeMap::from([(
1437            "X-Hub-Signature-256".to_string(),
1438            "sha256=757107ea0eb2509fc211221cce984b8a37570b6d7586c22c46f4379c8b043e17".to_string(),
1439        )]);
1440
1441        let error = verify_hmac_signed(
1442            log.as_ref(),
1443            &ProviderId::from("github"),
1444            HmacSignatureStyle::github(),
1445            b"Hello, World?\n",
1446            &headers,
1447            "It's a Secret to Everybody",
1448            None,
1449            OffsetDateTime::from_unix_timestamp(1_700_000_000).unwrap(),
1450        )
1451        .await
1452        .unwrap_err();
1453
1454        assert!(matches!(error, ConnectorError::InvalidSignature(_)));
1455        assert_eq!(audit_events(&log).await.len(), 1);
1456    }
1457
1458    #[tokio::test]
1459    async fn rejects_tampered_timestamp_header() {
1460        let log = log();
1461        let headers = BTreeMap::from([(
1462            "Stripe-Signature".to_string(),
1463            "t=not-a-timestamp,v1=2672d138c9a412830f3bfe2ecc5bfb3277cf6f5b49d0119d77dd6cb64da1257e"
1464                .to_string(),
1465        )]);
1466
1467        let error = verify_hmac_signed(
1468            log.as_ref(),
1469            &ProviderId::from("stripe"),
1470            HmacSignatureStyle::stripe(),
1471            b"{\n  \"id\": \"evt_test_webhook\",\n  \"object\": \"event\"\n}",
1472            &headers,
1473            "whsec_test_secret",
1474            Some(Duration::seconds(30)),
1475            OffsetDateTime::from_unix_timestamp(12_350).unwrap(),
1476        )
1477        .await
1478        .unwrap_err();
1479
1480        assert!(matches!(error, ConnectorError::InvalidHeader { .. }));
1481        assert_eq!(audit_events(&log).await.len(), 1);
1482    }
1483
1484    #[tokio::test]
1485    async fn rejects_expired_timestamp_window() {
1486        let log = log();
1487        let headers = BTreeMap::from([(
1488            "Stripe-Signature".to_string(),
1489            "t=12345,v1=2672d138c9a412830f3bfe2ecc5bfb3277cf6f5b49d0119d77dd6cb64da1257e"
1490                .to_string(),
1491        )]);
1492
1493        let error = verify_hmac_signed(
1494            log.as_ref(),
1495            &ProviderId::from("stripe"),
1496            HmacSignatureStyle::stripe(),
1497            b"{\n  \"id\": \"evt_test_webhook\",\n  \"object\": \"event\"\n}",
1498            &headers,
1499            "whsec_test_secret",
1500            Some(Duration::seconds(10)),
1501            OffsetDateTime::from_unix_timestamp(12_400).unwrap(),
1502        )
1503        .await
1504        .unwrap_err();
1505
1506        assert!(matches!(error, ConnectorError::TimestampOutOfWindow { .. }));
1507        assert_eq!(audit_events(&log).await.len(), 1);
1508    }
1509
1510    #[tokio::test]
1511    async fn rejects_expired_slack_timestamp_window() {
1512        let log = log();
1513        let headers = BTreeMap::from([
1514            (
1515                "X-Slack-Signature".to_string(),
1516                "v0=a2114d57b48eac39b9ad189dd8316235a7b4a8d21a10bd27519666489c69b503".to_string(),
1517            ),
1518            (
1519                "X-Slack-Request-Timestamp".to_string(),
1520                "1531420618".to_string(),
1521            ),
1522        ]);
1523        let body = b"token=xyzz0WbapA4vBCDEFasx0q6G&team_id=T1DC2JH3J&team_domain=testteamnow&channel_id=G8PSS9T3V&channel_name=foobar&user_id=U2CERLKJA&user_name=roadrunner&command=%2Fwebhook-collect&text=&response_url=https%3A%2F%2Fhooks.slack.com%2Fcommands%2FT1DC2JH3J%2F397700885554%2F96rGlfmibIGlgcZRskXaIFfN&trigger_id=398738663015.47445629121.803a0bc887a14d10d2c447fce8b6703c";
1524
1525        let error = verify_hmac_signed(
1526            log.as_ref(),
1527            &ProviderId::from("slack"),
1528            HmacSignatureStyle::slack(),
1529            body,
1530            &headers,
1531            "8f742231b10e8888abcd99yyyzzz85a5",
1532            Some(Duration::minutes(5)),
1533            OffsetDateTime::from_unix_timestamp(1_531_421_000).unwrap(),
1534        )
1535        .await
1536        .unwrap_err();
1537
1538        assert!(matches!(error, ConnectorError::TimestampOutOfWindow { .. }));
1539        assert_eq!(audit_events(&log).await.len(), 1);
1540    }
1541
1542    #[tokio::test]
1543    async fn rejects_missing_signature_header() {
1544        let log = log();
1545        let headers = BTreeMap::new();
1546
1547        let error = verify_hmac_signed(
1548            log.as_ref(),
1549            &ProviderId::from("github"),
1550            HmacSignatureStyle::github(),
1551            b"Hello, World!",
1552            &headers,
1553            "It's a Secret to Everybody",
1554            None,
1555            OffsetDateTime::from_unix_timestamp(1_700_000_000).unwrap(),
1556        )
1557        .await
1558        .unwrap_err();
1559
1560        assert!(
1561            matches!(error, ConnectorError::MissingHeader(header) if header == DEFAULT_GITHUB_SIGNATURE_HEADER)
1562        );
1563        assert_eq!(audit_events(&log).await.len(), 1);
1564    }
1565
1566    fn canonical_authorization(
1567        secret: &str,
1568        method: &str,
1569        path: &str,
1570        timestamp: i64,
1571        body: &[u8],
1572    ) -> String {
1573        let signed = canonical_request_message(method, path, &timestamp.to_string(), body);
1574        let signature = hmac_sha256(secret.as_bytes(), signed.as_bytes());
1575        format!(
1576            "{} timestamp={},signature={}",
1577            DEFAULT_CANONICAL_HMAC_SCHEME,
1578            timestamp,
1579            BASE64_STANDARD.encode(signature)
1580        )
1581    }
1582
1583    #[tokio::test]
1584    async fn verifies_canonical_request_authorization() {
1585        let log = log();
1586        let body = br#"{"task":"review"}"#;
1587        let timestamp = 1_700_000_000;
1588        let headers = BTreeMap::from([(
1589            "Authorization".to_string(),
1590            canonical_authorization("shared-secret", "POST", "/a2a/review", timestamp, body),
1591        )]);
1592
1593        verify_hmac_authorization(
1594            log.as_ref(),
1595            &ProviderId::from("orchestrator"),
1596            "POST",
1597            "/a2a/review",
1598            body,
1599            &headers,
1600            "shared-secret",
1601            Duration::minutes(5),
1602            OffsetDateTime::from_unix_timestamp(timestamp).unwrap(),
1603        )
1604        .await
1605        .unwrap();
1606
1607        assert!(audit_events(&log).await.is_empty());
1608    }
1609
1610    #[tokio::test]
1611    async fn rejects_canonical_request_authorization_with_wrong_path() {
1612        let log = log();
1613        let body = br#"{"task":"review"}"#;
1614        let timestamp = 1_700_000_000;
1615        let headers = BTreeMap::from([(
1616            "authorization".to_string(),
1617            canonical_authorization("shared-secret", "POST", "/a2a/review", timestamp, body),
1618        )]);
1619
1620        let error = verify_hmac_authorization(
1621            log.as_ref(),
1622            &ProviderId::from("orchestrator"),
1623            "POST",
1624            "/a2a/other",
1625            body,
1626            &headers,
1627            "shared-secret",
1628            Duration::minutes(5),
1629            OffsetDateTime::from_unix_timestamp(timestamp).unwrap(),
1630        )
1631        .await
1632        .unwrap_err();
1633
1634        assert!(matches!(error, ConnectorError::InvalidSignature(_)));
1635        assert_eq!(audit_events(&log).await.len(), 1);
1636    }
1637}