Skip to content

fix(kas): accept spec-compliant policy bindings - #4081

Closed
pflynn-virtru wants to merge 1 commit into
mainfrom
fix/kas-raw-policy-binding
Closed

pflynn-virtru wants to merge 1 commit into
mainfrom
fix/kas-raw-policy-binding

Conversation

@pflynn-virtru

@pflynn-virtru pflynn-virtru commented Sep 17, 2026 •

Copy link
Copy Markdown
Member

Fixes the KAS half of #3578. Split out of #3597, which depends on this.

The bug

The spec defines the key access object's policy binding as Base64(HMAC(KEY, POLICY)) (spec). #2095 added a branch so KAS would accept that alongside the legacy Base64(hex(HMAC)). It has never worked:

policyBinding := make([]byte, base64.StdEncoding.DecodedLen(len(b64)))  // 44 chars -> 33 bytes
n, err := base64.StdEncoding.Decode(policyBinding, []byte(b64))          // n == 32
if n == 64 {
    dehexed := make([]byte, hex.DecodedLen(n))
    _, err = hex.Decode(dehexed, policyBinding[:n])
    if err == nil { policyBinding = dehexed }   // hex path: replaced, right-sized
}
// raw path: policyBinding is still the untrimmed 33-byte buffer
dek.VerifyBinding(ctx, policyBody, policyBinding)

DecodedLen over-allocates. Only the hex branch swaps in a correctly-sized slice, so the raw branch hands 33 bytes to VerifyBinding, which compares with hmac.Equal against a 32-byte digest. hmac.Equal is length-sensitive, so it is always false. Confirmed present in released service/v0.9.0.

Net effect: no released KAS can accept a spec-compliant policy binding, and any SDK that starts emitting one becomes undecryptable against it.

The fix

Extract decodePolicyBinding, trimmed to the bytes actually decoded, and use it on the rewrap path. Both encodings are accepted; length disambiguates them (32 vs 64), so no version signal is needed. Legacy hex stays accepted indefinitely — TDFs are archival.

The extraction is there to make the fix testable; the inline block it replaces was 17 lines and is now 6.

Checklist

  • I have added or updated unit tests
  • I have added or updated integration tests (if appropriate)
  • I have added or updated documentation

Testing

TestDecodePolicyBindingIsTrimmed is the regression test, and it asserts the decoded length, with the over-allocation written out as a precondition. That matters: the buggy code produced the right bytes and only the wrong length, so a content comparison alone passes against it. Verified by reverting the one-line trim — the test fails with a trailing 0x0.

TestDecodePolicyBinding covers raw, legacy hex, and invalid base64.

go test ./service/kas/... -race green; golangci-lint clean on both files (the one sloglint hit in rewrap_test.go is pre-existing, just line-shifted by two added imports); make fmt clean.

Scope

Deliberately KAS-only: it widens what the server accepts and changes no output, so it can merge and deploy independently.

Left out on purpose, as separate concerns:

  • The SDK writer fix (fix(sdk): align policy binding encoding with spec, keep legacy compat #3597). It must not land until a KAS carrying this fix is deployed, and it is a breaking change when it does — worth a ! marker. Note release-please-config.sdk.json uses always-bump-patch, so the version number won't signal it.
  • Deleting verifyPolicyBinding. It has no production callers and is likely why the broken dual-accept looked correct — it reads like the verification path and its own hex handling is fine. Worth removing, but it is cleanup, not this fix.
  • Java and JS need the same writer change; KAS is Go-only, so nothing server-side there.

Worth checking any third-party KAS for the same untrimmed-buffer bug before relying on raw bindings against it.

🤖 Generated with Claude Code

@coderabbitai

coderabbitai Bot commented Sep 17, 2026

Copy link
Copy Markdown

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added comp:sdk A software development kit, including library, for client applications and inter-service communicati comp:kas Key Access Server size/m labels Sep 17, 2026
@github-actions

Copy link
Copy Markdown
Contributor
Benchmark results, click to expand

Benchmark authorization.GetDecisions Results:

Metric Value
Approved Decision Requests 1000
Denied Decision Requests 0
Total Time 163.08264ms

Benchmark authorization.v2.GetMultiResourceDecision Results:

Metric Value
Approved Decision Requests 1000
Denied Decision Requests 0
Total Time 85.430059ms

Benchmark Statistics

Name № Requests Avg Duration Min Duration Max Duration

Bulk Benchmark Results

Metric Value
Total Decrypts 100
Successful Decrypts 100
Failed Decrypts 0
Total Time 328.864214ms
Throughput 304.08 requests/second

TDF3 Benchmark Results:

Metric Value
Total Requests 5000
Successful Requests 5000
Failed Requests 0
Concurrent Requests 50
Total Time 42.096638428s
Average Latency 419.959582ms
Throughput 118.77 requests/second

The spec defines the policy binding as Base64(HMAC), but no released KAS
can decode that form. #2095 added a dual-accept branch and it has never
worked: base64.DecodedLen over-allocates, so a 44-character binding
decodes 32 bytes into a 33-byte buffer. Only the hex branch replaced that
buffer with a right-sized slice; the raw branch passed the untrimmed 33
bytes to VerifyBinding, which compares with hmac.Equal -- length
sensitive, so it always failed.

Extract decodePolicyBinding, trimmed to the bytes actually decoded, and
use it on the rewrap path. Legacy hex stays accepted indefinitely; TDFs
are archival.

This only widens what KAS accepts and changes no output, so it is safe to
deploy on its own. It is a prerequisite for fixing the SDK writer, which
is deliberately left for a follow-up: nothing may emit raw bindings until
a KAS that can read them is deployed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Paul Flynn <pflynn-virtru@users.noreply.github.com>
@pflynn-virtru
pflynn-virtru force-pushed the fix/kas-raw-policy-binding branch from 3077045 to ca178b0 Compare September 17, 2026 22:07
@github-actions

Copy link
Copy Markdown
Contributor

@github-actions

Copy link
Copy Markdown
Contributor
Benchmark results, click to expand

Benchmark authorization.GetDecisions Results:

Metric Value
Approved Decision Requests 1000
Denied Decision Requests 0
Total Time 248.372925ms

Benchmark authorization.v2.GetMultiResourceDecision Results:

Metric Value
Approved Decision Requests 1000
Denied Decision Requests 0
Total Time 141.874384ms

Benchmark Statistics

Name № Requests Avg Duration Min Duration Max Duration

Bulk Benchmark Results

Metric Value
Total Decrypts 100
Successful Decrypts 100
Failed Decrypts 0
Total Time 426.282406ms
Throughput 234.59 requests/second

TDF3 Benchmark Results:

Metric Value
Total Requests 5000
Successful Requests 5000
Failed Requests 0
Concurrent Requests 50
Total Time 1m1.632943326s
Average Latency 614.895236ms
Throughput 81.13 requests/second

@github-actions

Copy link
Copy Markdown
Contributor

⚠️ Govulncheck found vulnerabilities ⚠️

The following modules have known vulnerabilities:

  • otdfctl
  • service
  • tests-bdd

See the workflow run for details.

@pflynn-virtru

Copy link
Copy Markdown
Member Author

Security review: no findings

Reviewed this change specifically against the concern it invites — that relaxing policy-binding decoding opens a fail-open in an integrity control. It doesn't:

  • The comparison stays secret-keyed and length-sensitive. The only production VerifyBinding is HMAC-SHA256 + hmac.Equal, so anything decodePolicyBinding returns must be exactly 32 bytes and equal to the HMAC computed under the unwrapped DEK.
  • Both accepted encodings require the secret. Raw and hex are two spellings of the same HMAC; neither is producible without the DEK. The accepted set widens to exactly "base64 of the correct HMAC" and nothing else.
  • Edge cases stay fail-closed: empty hash, invalid base64, 64 bytes of invalid hex, and any other length all fail the compare or short-circuit to err400.
  • The old behaviour was a false negative, not a control. Trimming turns always-reject into correct-accept; it can't turn a false negative into a false positive.

Entitlement is unaffected — canAccess still runs after verification, over the same policy bytes that were HMAC'd.

One informational note, not a finding and not introduced here: the dead verifyPolicyBinding still logs the raw policyBinding value. Out of scope for this PR, but another small reason to delete it in the follow-up.

Worth saying plainly: this was an AI-assisted review, not a substitute for a human security sign-off on a change to an integrity check.

🤖 Generated with Claude Code

arkavo-com added a commit to arkavo-org/opentdf-platform that referenced this pull request Oct 8, 2026
Rewrap decoded keyAccess.policyBinding.hash into a DecodedLen-sized buffer
without trimming it, so the spec form Base64(HMAC-SHA256(DEK, policy))
(44 chars, 32 bytes) failed hmac.Equal on length and only the Go SDK's legacy
Base64(hex(HMAC)) form verified. Decode into buf[:n]; hex-decode only a
64-byte valid-hex value. Comparison stays constant-time and length-sensitive.

Port of opentdf#4081 (ca178b0) for rewrap.go/rewrap_test.go, plus
fork-only end-to-end coverage through verifyRewrapRequests: both forms
released; wrong HMAC, wrong lengths, odd/short/non-hex, bare hex, unpadded
or invalid base64 and empty hashes refused.

Unblocks OpenTDFKit emitting the spec binding (arkavo-org/OpenTDFKit#56).


Claude-Session: https://claude.ai/code/session_01BW3jJb6WzgesNADnPgGaef

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
arkavo-com added a commit to arkavo-org/OpenTDFKit that referenced this pull request Oct 8, 2026
…o #4081

TDFCrypto.policyBinding now writes Base64(HMAC-SHA256(DEK, Base64(policy)))
(44 chars, 32 raw bytes) per the OpenTDF spec, dropping the Go SDK's legacy
Base64(hex(HMAC)) form. Known-answer test derived from the old vector.

Rewrap requires a KAS that accepts the spec form: arkavo-org/opentdf-platform
#54 (deployed to platform.arkavo.net; verified live — spec form permitted,
tampered binding refused; stock Go decrypts the result). xtest runs the
upstream platform service, so pin it to opentdf/platform#4081
(ca178b05) until a service/* release includes that fix.

Closes #56

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW3jJb6WzgesNADnPgGaef
arkavo-com added a commit to arkavo-org/opentdf-rs that referenced this pull request Oct 9, 2026
)

keyAccess.policyBinding.hash is now Base64(HMAC-SHA256(DEK,
Base64(policy))), 44 characters, instead of the Go SDK's legacy
Base64(hex(HMAC)). Rewrap needs a KAS with opentdf/platform#4081;
platform.arkavo.net has it through arkavo-org/opentdf-platform#54.

The TDF-JSON, TDF-CBOR and xtest_cli JSON/CBOR writers passed the
already-base64 policy into calculate_policy_binding, which base64s it
again, so a KAS that verifies the binding refused them. They now pass
the policy JSON, so the HMAC input is the manifest's policy string.

X-Test pins the KAS to #4081 and the Go peer to #4063, as OpenTDFKit
does, until upstream releases include both.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
arkavo-com added a commit to arkavo-org/arkavo-edge that referenced this pull request Oct 9, 2026
* Trim stale guidance from AGENTS.md and the terminal CLAUDE.md

AGENTS.md drops its numbered headings and the build/test commands that
the pre-push checklist already covers. The terminal CLAUDE.md drops the
architecture, state and MCP sections that had drifted from the code, and
points the TUI testing tools at arkavo-mcp-macos, where they now live,
instead of the removed arkavo-test crate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Bump version to 0.101.2

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DB6GyYUSA2kkefLRhAHMoi

* Bump opentdf-rs to 0.16

0.16 writes the spec policy binding, Base64(HMAC-SHA256(DEK,
Base64(policy))), 44 characters, in place of the legacy Base64(hex(HMAC)).
Rewrapping what edge now writes needs a KAS that accepts the spec form:
platform.arkavo.net does through opentdf-platform#54; released upstream
KAS builds do not yet. The GGUF-TDF writer test now pins the spec form.

KeyAccessObject carries the split id 0.16 round-trips. Without it a
multi-KAS TDF from another SDK lost its sid on conversion. The field is
omitted when unset, so existing wire messages are unchanged. The three
copies of the KeyAccessObject <-> opentdf::KeyAccess conversion are now
one pair, so the sid cannot be dropped at one site and kept at another.

The kas feature of arkavo-tdf did not compile against 0.15 either:
KasClient::new takes an OpentdfConfiguration since 0.13. The client now
builds one with for_kas_legacy_rest, matching the legacy paths it
already uses for the public key. CI runs arkavo-tdf's tests with the kas
and a2a features, which nothing did before.

0.16 names the ZIP manifest entry manifest.json, where 0.15 wrote and
read only 0.manifest.json. GGUF-TDF archives are unaffected: edge's
writer names its own entry. Config bundles are affected: they are ZIP
TDFs from Tdf::encrypt, so a bundle sealed by this version cannot be
opened by an agent on 0.101.x or earlier, nor by released otdfctl or the
Go, Java and JS SDKs. Upgrade agents before the host that seals bundles.

Specs: TDF-010 (split id survives conversion) and TDF-011 (writers emit
the spec binding).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DB6GyYUSA2kkefLRhAHMoi

* Verify the policy binding before the A2A KAS rewraps a key

The kas.rewrap handler checked only that a binding claimed HS256 and was
not empty. A caller holding a wrapped key could send it with any policy
its entitlements satisfied, keep the original binding, and receive the
DEK for data whose real policy denied it.

The handler now recovers the DEK from the wrapped key, deriving it as
opentdf-kas's ec_unwrap does (ECDH with the header's ephemeral key, then
HKDF-SHA256 with the NanoTDF version salt), and checks
HMAC-SHA256(DEK, policy) over the base64 policy as sent, in constant
time. It accepts the spec Base64(MAC) that opentdf-rs 0.16 and
OpenTDFKit 5 write and the legacy Base64(hex(MAC)), as
opentdf/platform#4081 does. The check runs before ABAC, so the policy a
decision uses is the one the data was sealed with. A failed check is
PolicyBindingInvalid (JSON-RPC -32003) and nothing is rewrapped.

Tests seal a DEK from the writer's side of the ECDH, so they do not
share the handler's derivation, and open the rewrapped key as a client
does to show the released DEK is the bound one. With the check
disabled, the swapped-policy, wrong-key and placeholder-binding tests
fail. The binding module also pins the opentdf-rs/OpenTDFKit known
answer and an openssl-computed legacy vector. The server's delegation
test now sends a real binding instead of "test-binding-hash".

TDFS-006 now states what the KAS does. Its "tampering attempt logged
with forensic details" step is gone: nothing logged it before or after
this change. TDF-008 lists the rewrap checks in order.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DB6GyYUSA2kkefLRhAHMoi

* Make the TDF specs match what the code does

An audit of tdf.spec and tdf-security.spec against the code found
scenarios that named error variants that do not exist, claimed behavior
nothing implements, and cited code that had moved.

Marked wip, each with an issue naming the gap:
- TDF-002 and TDF-009: OpenTdfService seals wrappedKey under a key it
  discards and its decrypt always fails, so nothing it writes can be
  decrypted. The agent-credentials plan's layer B replaces this path.
- TDF-006: encrypt_stream buffers the whole stream.
- TDFS-009: no policy time window; only token expiry is enforced.
- TDFS-013: the rewrap session key is ECDH with the KAS's static key, so
  there is no forward secrecy.

TDFS-011 keeps the steps that hold (approved algorithms, no cleartext
DEK release, zeroized DEK). HSM custody and rotation move to a new wip
TDFS-016, which records that the agent server regenerates its KAS key
on every start.

Wording now matches the code: TdfError::Decryption, Decision::Deny and
KasError::AccessDenied, ChainBroken for a bad signature, Iroh as the
only BlobTransport, and no binding from PolicyBuilder. TDF-011 no longer
says OpenTdfService output can be rewrapped. Stale refs are updated.

Existing tests now carry the scenarios they exercise (TDF-003, 004, 005,
007, 011). Token-expiry tests move from TDFS-009 to TDF-005, and
keypair tests no longer claim TDFS-013. TDF-005 gains a test that a
token altered after signing is refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DB6GyYUSA2kkefLRhAHMoi

* Release the folded change as 0.102.0

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DB6GyYUSA2kkefLRhAHMoi

* Check the terminal guide for the sections it now keeps

The guide trim dropped the stale "Model Connection States" section and
renamed "Testing Requirements" to "TUI Testing Tools", but the doc test
still required the old headings. It now requires the sections the guide
keeps and checks that the TUI tool location the guide names exists,
since a stale path there is what the trim fixed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DB6GyYUSA2kkefLRhAHMoi

* Derive the test client's session key into the cipher's key type

CodeQL read the zeroed [u8; 32] HKDF output buffer in the rewrap test
helper as a hard-coded AES key. The buffer is now aes_gcm's Key, filled
by HKDF before use, so no literal flows to the cipher.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DB6GyYUSA2kkefLRhAHMoi

* Keep the per-IP rate limiter within its entry cap

check_rate_limit read the table size, evicted if it was full, and inserted
later without holding anything in between. Threads that each read one
below max_ip_entries all inserted, so the table grew past the cap that
bounds its memory. The nightly fuzz_rate_limit_concurrent target has
failed on this every night since at least 2026-10-02 ("Entry count 51
exceeds max 50").

Adding an entry now happens under a mutex: the capacity check, eviction
and insert run as one step. Lookups of existing entries stay lock-free.
Inside the lock the insert reuses an entry a concurrent first request from
the same IP has just added, rather than replacing it and refunding the
tokens that request spent.

The regression test fills the table to one below the cap, then releases
eight threads at once with new IPs. It failed 6 of 6 runs before the fix
and passes after.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DB6GyYUSA2kkefLRhAHMoi

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
@pflynn-virtru

Copy link
Copy Markdown
Member Author

Superseded by #4223, which landed the same KAS fix.

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

Labels

comp:kas Key Access Server comp:sdk A software development kit, including library, for client applications and inter-service communicati size/m

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant