Skip to content

fix: Bedrock SigV4 signing and secret redaction - #248

Merged
eric8810 merged 9 commits into
masterfrom
fix/bedrock-sigv4-signing-and-secret-redaction
Oct 15, 2026
Merged

eric8810 merged 9 commits into
masterfrom
fix/bedrock-sigv4-signing-and-secret-redaction

Conversation

@cunninghamcard-bit

@cunninghamcard-bit cunninghamcard-bit commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

Requested by li

Summary

Before: on master, Bedrock SigV4 signatures do not match AWS (the signing-key derivation swaps the HMAC key and message, and the canonical URI, query and header-value rules are missing), and AwsCredentials / CodexTokens print their secrets in {:?}. aimux-web sends the Settings key or the provider's env key to whatever base_url the caller passes, and env:VAR in a request reads any environment variable of the console process. Recordings keep the Prodia and Azure subscription key headers in clear text, and request URLs keep user:password@ in logs, errors and recordings. A Retry-After: Infinity turns into a hint of i64::MAX ms. The Python binding holds the GIL while it waits on the network, and the Flutter NativeFinalizer calls the uint64_t drop function through a void* signature.

After: signatures are computed as botocore's SigV4Auth computes them (tests pin botocore signatures), and both structs print [REDACTED]. aimux-web uses only a key carried in the request when the base URL is not the provider's default (no Settings key, no env key, env: refused), and env:VAR is accepted only for the provider's registered key variable. The two headers are recorded as [REDACTED]; userinfo is stripped wherever a request URL is shown. Non-finite Retry-After values give no hint. Every blocking Python call releases the GIL. The Flutter finalizer uses a new pointer-sized entry point, aimux_drop_handle_ptr(void *).

These are fixes for the released line, independent of the AI SDK migration series ([1/8] to [8/8]), so this PR can merge first.

Type of change

  • Bug fix (non-breaking)
  • New feature (non-breaking)
  • Breaking change
  • New provider
  • New / updated binding
  • Docs / RFC

Checklist

  • cargo fmt --all -- --check passes
  • cargo clippy --workspace --all-targets -- -D warnings passes
  • cargo test --workspace passes (cassette playback — no network/keys)
  • Added or updated cassette tests / contract fixtures for behavior changes
  • Updated relevant docs (README.md, docs/, rfc/, bindings/README.md)
  • Commit messages follow Conventional Commits

Notes for reviewers

CI artifact (zip) master a8e3a99 this PR bf0d19e change
ffi-x86_64-unknown-linux-gnu 3.77 MB 3.77 MB +3.6 KiB (+0.10%)
ffi-aarch64-apple-darwin 3.12 MB 3.12 MB +3.3 KiB (+0.11%)
ffi-x86_64-pc-windows-msvc 3.03 MB 3.03 MB +2.0 KiB (+0.07%)
node-x86_64-unknown-linux-gnu 55.30 MB 55.37 MB +69.4 KiB (+0.13%)
node-aarch64-unknown-linux-gnu 53.25 MB 53.33 MB +72.0 KiB (+0.14%)
node-aarch64-apple-darwin 15.06 MB 15.07 MB +6.9 KiB (+0.05%)
node-x86_64-apple-darwin 13.60 MB 13.61 MB +9.6 KiB (+0.07%)
node-x86_64-pc-windows-msvc 10.57 MB 10.59 MB +18.6 KiB (+0.18%)
node-aarch64-pc-windows-msvc 9.87 MB 9.88 MB +4.4 KiB (+0.05%)

The signing key chain passed every HMAC its key and data swapped and
dropped the AWS4 prefix on the secret, so AWS rejected every request
signed with an access key. The canonical request also left path
segments unencoded (the ':' in Bedrock model ids), kept the query
unsorted and did not collapse header whitespace.

Bedrock, Claude on AWS and Polly share this signer. The new tests pin
signatures computed by botocore's SigV4Auth.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A3WxDBWBA4ituJjXsHaEAa
…tput

AwsCredentials and CodexTokens derived Debug, so a {:?} in logs or an
error message printed the secret access key, session token, and the
Codex access and refresh tokens. Both now have hand-written Debug impls
that print [REDACTED] for the secrets.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A3WxDBWBA4ituJjXsHaEAa
Rust's f64 parser accepts `inf`, `Infinity` and `NaN`, and `as i64`
saturates infinity, so `retry-after-ms: Infinity` produced an
i64::MAX hint. The core retry loop dropped it through its window check,
but the public hint (FFI `aimux_error_retry_ms`, the Node and Python
`retry_ms`) reported it. A non-finite value, including seconds that
overflow once converted to milliseconds, is now no hint, as upstream
never uses it either.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A3WxDBWBA4ituJjXsHaEAa
is_sensitive_key matched only cookie, key, authorization, api-key and
*token names, so X-Prodia-Key and Azure's Ocp-Apim-Subscription-Key were
written in clear text into recordings (and logs, which share the
policy) whenever recording was on. Both names are now sensitive; a
recording test sends them through the HTTP layer and checks that the
stored exchange holds [REDACTED].

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A3WxDBWBA4ituJjXsHaEAa
sanitized_request_url dropped only the query, so a base URL written as
https://user:password@host kept its credentials in tracing fields,
ApiCallError.url and recorded exchanges. It now clears the username and
password too, also for a URL that does not parse. The download guard's
rejection message reuses it, so a blocked ftp://user:pass@ URL no
longer echoes the password.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A3WxDBWBA4ituJjXsHaEAa
The Dart NativeFinalizer called aimux_drop_handle(uint64_t) through a
void (*)(void *) pointer. On 32-bit ARM (armeabi-v7a, which the release
ships) a uint64_t argument does not travel like a pointer, so the drop
received a garbage high word and a GC'd Model or ProviderHandle leaked
its handle.

aimux-ffi now exports aimux_drop_handle_ptr(void *), which takes the
handle as a pointer-sized value and drops it, and the Flutter finalizer
uses it. Tests: the Rust unit test and the export smoke test release
handles through it, and a Flutter test checks it releases the handle
its address names.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A3WxDBWBA4ituJjXsHaEAa
Fourteen of the binding's block_on calls (generate_text,
generate_object, consume_stream_text, generate_text_as_openai,
ProviderHandle.list_models, get_model_specs and the eight multimodal
calls) held the GIL while they waited on the network, so every other
Python thread stopped for the whole request. They now run inside
allow_threads, as decide() and the stream iterator already did, and so
do the recording flushes, which wait on the writer thread. Results and
errors are unchanged.

test_gil.py serves the mock API from a thread of the test process: it
can only answer while the call releases the GIL. Without the fix both
calls fail with their 10 s total timeout.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A3WxDBWBA4ituJjXsHaEAa
A request's `api_key: "env:VAR"` read any environment variable of the
console process. Together with a caller-supplied base_url, a client of
a non-loopback console could send any server variable to a host of its
choice. resolve_api_key now accepts only the variable the provider's key
is registered under (the native providers' standard variable, or the
registry / overlay entry's) and refuses every other name before reading
it. This covers the calls, cache-probe and replay paths, which all
resolve keys through it.

aimux-providers gains provider_api_key_env(), the variable provider()
reads a key from when none is passed, so the console does not keep its
own copy of the registry.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A3WxDBWBA4ituJjXsHaEAa
/api/calls and /api/cache-probe resolved the key from the request spec,
then the Settings key store, then the provider's env var, and passed
the request's base_url to the provider unchanged. A client could point
base_url at its own host and receive the console's key. Replay had the
same hole through an imported recording's base URL.

When the base URL differs from the provider's own default, only a key
carried in the request (plaintext, loopback binds only) is used: the
Settings key and the env var are not read, env: references are
refused, and a call without a key goes out with an empty key instead of
the provider's env fallback. The test sends calls with a custom
base_url to a local server and checks that neither the stored key nor
the env key arrives.

The alignment doc's §0.6 note now records the web fixes as done.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A3WxDBWBA4ituJjXsHaEAa

@eric8810 eric8810 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

评审结论:批准合并

9 个提交与 #228 的 9 项一一对应(N1/B11/B12/B13/S2-1/S2-2/N2/C30/S2-3,经 issue 原文核对),SigV4 修复经独立复算验证正确,所有 redaction 均有诱饵 canary 测试且实测全绿。

关键核验:

  • SigV4:旧 bug 确认为 HMAC key/message 全链互换;修复后的派生链(AWS4+secret→date→region→service→aws4_request)、canonical request、非 S3 URI 逐段双重编码、query 解码重编码排序、header 空白折叠均符合 AWS 文档。4 个 pinned 签名用独立实现按官方文档逐位复算,全部一致。
  • redaction:录制层 X-Prodia-Key / Ocp-Apim-Subscription-Key 落盘 [REDACTED];URL userinfo 在日志/错误/录制三处统一剥除(sanitized_request_url);web 端到端用真实 TCP capture server + 三个诱饵证明 stored/env key 不外发到自定义 base_url。
  • RFC-0040 §3.1 对齐:Some("") 显式空值不落回 env 的语义恰是 RFC 要求的提前落地;错误只含字段名/env 名。
  • 本机实跑(/tmp 抽取副本):sigv4 5/5、aimux-web 26/26、core error/recording 59、http_recording 5/5、ffi exports 22/22。

两点请回复:

  1. native 路径空 key 实际发 Authorization: Bearer (空 bearer)而非字面"不带 key"——安全等价,但与描述有出入,可考虑省略该 header;
  2. x-amz-content-sha256 作为签名头是本实现自选(botocore 对非 S3 默认不加该头),请确认 pin 值是给 botocore 显式加该头后算出的。

注意:diff 里对 master 的大额删除是分叉后 RFC 批次合入的幻影,rebase 即消;与 #247 在 wire.rs 的冲突按描述由后合方解决。roadmap:#228 追踪的 master 线修复,无节点对应,与"master 线修复不等 V4 迁移"的分工一致。

@eric8810
eric8810 merged commit 8d5e629 into master Oct 15, 2026
24 checks passed
cunninghamcard-bit pushed a commit that referenced this pull request Oct 16, 2026
Brings in RFC-0042 (#212) and the Bedrock SigV4 fix (#248); no conflicts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112xDERMUyD5JALkFhnsdWS
cunninghamcard-bit pushed a commit that referenced this pull request Oct 16, 2026
…low-ups, the RFC-0043 docs, the RFC-0036 §12.2 back-link, a test race fix and the review follow-ups

The restored lower-layer tests take this layer's types and JSON: the
trace suite's stream file is a `LanguageModelFile`, the repeated-id test
reads the tool result's `input` from the AI SDK JSON, and the FFI
recoverable-frame test finds the error part by its `type`. The Gemini
media-type check keeps this layer's `LanguageModelFile`, and the
alignment doc's decision paragraph keeps this layer's camelCase text with
the Anthropic adapter added.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A3WxDBWBA4ituJjXsHaEAa

Copy link
Copy Markdown
Contributor Author
eric 的意见 我们的处理 位置
空 key 实际发出的是一个空凭据头,不是"不带 key" 代码不改:这和上游 @ai-sdk/openai 显式传 apiKey: '' 的行为一致,V4 栈里用上游录制的 empty-api-key fixture 钉住了。不准确的是文档措辞,master 上由 #266 改正(已合并),栈里 [5/8] 也改了(e03b5f1e) tools/aimux-web/README.md、rfc/0029-web-console.md
x-amz-content-sha256 是我们自己选择签进去的;pin 是怎么算出来的 4 个 pin 都是 botocore 1.43 的 SigV4Auth 先显式加 X-Amz-Content-SHA256 再签出来的,按三个版本复算,13 个 pin 全部一致。上游 aws4fetch 对 Bedrock 不发也不签这个头,V4 栈里 Bedrock 和 Claude on AWS 已改成 aws4fetch 的规则,只有 Polly 保留 botocore 的规则。[8/8] 的 pin 测试补了来源说明(13845e1c) aimux-provider-utils/src/sigv4_fetch.rs

Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants