Skip to content

fix(jose): adversarial hardening pass — close #6#9

Merged
moisesja merged 6 commits into
mainfrom
harden/issue-6-jose-enveloping
Jun 15, 2026
Merged

fix(jose): adversarial hardening pass — close #6#9
moisesja merged 6 commits into
mainfrom
harden/issue-6-jose-enveloping

Conversation

@moisesja

Copy link
Copy Markdown
Owner

Summary

Closes #6. Issue #6 asked for JWS/JWE enveloping building blocks; investigation found the JOSE layer (DataProofsDotnet.Jose: JWS/JWE/JWT/JWK/SD-JWT/SD-JWT VC/VC-JOSE) already implemented and passing — the issue was stale. Per the chosen path, this is a deep adversarial security/correctness hardening pass of that surface, fixing every confirmed weakness and pinning each with a regression test.

Method

A multi-agent workflow reviewed 9 dimensions (JWS bypass, JWE key-mgmt, ECDH-1PU, multi-recipient, JWK, SD-JWT/KB-JWT/VC, JWT, encoding/JSON, fail-closed/hygiene). Every finding was then put through 3 independent refute-by-default skeptics (exploitability / spec-correctness / already-mitigated lenses) and kept only on a ≥2/3 majority. Result: 11 findings verified, 5 confirmed, 6 refuted (correctly — by-design or already-guarded).

Confirmed findings fixed (each with a regression test)

Sev Finding Fix
HIGH SdJwtReconstructor walked the disclosed payload by unbounded recursion; a chained recursive-disclosure presentation (RFC 9901 §6.3) rooted in an issuer-signed _sd digest could drive an uncatchable StackOverflowException that terminates the host process — the strongest fail-closed violation. Depth-bound reconstruction (64) → MalformedJoseException (DISCLOSURE_INVALID).
MEDIUM A malformed/unsupported cnf (or issuer) key made CompactJwt.Verify escape as an uncaught NotSupportedException/crypto exception (reachable via an attacker-influenced cnf on the KB path). Fail closed as MalformedJoseException, which both call sites already handle.
LOW JWS reported SignerKid from the unauthenticated unprotected header. Report the verified signer only from the integrity-protected header; the unprotected kid stays a resolution hint.
LOW JWT verifier ignored typ (RFC 8725 §3.11 token confusion). Opt-in JwtValidationOptions.ExpectedType (case-insensitive, application/-prefix tolerant) + surfaced JwsParseResult.Typ.
LOW Base64Url.Decode accepted whitespace/padding, contradicting the no-pad contract. Strict base64url-alphabet validation.

Refuted (no change, with reasons): un-zeroed sender key on a 1PU build-throw, A256KW recipient-set commitment (by design), vct#integrity not validated (optional), padding-only base64 (subsumed by the strict fix), deterministic-number canonicalization (headers carry no floats).

API additions (opt-in, non-breaking)

  • JwtValidationOptions.ExpectedType — pin the JWS typ on JwtHandler.Verify.
  • JwsParseResult.Typ — the integrity-protected typ header.

Both are in PublicAPI.Unshipped.txt and exercised by Samples.JwtAndJwk (AC-9 gate green).

Verification

  • dotnet build DataProofsDotnet.sln0 warnings, 0 errors.
  • dotnet test DataProofsDotnet.sln781 passed, 0 failed (Jose 311, +14 new hardening regressions; no regressions elsewhere).
  • samples-coverage gate green (530 members across 6 packages, 0 uncovered); AC-6 hygiene + AC-7 API discipline green.
  • All crypto remains routed through NetCrypto; documented intentional algorithm omissions preserved; no new algorithms added.

🤖 Generated with Claude Code

Issue #6 asked for JWS/JWE building blocks; the JOSE layer was already
implemented and passing, so this is a deep adversarial security/correctness
hardening pass (9 review dimensions, each finding majority-vote verified).
5 confirmed findings fixed, each with a regression test
(Hardening/HardeningRegressionTests.cs):

- SD-JWT reconstruction is now depth-bounded and fails closed on a deep
  recursive-disclosure chain instead of overflowing the stack and crashing
  the host (uncatchable StackOverflowException) — the strongest fail-closed
  violation. (HIGH)
- A malformed/unsupported cnf/issuer key now fails closed as
  MalformedJoseException in CompactJwt.Verify instead of escaping as an
  uncaught NotSupportedException/crypto exception. (MEDIUM)
- JWS reports SignerKid only from the integrity-protected header; an
  unprotected-only kid is a routing hint, never the verified identity. (LOW)
- JwtValidationOptions.ExpectedType enforces RFC 8725 §3.11 explicit typing
  (opt-in); JwsParseResult.Typ surfaces the protected typ. (LOW)
- Base64Url.Decode is strict (rejects whitespace, padding, +/). (LOW)

New opt-in public API (ExpectedType, JwsParseResult.Typ) documented and
sample-covered. Full solution: 781 tests pass, build 0/0; AC-6/AC-7/AC-9
gates green.

Closes #6

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

@moisesja moisesja left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Automated review — PR #9 fix(jose): adversarial hardening pass — close #6

Solid hardening pass. The methodology (9 dimensions → 3 independent skeptics → majority-vote) is rigorous, each confirmed finding is paired with a regression test, the API additions are opt-in and non-breaking, and the PR description doubles as a useful security-audit trail. A few minor observations:


Base64Url.Decode — behavioral breaking change for lenient callers

The strict alphabet loop is the correct fix for JOSE contexts, but it changes the runtime behavior of an existing public API: any out-of-repo caller that previously passed =-padded or whitespace-containing input now gets a FormatException with no compile-time warning. The CHANGELOG frames this correctly as a correctness fix, but it is worth calling out explicitly so upgraders know to scan their own Base64Url call sites. A one-line note in the CHANGELOG ### Changed section (in addition to ### Security) would make the upgrade path clear.


Off-by-one on the depth guard (cosmetic)

private const int MaxReconstructionDepth = 64;
// …
if (depth > MaxReconstructionDepth)   // fires at depth 65 — allows 65 frames, not 64

The constant says 64 and the comment references the JsonNode.Parse default MaxDepth = 64, but > instead of >= means 65 frames are actually permitted. No real-world SD-JWT comes near either number and the test uses 200 links, so this is purely cosmetic. If you ever want the semantics to match the name exactly, swap to >= MaxReconstructionDepth.


samples-coverage-report.md contains an absolute local path

Samples directory: `/Users/moises/Projects/dataproofs-dotnet/samples`

Not a security issue, but the committed report will show a stale path whenever it is regenerated in CI or on another machine. Minor — worth addressing the next time the report is regenerated from a portable root.


Everything else looks correct

  • CompactJwt exception filter correctly passes through MalformedJoseException and OperationCanceledException before wrapping anything else.
  • TypeMatches correctly normalises the application/ prefix (case-insensitive, per RFC 8725 §3.11).
  • SignerKid now sourced from header.Kid (integrity-protected) rather than sig.Kid (unprotected) — closes the real identity-confusion risk.
  • 781/0 test result and samples-coverage gate green are good final confirmators.

None of the above block merge — the security fixes are sound.


Generated by Claude Code

The ac-5 gate pinned the ported JWS/JWE tests to a didcomm-dotnet source
commit SHA and diffed assertions against it. With didcomm-dotnet being
refactored, that pin is no longer meaningful, so the gate is removed:

- Drop the ac-5 job from .github/workflows/ci.yml (and its now-stale
  cross-references in the ac-3 comment and header).
- Delete the tasks/parity-diff tool (removed from the solution) and the
  tests/.../Parity/PARITY.md manifest.
- Mark AC-5 retired in the PRD (§9) and adjust the Phase B exit note.

The ported parity TEST suite under tests/DataProofsDotnet.Jose.Tests/Parity/
is KEPT — it is real JWS/JWE/JWK/KDF/AEAD coverage and continues to run as
part of the standard Jose test suite (ac-3 and build-test). JOSE conformance
is guaranteed by AC-3 (RFC vectors + oracle cross-verification).

Full solution: build 0/0, 792 tests pass (no test removed).

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

Copy link
Copy Markdown
Owner Author

Also folded in (per discussion): retired the ac-5 didcomm parity-diff gate (commit 7cd923c).

Root cause of the ac-5 CI failure was not a stale SHA — the pinned commit is main's HEAD on moisesja/didcomm-dotnet (even tagged). It was a bug in tasks/parity-diff (RunGit set both WorkingDirectory and git -C to the repo path, so CI's relative --didcomm-repo .parity-src/didcomm-dotnet was applied twice → fatal: cannot change to '…' → spurious "commit not found"). A one-line Path.GetFullPath would have fixed it.

But since didcomm-dotnet is being refactored, the source-diff pin is becoming meaningless, so rather than fix the tool we removed the gate:

  • Dropped the ac-5 job from .github/workflows/ci.yml (+ stale cross-references).
  • Deleted the tasks/parity-diff tool (removed from the solution) and tests/.../Parity/PARITY.md.
  • Marked AC-5 retired in the PRD §9 / Phase B note.

Kept the ported parity test suite (tests/.../Parity/* — real JWS/JWE/JWK/KDF/AEAD coverage); it still runs under ac-3 and build-test. JOSE conformance now rests on AC-3 (RFC vectors + oracle cross-verification).

Full solution: build 0/0, 792 tests pass (no test removed).

moisesja and others added 4 commits June 14, 2026 23:24
- CHANGELOG: add a ### Changed note that Base64Url.Decode is now strict
  (padded/whitespace input now throws FormatException) so upgraders scan
  their call sites — in addition to the existing Security entry.
- SdJwtReconstructor: depth guard uses `>=` so the permitted nesting matches
  the named bound exactly (MaxReconstructionDepth = 64 → depths 0..63).
- samples-coverage: emit repo-relative paths in the report so the committed
  artifact is machine-independent (no absolute local path); regenerated.

Full solution: build 0/0, 792 tests pass; samples-coverage gate green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Pre-existing CI failures:
- Windows build+test: RdfCanonicalizationConformanceTests failed because the
  .nq/.json fixtures were checked out as CRLF on windows-latest (core.autocrlf),
  inflating their byte length vs the LF N-Quads the canonicalizer emits. Add a
  .gitattributes pinning tests/fixtures/** (and *.nq/*.nt) to LF. All fixtures
  are already LF in the repo, so nothing is renormalized — this only constrains
  checkout.
- ac-10 smoke: the step hardcoded version="0.1.0" while packages pack as
  0.1.0-preview.2. Derive the version from the packed Core nupkg so the smoke
  install always matches what was packed.

NetCrypto 1.0.0 -> 1.1.0 (requested): the library source is unaffected. Compat
fixes for the two breaking changes 1.1.0 introduces:
- IKeyStore gained DeriveSharedSecretAsync(...) — implement it in the test
  RecordingKeyStore (delegates to the inner store, records the call).
- NetCrypto now ships a Base64Url type — add a `using Base64Url =
  DataProofsDotnet.Jose.Base64Url;` alias to the 6 samples that import both
  namespaces, resolving the ambiguity.

Full solution: build 0/0, 792 tests pass; samples-coverage green; all samples
run to exit 0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@moisesja
moisesja merged commit 6ae9e69 into main Jun 15, 2026
11 checks passed
@moisesja
moisesja deleted the harden/issue-6-jose-enveloping branch June 15, 2026 04:06
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.

Add JOSE enveloping (JWS/JWE) building blocks — the enveloping-proof half of the library

1 participant