diff --git a/.zeph/skills/setup-guide/SKILL.md b/.zeph/skills/setup-guide/SKILL.md index 64cfc8b0d..f1deefe7d 100644 --- a/.zeph/skills/setup-guide/SKILL.md +++ b/.zeph/skills/setup-guide/SKILL.md @@ -130,6 +130,7 @@ setting that actually governs outbound `--connect` requests. ```bash export ZEPH_A2A_CLIENT_REQUIRE_TLS=true export ZEPH_A2A_CLIENT_SSRF_PROTECTION=true +export ZEPH_A2A_CARD_TRUST_POLICY=ignore ``` Security policy for `zeph --tui --connect ` (remote-TUI attach to another @@ -142,6 +143,13 @@ as `[a2a]`'s (now-reserved) checks (10.x, 172.16.x, 192.168.x, 127.x) — not sh enforcement, since `[a2a]` no longer enforces anything on either path. Max body size: request payload limit in bytes (default 1 MiB). +`ZEPH_A2A_CARD_TRUST_POLICY` (`ignore` | `prefer` | `require`, default `ignore`) +controls peer AgentCard signature + URL-origin verification during discovery (A2A +1.0.0 §8.4, #5928). `require` needs the crate's `card-signing` feature compiled in +(part of the `a2a` feature bundle) or config validation fails at startup. Trusted +verification keys are configured via `[[a2a_client.trusted_agent_keys]]` in +`config.toml` (no env-var equivalent — see `config/default.toml` for the shape). + ## Tools Config: `tools.enabled` (default: true) — master toggle for all tool execution. diff --git a/CHANGELOG.md b/CHANGELOG.md index d102b173e..a3314b97d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -67,6 +67,36 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Added +- **A2A**: added optional JWS Agent Card signature verification (A2A 1.0.0 §8.4) and a + `require`/`prefer`/`ignore` trust policy for peer discovery (#5928): + - `AgentCard.signatures: Vec` — new, additive, `#[serde(default, + skip_serializing_if = "Vec::is_empty")]` field; unsigned/0.2.x peer cards round-trip + unchanged (empty on the wire). + - New `card-signing` Cargo feature (`crates/zeph-a2a`, `p256` + `serde_json_canonicalizer`, + pure-Rust, no `openssl-sys`) enabling ES256 (P-256) verification. Off by default; propagated + through the root `a2a` feature (and `full`) alongside the existing `ibct`/`server` features + so CI actually compiles and tests the crypto path. + - `AgentRegistry::with_trust(policy, trusted_keys)` runs a crypto-free URL-origin + (scheme+host+port) consistency check plus signature verification in `discover()`, combining + both axes by taking the most severe outcome (reject > warn > accept) and returning + `A2aError::UntrustedCard` / `A2aError::UrlMismatch`. + - `[a2a_client]` gains `card_trust_policy` (`CardTrustPolicy`, default `ignore` — byte-identical + to prior behavior) and `trusted_agent_keys` (`Vec`, public keys stored + inline, not vault-referenced). `Config::validate()` fails fast if `card_trust_policy = + "require"` is set without the `card-signing` feature compiled in, rather than silently + degrading or bricking discovery. New `ZEPH_A2A_CARD_TRUST_POLICY` env override and + `--migrate-config` step 82 (commented advisory block for existing configs). + - **Known limitations, tracked as follow-ups**: the JCS canonicalization and signing-input + construction were implemented from the A2A 1.0.0 spec text, not validated against a real + `a2a-sdk`-produced signed card (no network access to obtain a reference vector in this + environment) — treat `require` as unproven for real-peer interop until a vector lands. + `AgentRegistry` still has no runtime construction site in `zeph-core`/`src/`, so + `card_trust_policy` is a fully-implemented library knob with no consumer yet (pre-existing + gap, not created by this change). The well-known discovery path remains + `/.well-known/agent.json` (0.2.x); a pure-1.0.0 peer serving `/.well-known/agent-card.json` + is not yet discoverable. `A2A_PROTOCOL_VERSION` stays `"0.2.1"` — this change is one additive + 1.0.0 feature, not full 1.0.0 conformance. `jku`/JWKS auto-fetch, EdDSA/RS256, and signing + our own served card are all deferred. - **Config**: documented `[security.shadow_sentinel]` (`ShadowSentinelConfig`) as a commented advisory block in `config/default.toml`, and added migration step 81 (`migrate_shadow_sentinel_config`) so existing configs gain the same discoverable block via diff --git a/Cargo.lock b/Cargo.lock index dcd8484b8..dc90d7d63 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -696,6 +696,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" +[[package]] +name = "base16ct" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6" + [[package]] name = "base64" version = "0.13.1" @@ -1506,6 +1512,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "cpubits" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -1689,6 +1701,22 @@ dependencies = [ "zeroize", ] +[[package]] +name = "crypto-bigint" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a52aa3fcda4e6302a9f48734f234d35d4721b96f8fe07d073f07ce9df4f0271" +dependencies = [ + "cpubits", + "ctutils", + "getrandom 0.4.3", + "hybrid-array", + "num-traits", + "rand_core 0.10.1", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -1706,7 +1734,9 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" dependencies = [ + "getrandom 0.4.3", "hybrid-array", + "rand_core 0.10.1", ] [[package]] @@ -1749,6 +1779,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" dependencies = [ "cmov", + "subtle", ] [[package]] @@ -1964,6 +1995,17 @@ dependencies = [ "zeroize", ] +[[package]] +name = "der" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69dedd701da44b0536442edf09c81a64b0ab97a7a4a5e3d1971f00027cbc63d" +dependencies = [ + "const-oid 0.10.2", + "pem-rfc7468", + "zeroize", +] + [[package]] name = "deranged" version = "0.5.8" @@ -2220,12 +2262,27 @@ version = "0.16.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" dependencies = [ - "der", + "der 0.7.10", "digest 0.10.7", - "elliptic-curve", - "rfc6979", - "signature", - "spki", + "elliptic-curve 0.13.8", + "rfc6979 0.4.0", + "signature 2.2.0", + "spki 0.7.3", +] + +[[package]] +name = "ecdsa" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0681a4fc24c767085329728d8dfba959af91228aa4610cca4f8ce317ba46ae0" +dependencies = [ + "der 0.8.1", + "digest 0.11.3", + "elliptic-curve 0.14.1", + "rfc6979 0.6.0", + "signature 3.0.0", + "spki 0.8.0", + "zeroize", ] [[package]] @@ -2243,15 +2300,36 @@ version = "0.13.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" dependencies = [ - "base16ct", - "crypto-bigint", + "base16ct 0.2.0", + "crypto-bigint 0.5.5", "digest 0.10.7", - "ff", + "ff 0.13.1", "generic-array", - "group", - "pkcs8", + "group 0.13.0", + "pkcs8 0.10.2", "rand_core 0.6.4", - "sec1", + "sec1 0.7.3", + "subtle", + "zeroize", +] + +[[package]] +name = "elliptic-curve" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d65aa39b3a5c1c9c1b745c9a019234bb7a21b77abcb4f4d266d706e2d577d65" +dependencies = [ + "base16ct 1.0.0", + "crypto-bigint 0.7.5", + "crypto-common 0.2.2", + "digest 0.11.3", + "ff 0.14.0", + "group 0.14.0", + "hybrid-array", + "pem-rfc7468", + "pkcs8 0.11.0", + "rand_core 0.10.1", + "sec1 0.8.1", "subtle", "zeroize", ] @@ -2531,6 +2609,16 @@ dependencies = [ "subtle", ] +[[package]] +name = "ff" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1f686ab92a9fb0eaf188f6c6c87b89490baa6fdb0db4544ba4dc47f7942489f" +dependencies = [ + "rand_core 0.10.1", + "subtle", +] + [[package]] name = "fiat-crypto" version = "0.2.9" @@ -3224,11 +3312,22 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" dependencies = [ - "ff", + "ff 0.13.1", "rand_core 0.6.4", "subtle", ] +[[package]] +name = "group" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fd1a1c7a5206c5b7a3f5a0d7ccd3ff85d0c8f5133d62a02680255b0004af5f4" +dependencies = [ + "ff 0.14.0", + "rand_core 0.10.1", + "subtle", +] + [[package]] name = "h2" version = "0.4.15" @@ -3464,7 +3563,9 @@ version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" dependencies = [ + "subtle", "typenum", + "zeroize", ] [[package]] @@ -4097,8 +4198,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6e3919bbaa2945715f0bb6d3934a173d1e9a59ac23767fbaaef277265a7411b" dependencies = [ "cfg-if", - "ecdsa", - "elliptic-curve", + "ecdsa 0.16.9", + "elliptic-curve 0.13.8", "once_cell", "sha2 0.10.9", ] @@ -5107,6 +5208,19 @@ dependencies = [ "serde", ] +[[package]] +name = "p256" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2c9239b2dbc807adbbe147e8cf72ea7450c3a0aabe62cb8e75ff4ec22e1f72a" +dependencies = [ + "ecdsa 0.17.0", + "elliptic-curve 0.14.1", + "primefield", + "primeorder", + "sha2 0.11.0", +] + [[package]] name = "page_size" version = "0.6.0" @@ -5234,6 +5348,15 @@ dependencies = [ "unicode-normalization", ] +[[package]] +name = "pem-rfc7468" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6305423e0e7738146434843d1694d621cce767262b2a86910beab705e4493d9" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -5459,8 +5582,18 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" dependencies = [ - "der", - "spki", + "der 0.7.10", + "spki 0.7.3", +] + +[[package]] +name = "pkcs8" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7" +dependencies = [ + "der 0.8.1", + "spki 0.8.0", ] [[package]] @@ -5627,6 +5760,33 @@ dependencies = [ "num-integer", ] +[[package]] +name = "primefield" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c555a6e4eb7d4e158fcb028c835c3b8642206ddc279b5c6b202ef9a8bdb592f4" +dependencies = [ + "crypto-bigint 0.7.5", + "crypto-common 0.2.2", + "ff 0.14.0", + "rand_core 0.10.1", + "subtle", + "zeroize", +] + +[[package]] +name = "primeorder" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c9f42978c78a00e3d68f69fc03e57a234debae69da4020a4fb588fcdcd07b06" +dependencies = [ + "elliptic-curve 0.14.1", + "once_cell", + "primefield", + "serdect", + "wnaf", +] + [[package]] name = "proc-macro-error-attr2" version = "2.0.0" @@ -6514,6 +6674,16 @@ dependencies = [ "subtle", ] +[[package]] +name = "rfc6979" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4a459cddafb3fe76b31fd8f1108007566c40301feb64dc7b54656eb7388172b" +dependencies = [ + "crypto-bigint 0.7.5", + "hmac 0.13.0", +] + [[package]] name = "rgb" version = "0.8.53" @@ -6815,6 +6985,12 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "ryu-js" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04d056b875a9d2e6cb9a61d127afee9ac5999b9f87bcb32079d1318e505be714" + [[package]] name = "safetensors" version = "0.4.5" @@ -6941,10 +7117,24 @@ version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" dependencies = [ - "base16ct", - "der", + "base16ct 0.2.0", + "der 0.7.10", "generic-array", - "pkcs8", + "pkcs8 0.10.2", + "subtle", + "zeroize", +] + +[[package]] +name = "sec1" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56d437c2f19203ce5f7122e507831de96f3d2d4d3be5af44a0b0a09d8a80e4d" +dependencies = [ + "base16ct 1.0.0", + "ctutils", + "der 0.8.1", + "hybrid-array", "subtle", "zeroize", ] @@ -7091,6 +7281,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_json_canonicalizer" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe52319a927259afbfa5180c5157cd8167edfd3e8c254f9558c7fef44c5649f2" +dependencies = [ + "ryu-js", + "serde", + "serde_json", +] + [[package]] name = "serde_norway" version = "0.9.42" @@ -7188,6 +7389,16 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "serdect" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66cf8fedced2fcf12406bcb34223dffb92eaf34908ede12fed414c82b7f00b3e" +dependencies = [ + "base16ct 1.0.0", + "serde", +] + [[package]] name = "serial_test" version = "3.5.0" @@ -7344,6 +7555,16 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "signature" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" +dependencies = [ + "digest 0.11.3", + "rand_core 0.10.1", +] + [[package]] name = "simd-adler32" version = "0.3.9" @@ -7464,7 +7685,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" dependencies = [ "base64ct", - "der", + "der 0.7.10", +] + +[[package]] +name = "spki" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d9efca8738c78ee9484207732f728b1ef517bbb1833d6fc0879ca898a522f6f" +dependencies = [ + "base64ct", + "der 0.8.1", ] [[package]] @@ -10148,6 +10379,17 @@ version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +[[package]] +name = "wnaf" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab12e7090f27e2ffd9322651492942d50c2926094af30601e1964337db39daf1" +dependencies = [ + "ff 0.14.0", + "group 0.14.0", + "hybrid-array", +] + [[package]] name = "writeable" version = "0.6.3" @@ -10337,9 +10579,11 @@ dependencies = [ "hex", "hmac 0.13.0", "http-body-util", + "p256", "reqwest 0.13.4", "serde", "serde_json", + "serde_json_canonicalizer", "sha2 0.11.0", "thiserror 2.0.18", "tokio", diff --git a/Cargo.toml b/Cargo.toml index 754dd5e38..f8436e311 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -77,6 +77,7 @@ opentelemetry = "0.32.0" opentelemetry-otlp = { version = "0.32.0", default-features = false } opentelemetry_sdk = { version = "0.32.1", default-features = false } ordered-float = "5.3" +p256 = "0.14.0" parking_lot = "0.12.5" pdf-extract = "0.12.0" petgraph = "0.8.3" @@ -100,6 +101,7 @@ seccompiler = "0.5.0" semver = "1.0.28" serde = "1.0.228" serde_json = "1.0.150" +serde_json_canonicalizer = "0.3.2" serde_norway = "0.9.42" serial_test = "3.5.0" sha2 = "0.11" @@ -244,7 +246,7 @@ bench = ["dep:zeph-bench"] # === Individual feature flags === deep-link = ["zeph-common/deep-link", "zeph-config/deep-link"] -a2a = ["dep:zeph-a2a", "zeph-a2a?/server", "zeph-a2a?/ibct"] +a2a = ["dep:zeph-a2a", "zeph-a2a?/server", "zeph-a2a?/ibct", "zeph-a2a?/card-signing", "zeph-config/card-signing"] acp = ["dep:zeph-acp", "zeph-acp/unstable-session-delete", "zeph-acp/unstable-session-fork", "zeph-acp/unstable-session-resume", "zeph-acp/unstable-session-usage", "zeph-acp/unstable-elicitation", "zeph-acp/unstable-llm-providers", "zeph-acp/unstable-logout", "zeph-acp/unstable-auth-methods", "zeph-acp/unstable-message-id", "zeph-acp/unstable-session-add-dirs"] acp-http = ["acp", "dep:axum", "zeph-acp/acp-http"] candle = ["zeph-llm/candle", "zeph-core/candle"] diff --git a/config/default.toml b/config/default.toml index 664c377e7..32368ac88 100644 --- a/config/default.toml +++ b/config/default.toml @@ -653,6 +653,16 @@ max_body_size = 1048576 require_tls = true # Block non-loopback --connect targets that resolve to private/loopback IPs ssrf_protection = true +# A2A Agent Card signature + URL-origin trust policy (A2A 1.0.0 §8.4, #5928). +# "ignore" (default) skips verification; "prefer" warns on an unverifiable/mismatched +# card but still accepts it; "require" rejects it. "require" needs the crate's +# card-signing feature compiled in (see the `a2a` feature in the root Cargo.toml). +# card_trust_policy = "ignore" # "ignore" | "prefer" | "require" +# Public keys trusted to verify peer AgentCard signatures, keyed by `kid`. +# [[a2a_client.trusted_agent_keys]] +# kid = "key-1" +# alg = "ES256" +# jwk_or_pem = "" [tools] # Enable tool execution (bash commands) diff --git a/crates/zeph-a2a/Cargo.toml b/crates/zeph-a2a/Cargo.toml index 8bad41590..818b33fe7 100644 --- a/crates/zeph-a2a/Cargo.toml +++ b/crates/zeph-a2a/Cargo.toml @@ -13,6 +13,12 @@ description = "A2A protocol client and server with agent discovery for Zeph" readme = "README.md" [features] +# MUST be enabled together with zeph-config's `card-signing` marker feature (root Cargo.toml's +# `a2a` feature line pairs them) — zeph-config's Config::validate() gates +# card_trust_policy = "require" on that marker as a proxy for whether the crypto here is +# actually compiled in. Splitting them would make `require` falsely fail config validation +# even when this crate's crypto path works (#5928). +card-signing = ["dep:p256", "dep:serde_json_canonicalizer"] ibct = ["dep:hmac", "dep:sha2"] server = ["dep:axum", "dep:tower", "dep:tower-http", "zeph-common/http-middleware"] @@ -25,9 +31,11 @@ futures.workspace = true futures-core.workspace = true hex.workspace = true hmac = { workspace = true, optional = true } +p256 = { workspace = true, optional = true } reqwest = { workspace = true, features = ["json", "rustls", "stream"] } serde = { workspace = true, features = ["derive"] } serde_json.workspace = true +serde_json_canonicalizer = { workspace = true, optional = true } sha2 = { workspace = true, optional = true } thiserror.workspace = true tokio = { workspace = true, features = ["net", "sync"] } diff --git a/crates/zeph-a2a/src/card.rs b/crates/zeph-a2a/src/card.rs index 8c5a78952..d65085090 100644 --- a/crates/zeph-a2a/src/card.rs +++ b/crates/zeph-a2a/src/card.rs @@ -209,6 +209,7 @@ impl AgentCardBuilder { default_input_modes: self.input_modes, default_output_modes: self.output_modes, skills: self.skills, + signatures: Vec::new(), } } } diff --git a/crates/zeph-a2a/src/card_signing.rs b/crates/zeph-a2a/src/card_signing.rs new file mode 100644 index 000000000..f01656c5e --- /dev/null +++ b/crates/zeph-a2a/src/card_signing.rs @@ -0,0 +1,607 @@ +// SPDX-FileCopyrightText: 2026 Andrei G +// SPDX-License-Identifier: MIT OR Apache-2.0 + +//! JWS signature verification for [`AgentCard`](crate::AgentCard)s (A2A 1.0.0 §8.4). +//! +//! An [`AgentCardSignature`] covers the RFC 8785 JCS +//! canonicalization of the card's JSON representation with the `signatures` key +//! removed. Verification requires an out-of-band trusted-key store — this module +//! deliberately does **not** fetch keys from a card-supplied `jku` URL. +//! +//! # Trust model +//! +//! `jku`/JWKS auto-fetch is not implemented. +//! +//! // TODO(critic): jku/JWKS fetch deferred — SSRF risk on attacker-controlled URL; +//! // out-of-band key store is the trust anchor (#5928 follow-up). +//! +//! An attacker who can forge an entire card can also point a `jku` at a JWKS they +//! control and self-sign; only a pre-shared, operator-configured [`TrustedKey`] store +//! closes that gap. See [`TrustedKey`]. +//! +//! # Algorithm support +//! +//! Only ES256 (ECDSA P-256, JWS `alg: "ES256"`) is supported today, per the A2A spec's +//! mandatory example. `EdDSA` and RS256 are deferred (D4) — a signature using an +//! unrecognized `alg` resolves to [`SignatureVerification::Unverifiable`]. +//! +//! # Feature flag +//! +//! The [`SignatureVerification`], [`SigAlg`], and [`TrustedKey`] types are always +//! compiled (needed for config plumbing and the crypto-free URL-origin check in +//! [`crate::discovery`]). [`verify_card_signatures`] and [`sign_card`] require the +//! `card-signing` feature; without it, [`verify_card_signatures`] returns +//! [`SignatureVerification::FeatureDisabled`] and `sign_card` does not exist at all +//! (it has no meaningful behavior to fall back to — signing requires the crypto crates). +//! +//! # Known limitation — unvalidated against a real peer +//! +//! // TODO(critic): real a2a-sdk interop vector not obtainable in this environment — +//! // canonicalization implemented per A2A spec §8.4 verbatim but UNVALIDATED against a +//! // real peer; verify before relying on `require` in production (#5928 follow-up) +//! +//! The JCS canonicalization and signing-input construction below were implemented from +//! the A2A 1.0.0 spec text (§8.4.1–§8.4.3) retrieved verbatim during design review, not +//! from a real signed-card test vector produced by a reference implementation (e.g. the +//! Python/JS `a2a-sdk`). The `self_signed_round_trip_verifies` and +//! `raw_json_canonicalization_differs_from_typed_struct_reserialization` unit tests below +//! prove internal self-consistency and guard the exact bug class this module exists to avoid, +//! but neither proves interoperability with a real A2A peer's signer. Treat `require` +//! as unproven until a real vector is obtained and checked in. + +#[cfg(feature = "card-signing")] +use base64::Engine as _; +#[cfg(feature = "card-signing")] +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use serde_json::Value; + +use crate::types::AgentCardSignature; + +/// Signature algorithm identifiers recognized when verifying an [`AgentCardSignature`]. +/// +/// Always compiled — used by config plumbing ([`TrustedKey`]) regardless of whether the +/// `card-signing` feature is enabled. Only [`Es256`](SigAlg::Es256) has a cryptographic +/// implementation today (D4: EdDSA/RS256 deferred). +#[non_exhaustive] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SigAlg { + /// ECDSA using the P-256 curve and SHA-256 (JWS `alg: "ES256"`). + Es256, +} + +impl SigAlg { + /// Parse a JWS `alg` header value into a [`SigAlg`], or `None` if unrecognized. + /// + /// # Examples + /// + /// ```rust + /// use zeph_a2a::card_signing::SigAlg; + /// + /// assert_eq!(SigAlg::from_jws_alg("ES256"), Some(SigAlg::Es256)); + /// assert_eq!(SigAlg::from_jws_alg("EdDSA"), None); + /// ``` + #[must_use] + pub fn from_jws_alg(alg: &str) -> Option { + match alg { + "ES256" => Some(Self::Es256), + _ => None, + } + } +} + +/// A public key trusted to sign peer [`AgentCard`](crate::AgentCard)s, keyed by `kid`. +/// +/// This is the trust anchor for card signature verification: the operator configures +/// one entry per peer agent whose signature should be honored. There is no automatic +/// key discovery (see the module docs for why `jku` fetch is deferred). +/// +/// `key_material` accepts either a JWK JSON object (`{"kty":"EC","crv":"P-256","x":...,"y":...}`) +/// or a PEM-encoded `SubjectPublicKeyInfo`. Parsing happens lazily on each verification +/// attempt — verification is not on a hot path (`AgentRegistry::discover` has no runtime +/// caller yet, see D3), so caching the parsed key is not worth the complexity. +#[derive(Debug, Clone)] +pub struct TrustedKey { + /// Key identifier, matched against the `kid` in a signature's protected header. + pub kid: String, + /// Algorithm this key is trusted to verify. + pub alg: SigAlg, + /// JWK JSON or PEM-encoded public key material. + pub key_material: String, +} + +/// Outcome of verifying an [`AgentCard`](crate::AgentCard)'s signature(s) against a +/// [`TrustedKey`] store. +/// +/// This is a 3-way (plus [`FeatureDisabled`](Self::FeatureDisabled)) split rather than a +/// bool because policy decisions (`ignore`/`prefer`/`require`) need to distinguish "no +/// opinion" (`Unverifiable` — unsigned peer, or signed by an untrusted/unknown key) from +/// an active tampering signal (`Invalid` — a trusted key's signature does not match). +/// Treating both as "not verified" would let `prefer` silently accept a tampered card +/// from a peer whose `kid` happens to be unknown. +#[non_exhaustive] +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SignatureVerification { + /// At least one signature verified against a trusted key. + Verified, + /// No signature could be checked: the card is unsigned, every signature's `kid`/`alg` + /// is not in the trust store, or a signature's protected header is malformed. + Unverifiable { + /// Human-readable reason, suitable for a `tracing::warn!` log line. + reason: String, + }, + /// A signature matched a trusted key by `kid`/`alg` but cryptographic verification + /// failed, or the card's JSON could not be canonicalized. Signals tampering or a + /// canonicalization mismatch — never returned for an unsigned card. + Invalid { + /// Human-readable reason, suitable for a `tracing::warn!`/`tracing::error!` log line. + reason: String, + }, + /// The crate was compiled without the `card-signing` feature, so no cryptographic + /// verification was attempted. + FeatureDisabled, +} + +/// Errors from [`sign_card`] (test/tooling use only — see module docs on D5). +#[cfg(feature = "card-signing")] +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum CardSigningError { + /// RFC 8785 JCS canonicalization of the card JSON failed. + #[error("JCS canonicalization failed: {0}")] + Canonicalization(String), + /// The JWS protected header could not be serialized. + #[error("protected header serialization failed: {0}")] + HeaderSerialization(String), +} + +/// Verify `signatures` (typically [`AgentCard::signatures`](crate::AgentCard::signatures)) +/// against `raw_card` and a `trusted_keys` store. +/// +/// `raw_card` **must** be the raw JSON [`Value`] as received on the wire (e.g. parsed +/// directly from the HTTP response body), never a re-serialization of the typed +/// [`AgentCard`](crate::AgentCard) struct — see the module docs and the +/// `raw_json_canonicalization_differs_from_typed_struct_reserialization` unit test for why +/// that distinction is load-bearing, not stylistic. +/// +/// All entries in `signatures` are evaluated — order in the wire array never affects the +/// outcome. Returns [`SignatureVerification::Verified`] if **any** signature verifies +/// against a trusted key, even if another entry in the same array is tampered or +/// unresolvable (key rotation and multi-party attestation both put more than one signature +/// on a card; a bad sibling signature must not veto a good one). Otherwise, returns +/// [`SignatureVerification::Invalid`] if any signature matched a trusted key's `kid`/`alg` +/// but failed cryptographic verification. Otherwise, returns +/// [`SignatureVerification::Unverifiable`] if the card is unsigned or no signature resolves +/// to a trusted key. Returns [`SignatureVerification::FeatureDisabled`] when compiled +/// without the `card-signing` feature. +/// +/// # Examples +/// +/// ```rust +/// use zeph_a2a::card_signing::{verify_card_signatures, SignatureVerification}; +/// use serde_json::json; +/// +/// let raw_card = json!({"name": "peer", "url": "http://peer.example.com"}); +/// let result = verify_card_signatures(&raw_card, &[], &[]); +/// assert!(matches!( +/// result, +/// SignatureVerification::Unverifiable { .. } | SignatureVerification::FeatureDisabled +/// )); +/// ``` +#[must_use] +#[allow(clippy::needless_return, unused_variables)] +pub fn verify_card_signatures( + raw_card: &Value, + signatures: &[AgentCardSignature], + trusted_keys: &[TrustedKey], +) -> SignatureVerification { + #[cfg(not(feature = "card-signing"))] + { + return SignatureVerification::FeatureDisabled; + } + #[cfg(feature = "card-signing")] + { + if signatures.is_empty() { + return SignatureVerification::Unverifiable { + reason: "card carries no signatures".to_owned(), + }; + } + + let payload = match canonical_payload(raw_card) { + Ok(bytes) => bytes, + Err(e) => { + return SignatureVerification::Invalid { + reason: format!("canonicalization failed: {e}"), + }; + } + }; + + // Evaluate every signature before deciding — a tampered or unknown-kid signature + // earlier in the array must not veto a later signature that verifies (I1): the A2A + // spec's "verify >= 1 signature" and this function's own contract require checking + // all of them and taking `Verified` if any one verifies, regardless of position. + // Real scenarios this protects: key rotation (old+new signature during overlap), + // multi-party attestation. Precedence when none verify: Invalid > Unverifiable. + let mut last_unverifiable_reason = "no signature verified".to_owned(); + let mut invalid_reason: Option = None; + for sig in signatures { + match verify_one(&payload, sig, trusted_keys) { + imp::SigOutcome::Verified => return SignatureVerification::Verified, + imp::SigOutcome::Invalid(reason) => { + invalid_reason.get_or_insert(reason); + } + imp::SigOutcome::Unverifiable(reason) => last_unverifiable_reason = reason, + } + } + match invalid_reason { + Some(reason) => SignatureVerification::Invalid { reason }, + None => SignatureVerification::Unverifiable { + reason: last_unverifiable_reason, + }, + } + } +} + +/// Sign `raw_card` (raw JSON with `signatures` removed before canonicalization, per +/// [`verify_card_signatures`]) with `signing_key`, producing an [`AgentCardSignature`]. +/// +/// This exists to build round-trip tests and interop vectors, mirroring +/// [`crate::Ibct::issue`]/[`crate::Ibct::verify`]. Wiring this into the A2A server so it +/// signs our own served card is deferred (D5) — see module docs. +/// +/// # Errors +/// +/// Returns [`CardSigningError::Canonicalization`] if `raw_card` cannot be JCS-canonicalized, +/// or [`CardSigningError::HeaderSerialization`] if the protected header cannot be serialized. +/// +/// # Examples +/// +/// ```rust +/// # #[cfg(feature = "card-signing")] +/// # { +/// use zeph_a2a::card_signing::sign_card; +/// use p256::ecdsa::SigningKey; +/// use serde_json::json; +/// +/// let signing_key = SigningKey::from_bytes(&[7u8; 32].into()).unwrap(); +/// let raw_card = json!({"name": "my-agent", "url": "http://localhost:8080"}); +/// let sig = sign_card(&raw_card, "key-1", &signing_key).unwrap(); +/// assert!(!sig.protected.is_empty()); +/// # } +/// ``` +#[cfg(feature = "card-signing")] +pub fn sign_card( + raw_card: &Value, + kid: &str, + signing_key: &p256::ecdsa::SigningKey, +) -> Result { + use p256::ecdsa::signature::Signer; + + let payload = canonical_payload(raw_card).map_err(CardSigningError::Canonicalization)?; + let header = serde_json::json!({"alg": "ES256", "kid": kid}); + let header_bytes = serde_json::to_vec(&header) + .map_err(|e| CardSigningError::HeaderSerialization(e.to_string()))?; + let protected = URL_SAFE_NO_PAD.encode(header_bytes); + let signing_input = format!("{protected}.{}", URL_SAFE_NO_PAD.encode(&payload)); + let signature: p256::ecdsa::Signature = Signer::sign(signing_key, signing_input.as_bytes()); + Ok(AgentCardSignature { + protected, + signature: URL_SAFE_NO_PAD.encode(signature.to_bytes()), + header: None, + }) +} + +/// RFC 8785 JCS canonicalization of `raw_card` with the `signatures` key removed. +/// +/// Operates on the raw received [`Value`] — never on a re-serialization of the typed +/// [`AgentCard`](crate::AgentCard) struct. See module docs. +// TODO(critic): if the S1 real-vector gate (see module docs) ever finds a mismatch against a +// real a2a-sdk-signed card, the likely fix is a recursive proto3-default strip (drop keys whose +// value is `""`, `false`, `0`, `[]`, or `{}`, recursively through nested objects/arrays) applied +// to `card` below *before* JCS — not a bug in the JCS library itself. Flagging this now so a +// future fix isn't misdiagnosed as a `serde_json_canonicalizer` correctness issue. +#[cfg(feature = "card-signing")] +fn canonical_payload(raw_card: &Value) -> Result, String> { + let mut card = raw_card.clone(); + if let Value::Object(map) = &mut card { + map.remove("signatures"); + } + serde_json_canonicalizer::to_vec(&card).map_err(|e| e.to_string()) +} + +#[cfg(feature = "card-signing")] +mod imp { + use base64::Engine as _; + use base64::engine::general_purpose::URL_SAFE_NO_PAD; + use p256::ecdsa::signature::Verifier; + + use super::{SigAlg, TrustedKey}; + use crate::types::AgentCardSignature; + + pub(super) enum SigOutcome { + Verified, + Unverifiable(String), + Invalid(String), + } + + struct ProtectedHeader { + alg: String, + kid: Option, + } + + fn decode_protected_header(protected_b64: &str) -> Option { + let bytes = URL_SAFE_NO_PAD.decode(protected_b64).ok()?; + let v: serde_json::Value = serde_json::from_slice(&bytes).ok()?; + let alg = v.get("alg")?.as_str()?.to_owned(); + let kid = v + .get("kid") + .and_then(serde_json::Value::as_str) + .map(str::to_owned); + Some(ProtectedHeader { alg, kid }) + } + + /// Parse `material` (JWK JSON or PEM `SubjectPublicKeyInfo`) into a P-256 verifying key. + fn parse_verifying_key(material: &str) -> Result { + use p256::pkcs8::DecodePublicKey; + + let trimmed = material.trim(); + if trimmed.starts_with("-----BEGIN") { + return p256::ecdsa::VerifyingKey::from_public_key_pem(trimmed) + .map_err(|e| format!("PEM public key: {e}")); + } + + let jwk: serde_json::Value = serde_json::from_str(trimmed) + .map_err(|e| format!("key_material is neither PEM nor valid JWK JSON: {e}"))?; + let x = jwk + .get("x") + .and_then(serde_json::Value::as_str) + .ok_or("JWK missing 'x' coordinate")?; + let y = jwk + .get("y") + .and_then(serde_json::Value::as_str) + .ok_or("JWK missing 'y' coordinate")?; + let x_bytes = URL_SAFE_NO_PAD + .decode(x) + .map_err(|e| format!("JWK 'x' is not valid base64url: {e}"))?; + let y_bytes = URL_SAFE_NO_PAD + .decode(y) + .map_err(|e| format!("JWK 'y' is not valid base64url: {e}"))?; + + let mut sec1 = Vec::with_capacity(1 + x_bytes.len() + y_bytes.len()); + sec1.push(0x04); // SEC1 uncompressed point tag. + sec1.extend_from_slice(&x_bytes); + sec1.extend_from_slice(&y_bytes); + p256::ecdsa::VerifyingKey::from_sec1_bytes(&sec1) + .map_err(|e| format!("invalid P-256 point: {e}")) + } + + pub(super) fn verify_one( + payload: &[u8], + sig: &AgentCardSignature, + trusted_keys: &[TrustedKey], + ) -> SigOutcome { + let Some(header) = decode_protected_header(&sig.protected) else { + return SigOutcome::Unverifiable("malformed protected header".to_owned()); + }; + let Some(alg) = SigAlg::from_jws_alg(&header.alg) else { + return SigOutcome::Unverifiable(format!("unsupported alg '{}'", header.alg)); + }; + let Some(kid) = header.kid else { + return SigOutcome::Unverifiable("protected header missing 'kid'".to_owned()); + }; + let Some(key) = trusted_keys.iter().find(|k| k.kid == kid && k.alg == alg) else { + return SigOutcome::Unverifiable(format!("no trusted key for kid '{kid}'")); + }; + let verifying_key = match parse_verifying_key(&key.key_material) { + Ok(vk) => vk, + Err(e) => return SigOutcome::Invalid(format!("trusted key '{kid}' unparsable: {e}")), + }; + + let Ok(sig_bytes) = URL_SAFE_NO_PAD.decode(&sig.signature) else { + return SigOutcome::Invalid("signature is not valid base64url".to_owned()); + }; + let Ok(ecdsa_sig) = p256::ecdsa::Signature::from_slice(&sig_bytes) else { + return SigOutcome::Invalid( + "signature has invalid length/encoding for ES256".to_owned(), + ); + }; + + let signing_input = format!("{}.{}", sig.protected, URL_SAFE_NO_PAD.encode(payload)); + match verifying_key.verify(signing_input.as_bytes(), &ecdsa_sig) { + Ok(()) => SigOutcome::Verified, + Err(_) => SigOutcome::Invalid("ECDSA verification failed".to_owned()), + } + } +} + +#[cfg(feature = "card-signing")] +use imp::verify_one; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sig_alg_from_jws_alg() { + assert_eq!(SigAlg::from_jws_alg("ES256"), Some(SigAlg::Es256)); + assert_eq!(SigAlg::from_jws_alg("EdDSA"), None); + assert_eq!(SigAlg::from_jws_alg("none"), None); + } + + #[test] + fn verify_empty_signatures_is_unverifiable_or_disabled() { + let raw = serde_json::json!({"name": "peer"}); + let result = verify_card_signatures(&raw, &[], &[]); + assert!(matches!( + result, + SignatureVerification::Unverifiable { .. } | SignatureVerification::FeatureDisabled + )); + } + + #[cfg(feature = "card-signing")] + mod crypto { + use std::assert_matches; + + use p256::ecdsa::SigningKey; + use p256::pkcs8::EncodePublicKey; + + use super::super::*; + + fn test_signing_key() -> SigningKey { + SigningKey::from_bytes(&[9u8; 32].into()).expect("valid scalar") + } + + fn trusted_key_for(kid: &str, signing_key: &SigningKey) -> TrustedKey { + let verifying_key = signing_key.verifying_key(); + let pem = verifying_key + .to_public_key_pem(p256::pkcs8::LineEnding::LF) + .expect("pem encode"); + TrustedKey { + kid: kid.to_owned(), + alg: SigAlg::Es256, + key_material: pem, + } + } + + #[test] + fn self_signed_round_trip_verifies() { + let signing_key = test_signing_key(); + let raw_card = serde_json::json!({ + "name": "peer-agent", + "url": "http://peer.example.com", + "description": "", + }); + let sig = sign_card(&raw_card, "key-1", &signing_key).unwrap(); + let card_with_sig = { + let mut v = raw_card.clone(); + v["signatures"] = serde_json::json!([&sig]); + v + }; + let trusted = vec![trusted_key_for("key-1", &signing_key)]; + let result = verify_card_signatures(&card_with_sig, &[sig], &trusted); + assert_eq!(result, SignatureVerification::Verified); + } + + #[test] + fn tampered_signature_is_invalid() { + let signing_key = test_signing_key(); + let raw_card = + serde_json::json!({"name": "peer-agent", "url": "http://peer.example.com"}); + let mut sig = sign_card(&raw_card, "key-1", &signing_key).unwrap(); + sig.signature = URL_SAFE_NO_PAD.encode([0u8; 64]); + let trusted = vec![trusted_key_for("key-1", &signing_key)]; + let result = verify_card_signatures(&raw_card, &[sig], &trusted); + assert_matches!(result, SignatureVerification::Invalid { .. }); + } + + #[test] + fn tampered_payload_is_invalid() { + let signing_key = test_signing_key(); + let raw_card = + serde_json::json!({"name": "peer-agent", "url": "http://peer.example.com"}); + let sig = sign_card(&raw_card, "key-1", &signing_key).unwrap(); + let mut tampered_card = raw_card.clone(); + tampered_card["name"] = serde_json::json!("evil-agent"); + let trusted = vec![trusted_key_for("key-1", &signing_key)]; + let result = verify_card_signatures(&tampered_card, &[sig], &trusted); + assert_matches!(result, SignatureVerification::Invalid { .. }); + } + + #[test] + fn unknown_kid_is_unverifiable() { + let signing_key = test_signing_key(); + let raw_card = + serde_json::json!({"name": "peer-agent", "url": "http://peer.example.com"}); + let sig = sign_card(&raw_card, "unknown-key", &signing_key).unwrap(); + let other_key = trusted_key_for("key-1", &test_signing_key()); + let result = verify_card_signatures(&raw_card, &[sig], &[other_key]); + assert_matches!(result, SignatureVerification::Unverifiable { .. }); + } + + /// Regression test for I1: a tampered-but-trusted-`kid` signature earlier in the + /// array must not veto a later signature that verifies — the outcome must be + /// order-independent. Models key rotation (old signature tampered/expired, new + /// signature valid) and multi-party attestation. + #[test] + fn verified_signature_wins_regardless_of_position_invalid_then_verified() { + let key_a = SigningKey::from_bytes(&[11u8; 32].into()).unwrap(); + let key_b = SigningKey::from_bytes(&[22u8; 32].into()).unwrap(); + let raw_card = + serde_json::json!({"name": "peer-agent", "url": "http://peer.example.com"}); + + let mut sig_a = sign_card(&raw_card, "key-a", &key_a).unwrap(); + sig_a.signature = URL_SAFE_NO_PAD.encode([0u8; 64]); // tamper: now Invalid + let sig_b = sign_card(&raw_card, "key-b", &key_b).unwrap(); // untouched: Verified + + let trusted = vec![ + trusted_key_for("key-a", &key_a), + trusted_key_for("key-b", &key_b), + ]; + + let result_invalid_first = + verify_card_signatures(&raw_card, &[sig_a.clone(), sig_b.clone()], &trusted); + assert_eq!(result_invalid_first, SignatureVerification::Verified); + + let result_verified_first = + verify_card_signatures(&raw_card, &[sig_b, sig_a], &trusted); + assert_eq!(result_verified_first, SignatureVerification::Verified); + } + + /// When no signature verifies, `Invalid` must win over `Unverifiable` regardless of + /// which entry appears first — a tampered signature is a stronger reject signal than + /// an unresolvable one. + #[test] + fn invalid_wins_over_unverifiable_when_none_verify() { + let key_a = SigningKey::from_bytes(&[33u8; 32].into()).unwrap(); + let raw_card = + serde_json::json!({"name": "peer-agent", "url": "http://peer.example.com"}); + + let mut sig_a = sign_card(&raw_card, "key-a", &key_a).unwrap(); + sig_a.signature = URL_SAFE_NO_PAD.encode([0u8; 64]); // trusted kid, tampered → Invalid + let sig_unknown = sign_card(&raw_card, "unknown-key", &key_a).unwrap(); // Unverifiable + + let trusted = vec![trusted_key_for("key-a", &key_a)]; + + let result = verify_card_signatures(&raw_card, &[sig_unknown, sig_a], &trusted); + assert_matches!(result, SignatureVerification::Invalid { .. }); + } + + /// Regression test for the S1 bug class: JCS **must** canonicalize the raw received + /// JSON with `signatures` removed, never a re-serialization of the typed `AgentCard` + /// struct. A signer that omits proto3-default fields (empty string, `false`, `0`, + /// empty array) before JCS produces canonical bytes that differ from what + /// `serde_json::to_value(&typed_card)` re-materializes, because `#[serde(default)]` + /// fields without `skip_serializing_if` are always emitted by the typed struct's + /// `Serialize` impl. Canonicalizing the typed struct's re-serialization instead of the + /// raw bytes would make a genuinely valid signature fail verification. + #[test] + fn raw_json_canonicalization_differs_from_typed_struct_reserialization() { + // Raw wire JSON as a signer would emit it: `pushNotifications` and + // `stateTransitionHistory` (both `false`, the proto3 default) are omitted. + let raw_json = serde_json::json!({ + "name": "peer", + "description": "", + "url": "http://peer.example.com", + "version": "0.1.0", + "protocolVersion": "0.2.1", + "capabilities": {"streaming": true}, + "skills": [], + }); + + // Deserializing into the typed `AgentCard` and re-serializing re-materializes + // every `#[serde(default)]` field the raw JSON omitted. + let typed: crate::types::AgentCard = serde_json::from_value(raw_json.clone()).unwrap(); + let reserialized = serde_json::to_value(&typed).unwrap(); + + let raw_canonical = canonical_payload(&raw_json).unwrap(); + let reserialized_canonical = canonical_payload(&reserialized).unwrap(); + + assert_ne!( + raw_canonical, reserialized_canonical, + "raw and re-serialized-typed-struct canonical bytes must differ when the raw \ + JSON omits proto3-default fields — if this assertion fails, the typed struct's \ + Serialize impl started matching the raw wire shape exactly and this test's \ + premise no longer holds" + ); + } + } +} diff --git a/crates/zeph-a2a/src/discovery.rs b/crates/zeph-a2a/src/discovery.rs index 93f3409b9..184321679 100644 --- a/crates/zeph-a2a/src/discovery.rs +++ b/crates/zeph-a2a/src/discovery.rs @@ -1,18 +1,153 @@ // SPDX-FileCopyrightText: 2026 Andrei G // SPDX-License-Identifier: MIT OR Apache-2.0 -//! Agent discovery via `/.well-known/agent.json` with TTL-based caching. +//! Agent discovery via `/.well-known/agent.json` with TTL-based caching, plus optional +//! card-signature and URL-origin trust checks (A2A 1.0.0 §8.4, #5928). use std::collections::HashMap; use std::time::{Duration, Instant}; use tokio::sync::RwLock; +use crate::card_signing::{SignatureVerification, TrustedKey, verify_card_signatures}; use crate::error::A2aError; use crate::types::AgentCard; +// TODO(critic): 1.0.0 serves /.well-known/agent-card.json — needs version-aware +// fetch/fallback before pure-1.0.0 peers are discoverable (#5928 follow-up). const WELL_KNOWN_PATH: &str = "/.well-known/agent.json"; +/// Trust policy for peer [`AgentCard`] verification during [`AgentRegistry::discover`] +/// (A2A 1.0.0 §8.4). +/// +/// Mirrors `zeph_config::channels::CardTrustPolicy` (TOML-facing) as an independent +/// type, the same way `zeph_mcp::ToolDiscoveryStrategy` mirrors its `zeph-config` +/// counterpart: `zeph-config` must not depend on protocol crates, so config-side and +/// protocol-side enums are converted at the `zeph-core` wiring layer. +// TODO(critic): no runtime construction site consumes card_trust_policy yet — file +// wire-X follow-up before advertising the knob as enforcing (#5928). +#[non_exhaustive] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum CardTrustPolicy { + /// Discover peer cards without checking signatures or URL origin. Default — + /// byte-identical to pre-#5928 behavior. + #[default] + Ignore, + /// Log a warning on an untrusted/unverifiable card or URL-origin mismatch, but still + /// accept it. Reject only an actively **tampered** signature (a trusted key's + /// signature that fails cryptographic verification). + /// + /// Recommended production setting once the S1 real-vector interop gate (see + /// [`crate::card_signing`] module docs) has landed. + Prefer, + /// Reject any card with an unverifiable signature or a URL-origin mismatch. + Require, +} + +/// Severity of a trust-check outcome, used to combine the URL-origin and signature axes +/// (S2: evaluate both, take the most severe). +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +enum Severity { + Accept, + Warn, + Reject, +} + +fn origin_severity(policy: CardTrustPolicy, mismatch: bool) -> Severity { + if !mismatch { + return Severity::Accept; + } + match policy { + CardTrustPolicy::Ignore => Severity::Accept, + CardTrustPolicy::Prefer => Severity::Warn, + CardTrustPolicy::Require => Severity::Reject, + } +} + +/// Combine policy with a [`SignatureVerification`] outcome. +/// +/// `Invalid` always rejects under `prefer`/`require` regardless of the URL-origin axis +/// (S2: a tampered signature is a stronger signal than an origin mismatch). `Unverifiable` +/// and `FeatureDisabled` are treated identically: under `require` they reject (an operator +/// who sets `require` without the `card-signing` feature compiled in gets a loud failure, +/// never a silent downgrade — see S3), under `prefer` they warn-and-accept. +fn signature_severity(policy: CardTrustPolicy, verification: &SignatureVerification) -> Severity { + if policy == CardTrustPolicy::Ignore { + return Severity::Accept; + } + match verification { + SignatureVerification::Verified => Severity::Accept, + SignatureVerification::Invalid { .. } => Severity::Reject, + SignatureVerification::Unverifiable { .. } | SignatureVerification::FeatureDisabled => { + match policy { + CardTrustPolicy::Prefer => Severity::Warn, + CardTrustPolicy::Require => Severity::Reject, + CardTrustPolicy::Ignore => Severity::Accept, + } + } + } +} + +fn signature_reason(verification: &SignatureVerification) -> String { + match verification { + SignatureVerification::Verified => String::new(), + SignatureVerification::Unverifiable { reason } + | SignatureVerification::Invalid { reason } => reason.clone(), + SignatureVerification::FeatureDisabled => "card-signing feature not compiled in".to_owned(), + } +} + +/// Origin (scheme + host + port) comparison result between the queried `base_url` and the +/// card's self-declared `url` field. +enum OriginCheck { + Match, + Mismatch { queried: String, advertised: String }, +} + +/// Compare `base_url` (what was queried) against `card_url` (what the card claims) by +/// scheme + host (case-insensitive) + `port_or_known_default()`, per RFC 6454 origin +/// semantics. A parse failure of `card_url` counts as a mismatch. +fn check_origin(base_url: &str, card_url: &str) -> OriginCheck { + let queried = url::Url::parse(base_url); + let advertised = url::Url::parse(card_url); + let (Ok(queried), Ok(advertised)) = (queried, advertised) else { + return OriginCheck::Mismatch { + queried: base_url.to_owned(), + advertised: card_url.to_owned(), + }; + }; + let same_origin = queried.scheme().eq_ignore_ascii_case(advertised.scheme()) + && queried + .host_str() + .zip(advertised.host_str()) + .is_some_and(|(a, b)| a.eq_ignore_ascii_case(b)) + && queried.port_or_known_default() == advertised.port_or_known_default(); + if same_origin { + OriginCheck::Match + } else { + OriginCheck::Mismatch { + queried: format!( + "{}://{}:{}", + queried.scheme(), + queried.host_str().unwrap_or(""), + queried.port_or_known_default().unwrap_or(0) + ), + advertised: format!( + "{}://{}:{}", + advertised.scheme(), + advertised.host_str().unwrap_or(""), + advertised.port_or_known_default().unwrap_or(0) + ), + } + } +} + +#[derive(Default)] +struct TrustConfig { + policy: CardTrustPolicy, + trusted_keys: Vec, +} + struct CachedCard { card: AgentCard, fetched_at: Instant, @@ -53,6 +188,11 @@ pub struct AgentRegistry { ttl: Duration, /// Timeout for each network request in [`discover`](Self::discover). request_timeout: Duration, + /// Card-signing + URL-origin trust policy applied in [`discover`](Self::discover). + /// Defaults to [`CardTrustPolicy::Ignore`] with an empty key store when + /// [`with_trust`](Self::with_trust) is never called — byte-identical to pre-#5928 + /// behavior for existing callers. + trust: TrustConfig, } impl AgentRegistry { @@ -66,6 +206,7 @@ impl AgentRegistry { cache: RwLock::new(HashMap::new()), ttl, request_timeout: Duration::from_secs(10), + trust: TrustConfig::default(), } } @@ -78,6 +219,44 @@ impl AgentRegistry { self } + /// Configure the card-signing + URL-origin trust policy applied by + /// [`discover`](Self::discover). + /// + /// Not calling this method leaves the registry at [`CardTrustPolicy::Ignore`] with no + /// trusted keys — existing callers see zero behavior change. + /// + /// # Examples + /// + /// ```rust + /// use zeph_a2a::{AgentRegistry, CardTrustPolicy}; + /// use std::time::Duration; + /// + /// let registry = AgentRegistry::new(reqwest::Client::new(), Duration::from_mins(5)) + /// .with_trust(CardTrustPolicy::Prefer, vec![]); + /// ``` + #[must_use] + pub fn with_trust(mut self, policy: CardTrustPolicy, trusted_keys: Vec) -> Self { + // Breadcrumb for operators enabling enforcement: the S1 canonicalization/signing-input + // construction is implemented per the A2A spec text but has not been validated against + // a real peer's signer (see `crate::card_signing` module docs). Without this, a + // `require`-policy reject-all failure mode is loud in logs but the *cause* (unproven + // interop, not a real attack) is not obvious. Logged here rather than only in a doc + // comment, which the operator flipping the knob at runtime will never read. + if policy != CardTrustPolicy::Ignore { + tracing::warn!( + policy = ?policy, + "a2a discovery: card signature interop is unvalidated against a real A2A peer \ + (#5928) — canonicalization/signing-input construction is implemented per spec \ + text only; `require` may reject genuinely valid signed peers" + ); + } + self.trust = TrustConfig { + policy, + trusted_keys, + }; + self + } + /// Fetch the [`AgentCard`] from `{base_url}/.well-known/agent.json` and update the cache. /// /// Always performs a network request regardless of the current cache state. The result @@ -91,23 +270,40 @@ impl AgentRegistry { #[tracing::instrument(name = "a2a.discovery.discover", skip_all, err)] pub async fn discover(&self, base_url: &str) -> Result { let url = format!("{}{WELL_KNOWN_PATH}", base_url.trim_end_matches('/')); - let card: AgentCard = tokio::time::timeout(self.request_timeout, async { - let resp = self.client.get(&url).send().await?; - - if !resp.status().is_success() { - return Err(A2aError::Discovery { + let (card, raw_value): (AgentCard, serde_json::Value) = + tokio::time::timeout(self.request_timeout, async { + let resp = self.client.get(&url).send().await?; + + if !resp.status().is_success() { + return Err(A2aError::Discovery { + url: url.clone(), + reason: format!("HTTP {}", resp.status()), + }); + } + + let bytes = resp.bytes().await.map_err(|e| A2aError::Discovery { url: url.clone(), - reason: format!("HTTP {}", resp.status()), - }); - } - - resp.json().await.map_err(|e| A2aError::Discovery { - url: url.clone(), - reason: e.to_string(), + reason: e.to_string(), + })?; + // Keep the raw JSON `Value` around (not just the typed `AgentCard`): + // signature verification must canonicalize the bytes as received, never a + // re-serialization of the typed struct — see `card_signing` module docs (S1). + let raw_value: serde_json::Value = + serde_json::from_slice(&bytes).map_err(|e| A2aError::Discovery { + url: url.clone(), + reason: e.to_string(), + })?; + let card: AgentCard = + serde_json::from_value(raw_value.clone()).map_err(|e| A2aError::Discovery { + url: url.clone(), + reason: e.to_string(), + })?; + Ok((card, raw_value)) }) - }) - .await - .map_err(|_| A2aError::Timeout(self.request_timeout))??; + .await + .map_err(|_| A2aError::Timeout(self.request_timeout))??; + + self.check_trust(base_url, &card, &raw_value)?; let mut cache = self.cache.write().await; cache.insert( @@ -121,6 +317,82 @@ impl AgentRegistry { Ok(card) } + /// Apply the URL-origin and signature trust checks (S2: combine both axes, most + /// severe wins) and either accept (silently or with a `tracing::warn!`) or reject the + /// discovered card. + /// + /// # Errors + /// + /// Returns [`A2aError::UrlMismatch`] when only the URL-origin axis rejects, or + /// [`A2aError::UntrustedCard`] when the signature axis (alone or combined with the + /// URL axis) rejects. + fn check_trust( + &self, + base_url: &str, + card: &AgentCard, + raw_value: &serde_json::Value, + ) -> Result<(), A2aError> { + let origin = check_origin(base_url, &card.url); + let origin_mismatch = matches!(origin, OriginCheck::Mismatch { .. }); + let sig_verification = + verify_card_signatures(raw_value, &card.signatures, &self.trust.trusted_keys); + + let policy = self.trust.policy; + let o_sev = origin_severity(policy, origin_mismatch); + let s_sev = signature_severity(policy, &sig_verification); + + match o_sev.max(s_sev) { + Severity::Accept => Ok(()), + Severity::Warn => { + if o_sev == Severity::Warn + && let OriginCheck::Mismatch { + queried, + advertised, + } = &origin + { + tracing::warn!( + queried, + advertised, + "a2a discovery: card.url origin mismatch (prefer policy, accepting)" + ); + } + if s_sev == Severity::Warn { + tracing::warn!( + reason = %signature_reason(&sig_verification), + "a2a discovery: card signature unverifiable (prefer policy, accepting)" + ); + } + Ok(()) + } + Severity::Reject => { + if o_sev == Severity::Reject + && s_sev != Severity::Reject + && let OriginCheck::Mismatch { + queried, + advertised, + } = origin + { + return Err(A2aError::UrlMismatch { + queried, + advertised, + }); + } + let mut reason = signature_reason(&sig_verification); + if o_sev == Severity::Reject + && let OriginCheck::Mismatch { + queried, + advertised, + } = &origin + { + reason = format!( + "{reason}; additionally url origin mismatch (queried '{queried}', advertised '{advertised}')" + ); + } + Err(A2aError::UntrustedCard { reason }) + } + } + } + /// Return a cached [`AgentCard`] if it is still within the TTL, otherwise re-fetch. /// /// This is the preferred call for high-frequency routing decisions — it avoids a @@ -149,6 +421,9 @@ impl AgentRegistry { /// fetched and will not expire until `ttl` has elapsed from the time of this call. /// /// Useful when the card is already known (e.g., loaded from config) or in tests. + /// + /// Bypasses `card_trust_policy` entirely — no URL-origin or signature check runs for a + /// manually registered card, regardless of policy. The caller vouches for the card. #[tracing::instrument(name = "a2a.discovery.register", skip_all)] pub async fn register(&self, base_url: String, card: AgentCard) { let mut cache = self.cache.write().await; @@ -281,6 +556,233 @@ mod tests { assert_eq!(all.len(), 1); assert_eq!(all[0].name, "v2"); } + + #[test] + fn check_origin_matches_same_origin_different_path() { + let result = check_origin("http://example.com:8080", "http://example.com:8080/a2a"); + assert!(matches!(result, OriginCheck::Match)); + } + + #[test] + fn check_origin_case_insensitive_host() { + let result = check_origin("https://Example.COM", "https://example.com"); + assert!(matches!(result, OriginCheck::Match)); + } + + #[test] + fn check_origin_default_port_equivalence() { + let result = check_origin("https://example.com", "https://example.com:443"); + assert!(matches!(result, OriginCheck::Match)); + } + + #[test] + fn check_origin_detects_scheme_mismatch() { + let result = check_origin("http://example.com", "https://example.com"); + assert!(matches!(result, OriginCheck::Mismatch { .. })); + } + + #[test] + fn check_origin_detects_host_mismatch() { + let result = check_origin("http://example.com", "http://evil.example.com"); + assert!(matches!(result, OriginCheck::Mismatch { .. })); + } + + #[test] + fn check_origin_detects_port_mismatch() { + let result = check_origin("http://example.com:8080", "http://example.com:9090"); + assert!(matches!(result, OriginCheck::Mismatch { .. })); + } + + #[test] + fn check_origin_unparsable_card_url_is_mismatch() { + let result = check_origin("http://example.com", "not a url"); + assert!(matches!(result, OriginCheck::Mismatch { .. })); + } + + #[test] + fn origin_severity_table() { + assert_eq!( + origin_severity(CardTrustPolicy::Ignore, true), + Severity::Accept + ); + assert_eq!( + origin_severity(CardTrustPolicy::Ignore, false), + Severity::Accept + ); + assert_eq!( + origin_severity(CardTrustPolicy::Prefer, true), + Severity::Warn + ); + assert_eq!( + origin_severity(CardTrustPolicy::Prefer, false), + Severity::Accept + ); + assert_eq!( + origin_severity(CardTrustPolicy::Require, true), + Severity::Reject + ); + assert_eq!( + origin_severity(CardTrustPolicy::Require, false), + Severity::Accept + ); + } + + #[test] + fn signature_severity_table() { + let verified = SignatureVerification::Verified; + let invalid = SignatureVerification::Invalid { + reason: "bad".into(), + }; + let unverifiable = SignatureVerification::Unverifiable { + reason: "unsigned".into(), + }; + let disabled = SignatureVerification::FeatureDisabled; + + for policy in [ + CardTrustPolicy::Ignore, + CardTrustPolicy::Prefer, + CardTrustPolicy::Require, + ] { + assert_eq!( + signature_severity(policy, &verified), + Severity::Accept, + "Verified must always accept under {policy:?}" + ); + } + + assert_eq!( + signature_severity(CardTrustPolicy::Ignore, &invalid), + Severity::Accept + ); + assert_eq!( + signature_severity(CardTrustPolicy::Prefer, &invalid), + Severity::Reject, + "Invalid must reject under prefer even though other Unverifiable cases only warn" + ); + assert_eq!( + signature_severity(CardTrustPolicy::Require, &invalid), + Severity::Reject + ); + + assert_eq!( + signature_severity(CardTrustPolicy::Ignore, &unverifiable), + Severity::Accept + ); + assert_eq!( + signature_severity(CardTrustPolicy::Prefer, &unverifiable), + Severity::Warn + ); + assert_eq!( + signature_severity(CardTrustPolicy::Require, &unverifiable), + Severity::Reject + ); + + // FeatureDisabled is treated identically to Unverifiable (S2/S3): require rejects + // loudly rather than silently downgrading. + assert_eq!( + signature_severity(CardTrustPolicy::Ignore, &disabled), + Severity::Accept + ); + assert_eq!( + signature_severity(CardTrustPolicy::Prefer, &disabled), + Severity::Warn + ); + assert_eq!( + signature_severity(CardTrustPolicy::Require, &disabled), + Severity::Reject + ); + } + + #[test] + fn check_trust_default_ignore_accepts_everything() { + let registry = AgentRegistry::new(reqwest::Client::new(), Duration::from_mins(1)); + let mut card = test_card("peer"); + card.url = "http://totally-different.example.com".into(); + let raw = serde_json::to_value(&card).unwrap(); + assert!( + registry + .check_trust("http://localhost", &card, &raw) + .is_ok() + ); + } + + #[test] + fn check_trust_require_rejects_url_mismatch_and_unsigned_card_combined() { + // An unsigned card fails the signature axis too under `require`, so both axes + // reject and the combined `UntrustedCard` (not `UrlMismatch`) error is returned — + // see `check_trust_require_rejects_url_mismatch_alone_when_signature_verifies` + // below for the URL-axis-only rejection case. + let registry = AgentRegistry::new(reqwest::Client::new(), Duration::from_mins(1)) + .with_trust(CardTrustPolicy::Require, vec![]); + let mut card = test_card("peer"); + card.url = "http://totally-different.example.com".into(); + let raw = serde_json::to_value(&card).unwrap(); + let err = registry + .check_trust("http://localhost", &card, &raw) + .unwrap_err(); + let A2aError::UntrustedCard { reason } = err else { + panic!("expected UntrustedCard, got {err:?}"); + }; + assert!(reason.contains("url origin mismatch"), "reason: {reason}"); + } + + #[cfg(feature = "card-signing")] + #[test] + fn check_trust_require_rejects_url_mismatch_alone_when_signature_verifies() { + use p256::ecdsa::SigningKey; + use p256::pkcs8::EncodePublicKey; + + let signing_key = SigningKey::from_bytes(&[3u8; 32].into()).unwrap(); + let pem = signing_key + .verifying_key() + .to_public_key_pem(p256::pkcs8::LineEnding::LF) + .unwrap(); + let trusted = vec![TrustedKey { + kid: "key-1".into(), + alg: crate::card_signing::SigAlg::Es256, + key_material: pem, + }]; + + let mut card = test_card("peer"); + card.url = "http://totally-different.example.com".into(); + let raw_unsigned = serde_json::to_value(&card).unwrap(); + let sig = crate::card_signing::sign_card(&raw_unsigned, "key-1", &signing_key).unwrap(); + card.signatures = vec![sig.clone()]; + let mut raw = raw_unsigned; + raw["signatures"] = serde_json::json!([sig]); + + let registry = AgentRegistry::new(reqwest::Client::new(), Duration::from_mins(1)) + .with_trust(CardTrustPolicy::Require, trusted); + let err = registry + .check_trust("http://localhost", &card, &raw) + .unwrap_err(); + assert!(matches!(err, A2aError::UrlMismatch { .. })); + } + + #[test] + fn check_trust_prefer_warns_but_accepts_unsigned_peer() { + let registry = AgentRegistry::new(reqwest::Client::new(), Duration::from_mins(1)) + .with_trust(CardTrustPolicy::Prefer, vec![]); + let card = test_card("peer"); // url == base_url passed below; card is unsigned + let raw = serde_json::to_value(&card).unwrap(); + assert!( + registry + .check_trust("http://localhost", &card, &raw) + .is_ok() + ); + } + + #[test] + fn check_trust_require_rejects_unsigned_peer() { + let registry = AgentRegistry::new(reqwest::Client::new(), Duration::from_mins(1)) + .with_trust(CardTrustPolicy::Require, vec![]); + let card = test_card("peer"); + let raw = serde_json::to_value(&card).unwrap(); + let err = registry + .check_trust("http://localhost", &card, &raw) + .unwrap_err(); + assert!(matches!(err, A2aError::UntrustedCard { .. })); + } } #[cfg(test)] @@ -393,4 +895,95 @@ mod wiremock_tests { "expected Timeout error from slow discovery" ); } + + #[tokio::test] + async fn discover_unsigned_peer_accepted_under_default_ignore() { + let server = MockServer::start().await; + let base_url = server.uri(); + Mock::given(method("GET")) + .and(path("/.well-known/agent.json")) + .respond_with(agent_card_response("unsigned-peer", &base_url)) + .mount(&server) + .await; + + // Default policy (no `with_trust` call) — must behave exactly as before #5928. + let registry = AgentRegistry::new(reqwest::Client::new(), Duration::from_mins(1)); + let card = registry.discover(&base_url).await.unwrap(); + assert_eq!(card.name, "unsigned-peer"); + } + + #[tokio::test] + async fn discover_unsigned_peer_accepted_under_prefer() { + let server = MockServer::start().await; + let base_url = server.uri(); + Mock::given(method("GET")) + .and(path("/.well-known/agent.json")) + .respond_with(agent_card_response("unsigned-peer", &base_url)) + .mount(&server) + .await; + + let registry = AgentRegistry::new(reqwest::Client::new(), Duration::from_mins(1)) + .with_trust(crate::discovery::CardTrustPolicy::Prefer, vec![]); + let card = registry.discover(&base_url).await.unwrap(); + assert_eq!(card.name, "unsigned-peer"); + } + + #[tokio::test] + async fn discover_unsigned_peer_rejected_under_require() { + let server = MockServer::start().await; + let base_url = server.uri(); + Mock::given(method("GET")) + .and(path("/.well-known/agent.json")) + .respond_with(agent_card_response("unsigned-peer", &base_url)) + .mount(&server) + .await; + + let registry = AgentRegistry::new(reqwest::Client::new(), Duration::from_mins(1)) + .with_trust(crate::discovery::CardTrustPolicy::Require, vec![]); + let result = registry.discover(&base_url).await; + assert_matches!(result.unwrap_err(), A2aError::UntrustedCard { .. }); + } + + #[tokio::test] + async fn discover_url_mismatch_rejected_under_require() { + let server = MockServer::start().await; + let base_url = server.uri(); + Mock::given(method("GET")) + .and(path("/.well-known/agent.json")) + // Card advertises a different origin than the one queried. + .respond_with(agent_card_response( + "spoofed-peer", + "http://attacker.example.com", + )) + .mount(&server) + .await; + + let registry = AgentRegistry::new(reqwest::Client::new(), Duration::from_mins(1)) + .with_trust(crate::discovery::CardTrustPolicy::Require, vec![]); + let result = registry.discover(&base_url).await; + // The mock card is unsigned, so the signature axis also rejects under `require` + // — the combined `UntrustedCard` error is returned (see + // `check_trust_require_rejects_url_mismatch_alone_when_signature_verifies` in the + // `tests` module above for the URL-axis-only case). + assert_matches!(result.unwrap_err(), A2aError::UntrustedCard { .. }); + } + + #[tokio::test] + async fn discover_url_mismatch_warns_but_accepts_under_prefer() { + let server = MockServer::start().await; + let base_url = server.uri(); + Mock::given(method("GET")) + .and(path("/.well-known/agent.json")) + .respond_with(agent_card_response( + "spoofed-peer", + "http://attacker.example.com", + )) + .mount(&server) + .await; + + let registry = AgentRegistry::new(reqwest::Client::new(), Duration::from_mins(1)) + .with_trust(crate::discovery::CardTrustPolicy::Prefer, vec![]); + let card = registry.discover(&base_url).await.unwrap(); + assert_eq!(card.name, "spoofed-peer"); + } } diff --git a/crates/zeph-a2a/src/error.rs b/crates/zeph-a2a/src/error.rs index a9732c9d4..8fb2c2c82 100644 --- a/crates/zeph-a2a/src/error.rs +++ b/crates/zeph-a2a/src/error.rs @@ -55,6 +55,29 @@ pub enum A2aError { /// A request or task processing operation exceeded its deadline. #[error("operation timed out after {0:?}")] Timeout(std::time::Duration), + + /// A discovered [`AgentCard`](crate::types::AgentCard) failed the configured + /// [`CardTrustPolicy`](crate::discovery::CardTrustPolicy) signature check. + /// + /// Returned when the signature axis alone, or the signature axis combined with a + /// URL-origin mismatch, causes rejection (S2: signature `Invalid` dominates a URL + /// mismatch). See [`UrlMismatch`](A2aError::UrlMismatch) for a URL-only rejection. + #[error("untrusted agent card: {reason}")] + UntrustedCard { + /// Human-readable reason, e.g. an ECDSA verification failure or an unknown `kid`. + reason: String, + }, + + /// A discovered [`AgentCard`](crate::types::AgentCard)'s `url` field origin + /// (scheme + host + port) does not match the origin that was queried, and the + /// signature axis did not independently trigger rejection. + #[error("agent card url mismatch: queried '{queried}', card advertises '{advertised}'")] + UrlMismatch { + /// Origin that was queried (`scheme://host:port`). + queried: String, + /// Origin advertised by the card's `url` field (`scheme://host:port`). + advertised: String, + }, } impl From for A2aError { diff --git a/crates/zeph-a2a/src/lib.rs b/crates/zeph-a2a/src/lib.rs index db29210af..35da8f3bd 100644 --- a/crates/zeph-a2a/src/lib.rs +++ b/crates/zeph-a2a/src/lib.rs @@ -32,6 +32,7 @@ //! |---------|-------------| //! | `server` | Enables `A2aServer`, `TaskManager`, and `TaskProcessor` | //! | `ibct` | Enables [`Ibct`] token issuance and verification (HMAC-SHA256) | +//! | `card-signing` | Enables [`card_signing::verify_card_signatures`] and [`card_signing::sign_card`] (JWS/ES256 over RFC 8785 JCS). Without it, `AgentCardSignature`/`signatures` still (de)serialize, but verification always returns `SignatureVerification::FeatureDisabled`. | //! //! # Examples //! @@ -65,6 +66,7 @@ #![forbid(unsafe_code)] pub mod card; +pub mod card_signing; pub mod client; pub mod discovery; pub mod error; @@ -79,11 +81,24 @@ pub mod types; mod testing; /// A2A protocol version implemented by this crate. +/// +/// This crate implements A2A **0.2.1** for wire compatibility (method names, well-known +/// discovery path `/.well-known/agent.json`, field shapes), plus one additive 1.0.0 +/// feature: [`AgentCard::signatures`](crate::AgentCard::signatures) / [`card_signing`] +/// (A2A 1.0.0 §8.4). This constant is intentionally **not** bumped to `"1.0"` — doing so +/// would over-claim conformance the Key Invariant "`AgentCard` must accurately reflect +/// supported capabilities" forbids. Deferred 1.0.0 items, tracked as follow-ups to #5928: +/// +/// - Well-known path rename to `/.well-known/agent-card.json` (see `discovery.rs`). +/// - gRPC / HTTP-REST transport bindings (JSON-RPC only today). +/// - Signing our own served card (`server`/`card.rs` emitting `signatures`). +/// - `jku`/JWKS key retrieval and `x5c` certificate-chain trust anchoring. pub const A2A_PROTOCOL_VERSION: &str = "0.2.1"; pub use card::AgentCardBuilder; +pub use card_signing::{SigAlg, SignatureVerification, TrustedKey}; pub use client::{A2aClient, SecurityPolicy, TaskEvent, TaskEventStream}; -pub use discovery::AgentRegistry; +pub use discovery::{AgentRegistry, CardTrustPolicy}; pub use error::A2aError; pub use ibct::{Ibct, IbctError, IbctKey}; pub use jsonrpc::SendMessageParams; diff --git a/crates/zeph-a2a/src/server/mod.rs b/crates/zeph-a2a/src/server/mod.rs index 2d382e43a..d37c35199 100644 --- a/crates/zeph-a2a/src/server/mod.rs +++ b/crates/zeph-a2a/src/server/mod.rs @@ -349,6 +349,7 @@ pub(crate) mod testing { default_input_modes: vec!["text/plain".into()], default_output_modes: vec!["text/plain".into()], skills: vec![], + signatures: vec![], } } diff --git a/crates/zeph-a2a/src/types.rs b/crates/zeph-a2a/src/types.rs index 668977243..b9dfa7cb3 100644 --- a/crates/zeph-a2a/src/types.rs +++ b/crates/zeph-a2a/src/types.rs @@ -257,6 +257,30 @@ pub struct AgentCard { /// Discrete skills the agent exposes, each with its own examples and mode overrides. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub skills: Vec, + /// JWS signatures over this card's content, per A2A 1.0.0 §8.4.2. + /// + /// Empty for unsigned cards (all 0.2.x peers and most 1.0.0 peers today). See + /// [`crate::card_signing`] for verification. `#[serde(default)]` makes this field + /// backward compatible: legacy cards without a `signatures` key deserialize to `[]`. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub signatures: Vec, +} + +/// A single JWS signature over an [`AgentCard`], per A2A 1.0.0 §8.4.2. +/// +/// The signed payload is the RFC 8785 JCS canonicalization of the card's JSON +/// representation with the `signatures` field itself removed. See +/// [`crate::card_signing`] for the verification algorithm and its known limitations. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentCardSignature { + /// Base64url-encoded (unpadded) JWS protected header, e.g. `{"alg":"ES256","kid":"key-1"}`. + pub protected: String, + /// Base64url-encoded (unpadded) signature bytes. + pub signature: String, + /// Optional unprotected JWS header (A2A spec §8.4.2 `header` field). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub header: Option, } /// Organization that built or operates an agent. @@ -653,6 +677,7 @@ mod tests { input_modes: vec![], output_modes: vec![], }], + signatures: vec![], }; let json = serde_json::to_string_pretty(&card).unwrap(); let back: AgentCard = serde_json::from_str(&json).unwrap(); @@ -661,6 +686,53 @@ mod tests { assert_eq!(back.skills.len(), 1); } + #[test] + fn agent_card_signatures_default_empty_and_skipped() { + let card = minimal_card(); + let json = serde_json::to_string(&card).unwrap(); + assert!(!json.contains("signatures")); + let back: AgentCard = serde_json::from_str(&json).unwrap(); + assert!(back.signatures.is_empty()); + } + + #[test] + fn agent_card_deserializes_legacy_card_without_signatures_key() { + // A pre-#5928 card JSON with no `signatures` key at all must still deserialize. + let json = r#"{"name":"old","description":"","url":"http://x","version":"1","protocolVersion":"0.2.1","capabilities":{"streaming":false}}"#; + let card: AgentCard = serde_json::from_str(json).unwrap(); + assert!(card.signatures.is_empty()); + } + + #[test] + fn agent_card_signature_round_trips() { + let sig = AgentCardSignature { + protected: "eyJhbGciOiJFUzI1NiJ9".into(), + signature: "c2lnbmF0dXJlLWJ5dGVz".into(), + header: Some(serde_json::json!({"kid": "key-1"})), + }; + let json = serde_json::to_string(&sig).unwrap(); + let back: AgentCardSignature = serde_json::from_str(&json).unwrap(); + assert_eq!(back.protected, sig.protected); + assert_eq!(back.signature, sig.signature); + assert_eq!(back.header, sig.header); + } + + fn minimal_card() -> AgentCard { + AgentCard { + name: "test-agent".into(), + description: "A test agent".into(), + url: "http://localhost:8080".into(), + version: "0.1.0".into(), + protocol_version: "0.2.1".into(), + provider: None, + capabilities: AgentCapabilities::default(), + default_input_modes: vec![], + default_output_modes: vec![], + skills: vec![], + signatures: vec![], + } + } + #[test] fn task_status_update_event_serde() { let event = TaskStatusUpdateEvent { diff --git a/crates/zeph-config/Cargo.toml b/crates/zeph-config/Cargo.toml index ca1fefb69..840ced843 100644 --- a/crates/zeph-config/Cargo.toml +++ b/crates/zeph-config/Cargo.toml @@ -14,6 +14,13 @@ readme = "README.md" [features] default = [] +# Marker feature only (no deps) — mirrors `zeph-a2a/card-signing` so `Config::validate()` +# can fail fast when `card_trust_policy = require` is set without the crypto feature +# compiled in anywhere in the binary. MUST be enabled together with `zeph-a2a/card-signing` +# (root `Cargo.toml`'s `a2a` feature line pairs them) — nothing else enforces the pairing, so +# splitting them would make `require` falsely fail config validation even when the crypto in +# zeph-a2a actually works (#5928). +card-signing = [] deep-link = [] [dependencies] diff --git a/crates/zeph-config/src/channels.rs b/crates/zeph-config/src/channels.rs index 4da4a6384..991080c67 100644 --- a/crates/zeph-config/src/channels.rs +++ b/crates/zeph-config/src/channels.rs @@ -333,6 +333,46 @@ max_bot_chain_depth = 5 assert_eq!(server.ssrf_protection, client.ssrf_protection); } + #[test] + fn a2a_client_config_card_trust_policy_defaults_to_ignore() { + let cfg = A2aClientConfig::default(); + assert_eq!(cfg.card_trust_policy, CardTrustPolicy::Ignore); + assert!(cfg.trusted_agent_keys.is_empty()); + } + + #[test] + fn card_trust_policy_serde_lowercase() { + assert_eq!( + serde_json::to_string(&CardTrustPolicy::Ignore).unwrap(), + r#""ignore""# + ); + assert_eq!( + serde_json::to_string(&CardTrustPolicy::Prefer).unwrap(), + r#""prefer""# + ); + assert_eq!( + serde_json::to_string(&CardTrustPolicy::Require).unwrap(), + r#""require""# + ); + } + + #[test] + fn a2a_client_config_trusted_agent_keys_round_trip() { + let toml_src = r#" + card_trust_policy = "require" + + [[trusted_agent_keys]] + kid = "key-1" + alg = "ES256" + jwk_or_pem = "-----BEGIN PUBLIC KEY-----\nMFk...\n-----END PUBLIC KEY-----" + "#; + let cfg: A2aClientConfig = toml::from_str(toml_src).unwrap(); + assert_eq!(cfg.card_trust_policy, CardTrustPolicy::Require); + assert_eq!(cfg.trusted_agent_keys.len(), 1); + assert_eq!(cfg.trusted_agent_keys[0].kid, "key-1"); + assert_eq!(cfg.trusted_agent_keys[0].alg, "ES256"); + } + #[test] fn ibct_key_config_debug_redacts_key_hex() { let key = IbctKeyConfig { @@ -919,7 +959,7 @@ impl Default for A2aServerConfig { /// `--connect http://127.0.0.1:8080/a2a/stream` usage example must work out of the box. /// Non-loopback targets are governed by `require_tls`/`ssrf_protection` below, which /// default to the same hardened posture as the server config. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] #[serde(default)] pub struct A2aClientConfig { /// Reject non-loopback endpoints that do not start with `https://`. Default: `true`. @@ -927,6 +967,21 @@ pub struct A2aClientConfig { /// Resolve non-loopback endpoint hostnames via DNS and reject private/link-local /// ranges. Default: `true`. pub ssrf_protection: bool, + /// Trust policy applied to peer [`AgentCard`](https://docs.rs/zeph-a2a) signatures and + /// URL-origin consistency during discovery (A2A 1.0.0 §8.4, #5928). Default: `ignore` + /// — byte-identical to pre-#5928 discovery behavior. See + /// [`CardTrustPolicy`] doc comments for the `prefer`/`require` semantics, and + /// [`Config::validate`](crate::root::Config::validate) for the `require`-without-the- + /// `card-signing`-feature fail-fast check. + pub card_trust_policy: CardTrustPolicy, + /// Public keys trusted to sign peer `AgentCard`s, keyed by `kid`. Empty by default — + /// `prefer`/`require` with no entries treats every peer as unverifiable (see + /// `SignatureVerification::Unverifiable` in `zeph-a2a`). + /// + /// These are public verification keys, not secrets, so (unlike + /// [`A2aServerConfig::ibct_signing_key_vault_ref`]) they are stored inline rather than + /// via a vault reference. + pub trusted_agent_keys: Vec, } impl Default for A2aClientConfig { @@ -934,10 +989,58 @@ impl Default for A2aClientConfig { Self { require_tls: true, ssrf_protection: true, + card_trust_policy: CardTrustPolicy::default(), + trusted_agent_keys: Vec::new(), } } } +/// Trust policy for peer `AgentCard` signature + URL-origin verification during A2A +/// discovery (A2A 1.0.0 §8.4, #5928). +/// +/// Mirrors `zeph_a2a::discovery::CardTrustPolicy` (protocol-crate-facing) as an +/// independent type — `zeph-config` must not depend on protocol crates, the same reason +/// [`McpTrustLevel`] has no `zeph-mcp` counterpart dependency. Conversion happens at the +/// `zeph-core` wiring layer once a runtime `AgentRegistry` construction site exists +/// (currently none does — see the `card_trust_policy` field doc and the `discovery.rs` +/// TODO in `zeph-a2a`). +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +#[non_exhaustive] +pub enum CardTrustPolicy { + /// Discover peer cards without checking signatures or URL origin. Default — + /// byte-identical to pre-#5928 behavior. + #[default] + Ignore, + /// Log a warning on an untrusted/unverifiable card or URL-origin mismatch, but still + /// accept it; reject only an actively tampered signature. Recommended production + /// setting once real-peer interop is proven (see `zeph-a2a::card_signing` module docs). + Prefer, + /// Reject any card with an unverifiable signature or a URL-origin mismatch. + /// + /// Requires the `card-signing` feature to be compiled in — [`Config::validate`] + /// rejects this setting at config-load time otherwise, rather than allowing it to + /// silently degrade or brick discovery at runtime. + /// + /// [`Config::validate`]: crate::root::Config::validate + Require, +} + +/// A single trusted public key for verifying peer `AgentCard` signatures (#5928). +/// +/// Public verification key material — not secret, so stored inline in config rather than +/// resolved via a vault reference (contrast IBCT's `ibct_signing_key_vault_ref`, which +/// protects a symmetric HMAC secret). +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct TrustedAgentKey { + /// Key identifier, matched against the `kid` in a signature's protected header. + pub kid: String, + /// Signature algorithm this key is trusted to verify (e.g. `"ES256"`). + pub alg: String, + /// JWK JSON object or PEM-encoded `SubjectPublicKeyInfo` public key material. + pub jwk_or_pem: String, +} + /// Dynamic MCP tool context pruning configuration (#2204). /// /// When enabled, an LLM call evaluates which MCP tools are relevant to the current task diff --git a/crates/zeph-config/src/env.rs b/crates/zeph-config/src/env.rs index 1acdae0ca..2c2effad9 100644 --- a/crates/zeph-config/src/env.rs +++ b/crates/zeph-config/src/env.rs @@ -428,5 +428,19 @@ impl Config { { self.a2a_client.ssrf_protection = ssrf; } + if let Ok(v) = std::env::var("ZEPH_A2A_CARD_TRUST_POLICY") { + match v.to_ascii_lowercase().as_str() { + "ignore" => { + self.a2a_client.card_trust_policy = crate::channels::CardTrustPolicy::Ignore; + } + "prefer" => { + self.a2a_client.card_trust_policy = crate::channels::CardTrustPolicy::Prefer; + } + "require" => { + self.a2a_client.card_trust_policy = crate::channels::CardTrustPolicy::Require; + } + _ => {} + } + } } } diff --git a/crates/zeph-config/src/lib.rs b/crates/zeph-config/src/lib.rs index c3635ed60..4ccb7a479 100644 --- a/crates/zeph-config/src/lib.rs +++ b/crates/zeph-config/src/lib.rs @@ -117,10 +117,11 @@ pub use agent::{ SubAgentConfig, SubAgentLifecycleHooks, TaskSupervisorConfig, ToolFilterConfig, }; pub use channels::{ - A2aClientConfig, A2aServerConfig, ChannelSkillsConfig, DiscordConfig, IbctKeyConfig, McpConfig, - McpOAuthConfig, McpPolicy, McpServerConfig, McpTrustLevel, OAuthTokenStorage, RateLimit, - SlackConfig, TelegramConfig, ToolDiscoveryConfig, ToolDiscoveryStrategyConfig, - ToolPruningConfig, TrustCalibrationConfig, is_skill_allowed, + A2aClientConfig, A2aServerConfig, CardTrustPolicy, ChannelSkillsConfig, DiscordConfig, + IbctKeyConfig, McpConfig, McpOAuthConfig, McpPolicy, McpServerConfig, McpTrustLevel, + OAuthTokenStorage, RateLimit, SlackConfig, TelegramConfig, ToolDiscoveryConfig, + ToolDiscoveryStrategyConfig, ToolPruningConfig, TrustCalibrationConfig, TrustedAgentKey, + is_skill_allowed, }; pub use cli::{CliConfig, LoopConfig}; pub use cocoon::CocoonConfig; diff --git a/crates/zeph-config/src/loader.rs b/crates/zeph-config/src/loader.rs index 383e8925a..1c3b2a422 100644 --- a/crates/zeph-config/src/loader.rs +++ b/crates/zeph-config/src/loader.rs @@ -140,6 +140,7 @@ impl Config { if self.a2a.rate_limit == 0 { return Err(ConfigError::Validation("a2a.rate_limit must be > 0".into())); } + self.validate_a2a_client_trust()?; if self.gateway.rate_limit == 0 { return Err(ConfigError::Validation( "gateway.rate_limit must be > 0".into(), @@ -165,6 +166,31 @@ impl Config { Ok(()) } + /// Fail fast if `[a2a_client].card_trust_policy = "require"` is set without the + /// `card-signing` feature compiled in anywhere in the binary (S3, #5928). + /// + /// Without this check, `require` would either silently degrade to no signature + /// enforcement or brick all discovery, depending on how the unreachable code path is + /// interpreted — both are worse than a loud config-load error. See + /// `zeph_a2a::discovery::signature_severity` for the runtime-side half of this + /// contract (treats `FeatureDisabled` the same as `Unverifiable`/`Invalid` under + /// `require`, which only matters if this validation is ever bypassed). + #[cfg_attr( + feature = "card-signing", + allow(clippy::unused_self, clippy::unnecessary_wraps) + )] + fn validate_a2a_client_trust(&self) -> Result<(), ConfigError> { + #[cfg(not(feature = "card-signing"))] + if self.a2a_client.card_trust_policy == crate::channels::CardTrustPolicy::Require { + return Err(ConfigError::Validation( + "a2a_client.card_trust_policy = require requires the card-signing feature \ + to be enabled at build time (see the `a2a` feature in the root Cargo.toml)" + .into(), + )); + } + Ok(()) + } + /// Validate memory compression strategy bounds and compaction thresholds. fn validate_memory_compression(&self) -> Result<(), ConfigError> { if let crate::memory::CompressionStrategy::Proactive { @@ -726,6 +752,35 @@ mod tests { ); } + #[cfg(not(feature = "card-signing"))] + #[test] + fn card_trust_policy_require_without_feature_fails_validation() { + let mut cfg = Config::default(); + cfg.a2a_client.card_trust_policy = crate::channels::CardTrustPolicy::Require; + let err = cfg.validate().unwrap_err(); + assert!( + err.to_string().contains("card_trust_policy"), + "unexpected error: {err}" + ); + } + + #[test] + fn card_trust_policy_ignore_and_prefer_always_pass_validation() { + let mut cfg = Config::default(); + cfg.a2a_client.card_trust_policy = crate::channels::CardTrustPolicy::Ignore; + assert!(cfg.validate().is_ok()); + cfg.a2a_client.card_trust_policy = crate::channels::CardTrustPolicy::Prefer; + assert!(cfg.validate().is_ok()); + } + + #[cfg(feature = "card-signing")] + #[test] + fn card_trust_policy_require_with_feature_passes_validation() { + let mut cfg = Config::default(); + cfg.a2a_client.card_trust_policy = crate::channels::CardTrustPolicy::Require; + assert!(cfg.validate().is_ok()); + } + #[test] fn semantic_cache_threshold_invalid_infinity() { let err = config_with_sct(f32::INFINITY).validate().unwrap_err(); diff --git a/crates/zeph-config/src/migrate/infra.rs b/crates/zeph-config/src/migrate/infra.rs index ce215eaab..8ab022239 100644 --- a/crates/zeph-config/src/migrate/infra.rs +++ b/crates/zeph-config/src/migrate/infra.rs @@ -905,3 +905,45 @@ pub fn migrate_shadow_sentinel_config(toml_src: &str) -> Result Result { + let commented_present = toml_src.contains("# card_trust_policy"); + let doc = toml_src.parse::()?; + let active_present = doc + .get("a2a_client") + .and_then(|t| t.get("card_trust_policy")) + .is_some(); + if commented_present || active_present { + return Ok(MigrationResult { + output: toml_src.to_owned(), + changed_count: 0, + sections_changed: Vec::new(), + }); + } + + let block = "\n# A2A Agent Card signature + URL-origin trust policy (A2A 1.0.0 §8.4, #5928).\n\ + # Requires the `card-signing` feature (see the `a2a` feature in the root Cargo.toml)\n\ + # for `\"require\"` to be accepted at config load — see zeph-a2a::card_signing module\n\ + # docs for the ES256-only, out-of-band-key-store trust model.\n\ + # [a2a_client]\n\ + # card_trust_policy = \"ignore\" # \"ignore\" | \"prefer\" | \"require\"\n\ + # [[a2a_client.trusted_agent_keys]]\n\ + # kid = \"key-1\"\n\ + # alg = \"ES256\"\n\ + # jwk_or_pem = \"\"\n"; + let output = format!("{}{}", toml_src.trim_end(), block); + Ok(MigrationResult { + output, + changed_count: 1, + sections_changed: vec!["a2a_client".to_owned()], + }) +} diff --git a/crates/zeph-config/src/migrate/mod.rs b/crates/zeph-config/src/migrate/mod.rs index de8139bd2..838c385a1 100644 --- a/crates/zeph-config/src/migrate/mod.rs +++ b/crates/zeph-config/src/migrate/mod.rs @@ -597,21 +597,21 @@ pub trait Migration: Send + Sync { mod steps; use steps::{ - MigrateAcpAuthClientsConfig, MigrateAcpSubagentsConfig, MigrateAgentBudgetHint, - MigrateAgentRetryToToolsRetry, MigrateAutodreamConfig, MigrateCavemanConfig, - MigrateCocoonProviderNotice, MigrateCocoonShowBalance, MigrateCompressionPredictorConfig, - MigrateDatabaseUrl, MigrateDeepLinkConfig, MigrateDurableConfig, MigrateDurableSharedDb, - MigrateEgressConfig, MigrateEmbedProviderRename, MigrateEvalModelToProvider, - MigrateFidelityTimeoutDefaults, MigrateFiveSignalConfig, MigrateFocusAutoConsolidateMinWindow, - MigrateForgettingConfig, MigrateGoalsConfig, MigrateGonkagateToGonka, - MigrateHooksPermissionDeniedConfig, MigrateHooksTurnComplete, MigrateKnowledgeConfig, - MigrateLlmStreamLimits, MigrateMagicDocsConfig, MigrateMcpElicitationConfig, - MigrateMcpMaxConnectAttempts, MigrateMcpRetryAndToolTimeout, MigrateMcpTrustLevels, - MigrateMemoryGraph, MigrateMemoryGraphRecallIncludeImported, MigrateMemoryHebbian, - MigrateMemoryHebbianConsolidation, MigrateMemoryHebbianSpread, MigrateMemoryPersonaConfig, - MigrateMemoryReasoning, MigrateMemoryReasoningJudge, MigrateMemoryRetrieval, - MigrateMemoryRetrievalQueryBias, MigrateMicrocompactConfig, MigrateNliConfig, - MigrateOrchestrationAssetSensitivity, MigrateOrchestrationPersistence, + MigrateA2aCardTrustConfig, MigrateAcpAuthClientsConfig, MigrateAcpSubagentsConfig, + MigrateAgentBudgetHint, MigrateAgentRetryToToolsRetry, MigrateAutodreamConfig, + MigrateCavemanConfig, MigrateCocoonProviderNotice, MigrateCocoonShowBalance, + MigrateCompressionPredictorConfig, MigrateDatabaseUrl, MigrateDeepLinkConfig, + MigrateDurableConfig, MigrateDurableSharedDb, MigrateEgressConfig, MigrateEmbedProviderRename, + MigrateEvalModelToProvider, MigrateFidelityTimeoutDefaults, MigrateFiveSignalConfig, + MigrateFocusAutoConsolidateMinWindow, MigrateForgettingConfig, MigrateGoalsConfig, + MigrateGonkagateToGonka, MigrateHooksPermissionDeniedConfig, MigrateHooksTurnComplete, + MigrateKnowledgeConfig, MigrateLlmStreamLimits, MigrateMagicDocsConfig, + MigrateMcpElicitationConfig, MigrateMcpMaxConnectAttempts, MigrateMcpRetryAndToolTimeout, + MigrateMcpTrustLevels, MigrateMemoryGraph, MigrateMemoryGraphRecallIncludeImported, + MigrateMemoryHebbian, MigrateMemoryHebbianConsolidation, MigrateMemoryHebbianSpread, + MigrateMemoryPersonaConfig, MigrateMemoryReasoning, MigrateMemoryReasoningJudge, + MigrateMemoryRetrieval, MigrateMemoryRetrievalQueryBias, MigrateMicrocompactConfig, + MigrateNliConfig, MigrateOrchestrationAssetSensitivity, MigrateOrchestrationPersistence, MigrateOrchestratorProvider, MigrateOtelFilter, MigratePiiFilterNames, MigratePlannerModelToProvider, MigratePolicyProviderAndUtilityWindow, MigrateProviderMaxConcurrent, MigrateQdrantApiKey, MigrateQdrantTimeoutSecs, @@ -626,7 +626,7 @@ use steps::{ MigrateWorktreeConfig, MigrateWorktreeGitTimeout, }; -/// Ordered registry of all sequential migration steps (steps 1–81). +/// Ordered registry of all sequential migration steps (steps 1–82). /// /// Each entry wraps the corresponding free function and is evaluated lazily at first access. /// The ordering is chronological; the dispatch loop in `src/commands/migrate.rs` iterates @@ -773,6 +773,8 @@ pub static MIGRATIONS: std::sync::LazyLock> Box::new(MigrateSkillTrustRequireCheck), // Step 81 — add [security.shadow_sentinel] advisory block (spec 050, #5934) Box::new(MigrateShadowSentinelConfig), + // Step 82 — add [a2a_client] card_trust_policy/trusted_agent_keys advisory block (#5928) + Box::new(MigrateA2aCardTrustConfig), ] }); diff --git a/crates/zeph-config/src/migrate/steps.rs b/crates/zeph-config/src/migrate/steps.rs index 9323fc14a..95074c931 100644 --- a/crates/zeph-config/src/migrate/steps.rs +++ b/crates/zeph-config/src/migrate/steps.rs @@ -43,19 +43,22 @@ //! table (INV-8 `encryption_gate`, #5996); //! step 80 adds a commented `require_integrity_check_on_promote = true` advisory to an //! existing active `[skills.trust]` table (#6087); -//! step 81 adds a commented `[security.shadow_sentinel]` advisory block (spec 050, #5934). +//! step 81 adds a commented `[security.shadow_sentinel]` advisory block (spec 050, #5934); +//! step 82 adds a commented `[a2a_client]` `card_trust_policy`/`trusted_agent_keys` advisory +//! block (#5928). //! //! Each struct is a zero-size type that delegates to the corresponding free function in //! `super`. They exist solely to satisfy the object-safe [`super::Migration`] trait so the //! registry can hold `Box` values. use super::{ - MigrateError, Migration, MigrationResult, migrate_acp_auth_clients_config, - migrate_acp_subagents_config, migrate_agent_budget_hint, migrate_agent_retry_to_tools_retry, - migrate_autodream_config, migrate_caveman_config, migrate_cocoon_provider_notice, - migrate_cocoon_show_balance, migrate_compression_predictor_config, migrate_database_url, - migrate_deep_link_config, migrate_durable_config, migrate_durable_shared_db, - migrate_egress_config, migrate_embed_provider_rename, migrate_eval_model_to_provider, + MigrateError, Migration, MigrationResult, migrate_a2a_card_trust_config, + migrate_acp_auth_clients_config, migrate_acp_subagents_config, migrate_agent_budget_hint, + migrate_agent_retry_to_tools_retry, migrate_autodream_config, migrate_caveman_config, + migrate_cocoon_provider_notice, migrate_cocoon_show_balance, + migrate_compression_predictor_config, migrate_database_url, migrate_deep_link_config, + migrate_durable_config, migrate_durable_shared_db, migrate_egress_config, + migrate_embed_provider_rename, migrate_eval_model_to_provider, migrate_fidelity_timeout_defaults, migrate_five_signal_config, migrate_focus_auto_consolidate_min_window, migrate_forgetting_config, migrate_goals_config, migrate_hooks_permission_denied_config, migrate_hooks_turn_complete_config, @@ -996,3 +999,16 @@ impl Migration for MigrateShadowSentinelConfig { migrate_shadow_sentinel_config(toml_src) } } + +/// Step 82 — adds a commented `[a2a_client]` `card_trust_policy`/`trusted_agent_keys` +/// advisory block (A2A Agent Card signature verification, #5928). +pub(super) struct MigrateA2aCardTrustConfig; +impl Migration for MigrateA2aCardTrustConfig { + fn name(&self) -> &'static str { + "migrate_a2a_card_trust_config" + } + + fn apply(&self, toml_src: &str) -> Result { + migrate_a2a_card_trust_config(toml_src) + } +} diff --git a/crates/zeph-config/src/migrate/tests.rs b/crates/zeph-config/src/migrate/tests.rs index 09c7278dc..5a34bdcfe 100644 --- a/crates/zeph-config/src/migrate/tests.rs +++ b/crates/zeph-config/src/migrate/tests.rs @@ -9,8 +9,8 @@ use super::*; fn migrations_registry_has_all_steps() { assert_eq!( MIGRATIONS.len(), - 81, - "MIGRATIONS registry must contain all 81 sequential steps" + 82, + "MIGRATIONS registry must contain all 82 sequential steps" ); for m in MIGRATIONS.iter() { assert!( @@ -1753,7 +1753,7 @@ fn migrate_focus_auto_consolidate_noop_when_only_commented_section() { #[test] fn registry_has_fifty_entries() { - assert_eq!(MIGRATIONS.len(), 81); + assert_eq!(MIGRATIONS.len(), 82); } #[test] @@ -1874,6 +1874,7 @@ fn registry_preserves_order_matches_dispatch() { "migrate_durable_shared_db", "migrate_skill_trust_require_check", "migrate_shadow_sentinel_config", + "migrate_a2a_card_trust_config", ]; let actual: Vec<&str> = MIGRATIONS.iter().map(|m| m.name()).collect(); assert_eq!(actual, expected); @@ -4185,6 +4186,57 @@ fn step_81_idempotent_on_own_output() { ); } +// ── migrate_a2a_card_trust_config tests (step 82, #5928) ────────── + +#[test] +fn step_82_adds_card_trust_block_when_absent() { + let src = "[agent]\nname = \"zeph\"\n"; + let result = migrate_a2a_card_trust_config(src).expect("migrate"); + assert_eq!(result.changed_count, 1); + assert!(result.output.contains("# [a2a_client]")); + assert!(result.output.contains("# card_trust_policy")); + assert_eq!(result.sections_changed, vec!["a2a_client".to_owned()]); +} + +#[test] +fn step_82_noop_when_card_trust_policy_already_active() { + let src = "[a2a_client]\ncard_trust_policy = \"prefer\"\n"; + let result = migrate_a2a_card_trust_config(src).expect("migrate"); + assert_eq!(result.changed_count, 0); + assert_eq!(result.output, src); +} + +#[test] +fn step_82_noop_when_comment_already_present() { + let src = "# [a2a_client]\n# card_trust_policy = \"ignore\"\n"; + let result = migrate_a2a_card_trust_config(src).expect("migrate"); + assert_eq!(result.changed_count, 0); + assert_eq!(result.output, src); +} + +#[test] +fn step_82_idempotent_on_own_output() { + let src = "[agent]\nname = \"zeph\"\n"; + let first = migrate_a2a_card_trust_config(src).expect("first migrate"); + assert_eq!(first.changed_count, 1); + let second = migrate_a2a_card_trust_config(&first.output).expect("second migrate"); + assert_eq!(second.changed_count, 0, "second run must be a no-op"); + assert_eq!( + second.output, first.output, + "output unchanged on second run" + ); +} + +#[test] +fn step_82_injects_even_when_a2a_client_section_active_without_the_field() { + // An existing active `[a2a_client]` section that never set `card_trust_policy` must + // still gain the advisory — only the field's own presence suppresses re-injection. + let src = "[a2a_client]\nrequire_tls = false\n"; + let result = migrate_a2a_card_trust_config(src).expect("migrate"); + assert_eq!(result.changed_count, 1); + assert!(result.output.contains("# card_trust_policy")); +} + // ── M1 regression tests: narrower `section_header_present`-based guards (#5933) ───────── // // `migrate_egress_config`, `migrate_vigil_config`, and `migrate_tools_compression_config` diff --git a/crates/zeph-core/src/config.rs b/crates/zeph-core/src/config.rs index 9b7e1db2f..1266d4413 100644 --- a/crates/zeph-core/src/config.rs +++ b/crates/zeph-core/src/config.rs @@ -46,6 +46,7 @@ pub use zeph_config::{GuardrailAction, GuardrailConfig, GuardrailFailStrategy}; pub use zeph_config::A2aClientConfig; pub use zeph_config::A2aServerConfig; pub use zeph_config::ChannelSkillsConfig; +pub use zeph_config::{CardTrustPolicy, TrustedAgentKey}; pub use zeph_config::{FileChangedConfig, HooksConfig}; pub use zeph_config::{ diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml index 469fbca80..1483be91b 100644 --- a/docker/docker-compose.dev.yml +++ b/docker/docker-compose.dev.yml @@ -41,6 +41,7 @@ services: ZEPH_A2A_SSRF_PROTECTION: ${ZEPH_A2A_SSRF_PROTECTION:-true} ZEPH_A2A_CLIENT_REQUIRE_TLS: ${ZEPH_A2A_CLIENT_REQUIRE_TLS:-true} ZEPH_A2A_CLIENT_SSRF_PROTECTION: ${ZEPH_A2A_CLIENT_SSRF_PROTECTION:-true} + ZEPH_A2A_CARD_TRUST_POLICY: ${ZEPH_A2A_CARD_TRUST_POLICY:-ignore} ZEPH_SECURITY_REDACT_SECRETS: ${ZEPH_SECURITY_REDACT_SECRETS:-true} ZEPH_TOOLS_AUDIT_ENABLED: ${ZEPH_TOOLS_AUDIT_ENABLED:-true} ZEPH_TOOLS_AUDIT_DESTINATION: ${ZEPH_TOOLS_AUDIT_DESTINATION:-stdout} diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index fe74edf5d..40d683429 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -54,6 +54,7 @@ services: ZEPH_A2A_SSRF_PROTECTION: ${ZEPH_A2A_SSRF_PROTECTION:-true} ZEPH_A2A_CLIENT_REQUIRE_TLS: ${ZEPH_A2A_CLIENT_REQUIRE_TLS:-true} ZEPH_A2A_CLIENT_SSRF_PROTECTION: ${ZEPH_A2A_CLIENT_SSRF_PROTECTION:-true} + ZEPH_A2A_CARD_TRUST_POLICY: ${ZEPH_A2A_CARD_TRUST_POLICY:-ignore} ZEPH_SECURITY_REDACT_SECRETS: ${ZEPH_SECURITY_REDACT_SECRETS:-true} ZEPH_TOOLS_AUDIT_ENABLED: ${ZEPH_TOOLS_AUDIT_ENABLED:-false} ZEPH_TOOLS_AUDIT_DESTINATION: ${ZEPH_TOOLS_AUDIT_DESTINATION:-stdout} diff --git a/specs/014-a2a/spec.md b/specs/014-a2a/spec.md index 20142ec59..b368bedd7 100644 --- a/specs/014-a2a/spec.md +++ b/specs/014-a2a/spec.md @@ -34,8 +34,9 @@ related: | `crates/zeph-a2a/src/types.rs` | `Task`, `Message`, `AgentCard`, `Artifact` | | `crates/zeph-a2a/src/jsonrpc.rs` | JSON-RPC 2.0 envelope, error codes | | `crates/zeph-a2a/src/client.rs` | `A2aClient`, `send_message`, `stream_message`, `get_task`, `cancel_task` | -| `crates/zeph-a2a/src/discovery.rs` | `AgentRegistry`, TTL cache, `/.well-known/agent.json` | +| `crates/zeph-a2a/src/discovery.rs` | `AgentRegistry`, TTL cache, `/.well-known/agent.json`, `CardTrustPolicy` enforcement | | `crates/zeph-a2a/src/card.rs` | `AgentCard` serialization | +| `crates/zeph-a2a/src/card_signing.rs` | `AgentCardSignature` JWS verification (`card-signing` feature), `TrustedKey`, `SigAlg`, `SignatureVerification` | | `crates/zeph-a2a/src/server/mod.rs` | `A2aServer`, `TaskProcessor` trait | | `crates/zeph-a2a/src/server/handlers.rs` | JSON-RPC method handlers | | `crates/zeph-a2a/src/server/state.rs` | `TaskManager`, in-memory task store | @@ -55,13 +56,54 @@ related: ``` AgentRegistry ├── cache: RwLock> — URL → AgentCard, TTL-cached -└── discovery: GET {base_url}/.well-known/agent.json → AgentCard +├── trust: TrustConfig { policy: CardTrustPolicy, trusted_keys: Vec } +└── discovery: GET {base_url}/.well-known/agent.json → AgentCard → check_trust() → cache ``` -- Discovery endpoint: `/.well-known/agent.json` — standard A2A well-known path -- `AgentCard`: describes capabilities, supported methods, authentication requirements +- Discovery endpoint: `/.well-known/agent.json` — standard A2A (pre-1.0.0) well-known path. + A2A 1.0.0 renames this to `/.well-known/agent-card.json`; Zeph has not adopted the rename + (see `A2A_PROTOCOL_VERSION` below) — a pure-1.0.0 peer that only serves the new path is not + currently discoverable. +- `AgentCard`: describes capabilities, supported methods, authentication requirements, and + (A2A 1.0.0 §4.4.7) an optional `signatures: Vec` — `#[serde(default)]`, + so unsigned/pre-1.0.0 peers deserialize with an empty vec (backward-compatible). - Cache TTL: configurable; prevents repeated discovery requests to the same agent +### Card trust policy (`CardTrustPolicy`, #5928) + +`AgentRegistry::discover()` runs an optional trust check on every card it fetches, combining +two independent axes via most-severe-wins precedence (`Accept < Warn < Reject`): + +- **URL-origin consistency**: the queried `base_url` vs. the card's self-declared `url` field, + compared by scheme + host + port (RFC 6454 origin semantics, not full path). +- **Signature verification** (`crates/zeph-a2a/src/card_signing.rs`, feature `card-signing`): + JWS signatures over the RFC 8785 JCS canonicalization of the *raw received JSON* (never a + re-serialization of the typed `AgentCard` struct) with `signatures` removed. Only ES256 + (P-256) is supported; other algorithms resolve to `Unverifiable`. All signatures in the array + are evaluated — verification is order-independent, so key rotation (old+new signature during + overlap) and multi-party attestation both work correctly. + +`CardTrustPolicy` is tri-state, default **`Ignore`** (byte-identical to pre-#5928 behavior): + +| Policy | URL mismatch | Unverifiable/FeatureDisabled signature | Invalid (tampered) signature | +|---|---|---|---| +| `ignore` | accept | accept | accept | +| `prefer` | warn + accept | warn + accept | **reject** | +| `require` | **reject** | **reject** | **reject** | + +Two independent `CardTrustPolicy` enums exist by design: `zeph_a2a::discovery::CardTrustPolicy` +(protocol-crate-facing, used by `AgentRegistry::with_trust`) and +`zeph_config::channels::CardTrustPolicy` (TOML-facing, `[a2a_client] card_trust_policy`) — the +same pattern `zeph_mcp::ToolDiscoveryStrategy` uses for its `zeph-config` counterpart, because +`zeph-config` must not depend on protocol crates. Conversion between the two happens at the +`zeph-core` wiring layer once a caller exists (see Current Limitations below). + +Configuration: `[a2a_client].card_trust_policy` (default `"ignore"`) and +`[a2a_client].trusted_agent_keys` (list of `{ kid, alg, key_material }` — plain config, not +vault-referenced, since these are public keys). Env override: `ZEPH_A2A_CARD_TRUST_POLICY`. +Setting `card_trust_policy = "require"` while the `card-signing` feature is not compiled in +**fails config load** (`Config::validate`) rather than silently downgrading to `ignore`. + ## JSON-RPC 2.0 Protocol ``` @@ -117,6 +159,14 @@ Terminal states: `completed | failed | canceled | rejected` Client Security Posture below) - TLS enforcement: if `require_tls` enabled, `http://` URLs must be rejected, including via redirect - Server feature (`zeph-a2a?/server`) is independent of client — can run one without the other +- The trust anchor for `AgentCard` signature verification MUST be an out-of-band, + operator-configured key store (`[a2a_client].trusted_agent_keys`) — NEVER a card-supplied + `jku` URL. An attacker who can forge an entire card can also point `jku` at a JWKS they + control and self-sign; auto-fetching `jku` would additionally reopen an SSRF surface this + crate's transport-layer hardening already guards against (see Client Security Posture below) +- `card_trust_policy = "require"` without the `card-signing` feature compiled in MUST fail + config load loudly — never silently degrade to `ignore` +- `A2A_PROTOCOL_VERSION` stays at `"0.2.1"` — see A2A 1.0.0 Conformance below before bumping it --- @@ -216,6 +266,36 @@ The `ibct` feature flag must be enabled for IBCT to be compiled in. --- +## A2A 1.0.0 Conformance (2026-07-13, #5928) + +`A2A_PROTOCOL_VERSION` intentionally stays at `"0.2.1"` even though Agent Card signature +verification (§8.4, one 1.0.0 feature) has been added. Bumping the advertised version would +over-claim full 1.0.0 conformance. Other 1.0.0 deltas remain unimplemented and are explicitly +deferred, not silently dropped: + +- Well-known path rename `/.well-known/agent.json` → `/.well-known/agent-card.json` +- gRPC/REST transport bindings (JSON-RPC only today) +- Method-name/field-shape reconciliation against the 1.0.0 spec text + +Any change that bumps `A2A_PROTOCOL_VERSION` must first close this gap list (or explicitly +re-scope it) — do not bump the version as a side effect of landing one more 1.0.0 feature. + +### Current limitations + +- **No live consumer** (#6200): `AgentRegistry` has no runtime construction site anywhere in + the codebase outside `crates/zeph-a2a` itself. Nothing in the running agent currently + performs A2A peer discovery, so `card_trust_policy` — including `require` — enforces nothing + in a running Zeph instance today. It is a fully implemented and tested library + config + primitive, not yet wired into `zeph-core`. +- **Unvalidated interop** (#6201): the RFC 8785 JCS canonicalization in `card_signing.rs` is + implemented from the A2A 1.0.0 spec text only, never checked against a real `a2a-sdk` + reference-implementation signed-card vector (no network access during development). If a + real peer strips proto3-default fields before signing but transmits the full card on the + wire, verification could incorrectly reject a genuinely valid card. Treat `require` as + unproven against real peers until a real vector is obtained and checked in as a test case. + +--- + ## Addendum: A2A vs. ANP Positioning (2026-04-17) Cross-reference: `specs/045-interop-protocol-gaps/spec.md` diff --git a/specs/README.md b/specs/README.md index fae971933..87541326c 100644 --- a/specs/README.md +++ b/specs/README.md @@ -101,7 +101,7 @@ Spec IDs (001–069) follow a logical grouping: | `012-graph-memory/spec.md` | Entity graph, BFS recall, community detection, MAGMA typed edges, SYNAPSE spreading activation | `zeph-memory` | | `004-memory/004-6-graph-memory.md` | Graph memory sub-spec (concise reference within 004-memory): MAGMA typed edges, SYNAPSE config, A-MEM link weights, key invariants | `zeph-memory` | | `013-acp/spec.md` | ACP transports, session management, permissions, fork/resume, session/close handlers, capability advertisement, /agent.json endpoint; 0.14.0 bump: session/set_model removed, message-id echo removed, provider renames, feature flag stabilizations | `zeph-acp` | -| `014-a2a/spec.md` | A2A protocol, agent discovery, JSON-RPC 2.0, IBCT (Invocation-Bound Capability Tokens), HMAC-SHA256 signatures | `zeph-a2a` | +| `014-a2a/spec.md` | A2A protocol, agent discovery, JSON-RPC 2.0, IBCT (Invocation-Bound Capability Tokens), HMAC-SHA256 signatures, Agent Card JWS signature verification + tri-state `CardTrustPolicy` (`card-signing` feature, out-of-band trusted-key store, no `jku` auto-fetch; #5928, follow-ups #6200/#6201) | `zeph-a2a` | | `015-self-learning/spec.md` | FeedbackDetector (multi-language), Wilson score, trust model, SAGE RL cross-session reward, ARISE trace improvement, STEM pattern-to-skill migration, ERL experiential learning | `zeph-skills` | | `016-agent-feedback/spec.md` | Implicit correction detection: `FeedbackDetector` (regex-only, 7 languages, dual anchoring tiers), `JudgeDetector` (LLM judge with adaptive thresholds, sliding-window rate limiter 5/min), four correction kinds (explicit rejection, alternative request, repetition, self-correction), CJK limitations | `zeph-agent-feedback` | | `017-output-filtering/spec.md` | FilterPipeline, CommandMatcher, SecurityPatterns | `zeph-tools` | diff --git a/src/tui_remote.rs b/src/tui_remote.rs index db63924b5..dacf805e5 100644 --- a/src/tui_remote.rs +++ b/src/tui_remote.rs @@ -17,7 +17,7 @@ use zeph_tui::{App, EventReader}; #[cfg(all(feature = "tui", feature = "a2a"))] fn resolve_client_security_policy( url: &str, - client_cfg: zeph_core::config::A2aClientConfig, + client_cfg: &zeph_core::config::A2aClientConfig, ) -> zeph_a2a::SecurityPolicy { let is_loopback = url::Url::parse(url) .ok() @@ -53,7 +53,7 @@ pub(crate) async fn run_tui_remote( // `[a2a_client]` is a dedicated client-side policy for this `--connect` path — distinct // from `[a2a]` (`A2aServerConfig`), which only governs this process's own A2A server (#5878). - let security = resolve_client_security_policy(&url, config.a2a_client); + let security = resolve_client_security_policy(&url, &config.a2a_client); let client = zeph_a2a::A2aClient::new(zeph_core::http::default_client()).with_security(security); @@ -227,7 +227,7 @@ mod tests { fn loopback_ipv4_bypasses_tls_and_ssrf_even_when_hardened() { let policy = resolve_client_security_policy( "http://127.0.0.1:8080/a2a/stream", - hardened_client_cfg(), + &hardened_client_cfg(), ); assert!(!policy.require_tls); assert!(!policy.ssrf_protection); @@ -236,7 +236,7 @@ mod tests { #[test] fn loopback_ipv6_bypasses_tls_and_ssrf() { let policy = - resolve_client_security_policy("http://[::1]:8080/a2a/stream", hardened_client_cfg()); + resolve_client_security_policy("http://[::1]:8080/a2a/stream", &hardened_client_cfg()); assert!(!policy.require_tls); assert!(!policy.ssrf_protection); } @@ -245,7 +245,7 @@ mod tests { fn loopback_hostname_bypasses_tls_and_ssrf() { let policy = resolve_client_security_policy( "http://localhost:8080/a2a/stream", - hardened_client_cfg(), + &hardened_client_cfg(), ); assert!(!policy.require_tls); assert!(!policy.ssrf_protection); @@ -255,7 +255,7 @@ mod tests { fn non_loopback_http_uses_configured_client_policy() { let policy = resolve_client_security_policy( "http://agent.example.com/a2a/stream", - hardened_client_cfg(), + &hardened_client_cfg(), ); assert!(policy.require_tls); assert!(policy.ssrf_protection); @@ -266,16 +266,17 @@ mod tests { let permissive = A2aClientConfig { require_tls: false, ssrf_protection: false, + ..A2aClientConfig::default() }; let policy = - resolve_client_security_policy("http://agent.example.com/a2a/stream", permissive); + resolve_client_security_policy("http://agent.example.com/a2a/stream", &permissive); assert!(!policy.require_tls); assert!(!policy.ssrf_protection); } #[test] fn unparseable_url_falls_back_to_configured_client_policy() { - let policy = resolve_client_security_policy("not a url", hardened_client_cfg()); + let policy = resolve_client_security_policy("not a url", &hardened_client_cfg()); assert!(policy.require_tls); assert!(policy.ssrf_protection); } @@ -286,7 +287,7 @@ mod tests { // unaffected either way — `HTTP://` must resolve identically to `http://`. let policy = resolve_client_security_policy( "HTTP://127.0.0.1:8080/a2a/stream", - hardened_client_cfg(), + &hardened_client_cfg(), ); assert!(!policy.require_tls); assert!(!policy.ssrf_protection); @@ -297,7 +298,7 @@ mod tests { // `Url::host_str()` must return only the host, ignoring userinfo and port. let policy = resolve_client_security_policy( "http://user:pass@127.0.0.1:8080/a2a/stream", - hardened_client_cfg(), + &hardened_client_cfg(), ); assert!(!policy.require_tls); assert!(!policy.ssrf_protection); @@ -306,8 +307,10 @@ mod tests { #[test] fn unspecified_address_is_not_treated_as_loopback() { // `0.0.0.0` is unspecified, not loopback — must stay TLS/SSRF-hardened. - let policy = - resolve_client_security_policy("http://0.0.0.0:8080/a2a/stream", hardened_client_cfg()); + let policy = resolve_client_security_policy( + "http://0.0.0.0:8080/a2a/stream", + &hardened_client_cfg(), + ); assert!(policy.require_tls); assert!(policy.ssrf_protection); } @@ -317,7 +320,7 @@ mod tests { // 127.0.0.0/8 is entirely loopback, not just 127.0.0.1 — verify the top of the range. let policy = resolve_client_security_policy( "http://127.255.255.255:8080/a2a/stream", - hardened_client_cfg(), + &hardened_client_cfg(), ); assert!(!policy.require_tls); assert!(!policy.ssrf_protection); @@ -331,7 +334,7 @@ mod tests { "http://10.0.0.1:8080/a2a/stream", "http://192.168.1.1:8080/a2a/stream", ] { - let policy = resolve_client_security_policy(url, hardened_client_cfg()); + let policy = resolve_client_security_policy(url, &hardened_client_cfg()); assert!(policy.require_tls, "expected require_tls for {url}"); assert!(policy.ssrf_protection, "expected ssrf_protection for {url}"); } @@ -343,7 +346,7 @@ mod tests { // match — a lookalike hostname must not get the carve-out. let policy = resolve_client_security_policy( "http://notlocalhost.example.com/a2a/stream", - hardened_client_cfg(), + &hardened_client_cfg(), ); assert!(policy.require_tls); assert!(policy.ssrf_protection); @@ -357,7 +360,7 @@ mod tests { // configured (hardened) client policy rather than silently bypassing security. let policy = resolve_client_security_policy( "http://[::ffff:127.0.0.1]:8080/a2a/stream", - hardened_client_cfg(), + &hardened_client_cfg(), ); assert!(policy.require_tls); assert!(policy.ssrf_protection);