feat(secrets): opaque (non-extractable) signing & key agreement (#45)#46
Conversation
Let DIDComm crypto delegate the only two private-key operations — a raw JWS signature and an ECDH shared-secret derivation — to a keystore (HSM / cloud KMS / OS keychain / MPC / NetCrypto.IKeyStore) so the private scalar never leaves the secure boundary (FR-SEC-06). Single-repo wiring on top of the already-shipped DataProofs.Jose 1.1.0 async IEcdhKey seam (dataproofs#13). - Add optional capability DidComm.Secrets.IOpaqueKeyResolver (ResolveSignerAsync -> ISigner, ResolveKeyAgreementAsync -> IEcdhKey); internal KeyOperationResolver prefers the opaque handles and falls back to the extractable ISecretsResolver path (unchanged byte-for-byte). A wallet may mix opaque and extractable keys. - Pack: authcrypt via BuildEcdh1PuA256KwAsync(IEcdhKey); signing via pre-built JwsSigners; FromPriorBuilder gains additive JwsSigner overloads. - Unpack made async end-to-end (EnvelopeReader.UnpackAsync: PeekRecipients -> ParseAsync, always invoked with a decoy when unheld for constant-work). Retires SyncSecretsAdapter + IInternalSecretsLookup; FR-CONSIST-06 check now async. - NetDidKeyStoreSecretsResolver implements IOpaqueKeyResolver over IKeyStore (+ KeyStoreEcdhKey), kid==alias with optional hook — now a sufficient sole resolver for HSM-backed agents. DI honors a registered IOpaqueKeyResolver and auto-detects when the resolver itself implements it. - Bump DataProofsDotnet.Jose 1.0.1 -> 1.1.0; bump library version -> 1.1.0. Adversarial review (2 passes) fixed: (HIGH) keystore derive faults (KeyNotFoundException / CryptographicException) escaping UnpackAsync raw only on the held path — folded into the uniform CryptoException so no exception-type possession oracle and no raw escape of the FR-API-07 contract; (MEDIUM) a redundant keystore round-trip on the held receive path. Behavior change: malformed iv/tag/encrypted_key lengths on decrypt now surface as CryptoException (uniform), not MalformedMessageException — the dataproofs#12 constant-work hardening; also lands durable constant-work recipient selection (closes #42). Release build clean; 668 tests pass (incl. 12 new opaque-path tests: facade round-trips through a non-extractable keystore, cross-custody interop, ECDH KAT-equivalence, opaque from_prior, no-d-leak). Closes #45. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
moisesja
left a comment
There was a problem hiding this comment.
Let me find the recently opened PR.
PR #46 — a security-sensitive change (opaque keystore crypto, constant-work decryption). Let me review the diff and files.
The diff is large. Let me get the file list and read the diff in chunks.
Let me extract the diffs for the security-critical core files.
Strong security awareness so far. Let me review the facade, the NetDid resolver, writer, and DI wiring.
This is a high-quality, security-conscious PR. Let me check a few things before forming my review: CI status, whether the architecture doc was kept in sync (per CLAUDE.md), and the package version bump.
CI is green on both Ubuntu and Windows. Let me verify the architecture-doc sync rule from CLAUDE.md and the test additions quickly.
I have a complete picture now. No codebase-architecture.md exists in the repo (only the PRD, which was updated), so that CLAUDE.md sync rule is moot. CI is green on both OSes, tests are comprehensive, and the security reasoning is strong. Let me post the review.
Approval isn't permitted for this integration (likely can't self-approve). Let me post it as a review comment instead.
Review submission is blocked for this integration. Let me post it as a regular PR comment instead.
I'm unable to post to GitHub — every write path (review submit, review comment, and plain PR comment) returns 403 Resource not accessible by integration, so this session's token lacks comment/write scope on moisesja/didcomm-dotnet. I'll deliver the review here instead.
Review — PR #46: opaque (non-extractable) signing & key agreement (#45)
I reviewed the full diff (core, NetDid adapter, DI, tests, docs). This is a strong, well-scoped PR — LGTM. CI is green on both ubuntu-latest and windows-latest, the seam is genuinely additive, the extractable path is preserved, and the public DidCommClient surface is unchanged. Security reasoning is the right shape and documented at the call sites: decoy IEcdhKey to equalize held-vs-unheld work, uniform CryptoException folding of opaque-handle faults, scalar zeroing via CryptographicOperations.ZeroMemory, single-lookup curve discovery.
Non-blocking points worth a reply rather than a change:
-
Residual recipient-possession timing oracle on the held path.
ResolveRecipientKeyOrDecoyAsyncis honest that constant-work only holds fromParseAsyncinward: the held path runsFindPresentAsync+ResolveKeyAgreementAsync(→ a realGetInfoAsyncbacking-store round-trip) beforeParseAsync, while the unheld path goes straight to the in-process decoy. Against a real HSM/KMS with variable latency that's a measurable held-vs-unheld signal. Mitigation is punted to the consumer + receive rejection floor (#35) — defensible, but it lives only in aninternalXML comment. Is it surfaced where a downstream wallet author will see it (the publicIOpaqueKeyResolverdoc, or FR-SEC-06 in the PRD)? -
Decoy is hard-coded X25519. Correctness of the unheld P-256/P-384/P-521 path rides entirely on
JweParser.ParseAsyncswapping in a curve-matched work decoy (dataproofs#12). Do the new tests exercise the unheld/decoy path on a non-X25519 curve, or only the held path? If only held, our side leaves the EC constant-work guarantee unverified. -
Version jump 0.1.0 → 1.1.0 skips 1.0.0 — declaring first API stability and shipping a feature in one step. Intentional (tracking DataProofs 1.1.0)? A CHANGELOG one-liner would stop it reading as an accident later.
-
Exception-type change (
MalformedMessageException→CryptoExceptionfor malformediv/tag/encrypted_key) is the correct constant-work outcome and is documented — flagging only as catch-block-visible for consumers. Fine to bake into the inaugural 1.x.
The cross-custody (opaque ⇄ extractable) interop test is the right one to have — that mixed-wallet case is exactly what tends to regress. None of the above blocks merge.
If you'd like this posted to the PR, the session token needs comment/write scope on the repo (it currently has read-only access) — or you can paste it yourself.
…ing caveat, version-lineage note - Point 2: add an unheld/decoy-path test across X25519 + P-256/P-384/P-521 — verifies the EC decoy path (where our X25519 decoy differs from the envelope work curve) fails with the same uniform CryptoException from our side, not just the X25519 case. - Point 1: surface the residual held-vs-unheld prologue-timing signal (one backing-store round-trip on the held path before the constant-work decrypt) in the public IOpaqueKeyResolver XML docs and FR-SEC-06, with the mitigation (auth / rate-limiter + #35 receive floor) — it was previously only in an internal comment. - Point 3: CHANGELOG note clarifying 1.1.0 follows the published 1.0.0 line (the DidCommVersion build property had drifted to 0.1.0 in source), so the bump is a normal minor, not a skip. No production behavior change. Release build clean; 672 tests pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Thanks for the review — addressed in d4a33a7. 1. Residual prologue timing oracle (held path runs a backing-store round-trip before the constant-work decrypt). Valid — it was only in an 2. Decoy hard-coded X25519 → was the EC unheld/decoy path tested? Good catch — it wasn't. Added 3. 0.1.0 → 1.1.0 looks like it skips 1.0.0. It doesn't, but the changelog made it read that way: 1.0.0 is the last published release (on nuget.org); the 4. Exception-type change ( 672 tests pass (Release), build clean. |
Closes #45. Lets DIDComm crypto delegate to a keystore (HSM / cloud KMS / OS keychain / MPC /
NetCrypto.IKeyStore) so private keys never leave the secure boundary — unblocking non-extractable custody for downstream consumers (e.g. net-wallet-sdk Messaging).This is single-repo wiring on top of the already-shipped DataProofs.Jose 1.1.0 async
IEcdhKeyseam (dataproofs#13). Only two operations ever need the private key — a raw JWS signature and an ECDH shared-secret derivation; everything downstream (Concat-KDF, A256KW, AEAD, header assembly, signature normalization) is public-data math and is untouched.What changed
DidComm.Secrets.IOpaqueKeyResolver—ResolveSignerAsync(kid) -> ISigner,ResolveKeyAgreementAsync(kid) -> IEcdhKey. AnISecretsResolverMAY also implement it; the facade then routes signing (signed envelopes, inner JWS,from_prior) and ECDH (authcrypt send, authcrypt/anoncrypt receive) through these handles. Returnsnullper kid to fall back to the extractable path, so a wallet may mix opaque and extractable keys.KeyOperationResolverunifies opaque + extractable; the extractable path is byte-for-byte unchanged.EnvelopeReader.UnpackAsync):PeekRecipients -> FindPresent -> ResolveKeyAgreement -> ParseAsync, always invokingParseAsync(a decoyIEcdhKeywhen unheld) so the decrypt is constant-work fromParseAsyncinward. Retires theSyncSecretsAdaptersync-over-async bridge; FR-CONSIST-06 check now async. PublicDidCommClientAPI unchanged.NetDidKeyStoreSecretsResolverimplementsIOpaqueKeyResolveroverIKeyStore(+KeyStoreEcdhKey),kid == aliaswith an optional mapping hook — now a sufficient sole resolver for an HSM-backed agent (surfaces public-only JWKs).FromPriorBuildergains additiveJwsSigneroverloads (opaque rotation JWT).AddDidCommhonors a separately-registeredIOpaqueKeyResolver;UseSecretsResolversurfaces the capability; facade auto-detects when the resolver itself implements it.DataProofsDotnet.Jose1.0.1 → 1.1.0; library version → 1.1.0.Security (adversarial review — 2 passes, both fixes re-verified CLOSED)
KeyNotFoundException/CryptographicException) escapedUnpackAsyncraw, only on the held path (the in-process decoy can never throw them) → an exception-type recipient-possession oracle + FR-API-07 contract escape. Now folded into the uniformCryptoException("JWE could not be decrypted."). Regression test covers both fault types.GetInfoAsyncround-trip on the held receive path (timing on slow stores). Interface self-determines the curve in one lookup.Behavior change (see CHANGELOG)
Malformed
iv/tag/encrypted_keylengths on decrypt now surface asCryptoException(uniform), notMalformedMessageException— the dataproofs#12 constant-work hardening consumed here. Also lands the durable constant-work recipient selection (closes #42).Verification
InMemoryKeyStore(authcrypt / anoncrypt / signed / sign-then-encrypt / anoncrypt(authcrypt)), cross-custody interop (opaque ⇄ extractable), ECDH KAT-equivalence, opaquefrom_prior, and a no-d-leak assertion.Acceptance criteria (#45)
IKeyStorecan authcrypt / anoncrypt / sign on send and unpack on receive with no private bytes leaving the store.ISecretsResolverpath is unchanged (back-compat).NetDidKeyStoreSecretsResolveris a sufficient sole resolver for HSM-backed stores.🤖 Generated with Claude Code