fix(security): receive-path correctness (#22, #23, #24)#38
Conversation
Resolves three Low-severity audit findings on the unpack/JOSE receive surface. Each ships with tests; an adversarial red-team pass (AGENTS.md §2) found and fixed one further gap. Full suite 615 green; warnings-as-errors clean. - #22 EnvelopeReader gains a defensive ArgumentException -> MalformedMessageException boundary catch at both JWS/JWE delegation points. The originally-reported escape is already resolved upstream (DataProofsDotnet.Jose now wraps the AEAD's wrong-length-iv/tag ArgumentException and base64 FormatException as MalformedJoseException) — verified empirically — so this is a boundary guard + a regression test (wrong-length iv -> MalformedMessageException). - #24 Base64Url.Decode now rejects '=' padding, embedded whitespace, and '+'/'/' (anything outside [A-Za-z0-9-_]) per RFC 7515 §2 / RFC 4648 §3.2-3.3. Red-team follow-up: ForwardProcessor maps the resulting FormatException to MalformedMessageException so a malformed forward attachment no longer escapes the mediator as a raw exception. - #23 UnpackResult.AnonymousSender is derived from the outermost encrypt layer (matching its documented semantics) instead of OR-accumulating across layers; the contradiction the finding described was already blocked by the #17 gate. Cross-repo note: #22's underlying defect is already fixed in the dependency (DataProofsDotnet.Jose); this repo adds the boundary guard + regression coverage. Closes #22, closes #23, closes #24 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
moisesja
left a comment
There was a problem hiding this comment.
Review — solid, honest, well-tested. No blockers; one deliberate-decision item I'd like your read on before it lands.
This is careful work. The scoping note is refreshingly honest: #22 and #23 were already largely closed (the upstream MalformedJoseException wrap and the merged #17 gate), and you say so rather than overclaiming. The red-team pass earning its keep on the unguarded ForwardProcessor.ExtractAttachmentBytes is exactly what AGENTS.md §2 is for. Every change fails in the safe (reject) direction — no new attack surface. I verified the load-bearing claims myself; specifics below in priority order.
1. #24 strictness is global, and it tightens the mediator's tolerance of peer forward attachments
I empirically ran System.Buffers.Text.Base64Url.DecodeFromChars on net10.0 to confirm the "before" behavior:
| input | before (BCL) | after (#24) |
|---|---|---|
Mw==, SGVsbG8= (padding) |
ACCEPT | reject |
| space / tab / CRLF | ACCEPT (silently stripped) | reject |
a+b/ (+//) |
reject | reject |
So padded/whitespaced base64url was previously accepted at every Base64Url.Decode call site. On the from_prior JWT path (FromPriorValidator.cs:69-71) this tightening is a clear win — non-canonical encoding over signed bytes is a real parser-differential concern. But the same codec backs the mediator's forward relay (ForwardProcessor.cs:119): a forward whose data.base64 carries = padding is now rejected (MalformedMessageException) and no longer relayed, where before it relayed fine. On the relay path the mediator just passes bytes onward and the real recipient re-parses, so the security upside of strictness there is low while the interop cost (refusing a peer's forward) is concrete.
Question: are you certain padded data.base64 is out-of-spec for DIDComm v2.1 attachments? The JOSE no-pad rule is unambiguous for JWS/JWE segments, but attachment data.base64 is looser historically (Aries RFC 0017). If you're not confident every peer emits no-pad, consider keeping the forward-attachment decode lenient while keeping the JWT/JOSE paths strict.
Mitigating fact I checked: the strict change touches only four sites — local signer d, OOB URL, from_prior, and forward attachment. Externally-resolved DID-doc key material decodes inside DataProofsDotnet.Jose, not this codec, so resolved peers' keys are unaffected. Blast radius is contained.
2. The #22 and #23 tests guard the contract but don't exercise the new code
Wrong_length_iv_throws_MalformedMessageExceptionpasses via the upstreamMalformedJoseExceptioncatch (as your own test comment notes) — delete the newcatch (ArgumentException)inEnvelopeReaderand it stays green. The boundary catch is genuinely untested. A unit test with a stub JWS/JWE parser that throwsArgumentExceptionwould actually cover it.AnonymousSender_reflects_the_outermost_encrypt_layerpasses onmaintoo: for the only legal multi-encrypt shape (anoncrypt(authcrypt)) the old OR-accumulation already yieldedanonymous=true. The behavioral difference only appears on illegal shapes the #17 gate rejects, so this can't regression-guard the change. Fine for defense-in-depth — just don't mistake it for a regression test.
3. Minor: the ArgumentException catch is broad and its message is over-specific
The try wraps the whole JweParser.Parse / JwsParser.Parse call, which invokes consumer-supplied lookups — so a buggy IInternalSecretsLookup that throws ArgumentException (or an internal ArgumentNullException) gets relabeled "Malformed JWE field length (iv/tag)", masking a non-input error behind a misleading cause. Fail-closed + preserved InnerException makes it acceptable, but a neutral message ("Malformed JWE.") would age better.
None of this blocks merge — #24's core is correct and the from_prior coverage is the right call. I mainly want your decision on (1), since it changes what a mediator will relay. (I did not re-run the full 615-test suite locally; trusting CI for that, but I did verify the BCL behavior and the call-site blast radius directly.)
…ver the boundary catch - #24 (decision): keep strict no-pad Base64Url.Decode for the spec-mandated paths (from_prior JWTs, OOB URLs, JWK), but relay forward attachments leniently via a new Base64Url.DecodeRelaxed. Attachment data.base64 has no no-pad requirement (FR-ATT-02, cf. Aries RFC 0017) and the mediator only relays bytes the recipient re-parses strictly, so a peer's padded forward is no longer refused. Genuinely-invalid base64 still maps to MalformedMessageException. + test that a padded attachment is relayed. - #22 (coverage): add a unit test that drives the EnvelopeReader ArgumentException boundary catch directly (a throwing signer lookup → MalformedMessageException, inner preserved). The prior wrong-length-iv test passes via the upstream wrap, so it only guarded the contract — this exercises the new code. - #22 (nit): neutral catch messages ("Malformed JWE." / "Malformed JWS.") so a buggy consumer-lookup ArgumentException isn't mislabeled as a field-length error. - #23: relabel the AnonymousSender test as characterization/defense-in-depth (it can't regression-guard — the #17 gate already rejects the shapes where the old accumulation differed). Full suite 617 green; warnings-as-errors clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Thanks — all three addressed in c4ef29d. Full suite 617 green. 1. Forward-attachment strictness → relaxed (your decision item)You're right, and I checked the spec to be sure: no-pad is mandated only for the JOSE/JWT/OOB paths — FR-ENC-13/14 (apv/apu), FR-SIG-04 (signed payload), FR-OOB-02 ("base64url(whitespace-stripped …)"). Attachment So: kept strict 2. Tests now exercise the new code
3. Broad catch / messageMessages are now neutral — Captured the strictness-scoping takeaway as lesson L-027. Ready for re-review. |
Summary
Resolves three Low-severity audit findings on the unpack / JOSE receive surface (#22, #23, #24). Each fix ships with tests, and the mandatory adversarial red-team pass (AGENTS.md §2) found one further gap that is fixed here.
Full suite: 615 tests green;
dotnet buildclean under warnings-as-errors.main)Jose/Encryption/JweParser.cs+Crypto/Aead/*, which were deleted when the JOSE layer was delegated toDataProofsDotnet.Jose. Investigation (and an empirical test) shows the reported raw-ArgumentExceptionescape is already resolved upstream — the dependency's parser now wraps the AEAD's wrong-length-iv/tagArgumentException(and base64FormatException) asMalformedJoseException. This PR adds a defensive boundary catch inEnvelopeReader(ArgumentException → MalformedMessageExceptionat both JWS/JWE delegation points) plus a regression test, so the unpack contract is robust regardless of the delegate. Cross-repo: the root defect lives in the dependency and is already fixed there.Fixes
ArgumentException → MalformedMessageExceptionboundary catch at the JWS+JWE delegation points inEnvelopeReader; regression test (wrong-length iv →MalformedMessageException).Base64Url.Decoderejects=padding, whitespace, and+//(anything outside[A-Za-z0-9-_]) withFormatException, per RFC 7515 §2 / RFC 4648 §3.2-3.3.UnpackResult.AnonymousSenderderived from the outermost encrypt layer (matching its doc) instead of OR-accumulating across layers. The contradiction the finding described was already blocked by the merged #17 gate.Adversarial red-team pass (AGENTS.md §2)
Subagents hit the session limit, so I ran the pass inline against the real code:
UnpackResultreturn; the independent-flags semantics carry no dangerous false-positive.ForwardProcessor.ExtractAttachmentByteshad notry/catch, so a malformed forward attachment threw a rawFormatExceptionout of the mediator'sProcessAsync— the issue's premise ("call sites mapFormatException") was false there. Now mapped toMalformedMessageExceptionwith a regression test.Test plan
MalformedMessageException(not a raw exception).+//→FormatException; clean no-pad input still round-trips; an end-to-end paddedfrom_priorsegment →ProtocolException; a malformed forward attachment →MalformedMessageException.anoncrypt(authcrypt)yieldsAnonymousSender=true(outermost anon) andAuthenticated=true/SenderKid(inner authcrypt).Closes #22, closes #23, closes #24
🤖 Generated with Claude Code