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/// Normalizes a full URL (e.g. `https://agent.example.com/a2a/stream`) down to its origin
47/// (`https://agent.example.com`) for IBCT scoping.
48///
49/// Shared by both sides of the IBCT contract so they can never drift apart:
50///
51/// - **Client** (`crate::client::A2aClient::ibct_header_value`) applies this to the `endpoint`
52///   argument before calling [`Ibct::issue`], so a token issued for `POST /a2a` and one issued
53///   for `POST /a2a/stream` on the same agent carry the identical `endpoint` field.
54/// - **Server** (`crate::server::A2aServer::serve`) applies this to its own advertised
55///   `AgentCard::url` before constructing the `IbctConfig` [`Ibct::verify`] compares against —
56///   `card.url` is documented as a path-free base URL, but nothing enforces that an operator's
57///   configured `public_url` is actually canonical (no trailing slash, no `:443`, lowercase
58///   scheme/host). Normalizing both sides through the same function, rather than trusting one
59///   side's input to already be in the other side's expected shape, is what keeps `/a2a` and
60///   `/a2a/stream` verifiable against one value regardless of path, and keeps a non-canonical
61///   `public_url` from silently 403ing every request (the exact bug class of #6260 review S1,
62///   moved from the client to the server if only one side normalized).
63///
64/// Falls back to the input unchanged if it does not parse as a URL (matches [`Ibct::issue`]'s
65/// own tolerance of non-URL scope strings, and mirrors `discovery_origin` in
66/// `src/tui_remote.rs`, which strips the same route-specific path for the analogous
67/// `.well-known/agent.json` lookup).
68pub(crate) fn ibct_scope_origin(endpoint: &str) -> String {
69    url::Url::parse(endpoint).map_or_else(
70        |_| endpoint.to_owned(),
71        |u| u.origin().ascii_serialization(),
72    )
73}
74
75/// Errors produced by [`Ibct::issue`] and [`Ibct::verify`].
76#[derive(Debug, Error)]
77#[non_exhaustive]
78pub enum IbctError {
79    /// The HMAC-SHA256 signature does not match the token's fields.
80    /// Indicates tampering or use of a wrong key.
81    #[error("IBCT signature invalid")]
82    InvalidSignature,
83
84    /// The token's `expires_at` is in the past beyond the clock-skew grace window.
85    #[error("IBCT expired (expires_at={expires_at}, now={now})")]
86    Expired { expires_at: u64, now: u64 },
87
88    /// The token is bound to a different endpoint than the one being verified.
89    #[error("IBCT endpoint mismatch: expected {expected}, got {got}")]
90    EndpointMismatch { expected: String, got: String },
91
92    /// The token is bound to a different task ID than the one being verified.
93    #[error("IBCT task_id mismatch: expected {expected}, got {got}")]
94    TaskMismatch { expected: String, got: String },
95
96    /// The token's `key_id` is not present in the verifier's key set.
97    /// Either the key was rotated out or the token was issued by a different party.
98    #[error("IBCT key_id '{key_id}' not found in the configured key set")]
99    UnknownKeyId { key_id: String },
100
101    /// This crate was compiled without the `ibct` feature flag.
102    #[error("IBCT feature not enabled (compile with feature 'ibct')")]
103    FeatureDisabled,
104
105    /// The base64 token string could not be decoded.
106    #[error("base64 decode error: {0}")]
107    Base64(#[from] base64_compat::DecodeError),
108
109    /// The decoded bytes are not valid JSON for an [`Ibct`] struct.
110    #[error("JSON error: {0}")]
111    Json(#[from] serde_json::Error),
112}
113
114/// A key entry in the IBCT key set.
115///
116/// Multiple entries allow key rotation: old keys are kept until all in-flight tokens
117/// signed with them expire.
118///
119/// `Serialize` is hand-written and redacts `key_bytes` to `"[REDACTED]"` (mirroring the
120/// `Debug` impl below); `Deserialize` is derived and reads the real key bytes untouched.
121#[derive(Clone, Deserialize)]
122pub struct IbctKey {
123    /// Unique key identifier. Embedded in the token so the verifier can look it up.
124    pub key_id: String,
125    /// HMAC-SHA256 signing key (raw bytes, hex-encoded in config).
126    #[serde(with = "hex_bytes")]
127    pub key_bytes: Vec<u8>,
128}
129
130impl std::fmt::Debug for IbctKey {
131    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
132        f.debug_struct("IbctKey")
133            .field("key_id", &self.key_id)
134            .field("key_bytes", &"[REDACTED]")
135            .finish()
136    }
137}
138
139impl IbctKey {
140    /// Construct an `IbctKey` from a hex-encoded signing key, as used by
141    /// `[a2a] ibct_keys[].key_hex` in config and by vault-resolved IBCT secrets
142    /// (`ibct_signing_key_vault_ref`).
143    ///
144    /// # Errors
145    ///
146    /// Returns [`hex::FromHexError`] if `hex_key` is not valid hex.
147    ///
148    /// # Examples
149    ///
150    /// ```rust
151    /// use zeph_a2a::IbctKey;
152    ///
153    /// let key = IbctKey::from_hex("k1", "68656c6c6f2d7365637265742d6b6579").unwrap();
154    /// assert_eq!(key.key_id, "k1");
155    /// assert!(IbctKey::from_hex("k1", "not-hex").is_err());
156    /// ```
157    pub fn from_hex(key_id: impl Into<String>, hex_key: &str) -> Result<Self, hex::FromHexError> {
158        Ok(Self {
159            key_id: key_id.into(),
160            key_bytes: hex::decode(hex_key)?,
161        })
162    }
163}
164
165impl Serialize for IbctKey {
166    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
167        use serde::ser::SerializeStruct;
168        let mut s = serializer.serialize_struct("IbctKey", 2)?;
169        s.serialize_field("key_id", &self.key_id)?;
170        s.serialize_field("key_bytes", "[REDACTED]")?;
171        s.end()
172    }
173}
174
175/// An Invocation-Bound Capability Token.
176#[derive(Debug, Clone, Serialize, Deserialize)]
177pub struct Ibct {
178    /// Identifies which key was used for signing, enabling key rotation.
179    pub key_id: String,
180    /// A2A task ID this token is scoped to.
181    pub task_id: String,
182    /// A2A agent endpoint this token is scoped to.
183    pub endpoint: String,
184    /// Unix timestamp (seconds) when this token was issued.
185    pub issued_at: u64,
186    /// Unix timestamp (seconds) when this token expires.
187    pub expires_at: u64,
188    /// HMAC-SHA256 over `{key_id}|{task_id}|{endpoint}|{issued_at}|{expires_at}`, hex-encoded.
189    pub signature: String,
190}
191
192impl Ibct {
193    /// Issue a new IBCT scoped to `task_id` + `endpoint`, valid for `ttl`.
194    ///
195    /// # Errors
196    ///
197    /// Returns `IbctError::FeatureDisabled` when compiled without the `ibct` feature.
198    #[allow(clippy::needless_return)]
199    pub fn issue(
200        task_id: &str,
201        endpoint: &str,
202        ttl: Duration,
203        key: &IbctKey,
204    ) -> Result<Self, IbctError> {
205        #[cfg(not(feature = "ibct"))]
206        {
207            let _ = (task_id, endpoint, ttl, key);
208            return Err(IbctError::FeatureDisabled);
209        }
210        #[cfg(feature = "ibct")]
211        {
212            let now = unix_now();
213            let expires_at = now + ttl.as_secs();
214            let signature = sign(
215                &key.key_bytes,
216                &key.key_id,
217                task_id,
218                endpoint,
219                now,
220                expires_at,
221            );
222            Ok(Self {
223                key_id: key.key_id.clone(),
224                task_id: task_id.to_owned(),
225                endpoint: endpoint.to_owned(),
226                issued_at: now,
227                expires_at,
228                signature,
229            })
230        }
231    }
232
233    /// Verify this token against a key set, expected endpoint, and expected `task_id`.
234    ///
235    /// Looks up the key by `key_id`, verifies the HMAC signature, checks expiry
236    /// (with `CLOCK_SKEW_GRACE_SECS` grace), and checks endpoint + `task_id` binding.
237    ///
238    /// # Errors
239    ///
240    /// Returns one of `IbctError::*` on any verification failure.
241    #[allow(clippy::needless_return)]
242    pub fn verify(
243        &self,
244        keys: &[IbctKey],
245        expected_endpoint: &str,
246        expected_task_id: &str,
247    ) -> Result<(), IbctError> {
248        #[cfg(not(feature = "ibct"))]
249        {
250            let _ = (keys, expected_endpoint, expected_task_id);
251            return Err(IbctError::FeatureDisabled);
252        }
253        #[cfg(feature = "ibct")]
254        {
255            let key = keys
256                .iter()
257                .find(|k| k.key_id == self.key_id)
258                .ok_or_else(|| IbctError::UnknownKeyId {
259                    key_id: self.key_id.clone(),
260                })?;
261
262            // Constant-time HMAC verification: reconstruct the MAC and call verify_slice()
263            // instead of comparing hex strings, which would be vulnerable to timing attacks.
264            if verify_signature(
265                &key.key_bytes,
266                &self.key_id,
267                &self.task_id,
268                &self.endpoint,
269                self.issued_at,
270                self.expires_at,
271                &self.signature,
272            )
273            .is_err()
274            {
275                return Err(IbctError::InvalidSignature);
276            }
277
278            let now = unix_now();
279            if now > self.expires_at + CLOCK_SKEW_GRACE_SECS {
280                return Err(IbctError::Expired {
281                    expires_at: self.expires_at,
282                    now,
283                });
284            }
285
286            if self.endpoint != expected_endpoint {
287                return Err(IbctError::EndpointMismatch {
288                    expected: expected_endpoint.to_owned(),
289                    got: self.endpoint.clone(),
290                });
291            }
292
293            if self.task_id != expected_task_id {
294                return Err(IbctError::TaskMismatch {
295                    expected: expected_task_id.to_owned(),
296                    got: self.task_id.clone(),
297                });
298            }
299
300            Ok(())
301        }
302    }
303
304    /// Encode this token to a base64-JSON string suitable for use in an HTTP header.
305    ///
306    /// # Errors
307    ///
308    /// Returns `serde_json::Error` if serialization fails.
309    pub fn encode(&self) -> Result<String, serde_json::Error> {
310        let json = serde_json::to_vec(self)?;
311        Ok(base64_compat::encode(&json))
312    }
313
314    /// Decode a token from the base64-JSON string produced by `encode()`.
315    ///
316    /// # Errors
317    ///
318    /// Returns `IbctError::Base64` or `IbctError::Json` on decode failure.
319    pub fn decode(s: &str) -> Result<Self, IbctError> {
320        let bytes = base64_compat::decode(s)?;
321        let token = serde_json::from_slice(&bytes)?;
322        Ok(token)
323    }
324}
325
326#[cfg(feature = "ibct")]
327fn sign(
328    key_bytes: &[u8],
329    key_id: &str,
330    task_id: &str,
331    endpoint: &str,
332    issued_at: u64,
333    expires_at: u64,
334) -> String {
335    type HmacSha256 = Hmac<Sha256>;
336    let msg = format!("{key_id}|{task_id}|{endpoint}|{issued_at}|{expires_at}");
337    let mut mac = HmacSha256::new_from_slice(key_bytes).expect("HMAC accepts any key length");
338    mac.update(msg.as_bytes());
339    hex::encode(mac.finalize().into_bytes())
340}
341
342/// Verify an HMAC-SHA256 signature in constant time using `Mac::verify_slice`.
343///
344/// Decodes the hex `signature`, recomputes the MAC over the canonical message,
345/// and calls `verify_slice` — which uses a constant-time comparison internally.
346///
347/// # Errors
348///
349/// Returns an error if the hex is malformed or if the signature does not match.
350#[cfg(feature = "ibct")]
351fn verify_signature(
352    key_bytes: &[u8],
353    key_id: &str,
354    task_id: &str,
355    endpoint: &str,
356    issued_at: u64,
357    expires_at: u64,
358    signature_hex: &str,
359) -> Result<(), ()> {
360    type HmacSha256 = Hmac<Sha256>;
361    let decoded = hex::decode(signature_hex).map_err(|_| ())?;
362    let msg = format!("{key_id}|{task_id}|{endpoint}|{issued_at}|{expires_at}");
363    let mut mac = HmacSha256::new_from_slice(key_bytes).expect("HMAC accepts any key length");
364    mac.update(msg.as_bytes());
365    mac.verify_slice(&decoded).map_err(|_| ())
366}
367
368#[cfg(feature = "ibct")]
369fn unix_now() -> u64 {
370    SystemTime::now()
371        .duration_since(UNIX_EPOCH)
372        .unwrap_or(Duration::ZERO)
373        .as_secs()
374}
375
376/// Serde helper for hex-encoded byte vectors.
377mod hex_bytes {
378    use serde::{Deserialize, Deserializer};
379
380    pub fn deserialize<'de, D: Deserializer<'de>>(de: D) -> Result<Vec<u8>, D::Error> {
381        let s = String::deserialize(de)?;
382        hex::decode(&s).map_err(serde::de::Error::custom)
383    }
384}
385
386/// Minimal base64 compatibility layer (uses the `base64` crate already in the dep tree
387/// transitively via reqwest; we don't add a new dep).
388///
389/// This module wraps `base64::engine::general_purpose::STANDARD` under a stable API.
390mod base64_compat {
391    use base64::Engine as _;
392
393    pub use base64::DecodeError;
394
395    pub fn encode(input: &[u8]) -> String {
396        base64::engine::general_purpose::STANDARD.encode(input)
397    }
398
399    pub fn decode(input: &str) -> Result<Vec<u8>, DecodeError> {
400        base64::engine::general_purpose::STANDARD.decode(input)
401    }
402}
403
404#[cfg(test)]
405mod tests {
406    #[cfg(feature = "ibct")]
407    use super::*;
408    #[cfg(feature = "ibct")]
409    use std::assert_matches;
410
411    #[cfg(feature = "ibct")]
412    fn test_key() -> IbctKey {
413        IbctKey {
414            key_id: "k1".into(),
415            key_bytes: b"super-secret-key-for-testing-only".to_vec(),
416        }
417    }
418
419    #[cfg(feature = "ibct")]
420    #[test]
421    fn issue_and_verify_round_trip() {
422        let key = test_key();
423        let token = Ibct::issue(
424            "task-123",
425            "https://agent.example.com",
426            Duration::from_mins(5),
427            &key,
428        )
429        .unwrap();
430        assert!(
431            token
432                .verify(&[key], "https://agent.example.com", "task-123")
433                .is_ok()
434        );
435    }
436
437    #[cfg(feature = "ibct")]
438    #[test]
439    fn verify_rejects_wrong_endpoint() {
440        let key = test_key();
441        let token = Ibct::issue(
442            "task-123",
443            "https://agent.example.com",
444            Duration::from_mins(5),
445            &key,
446        )
447        .unwrap();
448        let err = token
449            .verify(&[key], "https://evil.example.com", "task-123")
450            .unwrap_err();
451        assert_matches!(err, IbctError::EndpointMismatch { .. });
452    }
453
454    #[cfg(feature = "ibct")]
455    #[test]
456    fn verify_rejects_wrong_task() {
457        let key = test_key();
458        let token = Ibct::issue(
459            "task-123",
460            "https://agent.example.com",
461            Duration::from_mins(5),
462            &key,
463        )
464        .unwrap();
465        let err = token
466            .verify(&[key], "https://agent.example.com", "task-999")
467            .unwrap_err();
468        assert_matches!(err, IbctError::TaskMismatch { .. });
469    }
470
471    #[cfg(feature = "ibct")]
472    #[test]
473    fn verify_rejects_tampered_signature() {
474        let key = test_key();
475        let mut token = Ibct::issue(
476            "task-123",
477            "https://agent.example.com",
478            Duration::from_mins(5),
479            &key,
480        )
481        .unwrap();
482        token.signature = "deadbeef".repeat(8);
483        let err = token
484            .verify(&[key], "https://agent.example.com", "task-123")
485            .unwrap_err();
486        assert_matches!(err, IbctError::InvalidSignature);
487    }
488
489    #[cfg(feature = "ibct")]
490    #[test]
491    fn verify_rejects_unknown_key_id() {
492        let key = test_key();
493        let token = Ibct::issue(
494            "task-123",
495            "https://agent.example.com",
496            Duration::from_mins(5),
497            &key,
498        )
499        .unwrap();
500        let other_key = IbctKey {
501            key_id: "k99".into(),
502            key_bytes: b"other".to_vec(),
503        };
504        let err = token
505            .verify(&[other_key], "https://agent.example.com", "task-123")
506            .unwrap_err();
507        assert_matches!(err, IbctError::UnknownKeyId { .. });
508    }
509
510    #[cfg(feature = "ibct")]
511    #[test]
512    fn encode_decode_round_trip() {
513        let key = test_key();
514        let token = Ibct::issue(
515            "task-abc",
516            "https://agent.example.com",
517            Duration::from_mins(1),
518            &key,
519        )
520        .unwrap();
521        let encoded = token.encode().unwrap();
522        let decoded = Ibct::decode(&encoded).unwrap();
523        assert_eq!(decoded.task_id, "task-abc");
524        assert_eq!(decoded.key_id, "k1");
525    }
526
527    #[cfg(feature = "ibct")]
528    #[test]
529    fn verify_rejects_expired_token() {
530        let key = test_key();
531        // Manually construct a token with expires_at in the past (beyond grace window).
532        let now = std::time::SystemTime::now()
533            .duration_since(std::time::UNIX_EPOCH)
534            .unwrap()
535            .as_secs();
536        // Set expires_at to 120 seconds ago (well beyond CLOCK_SKEW_GRACE_SECS=30).
537        let expired_at = now.saturating_sub(120);
538        let issued_at = expired_at.saturating_sub(300);
539        // Build the signature manually so it matches the token fields.
540        #[cfg(feature = "ibct")]
541        let signature = {
542            use hmac::{Hmac, KeyInit, Mac};
543            use sha2::Sha256;
544            type HmacSha256 = Hmac<Sha256>;
545            let msg = format!(
546                "{}|{}|{}|{}|{}",
547                key.key_id, "task-expired", "https://agent.example.com", issued_at, expired_at
548            );
549            let mut mac =
550                HmacSha256::new_from_slice(&key.key_bytes).expect("HMAC accepts any key length");
551            mac.update(msg.as_bytes());
552            hex::encode(mac.finalize().into_bytes())
553        };
554        let token = Ibct {
555            key_id: key.key_id.clone(),
556            task_id: "task-expired".into(),
557            endpoint: "https://agent.example.com".into(),
558            issued_at,
559            expires_at: expired_at,
560            signature,
561        };
562        let err = token
563            .verify(&[key], "https://agent.example.com", "task-expired")
564            .unwrap_err();
565        assert!(
566            matches!(err, IbctError::Expired { .. }),
567            "expected Expired, got {err:?}"
568        );
569    }
570
571    #[cfg(feature = "ibct")]
572    #[test]
573    fn key_rotation_verifies_with_old_key() {
574        let old_key = IbctKey {
575            key_id: "k1".into(),
576            key_bytes: b"old-key".to_vec(),
577        };
578        let new_key = IbctKey {
579            key_id: "k2".into(),
580            key_bytes: b"new-key".to_vec(),
581        };
582        let token = Ibct::issue(
583            "task-1",
584            "https://agent.example.com",
585            Duration::from_mins(5),
586            &old_key,
587        )
588        .unwrap();
589        // Verifier has both keys — old token still verifies
590        assert!(
591            token
592                .verify(&[old_key, new_key], "https://agent.example.com", "task-1")
593                .is_ok()
594        );
595    }
596
597    #[cfg(feature = "ibct")]
598    #[test]
599    fn ibct_key_debug_redacts_key_bytes() {
600        let key = test_key();
601        let debug = format!("{key:?}");
602        assert!(!debug.contains("super-secret-key-for-testing-only"));
603        assert!(debug.contains("k1"));
604        assert!(debug.contains("REDACTED"));
605    }
606
607    #[cfg(feature = "ibct")]
608    #[test]
609    fn ibct_key_from_hex_decodes_bytes() {
610        let key = IbctKey::from_hex("k1", "68656c6c6f").unwrap();
611        assert_eq!(key.key_id, "k1");
612        assert_eq!(key.key_bytes, b"hello");
613    }
614
615    #[cfg(feature = "ibct")]
616    #[test]
617    fn ibct_key_from_hex_rejects_invalid_hex() {
618        assert!(IbctKey::from_hex("k1", "not-hex").is_err());
619    }
620
621    // #6260 review M6: `ibct_scope_origin` moved here from `client.rs` so both the client
622    // (`A2aClient::ibct_header_value`) and the server (`A2aServer::serve`, normalizing
623    // `card.url`) share one normalization, instead of only the client normalizing while the
624    // server compared a raw, possibly non-canonical `card.url` (which would silently 403
625    // every request for a non-canonical `public_url` — the same bug class as S1, just moved
626    // to the other side). `client.rs`'s `ibct_scope_origin_*` tests and `router.rs`'s
627    // `non_canonical_card_url_still_matches_client_issued_token` cover the two call sites
628    // directly; this test covers the one normalization case neither of those exercises: an
629    // explicit default port must be stripped (`url::Url::Origin::ascii_serialization`'s own
630    // behavior, but worth pinning since #6260's review flagged `:443` by name).
631    #[cfg(feature = "ibct")]
632    #[test]
633    fn ibct_scope_origin_strips_explicit_default_port() {
634        assert_eq!(
635            ibct_scope_origin("https://agent.example.com:443/a2a"),
636            "https://agent.example.com"
637        );
638        assert_eq!(
639            ibct_scope_origin("http://agent.example.com:80/a2a/stream"),
640            "http://agent.example.com"
641        );
642    }
643
644    #[cfg(feature = "ibct")]
645    #[test]
646    fn ibct_key_serialize_redacts_key_bytes() {
647        let key = test_key();
648        let json = serde_json::to_string(&key).unwrap();
649        assert!(!json.contains(&hex::encode(b"super-secret-key-for-testing-only")));
650        assert!(json.contains("k1"));
651        assert!(json.contains("REDACTED"));
652    }
653}