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