Skip to content

Report which check refused a device-signature request - #407

Merged
marcjazz merged 1 commit into
mainfrom
feat/353-tier5-devsig-tracing
Sep 23, 2026
Merged

marcjazz merged 1 commit into
mainfrom
feat/353-tier5-devsig-tracing

Conversation

@marcjazz

Copy link
Copy Markdown
Collaborator

Picks up #353. Its four tiers are all merged, but they were scoped before authkestra-devsig's verification path was read closely — and it has the same shape tier 1 found in DPoP: the adapters record that verification failed, and nothing records which of nineteen checks did it.

What was actually silent

I measured this twice, because the first answer was wrong. A crude scan suggested most rejections were unlogged and that KeyNotBound was the flagship case. It isn't — KeyNotBound already has a good event, and so do the issuer and device-status rejections. Those stay, and the binding one says something a boundary log cannot: that the failure is the specific forgery the check exists to catch.

What genuinely had nothing is every rejection in signature.rs from the request-signature check onward:

bad signature · expired · lifetime too long
method · path · audience · query · body mismatch

Eight of the nineteen. A caller sees one flattened VerifyError and the adapters map the whole family onto a single 401, so for those eight the reason existed nowhere at all — debugging meant reading the source and guessing, which is exactly what #353 says about DPoP.

The fix

One report at the verify boundary, following verify_dpop_proof's pattern including the split into an _inner. Eight new call sites would have worked too; the boundary is better because the set cannot drift — a check added later is reported without anyone remembering to report it.

The field is VerifyError::code(), whose own doc already offered it as "suitable for metrics labels and log fields" and which nothing was using.

The replay pair keeps its own events despite the boundary, because each carries something it cannot see: the jti that was replayed, and the store's own error — which flattens into a plain ReplayDetected. Without that second one, a fail-closed outage would be indistinguishable in the logs from a genuine replay, and they are different kinds of event: a client being refused versus this service degrading.

Tests

Three, in a new tests/rejection_logging.rs:

test what it holds
a_request_binding_rejection_names_the_check_that_failed a genuinely-silent case now names itself
different_rejections_are_told_apart two rejections distinguishable from logs alone — over HTTP both are 401
a_rejection_never_logs_the_credentials neither the signature nor the attestation reaches the logs

That last one is asserted rather than left to review. #353 notes no existing instrumentation leaks credential material and that any new work must hold that line; a rejection echoing either JWS would put a live proof, or a reusable attestation, into whatever collects the logs.

tests/support gains #![allow(dead_code)] — dead code is checked per target, so a second target using half the fixtures turns the other half into errors under -D warnings.

What's left in #353

The tiers are done and the crates that landed after it was written are the remaining gap. Current measurement, for whoever picks it up:

crate spans pub fns note
authkestra-ssf 0 59 SET ingestion; 15 early returns, 1 logged
authkestra-policy 0 47 decision path is well covered; the two InvalidRequest returns are silent
authkestra-resource 1 43 still 0 info, thin spans — tier 3 improved it but did not finish

Worth noting #353 itself should probably be closed and re-filed against those, rather than staying open indefinitely against a table that no longer describes the code.

Checks

  • cargo fmt --all -- --check
  • cargo clippy --workspace --all-features --all-targets -- -D warnings
  • cargo test --workspace --all-features — exit 0, 75 suites
  • cargo deny check — advisories ok, bans ok, licenses ok, sources ok

🤖 Generated with Claude Code

https://claude.ai/code/session_01XaoYHiT9Vb85XhBdVCg9Xu

#353's four tiers are merged, but they were scoped before this crate's
verification path was looked at closely, and it has the same shape tier 1
found in DPoP: the adapters record *that* verification failed, and nothing
records which of nineteen checks did it.

Not all of them were silent, and the distinction matters. The issuer,
device-status and key-binding rejections each already had an event, and
the binding one says something a boundary log cannot — that the failure
is the specific forgery the check exists to catch — so those stay. What
had nothing was every rejection in signature.rs from the request-
signature check onward: bad signature, expired, lifetime too long, and
the method, path, audience, query and body mismatches. Eight of the
nineteen, and a caller sees one flattened VerifyError that the adapters
map onto a single 401, so for those eight the reason existed nowhere at
all.

Reported once at the `verify` boundary rather than at eight new call
sites, following `verify_dpop_proof`'s pattern exactly, including the
split into an `_inner`. Doing it there also means the set cannot drift:
a check added later is reported without anyone remembering to report it.
The field is `VerifyError::code()`, whose own doc already offered it as
"suitable for metrics labels and log fields" and which nothing was
using.

The replay pair keeps its own events despite the boundary, because each
carries something it cannot see — the jti that was replayed, and the
store's own error, which flattens into a plain ReplayDetected. Without
that second one a fail-closed outage would be indistinguishable in the
logs from a genuine replay, and they are different kinds of event: a
client being refused versus this service degrading.

Neither credential is logged, and a test asserts it rather than trusting
the review: a rejection that echoed the signature or the attestation
would put a live proof, or a reusable bearer-ish attestation, into
whatever collects the logs. The other two tests assert the reason
reaches the logs at all, and that two different rejections are
distinguishable from them alone — which over HTTP they are not, both
being 401.

`tests/support` gains `#![allow(dead_code)]`: dead code is per target, so
a second target using half the fixtures turns the other half into errors
under `-D warnings`.

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

codecov Bot commented Sep 22, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 93.75000% with 1 line in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
crates/authkestra-devsig/src/verify.rs 93.75% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

@marcjazz
marcjazz merged commit 35f7bcb into main Sep 23, 2026
21 of 22 checks passed
@github-actions github-actions Bot mentioned this pull request Sep 23, 2026
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.

1 participant