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