Skip to main content

agent_capability_token/
wire.rs

1//! The ACT envelope: encoding, signing and verification.
2//!
3//! An ACT is a CBOR map with four keys:
4//!
5//! ```text
6//! {
7//!   "v":      uint,   ; format version, currently 1
8//!   "alg":    tstr,   ; signature algorithm, currently "Ed25519"
9//!   "claims": bstr,   ; CBOR-encoded ActClaims - the signed bytes
10//!   "sig":    bstr,   ; 64-byte raw Ed25519 signature over "claims"
11//!   "kid":    tstr,   ; optional key identifier, for rotation
12//! }
13//! ```
14//!
15//! The signature covers the `claims` byte string exactly as it appears, not a
16//! re-serialization of the decoded claims. A verifier therefore never needs to
17//! reproduce the issuer's encoding decisions byte for byte, which is the usual
18//! source of signature failures in formats that sign a canonicalized structure.
19//!
20//! `v` and `alg` exist because neither superseded format carried either. Without
21//! a version there is no way to roll a format change out, and without an
22//! algorithm identifier there is no way to migrate off Ed25519.
23
24// The dalek `Verifier` trait is imported anonymously: only its `verify` method
25// is needed, and the name is taken by this module's own `Verifier` type.
26use ed25519_dalek::{Signature, Verifier as _, VerifyingKey};
27use serde::{Deserialize, Serialize};
28
29use crate::claims::ActClaims;
30use crate::error::{ActError, ActResult};
31use crate::scope::Scope;
32
33/// Current ACT format version.
34pub const FORMAT_VERSION: u64 = 1;
35
36/// The only signature algorithm this version defines.
37pub const ALGORITHM_ED25519: &str = "Ed25519";
38
39/// Maximum accepted size of an encoded ACT, in bytes.
40///
41/// Enforced before parsing so an oversized payload cannot drive allocation.
42pub const MAX_ACT_BYTES: usize = 16 * 1024;
43
44/// Length of a raw Ed25519 signature.
45const SIGNATURE_BYTES: usize = 64;
46
47/// A raw Ed25519 public key.
48///
49/// Kept as a plain array so this crate does not impose a key-handle type on
50/// callers, who typically hold a rotating set loaded from their issuer's key
51/// endpoint.
52pub type PublicKeyBytes = [u8; 32];
53
54/// The outer envelope, as it appears on the wire.
55#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct ActEnvelope {
57    /// Format version.
58    pub v: u64,
59    /// Signature algorithm identifier.
60    pub alg: String,
61    /// CBOR-encoded claims. These exact bytes are what the signature covers.
62    pub claims: serde_bytes::ByteBuf,
63    /// Raw signature over `claims`.
64    pub sig: serde_bytes::ByteBuf,
65    /// Optional key identifier, so a verifier can select a key during rotation
66    /// instead of trying every trusted key.
67    #[serde(default, skip_serializing_if = "Option::is_none")]
68    pub kid: Option<String>,
69}
70
71impl ActEnvelope {
72    /// Decodes an envelope without verifying its signature.
73    ///
74    /// Useful for reading `kid` in order to select a verification key. The
75    /// claims inside are unauthenticated until [`verify`] succeeds, and must not
76    /// be used for any authorization decision.
77    ///
78    /// # Errors
79    ///
80    /// Returns [`ActError::TooLarge`] above [`MAX_ACT_BYTES`],
81    /// [`ActError::EnvelopeDecode`] for malformed CBOR,
82    /// [`ActError::UnsupportedVersion`] or [`ActError::UnsupportedAlgorithm`]
83    /// for a format this build does not implement.
84    pub fn decode(bytes: &[u8]) -> ActResult<Self> {
85        if bytes.len() > MAX_ACT_BYTES {
86            return Err(ActError::TooLarge {
87                actual: bytes.len(),
88                limit: MAX_ACT_BYTES,
89            });
90        }
91
92        let envelope: Self =
93            ciborium::from_reader(bytes).map_err(|e| ActError::EnvelopeDecode(e.to_string()))?;
94
95        if envelope.v != FORMAT_VERSION {
96            return Err(ActError::UnsupportedVersion {
97                found: envelope.v,
98                supported: FORMAT_VERSION,
99            });
100        }
101        if envelope.alg != ALGORITHM_ED25519 {
102            return Err(ActError::UnsupportedAlgorithm(envelope.alg));
103        }
104
105        Ok(envelope)
106    }
107
108    /// Serializes this envelope to CBOR.
109    ///
110    /// # Errors
111    ///
112    /// Returns [`ActError::Encode`] if serialization fails.
113    pub fn encode(&self) -> ActResult<Vec<u8>> {
114        let mut buffer = Vec::new();
115        ciborium::into_writer(self, &mut buffer).map_err(|e| ActError::Encode(e.to_string()))?;
116        Ok(buffer)
117    }
118}
119
120/// What a verifier requires of a token.
121#[derive(Debug, Clone)]
122pub struct Verifier {
123    trusted_keys: Vec<PublicKeyBytes>,
124    expected_issuer: String,
125    expected_audience: String,
126    required_scopes: Vec<Scope>,
127    clock: Option<i64>,
128    leeway_seconds: i64,
129}
130
131impl Verifier {
132    /// Builds a verifier.
133    ///
134    /// # Errors
135    ///
136    /// Returns [`ActError::NoTrustedKeys`] if `trusted_keys` is empty, since no
137    /// token could then ever verify and the misconfiguration should surface at
138    /// construction rather than as a stream of signature failures.
139    pub fn new(
140        trusted_keys: Vec<PublicKeyBytes>,
141        expected_issuer: impl Into<String>,
142        expected_audience: impl Into<String>,
143    ) -> ActResult<Self> {
144        if trusted_keys.is_empty() {
145            return Err(ActError::NoTrustedKeys);
146        }
147        Ok(Self {
148            trusted_keys,
149            expected_issuer: expected_issuer.into(),
150            expected_audience: expected_audience.into(),
151            required_scopes: Vec::new(),
152            clock: None,
153            leeway_seconds: 0,
154        })
155    }
156
157    /// Requires that the token grant every scope in `scopes`.
158    #[must_use]
159    pub fn requiring_scopes(mut self, scopes: impl IntoIterator<Item = Scope>) -> Self {
160        self.required_scopes.extend(scopes);
161        self
162    }
163
164    /// Allows `seconds` of clock skew on the `nbf` and `exp` checks.
165    ///
166    /// Applied symmetrically. Callers running across hosts without tightly
167    /// synchronized clocks need a small allowance here; leaving it at zero makes
168    /// a token minted moments ago fail at a verifier whose clock trails the
169    /// issuer's.
170    #[must_use]
171    pub fn with_leeway_seconds(mut self, seconds: i64) -> Self {
172        self.leeway_seconds = seconds.abs();
173        self
174    }
175
176    /// Pins the time used for temporal checks, in seconds since the Unix epoch.
177    ///
178    /// Intended for tests and for replaying a decision at a known instant.
179    #[must_use]
180    pub fn with_clock(mut self, now_unix_seconds: i64) -> Self {
181        self.clock = Some(now_unix_seconds);
182        self
183    }
184
185    /// The time this verifier will compare against.
186    ///
187    /// # Errors
188    ///
189    /// Returns [`ActError::Encode`] if no clock was pinned and the system clock
190    /// is before the Unix epoch.
191    fn now(&self) -> ActResult<i64> {
192        match self.clock {
193            Some(pinned) => Ok(pinned),
194            // Read from the system clock without taking a chrono dependency, so
195            // that a sidecar embedding this crate stays dependency-light.
196            None => std::time::SystemTime::now()
197                .duration_since(std::time::UNIX_EPOCH)
198                .map(|d| {
199                    let secs = d.as_secs();
200                    i64::try_from(secs).unwrap_or(i64::MAX)
201                })
202                .map_err(|_| ActError::Encode("system clock is before the Unix epoch".to_string())),
203        }
204    }
205}
206
207/// Verifies an encoded ACT and returns its claims.
208///
209/// The order matters. Signature verification precedes every claim check, so a
210/// forged token is rejected before any of its unauthenticated content is used to
211/// make a decision or shape a log line.
212///
213/// 1. Enforce the size ceiling and decode the envelope.
214/// 2. Verify the Ed25519 signature over the raw `claims` bytes.
215/// 3. Decode the claims and check they are structurally sound.
216/// 4. Check the temporal window, issuer, audience, and required scopes.
217///
218/// # Errors
219///
220/// Returns the [`ActError`] variant describing the first failed step.
221///
222/// # Examples
223///
224/// ```
225/// use agent_capability_token::{verify, ActError, Verifier};
226///
227/// let verifier = Verifier::new(vec![[0u8; 32]], "arsenal:broker:prod-1", "omerta")?;
228/// // An empty payload is not a valid envelope.
229/// assert!(matches!(verify(&[], &verifier), Err(ActError::EnvelopeDecode(_))));
230/// # Ok::<(), ActError>(())
231/// ```
232pub fn verify(token_bytes: &[u8], verifier: &Verifier) -> ActResult<ActClaims> {
233    let envelope = ActEnvelope::decode(token_bytes)?;
234
235    if envelope.sig.is_empty() {
236        return Err(ActError::MissingSignature);
237    }
238    if envelope.sig.len() != SIGNATURE_BYTES {
239        return Err(ActError::MalformedSignature(envelope.sig.len()));
240    }
241    let signature_array: [u8; SIGNATURE_BYTES] =
242        <[u8; SIGNATURE_BYTES]>::try_from(envelope.sig.as_ref())
243            .map_err(|_| ActError::MalformedSignature(envelope.sig.len()))?;
244    let signature = Signature::from_bytes(&signature_array);
245
246    let candidate_keys = decode_trusted_keys(verifier)?;
247    let verified = candidate_keys
248        .iter()
249        .any(|key| key.verify(envelope.claims.as_ref(), &signature).is_ok());
250    if !verified {
251        return Err(ActError::SignatureInvalid {
252            tried: candidate_keys.len(),
253        });
254    }
255
256    let claims_bytes: &[u8] = envelope.claims.as_ref();
257    let claims: ActClaims =
258        ciborium::from_reader(claims_bytes).map_err(|e| ActError::ClaimsDecode(e.to_string()))?;
259    claims.validate_structure()?;
260
261    check_policy(&claims, verifier)?;
262
263    Ok(claims)
264}
265
266/// Decodes every trusted key up front.
267///
268/// A misconfigured key surfaces as [`ActError::MalformedTrustedKey`] before any
269/// verification is attempted, so it is not mistaken for a bad signature.
270///
271/// Verification tries each key in turn. The envelope's `kid` is carried for the
272/// issuer's benefit and for audit, and is deliberately not used to choose a key
273/// here: the trusted set is supplied as bare key bytes with no identifiers to
274/// match against, and treating a sender-supplied hint as authoritative would let
275/// that sender steer a verifier away from the key that would have validated. A
276/// keyed trusted set is the natural extension when rotation makes the linear
277/// scan costly.
278fn decode_trusted_keys(verifier: &Verifier) -> ActResult<Vec<VerifyingKey>> {
279    verifier
280        .trusted_keys
281        .iter()
282        .enumerate()
283        .map(|(index, raw)| {
284            VerifyingKey::from_bytes(raw).map_err(|e| ActError::MalformedTrustedKey {
285                index,
286                reason: e.to_string(),
287            })
288        })
289        .collect()
290}
291
292/// Applies the temporal, issuer, audience and scope checks.
293fn check_policy(claims: &ActClaims, verifier: &Verifier) -> ActResult<()> {
294    let now = verifier.now()?;
295
296    if claims.exp <= now.saturating_sub(verifier.leeway_seconds) {
297        return Err(ActError::Expired {
298            expired_at: claims.exp,
299            now,
300        });
301    }
302    if claims.nbf > now.saturating_add(verifier.leeway_seconds) {
303        return Err(ActError::NotYetValid {
304            valid_from: claims.nbf,
305            now,
306        });
307    }
308
309    if claims.iss != verifier.expected_issuer {
310        return Err(ActError::IssuerMismatch {
311            expected: verifier.expected_issuer.clone(),
312            found: claims.iss.clone(),
313        });
314    }
315
316    if !claims.has_audience(&verifier.expected_audience) {
317        return Err(ActError::AudienceMismatch {
318            expected: verifier.expected_audience.clone(),
319            found: claims.aud.clone(),
320        });
321    }
322
323    for required in &verifier.required_scopes {
324        if !claims.authorizes(required) {
325            return Err(ActError::MissingScope(required.to_string()));
326        }
327    }
328
329    Ok(())
330}
331
332/// Encodes claims into the exact byte string the signature must cover.
333///
334/// Exposed so an issuer holding its key in an HSM, or any other signer that
335/// cannot hand over private key material, can produce the payload here and sign
336/// it elsewhere.
337///
338/// # Errors
339///
340/// Returns [`ActError::Encode`] if serialization fails.
341pub fn claims_to_signing_payload(claims: &ActClaims) -> ActResult<Vec<u8>> {
342    claims.validate_structure()?;
343    let mut buffer = Vec::new();
344    ciborium::into_writer(claims, &mut buffer).map_err(|e| ActError::Encode(e.to_string()))?;
345    Ok(buffer)
346}
347
348/// Assembles an envelope from claims bytes and a detached signature.
349///
350/// Pairs with [`claims_to_signing_payload`] for signers that hold their key
351/// outside the process.
352///
353/// # Errors
354///
355/// Returns [`ActError::MalformedSignature`] unless `signature` is 64 bytes, and
356/// [`ActError::TooLarge`] if the assembled token exceeds [`MAX_ACT_BYTES`].
357pub fn envelope_from_parts(
358    claims_bytes: Vec<u8>,
359    signature: &[u8],
360    kid: Option<String>,
361) -> ActResult<Vec<u8>> {
362    if signature.len() != SIGNATURE_BYTES {
363        return Err(ActError::MalformedSignature(signature.len()));
364    }
365
366    let envelope = ActEnvelope {
367        v: FORMAT_VERSION,
368        alg: ALGORITHM_ED25519.to_string(),
369        claims: serde_bytes::ByteBuf::from(claims_bytes),
370        sig: serde_bytes::ByteBuf::from(signature.to_vec()),
371        kid,
372    };
373
374    let encoded = envelope.encode()?;
375    if encoded.len() > MAX_ACT_BYTES {
376        return Err(ActError::TooLarge {
377            actual: encoded.len(),
378            limit: MAX_ACT_BYTES,
379        });
380    }
381    Ok(encoded)
382}
383
384/// Signs claims with an in-process key and returns an encoded ACT.
385///
386/// Behind the `sign` feature: a verifier has no reason to link signing code, and
387/// most deployments verify in far more places than they mint.
388///
389/// # Errors
390///
391/// Returns [`ActError::Encode`] if serialization fails, or [`ActError::TooLarge`]
392/// if the assembled token exceeds [`MAX_ACT_BYTES`].
393#[cfg(feature = "sign")]
394pub fn sign(
395    claims: &ActClaims,
396    signing_key: &ed25519_dalek::SigningKey,
397    kid: Option<String>,
398) -> ActResult<Vec<u8>> {
399    use ed25519_dalek::Signer;
400
401    let claims_bytes = claims_to_signing_payload(claims)?;
402    let signature = signing_key.sign(&claims_bytes);
403    envelope_from_parts(claims_bytes, &signature.to_bytes(), kid)
404}