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#[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
129pub 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#[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, ×tamp.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}