Skip to content

fix(jose): report the verified signer kid from the unprotected JWS header (#10)#11

Merged
moisesja merged 2 commits into
mainfrom
fix/issue-10-jws-unprotected-kid
Jun 16, 2026
Merged

fix(jose): report the verified signer kid from the unprotected JWS header (#10)#11
moisesja merged 2 commits into
mainfrom
fix/issue-10-jws-unprotected-kid

Conversation

@moisesja

Copy link
Copy Markdown
Owner

Closes #10. Bumps the release line to v1.0.1.

Problem

JwsParser resolves the verifying key from the per-signature unprotected header kid and verifies the signature against it, but previously returned JwsParseResult.SignerKid == "" whenever the integrity-protected header carried no kid — discarding the very identity the signature proved.

DIDComm Messaging v2.1 places the signer kid in the unprotected JWS header, so every signed envelope and every authcrypt(sign(...)) composition lost its signer identity on unpack. This is the only gap behind the five failing Appendix C.2/C.3 interop vectors (c2-eddsa-signed, c2-es256-signed, c2-es256k-signed, c3-authcrypt-p256, c3-authcrypt-p521); the JWE/KDF/apu/apv/1PU byte-equivalence was already correct.

Fix

SignerKid is now the kid that resolved the key under which the signature verified — the protected header is preferred when present, otherwise the unprotected kid is reported:

var verifiedKid = !string.IsNullOrEmpty(header.Kid) ? header.Kid : (sig.Kid ?? string.Empty);
return new JwsParseResult(header.Alg, verifiedKid, payloadBytes) { Typ = header.Typ };

This is sound because verification has already succeeded: an attacker who rewrites the unprotected kid to K' makes the parser resolve K''s key, under which the original signature cannot verify (they don't hold K''s private key), so a forged kid never reaches the result. This corrects the over-conservative issue #6 hardening decision (Finding #2), whose regression test is updated here.

Unchanged behavior: a kid in the protected header is reported exactly as before, and a JWS whose protected and unprotected kid disagree is still rejected (MalformedJoseException). The compact path (no unprotected header) is unaffected. No public API members were added.

Tests

  • Reversed the prior-decision regression test → JwsJson_KidOnlyInUnprotectedHeader_VerifiesAndReportsTheResolvedSignerKid.
  • Added JwsJson_ConflictingProtectedAndUnprotectedKid_IsRejected (agreement check still fires).
  • Added JwsGeneral_KidOnlyInUnprotectedHeader_VerifiesAndReportsTheResolvedSignerKid (signatures-array shape used by DIDComm multi-recipient / authcrypt).
  • Full Jose suite: 313/313 pass.

Adversarial review (AGENTS.md §2)

A dedicated adversarial subagent attempted 7 concrete attacks (kid aliasing, multi-sig forged victim kid, empty/duplicate/case/whitespace kid variants, compact path) — verdict: NOT EXPLOITABLE. Every attack is stopped by the protected-vs-unprotected agreement check (reachable in both flattened and general-array paths), post-verification ordering, strict JSON (AllowDuplicateProperties = false), Ordinal comparison, and the fail-closed catch blocks. The one finding (a kid-ignoring resolver) is pre-existing, not exploitable for the DIDComm consumer (whose production resolver returns a key only when key.Kid equals the requested kid), and left out of scope to keep this fix minimal.

Acceptance criteria

  • Flattened/general JWS with kid only in the unprotected header → SignerKid equals that kid.
  • kid in the protected header → unchanged.
  • Conflicting protected vs unprotected kid → still rejected.
  • The C.2/C.3 vectors' shape (unprotected-only kid, flattened + general) round-trips.

Release

v1.0.1 publishes when the v1.0.1 git tag is pushed (publish.yml, gated environment). Not done in this PR — tagging is the explicit release trigger.

🤖 Generated with Claude Code

…ader (#10)

JwsParser resolves the verifying key from the per-signature unprotected
header `kid` and verifies the signature against it, but previously returned
`JwsParseResult.SignerKid == ""` whenever the integrity-protected header
carried no `kid` — discarding the very identity the signature proved.

DIDComm Messaging v2.1 places the signer `kid` in the unprotected JWS
header, so signed and authcrypt(sign(...)) envelopes lost their signer
identity on unpack (the five Appendix C.2/C.3 interop vectors).

SignerKid is now the `kid` that resolved the key under which the signature
verified: the protected header is preferred when present, otherwise the
unprotected `kid` is reported. This is sound because verification has
already succeeded — a rewritten unprotected `kid` resolves a different key
under which the signature cannot verify, so a forged `kid` never reaches
the result. Corrects the over-conservative issue #6 hardening decision
(Finding #2).

Unchanged: a protected-header `kid` is reported as before; a JWS whose
protected and unprotected `kid` disagree is still rejected. Verified by a
full Jose suite (313/313) and an adversarial review (7 attacks, none
exploitable). Bumps the release line to v1.0.1.

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

Copy link
Copy Markdown
Owner Author

The fix and its security reasoning are solid. One issue needs to be corrected before merging:

Bug: Duplicate ## [1.0.1] entry in CHANGELOG.md

The diff adds the ## [1.0.1] - 2026-06-16 block twice, back-to-back, with identical content — a generation artifact. Please remove one of the two duplicate blocks so the changelog stays clean.


Everything else looks good:

  • Fix logicvar verifiedKid = !string.IsNullOrEmpty(header.Kid) ? header.Kid : (sig.Kid ?? string.Empty) is correct. Protected header is preferred; unprotected is the fallback only after verification has already succeeded, so the over-conservative concern from the Add JOSE enveloping (JWS/JWE) building blocks — the enveloping-proof half of the library #6 hardening pass doesn't apply here.
  • Agreement check preserved — conflicting protected/unprotected kid is still rejected (MalformedJoseException), maintaining the right defense-in-depth layer.
  • Tests — three targeted cases cover flattened-unprotected-only, general-array-unprotected-only, and the conflicting-kid rejection. 313/313 passing is a clean bar.
  • Version bumpDirectory.Build.props 1.0.0 → 1.0.1 is appropriate for a patch-level bug fix.
  • XML doc on SignerKid — updated accurately to reflect the new semantics.

Generated by Claude Code

A rejected-then-reapplied edit during authoring left two identical
## [1.0.1] entries back-to-back. Keep one.

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

Copy link
Copy Markdown
Owner Author

Addressed in 6601eef — removed the duplicate ## [1.0.1] block; the changelog now has a single [1.0.1] entry above [1.0.0]. Thanks for the catch (it was a rejected-then-reapplied edit artifact from authoring).

No code changes; the fix, tests, and version bump are unchanged.

@moisesja moisesja self-assigned this Jun 16, 2026
@moisesja moisesja added this to the 1.0.1 milestone Jun 16, 2026
@moisesja
moisesja merged commit e402a04 into main Jun 16, 2026
11 checks passed
@moisesja
moisesja deleted the fix/issue-10-jws-unprotected-kid branch June 16, 2026 15:34
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.

JwsParser drops the verified signer kid when it is in the JWS unprotected header (breaks DIDComm v2.1 signed/authcrypt-sign conformance)

1 participant