Skip to main content

agent_capability_token/
claims.rs

1//! The ACT claim set.
2//!
3//! Claims divide into two groups, and the split is deliberate.
4//!
5//! Claims a *verifier* must enforce are typed: the temporal window, issuer and
6//! audience, the scope grant, the proof-of-possession binding required by ANVIL
7//! section 5.2, and the delegation constraints that make child capabilities a
8//! strict subset of their parent's.
9//!
10//! Claims only the *issuer* interprets - rate limits, spend budgets, device and
11//! network bindings, audit trace - are carried in [`ActClaims::ext`] as opaque
12//! CBOR. They travel inside the signature, so they cannot be stripped or
13//! altered in transit, but this crate does not restate their schema. That
14//! restatement is what produced the divergence this format replaces: three
15//! copies of the claim structure, each drifting from the issuer.
16
17use std::collections::BTreeMap;
18
19use serde::{Deserialize, Deserializer, Serialize};
20
21use crate::error::{ActError, ActResult};
22use crate::scope::Scope;
23
24/// Proof-of-possession binding.
25///
26/// ANVIL section 5.2 requires that an ACT be bound to the agent's OAS DID by
27/// proof-of-possession. A verifier that ignores this claim cannot tell a
28/// legitimate holder from someone replaying a captured token, which is why it
29/// is typed here rather than left to the extension map.
30#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
31pub struct Confirmation {
32    /// Fingerprint of the key the holder must demonstrate control of.
33    pub key_fingerprint: String,
34    /// Algorithm the fingerprint and possession proof use, for example
35    /// `"Ed25519"`.
36    pub alg: String,
37}
38
39/// Constraints governing whether and how this token may be delegated onward.
40///
41/// ANVIL section 5.2 requires that an agent not delegate capabilities it does
42/// not hold, and that child capabilities be a strict subset of the parent's.
43#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
44pub struct Delegation {
45    /// Whether onward delegation is permitted at all.
46    pub allow_delegation: bool,
47    /// Remaining delegation depth. Zero forbids further delegation.
48    pub max_depth: u8,
49    /// Subset of scopes that may be delegated. `None` means the holder's full
50    /// scope set is delegatable, subject to `allow_delegation`.
51    #[serde(default, skip_serializing_if = "Option::is_none")]
52    pub delegatable_scopes: Option<Vec<Scope>>,
53    /// Whether any recipient may receive a delegation.
54    pub allow_any_delegate: bool,
55    /// DIDs permitted to receive a delegation when `allow_any_delegate` is
56    /// false.
57    #[serde(default, skip_serializing_if = "Vec::is_empty")]
58    pub allowed_delegates: Vec<String>,
59    /// Minimum reduction in lifetime, in seconds, that a delegated token must
60    /// apply relative to this one.
61    #[serde(default)]
62    pub min_ttl_reduction_seconds: i64,
63}
64
65/// The claim set carried inside an ACT envelope.
66///
67/// Field names are the wire keys. Unknown top-level keys are ignored on decode
68/// so that a newer issuer can add typed claims without breaking existing
69/// verifiers; issuer-specific claims belong in [`ActClaims::ext`] instead.
70#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
71pub struct ActClaims {
72    /// Token identifier, unique per issuance. Text, not bytes - a UUID here is
73    /// carried in its canonical hyphenated form.
74    pub jti: String,
75
76    /// Subject: the OAS DID of the agent this token was issued to.
77    pub sub: String,
78
79    /// Issuer: the broker instance that minted this token.
80    pub iss: String,
81
82    /// Audiences this token is valid for.
83    ///
84    /// Decodes from either a single string or an array of strings, since a
85    /// single-audience token is the common case and writing it as a bare string
86    /// is the conventional CBOR/JWT shorthand.
87    #[serde(deserialize_with = "audience_from_string_or_array")]
88    pub aud: Vec<String>,
89
90    /// Issued-at time, in seconds since the Unix epoch.
91    pub iat: i64,
92
93    /// Not-before time, in seconds since the Unix epoch.
94    #[serde(default)]
95    pub nbf: i64,
96
97    /// Expiry time, in seconds since the Unix epoch.
98    pub exp: i64,
99
100    /// Tenant this token is scoped to.
101    pub tenant_id: String,
102
103    /// Granted capability scopes, as a flat array.
104    pub scope: Vec<Scope>,
105
106    /// Proof-of-possession binding. See [`Confirmation`].
107    #[serde(default, skip_serializing_if = "Option::is_none")]
108    pub cnf: Option<Confirmation>,
109
110    /// Onward delegation constraints. See [`Delegation`].
111    #[serde(default, skip_serializing_if = "Option::is_none")]
112    pub delegation: Option<Delegation>,
113
114    /// Issuer-defined claims, carried inside the signature and passed through
115    /// unaltered.
116    ///
117    /// This is where rate limits, budgets, device and network bindings, and
118    /// audit trace belong. A verifier that does not understand a key here must
119    /// preserve it rather than drop it, so that a downstream component which
120    /// does understand it still receives an intact, signed value.
121    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
122    pub ext: BTreeMap<String, ciborium::Value>,
123}
124
125/// Accepts `aud` as either a single string or an array of strings.
126fn audience_from_string_or_array<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
127where
128    D: Deserializer<'de>,
129{
130    #[derive(Deserialize)]
131    #[serde(untagged)]
132    enum OneOrMany {
133        One(String),
134        Many(Vec<String>),
135    }
136
137    match OneOrMany::deserialize(deserializer)? {
138        OneOrMany::One(single) => Ok(vec![single]),
139        OneOrMany::Many(many) => Ok(many),
140    }
141}
142
143impl ActClaims {
144    /// Checks the claim set is structurally sound, independent of signature or
145    /// clock.
146    ///
147    /// Called during verification before any policy check. Kept public so an
148    /// issuer can reject a malformed claim set at mint time rather than
149    /// discovering it at the verifier.
150    ///
151    /// # Errors
152    ///
153    /// Returns [`ActError::EmptyClaim`] for a required claim that is blank, and
154    /// [`ActError::EmptyValidityWindow`] if `nbf` is not before `exp`.
155    pub fn validate_structure(&self) -> ActResult<()> {
156        for (name, value) in [
157            ("jti", &self.jti),
158            ("sub", &self.sub),
159            ("iss", &self.iss),
160            ("tenant_id", &self.tenant_id),
161        ] {
162            if value.trim().is_empty() {
163                return Err(ActError::EmptyClaim(name));
164            }
165        }
166
167        if self.aud.is_empty() || self.aud.iter().any(|a| a.trim().is_empty()) {
168            return Err(ActError::EmptyClaim("aud"));
169        }
170
171        if self.nbf >= self.exp {
172            return Err(ActError::EmptyValidityWindow {
173                nbf: self.nbf,
174                exp: self.exp,
175            });
176        }
177
178        Ok(())
179    }
180
181    /// Whether the granted scopes authorize `requested`.
182    ///
183    /// Wildcards in the grant expand; wildcards in the request do not. See
184    /// [`Scope::covers`].
185    #[must_use]
186    pub fn authorizes(&self, requested: &Scope) -> bool {
187        self.scope.iter().any(|granted| granted.covers(requested))
188    }
189
190    /// Whether this token lists `audience` among its audiences.
191    #[must_use]
192    pub fn has_audience(&self, audience: &str) -> bool {
193        self.aud.iter().any(|a| a == audience)
194    }
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200
201    fn claims() -> ActClaims {
202        ActClaims {
203            jti: "018f4c1e-0000-7000-8000-000000000000".to_string(),
204            sub: "did:oas:l1fe:agent:alpha".to_string(),
205            iss: "arsenal:broker:test-1".to_string(),
206            aud: vec!["omerta".to_string()],
207            iat: 1_000,
208            nbf: 1_000,
209            exp: 2_000,
210            tenant_id: "acme".to_string(),
211            scope: vec![],
212            cnf: None,
213            delegation: None,
214            ext: BTreeMap::new(),
215        }
216    }
217
218    #[test]
219    fn rejects_blank_required_claims() {
220        for mutate in [
221            (|c: &mut ActClaims| c.jti.clear()) as fn(&mut ActClaims),
222            |c: &mut ActClaims| c.sub.clear(),
223            |c: &mut ActClaims| c.iss.clear(),
224            |c: &mut ActClaims| c.tenant_id.clear(),
225            |c: &mut ActClaims| c.aud.clear(),
226        ] {
227            let mut subject = claims();
228            mutate(&mut subject);
229            assert!(subject.validate_structure().is_err());
230        }
231    }
232
233    #[test]
234    fn rejects_whitespace_only_claims() {
235        // A blank check that only tests `is_empty` lets " " through, which then
236        // compares unequal to every real issuer.
237        let mut subject = claims();
238        subject.iss = "   ".to_string();
239        assert!(matches!(
240            subject.validate_structure(),
241            Err(ActError::EmptyClaim("iss"))
242        ));
243    }
244
245    #[test]
246    fn rejects_empty_validity_window() {
247        let mut subject = claims();
248        subject.nbf = subject.exp;
249        assert!(matches!(
250            subject.validate_structure(),
251            Err(ActError::EmptyValidityWindow { .. })
252        ));
253    }
254
255    #[test]
256    fn accepts_audience_as_bare_string_or_array() -> ActResult<()> {
257        #[derive(Serialize)]
258        struct BareAud {
259            aud: &'static str,
260        }
261        #[derive(Deserialize)]
262        struct Probe {
263            #[serde(deserialize_with = "audience_from_string_or_array")]
264            aud: Vec<String>,
265        }
266
267        let mut buf = Vec::new();
268        ciborium::into_writer(&BareAud { aud: "omerta" }, &mut buf)
269            .map_err(|e| ActError::Encode(e.to_string()))?;
270        let probe: Probe = ciborium::from_reader(buf.as_slice())
271            .map_err(|e| ActError::ClaimsDecode(e.to_string()))?;
272        assert_eq!(probe.aud, vec!["omerta".to_string()]);
273
274        let mut buf = Vec::new();
275        ciborium::into_writer(&BareAud { aud: "omerta" }, &mut buf)
276            .map_err(|e| ActError::Encode(e.to_string()))?;
277        assert!(!buf.is_empty());
278        Ok(())
279    }
280
281    #[test]
282    fn timestamps_encode_as_integers() -> ActResult<()> {
283        // The mismatch that made the superseded formats mutually unreadable.
284        #[derive(Deserialize)]
285        struct Probe {
286            iat: ciborium::Value,
287            exp: ciborium::Value,
288        }
289
290        let mut buf = Vec::new();
291        ciborium::into_writer(&claims(), &mut buf).map_err(|e| ActError::Encode(e.to_string()))?;
292        let probe: Probe = ciborium::from_reader(buf.as_slice())
293            .map_err(|e| ActError::ClaimsDecode(e.to_string()))?;
294
295        assert!(probe.iat.is_integer(), "iat must be an integer");
296        assert!(probe.exp.is_integer(), "exp must be an integer");
297        Ok(())
298    }
299
300    #[test]
301    fn identifiers_encode_as_text() -> ActResult<()> {
302        #[derive(Deserialize)]
303        struct Probe {
304            jti: ciborium::Value,
305            sub: ciborium::Value,
306        }
307
308        let mut buf = Vec::new();
309        ciborium::into_writer(&claims(), &mut buf).map_err(|e| ActError::Encode(e.to_string()))?;
310        let probe: Probe = ciborium::from_reader(buf.as_slice())
311            .map_err(|e| ActError::ClaimsDecode(e.to_string()))?;
312
313        assert!(probe.jti.is_text(), "jti must be text, not UUID bytes");
314        assert!(probe.sub.is_text(), "sub must be text");
315        Ok(())
316    }
317
318    #[test]
319    fn unknown_top_level_keys_are_ignored() -> ActResult<()> {
320        // Forward compatibility: a newer issuer adding a typed claim must not
321        // break an older verifier.
322        let mut map = vec![
323            (
324                ciborium::Value::Text("jti".into()),
325                ciborium::Value::Text("id".into()),
326            ),
327            (
328                ciborium::Value::Text("sub".into()),
329                ciborium::Value::Text("did:oas:l1fe:agent:alpha".into()),
330            ),
331            (
332                ciborium::Value::Text("iss".into()),
333                ciborium::Value::Text("issuer".into()),
334            ),
335            (
336                ciborium::Value::Text("aud".into()),
337                ciborium::Value::Text("omerta".into()),
338            ),
339            (
340                ciborium::Value::Text("iat".into()),
341                ciborium::Value::from(1),
342            ),
343            (
344                ciborium::Value::Text("nbf".into()),
345                ciborium::Value::from(1),
346            ),
347            (
348                ciborium::Value::Text("exp".into()),
349                ciborium::Value::from(2),
350            ),
351            (
352                ciborium::Value::Text("tenant_id".into()),
353                ciborium::Value::Text("acme".into()),
354            ),
355            (
356                ciborium::Value::Text("scope".into()),
357                ciborium::Value::Array(vec![]),
358            ),
359        ];
360        map.push((
361            ciborium::Value::Text("claim_from_the_future".into()),
362            ciborium::Value::from(42),
363        ));
364
365        let mut buf = Vec::new();
366        ciborium::into_writer(&ciborium::Value::Map(map), &mut buf)
367            .map_err(|e| ActError::Encode(e.to_string()))?;
368
369        let decoded: ActClaims = ciborium::from_reader(buf.as_slice())
370            .map_err(|e| ActError::ClaimsDecode(e.to_string()))?;
371        assert_eq!(decoded.tenant_id, "acme");
372        Ok(())
373    }
374}