Skip to main content

zeph_a2a/
ibct.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Invocation-Bound Capability Tokens (IBCT) for A2A delegation.
5//!
6//! An IBCT scopes an A2A delegation request to a specific `task_id` and `endpoint`.
7//! It is signed with HMAC-SHA256 using a shared secret. The `key_id` field allows
8//! multiple active keys so rotation can be performed without coordinated downtime (MF-4 fix).
9//!
10//! The token is serialized as base64-encoded JSON and transmitted in the
11//! `X-Zeph-IBCT` HTTP request header.
12//!
13//! # Feature flag
14//!
15//! The `ibct` feature flag enables HMAC-SHA256 signing and verification.
16//! The [`Ibct`], [`IbctKey`], and [`IbctError`] types are always present (for
17//! deserialization), but [`Ibct::issue`] and [`Ibct::verify`] return
18//! [`IbctError::FeatureDisabled`] when compiled without the `ibct` feature.
19//!
20//! # Security properties
21//!
22//! - Scope binding: the token is only valid for the specific `task_id` + `endpoint`.
23//! - Expiry: `expires_at` is checked on verification with a configurable grace window.
24//! - Key rotation: multiple keys indexed by `key_id` allow safe key rotation.
25//! - Constant-time comparison: signature verification uses `Mac::verify_slice` to avoid
26//!   timing side-channels.
27//! - Vault integration: signing keys should be stored in the age vault, referenced by
28//!   `ibct_signing_key_vault_ref` in `A2aServerConfig` (MF-3 fix).
29
30use std::time::Duration;
31#[cfg(feature = "ibct")]
32use std::time::{SystemTime, UNIX_EPOCH};
33
34use serde::{Deserialize, Serialize};
35use thiserror::Error;
36
37#[cfg(feature = "ibct")]
38use hmac::{Hmac, KeyInit, Mac};
39#[cfg(feature = "ibct")]
40use sha2::Sha256;
41
42/// Grace window added to `expires_at` during verification to tolerate clock skew.
43#[cfg(feature = "ibct")]
44const CLOCK_SKEW_GRACE_SECS: u64 = 30;
45
46/// Errors produced by [`Ibct::issue`] and [`Ibct::verify`].
47#[derive(Debug, Error)]
48pub enum IbctError {
49    /// The HMAC-SHA256 signature does not match the token's fields.
50    /// Indicates tampering or use of a wrong key.
51    #[error("IBCT signature invalid")]
52    InvalidSignature,
53
54    /// The token's `expires_at` is in the past beyond the clock-skew grace window.
55    #[error("IBCT expired (expires_at={expires_at}, now={now})")]
56    Expired { expires_at: u64, now: u64 },
57
58    /// The token is bound to a different endpoint than the one being verified.
59    #[error("IBCT endpoint mismatch: expected {expected}, got {got}")]
60    EndpointMismatch { expected: String, got: String },
61
62    /// The token is bound to a different task ID than the one being verified.
63    #[error("IBCT task_id mismatch: expected {expected}, got {got}")]
64    TaskMismatch { expected: String, got: String },
65
66    /// The token's `key_id` is not present in the verifier's key set.
67    /// Either the key was rotated out or the token was issued by a different party.
68    #[error("IBCT key_id '{key_id}' not found in the configured key set")]
69    UnknownKeyId { key_id: String },
70
71    /// This crate was compiled without the `ibct` feature flag.
72    #[error("IBCT feature not enabled (compile with feature 'ibct')")]
73    FeatureDisabled,
74
75    /// The base64 token string could not be decoded.
76    #[error("base64 decode error: {0}")]
77    Base64(#[from] base64_compat::DecodeError),
78
79    /// The decoded bytes are not valid JSON for an [`Ibct`] struct.
80    #[error("JSON error: {0}")]
81    Json(#[from] serde_json::Error),
82}
83
84/// A key entry in the IBCT key set.
85///
86/// Multiple entries allow key rotation: old keys are kept until all in-flight tokens
87/// signed with them expire.
88#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct IbctKey {
90    /// Unique key identifier. Embedded in the token so the verifier can look it up.
91    pub key_id: String,
92    /// HMAC-SHA256 signing key (raw bytes, hex-encoded in config).
93    #[serde(with = "hex_bytes")]
94    pub key_bytes: Vec<u8>,
95}
96
97/// An Invocation-Bound Capability Token.
98#[derive(Debug, Clone, Serialize, Deserialize)]
99pub struct Ibct {
100    /// Identifies which key was used for signing, enabling key rotation.
101    pub key_id: String,
102    /// A2A task ID this token is scoped to.
103    pub task_id: String,
104    /// A2A agent endpoint this token is scoped to.
105    pub endpoint: String,
106    /// Unix timestamp (seconds) when this token was issued.
107    pub issued_at: u64,
108    /// Unix timestamp (seconds) when this token expires.
109    pub expires_at: u64,
110    /// HMAC-SHA256 over `{key_id}|{task_id}|{endpoint}|{issued_at}|{expires_at}`, hex-encoded.
111    pub signature: String,
112}
113
114impl Ibct {
115    /// Issue a new IBCT scoped to `task_id` + `endpoint`, valid for `ttl`.
116    ///
117    /// # Errors
118    ///
119    /// Returns `IbctError::FeatureDisabled` when compiled without the `ibct` feature.
120    #[allow(clippy::needless_return)]
121    pub fn issue(
122        task_id: &str,
123        endpoint: &str,
124        ttl: Duration,
125        key: &IbctKey,
126    ) -> Result<Self, IbctError> {
127        #[cfg(not(feature = "ibct"))]
128        {
129            let _ = (task_id, endpoint, ttl, key);
130            return Err(IbctError::FeatureDisabled);
131        }
132        #[cfg(feature = "ibct")]
133        {
134            let now = unix_now();
135            let expires_at = now + ttl.as_secs();
136            let signature = sign(
137                &key.key_bytes,
138                &key.key_id,
139                task_id,
140                endpoint,
141                now,
142                expires_at,
143            );
144            Ok(Self {
145                key_id: key.key_id.clone(),
146                task_id: task_id.to_owned(),
147                endpoint: endpoint.to_owned(),
148                issued_at: now,
149                expires_at,
150                signature,
151            })
152        }
153    }
154
155    /// Verify this token against a key set, expected endpoint, and expected `task_id`.
156    ///
157    /// Looks up the key by `key_id`, verifies the HMAC signature, checks expiry
158    /// (with `CLOCK_SKEW_GRACE_SECS` grace), and checks endpoint + `task_id` binding.
159    ///
160    /// # Errors
161    ///
162    /// Returns one of `IbctError::*` on any verification failure.
163    #[allow(clippy::needless_return)]
164    pub fn verify(
165        &self,
166        keys: &[IbctKey],
167        expected_endpoint: &str,
168        expected_task_id: &str,
169    ) -> Result<(), IbctError> {
170        #[cfg(not(feature = "ibct"))]
171        {
172            let _ = (keys, expected_endpoint, expected_task_id);
173            return Err(IbctError::FeatureDisabled);
174        }
175        #[cfg(feature = "ibct")]
176        {
177            let key = keys
178                .iter()
179                .find(|k| k.key_id == self.key_id)
180                .ok_or_else(|| IbctError::UnknownKeyId {
181                    key_id: self.key_id.clone(),
182                })?;
183
184            // Constant-time HMAC verification: reconstruct the MAC and call verify_slice()
185            // instead of comparing hex strings, which would be vulnerable to timing attacks.
186            if verify_signature(
187                &key.key_bytes,
188                &self.key_id,
189                &self.task_id,
190                &self.endpoint,
191                self.issued_at,
192                self.expires_at,
193                &self.signature,
194            )
195            .is_err()
196            {
197                return Err(IbctError::InvalidSignature);
198            }
199
200            let now = unix_now();
201            if now > self.expires_at + CLOCK_SKEW_GRACE_SECS {
202                return Err(IbctError::Expired {
203                    expires_at: self.expires_at,
204                    now,
205                });
206            }
207
208            if self.endpoint != expected_endpoint {
209                return Err(IbctError::EndpointMismatch {
210                    expected: expected_endpoint.to_owned(),
211                    got: self.endpoint.clone(),
212                });
213            }
214
215            if self.task_id != expected_task_id {
216                return Err(IbctError::TaskMismatch {
217                    expected: expected_task_id.to_owned(),
218                    got: self.task_id.clone(),
219                });
220            }
221
222            Ok(())
223        }
224    }
225
226    /// Encode this token to a base64-JSON string suitable for use in an HTTP header.
227    ///
228    /// # Errors
229    ///
230    /// Returns `serde_json::Error` if serialization fails.
231    pub fn encode(&self) -> Result<String, serde_json::Error> {
232        let json = serde_json::to_vec(self)?;
233        Ok(base64_compat::encode(&json))
234    }
235
236    /// Decode a token from the base64-JSON string produced by `encode()`.
237    ///
238    /// # Errors
239    ///
240    /// Returns `IbctError::Base64` or `IbctError::Json` on decode failure.
241    pub fn decode(s: &str) -> Result<Self, IbctError> {
242        let bytes = base64_compat::decode(s)?;
243        let token = serde_json::from_slice(&bytes)?;
244        Ok(token)
245    }
246}
247
248#[cfg(feature = "ibct")]
249fn sign(
250    key_bytes: &[u8],
251    key_id: &str,
252    task_id: &str,
253    endpoint: &str,
254    issued_at: u64,
255    expires_at: u64,
256) -> String {
257    type HmacSha256 = Hmac<Sha256>;
258    let msg = format!("{key_id}|{task_id}|{endpoint}|{issued_at}|{expires_at}");
259    let mut mac = HmacSha256::new_from_slice(key_bytes).expect("HMAC accepts any key length");
260    mac.update(msg.as_bytes());
261    hex::encode(mac.finalize().into_bytes())
262}
263
264/// Verify an HMAC-SHA256 signature in constant time using `Mac::verify_slice`.
265///
266/// Decodes the hex `signature`, recomputes the MAC over the canonical message,
267/// and calls `verify_slice` — which uses a constant-time comparison internally.
268///
269/// # Errors
270///
271/// Returns an error if the hex is malformed or if the signature does not match.
272#[cfg(feature = "ibct")]
273fn verify_signature(
274    key_bytes: &[u8],
275    key_id: &str,
276    task_id: &str,
277    endpoint: &str,
278    issued_at: u64,
279    expires_at: u64,
280    signature_hex: &str,
281) -> Result<(), ()> {
282    type HmacSha256 = Hmac<Sha256>;
283    let decoded = hex::decode(signature_hex).map_err(|_| ())?;
284    let msg = format!("{key_id}|{task_id}|{endpoint}|{issued_at}|{expires_at}");
285    let mut mac = HmacSha256::new_from_slice(key_bytes).expect("HMAC accepts any key length");
286    mac.update(msg.as_bytes());
287    mac.verify_slice(&decoded).map_err(|_| ())
288}
289
290#[cfg(feature = "ibct")]
291fn unix_now() -> u64 {
292    SystemTime::now()
293        .duration_since(UNIX_EPOCH)
294        .unwrap_or(Duration::ZERO)
295        .as_secs()
296}
297
298/// Serde helper for hex-encoded byte vectors.
299mod hex_bytes {
300    use serde::{Deserialize, Deserializer, Serializer};
301
302    pub fn serialize<S: Serializer>(bytes: &Vec<u8>, ser: S) -> Result<S::Ok, S::Error> {
303        ser.serialize_str(&hex::encode(bytes))
304    }
305
306    pub fn deserialize<'de, D: Deserializer<'de>>(de: D) -> Result<Vec<u8>, D::Error> {
307        let s = String::deserialize(de)?;
308        hex::decode(&s).map_err(serde::de::Error::custom)
309    }
310}
311
312/// Minimal base64 compatibility layer (uses the `base64` crate already in the dep tree
313/// transitively via reqwest; we don't add a new dep).
314///
315/// This module wraps `base64::engine::general_purpose::STANDARD` under a stable API.
316mod base64_compat {
317    use base64::Engine as _;
318
319    pub use base64::DecodeError;
320
321    pub fn encode(input: &[u8]) -> String {
322        base64::engine::general_purpose::STANDARD.encode(input)
323    }
324
325    pub fn decode(input: &str) -> Result<Vec<u8>, DecodeError> {
326        base64::engine::general_purpose::STANDARD.decode(input)
327    }
328}
329
330#[cfg(test)]
331mod tests {
332    #[cfg(feature = "ibct")]
333    use super::*;
334
335    #[cfg(feature = "ibct")]
336    fn test_key() -> IbctKey {
337        IbctKey {
338            key_id: "k1".into(),
339            key_bytes: b"super-secret-key-for-testing-only".to_vec(),
340        }
341    }
342
343    #[cfg(feature = "ibct")]
344    #[test]
345    fn issue_and_verify_round_trip() {
346        let key = test_key();
347        let token = Ibct::issue(
348            "task-123",
349            "https://agent.example.com",
350            Duration::from_mins(5),
351            &key,
352        )
353        .unwrap();
354        assert!(
355            token
356                .verify(&[key], "https://agent.example.com", "task-123")
357                .is_ok()
358        );
359    }
360
361    #[cfg(feature = "ibct")]
362    #[test]
363    fn verify_rejects_wrong_endpoint() {
364        let key = test_key();
365        let token = Ibct::issue(
366            "task-123",
367            "https://agent.example.com",
368            Duration::from_mins(5),
369            &key,
370        )
371        .unwrap();
372        let err = token
373            .verify(&[key], "https://evil.example.com", "task-123")
374            .unwrap_err();
375        assert!(matches!(err, IbctError::EndpointMismatch { .. }));
376    }
377
378    #[cfg(feature = "ibct")]
379    #[test]
380    fn verify_rejects_wrong_task() {
381        let key = test_key();
382        let token = Ibct::issue(
383            "task-123",
384            "https://agent.example.com",
385            Duration::from_mins(5),
386            &key,
387        )
388        .unwrap();
389        let err = token
390            .verify(&[key], "https://agent.example.com", "task-999")
391            .unwrap_err();
392        assert!(matches!(err, IbctError::TaskMismatch { .. }));
393    }
394
395    #[cfg(feature = "ibct")]
396    #[test]
397    fn verify_rejects_tampered_signature() {
398        let key = test_key();
399        let mut token = Ibct::issue(
400            "task-123",
401            "https://agent.example.com",
402            Duration::from_mins(5),
403            &key,
404        )
405        .unwrap();
406        token.signature = "deadbeef".repeat(8);
407        let err = token
408            .verify(&[key], "https://agent.example.com", "task-123")
409            .unwrap_err();
410        assert!(matches!(err, IbctError::InvalidSignature));
411    }
412
413    #[cfg(feature = "ibct")]
414    #[test]
415    fn verify_rejects_unknown_key_id() {
416        let key = test_key();
417        let token = Ibct::issue(
418            "task-123",
419            "https://agent.example.com",
420            Duration::from_mins(5),
421            &key,
422        )
423        .unwrap();
424        let other_key = IbctKey {
425            key_id: "k99".into(),
426            key_bytes: b"other".to_vec(),
427        };
428        let err = token
429            .verify(&[other_key], "https://agent.example.com", "task-123")
430            .unwrap_err();
431        assert!(matches!(err, IbctError::UnknownKeyId { .. }));
432    }
433
434    #[cfg(feature = "ibct")]
435    #[test]
436    fn encode_decode_round_trip() {
437        let key = test_key();
438        let token = Ibct::issue(
439            "task-abc",
440            "https://agent.example.com",
441            Duration::from_mins(1),
442            &key,
443        )
444        .unwrap();
445        let encoded = token.encode().unwrap();
446        let decoded = Ibct::decode(&encoded).unwrap();
447        assert_eq!(decoded.task_id, "task-abc");
448        assert_eq!(decoded.key_id, "k1");
449    }
450
451    #[cfg(feature = "ibct")]
452    #[test]
453    fn verify_rejects_expired_token() {
454        let key = test_key();
455        // Manually construct a token with expires_at in the past (beyond grace window).
456        let now = std::time::SystemTime::now()
457            .duration_since(std::time::UNIX_EPOCH)
458            .unwrap()
459            .as_secs();
460        // Set expires_at to 120 seconds ago (well beyond CLOCK_SKEW_GRACE_SECS=30).
461        let expired_at = now.saturating_sub(120);
462        let issued_at = expired_at.saturating_sub(300);
463        // Build the signature manually so it matches the token fields.
464        #[cfg(feature = "ibct")]
465        let signature = {
466            use hmac::{Hmac, KeyInit, Mac};
467            use sha2::Sha256;
468            type HmacSha256 = Hmac<Sha256>;
469            let msg = format!(
470                "{}|{}|{}|{}|{}",
471                key.key_id, "task-expired", "https://agent.example.com", issued_at, expired_at
472            );
473            let mut mac =
474                HmacSha256::new_from_slice(&key.key_bytes).expect("HMAC accepts any key length");
475            mac.update(msg.as_bytes());
476            hex::encode(mac.finalize().into_bytes())
477        };
478        let token = Ibct {
479            key_id: key.key_id.clone(),
480            task_id: "task-expired".into(),
481            endpoint: "https://agent.example.com".into(),
482            issued_at,
483            expires_at: expired_at,
484            signature,
485        };
486        let err = token
487            .verify(&[key], "https://agent.example.com", "task-expired")
488            .unwrap_err();
489        assert!(
490            matches!(err, IbctError::Expired { .. }),
491            "expected Expired, got {err:?}"
492        );
493    }
494
495    #[cfg(feature = "ibct")]
496    #[test]
497    fn key_rotation_verifies_with_old_key() {
498        let old_key = IbctKey {
499            key_id: "k1".into(),
500            key_bytes: b"old-key".to_vec(),
501        };
502        let new_key = IbctKey {
503            key_id: "k2".into(),
504            key_bytes: b"new-key".to_vec(),
505        };
506        let token = Ibct::issue(
507            "task-1",
508            "https://agent.example.com",
509            Duration::from_mins(5),
510            &old_key,
511        )
512        .unwrap();
513        // Verifier has both keys — old token still verifies
514        assert!(
515            token
516                .verify(&[old_key, new_key], "https://agent.example.com", "task-1")
517                .is_ok()
518        );
519    }
520}