Skip to main content

basil_cose/
claims.rs

1// SPDX-FileCopyrightText: 2026 OpenBasil Contributors
2//
3// SPDX-License-Identifier: Apache-2.0
4
5//! Claim structs, message roles, and parameterized claim validation.
6//!
7//! The wire codec for claims (CWT map in header 15 plus the basil private
8//! labels) lives in the codec seam; this module owns the typed claim set and
9//! its temporal/audience/role checks.
10
11use alloc::collections::BTreeSet;
12use alloc::string::String;
13use alloc::vec::Vec;
14use core::time::Duration;
15
16use crate::error::ClaimsError;
17use crate::hash::RequestHash;
18use crate::label;
19use crate::types::{KeyId, MessageId, ResponseSubject, Subject, UnixTime};
20
21/// The claim set carried in a protected header (CWT map, header 15, plus the
22/// basil private labels).
23///
24/// One struct for every role; [`MessageRole`] validators enforce which fields
25/// must / must not be present.
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct Claims {
28    /// CWT `iss` (1).
29    pub issuer: Option<Subject>,
30    /// CWT `aud` (3).
31    pub audience: Option<Subject>,
32    /// CWT `exp` (4); when absent the effective expiry is
33    /// `iat + default_ttl` from [`ValidationParams`].
34    pub expires_at: Option<UnixTime>,
35    /// CWT `iat` (6). Required.
36    pub issued_at: UnixTime,
37    /// CWT `cti` (7). Required; sender-unique inside the replay window.
38    pub message_id: MessageId,
39    /// `-70003`: must equal the outer `kid` on signed/sealed messages.
40    pub sender_key_id: Option<KeyId>,
41    /// `-70004`: the key a response must be sealed to (requests).
42    pub response_key_id: Option<KeyId>,
43    /// `-70005`: where to deliver the response (requests, optional).
44    pub response_subject: Option<ResponseSubject>,
45    /// `-70001`: the request message id this message answers (responses).
46    pub in_reply_to: Option<MessageId>,
47    /// `-70002`: SHA3-256 of the complete request bytes (responses).
48    pub request_hash: Option<RequestHash>,
49}
50
51/// Additional protected header values carried outside the CWT claim map.
52#[derive(Debug, Clone, Default, PartialEq, Eq)]
53pub struct ProtectedHeaders {
54    /// `-70006`: compact trusted-signer certificate JWTs for the signer `kid`.
55    pub signer_certificates_jwt: Vec<String>,
56}
57
58impl ProtectedHeaders {
59    /// Return true when no optional protected headers are present.
60    #[must_use]
61    pub const fn is_empty(&self) -> bool {
62        self.signer_certificates_jwt.is_empty()
63    }
64}
65
66/// Which claim shape a validator demands.
67#[derive(Debug, Clone, Copy, PartialEq, Eq)]
68pub enum MessageRole {
69    /// Requires `sender_key_id` + `response_key_id`; forbids
70    /// `in_reply_to`/`request_hash`.
71    Request,
72    /// Requires `in_reply_to` + `request_hash`; forbids
73    /// `response_key_id`/`response_subject`.
74    Response,
75    /// Peer message: requires `sender_key_id`; forbids the
76    /// request/response correlation labels.
77    Peer,
78}
79
80/// Parameterized validation bounds. The broker feeds its `[invocation]`
81/// config. `now` is injected, never sampled
82/// internally: a broker-free crate does not own time policy.
83#[derive(Debug, Clone, PartialEq, Eq)]
84pub struct ValidationParams {
85    /// The verifier's current time.
86    pub now: UnixTime,
87    /// Maximum tolerated clock skew in either direction.
88    pub max_clock_skew: Duration,
89    /// Cap on an explicit `exp - iat` span.
90    pub max_ttl: Duration,
91    /// Effective TTL when `exp` is absent.
92    pub default_ttl: Duration,
93    /// Empty = no audience restriction configured. When non-empty, a present
94    /// `aud` must match; messages without `aud` are accepted.
95    pub allowed_audiences: BTreeSet<Subject>,
96    /// The claim shape to demand.
97    pub role: MessageRole,
98}
99
100/// Saturating conversion from a `Duration` to whole seconds as `i64`.
101#[allow(clippy::cast_possible_wrap)]
102const fn secs_i64(d: Duration) -> i64 {
103    let s = d.as_secs();
104    if s > i64::MAX as u64 {
105        i64::MAX
106    } else {
107        s as i64
108    }
109}
110
111impl Claims {
112    /// Validate this claim set against `params`: clock-skew on `iat`,
113    /// effective expiry (`iat + default_ttl` when `exp` is absent), the
114    /// `max_ttl` cap on an explicit span, the audience allow-list, and the
115    /// role shape.
116    ///
117    /// # Errors
118    /// The first [`ClaimsError`] encountered, in the order above.
119    pub fn validate(&self, params: &ValidationParams) -> Result<(), ClaimsError> {
120        let skew = secs_i64(params.max_clock_skew);
121        let iat = self.issued_at.0;
122        let now = params.now.0;
123
124        if iat > now.saturating_add(skew) {
125            return Err(ClaimsError::IssuedInFuture);
126        }
127
128        let effective_exp = match self.expires_at {
129            Some(UnixTime(exp)) => {
130                let span = exp.saturating_sub(iat);
131                if span <= 0 {
132                    return Err(ClaimsError::NonPositiveTtl);
133                }
134                if span > secs_i64(params.max_ttl) {
135                    return Err(ClaimsError::TtlTooLong { seconds: span });
136                }
137                exp
138            }
139            None => iat.saturating_add(secs_i64(params.default_ttl)),
140        };
141        if now > effective_exp.saturating_add(skew) {
142            return Err(ClaimsError::Expired);
143        }
144
145        if !params.allowed_audiences.is_empty()
146            && let Some(aud) = &self.audience
147            && !params.allowed_audiences.contains(aud)
148        {
149            return Err(ClaimsError::AudienceRejected);
150        }
151
152        self.validate_role(params.role)
153    }
154
155    /// Validate only the role shape (which basil labels must / must not be
156    /// present). Builders run this before sealing.
157    ///
158    /// # Errors
159    /// [`ClaimsError::MissingClaim`] / [`ClaimsError::ForbiddenClaim`] with
160    /// the offending label.
161    pub fn validate_role(&self, role: MessageRole) -> Result<(), ClaimsError> {
162        let require = |present: bool, l: i64| {
163            if present {
164                Ok(())
165            } else {
166                Err(ClaimsError::MissingClaim { label: l })
167            }
168        };
169        let forbid = |present: bool, l: i64| {
170            if present {
171                Err(ClaimsError::ForbiddenClaim { label: l })
172            } else {
173                Ok(())
174            }
175        };
176        match role {
177            MessageRole::Request => {
178                require(self.sender_key_id.is_some(), label::SENDER_KEY_ID)?;
179                require(self.response_key_id.is_some(), label::RESPONSE_KEY_ID)?;
180                forbid(self.in_reply_to.is_some(), label::IN_REPLY_TO)?;
181                forbid(self.request_hash.is_some(), label::REQUEST_HASH)
182            }
183            MessageRole::Response => {
184                require(self.in_reply_to.is_some(), label::IN_REPLY_TO)?;
185                require(self.request_hash.is_some(), label::REQUEST_HASH)?;
186                forbid(self.response_key_id.is_some(), label::RESPONSE_KEY_ID)?;
187                forbid(self.response_subject.is_some(), label::RESPONSE_SUBJECT)
188            }
189            MessageRole::Peer => {
190                require(self.sender_key_id.is_some(), label::SENDER_KEY_ID)?;
191                forbid(self.in_reply_to.is_some(), label::IN_REPLY_TO)?;
192                forbid(self.request_hash.is_some(), label::REQUEST_HASH)?;
193                forbid(self.response_key_id.is_some(), label::RESPONSE_KEY_ID)?;
194                forbid(self.response_subject.is_some(), label::RESPONSE_SUBJECT)
195            }
196        }
197    }
198}
199
200#[cfg(test)]
201mod tests {
202    use super::*;
203    use alloc::string::ToString;
204    use alloc::vec;
205
206    fn base_claims() -> Claims {
207        Claims {
208            issuer: None,
209            audience: None,
210            expires_at: None,
211            issued_at: UnixTime(1_000),
212            message_id: MessageId::from_bytes(vec![1, 2, 3]).unwrap(),
213            sender_key_id: Some(KeyId::from_text("alice").unwrap()),
214            response_key_id: None,
215            response_subject: None,
216            in_reply_to: None,
217            request_hash: None,
218        }
219    }
220
221    fn params(now: i64) -> ValidationParams {
222        ValidationParams {
223            now: UnixTime(now),
224            max_clock_skew: Duration::from_secs(5),
225            max_ttl: Duration::from_mins(5),
226            default_ttl: Duration::from_mins(1),
227            allowed_audiences: BTreeSet::new(),
228            role: MessageRole::Peer,
229        }
230    }
231
232    #[test]
233    fn accepts_fresh_message() {
234        assert_eq!(base_claims().validate(&params(1_010)), Ok(()));
235    }
236
237    #[test]
238    fn rejects_future_iat_beyond_skew() {
239        assert_eq!(
240            base_claims().validate(&params(990)),
241            Err(ClaimsError::IssuedInFuture)
242        );
243    }
244
245    #[test]
246    fn accepts_future_iat_within_skew() {
247        assert_eq!(base_claims().validate(&params(996)), Ok(()));
248    }
249
250    #[test]
251    fn rejects_expired_default_ttl() {
252        // iat 1000 + default 60 + skew 5 => latest acceptable now is 1065.
253        assert_eq!(base_claims().validate(&params(1_065)), Ok(()));
254        assert_eq!(
255            base_claims().validate(&params(1_066)),
256            Err(ClaimsError::Expired)
257        );
258    }
259
260    #[test]
261    fn explicit_exp_overrides_default() {
262        let mut c = base_claims();
263        c.expires_at = Some(UnixTime(1_100));
264        assert_eq!(c.validate(&params(1_105)), Ok(()));
265        assert_eq!(c.validate(&params(1_106)), Err(ClaimsError::Expired));
266    }
267
268    #[test]
269    fn rejects_over_long_explicit_ttl() {
270        let mut c = base_claims();
271        c.expires_at = Some(UnixTime(1_000 + 301));
272        assert_eq!(
273            c.validate(&params(1_010)),
274            Err(ClaimsError::TtlTooLong { seconds: 301 })
275        );
276    }
277
278    #[test]
279    fn rejects_non_positive_ttl() {
280        let mut c = base_claims();
281        c.expires_at = Some(UnixTime(1_000));
282        assert_eq!(c.validate(&params(1_010)), Err(ClaimsError::NonPositiveTtl));
283    }
284
285    #[test]
286    fn audience_rules() {
287        let mut p = params(1_010);
288        p.allowed_audiences
289            .insert(Subject::new("svc-a".to_string()).unwrap());
290        // Absent aud accepted even with a non-empty allow-list.
291        assert_eq!(base_claims().validate(&p), Ok(()));
292        // Matching aud accepted.
293        let mut c = base_claims();
294        c.audience = Some(Subject::new("svc-a".to_string()).unwrap());
295        assert_eq!(c.validate(&p), Ok(()));
296        // Non-matching aud rejected.
297        c.audience = Some(Subject::new("svc-b".to_string()).unwrap());
298        assert_eq!(c.validate(&p), Err(ClaimsError::AudienceRejected));
299        // Empty allow-list = no restriction.
300        assert_eq!(c.validate(&params(1_010)), Ok(()));
301    }
302
303    #[test]
304    fn request_role_shape() {
305        let mut c = base_claims();
306        assert_eq!(
307            c.validate_role(MessageRole::Request),
308            Err(ClaimsError::MissingClaim {
309                label: label::RESPONSE_KEY_ID
310            })
311        );
312        c.response_key_id = Some(KeyId::from_text("bob").unwrap());
313        assert_eq!(c.validate_role(MessageRole::Request), Ok(()));
314        c.request_hash = Some(RequestHash([0; 32]));
315        assert_eq!(
316            c.validate_role(MessageRole::Request),
317            Err(ClaimsError::ForbiddenClaim {
318                label: label::REQUEST_HASH
319            })
320        );
321    }
322
323    #[test]
324    fn response_role_shape() {
325        let mut c = base_claims();
326        assert_eq!(
327            c.validate_role(MessageRole::Response),
328            Err(ClaimsError::MissingClaim {
329                label: label::IN_REPLY_TO
330            })
331        );
332        c.in_reply_to = Some(MessageId::from_bytes(vec![9]).unwrap());
333        c.request_hash = Some(RequestHash([0; 32]));
334        assert_eq!(c.validate_role(MessageRole::Response), Ok(()));
335        c.response_subject = Some(ResponseSubject::new("inbox".to_string()).unwrap());
336        assert_eq!(
337            c.validate_role(MessageRole::Response),
338            Err(ClaimsError::ForbiddenClaim {
339                label: label::RESPONSE_SUBJECT
340            })
341        );
342    }
343
344    #[test]
345    fn peer_role_shape() {
346        let mut c = base_claims();
347        assert_eq!(c.validate_role(MessageRole::Peer), Ok(()));
348        c.sender_key_id = None;
349        assert_eq!(
350            c.validate_role(MessageRole::Peer),
351            Err(ClaimsError::MissingClaim {
352                label: label::SENDER_KEY_ID
353            })
354        );
355    }
356}