From 4fcae65b5261d2290bbd9db5cdf2b8c227ebabd9 Mon Sep 17 00:00:00 2001 From: Kun Lai Date: Thu, 5 Nov 2026 14:24:24 +0800 Subject: [PATCH 1/3] deps: pin ohttp to the AEAD suite policy commit The fork's tng branch now carries AES-256-GCM in both HPKE backends and the explicit-suite constructors the ingress policy uses. The pin also picks up d2c5e38, which was already on the branch. Assisted-by: Claude:fable-5 --- Cargo.lock | 4 ++-- tng/Cargo.toml | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9fe2bc00..3aa47711 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -784,7 +784,7 @@ checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" [[package]] name = "bhttp" version = "0.7.0" -source = "git+https://github.com/inclavare-containers/ohttp.git?rev=3e2e1123d395cf59f82e8a9f30758dae90597e22#3e2e1123d395cf59f82e8a9f30758dae90597e22" +source = "git+https://github.com/inclavare-containers/ohttp.git?rev=2ec8816bcd82291feed34dc8d1135c6108cefd7e#2ec8816bcd82291feed34dc8d1135c6108cefd7e" dependencies = [ "async-stream", "bytes", @@ -4982,7 +4982,7 @@ checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3" [[package]] name = "ohttp" version = "0.7.0" -source = "git+https://github.com/inclavare-containers/ohttp.git?rev=3e2e1123d395cf59f82e8a9f30758dae90597e22#3e2e1123d395cf59f82e8a9f30758dae90597e22" +source = "git+https://github.com/inclavare-containers/ohttp.git?rev=2ec8816bcd82291feed34dc8d1135c6108cefd7e#2ec8816bcd82291feed34dc8d1135c6108cefd7e" dependencies = [ "aead", "aes-gcm", diff --git a/tng/Cargo.toml b/tng/Cargo.toml index 543fefa4..3eae58b6 100644 --- a/tng/Cargo.toml +++ b/tng/Cargo.toml @@ -16,7 +16,7 @@ async-stream = {workspace = true} async-trait = {workspace = true} auto_enums = {workspace = true} base64 = {workspace = true} -bhttp = {git = "https://github.com/inclavare-containers/ohttp.git", rev = "3e2e1123d395cf59f82e8a9f30758dae90597e22", features = ["http-compat"]} +bhttp = {git = "https://github.com/inclavare-containers/ohttp.git", rev = "2ec8816bcd82291feed34dc8d1135c6108cefd7e", features = ["http-compat"]} bytes = {workspace = true} chrono = {workspace = true} cidr = {workspace = true} @@ -43,7 +43,7 @@ libc = "0.2" local-ip-address = "0.6" parking_lot = "0.12" nix = {workspace = true, features = ["process", "signal", "socket", "net"]} -ohttp = {git = "https://github.com/inclavare-containers/ohttp.git", rev = "3e2e1123d395cf59f82e8a9f30758dae90597e22"} +ohttp = {git = "https://github.com/inclavare-containers/ohttp.git", rev = "2ec8816bcd82291feed34dc8d1135c6108cefd7e"} opentelemetry = {workspace = true, optional = true} opentelemetry-otlp = {workspace = true, features = [ "logs", @@ -118,7 +118,7 @@ hyper = {workspace = true} hyper-util = {workspace = true} moka = {workspace = true} notify = {workspace = true} -ohttp = {git = "https://github.com/inclavare-containers/ohttp.git", rev = "3e2e1123d395cf59f82e8a9f30758dae90597e22", features = ["aws-lc"]} +ohttp = {git = "https://github.com/inclavare-containers/ohttp.git", rev = "2ec8816bcd82291feed34dc8d1135c6108cefd7e", features = ["aws-lc"]} quinn = {workspace = true} rats-cert = {path = "../rats-cert", default-features = false, features = ["crypto-rustcrypto", "attester-coco", "verifier-coco", "attester-ita", "verifier-ita"]} socket2 = {workspace = true} From 21f84dde61a7fbac46cb8569e043f5c6b206f0b6 Mon Sep 17 00:00:00 2001 From: Kun Lai Date: Thu, 5 Nov 2026 15:15:31 +0800 Subject: [PATCH 2/3] ohttp: add the AEAD suite policy for ingress and egress Add an ordered ohttp.aead_suites allowlist. On the egress it drives both the advertised key config and the decapsulation allowlist. A disallowed suite returns HTTP 422 so a TNG ingress refetches its key config, and the check also covers keys issued before a policy change and keys synced from a peer_shared peer. On the ingress the list is an allowlist and a client preference order. The ingress picks the first server key config with an intersection, then the first suite in its own order. The selected config drives the metadata hint and the status public key. No intersection fails the key fetch, with no fallback. Not setting the field keeps the previous behavior. Include AES-256-GCM in both ohttp HPKE backends, unit and integration tests, and the configuration and changelog documentation. Assisted-by: Claude:fable-5 --- CHANGELOG.md | 1 + docs/configuration.md | 27 ++ docs/configuration_zh.md | 28 ++ tng-testsuite/Cargo.toml | 4 + tng-testsuite/src/task/tng/external/podman.rs | 10 +- tng-testsuite/tests/ohttp/suite_policy.rs | 431 ++++++++++++++++++ tng/src/config/egress.rs | 24 + tng/src/config/ingress.rs | 19 + tng/src/config/mod.rs | 6 + tng/src/config/ohttp_aead.rs | 178 ++++++++ tng/src/config/parse.rs | 64 +++ tng/src/error.rs | 20 + tng/src/tools/key_sync_daemon.rs | 71 ++- .../egress/protocol/ohttp/security/api/mod.rs | 25 +- .../protocol/ohttp/security/api/tunnel.rs | 122 ++++- .../ohttp/security/key_manager/file.rs | 23 +- .../ohttp/security/key_manager/mod.rs | 102 +++-- .../peer_shared/cluster_key_set.rs | 131 +++++- .../security/key_manager/peer_shared/serf.rs | 334 +++++++++++--- .../security/key_manager/self_generated.rs | 15 +- .../egress/protocol/ohttp/security/server.rs | 10 + .../ingress/protocol/ohttp/security/client.rs | 323 +++++++++++-- .../ingress/protocol/ohttp/security/mod.rs | 15 + 23 files changed, 1786 insertions(+), 197 deletions(-) create mode 100644 tng-testsuite/tests/ohttp/suite_policy.rs create mode 100644 tng/src/config/ohttp_aead.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 60077241..87f96280 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `docs/protocols.md` / `docs/protocols_zh.md`: developer-facing reference for the tunnel protocols and their remote-attestation handshake, covering the ohttp tunnel's outer request routing, key-config distribution, request encapsulation, and the background-check and passport attestation flows for both directions, with sequence diagrams in a formal RFC 9334-style notation. - RATS-TLS chapter in `docs/protocols.md` / `docs/protocols_zh.md`: the standard-TLS-1.3 handshake with RA material carried in the self-signed DICE certificate, the four direction combinations, multiplexed versus non-multiplexed data planes, the freshness and session-resumption model, and handshake failure behavior, in the same notation as the ohttp chapter. - `trusted-network-gateway--1.el7.x86_64.rpm` release asset built on a glibc 2.17 (manylinux2014) baseline: it installs on any RPM-based distro with glibc 2.17 or newer, including AnolisOS 7.9, alongside the existing an8 package. +- OHTTP `aead_suites` policy on ingress and egress: a per-endpoint, ordered allowlist of HPKE AEAD suites (`aes128_gcm`, `aes256_gcm`, `chacha20_poly1305`). On the egress it sets both the advertised suites and the decapsulation allowlist, and a disallowed suite is rejected with HTTP 422 so a TNG ingress refetches its key config. On the ingress it also sets the client preference order, which takes precedence over the server's advertised order. Unset keeps the previous behavior; the egress default now advertises AES-256-GCM as well, and an unset ingress still selects the first advertised suite. ### Changed diff --git a/docs/configuration.md b/docs/configuration.md index 671d1a15..434a2522 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1120,6 +1120,7 @@ Enable OHTTP in `add_ingress` by specifying the `ohttp` field. |---|---|---|---| | `path_rewrites` | array [[PathRewrite](#pathrewrite)] | `[]` | Path rewrite rule list, matched in order | | `path_default` | string | `"root"` | Fallback outer path when no `path_rewrites` rule matches (unset/empty/no match). `"root"` → `/`; `"original"` → the inner request's original path | +| `aead_suites` | array [string] | — | HPKE AEAD suites this ingress may use, in preference order. See `aead_suites` below. | #### PathRewrite @@ -1224,6 +1225,19 @@ Whether to wrap the OHTTP-encrypted request in TLS (HTTPS) when forwarding to th PEM file paths of trusted CA certificates used to verify the upstream's TLS certificate when `tls: true`. Each file may contain a single certificate or a PEM bundle (multiple concatenated certs). Useful for self-signed or internal-CA gateways. Empty (the default) relies on the built-in webpki roots. Ignored unless `tls: true`. +#### `aead_suites` (optional, list of string) + +Restricts the HPKE AEAD suites this ingress may use, in preference order. Legal values are `aes128_gcm`, `aes256_gcm` and `chacha20_poly1305`; matching is case-sensitive and aliases are rejected. Omit the field to keep the historical behavior: no filtering, and the first suite advertised by the server's first key config. This is a compliance control, not a performance tuning option. + +When set, the ingress intersects each server key config's advertised suites with this list, picks the first server key config (in server order) with a non-empty intersection, then picks the first suite in this list's order. Taking the client's order rather than the server's first advertised suite is a deliberate deviation from RFC 9458. Only TNG as ingress behaves this way; standard OHTTP servers are unaffected. + +If no server key config intersects the list, the key fetch fails and requests error out. The ingress never falls back to the default suites. An empty list or a duplicate entry is rejected at config load. The policy is read once at startup, so changing it requires restarting the ingress. + +This field only constrains the TNG ingress that carries it. To constrain every client of an egress, configure `aead_suites` on the egress as well. + +In a `peer_shared` cluster, each node applies its own policy. Use the same list on every node: a node whose policy differs rejects the suites its peers advertise, and clients keep failing until they refetch a key config they can use. + +When tightening to AES-256-GCM only, first upgrade the egress and every ingress to a version that supports AES-256-GCM, then roll out the restrictive policy. A client still holding a cached key config with an older suite keeps failing until it refetches; the egress's HTTP 422 response makes the ingress invalidate its cache and refetch on the next request. @@ -1236,6 +1250,7 @@ Corresponding to Ingress, enable OHTTP in `add_egress` by specifying the `ohttp` | `cors` | [CorsConfig](#corsconfig) | None | CORS configuration for browser access to OHTTP endpoints | | `key` | [KeyConfig](#key-management) | None | Key management configuration (see [Key Management](#key-management) below) | | `forward_client_ip` | bool | `true` | When `true`, the egress injects `X-Real-IP` (set to the direct TCP peer) and `X-Forwarded-For` (append the direct peer) into the decrypted inner request before forwarding upstream. The direct peer is whoever delivered the OHTTP POST, which in the relay topology is the relay, not the real client. Set `false` to disable. | +| `aead_suites` | array [string] | `["aes128_gcm", "aes256_gcm", "chacha20_poly1305"]` | HPKE AEAD suites to advertise and accept. See `aead_suites` below. | > [!NOTE] > `allow_non_tng_traffic_regexes` is deprecated since 2.2.4; use `direct_forward` instead. @@ -1315,6 +1330,18 @@ Example: } ``` +#### `aead_suites` (optional, list of string) + +The HPKE AEAD suites this egress advertises in its key config and accepts when decapsulating a request. Legal values and defaults match the ingress side: `aes128_gcm`, `aes256_gcm` and `chacha20_poly1305`, defaulting to all three in that order. + +Both halves are driven by the same list. Newly generated and file-loaded key configs advertise only these suites, and every decapsulation checks the suite in the request header, including requests sealed under an older key config or under a key synced from a `peer_shared` peer. A disallowed suite is rejected with HTTP 422 and logged with the suite, key id and peer address; the response body carries no suite details. The 422 makes a TNG ingress discard its cached key config and fetch a new one on the next request. + +There is no fallback to the default list. An empty list or a duplicate entry is rejected at config load. The policy is read once at startup, so changing it requires restarting the egress. + +In a `peer_shared` cluster, key configs are synced without filtering, so nodes with different policies advertise suites their peers reject. Configure the same `aead_suites` list on every node of the group. + +Roll out a restrictive policy after upgrading the egress and the expected clients to a version that supports the suite being kept. A client caching a key config from before the change is rejected until it refetches, which the 422 response drives on its next request. + ### Key Management diff --git a/docs/configuration_zh.md b/docs/configuration_zh.md index 3f5339d4..1fce9522 100644 --- a/docs/configuration_zh.md +++ b/docs/configuration_zh.md @@ -1120,6 +1120,7 @@ OHTTP (Oblivious HTTP) 是一种旨在增强隐私保护的网络协议扩展, |---|---|---|---| | `path_rewrites` | array [[PathRewrite](#pathrewrite)] | `[]` | Path 重写规则列表,按顺序匹配 | | `path_default` | string | `"root"` | 当没有 `path_rewrites` 规则命中(未设置/为空/未匹配)时的外层路径回退值。`"root"` → `/`;`"original"` → 内层请求的原始 path | +| `aead_suites` | array [string] | — | 该 ingress 可使用的 HPKE AEAD 套件,按偏好顺序排列。详见下文 `aead_suites`。 | #### PathRewrite @@ -1212,6 +1213,20 @@ OHTTP 加密后的 HTTP 请求遵循以下规则,以便与 L7 负载均衡器 当 `tls: true` 时,用于校验上游 TLS 证书的可信 CA 证书 PEM 文件路径。每个文件可以是单张证书,也可以是 PEM bundle(多张拼接证书)。适用于自签或内网 CA 网关。默认为空,使用内置 webpki 根。仅在 `tls: true` 时生效。 +#### `aead_suites`(可选,字符串数组) + +限定该 ingress 可使用的 HPKE AEAD 套件,列表顺序即偏好顺序。合法值为 `aes128_gcm`、`aes256_gcm`、`chacha20_poly1305`,大小写敏感,不接受别名。省略该字段时保持原有行为:不做过滤,直接使用服务端第一份 key config 广告的第一个套件。这是合规开关,不是性能调优选项。 + +配置后,ingress 把每份服务端 key config 的广告套件与本列表求交集,按服务端 key config 的顺序选择第一份交集非空的 config,再在这份 config 内按本列表的顺序选择第一个可用套件。以客户端顺序优先而不是取服务端广告的第一个套件,是对 RFC 9458 字面规则的刻意偏离。只有 TNG 作为 ingress 时如此,标准 OHTTP 服务端不受影响。 + +如果所有服务端 key config 的交集都为空,key 拉取失败,请求以错误结束,不会回退到默认套件。空数组或重复项在配置加载阶段即报错。策略只在启动时读取,修改后需要重启 ingress。 + +该字段只约束配置它的这个 TNG ingress。要约束某个 egress 的所有客户端,必须同时在 egress 上配置 `aead_suites`。 + +在 `peer_shared` 集群中,每个节点各自应用自己的策略。请在所有节点上使用相同的列表:策略不一致的节点会拒绝其他节点广告的套件,客户端在重新拉取到可用的 key config 之前会持续失败。 + +收紧为只允许 AES-256-GCM 时,先把 egress 和所有 ingress 升级到支持 AES-256-GCM 的版本,再下发限制策略。仍持有旧 key config 的客户端会持续失败,直到重新拉取;egress 返回的 HTTP 422 会让 ingress 失效缓存,并在下一次请求时重新拉取。 + @@ -1224,6 +1239,7 @@ OHTTP 加密后的 HTTP 请求遵循以下规则,以便与 L7 负载均衡器 | `cors` | [CorsConfig](#corsconfig) | 无 | CORS 配置,用于浏览器端访问 OHTTP 端点 | | `key` | [KeyConfig](#密钥管理) | 无 | 密钥管理配置(见下方 [密钥管理](#密钥管理)) | | `forward_client_ip` | bool | `true` | 为 `true` 时,egress 在转发解密后的内层请求给上游前,注入 `X-Real-IP`(设为直连 TCP 对端)与 `X-Forwarded-For`(追加直连对端)。直连对端是投递 OHTTP POST 的一方,在 relay 拓扑里即 relay,而非真实客户端。设为 `false` 关闭。 | +| `aead_suites` | array [string] | `["aes128_gcm", "aes256_gcm", "chacha20_poly1305"]` | 广告并接受的 HPKE AEAD 套件。详见下文 `aead_suites`。 | > [!NOTE] > `allow_non_tng_traffic_regexes` 在 2.2.4+ 已弃用,请使用 `direct_forward` 替代。 @@ -1297,6 +1313,18 @@ OHTTP 加密后的 HTTP 请求遵循以下规则,以便与 L7 负载均衡器 } ``` +#### `aead_suites`(可选,字符串数组) + +该 egress 在 key config 中广告、并在解封请求时接受的 HPKE AEAD 套件。合法值与默认值和 ingress 侧一致:`aes128_gcm`、`aes256_gcm`、`chacha20_poly1305`,默认按此顺序全部启用。 + +广告与接受由同一份列表决定。新生成和从文件加载的 key config 只广告这些套件,每次解封都会检查请求头中的套件,包括用旧 key config 或从 `peer_shared` 对端同步来的 key 封装的请求。不在列表内的套件返回 HTTP 422,并在日志中记录套件名、key id 和对端地址;响应体不包含套件细节。422 会让 TNG ingress 失效缓存的 key config,并在下一次请求时重新拉取。 + +不会回退到默认列表。空数组或重复项在配置加载阶段即报错。策略只在启动时读取,修改后需要重启 egress。 + +在 `peer_shared` 集群中,key config 同步阶段不做过滤,因此策略不同的节点会广告其邻居会拒绝的套件。请在同一群组的所有节点上配置相同的 `aead_suites` 列表。 + +下发限制策略前,先升级 egress 和预期客户端到支持目标套件的版本。仍缓存改动前 key config 的客户端会被拒绝,直到重新拉取;422 响应会在其下一次请求时触发重新拉取。 + diff --git a/tng-testsuite/Cargo.toml b/tng-testsuite/Cargo.toml index 8608dfe3..149cad79 100644 --- a/tng-testsuite/Cargo.toml +++ b/tng-testsuite/Cargo.toml @@ -195,6 +195,10 @@ path = "tests/http/mapping_multi_rule.rs" name = "ohttp" path = "tests/ohttp/ohttp.rs" +[[test]] +name = "suite_policy" +path = "tests/ohttp/suite_policy.rs" + [[test]] name = "ohttp_egress_hook" path = "tests/ohttp/ohttp_egress_hook.rs" diff --git a/tng-testsuite/src/task/tng/external/podman.rs b/tng-testsuite/src/task/tng/external/podman.rs index 5eaed742..1d1fc15e 100644 --- a/tng-testsuite/src/task/tng/external/podman.rs +++ b/tng-testsuite/src/task/tng/external/podman.rs @@ -19,8 +19,14 @@ impl TngInstance { ]) .args([ "-v", "/tmp/:/tmp/" - ]) - .arg(image_url) + ]); + // Forward the log filter into the container: the suite policy tests + // assert on tng debug logs, and the container inherits neither the + // parent environment nor the testsuite subscriber. + if let Ok(rust_log) = std::env::var("RUST_LOG") { + cmd.arg("-e").arg(format!("RUST_LOG={rust_log}")); + } + cmd.arg(image_url) .arg("tng") .arg("launch") .arg("--config-content") diff --git a/tng-testsuite/tests/ohttp/suite_policy.rs b/tng-testsuite/tests/ohttp/suite_policy.rs new file mode 100644 index 00000000..8bc326f5 --- /dev/null +++ b/tng-testsuite/tests/ohttp/suite_policy.rs @@ -0,0 +1,431 @@ +use anyhow::Result; +use serial_test::serial; +use tng_testsuite::{ + capture_logs, run_test, + task::{ + app::AppType, + shell::{ShellMode, ShellTask}, + tng::TngInstance, + NodeType, Task as _, + }, +}; + +/// Egress at 192.168.1.1:20001 (OHTTP API + mapping to the backend at +/// 127.0.0.1:30001). `ohttp_body` is the inside of the `ohttp` object, for +/// example `"aead_suites": ["aes256_gcm"]`. +fn tng_server(ohttp_body: &str) -> TngInstance { + let config = format!( + r#"{{ + "add_egress": [ + {{ + "mapping": {{ + "in": {{ "host": "0.0.0.0", "port": 20001 }}, + "out": {{ "host": "127.0.0.1", "port": 30001 }} + }}, + "ohttp": {{ {ohttp_body} }}, + "no_ra": true + }} + ] + }}"# + ); + TngInstance::TngServer(Box::leak(config.into_boxed_str())) +} + +/// Ingress at 127.0.0.1:10001 forwarding to the egress above. +fn tng_client(ohttp_body: &str) -> TngInstance { + let config = format!( + r#"{{ + "add_ingress": [ + {{ + "mapping": {{ + "in": {{ "host": "0.0.0.0", "port": 10001 }}, + "out": {{ "host": "192.168.1.1", "port": 20001 }} + }}, + "ohttp": {{ {ohttp_body} }}, + "no_ra": true + }} + ] + }}"# + ); + TngInstance::TngClient(Box::leak(config.into_boxed_str())) +} + +fn http_server() -> AppType { + AppType::HttpServer { + port: 30001, + expected_host_header: "example.com", + expected_path_and_query: "/foo", + } +} + +fn http_client() -> AppType { + AppType::HttpClient { + host: "127.0.0.1", + port: 10001, + host_header: "example.com", + path_and_query: "/foo", + } +} + +/// Assert every decapsulated request logged the expected AEAD, and that at +/// least one request was decapsulated. The egress logs the header suite at +/// debug level, and the testsuite subscriber captures `tng=debug`. +fn assert_negotiated_aead( + logs: &std::sync::MutexGuard<'_, Vec>, + expected: &str, +) -> Result<()> { + let requests: Vec<&String> = logs + .iter() + .filter(|line| line.contains("Received OHTTP request")) + .collect(); + assert!( + !requests.is_empty(), + "no decapsulated request was logged; captured lines:\n{}", + logs.join("\n") + ); + for line in requests { + assert!( + line.contains(expected), + "expected AEAD {expected} in: {line}" + ); + } + Ok(()) +} + +/// Decode the base64 key-config list the egress returns and echo its hex. +/// The list layout ends with `symmetric len | (kdf, aead)*`, so the suffix +/// identifies exactly which suites are advertised. +const FETCH_KEY_CONFIG_HEX: &str = r#" +resp=$(curl -sS --max-time 15 -X POST http://192.168.1.1:20001/ \ + -H 'x-tng-ohttp-api: /tng/key-config' \ + -H 'Content-Type: application/json' \ + -d '{}') +b64=$(printf '%s' "$resp" | jq -r '.hpke_key_config.encoded_key_config_list') +printf '%s' "$b64" | base64 -d | od -An -v -tx1 | tr -d ' \n' +"#; + +/// A raw tunnel POST with a hand-built metadata block and request header. +/// `aead_id` is 1 = AES-128-GCM, 2 = AES-256-GCM, 3 = ChaCha20-Poly1305. +/// The body is `metadata_len | Metadata{no_auth} | key_id | kem | kdf | aead`; +/// the suite check runs before the key hint is consulted, so no real key is +/// needed. +fn raw_tunnel_post(aead_id: u8, expected_code: &str) -> String { + // Two-byte big-endian AEAD id, for example `\000\003`. + let aead_escape = format!("\\000\\{aead_id:03o}"); + format!( + r#" +printf '\000\000\000\002\012\000\001\000\040\000\001{aead_escape}' > /tmp/tng-suite-req.bin +code=$(curl -sS --max-time 15 -o /dev/null -w '%{{http_code}}' \ + -X POST http://192.168.1.1:20001/ \ + -H 'x-tng-ohttp-api: /tng/tunnel' \ + -H 'Content-Type: message/ohttp-chunked-req' \ + --data-binary @/tmp/tng-suite-req.bin) || code=ERR +if [ "$code" != "{expected_code}" ]; then + echo "FAIL: expected HTTP {expected_code}, got $code" + exit 1 +fi +echo "PASS: raw tunnel POST returned HTTP {expected_code}" +"# + ) +} + +fn shell(name: &str, script: String) -> ShellTask { + shell_with_mode(name, script, ShellMode::ForegroundStop) +} + +/// Use after a shell assertion when an `HttpClient` runs next: a +/// `ForegroundStop` shell cancels the token and tears the tunnels down before +/// the client can connect. +fn shell_continue(name: &str, script: String) -> ShellTask { + shell_with_mode(name, script, ShellMode::ForegroundContinue) +} + +fn shell_with_mode(name: &str, script: String, mode: ShellMode) -> ShellTask { + ShellTask { + name: name.to_string(), + node_type: NodeType::Client, + script, + mode, + } +} + +/// Enable the debug line the suite assertions read when tng runs as a separate +/// process. The external binary inherits `RUST_LOG`, and its `EnvFilter` then +/// emits `tng=debug`; the in-process subscriber reads the same variable. +fn enable_tng_trace_logs() { + static ONCE: std::sync::Once = std::sync::Once::new(); + ONCE.call_once(|| { + std::env::set_var( + "RUST_LOG", + "info,tokio_graceful=off,rats_cert=trace,tng=trace", + ); + }); +} + +/// Both sides unset: the egress advertises the default three suites and the +/// ingress keeps picking the first one (AES-128-GCM), exactly as before the +/// policy existed. The advertised list is asserted explicitly because the +/// fork adds AES-256-GCM to the default set. +#[serial] +#[tokio::test(flavor = "multi_thread", worker_threads = 10)] +async fn suite_policy_default_unchanged() -> Result<()> { + enable_tng_trace_logs(); + let logs = capture_logs().await; + + run_test!(vec![ + tng_server("").boxed(), + tng_client("").boxed(), + http_server().boxed(), + shell_continue( + "default-policy-key-config", + format!( + r#" +hex=$({FETCH_KEY_CONFIG_HEX}) +case "$hex" in + *000c000100010001000200010003) echo "PASS: default three suites advertised" ;; + *) echo "FAIL: unexpected key config $hex"; exit 1 ;; +esac +"# + ), + ) + .boxed(), + http_client().boxed(), + ]) + .await?; + + assert_negotiated_aead(&logs.lock().unwrap(), "Aes128Gcm")?; + Ok(()) +} + +/// Egress policy only: AES-256-GCM is the sole advertised and accepted suite, +/// so an unset ingress negotiates it. +#[serial] +#[tokio::test(flavor = "multi_thread", worker_threads = 10)] +async fn suite_policy_egress_only_aes256() -> Result<()> { + enable_tng_trace_logs(); + let logs = capture_logs().await; + + run_test!(vec![ + tng_server(r#""aead_suites": ["aes256_gcm"]"#).boxed(), + tng_client("").boxed(), + http_server().boxed(), + shell_continue( + "egress-key-config", + format!( + r#" +hex=$({FETCH_KEY_CONFIG_HEX}) +case "$hex" in + *000400010002) echo "PASS: only AES-256-GCM advertised" ;; + *) echo "FAIL: unexpected key config $hex"; exit 1 ;; +esac +"# + ), + ) + .boxed(), + http_client().boxed(), + ]) + .await?; + + assert_negotiated_aead(&logs.lock().unwrap(), "Aes256Gcm")?; + Ok(()) +} + +/// Ingress policy only: the egress still advertises all three suites, and the +/// ingress prefers and negotiates AES-256-GCM. +#[serial] +#[tokio::test(flavor = "multi_thread", worker_threads = 10)] +async fn suite_policy_ingress_only_aes256() -> Result<()> { + enable_tng_trace_logs(); + let logs = capture_logs().await; + + run_test!(vec![ + tng_server("").boxed(), + tng_client(r#""aead_suites": ["aes256_gcm"]"#).boxed(), + http_server().boxed(), + http_client().boxed(), + ]) + .await?; + + assert_negotiated_aead(&logs.lock().unwrap(), "Aes256Gcm")?; + Ok(()) +} + +/// The egress advertises ChaCha first, but the ingress preference order puts +/// AES-128 first. Ingress order wins, which is the documented deviation from +/// RFC 9458's "first advertised suite" rule. +#[serial] +#[tokio::test(flavor = "multi_thread", worker_threads = 10)] +async fn suite_policy_ingress_preference_wins_over_server_order() -> Result<()> { + enable_tng_trace_logs(); + let logs = capture_logs().await; + + run_test!(vec![ + tng_server(r#""aead_suites": ["chacha20_poly1305", "aes128_gcm"]"#).boxed(), + tng_client(r#""aead_suites": ["aes128_gcm", "chacha20_poly1305"]"#).boxed(), + http_server().boxed(), + shell_continue( + "egress-advertised-order", + format!( + r#" +hex=$({FETCH_KEY_CONFIG_HEX}) +case "$hex" in + *00080001000300010001) echo "PASS: ChaCha then AES-128 advertised" ;; + *) echo "FAIL: unexpected key config $hex"; exit 1 ;; +esac +"# + ), + ) + .boxed(), + http_client().boxed(), + ]) + .await?; + + // Server order would have picked ChaCha; ingress order picks AES-128. + assert_negotiated_aead(&logs.lock().unwrap(), "Aes128Gcm")?; + Ok(()) +} + +/// Disjoint policies fail closed on both sides: the ingress reports no +/// acceptable suite and never falls back, and the egress rejects a request +/// header carrying a suite it does not allow with HTTP 422. +#[serial] +#[tokio::test(flavor = "multi_thread", worker_threads = 10)] +async fn suite_policy_no_intersection_fails() -> Result<()> { + enable_tng_trace_logs(); + let logs = capture_logs().await; + + run_test!(vec![ + tng_server(r#""aead_suites": ["aes256_gcm"]"#).boxed(), + tng_client(r#""aead_suites": ["chacha20_poly1305"]"#).boxed(), + http_server().boxed(), + shell( + "no-intersection", + format!( + r#" +# The egress only allows AES-256-GCM; a ChaCha header is rejected with 422. +{} +# The ingress cannot use the advertised suite and must fail the request +# rather than fall back to the default. +code=$(curl -sS --max-time 15 -o /dev/null -w '%{{http_code}}' \ + -H 'Host: example.com' http://127.0.0.1:10001/foo) || code=ERR +case "$code" in + 2[0-9][0-9]) echo "FAIL: request unexpectedly succeeded with $code"; exit 1 ;; + *) echo "PASS: no-intersection request failed (code=$code)" ;; +esac +"#, + raw_tunnel_post(3, "422") + ), + ) + .boxed(), + ]) + .await?; + + let logs = logs.lock().unwrap(); + let no_intersection = logs + .iter() + .find(|line| line.contains("no intersection between server and ingress")) + .expect("ingress must warn about the empty intersection"); + assert!( + no_intersection.contains("Aes256Gcm") && no_intersection.contains("ChaCha20Poly1305"), + "warning must list both sides: {no_intersection}" + ); + Ok(()) +} + +/// A request sealed under a suite the egress no longer accepts is rejected +/// with 422 and logged with the suite, key id and peer. A normal request +/// afterwards succeeds, proving the tunnel serves the allowed suite. +/// +/// The client-side key config cache cannot be aged within one process (the +/// policy is read once at startup), so the raw POST stands in for a client +/// that still holds an older key config advertising ChaCha20-Poly1305 while +/// the egress policy now allows only AES-256-GCM. That is the exact state +/// `peer_shared` peers with mismatched policies and not-yet-refreshed clients +/// produce, and it is the state the 422-and-refetch path recovers from. +#[serial] +#[tokio::test(flavor = "multi_thread", worker_threads = 10)] +async fn suite_policy_stale_ciphertext_rejected_then_recovers() -> Result<()> { + enable_tng_trace_logs(); + let logs = capture_logs().await; + + run_test!(vec![ + tng_server(r#""aead_suites": ["aes256_gcm"]"#).boxed(), + tng_client("").boxed(), + http_server().boxed(), + shell_continue("stale-ciphertext", raw_tunnel_post(3, "422")).boxed(), + http_client().boxed(), + ]) + .await?; + + let logs = logs.lock().unwrap(); + let rejection = logs + .iter() + .find(|line| line.contains("Rejected OHTTP request")) + .expect("egress must log the rejected suite"); + assert!( + rejection.contains("ChaCha20Poly1305"), + "rejection must name the suite: {rejection}" + ); + assert!( + rejection.contains("key_id"), + "rejection must name the key id: {rejection}" + ); + + let requests: Vec<&String> = logs + .iter() + .filter(|line| line.contains("Received OHTTP request")) + .collect(); + assert!( + requests.iter().all(|line| line.contains("Aes256Gcm")), + "only the allowed suite may reach decapsulation" + ); + assert!(!requests.is_empty(), "a recovered request must succeed"); + Ok(()) +} + +/// File-mode keys honor the egress policy: the loaded key config advertises +/// only the configured suite, not the default three. +#[serial] +#[tokio::test(flavor = "multi_thread", worker_threads = 10)] +async fn suite_policy_file_key_advertises_only_allowed() -> Result<()> { + let key_dir = "/tmp/tng-test"; + let key_path = "/tmp/tng-test/ohttp-suite-key.pem"; + + tokio::fs::create_dir_all(key_dir).await?; + + // X25519 PKCS#8 fixture, same as the other file-mode tests. + let initial_key = "-----BEGIN PRIVATE KEY----- +MC4CAQAwBQYDK2VuBCIEIOixlJE0Ykdc4ePwmaf2LLAea8Lfkfb+SARsKYmCBRpR +-----END PRIVATE KEY-----"; + tokio::fs::write(key_path, initial_key).await?; + + enable_tng_trace_logs(); + let logs = capture_logs().await; + + run_test!(vec![ + tng_server(&format!( + r#""key": {{ "source": "file", "path": "{key_path}" }}, "aead_suites": ["aes256_gcm"]"# + )) + .boxed(), + tng_client("").boxed(), + http_server().boxed(), + shell_continue( + "file-key-config", + format!( + r#" +hex=$({FETCH_KEY_CONFIG_HEX}) +case "$hex" in + *000400010002) echo "PASS: file-mode key advertises only AES-256-GCM" ;; + *) echo "FAIL: unexpected key config $hex"; exit 1 ;; +esac +"# + ), + ) + .boxed(), + http_client().boxed(), + ]) + .await?; + + assert_negotiated_aead(&logs.lock().unwrap(), "Aes256Gcm")?; + Ok(()) +} diff --git a/tng/src/config/egress.rs b/tng/src/config/egress.rs index 275336fc..d3d563cd 100644 --- a/tng/src/config/egress.rs +++ b/tng/src/config/egress.rs @@ -4,6 +4,7 @@ use serde::{Deserialize, Serialize}; use serde_with::{formats::PreferMany, serde_as, OneOrMany}; use super::mapping_rule::MappingDe; +use super::ohttp_aead::AeadSuites; use super::ra::RaArgsUnchecked; use super::UdpQuicArgs; use crate::config::egress_hook::EgressHookArgs; @@ -382,6 +383,29 @@ pub struct OHttpArgs { /// Default true. Set false to disable. #[serde(default = "default_true")] pub forward_client_ip: bool, + + /// OHTTP HPKE AEAD suites to advertise in this egress's key config and + /// accept when decapsulating requests. Legal values: `aes128_gcm`, + /// `aes256_gcm`, `chacha20_poly1305`. Order is the advertised order. + /// + /// The same list is enforced on every decapsulation, including requests + /// sealed under an older key config or one synced from a `peer_shared` + /// peer, so a peer whose policy differs cannot slip a disallowed suite + /// through. Unset advertises and accepts `aes128_gcm`, `aes256_gcm`, + /// `chacha20_poly1305`. An empty list, a duplicate entry, or an unknown + /// value is a config error. A rejected request returns HTTP 422, which + /// makes a TNG ingress refetch the key config. + #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub aead_suites: Option, +} + +impl OHttpArgs { + /// The suites that are both advertised and accepted: the configured + /// allowlist, or `DEFAULT_OHTTP_AEAD_SUITES` when unset. + pub(crate) fn resolved_aead_suites(&self) -> AeadSuites { + self.aead_suites.clone().unwrap_or_default() + } } /// Defines the strategy for obtaining the HPKE private key used in OHTTP decryption. diff --git a/tng/src/config/ingress.rs b/tng/src/config/ingress.rs index 2f4e15d0..4ac8de27 100644 --- a/tng/src/config/ingress.rs +++ b/tng/src/config/ingress.rs @@ -7,6 +7,7 @@ use crate::tunnel::access_log::IngressAccessMode; use super::mapping_rule::MappingDe; use super::match_rule::{HostMatchConfig, PortMatchConfig}; +use super::ohttp_aead::AeadSuites; use super::{ra::RaArgsUnchecked, Endpoint, UdpQuicArgs}; #[derive(Debug, Clone, Serialize, Deserialize)] @@ -483,6 +484,24 @@ pub struct OHttpArgs { #[serde(default)] #[serde(skip_serializing_if = "Vec::is_empty")] pub tls_ca_certs: Vec, + + /// OHTTP HPKE AEAD suites this ingress is willing to use, in preference + /// order. Legal values: `aes128_gcm`, `aes256_gcm`, `chacha20_poly1305`. + /// + /// After fetching the server's key configs, the ingress intersects each + /// config's advertised suites with this list, picks the first server config + /// (server order) with a non-empty intersection, then picks the first suite + /// in *this list's* order. This preference order is a deliberate deviation + /// from RFC 9458's literal "first advertised suite" rule. + /// + /// Unset means no filtering and no preference: the ingress keeps the + /// historical behavior of using the first server config's first advertised + /// suite. An empty list, a duplicate entry, or an unknown value is a config + /// error. If no server config intersects this list, the key fetch fails and + /// requests error out; the ingress never falls back to the default suites. + #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub aead_suites: Option, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] diff --git a/tng/src/config/mod.rs b/tng/src/config/mod.rs index 93abfee9..c1cb5da1 100644 --- a/tng/src/config/mod.rs +++ b/tng/src/config/mod.rs @@ -12,6 +12,7 @@ pub mod ingress; pub mod mapping_rule; pub mod match_rule; pub mod observability; +pub(crate) mod ohttp_aead; pub mod parse; pub mod ra; @@ -130,6 +131,7 @@ pub mod tests { }), tls: None, tls_ca_certs: vec![], + aead_suites: None, }), rats_tls: None, quic: None, @@ -182,6 +184,7 @@ pub mod tests { ]), }), forward_client_ip: true, + aead_suites: None, }), rats_tls: None, quic: None, @@ -252,6 +255,7 @@ pub mod tests { }), tls: None, tls_ca_certs: vec![], + aead_suites: None, }), rats_tls: None, quic: None, @@ -308,6 +312,7 @@ pub mod tests { ]), }), forward_client_ip: true, + aead_suites: None, }), rats_tls: None, quic: None, @@ -361,6 +366,7 @@ pub mod tests { response_headers: HeaderPassthroughSpec::default(), }), forward_client_ip: true, + aead_suites: None, }), rats_tls: None, quic: None, diff --git a/tng/src/config/ohttp_aead.rs b/tng/src/config/ohttp_aead.rs new file mode 100644 index 00000000..89566685 --- /dev/null +++ b/tng/src/config/ohttp_aead.rs @@ -0,0 +1,178 @@ +use serde::{Deserialize, Deserializer, Serialize}; + +/// A HPKE AEAD suite selectable for OHTTP by the `aead_suites` config field. +/// +/// The KEM is fixed to DHKEM(X25519, HKDF-SHA256) and the KDF to HKDF-SHA256, +/// so the AEAD is the only selectable dimension. The wire values are +/// case-sensitive and take no aliases. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AeadSuite { + Aes128Gcm, + Aes256Gcm, + // `rename_all = "snake_case"` would render this variant as + // `cha_cha20_poly1305`; the documented value is `chacha20_poly1305`. + #[serde(rename = "chacha20_poly1305")] + ChaCha20Poly1305, +} + +/// A non-empty, duplicate-free ordered list of AEAD suites. +/// +/// Both invariants are enforced during deserialization, so any value of this +/// type is a usable policy. Order is meaningful: it is the preference order +/// an ingress uses and the advertised order an egress publishes. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(transparent)] +pub struct AeadSuites(Vec); + +impl<'de> Deserialize<'de> for AeadSuites { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let aead_suites = Vec::::deserialize(deserializer)?; + + if aead_suites.is_empty() { + return Err(serde::de::Error::custom( + "aead_suites must not be empty; omit the field to use the default policy", + )); + } + + for (index, suite) in aead_suites.iter().enumerate() { + if aead_suites[..index].contains(suite) { + return Err(serde::de::Error::custom(format!( + "aead_suites contains duplicate value `{suite}`" + ))); + } + } + + Ok(AeadSuites(aead_suites)) + } +} + +impl Default for AeadSuites { + fn default() -> Self { + AeadSuites(DEFAULT_OHTTP_AEAD_SUITES.to_vec()) + } +} + +impl AeadSuites { + pub fn as_slice(&self) -> &[AeadSuite] { + &self.0 + } +} + +/// The AEAD suites an egress advertises and accepts when `aead_suites` is +/// unset. AES-128-GCM stays first (the client picks the first supported suite +/// from the advertised list, and AES-128-GCM is the hardware-accelerated one). +pub const DEFAULT_OHTTP_AEAD_SUITES: [AeadSuite; 3] = [ + AeadSuite::Aes128Gcm, + AeadSuite::Aes256Gcm, + AeadSuite::ChaCha20Poly1305, +]; + +impl AeadSuite { + pub const fn as_str(self) -> &'static str { + match self { + AeadSuite::Aes128Gcm => "aes128_gcm", + AeadSuite::Aes256Gcm => "aes256_gcm", + AeadSuite::ChaCha20Poly1305 => "chacha20_poly1305", + } + } + + pub(crate) fn to_ohttp_aead(self) -> ohttp::hpke::Aead { + match self { + AeadSuite::Aes128Gcm => ohttp::hpke::Aead::Aes128Gcm, + AeadSuite::Aes256Gcm => ohttp::hpke::Aead::Aes256Gcm, + AeadSuite::ChaCha20Poly1305 => ohttp::hpke::Aead::ChaCha20Poly1305, + } + } + + pub(crate) fn from_ohttp_aead(aead: ohttp::hpke::Aead) -> Self { + match aead { + ohttp::hpke::Aead::Aes128Gcm => AeadSuite::Aes128Gcm, + ohttp::hpke::Aead::Aes256Gcm => AeadSuite::Aes256Gcm, + ohttp::hpke::Aead::ChaCha20Poly1305 => AeadSuite::ChaCha20Poly1305, + } + } + + /// The full `(KDF, AEAD)` pair advertised in a key config. The KDF is + /// fixed because the config exposes no KDF choice. + pub(crate) fn to_symmetric_suite(self) -> ohttp::SymmetricSuite { + ohttp::SymmetricSuite::new(ohttp::hpke::Kdf::HkdfSha256, self.to_ohttp_aead()) + } +} + +impl std::fmt::Display for AeadSuite { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} + +#[cfg(test)] +mod tests { + use super::{AeadSuite, AeadSuites}; + + fn parse(text: &str) -> Result { + serde_json::from_str(text) + } + + #[test] + fn parses_a_valid_list_in_order() { + let suites = parse(r#"["aes256_gcm", "aes128_gcm", "chacha20_poly1305"]"#) + .expect("valid list must parse"); + assert_eq!( + suites.as_slice(), + &[ + AeadSuite::Aes256Gcm, + AeadSuite::Aes128Gcm, + AeadSuite::ChaCha20Poly1305, + ] + ); + } + + #[test] + fn rejects_an_empty_list() { + let error = parse("[]").expect_err("empty list must fail"); + assert_eq!( + error.to_string(), + "aead_suites must not be empty; omit the field to use the default policy" + ); + } + + #[test] + fn rejects_a_duplicate_value() { + let error = parse(r#"["aes256_gcm", "aes256_gcm"]"#).expect_err("duplicate must fail"); + assert_eq!( + error.to_string(), + "aead_suites contains duplicate value `aes256_gcm`" + ); + } + + #[test] + fn rejects_an_unknown_value() { + let error = parse(r#"["aes192_gcm"]"#).expect_err("unknown value must fail"); + assert!( + error.to_string().contains("unknown variant"), + "unexpected message: {error}" + ); + } + + #[test] + fn value_matching_is_case_sensitive() { + let error = parse(r#"["AES256_GCM"]"#).expect_err("uppercase must fail"); + assert!( + error.to_string().contains("unknown variant"), + "unexpected message: {error}" + ); + } + + #[test] + fn serializes_as_a_plain_array() { + let suites = AeadSuites(vec![AeadSuite::Aes128Gcm, AeadSuite::ChaCha20Poly1305]); + assert_eq!( + serde_json::to_string(&suites).expect("serialize"), + r#"["aes128_gcm","chacha20_poly1305"]"# + ); + } +} diff --git a/tng/src/config/parse.rs b/tng/src/config/parse.rs index cc66404f..074211d4 100644 --- a/tng/src/config/parse.rs +++ b/tng/src/config/parse.rs @@ -227,4 +227,68 @@ mod tests { "unexpected message: {error:#}" ); } + + fn ingress_with_aead_suites(aead_suites: &str) -> String { + format!( + r#"{{ + "add_ingress": [{{ + "mapping": {{ + "in": {{"port": 10001}}, + "out": {{"host": "127.0.0.1", "port": 20001}} + }}, + "ohttp": {{"aead_suites": {aead_suites}}}, + "no_ra": true + }}] + }}"# + ) + } + + fn egress_with_aead_suites(aead_suites: &str) -> String { + format!( + r#"{{ + "add_egress": [{{ + "mapping": {{ + "in": {{"host": "0.0.0.0", "port": 20001}}, + "out": {{"host": "127.0.0.1", "port": 30001}} + }}, + "ohttp": {{"aead_suites": {aead_suites}}}, + "no_ra": true + }}] + }}"# + ) + } + + #[test] + fn aead_suites_parse_on_both_sides_in_order() -> anyhow::Result<()> { + use crate::config::ohttp_aead::AeadSuite; + + let config = parse_config_str(&ingress_with_aead_suites( + r#"["aes256_gcm", "chacha20_poly1305"]"#, + ))?; + assert_eq!( + config.add_ingress[0] + .common + .ohttp + .as_ref() + .unwrap() + .aead_suites + .as_ref() + .map(|suites| suites.as_slice()), + Some([AeadSuite::Aes256Gcm, AeadSuite::ChaCha20Poly1305].as_slice()) + ); + + let config = parse_config_str(&egress_with_aead_suites(r#"["aes256_gcm"]"#))?; + assert_eq!( + config.add_egress[0] + .common + .ohttp + .as_ref() + .unwrap() + .aead_suites + .as_ref() + .map(|suites| suites.as_slice()), + Some([AeadSuite::Aes256Gcm].as_slice()) + ); + Ok(()) + } } diff --git a/tng/src/error.rs b/tng/src/error.rs index eca48f77..c5f79aaa 100644 --- a/tng/src/error.rs +++ b/tng/src/error.rs @@ -9,6 +9,7 @@ use serde::{Deserialize, Serialize}; use strum_macros::AsRefStr; use thiserror::Error; +use crate::config::ohttp_aead::AeadSuite; use crate::tunnel::ohttp::key_config::PublicKeyData; /// Custom error type @@ -180,6 +181,20 @@ pub enum TngError { #[error("Status path not found")] StatusPathNotFound, + + /// The request header names an AEAD suite the egress policy does not + /// allow. The variant carries no details: they go to the log, never into + /// the HTTP response body. + #[error("OHTTP request uses an AEAD suite that the egress policy does not allow")] + OHttpSuiteNotAllowed, + + #[error( + "No OHTTP AEAD suite is acceptable to both sides: server advertises {server:?}, ingress allows {ingress:?}" + )] + OHttpNoAcceptableSuite { + server: Vec, + ingress: Vec, + }, } /// Error response structure @@ -274,6 +289,11 @@ impl IntoResponse for TngError { | TngError::TngEvidenceDecodeError(..) | TngError::EvidenceVerifyError(..) => StatusCode::INTERNAL_SERVER_ERROR, + // A disallowed AEAD suite must read as "refetch the key config": + // the ingress only invalidates its cache on 422. + TngError::OHttpSuiteNotAllowed => StatusCode::UNPROCESSABLE_ENTITY, + TngError::OHttpNoAcceptableSuite { .. } => StatusCode::INTERNAL_SERVER_ERROR, + // See the RFC 9458 section 6.4. Key Management // The client should request new KeyConfig from server when got UNPROCESSABLE_ENTITY from server. TngError::ServerKeyConfigNotFound { .. } => StatusCode::UNPROCESSABLE_ENTITY, diff --git a/tng/src/tools/key_sync_daemon.rs b/tng/src/tools/key_sync_daemon.rs index 3319d61d..78f1e082 100644 --- a/tng/src/tools/key_sync_daemon.rs +++ b/tng/src/tools/key_sync_daemon.rs @@ -26,6 +26,7 @@ //! key set itself keys by. use crate::config::egress::PeerSharedArgs; +use crate::config::ohttp_aead::DEFAULT_OHTTP_AEAD_SUITES; use crate::tunnel::egress::protocol::ohttp::security::key_manager::peer_shared::PeerSharedKeyManager; use crate::tunnel::egress::protocol::ohttp::security::key_manager::{KeyInfo, KeyStatus}; use crate::tunnel::ohttp::key_config::PublicKeyData; @@ -75,7 +76,8 @@ impl KeySyncDaemon { } let rotation_interval = args.rotation_interval; - let mgr = PeerSharedKeyManager::new(runtime, args).await?; + let mgr = + PeerSharedKeyManager::new(runtime, args, DEFAULT_OHTTP_AEAD_SUITES.to_vec()).await?; let daemon = Self { mgr, out_dir, @@ -407,17 +409,31 @@ mod tests { /// `new`/`insert_key_from_peer` constructors (no serf needed). fn make_test_keyset() -> ClusterKeySet { let now = SystemTime::get(); - let active = KeyInfo::generate(1, KeyStatus::Active, now, 300).unwrap(); - let pending = - KeyInfo::generate(2, KeyStatus::Pending, now + Duration::from_secs(100), 300).unwrap(); - let stale = - KeyInfo::generate(3, KeyStatus::Stale, now - Duration::from_secs(1000), 300).unwrap(); + let active = + KeyInfo::generate(1, KeyStatus::Active, now, 300, &DEFAULT_OHTTP_AEAD_SUITES).unwrap(); + let pending = KeyInfo::generate( + 2, + KeyStatus::Pending, + now + Duration::from_secs(100), + 300, + &DEFAULT_OHTTP_AEAD_SUITES, + ) + .unwrap(); + let stale = KeyInfo::generate( + 3, + KeyStatus::Stale, + now - Duration::from_secs(1000), + 300, + &DEFAULT_OHTTP_AEAD_SUITES, + ) + .unwrap(); let pk_active = active.key_config.public_key().unwrap(); let pk_pending = pending.key_config.public_key().unwrap(); let pk_stale = stale.key_config.public_key().unwrap(); - let mut cks = ClusterKeySet::new(pk_active, active, 300); + let mut cks = + ClusterKeySet::new(pk_active, active, 300, DEFAULT_OHTTP_AEAD_SUITES.to_vec()); assert!(cks.insert_key_from_peer(pk_pending, pending)); assert!(cks.insert_key_from_peer(pk_stale, stale)); cks @@ -513,8 +529,14 @@ mod tests { // First snapshot: one active key in current. let cks_before = { let now = SystemTime::get(); - let k = KeyInfo::generate(1, KeyStatus::Active, now, 300).unwrap(); - ClusterKeySet::new(k.key_config.public_key().unwrap(), k, 300) + let k = KeyInfo::generate(1, KeyStatus::Active, now, 300, &DEFAULT_OHTTP_AEAD_SUITES) + .unwrap(); + ClusterKeySet::new( + k.key_config.public_key().unwrap(), + k, + 300, + DEFAULT_OHTTP_AEAD_SUITES.to_vec(), + ) }; export_keyset(dir.path(), &cks_before).await.unwrap(); let before = list_key_stems(&dir.path().join("current")); @@ -523,8 +545,14 @@ mod tests { // Second snapshot: a different active key (old one is gone). let cks_after = { let now = SystemTime::get(); - let k = KeyInfo::generate(2, KeyStatus::Active, now, 300).unwrap(); - ClusterKeySet::new(k.key_config.public_key().unwrap(), k, 300) + let k = KeyInfo::generate(2, KeyStatus::Active, now, 300, &DEFAULT_OHTTP_AEAD_SUITES) + .unwrap(); + ClusterKeySet::new( + k.key_config.public_key().unwrap(), + k, + 300, + DEFAULT_OHTTP_AEAD_SUITES.to_vec(), + ) }; export_keyset(dir.path(), &cks_after).await.unwrap(); let after = list_key_stems(&dir.path().join("current")); @@ -550,10 +578,22 @@ mod tests { // Re-export with no stale key -> expired/ must vanish. let now = SystemTime::get(); - let active = KeyInfo::generate(1, KeyStatus::Active, now, 300).unwrap(); - let pending = - KeyInfo::generate(2, KeyStatus::Pending, now + Duration::from_secs(100), 300).unwrap(); - let mut cks2 = ClusterKeySet::new(active.key_config.public_key().unwrap(), active, 300); + let active = + KeyInfo::generate(1, KeyStatus::Active, now, 300, &DEFAULT_OHTTP_AEAD_SUITES).unwrap(); + let pending = KeyInfo::generate( + 2, + KeyStatus::Pending, + now + Duration::from_secs(100), + 300, + &DEFAULT_OHTTP_AEAD_SUITES, + ) + .unwrap(); + let mut cks2 = ClusterKeySet::new( + active.key_config.public_key().unwrap(), + active, + 300, + DEFAULT_OHTTP_AEAD_SUITES.to_vec(), + ); cks2.insert_key_from_peer(pending.key_config.public_key().unwrap(), pending); export_keyset(dir.path(), &cks2).await.unwrap(); assert!( @@ -862,6 +902,7 @@ mod tests { KeyStatus::Pending, now + Duration::from_secs(100), 600, + &DEFAULT_OHTTP_AEAD_SUITES, ) .context("generate pending key")?; let pk = pending diff --git a/tng/src/tunnel/egress/protocol/ohttp/security/api/mod.rs b/tng/src/tunnel/egress/protocol/ohttp/security/api/mod.rs index 56cd801b..ba7c22c9 100644 --- a/tng/src/tunnel/egress/protocol/ohttp/security/api/mod.rs +++ b/tng/src/tunnel/egress/protocol/ohttp/security/api/mod.rs @@ -10,6 +10,7 @@ use async_trait::async_trait; use tokio::sync::RwLock; use crate::config::egress::KeyArgs; +use crate::config::ohttp_aead::AeadSuite; use crate::error::TngError; use crate::status::{StatusProvider, StatusQueryResult}; use crate::tunnel::egress::protocol::ohttp::security::key_manager::file::FileBasedKeyManager; @@ -58,6 +59,10 @@ pub struct OhttpServerApi { passthrough_response_headers: Arc, /// Whether to inject X-Real-IP / X-Forwarded-For from the direct peer. forward_client_ip: bool, + /// AEAD suites accepted when decapsulating a request. Checked against the + /// suite in every request header, including requests sealed under an old + /// key config or one synced from a peer. + aead_suites: Arc<[AeadSuite]>, } impl OhttpServerApi { @@ -71,18 +76,23 @@ impl OhttpServerApi { passthrough_request_headers: Arc, passthrough_response_headers: Arc, forward_client_ip: bool, + aead_suites: Vec, ) -> Result { // Create key manager based on configuration let key_manager: Arc = match key { - KeyArgs::SelfGenerated { rotation_interval } => Arc::new( - SelfGeneratedKeyManager::new_with_auto_refresh(runtime, rotation_interval)?, - ), - KeyArgs::File { path } => { - Arc::new(FileBasedKeyManager::new(runtime, path.into()).await?) + KeyArgs::SelfGenerated { rotation_interval } => { + Arc::new(SelfGeneratedKeyManager::new_with_auto_refresh( + runtime, + rotation_interval, + aead_suites.clone(), + )?) } - KeyArgs::PeerShared(peer_shared_args) => { - Arc::new(PeerSharedKeyManager::new(runtime, peer_shared_args).await?) + KeyArgs::File { path } => { + Arc::new(FileBasedKeyManager::new(runtime, path.into(), aead_suites.clone()).await?) } + KeyArgs::PeerShared(peer_shared_args) => Arc::new( + PeerSharedKeyManager::new(runtime, peer_shared_args, aead_suites.clone()).await?, + ), }; Ok(OhttpServerApi { @@ -93,6 +103,7 @@ impl OhttpServerApi { passthrough_request_headers, passthrough_response_headers, forward_client_ip, + aead_suites: aead_suites.into(), }) } } diff --git a/tng/src/tunnel/egress/protocol/ohttp/security/api/tunnel.rs b/tng/src/tunnel/egress/protocol/ohttp/security/api/tunnel.rs index 8c4d21c8..5438b643 100644 --- a/tng/src/tunnel/egress/protocol/ohttp/security/api/tunnel.rs +++ b/tng/src/tunnel/egress/protocol/ohttp/security/api/tunnel.rs @@ -12,6 +12,7 @@ use crate::tunnel::provider::{ProviderType, TngToken}; use tokio::io::AsyncReadExt; use tokio_util::compat::TokioAsyncReadCompatExt as _; +use crate::config::ohttp_aead::AeadSuite; use crate::error::TngError; use crate::tunnel::egress::protocol::ohttp::security::api::OhttpServerApi; use crate::tunnel::egress::protocol::ohttp::security::context::TngStreamContext; @@ -78,8 +79,9 @@ impl OhttpServerApi { // Decapsulate stage: open the sealed inner request and open the // response seal bound to this request's HPKE context. - let (mut request, response_seal, sealed_sink) = - self.decapsulate_request(reader.compat(), metadata).await?; + let (mut request, response_seal, sealed_sink) = self + .decapsulate_request(reader.compat(), metadata, context.peer_addr) + .await?; // Rewrite stage: copy the configured outer request headers onto the // inner request and annotate it with the relay peer's client IP. @@ -148,6 +150,7 @@ impl OhttpServerApi { &self, sealed_body: S, metadata: Metadata, + peer_addr: Option, ) -> Result< ( http::Request< @@ -171,9 +174,25 @@ impl OhttpServerApi { e })?; + // The suite check must run before either `into_server_request` variant: + // it is the single enforcement point for both Base and Auth mode, and + // it also covers requests sealed under an old key config or one synced + // from a peer (those are never filtered on the way in). + let suite = header_decoded.symmetric_suite().map_err(TngError::from)?; + if !aead_suite_allowed(&self.aead_suites, suite) { + tracing::warn!( + aead = ?suite.aead(), + key_id = header_decoded.key_id(), + peer = ?peer_addr, + "Rejected OHTTP request: AEAD suite is not allowed by the egress policy" + ); + return Err(TngError::OHttpSuiteNotAllowed); + } + tracing::debug!( ?metadata, key_id = header_decoded.key_id(), + aead = ?suite.aead(), "Received OHTTP request" ); @@ -307,6 +326,14 @@ impl OhttpServerApi { } } +/// Whether `suite` is in the egress allowlist. Only the AEAD is selectable: +/// HKDF-SHA256 is the sole KDF TNG advertises, so a header naming any other +/// KDF is never allowed. +fn aead_suite_allowed(allowed: &[AeadSuite], suite: ohttp::SymmetricSuite) -> bool { + suite.kdf() == ohttp::hpke::Kdf::HkdfSha256 + && allowed.iter().any(|a| a.to_ohttp_aead() == suite.aead()) +} + /// Front gate: the outer POST must carry the streaming ohttp request /// content type, matching what the ingress `post_sealed_request` sends. fn check_request_content_type(headers: &http::HeaderMap) -> Result<(), TngError> { @@ -459,3 +486,94 @@ mod forwarded_client_ip_tests { assert_eq!(h.get("x-real-ip").unwrap().to_str().unwrap(), "203.0.113.7"); } } + +/// The egress allowlist is checked against the suite carried in the request +/// header, which is identical for Base and Auth mode. Both client +/// constructors are exercised so a regression in either one is caught. +#[cfg(test)] +mod aead_suite_policy_tests { + use super::aead_suite_allowed; + use crate::config::ohttp_aead::AeadSuite; + use hpke::{kem::X25519HkdfSha256, Kem as _, Serializable as _}; + use ohttp::hpke::{Aead, Kdf, Kem}; + use ohttp::{ClientRequest, KeyConfig, SymmetricSuite}; + use rand::SeedableRng as _; + use rand_chacha::ChaCha12Rng; + + const KEY_ID: u8 = 7; + + fn suite(aead: Aead) -> SymmetricSuite { + SymmetricSuite::new(Kdf::HkdfSha256, aead) + } + + /// A public key config a client can use, built from a server key config. + fn client_view(aead: Aead) -> KeyConfig { + let server = KeyConfig::new(KEY_ID, Kem::X25519Sha256, vec![suite(aead)]) + .expect("server key config"); + let encoded = server.encode().expect("encode key config"); + KeyConfig::decode(&encoded).expect("decode public key config") + } + + /// Decode the 7-byte header of an encapsulated request. + async fn header_suite( + request: ClientRequest, + ) -> Result<(ohttp::SymmetricSuite, u8), ohttp::Error> { + let (enc_request, _response) = request.encapsulate(b"").expect("encapsulate request"); + let header = ohttp::Server::decode_header(futures::io::Cursor::new(enc_request)).await?; + Ok((header.symmetric_suite()?, header.key_id())) + } + + #[tokio::test] + async fn base_mode_allowed_suite_passes() { + let config = client_view(Aead::Aes256Gcm); + let request = ClientRequest::from_config_with_suite(&config, suite(Aead::Aes256Gcm)) + .expect("build request"); + let (suite, key_id) = header_suite(request).await.expect("decode header"); + + assert!(aead_suite_allowed(&[AeadSuite::Aes256Gcm], suite)); + assert_eq!(key_id, KEY_ID); + } + + #[tokio::test] + async fn base_mode_disallowed_suite_is_rejected() { + let config = client_view(Aead::Aes256Gcm); + let request = ClientRequest::from_config_with_suite(&config, suite(Aead::Aes256Gcm)) + .expect("build request"); + let (suite, _key_id) = header_suite(request).await.expect("decode header"); + + assert!(!aead_suite_allowed(&[AeadSuite::ChaCha20Poly1305], suite)); + assert!(!aead_suite_allowed(&[], suite)); + } + + #[tokio::test] + async fn auth_mode_allowed_suite_passes() { + let config = client_view(Aead::ChaCha20Poly1305); + let mut rng = ChaCha12Rng::from_os_rng(); + let (client_sk, _client_pk) = X25519HkdfSha256::gen_keypair(&mut rng); + let request = ClientRequest::from_config_with_client_key_bytes_and_suite( + &config, + suite(Aead::ChaCha20Poly1305), + client_sk.to_bytes().as_ref(), + ) + .expect("build request"); + let (suite, _key_id) = header_suite(request).await.expect("decode header"); + + assert!(aead_suite_allowed(&[AeadSuite::ChaCha20Poly1305], suite)); + } + + #[tokio::test] + async fn auth_mode_disallowed_suite_is_rejected() { + let config = client_view(Aead::Aes128Gcm); + let mut rng = ChaCha12Rng::from_os_rng(); + let (client_sk, _client_pk) = X25519HkdfSha256::gen_keypair(&mut rng); + let request = ClientRequest::from_config_with_client_key_bytes_and_suite( + &config, + suite(Aead::Aes128Gcm), + client_sk.to_bytes().as_ref(), + ) + .expect("build request"); + let (suite, _key_id) = header_suite(request).await.expect("decode header"); + + assert!(!aead_suite_allowed(&[AeadSuite::Aes256Gcm], suite)); + } +} diff --git a/tng/src/tunnel/egress/protocol/ohttp/security/key_manager/file.rs b/tng/src/tunnel/egress/protocol/ohttp/security/key_manager/file.rs index c4187398..ee87d9e2 100644 --- a/tng/src/tunnel/egress/protocol/ohttp/security/key_manager/file.rs +++ b/tng/src/tunnel/egress/protocol/ohttp/security/key_manager/file.rs @@ -8,6 +8,7 @@ use async_trait::async_trait; use serde::Serialize; use tokio::sync::RwLock; +use crate::config::ohttp_aead::AeadSuite; use crate::status::{StatusProvider, StatusQueryResult}; use crate::tunnel::egress::protocol::ohttp::security::key_manager::{ KeyInfo, KeyManager, KeyStatus, @@ -71,6 +72,9 @@ struct FileBasedKeyManagerInner { /// Protected by a write lock during updates; readers can concurrently access the current key. /// When the file is modified, a new `KeyInfo` replaces the old one atomically. key: RwLock<(PublicKeyData, KeyInfo)>, + + /// AEAD suites advertised in the loaded key config, in order. + aead_suites: Vec, } impl FileBasedKeyManager { /// Create a new FileBasedKeyManager and start watching the file. @@ -93,6 +97,7 @@ impl FileBasedKeyManager { /// /// * `runtime` - The Tokio runtime used to spawn the background watch task. /// * `path` - Path to the PEM-encoded PKCS#8 private key file. + /// * `aead_suites` - The AEAD suites to advertise, in order. /// /// # Returns /// @@ -105,11 +110,16 @@ impl FileBasedKeyManager { /// - `TngError::LoadPrivateKeyFailed` if the initial PEM file cannot be read or parsed. /// - `TngError::WatchFileFailed` if the file watcher cannot be initialized. /// - pub async fn new(runtime: TokioRuntime, path: PathBuf) -> Result { - let key_info = Self::load_key_from_pem(&path).await?; + pub async fn new( + runtime: TokioRuntime, + path: PathBuf, + aead_suites: Vec, + ) -> Result { + let key_info = Self::load_key_from_pem(&path, &aead_suites).await?; let inner = Arc::new(FileBasedKeyManagerInner { key: RwLock::new((key_info.key_config.public_key()?, key_info)), + aead_suites, }); let inner_clone = inner.clone(); @@ -124,7 +134,7 @@ impl FileBasedKeyManager { Ok(()) => { tracing::info!(?path, "Key file changed, attempting to reload OHTTP key"); - match Self::load_key_from_pem(&path).await { + match Self::load_key_from_pem(&path, &inner_clone.aead_suites).await { Ok(new_key_info) => { let mut write = inner_clone.key.write().await; let public_key = match new_key_info.key_config.public_key() { @@ -163,12 +173,15 @@ impl FileBasedKeyManager { } /// Loads and parses a PEM-encoded PKCS#8 private key into a usable `KeyInfo` structure. - async fn load_key_from_pem(path: &Path) -> Result { + async fn load_key_from_pem( + path: &Path, + aead_suites: &[AeadSuite], + ) -> Result { let pem_data = tokio::fs::read_to_string(path) .await .map_err(|e| TngError::LoadPrivateKeyFailed(path.into(), anyhow::Error::from(e)))?; - KeyInfo::from_pkcs8_pem(&pem_data) + KeyInfo::from_pkcs8_pem(&pem_data, aead_suites) } } diff --git a/tng/src/tunnel/egress/protocol/ohttp/security/key_manager/mod.rs b/tng/src/tunnel/egress/protocol/ohttp/security/key_manager/mod.rs index 3af8f80e..3f117542 100644 --- a/tng/src/tunnel/egress/protocol/ohttp/security/key_manager/mod.rs +++ b/tng/src/tunnel/egress/protocol/ohttp/security/key_manager/mod.rs @@ -5,6 +5,7 @@ //! and requires [`StatusProvider`] as a supertrait so every key manager can respond //! to `/status/egress/` queries with its current key state. +use crate::config::ohttp_aead::AeadSuite; use crate::status::StatusProvider; use crate::tunnel::ohttp::key_config::PublicKeyData; use crate::{error::TngError, tunnel::ohttp::key_config::KeyConfigExtend}; @@ -57,11 +58,13 @@ impl KeyInfo { /// * `status` - The initial status (Pending, Active, or Stale) /// * `actived_at` - The time when the key will be activated /// * `rotation_interval` - The rotation interval in seconds + /// * `aead_suites` - The AEAD suites to advertise, in order pub fn generate( key_id: u8, status: KeyStatus, actived_at: SystemTime, rotation_interval: u64, + aead_suites: &[AeadSuite], ) -> Result { let stale_at = actived_at + std::time::Duration::from_secs(rotation_interval); let expire_at = stale_at + std::time::Duration::from_secs(rotation_interval); @@ -69,25 +72,10 @@ impl KeyInfo { let key_config = ohttp::KeyConfig::new( key_id, ohttp::hpke::Kem::X25519Sha256, - vec![ - // The client picks the first suite it supports from this list. - // AES-128-GCM runs on AES-NI/VAES + CLMUL and seals 64 KiB - // responses several times faster than the software AVX2 - // ChaCha20Poly1305 backend, so it goes first; ChaCha stays - // advertised for clients that only implement it. - ohttp::SymmetricSuite::new( - ohttp::hpke::Kdf::HkdfSha256, - ohttp::hpke::Aead::Aes128Gcm, - ), - ohttp::SymmetricSuite::new( - ohttp::hpke::Kdf::HkdfSha256, - ohttp::hpke::Aead::Aes256Gcm, - ), - ohttp::SymmetricSuite::new( - ohttp::hpke::Kdf::HkdfSha256, - ohttp::hpke::Aead::ChaCha20Poly1305, - ), - ], + aead_suites + .iter() + .map(|suite| suite.to_symmetric_suite()) + .collect(), ) .map_err(TngError::from)?; @@ -111,20 +99,17 @@ impl KeyInfo { /// /// # Arguments /// * `pem_data` - The PEM-encoded PKCS#8 private key data - pub fn from_pkcs8_pem(pem_data: &str) -> Result { + /// * `aead_suites` - The AEAD suites to advertise, in order + pub fn from_pkcs8_pem(pem_data: &str, aead_suites: &[AeadSuite]) -> Result { use ohttp::hpke; let key_config = ohttp::KeyConfig::new_from_pkcs8_pem( 0, hpke::Kem::X25519Sha256, - vec![ - // Suite order matches `generate()`: the client selects the - // first suite it supports, and AES-128-GCM is the - // hardware-accelerated one. - ohttp::SymmetricSuite::new(hpke::Kdf::HkdfSha256, hpke::Aead::Aes128Gcm), - ohttp::SymmetricSuite::new(hpke::Kdf::HkdfSha256, hpke::Aead::Aes256Gcm), - ohttp::SymmetricSuite::new(hpke::Kdf::HkdfSha256, hpke::Aead::ChaCha20Poly1305), - ], + aead_suites + .iter() + .map(|suite| suite.to_symmetric_suite()) + .collect(), pem_data, ) .map_err(TngError::from)?; @@ -197,6 +182,7 @@ pub trait KeyManager: StatusProvider + Send + Sync { #[cfg(test)] mod tests { use super::*; + use crate::config::ohttp_aead::DEFAULT_OHTTP_AEAD_SUITES; use web_time_compat::Duration; fn make_test_key_info(key_id: u8) -> KeyInfo { @@ -227,21 +213,59 @@ mod tests { assert!(debug.contains("expire_at")); } - /// The client selects `symmetric[0]`, so the advertised order is the - /// negotiated suite. AES-128-GCM must come first; AES-256-GCM is stripped - /// by the rust-hpke backend, leaving AES-128-GCM then ChaCha20Poly1305. + /// The default policy advertises all three suites, AES-128-GCM first so + /// the historical negotiation stays the same. #[test] - fn generated_key_config_advertises_aes128_gcm_first() { - let info = KeyInfo::generate(7, KeyStatus::Active, SystemTime::get(), 300) - .expect("generate key info"); + fn generated_key_config_advertises_default_suites() { + let info = KeyInfo::generate( + 7, + KeyStatus::Active, + SystemTime::get(), + 300, + &DEFAULT_OHTTP_AEAD_SUITES, + ) + .expect("generate key info"); let suites = info.key_config.symmetric(); - assert!(!suites.is_empty()); - assert_eq!(suites[0].aead(), ohttp::hpke::Aead::Aes128Gcm); - assert!( + assert_eq!( suites .iter() - .any(|s| s.aead() == ohttp::hpke::Aead::ChaCha20Poly1305), - "ChaCha20Poly1305 must stay advertised for old clients" + .map(|s| s.aead()) + .collect::>(), + vec![ + ohttp::hpke::Aead::Aes128Gcm, + ohttp::hpke::Aead::Aes256Gcm, + ohttp::hpke::Aead::ChaCha20Poly1305, + ] ); } + + /// A configured policy is the only thing advertised. + #[test] + fn generated_key_config_advertises_only_configured_suites() { + let info = KeyInfo::generate( + 7, + KeyStatus::Active, + SystemTime::get(), + 300, + &[AeadSuite::Aes256Gcm], + ) + .expect("generate key info"); + let suites = info.key_config.symmetric(); + assert_eq!(suites.len(), 1); + assert_eq!(suites[0].aead(), ohttp::hpke::Aead::Aes256Gcm); + } + + /// Loading a PEM file applies the same policy as generation. + #[test] + fn file_key_config_advertises_only_configured_suites() { + const PEM: &str = "-----BEGIN PRIVATE KEY----- +MC4CAQAwBQYDK2VuBCIEIOixlJE0Ykdc4ePwmaf2LLAea8Lfkfb+SARsKYmCBRpR +-----END PRIVATE KEY-----"; + + let info = + KeyInfo::from_pkcs8_pem(PEM, &[AeadSuite::Aes256Gcm]).expect("load key info from pem"); + let suites = info.key_config.symmetric(); + assert_eq!(suites.len(), 1); + assert_eq!(suites[0].aead(), ohttp::hpke::Aead::Aes256Gcm); + } } diff --git a/tng/src/tunnel/egress/protocol/ohttp/security/key_manager/peer_shared/cluster_key_set.rs b/tng/src/tunnel/egress/protocol/ohttp/security/key_manager/peer_shared/cluster_key_set.rs index 0bed643e..1221b2a6 100644 --- a/tng/src/tunnel/egress/protocol/ohttp/security/key_manager/peer_shared/cluster_key_set.rs +++ b/tng/src/tunnel/egress/protocol/ohttp/security/key_manager/peer_shared/cluster_key_set.rs @@ -1,3 +1,4 @@ +use crate::config::ohttp_aead::AeadSuite; use crate::error::TngError; use crate::tunnel::egress::protocol::ohttp::security::key_manager::{KeyInfo, KeyStatus}; use crate::tunnel::ohttp::key_config::{KeyConfigExtend, PublicKeyData}; @@ -19,6 +20,12 @@ pub struct ClusterKeySet { rotation_interval: u64, /// Notify key watcher to check immediately when key set changes notify: Option>, + /// AEAD suites advertised by keys this node generates, in order. + /// + /// A key set decoded from a peer starts empty and has the local list + /// attached by the preboot phase; the suites of synced key configs are + /// never rewritten. + aead_suites: Vec, } impl ClusterKeySet { @@ -26,16 +33,28 @@ impl ClusterKeySet { /// /// Note that there must be at least one active key. /// This constructor is used during Bootstrap when the first active key is created. - pub fn new(public_key: PublicKeyData, active_key: KeyInfo, rotation_interval: u64) -> Self { + pub fn new( + public_key: PublicKeyData, + active_key: KeyInfo, + rotation_interval: u64, + aead_suites: Vec, + ) -> Self { let mut keys = HashMap::new(); keys.insert(public_key, active_key); Self { keys, rotation_interval, notify: None, + aead_suites, } } + /// Attach the local AEAD suite policy after decoding a key set from a + /// peer. The policy is local: synced key configs keep their own suites. + pub fn set_aead_suites(&mut self, aead_suites: Vec) { + self.aead_suites = aead_suites; + } + /// Set the notify handle for key watcher. pub fn set_notify(&mut self, notify: Arc) { self.notify = Some(notify); @@ -246,6 +265,7 @@ impl ClusterKeySet { KeyStatus::Pending, actived_at, self.rotation_interval, + &self.aead_suites, ) .map_err(|e| anyhow::anyhow!("Failed to generate pending key: {}", e))?; @@ -409,6 +429,9 @@ impl TryFrom for super::cluster_key_set: keys, rotation_interval: value.rotation_interval, notify: None, + // The peer's policy is not ours to adopt; local generation uses + // the policy attached by the preboot phase before any key is made. + aead_suites: Vec::new(), }) } } @@ -416,11 +439,12 @@ impl TryFrom for super::cluster_key_set: #[cfg(test)] mod tests { use super::*; + use crate::config::ohttp_aead::DEFAULT_OHTTP_AEAD_SUITES; use crate::tunnel::egress::protocol::ohttp::security::key_manager::KeyStatus; fn create_test_key(key_id: u8, status: KeyStatus, actived_at_offset: u64) -> KeyInfo { let actived_at = SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(actived_at_offset); - KeyInfo::generate(key_id, status, actived_at, 300).unwrap() + KeyInfo::generate(key_id, status, actived_at, 300, &DEFAULT_OHTTP_AEAD_SUITES).unwrap() } #[test] @@ -428,7 +452,12 @@ mod tests { let key = create_test_key(1, KeyStatus::Active, 1000); let public_key = key.key_config.public_key().unwrap(); - let mut cks = ClusterKeySet::new(public_key.clone(), key, 300); + let mut cks = ClusterKeySet::new( + public_key.clone(), + key, + 300, + DEFAULT_OHTTP_AEAD_SUITES.to_vec(), + ); let key2 = create_test_key(2, KeyStatus::Active, 2000); cks.keys.insert(key2.key_config.public_key().unwrap(), key2); @@ -442,7 +471,12 @@ mod tests { let key = create_test_key(1, KeyStatus::Active, 1000); let public_key = key.key_config.public_key().unwrap(); - let cks = ClusterKeySet::new(public_key.clone(), key, 300); + let cks = ClusterKeySet::new( + public_key.clone(), + key, + 300, + DEFAULT_OHTTP_AEAD_SUITES.to_vec(), + ); let visible = cks.get_client_visible_key().unwrap(); assert_eq!(visible.key_config.public_key().unwrap(), public_key); @@ -469,8 +503,12 @@ mod tests { expire_at: now + std::time::Duration::from_secs(1000), }; - let mut cks = - ClusterKeySet::new(active_key.key_config.public_key().unwrap(), active_key, 300); + let mut cks = ClusterKeySet::new( + active_key.key_config.public_key().unwrap(), + active_key, + 300, + DEFAULT_OHTTP_AEAD_SUITES.to_vec(), + ); // Create a pending key that is expired let expired_pending = KeyInfo { @@ -506,7 +544,12 @@ mod tests { let key = create_test_key(1, KeyStatus::Active, 1000); let public_key = key.key_config.public_key().unwrap(); - let mut cks = ClusterKeySet::new(public_key.clone(), key, 300); + let mut cks = ClusterKeySet::new( + public_key.clone(), + key, + 300, + DEFAULT_OHTTP_AEAD_SUITES.to_vec(), + ); // Transition the only active key to stale if let Some(k) = cks.keys.get_mut(&public_key) { @@ -560,7 +603,8 @@ mod tests { let pk1 = key1.key_config.public_key().unwrap(); let pk2 = key2.key_config.public_key().unwrap(); - let mut cks = ClusterKeySet::new(pk1.clone(), key1, 300); + let mut cks = + ClusterKeySet::new(pk1.clone(), key1, 300, DEFAULT_OHTTP_AEAD_SUITES.to_vec()); cks.keys.insert(pk2.clone(), key2); // Should return key2 (later expire_at) @@ -621,7 +665,12 @@ mod tests { }; let active_pk = active_key.key_config.public_key().unwrap(); - let mut cks = ClusterKeySet::new(active_pk.clone(), active_key, 300); + let mut cks = ClusterKeySet::new( + active_pk.clone(), + active_key, + 300, + DEFAULT_OHTTP_AEAD_SUITES.to_vec(), + ); cks.keys .insert(pending_key.key_config.public_key().unwrap(), pending_key); cks.keys @@ -637,11 +686,12 @@ mod tests { fn test_merge_disjoint_keys() { let key1 = create_test_key(1, KeyStatus::Active, 1000); let pk1 = key1.key_config.public_key().unwrap(); - let mut cks_a = ClusterKeySet::new(pk1.clone(), key1, 300); + let mut cks_a = + ClusterKeySet::new(pk1.clone(), key1, 300, DEFAULT_OHTTP_AEAD_SUITES.to_vec()); let key2 = create_test_key(2, KeyStatus::Active, 2000); let pk2 = key2.key_config.public_key().unwrap(); - let cks_b = ClusterKeySet::new(pk2, key2, 300); + let cks_b = ClusterKeySet::new(pk2, key2, 300, DEFAULT_OHTTP_AEAD_SUITES.to_vec()); cks_a.merge(cks_b); @@ -694,8 +744,18 @@ mod tests { let pk1 = key1_old.key_config.public_key().unwrap(); // Local has stale version, remote has active version - let mut cks_local = ClusterKeySet::new(pk1.clone(), key1_old, 300); - let mut cks_remote = ClusterKeySet::new(pk1.clone(), key1_new, 300); + let mut cks_local = ClusterKeySet::new( + pk1.clone(), + key1_old, + 300, + DEFAULT_OHTTP_AEAD_SUITES.to_vec(), + ); + let mut cks_remote = ClusterKeySet::new( + pk1.clone(), + key1_new, + 300, + DEFAULT_OHTTP_AEAD_SUITES.to_vec(), + ); // Add a second key to remote to verify it still gets merged let key2 = create_test_key(2, KeyStatus::Active, 2000); @@ -720,11 +780,13 @@ mod tests { keys: HashMap::new(), rotation_interval: 300, notify: None, + aead_suites: DEFAULT_OHTTP_AEAD_SUITES.to_vec(), }; let cks_b = ClusterKeySet { keys: HashMap::new(), rotation_interval: 300, notify: None, + aead_suites: DEFAULT_OHTTP_AEAD_SUITES.to_vec(), }; cks_a.merge(cks_b); @@ -737,6 +799,7 @@ mod tests { keys: HashMap::new(), rotation_interval: 300, notify: None, + aead_suites: DEFAULT_OHTTP_AEAD_SUITES.to_vec(), }; assert!(cks.next_deadline().is_none()); @@ -798,7 +861,12 @@ mod tests { let active_pk = active_key.key_config.public_key().unwrap(); let stale_pk = stale_key_info.key_config.public_key().unwrap(); - let mut cks = ClusterKeySet::new(pending_pk, pending_key, 300); + let mut cks = ClusterKeySet::new( + pending_pk, + pending_key, + 300, + DEFAULT_OHTTP_AEAD_SUITES.to_vec(), + ); cks.keys.insert(active_pk, active_key); cks.keys.insert(stale_pk, stale_key_info); @@ -815,7 +883,12 @@ mod tests { let key = create_test_key(1, KeyStatus::Active, 1000); let public_key = key.key_config.public_key().unwrap(); - let mut cks = ClusterKeySet::new(public_key.clone(), key, 300); + let mut cks = ClusterKeySet::new( + public_key.clone(), + key, + 300, + DEFAULT_OHTTP_AEAD_SUITES.to_vec(), + ); // First call should generate a pending key let result = cks.generate_pending_key_if_none(); @@ -837,6 +910,7 @@ mod tests { keys: HashMap::new(), rotation_interval: 300, notify: None, + aead_suites: DEFAULT_OHTTP_AEAD_SUITES.to_vec(), }; // Should still generate a pending key (actived_at defaults to SystemTime::get) @@ -872,7 +946,12 @@ mod tests { }; let pk = active_key.key_config.public_key().unwrap(); - let mut cks = ClusterKeySet::new(pk.clone(), active_key, 300); + let mut cks = ClusterKeySet::new( + pk.clone(), + active_key, + 300, + DEFAULT_OHTTP_AEAD_SUITES.to_vec(), + ); // Should NOT transition the only active key to stale let transitioned = cks.transition_active_to_stale(now); @@ -937,7 +1016,8 @@ mod tests { let pk2 = key2.key_config.public_key().unwrap(); let pk3 = key3.key_config.public_key().unwrap(); - let mut cks = ClusterKeySet::new(pk1.clone(), key1, 300); + let mut cks = + ClusterKeySet::new(pk1.clone(), key1, 300, DEFAULT_OHTTP_AEAD_SUITES.to_vec()); cks.keys.insert(pk2.clone(), key2); cks.keys.insert(pk3.clone(), key3); @@ -1006,7 +1086,12 @@ mod tests { let pending_pk2 = pending_key_future.key_config.public_key().unwrap(); let active_pk = active_key.key_config.public_key().unwrap(); - let mut cks = ClusterKeySet::new(pending_pk1.clone(), pending_key_at_boundary, 300); + let mut cks = ClusterKeySet::new( + pending_pk1.clone(), + pending_key_at_boundary, + 300, + DEFAULT_OHTTP_AEAD_SUITES.to_vec(), + ); cks.keys.insert(pending_pk2.clone(), pending_key_future); cks.keys.insert(active_pk.clone(), active_key); @@ -1057,7 +1142,8 @@ mod tests { let pk1 = key1.key_config.public_key().unwrap(); let pk2 = key2.key_config.public_key().unwrap(); - let mut cks = ClusterKeySet::new(pk1.clone(), key1, 300); + let mut cks = + ClusterKeySet::new(pk1.clone(), key1, 300, DEFAULT_OHTTP_AEAD_SUITES.to_vec()); cks.keys.insert(pk2.clone(), key2); // All active keys are expired. Should preserve the one with latest stale_at (key2) @@ -1074,7 +1160,12 @@ mod tests { let key = create_test_key(1, KeyStatus::Active, 1000); let public_key = key.key_config.public_key().unwrap(); - let mut cks = ClusterKeySet::new(public_key.clone(), key, 300); + let mut cks = ClusterKeySet::new( + public_key.clone(), + key, + 300, + DEFAULT_OHTTP_AEAD_SUITES.to_vec(), + ); // Insert a new key let new_key = create_test_key(2, KeyStatus::Active, 2000); diff --git a/tng/src/tunnel/egress/protocol/ohttp/security/key_manager/peer_shared/serf.rs b/tng/src/tunnel/egress/protocol/ohttp/security/key_manager/peer_shared/serf.rs index e16fce1f..73247f4b 100644 --- a/tng/src/tunnel/egress/protocol/ohttp/security/key_manager/peer_shared/serf.rs +++ b/tng/src/tunnel/egress/protocol/ohttp/security/key_manager/peer_shared/serf.rs @@ -1,4 +1,5 @@ use crate::config::egress::PeerSharedArgs; +use crate::config::ohttp_aead::AeadSuite; use crate::error::TngError; use crate::tunnel::egress::protocol::ohttp::security::key_manager::peer_shared::cluster_key_set::ClusterKeySet; use crate::tunnel::egress::protocol::ohttp::security::key_manager::peer_shared::memberlist_rats_quic::RatsQuic; @@ -69,7 +70,11 @@ pub(super) struct PeerSharedKeyManagerInner { } impl PeerSharedKeyManager { - pub async fn new(runtime: TokioRuntime, peer_shared: PeerSharedArgs) -> Result { + pub async fn new( + runtime: TokioRuntime, + peer_shared: PeerSharedArgs, + aead_suites: Vec, + ) -> Result { // Step 1: Initialize Serf node and network transport let (serf, subscriber) = Self::setup_serf(&runtime, &peer_shared).await?; @@ -81,6 +86,7 @@ impl PeerSharedKeyManager { &serf, peer_shared.rotation_interval, Duration::from_secs(peer_shared.preboot_timeout), + &aead_suites, ) .await?; @@ -309,6 +315,7 @@ impl PeerSharedKeyManager { serf: &Serf, rotation_interval: u64, preboot_timeout: Duration, + aead_suites: &[AeadSuite], ) -> Result { tracing::info!("Starting preboot phase: synchronizing cluster key set"); @@ -392,7 +399,10 @@ impl PeerSharedKeyManager { } } - if let Some(key_set) = merged_key_set { + if let Some(mut key_set) = merged_key_set { + // A key set decoded from a peer carries no local policy; + // attach ours before it generates any key. + key_set.set_aead_suites(aead_suites.to_vec()); let key_hex: Vec = key_set .iter_keys() .map(|(pk, _)| hex::encode(pk.as_ref())) @@ -423,14 +433,18 @@ impl PeerSharedKeyManager { } // Bootstrap: create initial active key - let initial_key = - KeyInfo::generate(0, KeyStatus::Active, SystemTime::get(), rotation_interval).map_err( - |e| { - TngError::KeyUpdateMessageDecodeError( - anyhow::Error::from(e).context("Failed to generate initial key"), - ) - }, - )?; + let initial_key = KeyInfo::generate( + 0, + KeyStatus::Active, + SystemTime::get(), + rotation_interval, + aead_suites, + ) + .map_err(|e| { + TngError::KeyUpdateMessageDecodeError( + anyhow::Error::from(e).context("Failed to generate initial key"), + ) + })?; let public_key = initial_key.key_config.public_key()?; tracing::info!( public_key_hex = %hex::encode(public_key.as_ref()), @@ -440,6 +454,7 @@ impl PeerSharedKeyManager { public_key, initial_key, rotation_interval, + aead_suites.to_vec(), )) } @@ -1261,6 +1276,7 @@ impl Drop for SerfGracefulShutdown { mod tests { use super::*; use crate::config::egress::PeerSharedArgs; + use crate::config::ohttp_aead::DEFAULT_OHTTP_AEAD_SUITES; use crate::config::ra::RaArgsUnchecked; use crate::tests::run_test_with_tokio_runtime; use crate::tunnel::egress::protocol::ohttp::security::key_manager::KeyManager; @@ -1600,7 +1616,9 @@ mod tests { let port = portpicker::pick_unused_port().unwrap(); let args = make_peer_shared_args(port, vec![], 300); - let manager = PeerSharedKeyManager::new(runtime, args).await?; + let manager = + PeerSharedKeyManager::new(runtime, args, DEFAULT_OHTTP_AEAD_SUITES.to_vec()) + .await?; // Verify the manager was created and has at least one key (bootstrapped) let cks = manager.inner.cluster_key_set.read().await; @@ -1627,7 +1645,7 @@ mod tests { let result = tokio::time::timeout( Duration::from_secs(30), - PeerSharedKeyManager::new(runtime, args), + PeerSharedKeyManager::new(runtime, args, DEFAULT_OHTTP_AEAD_SUITES.to_vec()), ) .await .expect("startup must honor the join deadline, not hang"); @@ -1670,7 +1688,12 @@ mod tests { // A is a healthy, fully started cluster member. let args_a = make_peer_shared_args(port_a, vec![], 300); - let _manager_a = PeerSharedKeyManager::new(runtime.clone(), args_a).await?; + let _manager_a = PeerSharedKeyManager::new( + runtime.clone(), + args_a, + DEFAULT_OHTTP_AEAD_SUITES.to_vec(), + ) + .await?; // B joins A successfully but enters preboot with zero budget: the // visible member must not be waited on. @@ -1680,7 +1703,11 @@ mod tests { let result = tokio::time::timeout( Duration::from_secs(30), - PeerSharedKeyManager::new(runtime.clone(), args_b), + PeerSharedKeyManager::new( + runtime.clone(), + args_b, + DEFAULT_OHTTP_AEAD_SUITES.to_vec(), + ), ) .await .expect("preboot deadline must be honored, not hang"); @@ -1729,7 +1756,12 @@ mod tests { // Start bootstrap node A let args_a = make_peer_shared_args(port_a, vec![], 300); - let manager_a = PeerSharedKeyManager::new(runtime.clone(), args_a).await?; + let manager_a = PeerSharedKeyManager::new( + runtime.clone(), + args_a, + DEFAULT_OHTTP_AEAD_SUITES.to_vec(), + ) + .await?; // Get A's active key public key let a_pk = { @@ -1744,7 +1776,9 @@ mod tests { // Start node B joining A let args_b = make_peer_shared_args(port_b, vec![format!("127.0.0.1:{}", port_a)], 300); - let manager_b = PeerSharedKeyManager::new(runtime, args_b).await?; + let manager_b = + PeerSharedKeyManager::new(runtime, args_b, DEFAULT_OHTTP_AEAD_SUITES.to_vec()) + .await?; // Wait for B to receive A's key wait_for_key_in_cks(&manager_b, &a_pk, Duration::from_secs(10)).await?; @@ -1786,7 +1820,13 @@ mod tests { .spawn_supervised_task_current_span(async move { tokio::time::sleep(Duration::from_secs(2)).await; let args_a = make_peer_shared_args(port_a, vec![], 300); - let manager_a = PeerSharedKeyManager::new(runtime_a, args_a).await.unwrap(); + let manager_a = PeerSharedKeyManager::new( + runtime_a, + args_a, + DEFAULT_OHTTP_AEAD_SUITES.to_vec(), + ) + .await + .unwrap(); // Report A's active public key to the test body. let a_pk = manager_a .get_client_visible_key() @@ -1804,7 +1844,9 @@ mod tests { // B points at A. PeerSharedKeyManager::new must block (retry) until A is up, // then return — rather than bootstrap standalone on the first failed attempt. let args_b = make_peer_shared_args(port_b, vec![format!("127.0.0.1:{}", port_a)], 300); - let manager_b = PeerSharedKeyManager::new(runtime, args_b).await?; + let manager_b = + PeerSharedKeyManager::new(runtime, args_b, DEFAULT_OHTTP_AEAD_SUITES.to_vec()) + .await?; // After B joined A, B must NOT have bootstrapped its own key — it should // have synced A's key set (members.len() > 1 at preboot). @@ -1842,12 +1884,22 @@ mod tests { // Start A first (bootstrap), then B joins let args_a = make_peer_shared_args(port_a, vec![], 300); - let manager_a = PeerSharedKeyManager::new(runtime.clone(), args_a).await?; + let manager_a = PeerSharedKeyManager::new( + runtime.clone(), + args_a, + DEFAULT_OHTTP_AEAD_SUITES.to_vec(), + ) + .await?; tokio::time::sleep(Duration::from_secs(1)).await; let args_b = make_peer_shared_args(port_b, vec![format!("127.0.0.1:{}", port_a)], 300); - let manager_b = PeerSharedKeyManager::new(runtime.clone(), args_b).await?; + let manager_b = PeerSharedKeyManager::new( + runtime.clone(), + args_b, + DEFAULT_OHTTP_AEAD_SUITES.to_vec(), + ) + .await?; // Wait for convergence wait_for_active_key_count(&manager_b, 1, Duration::from_secs(10)).await?; @@ -1877,25 +1929,45 @@ mod tests { // Start A (bootstrap) let args_a = make_peer_shared_args(port_a, vec![], 300); - let manager_a = PeerSharedKeyManager::new(runtime.clone(), args_a).await?; + let manager_a = PeerSharedKeyManager::new( + runtime.clone(), + args_a, + DEFAULT_OHTTP_AEAD_SUITES.to_vec(), + ) + .await?; tokio::time::sleep(Duration::from_secs(1)).await; // Start B joining A let args_b = make_peer_shared_args(port_b, vec![format!("127.0.0.1:{}", port_a)], 300); - let manager_b = PeerSharedKeyManager::new(runtime.clone(), args_b).await?; + let manager_b = PeerSharedKeyManager::new( + runtime.clone(), + args_b, + DEFAULT_OHTTP_AEAD_SUITES.to_vec(), + ) + .await?; tokio::time::sleep(Duration::from_secs(1)).await; // Start C joining A let args_c = make_peer_shared_args(port_c, vec![format!("127.0.0.1:{}", port_a)], 300); - let manager_c = PeerSharedKeyManager::new(runtime.clone(), args_c).await?; + let manager_c = PeerSharedKeyManager::new( + runtime.clone(), + args_c, + DEFAULT_OHTTP_AEAD_SUITES.to_vec(), + ) + .await?; tokio::time::sleep(Duration::from_secs(1)).await; // Start D joining A let args_d = make_peer_shared_args(port_d, vec![format!("127.0.0.1:{}", port_a)], 300); - let manager_d = PeerSharedKeyManager::new(runtime.clone(), args_d).await?; + let manager_d = PeerSharedKeyManager::new( + runtime.clone(), + args_d, + DEFAULT_OHTTP_AEAD_SUITES.to_vec(), + ) + .await?; // D should have at least 1 active key wait_for_active_key_count(&manager_d, 1, Duration::from_secs(15)).await?; @@ -1941,7 +2013,12 @@ mod tests { // Start A with 2-second rotation interval let args_a = make_peer_shared_args(port_a, vec![], 2); - let manager_a = PeerSharedKeyManager::new(runtime.clone(), args_a).await?; + let manager_a = PeerSharedKeyManager::new( + runtime.clone(), + args_a, + DEFAULT_OHTTP_AEAD_SUITES.to_vec(), + ) + .await?; // Get initial key let initial_pk = manager_a @@ -1954,7 +2031,12 @@ mod tests { // Start B joining A let args_b = make_peer_shared_args(port_b, vec![format!("127.0.0.1:{}", port_a)], 2); - let manager_b = PeerSharedKeyManager::new(runtime.clone(), args_b).await?; + let manager_b = PeerSharedKeyManager::new( + runtime.clone(), + args_b, + DEFAULT_OHTTP_AEAD_SUITES.to_vec(), + ) + .await?; // B should have A's initial key wait_for_key_in_cks(&manager_b, &initial_pk, Duration::from_secs(10)).await?; @@ -2013,7 +2095,12 @@ mod tests { // Single node with 2-second rotation interval let args = make_peer_shared_args(port, vec![], 2); - let manager = PeerSharedKeyManager::new(runtime.clone(), args).await?; + let manager = PeerSharedKeyManager::new( + runtime.clone(), + args, + DEFAULT_OHTTP_AEAD_SUITES.to_vec(), + ) + .await?; // Get initial key let initial_pk = manager @@ -2061,8 +2148,12 @@ mod tests { // Start bootstrap node let args_bootstrap = make_peer_shared_args(port_bootstrap, vec![], 300); - let bootstrap_manager = - PeerSharedKeyManager::new(runtime.clone(), args_bootstrap).await?; + let bootstrap_manager = PeerSharedKeyManager::new( + runtime.clone(), + args_bootstrap, + DEFAULT_OHTTP_AEAD_SUITES.to_vec(), + ) + .await?; let bootstrap_pk = bootstrap_manager .get_client_visible_key() @@ -2078,7 +2169,12 @@ mod tests { let port = portpicker::pick_unused_port().unwrap(); let args = make_peer_shared_args(port, vec![format!("127.0.0.1:{}", port_bootstrap)], 300); - let manager = PeerSharedKeyManager::new(runtime.clone(), args).await?; + let manager = PeerSharedKeyManager::new( + runtime.clone(), + args, + DEFAULT_OHTTP_AEAD_SUITES.to_vec(), + ) + .await?; managers.push(manager); tokio::time::sleep(Duration::from_millis(500)).await; } @@ -2119,7 +2215,12 @@ mod tests { // Start A with 2-second rotation interval let args_a = make_peer_shared_args(port_a, vec![], 2); - let manager_a = PeerSharedKeyManager::new(runtime.clone(), args_a).await?; + let manager_a = PeerSharedKeyManager::new( + runtime.clone(), + args_a, + DEFAULT_OHTTP_AEAD_SUITES.to_vec(), + ) + .await?; // Get initial key let key1_pk = manager_a @@ -2132,7 +2233,12 @@ mod tests { // Start B joining A let args_b = make_peer_shared_args(port_b, vec![format!("127.0.0.1:{}", port_a)], 2); - let manager_b = PeerSharedKeyManager::new(runtime.clone(), args_b).await?; + let manager_b = PeerSharedKeyManager::new( + runtime.clone(), + args_b, + DEFAULT_OHTTP_AEAD_SUITES.to_vec(), + ) + .await?; // B should have key1 wait_for_key_in_cks(&manager_b, &key1_pk, Duration::from_secs(10)).await?; @@ -2180,7 +2286,12 @@ mod tests { // Start A (bootstrap) let args_a = make_peer_shared_args(port_a, vec![], 300); - let manager_a = PeerSharedKeyManager::new(runtime.clone(), args_a).await?; + let manager_a = PeerSharedKeyManager::new( + runtime.clone(), + args_a, + DEFAULT_OHTTP_AEAD_SUITES.to_vec(), + ) + .await?; let a_pk = manager_a .get_client_visible_key() @@ -2192,7 +2303,12 @@ mod tests { // Start B joining A let args_b = make_peer_shared_args(port_b, vec![format!("127.0.0.1:{}", port_a)], 300); - let manager_b = PeerSharedKeyManager::new(runtime.clone(), args_b).await?; + let manager_b = PeerSharedKeyManager::new( + runtime.clone(), + args_b, + DEFAULT_OHTTP_AEAD_SUITES.to_vec(), + ) + .await?; // B should have A's key wait_for_key_in_cks(&manager_b, &a_pk, Duration::from_secs(10)).await?; @@ -2228,7 +2344,12 @@ mod tests { // Start A (bootstrap) let args_a = make_peer_shared_args(port_a, vec![], 300); - let manager_a = PeerSharedKeyManager::new(runtime.clone(), args_a).await?; + let manager_a = PeerSharedKeyManager::new( + runtime.clone(), + args_a, + DEFAULT_OHTTP_AEAD_SUITES.to_vec(), + ) + .await?; let a_pk = manager_a .get_client_visible_key() @@ -2240,7 +2361,12 @@ mod tests { // Start B joining A let args_b = make_peer_shared_args(port_b, vec![format!("127.0.0.1:{}", port_a)], 300); - let manager_b = PeerSharedKeyManager::new(runtime.clone(), args_b).await?; + let manager_b = PeerSharedKeyManager::new( + runtime.clone(), + args_b, + DEFAULT_OHTTP_AEAD_SUITES.to_vec(), + ) + .await?; // Wait for B to have A's key via normal sync wait_for_key_in_cks(&manager_b, &a_pk, Duration::from_secs(10)).await?; @@ -2280,13 +2406,23 @@ mod tests { // Start A (bootstrap) let args_a = make_peer_shared_args(port_a, vec![], 300); - let manager_a = PeerSharedKeyManager::new(runtime.clone(), args_a).await?; + let manager_a = PeerSharedKeyManager::new( + runtime.clone(), + args_a, + DEFAULT_OHTTP_AEAD_SUITES.to_vec(), + ) + .await?; tokio::time::sleep(Duration::from_secs(1)).await; // Start B joining A let args_b = make_peer_shared_args(port_b, vec![format!("127.0.0.1:{}", port_a)], 300); - let manager_b = PeerSharedKeyManager::new(runtime.clone(), args_b).await?; + let manager_b = PeerSharedKeyManager::new( + runtime.clone(), + args_b, + DEFAULT_OHTTP_AEAD_SUITES.to_vec(), + ) + .await?; // Wait for convergence wait_for_active_key_count(&manager_b, 1, Duration::from_secs(10)).await?; @@ -2334,19 +2470,34 @@ mod tests { // Start A (bootstrap) let args_a = make_peer_shared_args(port_a, vec![], 300); - let manager_a = PeerSharedKeyManager::new(runtime.clone(), args_a).await?; + let manager_a = PeerSharedKeyManager::new( + runtime.clone(), + args_a, + DEFAULT_OHTTP_AEAD_SUITES.to_vec(), + ) + .await?; tokio::time::sleep(Duration::from_secs(1)).await; // Start B joining A let args_b = make_peer_shared_args(port_b, vec![format!("127.0.0.1:{}", port_a)], 300); - let manager_b = PeerSharedKeyManager::new(runtime.clone(), args_b).await?; + let manager_b = PeerSharedKeyManager::new( + runtime.clone(), + args_b, + DEFAULT_OHTTP_AEAD_SUITES.to_vec(), + ) + .await?; tokio::time::sleep(Duration::from_secs(1)).await; // Start C joining A let args_c = make_peer_shared_args(port_c, vec![format!("127.0.0.1:{}", port_a)], 300); - let manager_c = PeerSharedKeyManager::new(runtime.clone(), args_c).await?; + let manager_c = PeerSharedKeyManager::new( + runtime.clone(), + args_c, + DEFAULT_OHTTP_AEAD_SUITES.to_vec(), + ) + .await?; // Wait for all to converge tokio::time::sleep(Duration::from_secs(3)).await; @@ -2411,7 +2562,12 @@ mod tests { let port_a = portpicker::pick_unused_port().unwrap(); let args_a = make_peer_shared_args(port_a, vec![], rotation_interval); - let manager_a = PeerSharedKeyManager::new(runtime.clone(), args_a).await?; + let manager_a = PeerSharedKeyManager::new( + runtime.clone(), + args_a, + DEFAULT_OHTTP_AEAD_SUITES.to_vec(), + ) + .await?; let a_pk = manager_a .get_client_visible_key() @@ -2421,7 +2577,12 @@ mod tests { let port_b = portpicker::pick_unused_port().unwrap(); let args_b = make_peer_shared_args(port_b, vec![], rotation_interval); - let manager_b = PeerSharedKeyManager::new(runtime.clone(), args_b).await?; + let manager_b = PeerSharedKeyManager::new( + runtime.clone(), + args_b, + DEFAULT_OHTTP_AEAD_SUITES.to_vec(), + ) + .await?; let b_pk = manager_b .get_client_visible_key() @@ -2445,7 +2606,12 @@ mod tests { ], rotation_interval, ); - let manager_c = PeerSharedKeyManager::new(runtime.clone(), args_c).await?; + let manager_c = PeerSharedKeyManager::new( + runtime.clone(), + args_c, + DEFAULT_OHTTP_AEAD_SUITES.to_vec(), + ) + .await?; // --- Phase 3: Verify cross-node key accessibility --- @@ -2493,7 +2659,12 @@ mod tests { let port_a = portpicker::pick_unused_port().unwrap(); let args_a = make_peer_shared_args(port_a, vec![], rotation_interval); - let manager_a = PeerSharedKeyManager::new(runtime.clone(), args_a).await?; + let manager_a = PeerSharedKeyManager::new( + runtime.clone(), + args_a, + DEFAULT_OHTTP_AEAD_SUITES.to_vec(), + ) + .await?; let a_pk = manager_a .get_client_visible_key() @@ -2503,7 +2674,12 @@ mod tests { let port_b = portpicker::pick_unused_port().unwrap(); let args_b = make_peer_shared_args(port_b, vec![], rotation_interval); - let manager_b = PeerSharedKeyManager::new(runtime.clone(), args_b).await?; + let manager_b = PeerSharedKeyManager::new( + runtime.clone(), + args_b, + DEFAULT_OHTTP_AEAD_SUITES.to_vec(), + ) + .await?; let b_pk = manager_b .get_client_visible_key() @@ -2513,7 +2689,12 @@ mod tests { let port_c = portpicker::pick_unused_port().unwrap(); let args_c = make_peer_shared_args(port_c, vec![], rotation_interval); - let manager_c = PeerSharedKeyManager::new(runtime.clone(), args_c).await?; + let manager_c = PeerSharedKeyManager::new( + runtime.clone(), + args_c, + DEFAULT_OHTTP_AEAD_SUITES.to_vec(), + ) + .await?; let c_pk = manager_c .get_client_visible_key() @@ -2567,7 +2748,12 @@ mod tests { // Start A as bootstrap (creates its own key) let port_a = portpicker::pick_unused_port().unwrap(); let args_a = make_peer_shared_args(port_a, vec![], rotation_interval); - let manager_a = PeerSharedKeyManager::new(runtime.clone(), args_a).await?; + let manager_a = PeerSharedKeyManager::new( + runtime.clone(), + args_a, + DEFAULT_OHTTP_AEAD_SUITES.to_vec(), + ) + .await?; let a_pk = manager_a .get_client_visible_key() @@ -2578,7 +2764,12 @@ mod tests { // Start B as bootstrap too (creates its own DIFFERENT key) let port_b = portpicker::pick_unused_port().unwrap(); let args_b = make_peer_shared_args(port_b, vec![], rotation_interval); - let manager_b = PeerSharedKeyManager::new(runtime.clone(), args_b).await?; + let manager_b = PeerSharedKeyManager::new( + runtime.clone(), + args_b, + DEFAULT_OHTTP_AEAD_SUITES.to_vec(), + ) + .await?; let b_pk = manager_b .get_client_visible_key() @@ -2602,7 +2793,12 @@ mod tests { ], rotation_interval, ); - let manager_c = PeerSharedKeyManager::new(runtime.clone(), args_c).await?; + let manager_c = PeerSharedKeyManager::new( + runtime.clone(), + args_c, + DEFAULT_OHTTP_AEAD_SUITES.to_vec(), + ) + .await?; // Wait for C to get keys from both A and B via preboot merge tokio::time::sleep(Duration::from_secs(3)).await; @@ -2668,9 +2864,13 @@ mod tests { .spawn_supervised_task_current_span(async move { tokio::time::sleep(Duration::from_secs(2)).await; let args_a = make_peer_shared_args(port_a, vec![], 300); - let _manager_a = PeerSharedKeyManager::new(runtime_for_a, args_a) - .await - .expect("A should start"); + let _manager_a = PeerSharedKeyManager::new( + runtime_for_a, + args_a, + DEFAULT_OHTTP_AEAD_SUITES.to_vec(), + ) + .await + .expect("A should start"); // Keep A alive for the rest of the test. std::future::pending::<()>().await; }); @@ -2683,7 +2883,12 @@ mod tests { let mut args_b = make_peer_shared_args(port_b, vec![], 300); args_b.peers_file = Some(peers_file.to_string_lossy().to_string()); - let manager_b = PeerSharedKeyManager::new(runtime.clone(), args_b).await?; + let manager_b = PeerSharedKeyManager::new( + runtime.clone(), + args_b, + DEFAULT_OHTTP_AEAD_SUITES.to_vec(), + ) + .await?; // Now write A's address into the file. The watcher should pick it up // and spawn a background retry_join_peer task that retries until A @@ -2735,19 +2940,34 @@ mod tests { // complete and the new key would activate before the final assertion, // causing the "Key should remain the same" check to fail. let args_a = make_peer_shared_args(port_a, vec![], 300); - let manager_a = PeerSharedKeyManager::new(runtime.clone(), args_a).await?; + let manager_a = PeerSharedKeyManager::new( + runtime.clone(), + args_a, + DEFAULT_OHTTP_AEAD_SUITES.to_vec(), + ) + .await?; tokio::time::sleep(Duration::from_secs(1)).await; // Start B joining A let args_b = make_peer_shared_args(port_b, vec![format!("127.0.0.1:{}", port_a)], 300); - let manager_b = PeerSharedKeyManager::new(runtime.clone(), args_b).await?; + let manager_b = PeerSharedKeyManager::new( + runtime.clone(), + args_b, + DEFAULT_OHTTP_AEAD_SUITES.to_vec(), + ) + .await?; tokio::time::sleep(Duration::from_secs(1)).await; // Start C joining A (to create multiple leave events later) let args_c = make_peer_shared_args(port_c, vec![format!("127.0.0.1:{}", port_a)], 300); - let manager_c = PeerSharedKeyManager::new(runtime.clone(), args_c).await?; + let manager_c = PeerSharedKeyManager::new( + runtime.clone(), + args_c, + DEFAULT_OHTTP_AEAD_SUITES.to_vec(), + ) + .await?; // Wait for convergence wait_for_active_key_count(&manager_b, 1, Duration::from_secs(10)).await?; diff --git a/tng/src/tunnel/egress/protocol/ohttp/security/key_manager/self_generated.rs b/tng/src/tunnel/egress/protocol/ohttp/security/key_manager/self_generated.rs index 93676dc1..2636ecd7 100644 --- a/tng/src/tunnel/egress/protocol/ohttp/security/key_manager/self_generated.rs +++ b/tng/src/tunnel/egress/protocol/ohttp/security/key_manager/self_generated.rs @@ -1,3 +1,4 @@ +use crate::config::ohttp_aead::AeadSuite; use crate::error::TngError; use crate::status::{StatusProvider, StatusQueryResult}; use crate::tunnel::egress::protocol::ohttp::security::key_manager::{ @@ -51,6 +52,9 @@ pub struct RandomKeyManagerInner { keys: tokio::sync::RwLock>, rotation_interval: u64, + + /// AEAD suites advertised in generated key configs, in order. + aead_suites: Vec, } impl SelfGeneratedKeyManager { @@ -61,10 +65,12 @@ impl SelfGeneratedKeyManager { pub fn new_with_auto_refresh( runtime: TokioRuntime, rotation_interval: u64, + aead_suites: Vec, ) -> Result { let inner = Arc::new(RandomKeyManagerInner { keys: tokio::sync::RwLock::new(HashMap::new()), rotation_interval, + aead_suites, }); let inner_clone = inner.clone(); @@ -158,8 +164,13 @@ impl RandomKeyManagerInner { 0 }); - let key_info = - KeyInfo::generate(new_key_id, KeyStatus::Active, now, self.rotation_interval)?; + let key_info = KeyInfo::generate( + new_key_id, + KeyStatus::Active, + now, + self.rotation_interval, + &self.aead_suites, + )?; tracing::info!(?key_info, "New OHTTP key generated"); keys.insert(key_info.key_config.public_key()?, key_info); } diff --git a/tng/src/tunnel/egress/protocol/ohttp/security/server.rs b/tng/src/tunnel/egress/protocol/ohttp/security/server.rs index 46c204c8..cff2e7db 100644 --- a/tng/src/tunnel/egress/protocol/ohttp/security/server.rs +++ b/tng/src/tunnel/egress/protocol/ohttp/security/server.rs @@ -53,6 +53,15 @@ impl OhttpServer { runtime: TokioRuntime, ) -> Result { let forward_client_ip = ohttp_args.forward_client_ip; + let aead_suites = ohttp_args.resolved_aead_suites(); + // One line per egress OHTTP instance: the list is both the advertised + // set and the decapsulation allowlist, so both are stated. + tracing::info!( + aead_suites = ?aead_suites.as_slice(), + default = ohttp_args.aead_suites.is_none(), + advertised_aead_suites = ?aead_suites.as_slice(), + "OHTTP egress AEAD suite policy in effect" + ); let (passthrough_request_headers, passthrough_response_headers) = ( Arc::new( ohttp_args @@ -79,6 +88,7 @@ impl OhttpServer { passthrough_request_headers, passthrough_response_headers, forward_client_ip, + aead_suites.as_slice().to_vec(), ) .await?, ), diff --git a/tng/src/tunnel/ingress/protocol/ohttp/security/client.rs b/tng/src/tunnel/ingress/protocol/ohttp/security/client.rs index f12d71ef..476d19ac 100644 --- a/tng/src/tunnel/ingress/protocol/ohttp/security/client.rs +++ b/tng/src/tunnel/ingress/protocol/ohttp/security/client.rs @@ -45,18 +45,7 @@ use crate::tunnel::ohttp::protocol::userdata::ClientUserData; #[cfg(unix)] use crate::tunnel::ra_context::AttestContext; use crate::{ - error::CheckErrorResponse as _, - tunnel::{ - ohttp::protocol::{ - metadata::{metadata::ClientAuth, Metadata, NoAuth, METADATA_MAX_LEN}, - userdata::ServerUserData, - AttestationChallengeResponse, AttestationRequest, AttestationVerifyRequest, - AttestationVerifyResponse, KeyConfigRequest, KeyConfigResponse, ServerAttestationInfo, - }, - utils::maybe_cached::{Expire, MaybeCached, RefreshStrategy}, - }, -}; -use crate::{ + config::ohttp_aead::{AeadSuite, AeadSuites}, error::TngError, tunnel::{ ohttp::{ @@ -73,6 +62,18 @@ use crate::{ }, AttestationResult, TokioRuntime, }; +use crate::{ + error::CheckErrorResponse as _, + tunnel::{ + ohttp::protocol::{ + metadata::{metadata::ClientAuth, Metadata, NoAuth, METADATA_MAX_LEN}, + userdata::ServerUserData, + AttestationChallengeResponse, AttestationRequest, AttestationVerifyRequest, + AttestationVerifyResponse, KeyConfigRequest, KeyConfigResponse, ServerAttestationInfo, + }, + utils::maybe_cached::{Expire, MaybeCached, RefreshStrategy}, + }, +}; const DEFAULT_KEY_CONFIG_REFRESH_SECOND: u64 = 5 * 60; // 5 minutes @@ -95,15 +96,18 @@ pub struct OHttpClientInner { /// Headers to copy from the outer OHTTP response to the inner (plaintext) response. #[allow(unused)] passthrough_response_headers: Arc, + /// The configured AEAD allowlist and preference order. `None` keeps the + /// historical server-order behavior with no filtering. + aead_suites: Option, } struct KeyStoreValue { /// The outer-POST metadata frame (big-endian length + prost-encoded /// Metadata) for this key generation. Both of its inputs - `client_auth` - /// and the selected (first) server key config's public key - are fixed - /// per store generation, so the frame is built once here instead of per - /// request. `None` when the server returned an empty key-config list: - /// the request path then reports the same + /// and the selected server key config's public key - are fixed per store + /// generation, so the frame is built once here instead of per request. + /// `None` when the server returned an empty key-config list with no + /// policy configured: the request path then reports the same /// `ClientSelectHpkeConfigurationFailed` as before. outer_metadata: Option, @@ -112,13 +116,88 @@ struct KeyStoreValue { ::PublicKey, )>, - /// A base64 encoded list of key configurations, each entry is a Individual key configuration entry. Defined in Section 3.1 of RFC 9458. - server_key_config_list: Vec, + /// The server key config selected for this generation: the first one with + /// a non-empty intersection when `aead_suites` is configured, otherwise + /// the server's first config. The metadata hint and `server_status()` are + /// both derived from this config, never from the list's first entry. + selected_key_config: Option, + + /// The suite chosen by the ingress policy. `None` when no policy is + /// configured, so the request path keeps the historical + /// `KeyConfig::symmetric[0]` selection. + selected_suite: Option, /// Server attestation information. This is only represented if the server attestation is required. server_attestation_result: Option, } +/// Result of the ingress key-config selection. +#[derive(Debug)] +struct SelectedServerKeyConfig { + key_config: KeyConfig, + /// `None` when no policy is configured: the request path keeps the + /// historical `KeyConfig::symmetric[0]` selection. + suite: Option, +} + +/// Pick the server key config and suite for one key-store generation. +/// +/// Without a policy this returns the server's first config (or `None` for an +/// empty list, preserving the request-time error). With a policy: iterate the +/// server configs in server order and take the first one whose advertised +/// suites intersect the allowlist, then take the first suite in the +/// *ingress's* configured order inside it. Ingress order wins over server +/// order, which is the documented deviation from RFC 9458. +fn select_server_key_config( + server_key_config_list: &[KeyConfig], + aead_suites: Option<&[AeadSuite]>, +) -> Result, TngError> { + let Some(aead_suites) = aead_suites else { + return Ok(server_key_config_list.first().cloned().map(|key_config| { + SelectedServerKeyConfig { + key_config, + suite: None, + } + })); + }; + + for key_config in server_key_config_list { + let advertised = key_config.symmetric(); + // Compare the full (KDF, AEAD) pair: the policy only selects the + // AEAD, and the constructed suite must exist verbatim in the config. + if let Some(allowed) = aead_suites + .iter() + .find(|allowed| advertised.contains(&allowed.to_symmetric_suite())) + { + return Ok(Some(SelectedServerKeyConfig { + key_config: key_config.clone(), + suite: Some(allowed.to_symmetric_suite()), + })); + } + } + + // Report every suite the server advertised across all configs, in server + // order, so the operator sees both sides of the failed intersection. + let mut server: Vec = Vec::new(); + for key_config in server_key_config_list { + for suite in key_config.symmetric() { + let suite = AeadSuite::from_ohttp_aead(suite.aead()); + if !server.contains(&suite) { + server.push(suite); + } + } + } + tracing::warn!( + server_advertised = ?server, + ingress_allowed = ?aead_suites, + "OHTTP AEAD suite policy has no intersection between server and ingress" + ); + Err(TngError::OHttpNoAcceptableSuite { + server, + ingress: aead_suites.to_vec(), + }) +} + /// A single entry in the status API response for an upstream OHTTP server. /// /// Contains the server URL, optional HPKE public key in hex format, @@ -139,8 +218,8 @@ impl OHttpClient { Some(ServerStatusEntry { url: self.inner.base_url.to_string(), server_public_key: ksv - .server_key_config_list - .first() + .selected_key_config + .as_ref() .and_then(|kc| kc.public_key().ok().map(|pk| hex::encode(pk.as_ref()))), server_attestation: ksv .server_attestation_result @@ -156,6 +235,7 @@ impl OHttpClient { runtime: TokioRuntime, passthrough_request_headers: Arc, passthrough_response_headers: Arc, + aead_suites: Option, ) -> Result { // Parsed once per client, not per forwarded request. The error keeps // the context text the per-request parse produced before; the caller @@ -191,6 +271,7 @@ impl OHttpClient { runtime: runtime.clone(), passthrough_request_headers, passthrough_response_headers, + aead_suites, }); let key_store_value = MaybeCached::new(runtime.clone(), refresh_strategy, { @@ -219,7 +300,8 @@ impl OHttpClient { match self .inner .send_encrypted_request( - &key_store_value.server_key_config_list, + key_store_value.selected_key_config.as_ref(), + key_store_value.selected_suite, key_store_value.outer_metadata.as_ref(), key_store_value.client_key.as_ref(), request, @@ -327,20 +409,32 @@ impl OHttpClientInner { .as_ref(), )?; - // Fixed inputs (client auth + the selected first key config's public - // key), so the outer-POST metadata frame is built once per generation - // rather than re-encoded per request. An empty list keeps the + // Selection happens once per key-store generation, not per request. + // With a policy this is the first config with a non-empty + // intersection and the first allowed suite in ingress order; without + // one it is the server's first config and the historical + // `symmetric[0]` choice. An empty list with no policy keeps the // request-time `ClientSelectHpkeConfigurationFailed` behavior. - let outer_metadata = match server_key_config_list.first() { - Some(key_config) => Some(Self::build_metadata_block(&client_auth, key_config)?), - None => None, - }; + let selected = select_server_key_config( + &server_key_config_list, + self.aead_suites.as_ref().map(|suites| suites.as_slice()), + )?; + + let outer_metadata = selected + .as_ref() + .map(|selected| Self::build_metadata_block(&client_auth, &selected.key_config)) + .transpose()?; + let selected_key_config = selected + .as_ref() + .map(|selected| selected.key_config.clone()); + let selected_suite = selected.and_then(|selected| selected.suite); Ok(( KeyStoreValue { outer_metadata, client_key, - server_key_config_list, + selected_key_config, + selected_suite, server_attestation_result, }, expire, @@ -491,7 +585,8 @@ impl OHttpClientInner { /// response. async fn send_encrypted_request( &self, - server_key_config_list: &[KeyConfig], + selected_key_config: Option<&KeyConfig>, + selected_suite: Option, outer_metadata: Option<&Bytes>, client_key: Option<&( ::PrivateKey, @@ -505,8 +600,9 @@ impl OHttpClientInner { // Encode the request to bhttp message let bhttp_encoder = BhttpEncoder::from_request(request); - // Select the server key config and build the HPKE request encapsulator - let client = Self::build_request_encapsulator(server_key_config_list, client_key)?; + // Build the HPKE request encapsulator from the pre-selected config + let client = + Self::build_request_encapsulator(selected_key_config, selected_suite, client_key)?; // Seal stage: encrypt the bhttp stream into the ohttp envelope that // becomes the outer body, with a per-platform strategy because the @@ -597,9 +693,10 @@ impl OHttpClientInner { .await } - /// Select the server's first advertised key config and build the HPKE - /// request encapsulator. (The metadata hint for the selected config is - /// precomputed per key generation in `create_key_store_value`.) + /// Build the HPKE request encapsulator from the key config selected in + /// `create_key_store_value`. When `selected_suite` is `Some`, that exact + /// suite is used; when it is `None` the historical first-advertised-suite + /// selection is kept. /// /// With an attested client key (native RA mode) the seal runs in HPKE /// Auth mode using that key, so the envelope's sender matches the `pk_s` @@ -607,17 +704,16 @@ impl OHttpClientInner { /// Without one -- and always on wasm, where the client has no attester -- /// HPKE Base mode generates an ephemeral sender key. fn build_request_encapsulator( - server_key_config_list: &[KeyConfig], + selected_key_config: Option<&KeyConfig>, + selected_suite: Option, client_key: Option<&( ::PrivateKey, ::PublicKey, )>, ) -> Result { - let mut key_config = server_key_config_list - .first() + let key_config = selected_key_config .context("No key config found") - .map_err(TngError::ClientSelectHpkeConfigurationFailed)? - .clone(); + .map_err(TngError::ClientSelectHpkeConfigurationFailed)?; tracing::debug!( public_key = ?key_config.public_key(), @@ -625,20 +721,32 @@ impl OHttpClientInner { ); #[cfg(unix)] - let client = match client_key { - Some((sk, _pk)) => { - let sk_bytes = hpke::Serializable::to_bytes(sk); - ohttp::ClientRequest::from_config_with_client_key_bytes( - &mut key_config, + let client = if let Some((sk, _pk)) = client_key { + let sk_bytes = hpke::Serializable::to_bytes(sk); + match selected_suite { + Some(suite) => ohttp::ClientRequest::from_config_with_client_key_bytes_and_suite( + key_config, + suite, + sk_bytes.as_ref(), + )?, + None => ohttp::ClientRequest::from_config_with_client_key_bytes( + &mut key_config.clone(), sk_bytes.as_ref(), - )? + )?, + } + } else { + match selected_suite { + Some(suite) => ohttp::ClientRequest::from_config_with_suite(key_config, suite)?, + None => ohttp::ClientRequest::from_config(&mut key_config.clone())?, } - None => ohttp::ClientRequest::from_config(&mut key_config)?, }; #[cfg(not(unix))] let client = { let _ = client_key; // unused on wasm - ohttp::ClientRequest::from_config(&mut key_config)? + match selected_suite { + Some(suite) => ohttp::ClientRequest::from_config_with_suite(key_config, suite)?, + None => ohttp::ClientRequest::from_config(&mut key_config.clone())?, + } }; Ok(client) @@ -1064,6 +1172,125 @@ pub(crate) async fn verify_keyconfig_attestation( mod tests { use super::*; + use ohttp::hpke::Aead; + + fn key_config(key_id: u8, aeads: &[Aead]) -> KeyConfig { + ohttp::KeyConfig::new( + key_id, + ohttp::hpke::Kem::X25519Sha256, + aeads + .iter() + .map(|aead| ohttp::SymmetricSuite::new(ohttp::hpke::Kdf::HkdfSha256, *aead)) + .collect(), + ) + .expect("build key config") + } + + #[test] + fn selection_prefers_ingress_order_over_server_order() { + // The server advertises AES-128 first; the ingress prefers ChaCha. + let list = vec![key_config(1, &[Aead::Aes128Gcm, Aead::ChaCha20Poly1305])]; + let selected = select_server_key_config( + &list, + Some(&[AeadSuite::ChaCha20Poly1305, AeadSuite::Aes128Gcm]), + ) + .expect("selection succeeds") + .expect("a config is selected"); + + assert_eq!(selected.key_config.key_id(), 1); + assert_eq!( + selected.suite.expect("suite selected").aead(), + Aead::ChaCha20Poly1305 + ); + } + + #[test] + fn selection_takes_first_config_with_intersection() { + // Config 1 advertises only ChaCha, which is not allowed; config 2 is + // the first one with an intersection. + let list = vec![ + key_config(1, &[Aead::ChaCha20Poly1305]), + key_config(2, &[Aead::Aes256Gcm, Aead::Aes128Gcm]), + ]; + let selected = select_server_key_config(&list, Some(&[AeadSuite::Aes256Gcm])) + .expect("selection succeeds") + .expect("a config is selected"); + + assert_eq!(selected.key_config.key_id(), 2); + assert_eq!( + selected.suite.expect("suite selected").aead(), + Aead::Aes256Gcm + ); + } + + #[test] + fn selected_config_drives_metadata_hint() { + let list = vec![ + key_config(1, &[Aead::ChaCha20Poly1305]), + key_config(2, &[Aead::Aes256Gcm]), + ]; + let selected = select_server_key_config(&list, Some(&[AeadSuite::Aes256Gcm])) + .expect("selection succeeds") + .expect("a config is selected"); + + let block = OHttpClientInner::build_metadata_block( + &ClientAuth::NoAuth(NoAuth {}), + &selected.key_config, + ) + .expect("build metadata"); + let metadata = Metadata::decode(&block[4..]).expect("decode metadata"); + let hint = metadata + .key_config_hint + .expect("metadata carries a hint") + .public_key; + + let selected_pk = selected.key_config.public_key().unwrap().into_vec(); + let first_pk = list[0].public_key().unwrap().into_vec(); + assert_eq!(hint, selected_pk, "hint must name the selected config"); + assert_ne!( + hint, first_pk, + "hint must not name the server's first config" + ); + } + + #[test] + fn selection_reports_empty_intersection_with_both_lists() { + let list = vec![ + key_config(1, &[Aead::ChaCha20Poly1305]), + key_config(2, &[Aead::Aes128Gcm]), + ]; + let error = select_server_key_config(&list, Some(&[AeadSuite::Aes256Gcm])) + .expect_err("no intersection must fail"); + + match error { + TngError::OHttpNoAcceptableSuite { server, ingress } => { + assert_eq!( + server, + vec![AeadSuite::ChaCha20Poly1305, AeadSuite::Aes128Gcm] + ); + assert_eq!(ingress, vec![AeadSuite::Aes256Gcm]); + } + other => panic!("unexpected error: {other:?}"), + } + } + + #[test] + fn unset_policy_keeps_first_config_and_no_explicit_suite() { + let list = vec![ + key_config(1, &[Aead::Aes128Gcm]), + key_config(2, &[Aead::Aes256Gcm]), + ]; + let selected = select_server_key_config(&list, None) + .expect("selection succeeds") + .expect("a config is selected"); + + assert_eq!(selected.key_config.key_id(), 1); + assert!(selected.suite.is_none()); + assert!(select_server_key_config(&[], None) + .expect("empty list is not an error without a policy") + .is_none()); + } + #[test] fn test_server_status_entry_full() { let entry = ServerStatusEntry { diff --git a/tng/src/tunnel/ingress/protocol/ohttp/security/mod.rs b/tng/src/tunnel/ingress/protocol/ohttp/security/mod.rs index 5f0cf819..943eef70 100644 --- a/tng/src/tunnel/ingress/protocol/ohttp/security/mod.rs +++ b/tng/src/tunnel/ingress/protocol/ohttp/security/mod.rs @@ -11,6 +11,7 @@ use crate::tunnel::utils::socket::{ }; use crate::{ config::ingress::{OHttpArgs, PathDefault}, + config::ohttp_aead::AeadSuites, error::TngError, tunnel::{ endpoint::TngEndpoint, @@ -182,6 +183,8 @@ pub struct OHttpSecurityLayer { passthrough_request_headers: Arc, /// Headers to copy from the outer (ciphertext) response to the inner (plaintext) response. passthrough_response_headers: Arc, + /// AEAD allowlist and preference order; `None` means no policy. + aead_suites: Option, } impl OHttpSecurityLayer { @@ -226,6 +229,16 @@ impl OHttpSecurityLayer { let ohttp_clients: OhttpClientCache = Default::default(); + let aead_suites = ohttp_args.aead_suites.clone(); + // One line per ingress OHTTP instance. `default = true` means no + // filter and no preference: the server's first config and its first + // advertised suite are used. + tracing::info!( + aead_suites = ?aead_suites.as_ref().map(|suites| suites.as_slice()), + default = aead_suites.is_none(), + "OHTTP ingress AEAD suite policy in effect" + ); + let passthrough_request_headers = Arc::new( ohttp_args .header_passthrough @@ -252,6 +265,7 @@ impl OHttpSecurityLayer { runtime, passthrough_request_headers, passthrough_response_headers, + aead_suites, }) } @@ -351,6 +365,7 @@ impl OHttpSecurityLayer { self.runtime.clone(), self.passthrough_request_headers.clone(), self.passthrough_response_headers.clone(), + self.aead_suites.clone(), ) .await .map_err(TngError::CreateOHttpClientFailed)?, From 0f97f744ead5100e8a5dc537ee3138c1d8862fca Mon Sep 17 00:00:00 2001 From: Kun Lai Date: Thu, 5 Nov 2026 14:43:33 +0800 Subject: [PATCH 3/3] docs: add naming and validation guidance to CLAUDE.md Both rules come from the OHTTP AEAD suite policy review. A name must match the value space it selects, and an invariant belongs to the type that owns it rather than to a global config pass. Assisted-by: Claude:fable-5 --- CLAUDE.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 7360314f..7c003f0a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -239,6 +239,26 @@ on: - "master" ``` +## Naming + +A name is a promise about scope. It must match what the field actually selects, no wider and no narrower. + +- Derive the noun from the value space. If only AEADs are selectable, `aead_suites` is right; `cipher_suites` (RFC 9180 defines it as KEM + KDF + AEAD) and `hpke_suites` (suggests the KEM is selectable) over-promise. +- Do not borrow a standard term whose meaning spans more than the knob does. In RFC 9458, `symmetric_algorithms` covers KDF and AEAD, so an AEAD-only list must not use `symmetric`. +- Avoid collisions with established terms from adjacent layers (TLS `cipher_suites`) even when the familiar name reads better. +- Design for the growth axis. If KDF or KEM may become configurable later, add a sibling field (`kdf`) instead of widening the existing name. Additive beats redefinition. +- Values must deliver what the name promises. If the name needs a sentence explaining what it does not cover, it is the wrong name. + +## Validation + +Validate a value where it is created, in the layer that owns the invariant. A pass over the whole config is a last resort, not the default. + +- Prefer a type that cannot hold an invalid value (a newtype with a fallible constructor or a custom deserializer) over a later `validate()` pass. Every consumer then receives an already-valid value. +- Keep the check in the module that owns the type. A feature-specific field does not belong in a global aggregate's validation hook; a shared entry point should not enumerate each module's rules. +- Do not hand-build location strings for error messages. Parsing and type errors already carry the location; a hand-written path rots when the shape changes. +- Put one feature's types in a module named for what they own, not in the generic entry module. +- Why: a global pass couples every feature to the entry point and is easy to bypass when a new construction path appears. An invariant held by the type cannot be bypassed. + ## API Compatibility When designing public APIs (REST endpoints, config fields, trait methods, public structs/traits), always design the full namespace structure upfront — do not defer naming decisions. Retrofitting a namespace layer later (e.g. inserting `/ohttp/` into an existing path like `/status/egress//keys` → `/status/egress//ohttp/keys`) breaks backward compatibility and is extremely costly. If a category of resources might grow multiple sub-resources in the future, include that category's namespace from day one.