Security
What this SDK guarantees, what it leaves to you, and what it does not do at all. Production Hardening has the knobs; this page is the posture behind them, including the parts that are gaps rather than features.
What holds by construction
| Property | How |
|---|---|
| No memory-unsafety in the published crates | All four set #![forbid(unsafe_code)]. There is no unsafe block to review, because one cannot be added without removing that line in a diff |
| No panic paths in library code | Zero .unwrap() in runtime library code, and zero panic!/todo!/unimplemented!. A CI ratchet fails the build when that changes |
| Undocumented public API cannot ship | #![deny(missing_docs)] |
| Dependencies are audited | cargo-deny runs in CI over the workspace, and separately over the SLIMRPC binding's much larger tree |
| Releases are attested | SLSA build provenance and a per-crate CycloneDX SBOM are produced by the release workflow |
Authentication
Nothing is authenticated unless you say so. An agent built with no interceptor serves every caller.
| Interceptor | Feature | Use when |
|---|---|---|
BearerTokenAuthInterceptor | none | you validate an opaque token yourself |
ApiKeyAuthInterceptor | none | a shared secret in a header is genuinely enough |
JwtAuthInterceptor | auth-jwt | HS256/RS256/ES256, static or remote JWKS with OIDC discovery |
JwtAuthInterceptor is the one to reach for when the caller is another
organisation: it verifies signatures against a key set you did not have to
distribute by hand, and it is the only one of the three that can tell you
who the caller is rather than only that they knew a secret.
Pair it with a BearerTokenTenantResolver if you are multi-tenant — see
Multi-Tenancy, where the failure mode of getting this
wrong is spelled out.
Transport
The server does not terminate inbound TLS. Put it behind a reverse proxy. This is a deliberate scope decision, not an omission: certificate lifecycle, SNI, ALPN and renewal are solved better by nginx, Caddy or a load balancer than by a protocol library.
Client ──HTTPS──→ [nginx / Caddy / LB] ──HTTP──→ [a2a-rust agent]
Outbound is different. The client and the push sender do speak TLS, via
tls-rustls — on by default in a2a-protocol-sdk and a2a-protocol-client,
off in a direct dependency on a2a-protocol-server.
Outbound requests are the dangerous ones
An agent that accepts a webhook URL from a caller is an SSRF primitive unless
something stops it. The bundled HttpPushSender does:
- private and loopback addresses are rejected at config creation and again at delivery time — defence in depth, because the two moments can disagree;
- IPv4-in-IPv6 smuggling is rejected (
::ffff:mapped, NAT6464:ff9b::, and the deprecated::a.b.c.d); - the non-canonical numeric IPv4 encodings a C resolver still accepts are
normalised and rejected —
http://2852039166/,http://0xA9FEA9FE/andhttp://0251.0376.0251.0376/all denote169.254.169.254; - the validated IP is pinned, so DNS rebinding between check and connect does not help an attacker;
- credentials are checked for
\rand\nbefore they reach a header; - every delivery is capped at 30 seconds.
If you replace the sender, you inherit all of that as your responsibility. The validation helpers are public and reusable — use them rather than reimplementing the list above.
That "the two moments can disagree" is not hypothetical: the numeric-IPv4 line above was added after an adversarial run found the registration check accepting an integer-encoded metadata URL that delivery still blocked. The gap is closed and regression-tested in both the unit suite and the over-the-wire probe.
Input handling
- The REST dispatcher rejects
..,%2E%2Eand%2e%2ein path segments, and paths that escape the route hierarchy. - Request bodies are capped at 4 MiB (JSON-RPC and REST) and query strings at 4 KiB (REST); SSE events at 16 MiB, configurable.
- A tenant at
max_concurrent_tasksis refused rather than queued, so a declared bound stays a bound under load.
Agent card signing, and the part people miss
signing on a2a-protocol-server is a forwarding feature. It makes the
signing types available. The server never signs the card it serves.
Sign the card yourself with sign_agent_card and hand the signed card to
RequestHandlerBuilder. An unsigned card is served happily and verifies as
unsigned — the server has done what it was asked, so there is no error to look
for. examples/incident-response does this correctly and is worth copying.
Declare the extensions before you sign. build() advertises the
extensions this server can honour by appending to
capabilities.extensions, and canonicalize_card strips only signatures
— so capabilities.extensions is inside the bytes a signature covers. A
card signed before those entries exist would be served canonicalizing to
bytes nobody signed, and every client that verifies would fail while this
side reported nothing. So build() refuses a signed card it would
otherwise have to edit, and names the URIs to add:
#![allow(unused)] fn main() { use a2a_protocol_types::agent_card::{AgentCard, AgentInterface}; use a2a_protocol_types::error::A2aResult; use a2a_protocol_types::extensions::AgentExtension; use a2a_protocol_types::failure::FAILURE_EXTENSION_URI; use a2a_protocol_types::idempotency::IDEMPOTENCY_EXTENSION_URI; use a2a_protocol_types::signing::sign_agent_card; fn card_to_serve(pkcs8: &[u8]) -> A2aResult<AgentCard> { let mut card = AgentCard::new( "Triage Agent", "1.0.0", AgentInterface::jsonrpc("https://agent.example/rpc"), ); // Declare first. `AgentExtension::new` leaves `required` unset, which is // what a non-required extension means on the wire. card.capabilities.extensions = Some(vec![ AgentExtension::new(IDEMPOTENCY_EXTENSION_URI), AgentExtension::new(FAILURE_EXTENSION_URI), ]); // ...then sign the card that results, and hand *that* to the builder. let signature = sign_agent_card(&card, pkcs8, Some("my-key"))?; card.signatures = Some(vec![signature]); Ok(card) } }
Declare only the extensions your configuration actually advertises:
IDEMPOTENCY_EXTENSION_URI is advertised exactly when the configured
TaskStore reports supports_idempotency(), and FAILURE_EXTENSION_URI
always. Declaring one the server would not have added is allowed — an
operator's own entry is never overwritten — but it advertises a capability
to clients, so declare it only if it holds.
Known gaps
Stated because a security page that lists only strengths is not one.
- No PGP key for vulnerability reports. Email to the maintainer cannot be encrypted end-to-end. Use GitHub Security Advisories instead, which keeps a report private without needing a key.
- Release tags are not signed. Since
v0.8.0they are annotated — they carry a tagger and a date, and the release workflow refuses a lightweight tag — but nothing is GPG/SSH-signed, sogit tag -vcannot verify a release. For a cryptographic link from a published version to this repository, use the build provenance attestations described inPROVENANCE.md, not the tag. - No rate limiting is applied unless you wire it.
rate_limit_rpsis counted in an interceptor; setting the field without installingRateLimitInterceptordoes nothing. - The SLIMRPC binding is out of scope for the published crates' audit
surface. It pulls 359 transitive dependencies including a native C crypto
build, which is why it lives outside the workspace with its own lockfile and
its own
cargo-denyrun.
Reporting a vulnerability
Do not open a public issue. Open a draft security
advisory, or
email the address in this project's copyright headers. SECURITY.md carries the
full policy, including the 90-day coordinated-disclosure timeline and what to
include in a report.