From ef04a5e17dda0f0a12ccfd055e09ae7356ed1596 Mon Sep 17 00:00:00 2001 From: Moises E Jaramillo Date: Tue, 16 Jun 2026 22:59:08 -0400 Subject: [PATCH 1/2] fix(security): receive-path correctness (#22, #23, #24) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- CHANGELOG.md | 23 ++++ .../Composition/EnvelopeReader.cs | 36 +++++- src/DidComm.Core/Jose/Base64Url.cs | 22 +++- .../Protocols/Routing/ForwardProcessor.cs | 15 ++- tasks/lessons.md | 16 +++ tasks/todo20260617T021817Z.md | 119 ++++++++++++++++++ .../Composition/EnvelopeReaderTests.cs | 45 +++++++ .../Envelopes/Encryption/Base64UrlTests.cs | 51 ++++++++ .../Routing/ForwardProcessorTests.cs | 22 ++++ .../Rotation/FromPriorRotationTests.cs | 1 + 10 files changed, 343 insertions(+), 7 deletions(-) create mode 100644 tasks/todo20260617T021817Z.md create mode 100644 tests/DidComm.Core.Tests/Envelopes/Encryption/Base64UrlTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index d0cec54..93fc0db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,29 @@ All notable changes to didcomm-dotnet are documented here. Format follows ## [Unreleased] +### Fixed — receive-path correctness (`feat/security-receive-correctness`) + +Resolves three Low-severity audit findings on the unpack/JOSE receive surface (GitHub issues +#22, #23, #24). Each ships with tests and an adversarial red-team pass; full suite (615 tests) green. + +- **Unpack honors its exception contract for non-canonical field lengths (#22).** `EnvelopeReader` + gains a defensive `ArgumentException → MalformedMessageException` boundary catch at both the JWS and + JWE delegation points. Note: the originally-reported escape is **already resolved upstream** — the + delegated `DataProofsDotnet.Jose` parser now wraps the AEAD's wrong-length-iv/tag `ArgumentException` + (and base64 `FormatException`) as `MalformedJoseException` — so this is a boundary guard plus a + regression test asserting wrong-length-iv → `MalformedMessageException`. +- **Strict JOSE base64url decoding (#24).** `Base64Url.Decode` now rejects `=` padding, embedded ASCII + whitespace, and standard-base64 `+`/`/` (anything outside `[A-Za-z0-9-_]`) with `FormatException`, + per RFC 7515 §2 / RFC 4648 §3.2-3.3, closing a parser-differential / non-canonical-encoding gap on + the from_prior, forward-attachment, and OOB receive paths. Red-team follow-up: `ForwardProcessor` + now maps that `FormatException` to `MalformedMessageException` so a malformed forward attachment no + longer escapes the mediator as a raw exception. +- **`UnpackResult.AnonymousSender` derived from the outermost encrypt layer (#23).** The flag is now + computed from the outermost encrypt layer's alg (matching its documented semantics) instead of + OR-accumulating across layers. The contradiction the finding described (`authcrypt(anoncrypt)` → + both flags true) was already unreachable after the #17 composition gate; this makes the code match + its contract by construction. + ### Security — Medium-severity audit remediation (`feat/security-medium-cluster`) Remediates the five Medium-severity findings from the multi-agent security & compliance audit diff --git a/src/DidComm.Core/Composition/EnvelopeReader.cs b/src/DidComm.Core/Composition/EnvelopeReader.cs index 594e920..a803f72 100644 --- a/src/DidComm.Core/Composition/EnvelopeReader.cs +++ b/src/DidComm.Core/Composition/EnvelopeReader.cs @@ -147,10 +147,10 @@ public static UnpackResult Unpack( Authenticated: authenticated, NonRepudiation: nonRepudiation, // The two flags are independent (matches SICPA reference impl): - // anonymous reflects whether the *outermost* encrypt layer was - // anoncrypt (true) or authcrypt (false); authenticated reflects - // whether any layer (signed or authcrypt) bound a sender identity. - // anon-encrypt + inner-sign sets both flags. + // anonymous is derived strictly from the *outermost* encrypt layer's alg — + // anoncrypt (true) or authcrypt (false), set once when that layer is unwrapped + // (#23) — while authenticated reflects whether any layer (signed or authcrypt) + // bound a sender identity. anoncrypt-encrypt + inner-sign sets both flags. AnonymousSender: anonymous, ContentEncryption: contentEnc, KeyWrap: keyWrap, @@ -180,6 +180,13 @@ public static UnpackResult Unpack( { throw new CryptoException(ex.Message, ex); } + catch (ArgumentException ex) + { + // Defensive boundary guard (#22, FR-API-07): map any ArgumentException the + // delegated JWS parser might surface for a non-canonical field length to the + // documented unpack contract, so a raw ArgumentException never escapes UnpackAsync. + throw new MalformedMessageException("Malformed JWS field length.", ex); + } // Fail closed: a verified JWS MUST surface a signer kid. Otherwise FR-CONSIST-03 // (signed 'from' ↔ signer) and FR-CONSIST-05 (inner signer ↔ skid) would silently @@ -224,13 +231,33 @@ public static UnpackResult Unpack( { throw new CryptoException(ex.Message, ex); } + catch (ArgumentException ex) + { + // Defensive boundary guard (#22, FR-API-07). The delegated DataProofsDotnet.Jose + // parser currently wraps the AEAD's wrong-length-iv/tag ArgumentException as + // MalformedJoseException (caught above), but EnvelopeReader's contract promises + // only MalformedMessageException/CryptoException — so map ANY ArgumentException + // from the delegate to the contract type rather than let a raw one escape + // UnpackAsync. Generic message; no attacker-influenced detail. + throw new MalformedMessageException("Malformed JWE field length (iv/tag).", ex); + } + // The loop unwraps outermost→inner, so the first encrypt layer is the outermost one. + // AnonymousSender is documented as "the outermost encrypt layer was anoncrypt", so + // derive it from THIS layer's alg only when it's the outermost encrypt — not by + // OR-accumulating across layers (#23). For legal shapes the #17 gate already + // guarantees anoncrypt-if-present is outermost; deriving here keeps the flag correct + // by construction regardless. + var isOutermostEncryptLayer = !encrypted; encrypted = true; contentEnc = jweResult.ContentEncryption; keyWrap = jweResult.Algorithm; recipientKid = jweResult.RecipientKid; allRecipientKids = jweResult.AllRecipientKids; + if (isOutermostEncryptLayer) + anonymous = !jweResult.IsAuthenticated; + if (jweResult.IsAuthenticated) { authenticated = true; @@ -239,7 +266,6 @@ public static UnpackResult Unpack( } else { - anonymous = true; shape.Add(LayerShape.AnonEncrypt); } diff --git a/src/DidComm.Core/Jose/Base64Url.cs b/src/DidComm.Core/Jose/Base64Url.cs index 94a488b..6d58951 100644 --- a/src/DidComm.Core/Jose/Base64Url.cs +++ b/src/DidComm.Core/Jose/Base64Url.cs @@ -27,13 +27,33 @@ public static string EncodeUtf8(string utf8String) } /// Decode from base64url without padding to bytes. - /// Base64url string (no padding). + /// Strict base64url string: no padding, no whitespace, alphabet [A-Za-z0-9-_]. + /// When contains a non-base64url character or non-canonical trailing bits. public static byte[] Decode(string value) { ArgumentException.ThrowIfNullOrEmpty(value); + + // Strict JOSE base64url (RFC 7515 §2 / RFC 4648 §3.2-3.3): no padding, no line breaks, no + // whitespace, and not standard-base64 '+'/'/'. The BCL codec tolerates trailing '=' padding + // and silently strips embedded ASCII whitespace, so pre-validate the alphabet here and reject + // anything outside [A-Za-z0-9-_] before delegating (#24). The BCL still enforces the + // non-canonical-trailing-bits check. + foreach (var c in value) + { + if (!IsBase64UrlChar(c)) + { + throw new FormatException( + "Input is not strict base64url: a character outside the [A-Za-z0-9-_] alphabet was found " + + "(RFC 7515 §2 forbids padding, whitespace, and '+'/'/')."); + } + } + return SystemBase64Url.DecodeFromChars(value.AsSpan()); } + private static bool IsBase64UrlChar(char c) + => (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-' || c == '_'; + /// Decode a base64url string and return the bytes interpreted as a UTF-8 string. /// Base64url string. public static string DecodeUtf8(string value) => Encoding.UTF8.GetString(Decode(value)); diff --git a/src/DidComm.Core/Protocols/Routing/ForwardProcessor.cs b/src/DidComm.Core/Protocols/Routing/ForwardProcessor.cs index a401016..f7d398a 100644 --- a/src/DidComm.Core/Protocols/Routing/ForwardProcessor.cs +++ b/src/DidComm.Core/Protocols/Routing/ForwardProcessor.cs @@ -116,7 +116,20 @@ private static byte[] ExtractAttachmentBytes(Messages.Attachment attachment) return Encoding.UTF8.GetBytes(json.ToJsonString()); if (!string.IsNullOrEmpty(attachment.Data.Base64)) - return Base64Url.Decode(attachment.Data.Base64); + { + try + { + return Base64Url.Decode(attachment.Data.Base64); + } + catch (FormatException ex) + { + // The attachment payload is attacker-controlled; a non-strict-base64url value must + // surface as the documented malformed-input type, not a raw FormatException out of the + // mediator's ProcessAsync (#24 — strict Base64Url.Decode widened the rejected set). + throw new MalformedMessageException( + "Forward attachment 'data.base64' is not valid base64url.", ex); + } + } throw new ConsistencyException( "Forward attachment is missing both 'data.json' and 'data.base64'. The mediator has nothing to relay."); diff --git a/tasks/lessons.md b/tasks/lessons.md index 422a260..fda0659 100644 --- a/tasks/lessons.md +++ b/tasks/lessons.md @@ -482,3 +482,19 @@ Format per entry: argument for shared mutable state must be re-derived under concurrency; guard expensive rebuild/evict passes with single-flight. (3) Always run a break-it adversarial pass on a security fix before declaring done — the fix that closes the headline issue often leaves a residual. + +## L-026 — When you make a shared primitive throw a new/wider exception, audit EVERY call site's exception contract — don't trust the issue's "call sites already handle it" premise. + +- **Lesson:** Making `Base64Url.Decode` strict (#24) widened when it throws `FormatException`. The issue + body asserted the call sites "map it to MalformedMessageException" — but the adversarial pass found + `ForwardProcessor.ExtractAttachmentBytes` had NO try/catch, so a malformed forward attachment threw a + raw `FormatException` out of the mediator's `ProcessAsync`. The from_prior site mapped it (#19); the + forward site did not. +- **Why:** A primitive's exception is part of its contract; changing/widening it ripples to every + caller. The audit-era issue text described call-site behavior that was only partially true, and the + pre-existing raw-`FormatException` escape was simply made more reachable. +- **How to apply:** Before changing what a shared decoder/parser throws, `grep` every call site and + verify each maps the (new or widened) exception to the module's documented contract type — add the + map where missing, with a regression test per reachable site. Treat the issue's stated premise as a + hypothesis to verify, not a fact. (This is exactly what the mandatory adversarial pass, AGENTS.md + §2, is for — run it on every security fix and fix what it finds.) diff --git a/tasks/todo20260617T021817Z.md b/tasks/todo20260617T021817Z.md new file mode 100644 index 0000000..961f957 --- /dev/null +++ b/tasks/todo20260617T021817Z.md @@ -0,0 +1,119 @@ +# Plan — Batch A: receive-path correctness (#22, #23, #24) + +**Branch:** `feat/security-receive-correctness` (off `main`) +**One PR** closing **#22, #24**, and **#23** (see disposition below). + +## ⚠️ Scoping correction vs the issue text + +The issues were filed during the original audit, **before** the JOSE/crypto layer was delegated to +`DataProofsDotnet.Jose` (now on `main`). Two of them reference code that no longer exists or has moved: + +- **#22** cites `src/DidComm.Core/Jose/Encryption/JweParser.cs` and `Crypto/Aead/*` — **those files are + deleted on `main`.** JWE parsing is now `DataProofsDotnet.Jose.Encryption.JweParser`, whose AEAD throws + `ArgumentException` on a wrong-length iv/tag (verified in the dataproofs tests). `EnvelopeReader` wraps + that call in `catch (MalformedJoseException)` / `catch (JoseCryptoException)` only — so the + `ArgumentException` **still escapes `UnpackAsync`** (bug is real; fix location moved to the delegation + boundary). Cross-repo note: the upstream parser ideally should validate iv/tag length itself; the + in-repo defensive catch fixes it for didcomm-dotnet regardless. +- **#23**'s exploit (`authcrypt(anoncrypt)` → both flags true) is **already unreachable** — the merged + #17 composition gate rejects that ordering. For every *legal* shape the accumulated flags already + equal the documented "outermost layer" semantics. Residual = make the flag *derivation* literally + match its doc (defense-in-depth), or close as resolved-by-#17. +- **#24**'s `Base64Url.Decode` is genuinely in-repo and reachable (from_prior, forward attachment, OOB, + JWK) — clean fix, as written. + +--- + +## Fixes + +### [ ] #22 — wrong-length iv/tag `ArgumentException` escapes the unpack contract +- **File:** `src/DidComm.Core/Composition/EnvelopeReader.cs` (JWE parse block ~214-227; JWS block ~170-182). +- **Fix:** add `catch (ArgumentException ex) { throw new MalformedMessageException(ex.Message, ex); }` to + **both** the `DpEnc.JweParser.Parse` and `DpSig.JwsParser.Parse` try/catch blocks (after the existing + `MalformedJoseException`/`JoseCryptoException` catches). The lookups passed in are non-null + (constructed internally), so the only `ArgumentException` from these calls on attacker bytes is + malformed input — safe to map to the contract type. +- **Test:** end-to-end — craft a valid anoncrypt envelope, splice a wrong-length `iv` (e.g. 5 bytes + base64url) into the JWE JSON, `EnvelopeReader.Unpack`/`UnpackAsync` ⇒ `MalformedMessageException` + (not `ArgumentException`). Cover A256GCM iv and, if cheap, a wrong-length tag. +- **Optional follow-up (cross-repo, NOT this PR):** file an upstream issue to have + `DataProofsDotnet.Jose.JweParser` validate iv/tag length → `MalformedJoseException`. + +### [ ] #24 — strict base64url (reject `=` padding + embedded whitespace + `+`/`/`) +- **File:** `src/DidComm.Core/Jose/Base64Url.cs` (`Decode`). +- **Fix:** before delegating to the BCL codec, scan the input and reject any char outside + `[A-Za-z0-9-_]` by throwing `FormatException` (catches `=`, whitespace, `+`, `/`). Keep `Encode` + unchanged (already no-pad). Apply in `Decode` so `DecodeUtf8` inherits it. +- **Call-site contract (verify, no behavior regression):** `FromPriorValidator` already maps + `FormatException` → `ProtocolException` (#19); OOB `FromUrl` documents/raises `FormatException`; + `ForwardProcessor.ExtractAttachmentBytes` (`:119`) already could surface `FormatException` from a + malformed attachment today (BCL rejects `+`/`/`) — #24 only widens the rejected set, not the + exception *kind*. JWK material (`JwsSignerFactory`) is pack-side/local, not attacker-reachable. + → confirm each reachable receive site surfaces a `DidCommException`; add a minimal map if a latent + raw-`FormatException` escape is found (note it, keep scope tight). +- **Tests:** `Base64Url` unit tests — `"Mw=="`, `"SGVsbG8="` (padding), `"M w"`, `"SGVs\tbG8"`, + `"SGVs\r\nbG8"` (whitespace), `"a+b/"` (std-base64) all ⇒ `FormatException`; clean no-pad input still + round-trips. Plus an end-to-end: a `from_prior` JWT segment with trailing `=` ⇒ `ProtocolException`. + +### [ ] #23 — derive `AnonymousSender` from the outermost encrypt layer (match the doc) +- **Disposition:** the security contradiction is **already fixed by #17**; this is a small + defense-in-depth / contract-honesty change so the code literally matches `UnpackResult.cs:16` + ("outermost encrypt layer"). *If you'd rather just close #23 as resolved-by-#17, say so and I'll drop + this from the PR.* +- **File:** `src/DidComm.Core/Composition/EnvelopeReader.cs`. +- **Fix:** set `AnonymousSender` from the **first** (outermost) encrypt layer's anon/auth flag only, + instead of OR-accumulating across all layers; tidy the doc comment to state the #17 gate guarantees + anoncrypt-if-present is outermost. (`Authenticated` stays "any layer bound a sender" — that already + matches its documented meaning.) +- **Test:** assert flags for the legal shapes (`anoncrypt`, `authcrypt`, `anoncrypt(authcrypt)`, + `anoncrypt(sign)`, `anoncrypt(authcrypt(sign))`) are correct, and note the illegal contradiction is + now rejected by the gate (already covered by #17 tests). + +--- + +## Cross-cutting +- [ ] Branch `feat/security-receive-correctness` off `main`. +- [ ] Implement the three fixes (additive; no public API change). +- [ ] `dotnet build` (warnings-as-errors) + full `dotnet test` green, incl. new tests; watch that no + existing test relied on lenient base64url decoding. +- [ ] Update `CHANGELOG.md` (Security/Fixed) with the three issues + the #22 delegation-boundary note. +- [ ] **Adversarial red-team pass** on the three fixes before PR (per AGENTS.md), remediate findings. +- [ ] One PR: `fix(security): receive-path correctness (#22, #23, #24)`, `Closes #22`, `closes #23`, + `closes #24`. + +## Verification standard +- #22: prove the wrong-length-iv path returns `MalformedMessageException` (post-tamper of a real + envelope), not a raw `ArgumentException`. +- #24: prove padded/whitespaced/std-base64 inputs are rejected AND clean inputs still decode; prove an + end-to-end receive path (from_prior) maps the new `FormatException` to a `DidCommException`. +- Diff behavior vs `main` for the unpack exception types. + +## Review section + +All three fixes implemented on `feat/security-receive-correctness`. **615 tests green**, build clean +under warnings-as-errors. + +- **#22** — the audit-cited files are deleted; investigation showed the escape is **already resolved + upstream** (`DataProofsDotnet.Jose.JweParser` now wraps the AEAD's `ArgumentException` and the base64 + `FormatException` as `MalformedJoseException`). Verified empirically (the inner exception is + `MalformedJoseException`, not `ArgumentException`). Shipped a **defensive boundary catch** + (`ArgumentException → MalformedMessageException` at both JWS/JWE delegation points) + a regression + test asserting wrong-length-iv → `MalformedMessageException`. Honest about this in the PR/CHANGELOG. +- **#24** — strict alphabet pre-validation in `Base64Url.Decode`; unit tests for padding/whitespace/ + `+`/`/`, plus an end-to-end from_prior padded-segment → `ProtocolException`. +- **#23** — `AnonymousSender` now derived from the outermost encrypt layer (matches its doc); the #17 + gate already blocked the old contradiction. + +### Adversarial red-team pass (AGENTS.md §2/L36) +Subagents hit the session limit, so I ran the pass inline against the real code. +- **#22 holds** — external parser comprehensively wraps base64/AEAD/AES-KW exceptions; boundary catch + is sound defense-in-depth. +- **#23 holds** — outermost-first derivation correct for every legal shape; gate runs before the + `UnpackResult` return; independent-flags semantics are safe (no dangerous false-positive). +- **#24 — REAL FINDING, fixed.** `ForwardProcessor.ExtractAttachmentBytes` had no `try/catch`, so a + malformed forward attachment threw a raw `FormatException` out of the mediator. The issue's premise + ("call sites map FormatException") was false here. Wrapped → `MalformedMessageException` + regression + test. Lesson L-026 captured. + +**Cross-repo note (for the PR):** #22's underlying defect is already fixed in the dependency +(`DataProofsDotnet.Jose`); this repo's contribution is the boundary guard + regression coverage. diff --git a/tests/DidComm.Core.Tests/Envelopes/Composition/EnvelopeReaderTests.cs b/tests/DidComm.Core.Tests/Envelopes/Composition/EnvelopeReaderTests.cs index 2868f0e..998458d 100644 --- a/tests/DidComm.Core.Tests/Envelopes/Composition/EnvelopeReaderTests.cs +++ b/tests/DidComm.Core.Tests/Envelopes/Composition/EnvelopeReaderTests.cs @@ -424,4 +424,49 @@ public async Task Anoncrypt_authcrypt_sign_is_accepted_on_receive() unpacked.AnonymousSender.Should().BeTrue(); unpacked.NonRepudiation.Should().BeTrue(); } + + [Fact] + public async Task Wrong_length_iv_throws_MalformedMessageException_honoring_the_unpack_contract() + { + // Issue #22: a non-canonical iv length must surface as MalformedMessageException, never as a raw + // ArgumentException out of unpack. (A256GCM requires a 12-byte iv; splice a 5-byte one.) Note: + // the delegated DataProofsDotnet.Jose parser already wraps the AEAD's ArgumentException as + // MalformedJoseException, so this is a regression guard on the unpack contract; EnvelopeReader + // also carries a defensive ArgumentException boundary catch for any path the delegate doesn't wrap. + var bob = TestKeyMaterial.Generate(KeyType.X25519, "did:example:bob#x"); + var packed = await EnvelopeWriter.PackEncryptedAsync( + new PackEncryptedParameters(EmptyMessage(), new[] { bob.PublicJwk }, "A256GCM"), _crypto); + + var jwe = System.Text.Json.Nodes.JsonNode.Parse(packed)!.AsObject(); + jwe["iv"] = DidComm.Jose.Base64Url.Encode(new byte[5]); // valid base64url, wrong length + var tampered = jwe.ToJsonString(); + + Action act = () => EnvelopeReader.Unpack(tampered, + new DictionarySecretsLookup(new[] { bob.PrivateJwk }), senderLookup: null, signerLookup: null, _crypto); + + act.Should().Throw(); + } + + [Fact] + public async Task AnonymousSender_reflects_the_outermost_encrypt_layer_not_accumulation() + { + // Issue #23: for anoncrypt(authcrypt(...)) the OUTERMOST encrypt layer is anoncrypt, so + // AnonymousSender must be true even though the inner authcrypt layer binds and authenticates + // the sender. The flag is derived from the outermost layer, not OR-accumulated across layers. + var alice = TestKeyMaterial.Generate(KeyType.X25519, "did:example:alice#x"); + var bob = TestKeyMaterial.Generate(KeyType.X25519, "did:example:bob#x"); + + var packed = await EnvelopeWriter.PackEncryptedAsync( + new PackEncryptedParameters(EmptyMessage(), new[] { bob.PublicJwk }, "A256CBC-HS512", + SenderPrivateJwk: alice.PrivateJwk, Skid: alice.PublicJwk.Kid, ProtectSender: true), _crypto); + + var unpacked = EnvelopeReader.Unpack(packed, + new DictionarySecretsLookup(new[] { bob.PrivateJwk }), + senderLookup: new DictionarySenderKeyLookup(new[] { alice.PublicJwk }), + signerLookup: null, _crypto); + + unpacked.AnonymousSender.Should().BeTrue(); // outermost layer is anoncrypt + unpacked.Authenticated.Should().BeTrue(); // inner authcrypt bound the sender + unpacked.SenderKid.Should().Be(alice.PublicJwk.Kid); + } } diff --git a/tests/DidComm.Core.Tests/Envelopes/Encryption/Base64UrlTests.cs b/tests/DidComm.Core.Tests/Envelopes/Encryption/Base64UrlTests.cs new file mode 100644 index 0000000..7f9e147 --- /dev/null +++ b/tests/DidComm.Core.Tests/Envelopes/Encryption/Base64UrlTests.cs @@ -0,0 +1,51 @@ +using System.Text; +using DidComm.Jose; +using FluentAssertions; +using Xunit; + +namespace DidComm.Tests.Envelopes.Encryption; + +/// +/// Issue #24: must enforce strict JOSE base64url (RFC 7515 §2): +/// no padding, no whitespace, alphabet [A-Za-z0-9-_] — diverging inputs throw +/// , which the receive call sites map to a DidCommException. +/// +public sealed class Base64UrlTests +{ + [Theory] + [InlineData("Mw==")] // trailing '=' padding (RFC 4648 §3.2 forbids it for base64url-no-pad) + [InlineData("SGVsbG8=")] // single '=' pad + [InlineData("M w")] // embedded space + [InlineData(" SGVsbG8")] // leading space + [InlineData("SGVs\tbG8")] // embedded tab + [InlineData("SGVs\r\nbG8")] // embedded CRLF (line break — RFC 4648 §3.3) + [InlineData("a+b/")] // standard-base64 '+' and '/' + [InlineData("SGVsbG8%3D")] // percent-encoded padding (non-alphabet) + public void Decode_rejects_non_strict_base64url(string input) + { + ((Action)(() => Base64Url.Decode(input))).Should().Throw(); + } + + [Fact] + public void Decode_accepts_clean_no_pad_base64url_and_round_trips() + { + // The whole base64url alphabet plus a value that encodes to '-'/'_' must still decode. + foreach (var original in new[] { "Hello, DIDComm v2.1!", "f", "fo", "foo", "ÿþýü" }) + { + var bytes = Encoding.UTF8.GetBytes(original); + var encoded = Base64Url.Encode(bytes); + encoded.Should().NotContain("=").And.NotContain("+").And.NotContain("/"); + + var decoded = Base64Url.Decode(encoded); + Encoding.UTF8.GetString(decoded).Should().Be(original); + } + } + + [Fact] + public void Decode_accepts_values_containing_dash_and_underscore() + { + // 0xFB 0xFF encodes to "-_8" in base64url — proves the '-' and '_' alphabet chars are allowed. + var decoded = Base64Url.Decode("-_8"); + decoded.Should().Equal(0xFB, 0xFF); + } +} diff --git a/tests/DidComm.Core.Tests/Protocols/Routing/ForwardProcessorTests.cs b/tests/DidComm.Core.Tests/Protocols/Routing/ForwardProcessorTests.cs index 59921d9..66ea552 100644 --- a/tests/DidComm.Core.Tests/Protocols/Routing/ForwardProcessorTests.cs +++ b/tests/DidComm.Core.Tests/Protocols/Routing/ForwardProcessorTests.cs @@ -243,6 +243,28 @@ public async Task ProcessAsync_decodes_a_base64url_attachment_payload() Encoding.UTF8.GetString(result.OnwardPacked).Should().Be(innerPayload); } + [Fact] + public async Task ProcessAsync_maps_malformed_base64_attachment_to_MalformedMessageException() + { + // Issue #24 (red-team): a forward attachment whose data.base64 is not strict base64url (e.g. + // '=' padding) must surface as MalformedMessageException, not a raw FormatException escaping + // the mediator's ProcessAsync. + var forward = new MessageBuilder() + .WithType(ForwardConstants.ForwardTypeUri) + .WithBody(new JsonObject { ["next"] = "did:example:n" }) + .WithAttachment(new Attachment { Data = new AttachmentData { Base64 = "SGVsbG8=" } }) // padded → rejected + .Build(); + var client = NewClient(); + var packed = await client.PackPlaintextAsync(forward); + + var processor = new ForwardProcessor(client, new EmptyKeyService(), new ForwardProcessorOptions()); + + var act = async () => await processor.ProcessAsync(packed); + + (await act.Should().ThrowAsync()) + .Which.Message.Should().Contain("not valid base64url"); + } + [Theory] [InlineData(long.MinValue)] [InlineData(-long.MaxValue)] diff --git a/tests/DidComm.InteropTests/Rotation/FromPriorRotationTests.cs b/tests/DidComm.InteropTests/Rotation/FromPriorRotationTests.cs index 9451c5b..2681ba4 100644 --- a/tests/DidComm.InteropTests/Rotation/FromPriorRotationTests.cs +++ b/tests/DidComm.InteropTests/Rotation/FromPriorRotationTests.cs @@ -125,6 +125,7 @@ public async Task Validator_RejectsMalformedJwt() [Theory] [InlineData("!!!not-base64url!!!", 0)] // header segment is not base64url -> FormatException [InlineData("@@@", 2)] // signature segment is not base64url -> FormatException + [InlineData("SGVsbG8=", 1)] // #24: '=' padded segment -> strict base64url FormatException public async Task Validator_RejectsNonBase64UrlSegment_AsProtocolException(string garbage, int segmentIndex) { var jwt = await FromPriorBuilder.BuildAsync(SampleClaims(), SignerPrivateJwk()); From c4ef29dd0fb6696ae22e8df1ef310237893f1519 Mon Sep 17 00:00:00 2001 From: Moises E Jaramillo Date: Tue, 16 Jun 2026 23:26:43 -0400 Subject: [PATCH 2/2] =?UTF-8?q?fix(security):=20address=20PR=20#38=20revie?= =?UTF-8?q?w=20=E2=80=94=20scope=20base64url=20strictness;=20cover=20the?= =?UTF-8?q?=20boundary=20catch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - #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) --- CHANGELOG.md | 18 ++++++---- .../Composition/EnvelopeReader.cs | 16 +++++---- src/DidComm.Core/Jose/Base64Url.cs | 16 +++++++++ .../Protocols/Routing/ForwardProcessor.cs | 10 +++--- tasks/lessons.md | 15 ++++++++ .../Composition/EnvelopeReaderTests.cs | 27 +++++++++++++-- .../Routing/ForwardProcessorTests.cs | 34 ++++++++++++++++--- 7 files changed, 111 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 93fc0db..692d3e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,16 +13,20 @@ Resolves three Low-severity audit findings on the unpack/JOSE receive surface (G - **Unpack honors its exception contract for non-canonical field lengths (#22).** `EnvelopeReader` gains a defensive `ArgumentException → MalformedMessageException` boundary catch at both the JWS and - JWE delegation points. Note: the originally-reported escape is **already resolved upstream** — the - delegated `DataProofsDotnet.Jose` parser now wraps the AEAD's wrong-length-iv/tag `ArgumentException` - (and base64 `FormatException`) as `MalformedJoseException` — so this is a boundary guard plus a - regression test asserting wrong-length-iv → `MalformedMessageException`. + JWE delegation points (neutral message; `InnerException` preserved). Note: the originally-reported + escape is **already resolved upstream** — the delegated `DataProofsDotnet.Jose` parser now wraps the + AEAD's wrong-length-iv/tag `ArgumentException` (and base64 `FormatException`) as `MalformedJoseException`. + Covered by a regression test on the unpack contract plus a unit test that drives the boundary catch + directly (a throwing consumer lookup → `MalformedMessageException`). - **Strict JOSE base64url decoding (#24).** `Base64Url.Decode` now rejects `=` padding, embedded ASCII whitespace, and standard-base64 `+`/`/` (anything outside `[A-Za-z0-9-_]`) with `FormatException`, per RFC 7515 §2 / RFC 4648 §3.2-3.3, closing a parser-differential / non-canonical-encoding gap on - the from_prior, forward-attachment, and OOB receive paths. Red-team follow-up: `ForwardProcessor` - now maps that `FormatException` to `MalformedMessageException` so a malformed forward attachment no - longer escapes the mediator as a raw exception. + the strict-no-pad paths: `from_prior` JWTs, OOB URLs (FR-OOB-02), and JWK material. **Attachment + `data.base64` is deliberately kept lenient** (new `Base64Url.DecodeRelaxed`): FR-ATT-02 imposes no + no-pad requirement and the mediator only relays bytes the recipient re-parses strictly, so a peer's + padded forward attachment is still relayed rather than refused (PR #38 review). Either way a + genuinely-invalid forward attachment maps to `MalformedMessageException` rather than escaping the + mediator as a raw `FormatException` (red-team follow-up). - **`UnpackResult.AnonymousSender` derived from the outermost encrypt layer (#23).** The flag is now computed from the outermost encrypt layer's alg (matching its documented semantics) instead of OR-accumulating across layers. The contradiction the finding described (`authcrypt(anoncrypt)` → diff --git a/src/DidComm.Core/Composition/EnvelopeReader.cs b/src/DidComm.Core/Composition/EnvelopeReader.cs index a803f72..e2ed3d2 100644 --- a/src/DidComm.Core/Composition/EnvelopeReader.cs +++ b/src/DidComm.Core/Composition/EnvelopeReader.cs @@ -182,10 +182,11 @@ public static UnpackResult Unpack( } catch (ArgumentException ex) { - // Defensive boundary guard (#22, FR-API-07): map any ArgumentException the - // delegated JWS parser might surface for a non-canonical field length to the - // documented unpack contract, so a raw ArgumentException never escapes UnpackAsync. - throw new MalformedMessageException("Malformed JWS field length.", ex); + // Defensive boundary guard (#22, FR-API-07): map any ArgumentException surfacing + // from the delegated JWS parse (a non-canonical field length, or a throwing + // consumer signer lookup) to the documented unpack contract, so a raw + // ArgumentException never escapes UnpackAsync. InnerException is preserved. + throw new MalformedMessageException("Malformed JWS.", ex); } // Fail closed: a verified JWS MUST surface a signer kid. Otherwise FR-CONSIST-03 @@ -237,9 +238,10 @@ public static UnpackResult Unpack( // parser currently wraps the AEAD's wrong-length-iv/tag ArgumentException as // MalformedJoseException (caught above), but EnvelopeReader's contract promises // only MalformedMessageException/CryptoException — so map ANY ArgumentException - // from the delegate to the contract type rather than let a raw one escape - // UnpackAsync. Generic message; no attacker-influenced detail. - throw new MalformedMessageException("Malformed JWE field length (iv/tag).", ex); + // surfacing from the delegated parse to the contract type rather than let a raw + // one escape UnpackAsync. Neutral message (the cause may be a field length or a + // throwing consumer lookup); the original is preserved as InnerException. + throw new MalformedMessageException("Malformed JWE.", ex); } // The loop unwraps outermost→inner, so the first encrypt layer is the outermost one. diff --git a/src/DidComm.Core/Jose/Base64Url.cs b/src/DidComm.Core/Jose/Base64Url.cs index 6d58951..65ab9e2 100644 --- a/src/DidComm.Core/Jose/Base64Url.cs +++ b/src/DidComm.Core/Jose/Base64Url.cs @@ -54,6 +54,22 @@ public static byte[] Decode(string value) private static bool IsBase64UrlChar(char c) => (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-' || c == '_'; + /// + /// Decode tolerating '=' padding and ASCII whitespace (the BCL + /// codec's lenient behavior). Use ONLY for transport/attachment payloads where padding is + /// historically permitted and not spec-forbidden — DIDComm attachment data.base64 + /// (FR-ATT-02; cf. Aries RFC 0017), which the mediator merely relays for the recipient to + /// re-parse. JOSE fields (JWS/JWE segments, JWK material), from_prior JWTs, and OOB URLs + /// have an explicit no-pad requirement and MUST use the strict . + /// + /// Base64url string, possibly padded. + /// When the input is not valid base64url even allowing padding/whitespace. + public static byte[] DecodeRelaxed(string value) + { + ArgumentException.ThrowIfNullOrEmpty(value); + return SystemBase64Url.DecodeFromChars(value.AsSpan()); + } + /// Decode a base64url string and return the bytes interpreted as a UTF-8 string. /// Base64url string. public static string DecodeUtf8(string value) => Encoding.UTF8.GetString(Decode(value)); diff --git a/src/DidComm.Core/Protocols/Routing/ForwardProcessor.cs b/src/DidComm.Core/Protocols/Routing/ForwardProcessor.cs index f7d398a..5ea9e99 100644 --- a/src/DidComm.Core/Protocols/Routing/ForwardProcessor.cs +++ b/src/DidComm.Core/Protocols/Routing/ForwardProcessor.cs @@ -119,13 +119,15 @@ private static byte[] ExtractAttachmentBytes(Messages.Attachment attachment) { try { - return Base64Url.Decode(attachment.Data.Base64); + // Relaxed decode: attachment data.base64 has no spec no-pad requirement (FR-ATT-02, + // cf. Aries RFC 0017), and a mediator only relays the bytes for the recipient to + // re-parse strictly — so tolerate '=' padding rather than refuse a peer's forward. + // Genuinely-invalid base64 still maps to the documented malformed-input type (a raw + // FormatException must not escape ProcessAsync). (#24 + PR #38 review.) + return Base64Url.DecodeRelaxed(attachment.Data.Base64); } catch (FormatException ex) { - // The attachment payload is attacker-controlled; a non-strict-base64url value must - // surface as the documented malformed-input type, not a raw FormatException out of the - // mediator's ProcessAsync (#24 — strict Base64Url.Decode widened the rejected set). throw new MalformedMessageException( "Forward attachment 'data.base64' is not valid base64url.", ex); } diff --git a/tasks/lessons.md b/tasks/lessons.md index fda0659..43976b2 100644 --- a/tasks/lessons.md +++ b/tasks/lessons.md @@ -498,3 +498,18 @@ Format per entry: map where missing, with a regression test per reachable site. Treat the issue's stated premise as a hypothesis to verify, not a fact. (This is exactly what the mandatory adversarial pass, AGENTS.md §2, is for — run it on every security fix and fix what it finds.) + +## L-027 — Scope encoding strictness to where the spec actually mandates it; a global tightening can refuse spec-valid peer data. + +- **Lesson:** Making `Base64Url.Decode` strict (no-pad) globally also tightened the mediator's + forward-attachment relay. The reviewer flagged that attachment `data.base64` (FR-ATT-02; Aries RFC + 0017) has NO no-pad requirement — only JOSE/JWT segments and OOB URLs do (FR-ENC-13/14, FR-SIG-04, + FR-OOB-02 "whitespace-stripped"). A global strict decode would refuse a peer's padded forward, an + interop regression with ~zero security upside (the mediator just relays bytes the recipient re-parses + strictly). Fix: keep `Decode` strict for the JOSE/JWT/OOB paths, add `DecodeRelaxed` for the + attachment relay. +- **Why:** "Strict is safer" is true for fields the spec pins (signed bytes, key material) but wrong + where the spec is permissive and the data is pass-through — there it just breaks interop. +- **How to apply:** Before tightening a shared codec/parser, enumerate each call site and check the + *spec's* strictness requirement for THAT field. Apply strictness only where mandated; give permissive + fields a clearly-named relaxed path. Verify the spec, don't assume one rule fits all call sites. diff --git a/tests/DidComm.Core.Tests/Envelopes/Composition/EnvelopeReaderTests.cs b/tests/DidComm.Core.Tests/Envelopes/Composition/EnvelopeReaderTests.cs index 998458d..1536bfd 100644 --- a/tests/DidComm.Core.Tests/Envelopes/Composition/EnvelopeReaderTests.cs +++ b/tests/DidComm.Core.Tests/Envelopes/Composition/EnvelopeReaderTests.cs @@ -447,12 +447,33 @@ public async Task Wrong_length_iv_throws_MalformedMessageException_honoring_the_ act.Should().Throw(); } + [Fact] + public async Task ArgumentException_from_the_delegated_parse_maps_to_MalformedMessageException() + { + // Issue #22 boundary guard — directly exercised. The wrong-length-iv path is wrapped upstream, + // so this drives the new catch via a signer lookup that throws ArgumentException (the lookup is + // invoked inside the delegated parse). Contract: ANY ArgumentException from the delegated parse + // becomes MalformedMessageException, never escapes raw; InnerException is preserved for diagnosis. + var signer = TestKeyMaterial.Generate(KeyType.Ed25519, "did:example:alice#k"); + var packed = await EnvelopeWriter.PackSignedAsync(new PackSignedParameters(EmptyMessage(), new[] { signer.PrivateJwk })); + + Action act = () => EnvelopeReader.Unpack(packed, + new DictionarySecretsLookup(Array.Empty()), + senderLookup: null, + signerLookup: _ => throw new ArgumentException("boom from a buggy lookup"), + _crypto); + + act.Should().Throw().WithInnerException(); + } + [Fact] public async Task AnonymousSender_reflects_the_outermost_encrypt_layer_not_accumulation() { - // Issue #23: for anoncrypt(authcrypt(...)) the OUTERMOST encrypt layer is anoncrypt, so - // AnonymousSender must be true even though the inner authcrypt layer binds and authenticates - // the sender. The flag is derived from the outermost layer, not OR-accumulated across layers. + // Issue #23 — characterization / defense-in-depth (NOT a regression guard: the #17 gate already + // rejects the only shapes where the old OR-accumulation would have differed, so this passes on + // main too). It pins the documented contract: for anoncrypt(authcrypt(...)) the OUTERMOST encrypt + // layer is anoncrypt, so AnonymousSender is true even though the inner authcrypt authenticates + // the sender — the flag now reads from the outermost layer rather than OR-accumulating. var alice = TestKeyMaterial.Generate(KeyType.X25519, "did:example:alice#x"); var bob = TestKeyMaterial.Generate(KeyType.X25519, "did:example:bob#x"); diff --git a/tests/DidComm.Core.Tests/Protocols/Routing/ForwardProcessorTests.cs b/tests/DidComm.Core.Tests/Protocols/Routing/ForwardProcessorTests.cs index 66ea552..387e87d 100644 --- a/tests/DidComm.Core.Tests/Protocols/Routing/ForwardProcessorTests.cs +++ b/tests/DidComm.Core.Tests/Protocols/Routing/ForwardProcessorTests.cs @@ -246,13 +246,13 @@ public async Task ProcessAsync_decodes_a_base64url_attachment_payload() [Fact] public async Task ProcessAsync_maps_malformed_base64_attachment_to_MalformedMessageException() { - // Issue #24 (red-team): a forward attachment whose data.base64 is not strict base64url (e.g. - // '=' padding) must surface as MalformedMessageException, not a raw FormatException escaping - // the mediator's ProcessAsync. + // Issue #24 (red-team): a forward attachment whose data.base64 is genuinely invalid base64url + // ('@' is non-alphabet; '+'/'/' are standard-base64, not base64url) must surface as + // MalformedMessageException, not a raw FormatException escaping the mediator's ProcessAsync. var forward = new MessageBuilder() .WithType(ForwardConstants.ForwardTypeUri) .WithBody(new JsonObject { ["next"] = "did:example:n" }) - .WithAttachment(new Attachment { Data = new AttachmentData { Base64 = "SGVsbG8=" } }) // padded → rejected + .WithAttachment(new Attachment { Data = new AttachmentData { Base64 = "@@@@" } }) // not base64url at all .Build(); var client = NewClient(); var packed = await client.PackPlaintextAsync(forward); @@ -265,6 +265,32 @@ public async Task ProcessAsync_maps_malformed_base64_attachment_to_MalformedMess .Which.Message.Should().Contain("not valid base64url"); } + [Fact] + public async Task ProcessAsync_relays_a_padded_base64_attachment_FR_ATT_02() + { + // PR #38 review: attachment data.base64 has no spec no-pad requirement (FR-ATT-02, cf. Aries + // RFC 0017), and the mediator only relays the bytes for the recipient to re-parse — so a peer's + // '=' padded forward attachment must still be relayed, not refused. (Only the strict JOSE/JWT/ + // OOB paths reject padding.) + var innerPayload = "forward-me!"; // 11 bytes → base64 carries a trailing '=' pad + var bytes = Encoding.UTF8.GetBytes(innerPayload); + var paddedBase64Url = Convert.ToBase64String(bytes).Replace('+', '-').Replace('/', '_'); // padded base64url + paddedBase64Url.Should().EndWith("="); // sanity: this value really is padded + + var forward = new MessageBuilder() + .WithType(ForwardConstants.ForwardTypeUri) + .WithBody(new JsonObject { ["next"] = "did:example:n" }) + .WithAttachment(new Attachment { Data = new AttachmentData { Base64 = paddedBase64Url } }) + .Build(); + var client = NewClient(); + var packed = await client.PackPlaintextAsync(forward); + + var processor = new ForwardProcessor(client, new EmptyKeyService(), new ForwardProcessorOptions()); + var result = await processor.ProcessAsync(packed); + + Encoding.UTF8.GetString(result.OnwardPacked).Should().Be(innerPayload); + } + [Theory] [InlineData(long.MinValue)] [InlineData(-long.MaxValue)]