zeph-a2a
A2A protocol client and server with agent discovery for Zeph.
Overview
Implements the Agent-to-Agent (A2A) protocol over JSON-RPC 2.0, enabling Zeph to discover, communicate with, and delegate tasks to remote agents. Feature-gated behind a2a; the server component requires the server sub-feature.
Key Modules
- client —
A2aClientfor sending tasks and messages to remote agents - server —
A2aServerexposing an A2A-compliant endpoint withProcessorEventstreaming viampsc::Sender(requiresserverfeature) - card —
AgentCardBuilderfor constructing agent capability cards; includesprotocolVersionfield set toA2A_PROTOCOL_VERSIONconstant ("0.2.1") in the default card served at/.well-known/agent.json - discovery —
AgentRegistryfor agent lookup and registration, with an optional card-signing + URL-origin trust policy applied indiscover()(see below) - jsonrpc — JSON-RPC 2.0 request/response types
- types — shared protocol types (Task, Message, Artifact, etc.)
- error —
A2aErrorerror types
IBCT (Invocation-Bound Capability Tokens)
IBCT is an opt-in, finer-grained authorization layer on top of the coarse bearer-token gate: HMAC-SHA256 capability tokens scoped to a specific task_id + endpoint, sent in the X-Zeph-IBCT request header.
Server (A2aServer::with_ibct_keys): when configured with a non-empty key set, zeph-a2a's router rejects every /a2a and /a2a/stream request that does not carry a valid X-Zeph-IBCT header — 401 if the header is missing or undecodable, 403 if it fails verification (bad signature, expired, unknown key_id, or scoped to the wrong endpoint/task). The expected endpoint is the server's own advertised AgentCard::url; the expected task_id is read from the request (params.id for tasks/get/tasks/cancel, params.message.taskId for message/send/message/stream — the empty-string sentinel for a brand-new task that has no server-assigned ID yet). An empty key set (the default) disables enforcement entirely.
Client (A2aClient::with_ibct_key): when configured with an IbctKey, the client issues a token scoped to the target endpoint + task on every request and attaches it alongside the bearer token. Issuance failures are logged and the request proceeds without the header — the server decides whether to reject it.
Key rotation is supported via key_id: multiple keys can be configured on the server simultaneously, so an old signing key stays valid for verification until every token it signed has expired.
| Config field | Type | Default | Description |
|---|---|---|---|
ibct_keys |
Vec<IbctKeyConfig> |
[] |
Named HMAC keys ({ key_id, key_hex }) verified against incoming tokens |
ibct_signing_key_vault_ref |
string | "" |
Vault reference for the primary key (key_id = "primary"); takes precedence over ibct_keys[0] |
ibct_ttl_secs |
u64 | 300 |
Token validity window in seconds, for callers issuing tokens with this TTL |
[]
= 300
= "ZEPH_A2A_IBCT_KEY"
[[]]
= "k1"
= "68656c6c6f2d7365637265742d6b6579" # legacy inline path; prefer the vault ref above
Note: IBCT signing/verification requires the ibct feature flag. Without it, Ibct::issue/Ibct::verify always return IbctError::FeatureDisabled — a server configured with ibct_keys would then reject every request, and a client configured with with_ibct_key would log a warning and send no header on every request.
Important — this ships the enforcement primitive, not an activated end-to-end control. As of this writing, no caller bundled in this repository calls A2aClient::with_ibct_key: src/tui_remote.rs's A2aClient usage (the --connect remote-TUI-over-A2A-SSE attach feature) does not issue IBCT tokens, and there is no delegation client that spawns subagent tasks over A2A with a token attached. Concretely:
- With the default
ibct_keys = [], the server stays a no-op — enabling this fix alone does not change behavior for any existing deployment. - Setting
ibct_keysto a non-empty list makes the server requireX-Zeph-IBCTon every/a2aand/a2a/streamrequest. Since nothing in this repository attaches that header, doing so will401zeph --connect's owntui_remoteclient and any standard (non-Zeph) A2A peer that has no knowledge of this header — it does not, by itself, protect a delegated subagent task from a leaked bearer token, because no delegation client using IBCT exists yet to protect. - To get real protection from IBCT, an operator (or a follow-up change) must build/wire a caller — most likely a task-delegation client for subagent orchestration — that calls
with_ibct_keyand scopes tokens to the tasks it delegates, before enablingibct_keyson the receiving server.
Agent Card trust policy (JWS signature verification)
AgentRegistry supports an optional, feature-gated (card-signing) A2A 1.0.0 AgentCardSignature
check applied inside discover(), closing the card-spoofing/impersonation gap where a peer card was
trusted unauthenticated with no cross-check between the queried base URL and the card's own url
field.
AgentRegistry::with_trust(policy, trusted_keys) configures a tri-state CardTrustPolicy
(Ignore / Prefer / Require, default Ignore) combining signature verification and
URL-origin consistency via most-severe-wins precedence, checked against an out-of-band
operator-configured trusted-key store (never the card-supplied jku, which would reopen an SSRF
surface this crate already guards against elsewhere). Not calling with_trust leaves the registry
at Ignore with no trusted keys — zero behavior change for existing callers.
use ;
use Duration;
let registry = new
.with_trust;
zeph --connect <URL> — the only outbound A2A client path in the binary — wires
AgentRegistry::discover with the operator's configured [a2a] card_trust_policy and
trusted_agent_keys.
[!WARNING] Canonicalization is implemented per the A2A spec text but has not been validated against a real
a2a-sdk-produced signed-card vector —requiremay reject genuinely valid peers until this is proven (tracked in #6201).
Authentication
A2aServer supports bearer token authentication via the with_auth() builder method. When auth_token is None, the server emits a tracing::warn! at startup indicating that the endpoint is unauthenticated.
use Arc;
use watch;
use ;
let card = new.build;
let = channel;
new
.with_auth
.with_rate_limit // requests per 60s window per IP; 0 disables
.serve
.await?;
The token is hashed once at construction time; each request compares blake3 hashes of both sides to prevent timing attacks. A2aServer::with_require_auth(true) rejects all requests when no token is configured. Failed-auth requests (missing/invalid bearer token or IBCT header) are also subject to the same per-IP rate limit as ordinary requests, closing a brute-force vector against the auth layer itself.
Features
| Feature | Description |
|---|---|
server |
Enables A2aServer, TaskManager, and TaskProcessor with an axum HTTP handler and bearer auth (requires axum, tower, tower-http) |
ibct |
Enables Ibct token issuance and verification (HMAC-SHA256) |
card-signing |
Enables A2A 1.0.0 AgentCardSignature verification and the CardTrustPolicy trust check in AgentRegistry::discover (requires p256, serde_json_canonicalizer). Must be compiled in together with zeph-config's matching marker feature — card_trust_policy = "require" fails config validation otherwise. |
Installation
# With server component
Enabled via the a2a feature flag on the root zeph crate.
Documentation
Full documentation: https://bug-ops.github.io/zeph/
License
Licensed under either of MIT or Apache License, Version 2.0 at your option.