1#![forbid(unsafe_code)]
43
44pub mod canonical;
45pub mod keys;
46pub mod message;
47pub mod replay;
48pub mod tier1_hmac;
49pub mod tier2_authz;
50pub mod tier3_aead;
51
52#[cfg(feature = "axum")]
53pub mod axum_adapter;
54
55#[cfg(feature = "serde")]
56pub mod vectors;
57
58use time::OffsetDateTime;
59
60pub use crate::message::{SesameError, SesameHeaders};
61
62use crate::keys::KeyProvider;
63use crate::message::{hex_decode, PROTOCOL_VERSION};
64use crate::replay::ReplayCache;
65
66#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
68pub enum Tier {
69 Zero = 0,
71 One = 1,
73 Two = 2,
75 Three = 3,
77}
78
79impl Tier {
80 pub fn from_u8(n: u8) -> Tier {
81 match n {
82 0 => Tier::Zero,
83 1 => Tier::One,
84 2 => Tier::Two,
85 _ => Tier::Three,
86 }
87 }
88
89 pub fn level(self) -> u8 {
91 self as u8
92 }
93}
94
95#[derive(Debug, Clone)]
97pub struct SesameConfig {
98 pub replay_window_secs: i64,
100}
101
102impl Default for SesameConfig {
103 fn default() -> Self {
104 SesameConfig {
105 replay_window_secs: 300,
106 }
107 }
108}
109
110pub struct VerifiedRequest {
112 pub plaintext: Vec<u8>,
114 pub key_id: String,
116 pub scope_channel: Option<String>,
118 pub achieved_tier: Tier,
120}
121
122pub struct SignedResponse {
124 pub headers: Vec<(&'static str, String)>,
126 pub body: Vec<u8>,
128 pub content_type: &'static str,
130}
131
132pub struct RequestContext<'a> {
134 pub method: &'a str,
135 pub path: &'a str,
138 pub target_channel: Option<&'a str>,
141}
142
143#[allow(clippy::too_many_arguments)]
150pub fn verify_request(
151 cfg: &SesameConfig,
152 provider: &dyn KeyProvider,
153 replay: &dyn ReplayCache,
154 ctx: &RequestContext<'_>,
155 headers: &SesameHeaders,
156 raw_body: &[u8],
157 now: OffsetDateTime,
158 min_tier: Tier,
159) -> Result<VerifiedRequest, SesameError> {
160 if headers.is_absent() {
162 if min_tier == Tier::Zero {
163 return Ok(VerifiedRequest {
164 plaintext: raw_body.to_vec(),
165 key_id: String::new(),
166 scope_channel: None,
167 achieved_tier: Tier::Zero,
168 });
169 }
170 return Err(SesameError::MissingHeaders);
171 }
172
173 let t1 = headers.require_tier1()?;
175
176 if t1.version != PROTOCOL_VERSION {
177 return Err(SesameError::InvalidVersion);
178 }
179
180 tier1_hmac::check_freshness(t1.timestamp, now, cfg.replay_window_secs)?;
182
183 if provider.is_revoked(t1.key_id) {
185 return Err(SesameError::KeyRevoked);
186 }
187 let signing_keys: Vec<Vec<u8>> = provider
188 .signing_keys(t1.key_id)
189 .into_iter()
190 .map(|k| k.0)
191 .collect();
192 if signing_keys.is_empty() {
193 return Err(SesameError::UnknownKey);
194 }
195
196 let body_hash = canonical::body_hash_hex(raw_body);
199 let scope_for_sig = headers.scope.as_deref();
200 let canonical = canonical::request_canonical(
201 ctx.method,
202 ctx.path,
203 t1.timestamp,
204 t1.nonce,
205 &body_hash,
206 scope_for_sig,
207 );
208 tier1_hmac::verify_any(&signing_keys, &canonical, t1.signature)?;
209
210 if !replay.check_and_remember(t1.key_id, t1.nonce, now.unix_timestamp()) {
213 return Err(SesameError::ReplayDetected);
214 }
215
216 let mut achieved = Tier::One;
217 let mut scope_channel = None;
218
219 if let Some(scope) = headers.scope.as_deref() {
221 let channel = tier2_authz::authorize(provider, t1.key_id, scope, ctx.target_channel)?;
222 scope_channel = Some(channel);
223 achieved = Tier::Two;
224 } else if min_tier >= Tier::Two {
225 return Err(SesameError::ScopeDenied);
227 }
228
229 let plaintext = if headers.encrypted {
231 let enc_key_id = headers
232 .enc_key_id
233 .as_deref()
234 .ok_or(SesameError::DecryptFailed)?;
235 let iv_hex = headers.iv.as_deref().ok_or(SesameError::DecryptFailed)?;
236 let iv_bytes = hex_decode(iv_hex).ok_or(SesameError::DecryptFailed)?;
237 if iv_bytes.len() != tier3_aead::IV_LEN {
238 return Err(SesameError::DecryptFailed);
239 }
240 let mut iv = [0u8; tier3_aead::IV_LEN];
241 iv.copy_from_slice(&iv_bytes);
242 let aead = provider
243 .aead_key(enc_key_id)
244 .ok_or(SesameError::DecryptFailed)?;
245 let aad = tier3_aead::aad_for_headers(
246 t1.version,
247 t1.key_id,
248 t1.timestamp,
249 t1.nonce,
250 scope_for_sig,
251 );
252 let pt = tier3_aead::open(&aead.0, &iv, &aad, raw_body)?;
253 achieved = Tier::Three;
254 pt
255 } else if min_tier >= Tier::Three {
256 return Err(SesameError::DecryptFailed);
257 } else {
258 raw_body.to_vec()
259 };
260
261 if achieved < min_tier {
262 return Err(SesameError::MissingHeaders);
264 }
265
266 Ok(VerifiedRequest {
267 plaintext,
268 key_id: t1.key_id.to_string(),
269 scope_channel,
270 achieved_tier: achieved,
271 })
272}
273
274pub struct ResponseParams<'a> {
276 pub signing_key_id: &'a str,
278 pub correlation: &'a str,
281 pub scope: Option<&'a str>,
283 pub tier: Tier,
285 pub enc_key_id: Option<&'a str>,
287}
288
289#[cfg(feature = "rng")]
296pub fn sign_response(
297 cfg: &SesameConfig,
298 provider: &dyn KeyProvider,
299 params: &ResponseParams<'_>,
300 plaintext_xml: &[u8],
301 now: OffsetDateTime,
302) -> Result<SignedResponse, SesameError> {
303 use crate::message::hex_encode;
304 use time::format_description::well_known::Rfc3339;
305
306 let _ = cfg; let signing_key = provider
308 .primary_signing_key(params.signing_key_id)
309 .ok_or(SesameError::UnknownKey)?;
310
311 let timestamp = now
312 .format(&Rfc3339)
313 .map_err(|_| SesameError::ExpiredTimestamp)?;
314 let nonce = hex_encode(&random_128());
315
316 let mut headers: Vec<(&'static str, String)> = vec![
317 (message::H_VERSION, PROTOCOL_VERSION.to_string()),
318 (message::H_KEY_ID, params.signing_key_id.to_string()),
319 (message::H_TIMESTAMP, timestamp.clone()),
320 (message::H_NONCE, nonce.clone()),
321 ];
322 if let Some(scope) = params.scope {
323 headers.push((message::H_SCOPE, scope.to_string()));
324 }
325
326 let (body, content_type): (Vec<u8>, &'static str) = if params.tier >= Tier::Three {
328 let enc_key_id = params.enc_key_id.ok_or(SesameError::DecryptFailed)?;
329 let aead = provider
330 .aead_key(enc_key_id)
331 .ok_or(SesameError::DecryptFailed)?;
332 let iv = tier3_aead::random_iv();
333 let aad = tier3_aead::aad_for_headers(
334 PROTOCOL_VERSION,
335 params.signing_key_id,
336 ×tamp,
337 &nonce,
338 params.scope,
339 );
340 let ct = tier3_aead::seal(&aead.0, &iv, &aad, plaintext_xml)?;
341 headers.push((message::H_ENCRYPTED, "true".to_string()));
342 headers.push((message::H_ENC_KEY_ID, enc_key_id.to_string()));
343 headers.push((message::H_IV, hex_encode(&iv)));
344 (ct, "application/octet-stream")
345 } else {
346 (plaintext_xml.to_vec(), "application/xml")
347 };
348
349 let body_hash = canonical::body_hash_hex(&body);
351 let canonical = canonical::response_canonical(
352 params.correlation,
353 ×tamp,
354 &nonce,
355 &body_hash,
356 params.scope,
357 );
358 let signature = tier1_hmac::sign(&signing_key.0, &canonical);
359 headers.push((message::H_SIGNATURE, signature));
360
361 Ok(SignedResponse {
362 headers,
363 body,
364 content_type,
365 })
366}
367
368#[cfg(feature = "rng")]
370fn random_128() -> [u8; 16] {
371 use rand::rngs::OsRng;
372 use rand::RngCore;
373 let mut b = [0u8; 16];
374 OsRng.fill_bytes(&mut b);
375 b
376}
377
378#[cfg(test)]
382mod tests {
383 use super::*;
384 use crate::keys::{AeadKey, ChannelScope, HmacKey, StaticKeyProvider};
385 use crate::message::hex_encode;
386 use crate::replay::InMemoryReplayCache;
387 use crate::tier3_aead::KEY_LEN;
388 use time::format_description::well_known::Rfc3339;
389
390 const XML: &[u8] = b"<?xml version=\"1.0\"?><SignalProcessingEvent/>";
391
392 fn now() -> OffsetDateTime {
393 OffsetDateTime::parse("2026-02-24T18:00:00Z", &Rfc3339).unwrap()
394 }
395
396 fn provider() -> StaticKeyProvider {
397 StaticKeyProvider::new()
398 .with_signing_key(
399 "sas-east-01",
400 HmacKey(b"client-secret".to_vec()),
401 ChannelScope::list(["SportsFeed-East"]),
402 )
403 .with_signing_key(
404 "pois-primary",
405 HmacKey(b"pois-secret".to_vec()),
406 ChannelScope::all(),
407 )
408 .with_aead_key("enc-sportsfeed-2026q1", AeadKey([0x42; KEY_LEN]))
409 }
410
411 fn make_request(tier: Tier, encrypt_with: Option<&str>) -> (SesameHeaders, Vec<u8>) {
414 let p = provider();
415 let key = p.primary_signing_key("sas-east-01").unwrap().0;
416 let timestamp = "2026-02-24T18:00:00Z";
417 let nonce = "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6";
418 let scope = if tier >= Tier::Two {
419 Some("channel=SportsFeed-East")
420 } else {
421 None
422 };
423
424 let (body, enc_headers) = if tier >= Tier::Three {
425 let enc_key_id = encrypt_with.unwrap_or("enc-sportsfeed-2026q1");
426 let aead = p.aead_key(enc_key_id).unwrap();
427 let iv = [0u8; tier3_aead::IV_LEN];
428 let aad = tier3_aead::aad_for_headers(
429 PROTOCOL_VERSION,
430 "sas-east-01",
431 timestamp,
432 nonce,
433 scope,
434 );
435 let ct = tier3_aead::seal(&aead.0, &iv, &aad, XML).unwrap();
436 (ct, Some((enc_key_id.to_string(), hex_encode(&iv))))
437 } else {
438 (XML.to_vec(), None)
439 };
440
441 let body_hash = canonical::body_hash_hex(&body);
442 let canonical = canonical::request_canonical(
443 "POST",
444 "/esam?channel=SportsFeed-East",
445 timestamp,
446 nonce,
447 &body_hash,
448 scope,
449 );
450 let signature = tier1_hmac::sign(&key, &canonical);
451
452 let headers = SesameHeaders {
453 version: Some(PROTOCOL_VERSION.to_string()),
454 key_id: Some("sas-east-01".to_string()),
455 timestamp: Some(timestamp.to_string()),
456 nonce: Some(nonce.to_string()),
457 signature: Some(signature),
458 scope: scope.map(|s| s.to_string()),
459 encrypted: enc_headers.is_some(),
460 enc_key_id: enc_headers.as_ref().map(|(k, _)| k.clone()),
461 iv: enc_headers.as_ref().map(|(_, iv)| iv.clone()),
462 };
463 (headers, body)
464 }
465
466 fn ctx() -> RequestContext<'static> {
467 RequestContext {
468 method: "POST",
469 path: "/esam?channel=SportsFeed-East",
470 target_channel: Some("SportsFeed-East"),
471 }
472 }
473
474 #[test]
475 fn tier1_roundtrip() {
476 let (h, body) = make_request(Tier::One, None);
477 let cache = InMemoryReplayCache::new(300);
478 let v = verify_request(
479 &SesameConfig::default(),
480 &provider(),
481 &cache,
482 &ctx(),
483 &h,
484 &body,
485 now(),
486 Tier::One,
487 )
488 .expect("tier1 must verify");
489 assert_eq!(v.plaintext, XML);
490 assert_eq!(v.achieved_tier, Tier::One);
491 }
492
493 #[test]
494 fn tier2_roundtrip() {
495 let (h, body) = make_request(Tier::Two, None);
496 let cache = InMemoryReplayCache::new(300);
497 let v = verify_request(
498 &SesameConfig::default(),
499 &provider(),
500 &cache,
501 &ctx(),
502 &h,
503 &body,
504 now(),
505 Tier::Two,
506 )
507 .expect("tier2 must verify");
508 assert_eq!(v.scope_channel.as_deref(), Some("SportsFeed-East"));
509 assert_eq!(v.achieved_tier, Tier::Two);
510 }
511
512 #[test]
513 fn tier3_roundtrip_decrypts_to_original_xml() {
514 let (h, body) = make_request(Tier::Three, None);
515 let cache = InMemoryReplayCache::new(300);
516 let v = verify_request(
517 &SesameConfig::default(),
518 &provider(),
519 &cache,
520 &ctx(),
521 &h,
522 &body,
523 now(),
524 Tier::Three,
525 )
526 .expect("tier3 must verify");
527 assert_eq!(v.plaintext, XML);
529 assert_eq!(v.achieved_tier, Tier::Three);
530 }
531
532 #[test]
533 fn tier0_passthrough_when_allowed() {
534 let cache = InMemoryReplayCache::new(300);
535 let h = SesameHeaders::default();
536 let v = verify_request(
537 &SesameConfig::default(),
538 &provider(),
539 &cache,
540 &ctx(),
541 &h,
542 XML,
543 now(),
544 Tier::Zero,
545 )
546 .expect("tier0 passthrough");
547 assert_eq!(v.achieved_tier, Tier::Zero);
548 assert_eq!(v.plaintext, XML);
549 }
550
551 #[test]
552 fn tier0_rejected_when_tier1_required() {
553 let cache = InMemoryReplayCache::new(300);
554 let h = SesameHeaders::default();
555 assert_eq!(
556 verify_request(
557 &SesameConfig::default(),
558 &provider(),
559 &cache,
560 &ctx(),
561 &h,
562 XML,
563 now(),
564 Tier::One
565 )
566 .err(),
567 Some(SesameError::MissingHeaders)
568 );
569 }
570
571 #[test]
574 fn tampered_body_rejected() {
575 let (h, mut body) = make_request(Tier::One, None);
576 body.extend_from_slice(b"<!-- injected -->");
577 let cache = InMemoryReplayCache::new(300);
578 assert_eq!(
579 verify_request(
580 &SesameConfig::default(),
581 &provider(),
582 &cache,
583 &ctx(),
584 &h,
585 &body,
586 now(),
587 Tier::One
588 )
589 .err(),
590 Some(SesameError::SignatureMismatch)
591 );
592 }
593
594 #[test]
595 fn tampered_signed_header_rejected() {
596 let (mut h, body) = make_request(Tier::One, None);
597 h.nonce = Some("ffffffffffffffffffffffffffffffff".to_string()); let cache = InMemoryReplayCache::new(300);
599 assert_eq!(
600 verify_request(
601 &SesameConfig::default(),
602 &provider(),
603 &cache,
604 &ctx(),
605 &h,
606 &body,
607 now(),
608 Tier::One
609 )
610 .err(),
611 Some(SesameError::SignatureMismatch)
612 );
613 }
614
615 #[test]
616 fn replayed_nonce_rejected() {
617 let (h, body) = make_request(Tier::One, None);
618 let cache = InMemoryReplayCache::new(300);
619 assert!(verify_request(
620 &SesameConfig::default(),
621 &provider(),
622 &cache,
623 &ctx(),
624 &h,
625 &body,
626 now(),
627 Tier::One
628 )
629 .is_ok());
630 assert_eq!(
632 verify_request(
633 &SesameConfig::default(),
634 &provider(),
635 &cache,
636 &ctx(),
637 &h,
638 &body,
639 now(),
640 Tier::One
641 )
642 .err(),
643 Some(SesameError::ReplayDetected)
644 );
645 }
646
647 #[test]
648 fn stale_timestamp_rejected() {
649 let (h, body) = make_request(Tier::One, None);
650 let cache = InMemoryReplayCache::new(300);
651 let later = OffsetDateTime::parse("2026-02-24T18:10:00Z", &Rfc3339).unwrap(); assert_eq!(
653 verify_request(
654 &SesameConfig::default(),
655 &provider(),
656 &cache,
657 &ctx(),
658 &h,
659 &body,
660 later,
661 Tier::One
662 )
663 .err(),
664 Some(SesameError::ExpiredTimestamp)
665 );
666 }
667
668 #[test]
669 fn unknown_key_rejected() {
670 let (mut h, body) = make_request(Tier::One, None);
671 h.key_id = Some("ghost".to_string());
672 let cache = InMemoryReplayCache::new(300);
673 assert_eq!(
674 verify_request(
675 &SesameConfig::default(),
676 &provider(),
677 &cache,
678 &ctx(),
679 &h,
680 &body,
681 now(),
682 Tier::One
683 )
684 .err(),
685 Some(SesameError::UnknownKey)
686 );
687 }
688
689 #[test]
690 fn unauthorized_channel_rejected() {
691 let p = provider();
693 let key = p.primary_signing_key("sas-east-01").unwrap().0;
694 let timestamp = "2026-02-24T18:00:00Z";
695 let nonce = "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6";
696 let scope = "channel=PremiumFeed";
697 let body_hash = canonical::body_hash_hex(XML);
698 let canonical = canonical::request_canonical(
699 "POST",
700 "/esam?channel=PremiumFeed",
701 timestamp,
702 nonce,
703 &body_hash,
704 Some(scope),
705 );
706 let signature = tier1_hmac::sign(&key, &canonical);
707 let h = SesameHeaders {
708 version: Some(PROTOCOL_VERSION.to_string()),
709 key_id: Some("sas-east-01".to_string()),
710 timestamp: Some(timestamp.to_string()),
711 nonce: Some(nonce.to_string()),
712 signature: Some(signature),
713 scope: Some(scope.to_string()),
714 ..Default::default()
715 };
716 let ctx = RequestContext {
717 method: "POST",
718 path: "/esam?channel=PremiumFeed",
719 target_channel: Some("PremiumFeed"),
720 };
721 let cache = InMemoryReplayCache::new(300);
722 assert_eq!(
723 verify_request(
724 &SesameConfig::default(),
725 &p,
726 &cache,
727 &ctx,
728 &h,
729 XML,
730 now(),
731 Tier::Two
732 )
733 .err(),
734 Some(SesameError::ScopeDenied)
735 );
736 }
737
738 #[test]
739 fn truncated_gcm_tag_rejected() {
740 let (h, mut body) = make_request(Tier::Three, None);
741 body.truncate(body.len() - 1); let cache = InMemoryReplayCache::new(300);
743 let err = verify_request(
744 &SesameConfig::default(),
745 &provider(),
746 &cache,
747 &ctx(),
748 &h,
749 &body,
750 now(),
751 Tier::Three,
752 )
753 .err();
754 assert_eq!(err, Some(SesameError::SignatureMismatch)); }
756
757 #[test]
758 fn wrong_version_rejected() {
759 let (mut h, body) = make_request(Tier::One, None);
760 h.version = Some("2.0".to_string());
761 let cache = InMemoryReplayCache::new(300);
762 assert_eq!(
763 verify_request(
764 &SesameConfig::default(),
765 &provider(),
766 &cache,
767 &ctx(),
768 &h,
769 &body,
770 now(),
771 Tier::One
772 )
773 .err(),
774 Some(SesameError::InvalidVersion)
775 );
776 }
777
778 #[test]
779 fn response_sign_and_client_verify_roundtrip() {
780 let p = provider();
784 let params = ResponseParams {
785 signing_key_id: "pois-primary",
786 correlation: "sig-20260224-001",
787 scope: Some("channel=SportsFeed-East"),
788 tier: Tier::Two,
789 enc_key_id: None,
790 };
791 let resp = sign_response(&SesameConfig::default(), &p, ¶ms, XML, now()).unwrap();
792
793 let get = |name: &str| {
795 resp.headers
796 .iter()
797 .find(|(k, _)| *k == name)
798 .map(|(_, v)| v.clone())
799 };
800 let ts = get(message::H_TIMESTAMP).unwrap();
801 let nonce = get(message::H_NONCE).unwrap();
802 let sig = get(message::H_SIGNATURE).unwrap();
803 let body_hash = canonical::body_hash_hex(&resp.body);
804 let canonical = canonical::response_canonical(
805 "sig-20260224-001",
806 &ts,
807 &nonce,
808 &body_hash,
809 Some("channel=SportsFeed-East"),
810 );
811 let key = p.primary_signing_key("pois-primary").unwrap().0;
812 assert!(tier1_hmac::verify(&key, &canonical, &sig).is_ok());
813 }
814
815 #[test]
816 fn forged_response_detected() {
817 let p = provider();
818 let params = ResponseParams {
819 signing_key_id: "pois-primary",
820 correlation: "sig-1",
821 scope: None,
822 tier: Tier::One,
823 enc_key_id: None,
824 };
825 let resp = sign_response(&SesameConfig::default(), &p, ¶ms, XML, now()).unwrap();
826 let get = |name: &str| {
827 resp.headers
828 .iter()
829 .find(|(k, _)| *k == name)
830 .map(|(_, v)| v.clone())
831 };
832 let ts = get(message::H_TIMESTAMP).unwrap();
833 let nonce = get(message::H_NONCE).unwrap();
834 let sig = get(message::H_SIGNATURE).unwrap();
835 let forged = b"<SignalProcessingNotification action=\"blackout\"/>";
837 let body_hash = canonical::body_hash_hex(forged);
838 let canonical = canonical::response_canonical("sig-1", &ts, &nonce, &body_hash, None);
839 let key = p.primary_signing_key("pois-primary").unwrap().0;
840 assert!(tier1_hmac::verify(&key, &canonical, &sig).is_err());
841 }
842
843 #[test]
844 fn response_iv_differs_from_request_iv() {
845 let (req_headers, _req_body) = make_request(Tier::Three, None);
848 let req_iv = req_headers.iv.clone().unwrap();
849
850 let p = provider();
851 let params = ResponseParams {
852 signing_key_id: "pois-primary",
853 correlation: "sig-001",
854 scope: Some("channel=SportsFeed-East"),
855 tier: Tier::Three,
856 enc_key_id: Some("enc-sportsfeed-2026q1"), };
858 let resp = sign_response(&SesameConfig::default(), &p, ¶ms, XML, now()).unwrap();
859 let resp_iv = resp
860 .headers
861 .iter()
862 .find(|(k, _)| *k == message::H_IV)
863 .map(|(_, v)| v.clone())
864 .unwrap();
865 assert_ne!(
866 req_iv, resp_iv,
867 "response reused the request IV under the same EncKeyId"
868 );
869 }
870
871 #[test]
872 fn tier3_response_uses_fresh_iv() {
873 let p = provider();
874 let params = ResponseParams {
875 signing_key_id: "pois-primary",
876 correlation: "sig-1",
877 scope: Some("channel=SportsFeed-East"),
878 tier: Tier::Three,
879 enc_key_id: Some("enc-sportsfeed-2026q1"),
880 };
881 let r1 = sign_response(&SesameConfig::default(), &p, ¶ms, XML, now()).unwrap();
882 let r2 = sign_response(&SesameConfig::default(), &p, ¶ms, XML, now()).unwrap();
883 let iv = |r: &SignedResponse| {
884 r.headers
885 .iter()
886 .find(|(k, _)| *k == message::H_IV)
887 .map(|(_, v)| v.clone())
888 .unwrap()
889 };
890 assert_ne!(iv(&r1), iv(&r2), "each response MUST use a fresh GCM IV");
891 assert_eq!(r1.content_type, "application/octet-stream");
892 }
893}