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}