JWE constant-work decrypt (#12) + async IEcdhKey seam (#13) → v1.1.0#14
Conversation
… — v1.1.0 #12 (security/bug): JweParser.Parse no longer fast-fails before any ECDH when no recipient kid matches a held key — it runs the same ECDH/key-unwrap against a per-process cached decoy key on the envelope's epk curve and fails with a single uniform exception, so neither response time nor the exception type/message distinguishes which recipient private keys the holder possesses. The decision keys on "held private key matching the work curve", closing the held-but-wrong- curve and exception-channel variants the issue's own suggested fix missed. Internal only; no public API change. #13 (feature): additive async IEcdhKey seam lets JWE ECDH run on an opaque (HSM/keystore) key that never exposes its scalar — public IEcdhKey/RawEcdhKey, JweParser.ParseAsync/ParseCompactAsync, JweBuilder.BuildEcdh1PuA256KwAsync, and async KDF cores sharing the frozen sync ConcatKDF assembly. The existing sync JWE API and the draft-04/RFC 7518 conformance vectors are byte-for-byte unchanged. Verified: 818/818 tests pass solution-wide (22 new + regression tests); two adversarial review passes, the second confirming both the timing and the exception channels are closed. Bumps DataProofsVersion 1.0.1 -> 1.1.0. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
moisesja
left a comment
There was a problem hiding this comment.
Review — PR #14 (v1.1.0: JWE constant-work + async IEcdhKey)
This is a well-engineered PR. Both issues are closed correctly and the two adversarial review passes are evident in the design. No blocking issues found.
What looks good
Issue #12 (timing side-channel)
- The decoy predicate keys on the envelope's
epkcurve, not on a held key's curve — the correct predicate. The originalkid-only approach still leaked on the held-but-wrong-curve case; this closes it. DecoyKeyCacheusesLazy<T>(ExecutionAndPublication)for a correct per-process singleton under concurrent first-calls. Per-call key generation would re-introduce the timing oracle; caching is correctly explained as load-bearing.SelectRecipientOrDecoyscans the full recipient list without early-out, preventing a selection-index timing signal.DecryptFailureMessage = "JWE could not be decrypted."is applied at both the AES-KW unwrap and AEAD stages — same exception type, same message, no inner cause, no recipient kid. The adversarial exception-channel finding is fully addressed.IsUsableForEnvelopechecks!string.IsNullOrEmpty(key.D), so a public-only JWK under the right kid takes the decoy path rather than producing a distinguishable"missing 'd'"failure. Good catch from pass 2.- The scope caveat (caller's
IJweRecipientKeyResolvertiming is outside the guarantee) is documented in XML docs, CHANGELOG, and the task file — the right places.
Issue #13 (async IEcdhKey seam)
IEcdhKeyis deliberately minimal:Crv+DeriveAsync. The structural surface test (IEcdhKey_surface_exposes_no_private_scalar) is a nice compile-time+reflection guard.- The
DeriveAsyncXML doc noting "may be invoked more than once" (ECDH-1PU derives Ze then Zs) sets the contract for HSM/KMS implementations correctly. FinishConcatKdfextraction ensures sync and async cores share identical post-ECDH assembly — the parity guarantee is structural, not just empirical.RawEcdhKey.DeriveAsynccompletes synchronously and checksct.ThrowIfCancellationRequested()before the computation.UnwrapAnyAndDecryptscans all recipients, zeroizes losing candidates, and zeroizes the KEK infinally. Correct.
Tests
Issue12ConstantWorkTestsuses a call-count proxy (CountingCryptoProvider.DeriveSharedSecretCalls) rather than wall-clock timing — the right approach for a CI-stable side-channel regression test.- Covers: wrong-curve key → decoy path, public-only key → decoy path, length-corrupted envelope → same exception for held and unheld, multi-recipient with a non-first held key, and the real opaque key not invoked on curve-mismatch in the async path. Comprehensive.
Minor observations (not blocking)
-
RawEcdhKeyprivate bytes are not zeroized on disposal. The constructor copiesprivateKey.ToArray(), which lives until GC. For the stated use-case (in-process key bytes) this is fine, but a futureIDisposablepath or a doc note would help callers understand the security boundary. Not a regression — the existing sync API has the same characteristic. -
FinishConcatKdfdoes not zeroize the concatenatedz. In bothEcdhEsKdfandEcdh1PuKdf,ze/zsare zeroized but the concatenationz = ze‖zsis not cleared after the Concat KDF. Not a regression from the pre-refactor code, but worth tracking for a cleanup pass. -
DecoyForEnvelopeX25519 fallback. The placeholder returned for an absent/unsupportedepkcurve is never used for ECDH because the structural throw fires first. ADebug.Assertto make that invariant machine-checkable would be a small improvement.
These are all low-severity and fine to track as future work. The security change (constant-work + uniform failure) is solid, the async seam is correct, the 818-test suite is comprehensive, and the public API surface is properly registered. Ready to merge. ✅
Generated by Claude Code
|
Thanks for the review. Evaluated the three minor observations; no code changes warranted:
Merging as-is. |
Bundles the two milestone-1.1.0 issues. Targets v1.1.0 (bumps
DataProofsVersion1.0.1 → 1.1.0).#12 — Timing side-channel: recipient-key enumeration oracle (
bug)JweParser.Parsefast-failed before any ECDH when no recipientkidmatched a held private key, so held-vs-unheld was observable as a response-time difference (the root cause of downstreamdidcomm-dotnet#35).Fix (internal only, no public API change):
DecoyKeyCache— a per-process cached decoy ECDH key per curve (caching is load-bearing; a per-call keygen would itself re-introduce a timing signal).SelectRecipientOrDecoyroutes the non-decryptable case through the same ECDH → AES-KW unwrap on the envelope'sepkwork curve. The predicate is "is a held private key present whose curve matchesepk?" — which also closes a residual held-but-wrong-curve leak the issue's own suggested fix missed.JoseCryptoException, same message, no recipient kid, no inner cause — closing the exception-type/message channel (found by adversarial review: a length-corrupted captured envelope let a held key reach the AEAD stage while an unheld key died at unwrap).Scope caveat (documented): this makes the parser's own path constant-work for a fixed
(alg, enc, epk-curve). A fully constant-time decrypt additionally requires the caller'sIJweRecipientKeyResolver(FindPresent/TryGet) to be timing-independent — that contract stays with the caller.#13 — Async
IEcdhKeyseam for opaque (HSM/keystore) ECDH keys (feature)Additive seam so JWE ECDH can run on a private key that never exposes its scalar (HSM, KMS, keychain,
NetCrypto.IKeyStore), keepingDataProofsDotnet.JoseDID-agnostic.IEcdhKey(Crv+DeriveAsync→ rawZ) andRawEcdhKey.JweParser.ParseAsync/ParseCompactAsyncandJweBuilder.BuildEcdh1PuA256KwAsync.EcdhEsKdf/Ecdh1PuKdfshare a newFinishConcatKdfwith the frozen sync cores.Verification
TreatWarningsAsErrors→ PublicAPI + BannedSymbols analyzers pass).[1.1.0], PRD FR-14, and README updated.Closes #12, #13.
🤖 Generated with Claude Code