Skip to main content

sesame/
lib.rs

1//! # SESAME
2//!
3//! Secure ESAM Authentication and Message Encryption: the proposed SCTE 130-9
4//! security layer for the ESAM interface. A portable, framework-agnostic core
5//! implementing the three additive tiers of **ANSI/SCTE 130-9 (SESAME) draft
6//! v0.5**, bidirectionally (verify inbound requests, sign/encrypt outbound
7//! responses), all carried in HTTP headers with no ESAM XML schema change.
8//!
9//! | Tier | Capability | Mechanism |
10//! |---|---|---|
11//! | 0 | Unauthenticated baseline | no SESAME headers (backward compatible) |
12//! | 1 | Authentication + integrity | HMAC-SHA256 over a canonical string |
13//! | 2 | Channel-scoped authorization | signed `X-SESAME-Scope`, policy lookup |
14//! | 3 | Payload encryption | AES-256-GCM (96-bit IV, 128-bit tag) |
15//!
16//! ## Provenance
17//!
18//! This crate is the canonical home of the SESAME protocol, extracted
19//! byte-for-byte from the `rust-pois` reference implementation (originally MIT,
20//! © POIS Contributors). The deployed `rust-pois` server is intended to depend
21//! on this crate so the protocol lives in exactly one place. Byte-level
22//! conformance is pinned by the golden vectors in `test-vectors/`, which are
23//! generated from `rust-pois` and reproduced by `tests/conformance.rs`.
24//!
25//! ## Design
26//!
27//! - **No I/O, no HTTP framework.** [`verify_request`] / [`sign_response`] take
28//!   the request parts, the parsed [`SesameHeaders`], the body, and `now`.
29//! - **Host owns the resources** via injected traits: the key directory
30//!   ([`KeyProvider`](keys::KeyProvider)) and the replay memory
31//!   ([`ReplayCache`](replay::ReplayCache)). The reference in-memory replay
32//!   cache ships; distributed stores are the host's concern.
33//! - **RNG is feature-gated.** Verification is RNG-free. Signing responses needs
34//!   a fresh nonce/IV, so [`sign_response`] and the IV/nonce helpers sit behind
35//!   the default-on `rng` feature; disable it for verify-only/embedded hosts.
36//!
37//! ## Wire format
38//!
39//! See [`SESAME.md`](https://github.com/bokelleher/sesame-sdk/blob/main/SESAME.md)
40//! for the byte-exact specification (canonical strings, headers, encodings).
41
42#![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/// A SESAME security tier. Tiers are additive (Tier N implies Tier 1..N).
67#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
68pub enum Tier {
69    /// Unauthenticated baseline (Appendix A.1), backward-compatible passthrough.
70    Zero = 0,
71    /// Authentication + integrity (HMAC-SHA256).
72    One = 1,
73    /// + channel-scoped authorization.
74    Two = 2,
75    /// + AES-256-GCM payload encryption.
76    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    /// Numeric tier level (0..3).
90    pub fn level(self) -> u8 {
91        self as u8
92    }
93}
94
95/// Deployment-wide SESAME configuration.
96#[derive(Debug, Clone)]
97pub struct SesameConfig {
98    /// Replay/freshness window in seconds (§8.5 item 6; default 300).
99    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
110/// Outcome of verifying an inbound request.
111pub struct VerifiedRequest {
112    /// The decrypted ESAM XML body (== raw body when Tier 3 was not used).
113    pub plaintext: Vec<u8>,
114    /// Authenticated signing key-id.
115    pub key_id: String,
116    /// Declared channel scope, if Tier 2 was used.
117    pub scope_channel: Option<String>,
118    /// Highest tier satisfied by this request.
119    pub achieved_tier: Tier,
120}
121
122/// A signed (and optionally encrypted) outbound response ready to send.
123pub struct SignedResponse {
124    /// SESAME headers to attach (name, value).
125    pub headers: Vec<(&'static str, String)>,
126    /// The response body bytes (ciphertext when Tier 3, else the XML).
127    pub body: Vec<u8>,
128    /// Content-Type to set (`application/octet-stream` when encrypted, §8.4).
129    pub content_type: &'static str,
130}
131
132/// Context needed to verify a request.
133pub struct RequestContext<'a> {
134    pub method: &'a str,
135    /// Exact request-target (path + query) as signed, e.g.
136    /// `/esam?channel=SportsFeed-East` (§8.2.3).
137    pub path: &'a str,
138    /// The channel the request actually targets (route/query/body-resolved),
139    /// used to cross-check the Tier-2 scope. May be `None` pre-resolution.
140    pub target_channel: Option<&'a str>,
141}
142
143/// Verify an inbound ESAM request against the SESAME protocol.
144///
145/// `min_tier` is the channel's minimum required tier (§9.3). When `min_tier` is
146/// `Tier::Zero` and no SESAME headers are present, the request passes through
147/// unauthenticated (backward compatibility). Fails closed at each step with the
148/// distinct `SesameError` from Appendix A.7.
149#[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    // Tier 0: unauthenticated passthrough, only when the policy permits it.
161    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    // --- Tier 1: authentication + integrity ---
174    let t1 = headers.require_tier1()?;
175
176    if t1.version != PROTOCOL_VERSION {
177        return Err(SesameError::InvalidVersion);
178    }
179
180    // Freshness (cheap, do before key lookup to shed obviously-stale traffic).
181    tier1_hmac::check_freshness(t1.timestamp, now, cfg.replay_window_secs)?;
182
183    // Key lookup + revocation.
184    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    // Canonical string is computed over the body AS TRANSMITTED (ciphertext when
197    // encrypted -> encrypt-then-MAC).
198    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    // Replay only AFTER the signature is valid, so an attacker cannot poison the
211    // cache with unauthenticated nonces.
212    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    // --- Tier 2: authorization ---
220    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        // Policy requires Tier 2 but no scope was declared.
226        return Err(SesameError::ScopeDenied);
227    }
228
229    // --- Tier 3: decryption ---
230    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        // Authenticated but below the channel's required tier.
263        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
274/// Parameters for signing an outbound response.
275pub struct ResponseParams<'a> {
276    /// This node's signing key-id (e.g. `pois-primary`), placed in X-SESAME-KeyId.
277    pub signing_key_id: &'a str,
278    /// acquisitionSignalID being answered, binds the response to its request
279    /// ([BO] response correlation, see canonical.rs).
280    pub correlation: &'a str,
281    /// Channel scope to echo (Tier 2+), as `channel=<id>`. `None` below Tier 2.
282    pub scope: Option<&'a str>,
283    /// Tier to emit. Must be <= the tiers this node can satisfy with its keys.
284    pub tier: Tier,
285    /// Encryption key-id for Tier 3 (X-SESAME-EncKeyId).
286    pub enc_key_id: Option<&'a str>,
287}
288
289/// Sign (and optionally encrypt) an outbound ESAM response. This is the primary
290/// SESAME protection: it authenticates the POIS's conditioning decision so a
291/// forged/tampered response (spoofed blackout/avail/redirect) is detectable.
292///
293/// A fresh nonce and (for Tier 3) a fresh IV are drawn from the OS CSPRNG per
294/// call, never reused (contrast Appendix A.4, [BO] item 4).
295#[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; // reserved (window not needed when signing)
307    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    // Tier 3: encrypt the body first (encrypt-then-MAC).
327    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            &timestamp,
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    // Tier 1: sign over the (possibly-encrypted) body.
350    let body_hash = canonical::body_hash_hex(&body);
351    let canonical = canonical::response_canonical(
352        params.correlation,
353        &timestamp,
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/// 128 bits from the OS CSPRNG (nonces, §8.5 item 5 / RFC 4086).
369#[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// ---------------------------------------------------------------------------
379// Integration-level tests: full positive round-trips and the negative matrix.
380// ---------------------------------------------------------------------------
381#[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    /// Build a signed Tier-1/2/3 request the way a conformant client would, then
412    /// return (headers, body) for verify_request.
413    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        // Schema-untouched: decrypted body is byte-for-byte the original ESAM XML.
528        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    // ---- negative matrix (handoff §8) ----
572
573    #[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()); // changes canonical
598        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        // Second identical request -> replay.
631        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(); // +600s
652        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        // Build a valid Tier-2 request but target/declare a channel the key can't use.
692        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); // damage the tag
742        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)); // body hash changes first
755    }
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        // POIS signs a response; a client re-derives the canonical string and
781        // verifies it with the POIS public key-id. This is the forged-response
782        // defense (the primary threat).
783        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, &params, XML, now()).unwrap();
792
793        // client side
794        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, &params, 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        // Attacker swaps the decision body.
836        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        // Regression for the Appendix A.4 crypto bug (errata E1 / [BO-4]): a
846        // response MUST NOT reuse the request's GCM IV under the same EncKeyId.
847        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"), // same key as the request
857        };
858        let resp = sign_response(&SesameConfig::default(), &p, &params, 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, &params, XML, now()).unwrap();
882        let r2 = sign_response(&SesameConfig::default(), &p, &params, 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}