Skip to main content

sesame/
message.rs

1// src/sesame/message.rs
2//
3// SESAME header names, the parsed `SesameHeaders` view, the error taxonomy
4// (matching the paper's Appendix A.7 error-code table), and small hex helpers.
5//
6// Spec source of truth: ANSI/SCTE 130-9 (SESAME) draft v0.5, §8.2 and Appendix A.
7// Where this code resolves a gap or contradiction in the draft, the comment is
8// tagged `[BO]` and the decision is recorded in docs/SESAME_reconciliation.md.
9
10// -------------------------------------------------------------------------
11// Header names (Appendix A.6, "SESAME Header Reference")
12// -------------------------------------------------------------------------
13
14pub const H_VERSION: &str = "X-SESAME-Version";
15pub const H_KEY_ID: &str = "X-SESAME-KeyId";
16pub const H_TIMESTAMP: &str = "X-SESAME-Timestamp";
17pub const H_NONCE: &str = "X-SESAME-Nonce";
18pub const H_SIGNATURE: &str = "X-SESAME-Signature";
19pub const H_SCOPE: &str = "X-SESAME-Scope";
20pub const H_ENCRYPTED: &str = "X-SESAME-Encrypted";
21pub const H_ENC_KEY_ID: &str = "X-SESAME-EncKeyId";
22pub const H_IV: &str = "X-SESAME-IV";
23
24/// The protocol version this implementation speaks (paper §8.2: `X-SESAME-Version: 1.0`).
25pub const PROTOCOL_VERSION: &str = "1.0";
26
27// -------------------------------------------------------------------------
28// Error taxonomy (Appendix A.7, "SESAME Error Codes")
29// -------------------------------------------------------------------------
30
31/// Every distinct SESAME failure, fail-closed. Each maps 1:1 to a wire error
32/// code and HTTP status from Appendix A.7.
33///
34/// NOTE for [BO]: the draft distinguishes `sesame_unknown_key` from
35/// `sesame_signature_mismatch` (both 401). That is a mild key-enumeration
36/// oracle and conflicts with the handoff's "no-leak" goal. We follow the paper
37/// (distinct codes) but expose `http_status()` so an operator can collapse
38/// them to a single opaque 401 if desired. See reconciliation note item 7.
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub enum SesameError {
41    MissingHeaders,
42    InvalidVersion,
43    UnknownKey,
44    ExpiredTimestamp,
45    ReplayDetected,
46    SignatureMismatch,
47    ScopeDenied,
48    DecryptFailed,
49    KeyRevoked,
50}
51
52impl SesameError {
53    /// Stable wire error code (Appendix A.7, first column).
54    pub fn code(&self) -> &'static str {
55        match self {
56            SesameError::MissingHeaders => "sesame_missing_headers",
57            SesameError::InvalidVersion => "sesame_invalid_version",
58            SesameError::UnknownKey => "sesame_unknown_key",
59            SesameError::ExpiredTimestamp => "sesame_expired_timestamp",
60            SesameError::ReplayDetected => "sesame_replay_detected",
61            SesameError::SignatureMismatch => "sesame_signature_mismatch",
62            SesameError::ScopeDenied => "sesame_scope_denied",
63            SesameError::DecryptFailed => "sesame_decrypt_failed",
64            SesameError::KeyRevoked => "sesame_key_revoked",
65        }
66    }
67
68    /// HTTP status (Appendix A.7, second column).
69    pub fn http_status(&self) -> u16 {
70        match self {
71            SesameError::InvalidVersion | SesameError::DecryptFailed => 400,
72            SesameError::ScopeDenied => 403,
73            _ => 401,
74        }
75    }
76}
77
78impl core::fmt::Display for SesameError {
79    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
80        write!(f, "{}", self.code())
81    }
82}
83
84impl std::error::Error for SesameError {}
85
86// -------------------------------------------------------------------------
87// Parsed header view
88// -------------------------------------------------------------------------
89
90/// The SESAME headers extracted from a request or response. Timestamp, nonce,
91/// signature and IV are kept as their exact on-wire string forms because the
92/// signature is computed over those exact bytes.
93#[derive(Debug, Clone, Default, PartialEq, Eq)]
94pub struct SesameHeaders {
95    pub version: Option<String>,
96    pub key_id: Option<String>,
97    pub timestamp: Option<String>,
98    pub nonce: Option<String>,
99    pub signature: Option<String>,
100    pub scope: Option<String>,
101    pub encrypted: bool,
102    pub enc_key_id: Option<String>,
103    pub iv: Option<String>,
104}
105
106impl SesameHeaders {
107    /// True when none of the Tier-1 headers are present, i.e. an unauthenticated
108    /// (Tier 0) request, permitted only when the channel policy allows it (§9.3).
109    pub fn is_absent(&self) -> bool {
110        self.version.is_none()
111            && self.key_id.is_none()
112            && self.timestamp.is_none()
113            && self.nonce.is_none()
114            && self.signature.is_none()
115    }
116
117    /// Parse from any header source via a case-insensitive lookup closure.
118    /// Framework-agnostic: the axum adapter passes a closure over `HeaderMap`.
119    pub fn from_lookup<F>(get: F) -> Self
120    where
121        F: Fn(&str) -> Option<String>,
122    {
123        let encrypted = get(H_ENCRYPTED)
124            .map(|v| v.eq_ignore_ascii_case("true"))
125            .unwrap_or(false);
126        SesameHeaders {
127            version: get(H_VERSION),
128            key_id: get(H_KEY_ID),
129            timestamp: get(H_TIMESTAMP),
130            nonce: get(H_NONCE),
131            signature: get(H_SIGNATURE),
132            scope: get(H_SCOPE),
133            encrypted,
134            enc_key_id: get(H_ENC_KEY_ID),
135            iv: get(H_IV),
136        }
137    }
138
139    /// Tier-1 headers required on every authenticated message. Returns the
140    /// fields or `MissingHeaders` if any are absent.
141    pub fn require_tier1(&self) -> Result<Tier1Fields<'_>, SesameError> {
142        match (
143            self.version.as_deref(),
144            self.key_id.as_deref(),
145            self.timestamp.as_deref(),
146            self.nonce.as_deref(),
147            self.signature.as_deref(),
148        ) {
149            (Some(version), Some(key_id), Some(timestamp), Some(nonce), Some(signature)) => {
150                Ok(Tier1Fields {
151                    version,
152                    key_id,
153                    timestamp,
154                    nonce,
155                    signature,
156                })
157            }
158            _ => Err(SesameError::MissingHeaders),
159        }
160    }
161}
162
163/// Borrowed view of the mandatory Tier-1 fields after presence validation.
164pub struct Tier1Fields<'a> {
165    pub version: &'a str,
166    pub key_id: &'a str,
167    pub timestamp: &'a str,
168    pub nonce: &'a str,
169    pub signature: &'a str,
170}
171
172// -------------------------------------------------------------------------
173// Hex helpers (lowercase, per the paper, nonces/signatures/IVs are hex)
174// -------------------------------------------------------------------------
175
176pub fn hex_encode(bytes: &[u8]) -> String {
177    const LUT: &[u8; 16] = b"0123456789abcdef";
178    let mut out = String::with_capacity(bytes.len() * 2);
179    for &b in bytes {
180        out.push(LUT[(b >> 4) as usize] as char);
181        out.push(LUT[(b & 0x0f) as usize] as char);
182    }
183    out
184}
185
186pub fn hex_decode(s: &str) -> Option<Vec<u8>> {
187    if s.len() % 2 != 0 {
188        return None;
189    }
190    let bytes = s.as_bytes();
191    let mut out = Vec::with_capacity(s.len() / 2);
192    let val = |c: u8| -> Option<u8> {
193        match c {
194            b'0'..=b'9' => Some(c - b'0'),
195            b'a'..=b'f' => Some(c - b'a' + 10),
196            b'A'..=b'F' => Some(c - b'A' + 10),
197            _ => None,
198        }
199    };
200    let mut i = 0;
201    while i < bytes.len() {
202        let hi = val(bytes[i])?;
203        let lo = val(bytes[i + 1])?;
204        out.push((hi << 4) | lo);
205        i += 2;
206    }
207    Some(out)
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213
214    #[test]
215    fn hex_roundtrip() {
216        let data = [0x00u8, 0x0f, 0xa1, 0xff, 0x10];
217        assert_eq!(hex_encode(&data), "000fa1ff10");
218        assert_eq!(hex_decode("000fa1ff10").unwrap(), data);
219    }
220
221    #[test]
222    fn hex_decode_rejects_odd_and_nonhex() {
223        assert!(hex_decode("abc").is_none());
224        assert!(hex_decode("zz").is_none());
225    }
226
227    #[test]
228    fn absent_headers_detected() {
229        assert!(SesameHeaders::default().is_absent());
230    }
231
232    #[test]
233    fn error_codes_and_statuses_match_appendix() {
234        assert_eq!(SesameError::ScopeDenied.code(), "sesame_scope_denied");
235        assert_eq!(SesameError::ScopeDenied.http_status(), 403);
236        assert_eq!(SesameError::DecryptFailed.http_status(), 400);
237        assert_eq!(SesameError::InvalidVersion.http_status(), 400);
238        assert_eq!(SesameError::ReplayDetected.http_status(), 401);
239    }
240}