From de7091cc5ae71d2315f3b5c68ca8de5925a8ad40 Mon Sep 17 00:00:00 2001 From: Moises E Jaramillo Date: Mon, 22 Jun 2026 14:46:14 -0400 Subject: [PATCH 1/2] feat(secrets): opaque (non-extractable) signing & key agreement (#45) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG.md | 60 ++++++ Directory.Build.props | 2 +- Directory.Packages.props | 6 +- docs/didcomm-dotnet_PRD.md | 1 + .../KeyStoreEcdhKey.cs | 42 ++++ .../NetDidKeyStoreSecretsResolver.cs | 73 +++++-- .../Composition/EnvelopeReader.cs | 94 ++++++++- .../Composition/EnvelopeWriter.cs | 39 ++-- .../Composition/PackParameters.cs | 22 ++- .../Consistency/AddressingConsistency.cs | 28 +-- src/DidComm.Core/Facade/DidCommClient.cs | 92 +++++---- .../Protocols/Rotation/FromPriorBuilder.cs | 49 ++++- .../Resolution/DidKeyServiceLookups.cs | 9 +- .../Secrets/IInternalSecretsLookup.cs | 16 -- .../Secrets/IOpaqueKeyResolver.cs | 55 ++++++ .../Secrets/KeyOperationResolver.cs | 96 +++++++++ .../Secrets/SyncSecretsAdapter.cs | 27 --- .../DidCommBuilder.cs | 13 +- .../DidCommServiceCollectionExtensions.cs | 7 +- tasks/lessons.md | 30 +++ tasks/todo20260622-113605.md | 185 ++++++++++++++++++ .../Consistency/AddressingConsistencyTests.cs | 12 +- .../Consistency/ResolverAuthorizationTests.cs | 34 ++-- .../Composition/EnvelopeReaderTests.cs | 133 +++++++++---- .../Encryption/AnoncryptRoundTripTests.cs | 8 +- .../Encryption/AuthcryptRoundTripTests.cs | 18 +- .../Envelopes/Signing/JwsRoundTripTests.cs | 20 +- .../Envelopes/TestKeyMaterial.cs | 9 +- .../NetDidKeyStoreSecretsResolverTests.cs | 69 +++++++ .../DidComm.InteropTests.csproj | 2 + ...dCommClientOpaqueKeystoreRoundTripTests.cs | 154 +++++++++++++++ .../Resolution/SpecActorRegistry.cs | 52 +++-- .../Runner/FixtureDispatcher.cs | 8 +- .../DidComm.TestSupport.csproj | 7 + .../EnvelopeReaderTestRunner.cs | 31 +++ tests/DidComm.TestSupport/TestHandles.cs | 25 +++ 36 files changed, 1254 insertions(+), 274 deletions(-) create mode 100644 src/DidComm.Adapters.NetDid/KeyStoreEcdhKey.cs delete mode 100644 src/DidComm.Core/Secrets/IInternalSecretsLookup.cs create mode 100644 src/DidComm.Core/Secrets/IOpaqueKeyResolver.cs create mode 100644 src/DidComm.Core/Secrets/KeyOperationResolver.cs delete mode 100644 src/DidComm.Core/Secrets/SyncSecretsAdapter.cs create mode 100644 tasks/todo20260622-113605.md create mode 100644 tests/DidComm.InteropTests/Facade/DidCommClientOpaqueKeystoreRoundTripTests.cs create mode 100644 tests/DidComm.TestSupport/EnvelopeReaderTestRunner.cs create mode 100644 tests/DidComm.TestSupport/TestHandles.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 0bb57a6..8042903 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,66 @@ All notable changes to didcomm-dotnet are documented here. Format follows ## [Unreleased] +## [1.1.0] - 2026-06-22 + +### Added — Opaque (non-extractable) signing & key agreement (`feat/opaque-keystore-crypto-45`) + +Closes **#45** (depends on, and ships after, `moisesja/dataproofs-dotnet#13`). DIDComm can now sign and +decrypt with private keys that **never leave a secure boundary** — HSM, cloud KMS, OS keychain, MPC, or +a `NetCrypto.IKeyStore` — instead of requiring an extractable private `Jwk`. This unblocks +non-extractable custody for downstream consumers (e.g. net-wallet-sdk's Messaging phase) whose invariant +is "private keys never leave the keystore" (FR-SEC-06). + +Only **two** operations ever need the private key — a raw JWS signature and an ECDH shared-secret +derivation — so the change is a thin, additive seam; everything downstream of the signature/`Z` +(Concat-KDF, A256KW key wrap, AEAD, header assembly, signature normalization) is public-data math and is +untouched. + +- **New optional capability `DidComm.Secrets.IOpaqueKeyResolver`.** An `ISecretsResolver` MAY also + implement it: `ResolveSignerAsync(kid)` → `NetCrypto.ISigner` and `ResolveKeyAgreementAsync(kid)` → + `DataProofsDotnet.Jose.Encryption.IEcdhKey`. When the registered resolver provides it, the facade routes + signing (signed envelopes, the inner JWS of sign-then-encrypt, and the `from_prior` JWT) and ECDH + (authcrypt send, authcrypt/anoncrypt receive) through these handles. Returns `null` per kid to fall back + to the extractable path, so a wallet may **mix** opaque and extractable keys. +- **`NetDidKeyStoreSecretsResolver` is now a sufficient sole resolver for an HSM-backed agent.** It + implements `IOpaqueKeyResolver` over `IKeyStore` (signing via `CreateSignerAsync`, ECDH via the new + `KeyStoreEcdhKey` over `DeriveSharedSecretAsync`), surfaces **public-only** JWKs (`d` absent), and gains + an optional `kid → alias` mapping hook (identity by default). Its XML docs no longer flag the opaque + path as "future work." +- **`FromPriorBuilder` gains `JwsSigner` overloads** so the rotation JWT can be minted with an opaque + signer; the existing `Jwk` overloads delegate to them (back-compatible). EdDSA output is byte-identical + to the extractable path (covered by a determinism test). +- **DI:** `AddDidComm` honors a separately-registered `IOpaqueKeyResolver`, and `UseSecretsResolver` + surfaces a resolver's opaque capability automatically. No new required wiring — the facade also + auto-detects when the `ISecretsResolver` itself implements `IOpaqueKeyResolver`. +- **Dependency:** `DataProofsDotnet.Jose` `1.0.1 → 1.1.0` (the additive async `IEcdhKey` ECDH seam + + `JweParser.PeekRecipients`/`ParseAsync`, and the constant-work JWE decrypt — dataproofs#12/#13). +- **Acceptance:** interop round-trips (`DidCommClientOpaqueKeystoreRoundTripTests`) pack authcrypt / + anoncrypt / signed / sign-then-encrypt / anoncrypt(authcrypt) and unpack them end-to-end through a + non-extractable `InMemoryKeyStore`, plus a cross-custody interop case (opaque ⇄ extractable) — with no + private key bytes leaving the store. + +### Changed + +- **Unpack is now async end-to-end internally.** `DidCommClient.UnpackAsync`'s public signature is + unchanged, but the recipient (secret-key) decrypt path now runs natively async through the opaque-or- + extractable `IEcdhKey` handles — retiring the `SyncSecretsAdapter` sync-over-async bridge and its + documented deadlock caveat for the secrets path. The FR-CONSIST-06 authorization check also runs natively + async. Public-key DID-resolution lookups (sender/signer) remain sync-over-async by DataProofs' contract. +- **Durable constant-work recipient selection (closes #42 / lands dataproofs#12 here).** The unpack path + always drives `JweParser.ParseAsync` — with a throwaway decoy `IEcdhKey` when no recipient key is held — + so from `ParseAsync` inward the ECDH/unwrap cost and the uniform decryption failure no longer depend on + which (or whether) recipient key the agent holds. This is the durable fix the #35 HTTP rejection floor was + the compensating edge control for; the floor remains as defense-in-depth for the resolver prologue. +- **Behavior change — malformed `iv`/`tag`/`encrypted_key` lengths on decrypt now surface as + `CryptoException` ("JWE could not be decrypted."), not `MalformedMessageException`.** The constant-work + decrypt folds non-canonical AEAD field lengths into the same uniform failure as a wrong-key / tampered- + ciphertext failure, so a malformed envelope cannot be distinguished from an undecryptable one by exception + type or message. Any opaque-handle fault (e.g. a `KeyNotFoundException` from a key rotated out mid-unpack, + or an enclave `CryptographicException`) is likewise folded into that uniform `CryptoException`, so the + opaque path leaks no recipient-possession oracle by exception type and no raw exception escapes the + FR-API-07 unpack contract. (Adversarial review, Finding 1.) + ### Security — HTTP receive timing side-channel (`feat/security-receive-timing-floor`) Closes GitHub issue **#35** (High) — the timing residual the #20 red-team explicitly filed (see diff --git a/Directory.Build.props b/Directory.Build.props index 7e6d37d..5f5d9f5 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -5,7 +5,7 @@ enable latest true - 0.1.0 + 1.1.0 true $(NoWarn);1591 true diff --git a/Directory.Packages.props b/Directory.Packages.props index dda25d2..f4f06f3 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -18,8 +18,10 @@ bytes. Everything else (JWE/JWS build+parse, ECDH, AEAD, key wrap, JWK) is DataProofs. --> + Brings NetCrypto transitively; didcomm carries no envelope crypto of its own. 1.1.0 adds + the async IEcdhKey ECDH seam + JweParser.PeekRecipients/ParseAsync (dataproofs-dotnet#13) + that let opaque (HSM/KMS keystore) private keys do ECDH without exposing the scalar, plus + the constant-work JWE decrypt path (dataproofs-dotnet#12). Consumed by didcomm-dotnet#45. --> diff --git a/docs/didcomm-dotnet_PRD.md b/docs/didcomm-dotnet_PRD.md index e9bc59d..ff4a27c 100644 --- a/docs/didcomm-dotnet_PRD.md +++ b/docs/didcomm-dotnet_PRD.md @@ -360,6 +360,7 @@ public sealed class DidComm { | FR-SEC-03 | MUST | Key lookup is two-phase: resolve DID via net-did → discover kids; then `ISecretsResolver` supplies the private key for sign/decrypt/1PU-sender. | Decryption requests only the kids present in `recipients`. | | FR-SEC-04 | SHOULD | Provide an optional adapter (separate package or `Core` extension) that backs `ISecretsResolver` with a `NetDid.IKeyStore`, so an application that already mints DIDs with net-did can surface those private keys to didcomm-dotnet without a second key store. The adapter must not weaken DD-02 (still no production store shipped). | A `NetDid.IKeyStore` populated via NetDid create-ops resolves the matching kid for decryption. | | FR-SEC-05 | SHOULD | Ship a test-only in-memory `ISecretsResolver` in the **test** assembly seeded from Appendix A. | Vector tests use it. | +| FR-SEC-06 | SHOULD | Support **non-extractable (opaque) custody** — HSM / cloud KMS / OS keychain / MPC / `NetCrypto.IKeyStore` — where the private scalar never leaves the secure boundary. Define an optional `IOpaqueKeyResolver` capability (`ResolveSignerAsync` → `ISigner`, `ResolveKeyAgreementAsync` → `IEcdhKey`) an `ISecretsResolver` MAY also implement; when present the facade routes the **only two** private-key operations — a raw JWS signature and an ECDH shared-secret derivation — through those handles instead of decoding a private `Jwk`. Everything downstream of the signature/`Z` (Concat-KDF, A256KW key wrap, AEAD, header assembly, signature normalization) is public-data math and is unchanged. The extractable `ISecretsResolver` path stays the default and is byte-for-byte unchanged; the two coexist and may be mixed per kid. `NetDidKeyStoreSecretsResolver` becomes a sufficient sole resolver for an HSM-backed agent. | A wallet whose keys live only in a non-extractable `IKeyStore` can authcrypt / anoncrypt / sign on send and unpack on receive with no private key bytes leaving the store (interop round-trips); the keystore resolver returns public-only JWKs (`d` absent). | --- diff --git a/src/DidComm.Adapters.NetDid/KeyStoreEcdhKey.cs b/src/DidComm.Adapters.NetDid/KeyStoreEcdhKey.cs new file mode 100644 index 0000000..6367b5a --- /dev/null +++ b/src/DidComm.Adapters.NetDid/KeyStoreEcdhKey.cs @@ -0,0 +1,42 @@ +using DataProofsDotnet.Jose.Encryption; +using NetCrypto; + +namespace DidComm.Adapters.NetDid; + +/// +/// An that performs ECDH key agreement inside a NetCrypto +/// — the private scalar never leaves the store, so HSM / KMS / keychain +/// custody holds (FR-SEC-06). Wraps a (keystore, alias, crv) triple; +/// delegates to and returns the raw, unhashed shared +/// secret Z that the JOSE layer feeds into the Concat-KDF (everything after Z is +/// public-data math). +/// +internal sealed class KeyStoreEcdhKey : IEcdhKey +{ + private readonly IKeyStore _keyStore; + private readonly string _alias; + + /// Wrap a key-agreement key held under in . + /// The backing NetCrypto key store. + /// The store alias of the local key-agreement key. + /// The JWK crv the key agrees on (X25519 / P-256 / P-384 / P-521). + public KeyStoreEcdhKey(IKeyStore keyStore, string alias, string crv) + { + ArgumentNullException.ThrowIfNull(keyStore); + ArgumentException.ThrowIfNullOrEmpty(alias); + ArgumentException.ThrowIfNullOrEmpty(crv); + _keyStore = keyStore; + _alias = alias; + Crv = crv; + } + + /// + public string Crv { get; } + + /// + public ValueTask DeriveAsync(ReadOnlyMemory peerPublicKey, CancellationToken ct = default) + // The peer key is already in the curve's canonical encoding the JOSE layer assembled from the + // JWE epk / sender pub — the same encoding NetCrypto's DeriveSharedSecret expects — so it + // flows straight through. The store returns the raw Z (no KDF). + => new(_keyStore.DeriveSharedSecretAsync(_alias, peerPublicKey, ct)); +} diff --git a/src/DidComm.Adapters.NetDid/NetDidKeyStoreSecretsResolver.cs b/src/DidComm.Adapters.NetDid/NetDidKeyStoreSecretsResolver.cs index 6c1f3f3..5ff57a3 100644 --- a/src/DidComm.Adapters.NetDid/NetDidKeyStoreSecretsResolver.cs +++ b/src/DidComm.Adapters.NetDid/NetDidKeyStoreSecretsResolver.cs @@ -1,51 +1,58 @@ using DidComm.Secrets; using NetCrypto; using DpJwkConversion = DataProofsDotnet.Jose.JwkConversion; +using IEcdhKey = DataProofsDotnet.Jose.Encryption.IEcdhKey; using Jwk = DataProofsDotnet.Jose.Jwk; namespace DidComm.Adapters.NetDid; /// -/// Optional bridge backed by a NetCrypto -/// (FR-SEC-04, SHOULD). Apps already holding keys in a NetCrypto key store can avoid duplicating -/// their key material into a second store. +/// bridge backed by a NetCrypto (FR-SEC-04) +/// that also implements for non-extractable custody (FR-SEC-06). Apps +/// holding keys in a NetCrypto key store can adopt the DIDComm facade with a single resolver — keys +/// never have to be duplicated into a second store, and on the opaque path the private scalar never +/// leaves the keystore boundary (HSM / cloud KMS / OS keychain / MPC). /// /// /// -/// Scope: exposes SignAsync and -/// DeriveSharedSecretAsync plus public-key surfaces, but never raw private-key bytes — by -/// design, so HSM-backed stores are safe. That means this adapter surfaces only the -/// public JWK shape with no d component. DIDComm signing -/// (EnvelopeWriter.PackSignedAsync, the outer signed envelope, and the from_prior -/// JWT) and decryption still resolve private JWKs through today, so this -/// adapter ships in "public-resolution only" form. It is therefore not sufficient as the -/// sole in a fully-functional facade; an opaque-signer / opaque-ECDH -/// path over and -/// is future work. Consumers using HSM-backed stores typically pair it with an extractable-secret -/// store for the keys that must perform crypto. +/// How it works. exposes SignAsync / +/// CreateSignerAsync and DeriveSharedSecretAsync plus public-key surfaces, but never raw +/// private-key bytes. So surfaces only the public JWK shape (no +/// d) — enough for the facade to select keys and resolve curves — while the actual secret +/// operations run through the handles: signing through +/// (an ISigner that signs inside the store) and ECDH +/// through a over . The +/// facade prefers these handles whenever the registered resolver implements +/// , so this adapter is a sufficient sole resolver +/// for an HSM-backed agent — it can authcrypt / anoncrypt / sign on send and unpack on receive with +/// no private key bytes leaving the store. /// /// -/// The kid passed to is treated as the key alias inside the -/// . Apps that key by DID URL should ensure their store's aliases match. +/// kid → alias. By default a DID-URL kid is used verbatim as the +/// alias (apps that key by DID URL should ensure their store's aliases match). +/// Stores that key differently can pass a kidToAlias mapping to the constructor. /// /// -public sealed class NetDidKeyStoreSecretsResolver : ISecretsResolver +public sealed class NetDidKeyStoreSecretsResolver : ISecretsResolver, IOpaqueKeyResolver { private readonly IKeyStore _keyStore; + private readonly Func _kidToAlias; /// Initialise the adapter. /// The backing NetCrypto key store. - public NetDidKeyStoreSecretsResolver(IKeyStore keyStore) + /// Optional map from a DID-URL kid to its store alias. Defaults to identity (the kid IS the alias). + public NetDidKeyStoreSecretsResolver(IKeyStore keyStore, Func? kidToAlias = null) { ArgumentNullException.ThrowIfNull(keyStore); _keyStore = keyStore; + _kidToAlias = kidToAlias ?? (static kid => kid); } /// public async Task FindAsync(string kid, CancellationToken ct = default) { ArgumentException.ThrowIfNullOrEmpty(kid); - var info = await _keyStore.GetInfoAsync(kid, ct).ConfigureAwait(false); + var info = await _keyStore.GetInfoAsync(_kidToAlias(kid), ct).ConfigureAwait(false); if (info is null) return null; return DpJwkConversion.ToPublicJwk(info.KeyType, info.PublicKey, kid); } @@ -56,6 +63,32 @@ public async Task> FindPresentAsync(IEnumerable ki ArgumentNullException.ThrowIfNull(kids); var aliases = await _keyStore.ListAsync(ct).ConfigureAwait(false); var set = new HashSet(aliases, StringComparer.Ordinal); - return kids.Where(set.Contains).ToArray(); + return kids.Where(kid => set.Contains(_kidToAlias(kid))).ToArray(); + } + + /// + public async Task ResolveSignerAsync(string kid, CancellationToken ct = default) + { + ArgumentException.ThrowIfNullOrEmpty(kid); + var alias = _kidToAlias(kid); + // Confirm the key is held before creating the signer, so an unheld kid returns null (the + // facade then falls through to any extractable resolver) rather than throwing. + var info = await _keyStore.GetInfoAsync(alias, ct).ConfigureAwait(false); + if (info is null) return null; + return await _keyStore.CreateSignerAsync(alias, ct).ConfigureAwait(false); + } + + /// + public async Task ResolveKeyAgreementAsync(string kid, CancellationToken ct = default) + { + ArgumentException.ThrowIfNullOrEmpty(kid); + var alias = _kidToAlias(kid); + // One backing lookup confirms held-ness AND yields the key's curve (no caller-supplied crv, + // no redundant second GetInfo): a held opaque receive is a single keystore round-trip. + var info = await _keyStore.GetInfoAsync(alias, ct).ConfigureAwait(false); + if (info is null) return null; + var crv = DpJwkConversion.ToPublicJwk(info.KeyType, info.PublicKey, kid).Crv; + if (string.IsNullOrEmpty(crv)) return null; + return new KeyStoreEcdhKey(_keyStore, alias, crv); } } diff --git a/src/DidComm.Core/Composition/EnvelopeReader.cs b/src/DidComm.Core/Composition/EnvelopeReader.cs index e2ed3d2..4537ce8 100644 --- a/src/DidComm.Core/Composition/EnvelopeReader.cs +++ b/src/DidComm.Core/Composition/EnvelopeReader.cs @@ -7,6 +7,7 @@ using DidComm.Secrets; using DpEnc = DataProofsDotnet.Jose.Encryption; using DpSig = DataProofsDotnet.Jose.Signing; +using JoseAlgorithms = DataProofsDotnet.Jose.JoseAlgorithms; using JoseCryptoProvider = DataProofsDotnet.Jose.JoseCryptoProvider; namespace DidComm.Composition; @@ -20,39 +21,57 @@ namespace DidComm.Composition; /// carrying both the inner plaintext and the FR-API-04 metadata. /// /// +/// /// The JOSE layer (DataProofsDotnet.Jose) verifies signatures and decrypts but knows nothing of the /// DIDComm plaintext from/to headers, so it returns the signer/sender/recipient kids /// (and the verified payload bytes) and this reader binds them to the message-layer addressing /// rules. In particular FR-CONSIST-03 (signed from ↔ signer kid) and FR-SIG-06 (an inner /// signed JWM under encryption MUST carry to) are enforced here, against the deserialized /// inner message, since the JWS parser returns raw payload bytes rather than a DIDComm message. +/// +/// +/// Opaque key agreement (FR-SEC-06). Each encrypt layer is decrypted through the +/// async JweParser.ParseAsync with an IEcdhKey the +/// resolves for the held recipient kid — opaque (keystore/HSM) or extractable. The recipient private +/// scalar never enters this layer on the opaque path. The reader always invokes ParseAsync +/// (with a throwaway decoy handle when no recipient key is held), so from ParseAsync inward the +/// ECDH/unwrap cost and the uniform decryption failure are constant-work — independent of which (or +/// whether) recipient key the agent holds (dataproofs #12). Any opaque-handle fault is folded into that +/// same uniform failure so exception type/shape leaks nothing either. The held path does perform one +/// extra backing-store lookup to fetch the key before ParseAsync (the unheld path goes straight +/// to the in-process decoy); on a slow store that prologue is the consumer resolver's responsibility, +/// outside this layer's constant-work guarantee (as JweParser documents) and covered at the +/// transport boundary by the receive rejection floor (issue #35). +/// /// internal static class EnvelopeReader { /// Unpack into its inner plaintext + metadata. /// A packed DIDComm message: plaintext JWM, signed JWS, or encrypted JWE. - /// Internal lookup for recipient private keys (decrypt path). + /// Resolves the recipient ECDH key-agreement handle (opaque or extractable) for the decrypt path, and answers held-ness for recipient selection. /// Internal lookup for sender public keys (authcrypt path); MAY be null when no authcrypt is expected. /// Function returning the public JWK of a signer kid (verify path); MAY be null when no signed layers are expected. /// JOSE crypto provider (NetCrypto-backed). /// - /// FR-CONSIST-06 resolver-backed authorization predicate (assertedDid, kid, relationship) => isAuthorized. + /// FR-CONSIST-06 resolver-backed authorization predicate (assertedDid, kid, relationship, ct) => isAuthorized. /// When non-null, the unpack pipeline asserts the inner plaintext's sender / recipient / signer kids are present /// under the resolved DID Document's matching relationship. Pass null to short-circuit the check. /// + /// Cancellation token for the (possibly I/O-bound) key agreement and DID resolution. /// When the input is not well-formed. /// When decryption / verification fails. /// When an addressing-consistency rule (FR-CONSIST-*) is violated. - public static UnpackResult Unpack( + public static async Task UnpackAsync( string packed, - IInternalSecretsLookup secretsLookup, + KeyOperationResolver recipientKeys, IInternalSenderKeyLookup? senderLookup, Func? signerLookup, JoseCryptoProvider cryptoProvider, - Func? resolverCheck = null) + Func>? resolverCheck = null, + CancellationToken ct = default) { ArgumentException.ThrowIfNullOrEmpty(packed); - ArgumentNullException.ThrowIfNull(secretsLookup); + ArgumentNullException.ThrowIfNull(recipientKeys); ArgumentNullException.ThrowIfNull(cryptoProvider); var stack = new List(); @@ -127,17 +146,17 @@ public static UnpackResult Unpack( if (resolverCheck is not null) { if (senderKid is not null && message.From is not null) - AddressingConsistency.CheckResolverAuthorization(message.From, senderKid, "keyAgreement", resolverCheck); + await AddressingConsistency.CheckResolverAuthorizationAsync(message.From, senderKid, "keyAgreement", resolverCheck, ct).ConfigureAwait(false); if (encrypted && recipientKid is not null) { var recipientDid = DidSubject.DidSubjectOf(recipientKid); if (recipientDid is not null) - AddressingConsistency.CheckResolverAuthorization(recipientDid, recipientKid, "keyAgreement", resolverCheck); + await AddressingConsistency.CheckResolverAuthorizationAsync(recipientDid, recipientKid, "keyAgreement", resolverCheck, ct).ConfigureAwait(false); } if (signerKid is not null && message.From is not null) - AddressingConsistency.CheckResolverAuthorization(message.From, signerKid, "authentication", resolverCheck); + await AddressingConsistency.CheckResolverAuthorizationAsync(message.From, signerKid, "authentication", resolverCheck, ct).ConfigureAwait(false); } return new UnpackResult( @@ -222,7 +241,14 @@ public static UnpackResult Unpack( DpEnc.JweParseResult jweResult; try { - jweResult = DpEnc.JweParser.Parse(current, secretsLookup, senderLookup, cryptoProvider); + // Discover the recipient kids without any private key, select the held one, and + // resolve its opaque-or-extractable ECDH handle. When none is held we still drive + // a full ParseAsync with a decoy handle — the parser's constant-work path + // (dataproofs #12) makes the ECDH/unwrap cost and the uniform failure identical + // to the held path, closing the held-vs-unheld recipient-enumeration oracle. + var peek = DpEnc.JweParser.PeekRecipients(current); + var recipientKey = await ResolveRecipientKeyOrDecoyAsync(recipientKeys, peek.RecipientKids, cryptoProvider, ct).ConfigureAwait(false); + jweResult = await DpEnc.JweParser.ParseAsync(current, recipientKey, senderLookup, cryptoProvider, ct).ConfigureAwait(false); } catch (DataProofsDotnet.Jose.MalformedJoseException ex) { @@ -243,6 +269,20 @@ public static UnpackResult Unpack( // throwing consumer lookup); the original is preserved as InnerException. throw new MalformedMessageException("Malformed JWE.", ex); } + catch (Exception ex) when (ex is not OperationCanceledException + and not MalformedMessageException and not CryptoException and not ConsistencyException) + { + // Constant-work + FR-API-07: an opaque (keystore/HSM) recipient handle can fault in + // ways the in-process path never does — a KeyNotFoundException when a kid is rotated + // out between selection and derive, a CryptographicException from the enclave, or any + // backing-store error. Fold ALL of them into the SAME uniform decryption failure as a + // wrong-key / tampered-ciphertext failure, so (a) no raw exception escapes the unpack + // contract and (b) the opaque path cannot reintroduce a recipient-possession oracle by + // exception type/shape (the held path would otherwise throw a distinct type the decoy + // path — an in-process RawEcdhKey — never can). The cause is preserved as + // InnerException for diagnosis; cancellation is allowed to propagate. + throw new CryptoException("JWE could not be decrypted.", 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 @@ -283,6 +323,40 @@ public static UnpackResult Unpack( throw new MalformedMessageException("Envelope nesting exceeded the legal depth of 4."); } + /// + /// Resolve the ECDH key-agreement handle the encrypt layer is decrypted with: the first held + /// recipient kid's handle (opaque or extractable), or — when none is held or usable — a throwaway + /// decoy on a supported curve. The decoy keeps the per-layer work constant whether or not the + /// agent holds a recipient key (dataproofs #12); the parser additionally swaps in its own + /// work-curve decoy when this handle's curve doesn't match the envelope, so the decoy here only + /// needs to exist, not to match. + /// + private static async Task ResolveRecipientKeyOrDecoyAsync( + KeyOperationResolver recipientKeys, + IReadOnlyList recipientKids, + JoseCryptoProvider cryptoProvider, + CancellationToken ct) + { + var heldKids = await recipientKeys.FindPresentAsync(recipientKids, ct).ConfigureAwait(false); + foreach (var kid in heldKids) + { + var handle = await recipientKeys.ResolveKeyAgreementAsync(kid, ct).ConfigureAwait(false); + if (handle is not null) + return handle; + } + + var scalar = new byte[32]; + cryptoProvider.Fill(scalar); + try + { + return new DpEnc.RawEcdhKey(JoseAlgorithms.CrvX25519, scalar, cryptoProvider); + } + finally + { + CryptographicOperations.ZeroMemory(scalar); + } + } + /// A single envelope layer, outer→inner, with the auth/anon distinction the composition gate needs. private enum LayerShape { diff --git a/src/DidComm.Core/Composition/EnvelopeWriter.cs b/src/DidComm.Core/Composition/EnvelopeWriter.cs index 18286e6..1a87248 100644 --- a/src/DidComm.Core/Composition/EnvelopeWriter.cs +++ b/src/DidComm.Core/Composition/EnvelopeWriter.cs @@ -37,13 +37,13 @@ public static string PackPlaintext(Message message) public static Task PackSignedAsync(PackSignedParameters parameters, CancellationToken ct = default) { ArgumentNullException.ThrowIfNull(parameters); - return SignAsync(parameters.Message, parameters.SignerPrivateJwks, parameters.RequireInnerToHeader, ct); + return SignAsync(parameters.Message, parameters.Signers, parameters.RequireInnerToHeader, ct); } /// /// Pack an encrypted DIDComm message. Routes to anoncrypt or authcrypt based on the presence - /// of ; nests a JWS inside when - /// is provided; optionally wraps the + /// of ; nests a JWS inside when + /// is provided; optionally wraps the /// entire result in an extra anoncrypt layer to protect the skid /// ( = anoncrypt(authcrypt(...))). /// @@ -60,8 +60,8 @@ public static async Task PackEncryptedAsync( var innerBytes = await BuildInnerBytesAsync(parameters, ct).ConfigureAwait(false); - var encryptedJson = parameters.SenderPrivateJwk is not null - ? PackAuthcrypt(parameters, innerBytes, cryptoProvider) + var encryptedJson = parameters.SenderKey is not null + ? await PackAuthcryptAsync(parameters, innerBytes, cryptoProvider, ct).ConfigureAwait(false) : DpEnc.JweBuilder.BuildEcdhEsA256Kw( innerBytes, parameters.Recipients, parameters.ContentEncryption, cryptoProvider, MediaTypes.Encrypted); @@ -81,7 +81,7 @@ public static async Task PackEncryptedAsync( private static async Task BuildInnerBytesAsync(PackEncryptedParameters parameters, CancellationToken ct) { - if (parameters.SignerPrivateJwks is { Count: > 0 } signers) + if (parameters.Signers is { Count: > 0 } signers) { // FR-SIG-06: the inner signed JWM MUST carry 'to'. var signedJson = await SignAsync(parameters.Message, signers, requireInnerToHeader: true, ct).ConfigureAwait(false); @@ -96,13 +96,13 @@ private static async Task BuildInnerBytesAsync(PackEncryptedParameters p private static async Task SignAsync( Message message, - IReadOnlyList? signerPrivateJwks, + IReadOnlyList? signers, bool requireInnerToHeader, CancellationToken ct) { ArgumentNullException.ThrowIfNull(message); - if (signerPrivateJwks is null || signerPrivateJwks.Count == 0) - throw new ArgumentException("At least one signer key is required.", nameof(signerPrivateJwks)); + if (signers is null || signers.Count == 0) + throw new ArgumentException("At least one signer is required.", nameof(signers)); message.Validate(); if (requireInnerToHeader && (message.To is null || message.To.Count == 0)) @@ -110,30 +110,33 @@ private static async Task SignAsync( "Sign-then-encrypt requires the inner signed JWM to carry a 'to' header (FR-SIG-06)."); // The JWS payload is the deterministic canonical bytes of the inner JWM (FR-SIG-04), - // produced here so DataProofs signs exactly the bytes DIDComm canonicalizes. + // produced here so DataProofs signs exactly the bytes DIDComm canonicalizes. The signer + // handles are opaque-capable (keystore-backed) or built from a private JWK by the facade — + // DataProofs signs through NetCrypto's ISigner either way (FR-SEC-06). var node = JsonSerializer.SerializeToNode(message, DidCommJson.Default); var payloadBytes = DeterministicJsonWriter.WriteUtf8(node); - var signers = new List(signerPrivateJwks.Count); - foreach (var jwk in signerPrivateJwks) - signers.Add(JwsSignerFactory.FromPrivateJwk(jwk)); - return await DpSig.JwsBuilder .BuildJsonAsync(payloadBytes, signers, MediaTypes.Signed, detachedPayload: false, ct) .ConfigureAwait(false); } - private static string PackAuthcrypt(PackEncryptedParameters parameters, byte[] innerBytes, JoseCryptoProvider cryptoProvider) + private static async Task PackAuthcryptAsync( + PackEncryptedParameters parameters, + byte[] innerBytes, + JoseCryptoProvider cryptoProvider, + CancellationToken ct) { if (string.IsNullOrEmpty(parameters.Skid)) throw new ArgumentException("Authcrypt requires a non-empty 'Skid'.", nameof(parameters)); - return DpEnc.JweBuilder.BuildEcdh1PuA256Kw( + return await DpEnc.JweBuilder.BuildEcdh1PuA256KwAsync( innerBytes, parameters.Recipients, - parameters.SenderPrivateJwk!, + parameters.SenderKey!, parameters.Skid, parameters.ContentEncryption, cryptoProvider, - MediaTypes.Encrypted); + MediaTypes.Encrypted, + ct).ConfigureAwait(false); } } diff --git a/src/DidComm.Core/Composition/PackParameters.cs b/src/DidComm.Core/Composition/PackParameters.cs index 9ee13bf..e8b6171 100644 --- a/src/DidComm.Core/Composition/PackParameters.cs +++ b/src/DidComm.Core/Composition/PackParameters.cs @@ -1,33 +1,35 @@ -using DidComm.Jose; using DidComm.Messages; +using DpEnc = DataProofsDotnet.Jose.Encryption; +using DpSig = DataProofsDotnet.Jose.Signing; namespace DidComm.Composition; /// -/// Parameters for . Phase 2 keeps this internal; the -/// Phase 3 public facade will wrap it with resolver-driven key fetching. +/// Parameters for . The facade resolves each signer DID +/// to an opaque-or-extractable handle (FR-SEC-06) before calling in, so +/// the envelope layer holds no private key material. /// /// Plaintext message to sign. -/// One or more signer private JWKs. +/// One or more JWS signer handles (keystore-backed or built from a private JWK). /// When true, refuse to build if the message has no to (FR-SIG-06; used when this signed envelope will be nested inside an encrypt layer). internal sealed record PackSignedParameters( Message Message, - IReadOnlyList SignerPrivateJwks, + IReadOnlyList Signers, bool RequireInnerToHeader = false); /// Parameters for . /// Plaintext message to encrypt. /// Recipient public JWKs, all sharing the same curve (FR-ENC-04, FR-ENC-11). /// JOSE enc algorithm. -/// Sender's static private JWK; presence selects authcrypt. null ⇒ anoncrypt. -/// Sender key identifier (required when is non-null). -/// Signer private JWKs for sign-then-encrypt composition; null ⇒ pack the plaintext directly. +/// Sender's static ECDH key-agreement handle; presence selects authcrypt. null ⇒ anoncrypt. Opaque (keystore) or extractable, resolved by the facade (FR-SEC-06). +/// Sender key identifier (required when is non-null). +/// Signer handles for sign-then-encrypt composition; null ⇒ pack the plaintext directly. /// When true with authcrypt, wraps the authcrypt envelope in an additional anoncrypt layer to hide skid from mediators (FR-API-01 / anoncrypt(authcrypt)). internal sealed record PackEncryptedParameters( Message Message, IReadOnlyList Recipients, string ContentEncryption, - Jwk? SenderPrivateJwk = null, + DpEnc.IEcdhKey? SenderKey = null, string? Skid = null, - IReadOnlyList? SignerPrivateJwks = null, + IReadOnlyList? Signers = null, bool ProtectSender = false); diff --git a/src/DidComm.Core/Consistency/AddressingConsistency.cs b/src/DidComm.Core/Consistency/AddressingConsistency.cs index fef3f9c..3f83a15 100644 --- a/src/DidComm.Core/Consistency/AddressingConsistency.cs +++ b/src/DidComm.Core/Consistency/AddressingConsistency.cs @@ -16,9 +16,8 @@ namespace DidComm.Consistency; /// /// /// The resolver-backed authorization check (FR-CONSIST-06) lives in -/// and is intentionally a hook in Phase 1 — Phase 3 -/// supplies the real implementation through IDidKeyService. Until then callers may -/// supply a null resolver and the hook short-circuits to "authorized". +/// , supplied by the facade through IDidKeyService. +/// Callers may supply a null resolver and the hook short-circuits to "authorized". /// /// internal static class AddressingConsistency @@ -124,24 +123,25 @@ public static void CheckAuthcryptInnerSignerMatchesSkid(string signerKid, string } /// - /// FR-CONSIST-06 — resolver-backed authorization check. This is the hook Phase 3 will - /// flesh out via IDidKeyService: the supplied must appear in - /// the resolved DID document of under the required - /// verification relationship (keyAgreement or authentication). The Phase 1 - /// implementation accepts a caller-supplied delegate; - /// when none is supplied the check is a no-op (logged via XML doc only — the wiring - /// belongs to Phase 3). + /// FR-CONSIST-06 — resolver-backed authorization check: the supplied must + /// appear in the resolved DID document of under the required + /// verification relationship (keyAgreement or authentication). The facade supplies + /// the real implementation backed by IDidKeyService; when no + /// is supplied the check is a no-op. Async because DID resolution is I/O-bound — the predicate runs + /// natively async now that the unpack pipeline is async (no sync-over-async bridge). /// /// The DID asserted to control the key (the from or matched to). /// The key identifier whose presence in the DID document is being verified. /// Either "keyAgreement" (for encrypt-side kids) or "authentication" (for signer kids). - /// Pluggable predicate: returns true when the key is authorized. Phase 3 supplies the real implementation; pass null to short-circuit to "authorized". + /// Pluggable predicate: returns true when the key is authorized. Pass null to short-circuit to "authorized". + /// Cancellation token for the DID resolution. /// When the resolver indicates the kid is not authorized. - public static void CheckResolverAuthorization( + public static async Task CheckResolverAuthorizationAsync( string assertedDid, string kid, string relationship, - Func? resolverCheck) + Func>? resolverCheck, + CancellationToken ct = default) { ArgumentException.ThrowIfNullOrEmpty(assertedDid); ArgumentException.ThrowIfNullOrEmpty(kid); @@ -149,7 +149,7 @@ public static void CheckResolverAuthorization( if (resolverCheck is null) return; - if (!resolverCheck(assertedDid, kid, relationship)) + if (!await resolverCheck(assertedDid, kid, relationship, ct).ConfigureAwait(false)) throw new ConsistencyException( $"Key '{kid}' is not present under '{relationship}' in the resolved DID Document of '{assertedDid}' (FR-CONSIST-06)."); } diff --git a/src/DidComm.Core/Facade/DidCommClient.cs b/src/DidComm.Core/Facade/DidCommClient.cs index 67e9c20..b838e97 100644 --- a/src/DidComm.Core/Facade/DidCommClient.cs +++ b/src/DidComm.Core/Facade/DidCommClient.cs @@ -7,6 +7,8 @@ using DidComm.Resolution; using DidComm.Secrets; using DidComm.Transports; +using DpEnc = DataProofsDotnet.Jose.Encryption; +using DpSig = DataProofsDotnet.Jose.Signing; using JoseCryptoProvider = DataProofsDotnet.Jose.JoseCryptoProvider; namespace DidComm.Facade; @@ -25,6 +27,10 @@ public sealed class DidCommClient private readonly ITransportRouter? _transportRouter; private readonly DidCommOptions _options; private readonly JoseCryptoProvider _cryptoProvider; + // Resolves a kid to an opaque-or-extractable JWS signer / ECDH handle (FR-SEC-06). The opaque + // path is selected when the registered ISecretsResolver also implements IOpaqueKeyResolver (or + // one is registered separately), so non-extractable (HSM/KMS/keystore) keys never surface 'd'. + private readonly KeyOperationResolver _keyOps; /// Initialize the facade. Routing (FR-ROUTE-*) is unavailable without an ; for that, use the overload or register the facade through AddDidComm. /// Consumer-supplied private-key resolver (FR-SEC-01). @@ -66,14 +72,15 @@ public DidCommClient( ArgumentNullException.ThrowIfNull(transportRouter); } - /// Initialize the facade with a custom crypto provider; used by tests. + /// Initialize the facade with a custom crypto provider (and optional opaque key resolver); used by tests and DI. internal DidCommClient( ISecretsResolver secrets, IDidKeyService keyService, IServiceEndpointResolver? serviceResolver, ITransportRouter? transportRouter, DidCommOptions options, - JoseCryptoProvider cryptoProvider) + JoseCryptoProvider cryptoProvider, + IOpaqueKeyResolver? opaqueKeys = null) { ArgumentNullException.ThrowIfNull(secrets); ArgumentNullException.ThrowIfNull(keyService); @@ -85,6 +92,11 @@ internal DidCommClient( _transportRouter = transportRouter; _options = options; _cryptoProvider = cryptoProvider; + // Prefer an explicitly-registered IOpaqueKeyResolver; otherwise auto-detect when the secrets + // resolver itself implements it (the common case — e.g. a keystore-backed resolver). Either + // way the extractable path is preserved when no opaque capability is present (FR-SEC-06). + var opaque = opaqueKeys ?? secrets as IOpaqueKeyResolver; + _keyOps = new KeyOperationResolver(secrets, opaque, cryptoProvider); } /// @@ -120,8 +132,8 @@ public async Task PackSignedAsync(Message message, string signFrom, Canc _keyService.RejectUnsupportedMethod(signFrom); RejectUnsupportedDidsOnMessage(message); - var signerPriv = await PickSignerPrivateKeyAsync(signFrom, ct).ConfigureAwait(false); - var parameters = new PackSignedParameters(message, new[] { signerPriv }); + var signer = await PickSignerAsync(signFrom, ct).ConfigureAwait(false); + var parameters = new PackSignedParameters(message, new[] { signer }); return await EnvelopeWriter.PackSignedAsync(parameters, ct).ConfigureAwait(false); } @@ -186,9 +198,10 @@ public async Task PackEncryptedAsync(Message message, PackE if (allSenderPubs.Count == 0) throw new DidResolutionException(options.From, "no keyAgreement keys available for sender"); - // Filter to keys for which the registered ISecretsResolver actually holds the private half. + // Filter to keys the agent actually holds the private half of — opaque (keystore) or + // extractable. FindPresent answers held-ness without exposing key material (FR-SEC-06). var senderKids = allSenderPubs.Where(k => k.Kid is not null).Select(k => k.Kid!).ToArray(); - var heldKids = await _secrets.FindPresentAsync(senderKids, ct).ConfigureAwait(false); + var heldKids = await _keyOps.FindPresentAsync(senderKids, ct).ConfigureAwait(false); var heldKidSet = new HashSet(heldKids, StringComparer.Ordinal); senderPublics = allSenderPubs.Where(k => k.Kid is not null && heldKidSet.Contains(k.Kid!)).ToArray(); if (senderPublics.Count == 0) @@ -208,30 +221,32 @@ public async Task PackEncryptedAsync(Message message, PackE chosenRecipients.Add(recipientPublics[did].First(k => k.Crv == chosenCurve)); } - Jwk? senderPrivateJwk = null; + DpEnc.IEcdhKey? senderKey = null; string? skid = null; if (options.From is not null) { var senderPubJwk = senderPublics.First(k => k.Crv == chosenCurve); skid = senderPubJwk.Kid; - senderPrivateJwk = await _secrets.FindAsync(senderPubJwk.Kid!, ct).ConfigureAwait(false) + // Opaque (keystore) or extractable ECDH handle for the sender-static 1PU derivation; the + // private scalar never leaves custody on the opaque path (FR-SEC-06). + senderKey = await _keyOps.ResolveKeyAgreementAsync(senderPubJwk.Kid!, ct).ConfigureAwait(false) ?? throw new SecretNotFoundException(senderPubJwk.Kid!); } - IReadOnlyList? signerJwks = null; + IReadOnlyList? signers = null; if (options.SignFrom is not null) { - var signerPriv = await PickSignerPrivateKeyAsync(options.SignFrom, ct).ConfigureAwait(false); - signerJwks = new[] { signerPriv }; + var signer = await PickSignerAsync(options.SignFrom, ct).ConfigureAwait(false); + signers = new[] { signer }; } var parameters = new PackEncryptedParameters( Message: message, Recipients: chosenRecipients, ContentEncryption: encJoseAlg, - SenderPrivateJwk: senderPrivateJwk, + SenderKey: senderKey, Skid: skid, - SignerPrivateJwks: signerJwks, + Signers: signers, ProtectSender: options.ProtectSender); var innerPacked = await EnvelopeWriter.PackEncryptedAsync(parameters, _cryptoProvider, ct).ConfigureAwait(false); @@ -328,14 +343,16 @@ public async Task SendAsync(Message message, SendOptions options, Ca /// FR-CONSIST-01..06 addressing-consistency rules (FR-CONSIST-06 is resolver-backed). /// /// - /// Support boundary: the unpack pipeline drives the consumer-supplied - /// and through a sync-over-async - /// bridge (the JOSE composition layer is synchronous — see PRD §7). This is safe in hosts - /// with no — ASP.NET Core, console, - /// generic-host worker services (the supported targets). Calling this under a captured - /// synchronization context (legacy WPF/WinForms or a custom context) can deadlock if a - /// resolver implementation has an inner await without ConfigureAwait(false). - /// Invoke from such contexts via Task.Run(() => client.UnpackAsync(...)). + /// Support boundary: the recipient (secret-key) decrypt path runs natively async + /// as of 1.1.0 — ECDH key agreement flows through opaque-or-extractable IEcdhKey handles + /// (FR-SEC-06), so the consumer-supplied is now awaited + /// directly (no sync-over-async bridge, no deadlock risk under a captured + /// ). The remaining sync-over-async at the + /// seam is confined to public-key DID resolution () feeding + /// DataProofs' synchronous JWS-verify / sender-key contracts; that is safe in hosts with no + /// synchronization context — ASP.NET Core, console, generic-host worker services (the supported + /// targets) — and, as before, such a host should still ensure resolver implementations use + /// ConfigureAwait(false). /// /// The packed DIDComm message. /// Cancellation token. @@ -350,12 +367,17 @@ public async Task UnpackAsync(string packed, CancellationToken ct ct.ThrowIfCancellationRequested(); - var secretsLookup = new SyncSecretsAdapter(_secrets); var senderLookup = DidKeyServiceLookups.SenderKeyLookup(_keyService); var signerLookup = DidKeyServiceLookups.SignerKeyLookup(_keyService); var resolverCheck = BuildResolverAuthorizationPredicate(); - var internalResult = EnvelopeReader.Unpack(packed, secretsLookup, senderLookup, signerLookup, _cryptoProvider, resolverCheck); + // The recipient (secret-key) path is now fully async: ECDH key agreement runs through the + // KeyOperationResolver's opaque-or-extractable IEcdhKey handles, so the pre-1.1.0 + // SyncSecretsAdapter sync-over-async bridge is gone. Sender/signer lookups stay sync — they are + // public-key DID resolutions and DataProofs' IJweSenderKeyResolver / JwsParser contracts for + // them are synchronous (not a secret-key path). + var internalResult = await EnvelopeReader.UnpackAsync( + packed, _keyOps, senderLookup, signerLookup, _cryptoProvider, resolverCheck, ct).ConfigureAwait(false); var message = internalResult.Message; if (message.From is not null) @@ -420,20 +442,20 @@ public async Task UnpackAsync(string packed, CancellationToken ct FromPrior: fromPrior); } - private async Task PickSignerPrivateKeyAsync(string signerDid, CancellationToken ct) + private async Task PickSignerAsync(string signerDid, CancellationToken ct) { var pubs = await _keyService.GetVerificationMethodsAsync(signerDid, VerificationRelationship.Authentication, ct).ConfigureAwait(false); if (pubs.Count == 0) throw new DidResolutionException(signerDid, "no authentication keys available"); - var presentKids = await _secrets.FindPresentAsync(pubs.Where(k => k.Kid is not null).Select(k => k.Kid!), ct).ConfigureAwait(false); + var presentKids = await _keyOps.FindPresentAsync(pubs.Where(k => k.Kid is not null).Select(k => k.Kid!), ct).ConfigureAwait(false); if (presentKids.Count == 0) throw new SecretNotFoundException($"{signerDid} (no authentication key held)"); var kid = presentKids[0]; - var priv = await _secrets.FindAsync(kid, ct).ConfigureAwait(false) + // Opaque (keystore) or extractable JWS signer for the chosen authentication key (FR-SEC-06). + return await _keyOps.ResolveSignerAsync(kid, ct).ConfigureAwait(false) ?? throw new SecretNotFoundException(kid); - return priv; } private void RejectUnsupportedDidsOnMessage(Message message) @@ -488,22 +510,20 @@ private void ValidateFromPriorFreshness(FromPriorClaims claims) }; /// - /// Build the FR-CONSIST-06 resolver-backed authorization predicate. The closure does a - /// sync-over-async call — safe under - /// .NET 10's no-synchronization-context runtime, and warm against the - /// CachingDidResolver for resolvers wrapped in net-did's DI builder. See the - /// support-boundary note for the synchronization-context caveat. + /// Build the FR-CONSIST-06 resolver-backed authorization predicate. Now that the unpack pipeline is + /// async end-to-end, the predicate calls natively + /// (no sync-over-async), warm against the CachingDidResolver for resolvers wrapped in + /// net-did's DI builder. /// - private Func BuildResolverAuthorizationPredicate() + private Func> BuildResolverAuthorizationPredicate() { var keyService = _keyService; - return (assertedDid, kid, relationship) => + return (assertedDid, kid, relationship, ct) => { var rel = string.Equals(relationship, "authentication", StringComparison.Ordinal) ? VerificationRelationship.Authentication : VerificationRelationship.KeyAgreement; - return keyService.IsKeyAuthorizedAsync(assertedDid, kid, rel) - .ConfigureAwait(false).GetAwaiter().GetResult(); + return keyService.IsKeyAuthorizedAsync(assertedDid, kid, rel, ct); }; } diff --git a/src/DidComm.Core/Protocols/Rotation/FromPriorBuilder.cs b/src/DidComm.Core/Protocols/Rotation/FromPriorBuilder.cs index 9b11aec..952083e 100644 --- a/src/DidComm.Core/Protocols/Rotation/FromPriorBuilder.cs +++ b/src/DidComm.Core/Protocols/Rotation/FromPriorBuilder.cs @@ -21,13 +21,28 @@ public static class FromPriorBuilder /// Sub / Iss / Iat triple. /// Private JWK; Kid MUST identify a key authorized under .Iss authentication. /// Cancellation token. - public static async Task BuildAsync(FromPriorClaims claims, Jwk signerPrivateJwk, CancellationToken ct = default) + public static Task BuildAsync(FromPriorClaims claims, Jwk signerPrivateJwk, CancellationToken ct = default) { - ArgumentNullException.ThrowIfNull(claims); ArgumentNullException.ThrowIfNull(signerPrivateJwk); - // JwsSignerFactory validates crv/d/kid and adapts the JWK into a NetCrypto-backed signer. - var signer = JwsSignerFactory.FromPrivateJwk(signerPrivateJwk); + return BuildAsync(claims, JwsSignerFactory.FromPrivateJwk(signerPrivateJwk), ct); + } + + /// + /// Build a from_prior JWT from validated claims using an opaque-or-extractable JWS signer handle + /// (FR-SEC-06). Use this overload to sign the rotation JWT with a key held in a non-extractable + /// boundary (HSM / KMS / keychain / NetCrypto.IKeyStore) — e.g. + /// new JwsSigner(await keyStore.CreateSignerAsync(alias), kid) — so the prior DID's signing + /// scalar never leaves custody. The signer's kid MUST identify a key authorized under + /// .Iss authentication. + /// + /// Sub / Iss / Iat triple. + /// JWS signer handle whose kid identifies the prior DID's signing key. + /// Cancellation token. + public static async Task BuildAsync(FromPriorClaims claims, DpSig.JwsSigner signer, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(claims); + ArgumentNullException.ThrowIfNull(signer); // Claims in lexicographic key order (exp, iat, iss, nbf, sub) so identical inputs produce // byte-identical payloads across runs. exp/nbf are emitted only when present (RFC 7519 @@ -63,13 +78,33 @@ public static async Task BuildAsync(FromPriorClaims claims, Jwk signerPr public static Task BuildAsync(FromPriorClaims claims, Jwk signerPrivateJwk, TimeSpan lifetime, CancellationToken ct = default) { ArgumentNullException.ThrowIfNull(claims); + return BuildAsync(BoundedClaims(claims, lifetime), signerPrivateJwk, ct); + } + + /// + /// Build a freshness-bounded from_prior JWT signed by an opaque-or-extractable JWS signer handle + /// (FR-SEC-06). As , + /// but sets exp = .Iat + so the rotation + /// token cannot be replayed past the window (FR-ROT-05). + /// + /// Sub / Iss / Iat (and optional Nbf); Exp is computed from . + /// JWS signer handle whose kid identifies the prior DID's signing key. + /// Positive validity window added to Iat to form exp. + /// Cancellation token. + /// is not positive. + public static Task BuildAsync(FromPriorClaims claims, DpSig.JwsSigner signer, TimeSpan lifetime, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(claims); + return BuildAsync(BoundedClaims(claims, lifetime), signer, ct); + } + + private static FromPriorClaims BoundedClaims(FromPriorClaims claims, TimeSpan lifetime) + { // exp is second-granular, so a sub-second lifetime would floor to exp == iat — a token already // expired at issue. Require at least one whole second (red-team: avoid the silent zero-window). if (lifetime < TimeSpan.FromSeconds(1)) throw new ArgumentOutOfRangeException(nameof(lifetime), lifetime, "from_prior lifetime must be at least 1 second (exp is second-granular)."); - - var bounded = claims with { Exp = claims.Iat + (long)lifetime.TotalSeconds }; - return BuildAsync(bounded, signerPrivateJwk, ct); + return claims with { Exp = claims.Iat + (long)lifetime.TotalSeconds }; } } diff --git a/src/DidComm.Core/Resolution/DidKeyServiceLookups.cs b/src/DidComm.Core/Resolution/DidKeyServiceLookups.cs index 3ff6957..c453437 100644 --- a/src/DidComm.Core/Resolution/DidKeyServiceLookups.cs +++ b/src/DidComm.Core/Resolution/DidKeyServiceLookups.cs @@ -12,9 +12,12 @@ namespace DidComm.Resolution; /// /// /// -/// Sync-over-async at the seam: the public contract is async, but -/// the JOSE composition layer is sync. The same .NET 10 no-sync-context property that makes -/// safe applies here. +/// Sync-over-async at the seam: the public contract is async, but the +/// DataProofs JWS verify (JwsParser.Parse) and the authcrypt sender-key resolver +/// (IJweSenderKeyResolver) are synchronous contracts, so these public-key lookups +/// bridge sync-over-async. This is safe under .NET 10's no-synchronization-context runtime. (The +/// secret-key recipient path no longer bridges — it runs natively async through the +/// opaque-capable ECDH handles; FR-SEC-06.) /// /// /// The DID portion of the kid is extracted by ; for a diff --git a/src/DidComm.Core/Secrets/IInternalSecretsLookup.cs b/src/DidComm.Core/Secrets/IInternalSecretsLookup.cs deleted file mode 100644 index e45744f..0000000 --- a/src/DidComm.Core/Secrets/IInternalSecretsLookup.cs +++ /dev/null @@ -1,16 +0,0 @@ -using DataProofsDotnet.Jose.Encryption; - -namespace DidComm.Secrets; - -/// -/// Minimal internal contract that the envelope layer uses to fetch recipient private keys for the -/// decrypt path. Identical in shape to DataProofsDotnet.Jose's -/// (TryGet + FindPresent), which it extends so -/// an can be handed straight to -/// DataProofsDotnet.Jose.Encryption.JweParser.Parse with no adapter. The didcomm-specific -/// type name is retained as the envelope layer's seam (and to keep the sync-over-async bridge in -/// documented in DIDComm terms). -/// -internal interface IInternalSecretsLookup : IJweRecipientKeyResolver -{ -} diff --git a/src/DidComm.Core/Secrets/IOpaqueKeyResolver.cs b/src/DidComm.Core/Secrets/IOpaqueKeyResolver.cs new file mode 100644 index 0000000..35efe3a --- /dev/null +++ b/src/DidComm.Core/Secrets/IOpaqueKeyResolver.cs @@ -0,0 +1,55 @@ +using DataProofsDotnet.Jose.Encryption; +using NetCrypto; + +namespace DidComm.Secrets; + +/// +/// Optional capability an MAY also implement to perform DIDComm's two +/// private-key operations — JWS signing and ECDH key agreement — WITHOUT ever exposing private key +/// material (FR-SEC-06). This is the seam that lets keys held in a non-extractable boundary — HSM, +/// cloud KMS, OS keychain, MPC, or a NetCrypto.IKeyStore — sign and decrypt DIDComm v2 +/// envelopes while the private scalar never leaves custody. +/// +/// +/// +/// When the resolver registered as the singleton also implements this +/// interface, the facade routes the signature step (signed envelopes, the inner JWS of +/// sign-then-encrypt, and the from_prior JWT) and the ECDH step (authcrypt send, +/// authcrypt / anoncrypt receive) through these operation handles instead of decoding a +/// private . Both methods return null when the kid is not held +/// opaquely, so the facade falls back to the extractable +/// path for that key. A single wallet may therefore mix opaque (keystore-held) and extractable +/// (in-memory) keys. +/// +/// +/// Selection — which recipient / sender / signer kids the agent holds — still flows through +/// ; a keystore-backed resolver answers it from its +/// alias list, so it need not expose private material to be a sufficient sole resolver. The handles +/// here cover only the secret operation itself; everything downstream (Concat-KDF, A256KW key wrap, +/// AEAD, header assembly, signature normalization) is public-data math the JOSE layer already owns. +/// +/// +public interface IOpaqueKeyResolver +{ + /// + /// Return an opaque JWS signer for (signs inside the secure boundary), or + /// null when the key is not held opaquely. The returned MAY emit a + /// raw EdDSA / compact-or-DER ECDSA signature; the JWS layer normalizes it to the JOSE wire form. + /// + /// Key identifier — typically a DID URL with a fragment. + /// Cancellation token. + Task ResolveSignerAsync(string kid, CancellationToken ct = default); + + /// + /// Return an opaque ECDH key-agreement handle for , or null when the + /// key is not held opaquely. The handle is self-describing (it carries its own + /// crvX25519 / P-256 / P-384 / P-521) and derives the raw + /// shared secret Z inside the secure boundary; the JWE layer performs all subsequent + /// (public-data) KDF / key-wrap / AEAD. Implementations SHOULD resolve held-ness and the curve in a + /// single backing lookup (the facade does not pre-fetch either), so the held receive path stays as + /// cheap as possible. + /// + /// Key identifier — typically a DID URL with a fragment. + /// Cancellation token. + Task ResolveKeyAgreementAsync(string kid, CancellationToken ct = default); +} diff --git a/src/DidComm.Core/Secrets/KeyOperationResolver.cs b/src/DidComm.Core/Secrets/KeyOperationResolver.cs new file mode 100644 index 0000000..f8c6c13 --- /dev/null +++ b/src/DidComm.Core/Secrets/KeyOperationResolver.cs @@ -0,0 +1,96 @@ +using DataProofsDotnet.Jose; +using DataProofsDotnet.Jose.Encryption; +using DataProofsDotnet.Jose.Signing; +using DidComm.Jose.Signing; +using JoseCryptoProvider = DataProofsDotnet.Jose.JoseCryptoProvider; + +namespace DidComm.Secrets; + +/// +/// Turns a kid into a DataProofsDotnet.Jose operation handle — a for +/// signing or an for ECDH key agreement — preferring the opaque +/// (non-extractable) path when the registered also implements +/// , and otherwise building the handle from an extractable private +/// . It is the one place the facade / envelope layer resolves "something that can +/// sign or derive" for a kid (FR-SEC-06): on the opaque side no private scalar is ever materialized +/// here; on the extractable side the behavior reproduces the pre-1.1.0 path byte-for-byte. +/// +internal sealed class KeyOperationResolver +{ + private readonly ISecretsResolver _secrets; + private readonly IOpaqueKeyResolver? _opaque; + private readonly JoseCryptoProvider _crypto; + + public KeyOperationResolver(ISecretsResolver secrets, IOpaqueKeyResolver? opaque, JoseCryptoProvider crypto) + { + ArgumentNullException.ThrowIfNull(secrets); + ArgumentNullException.ThrowIfNull(crypto); + _secrets = secrets; + _opaque = opaque; + _crypto = crypto; + } + + /// Whether an opaque key resolver is wired in (selects the constant-work receive path). + public bool HasOpaqueResolver => _opaque is not null; + + /// Which of the agent holds a usable private key for. Held-ness is + /// answered by the resolver (a keystore answers from its alias list), so opaque keys count without + /// exposing material. + public Task> FindPresentAsync(IEnumerable kids, CancellationToken ct = default) + => _secrets.FindPresentAsync(kids, ct); + + /// + /// Build a JWS signer for — opaque if the resolver holds it that way, else + /// from the extractable private JWK — or null when no signing key is held. + /// + public async Task ResolveSignerAsync(string kid, CancellationToken ct = default) + { + ArgumentException.ThrowIfNullOrEmpty(kid); + + if (_opaque is not null) + { + var signer = await _opaque.ResolveSignerAsync(kid, ct).ConfigureAwait(false); + if (signer is not null) + return new JwsSigner(signer, kid); + } + + var jwk = await _secrets.FindAsync(kid, ct).ConfigureAwait(false); + return jwk is null ? null : JwsSignerFactory.FromPrivateJwk(jwk); + } + + /// + /// Build an ECDH key-agreement handle for — opaque if held that way, else a + /// over the extractable private scalar — or null when no + /// key-agreement key is held. The curve is discovered from the resolver's JWK for the kid (a + /// keystore surfaces a public JWK that still carries crv). + /// + public async Task ResolveKeyAgreementAsync(string kid, CancellationToken ct = default) + { + ArgumentException.ThrowIfNullOrEmpty(kid); + + // Opaque first — the handle is self-describing (carries its own crv), so the held opaque path + // needs no extra crv pre-fetch (one backing lookup, not two). Only when the kid is NOT held + // opaquely do we consult the extractable resolver and build a RawEcdhKey from the private scalar. + if (_opaque is not null) + { + var handle = await _opaque.ResolveKeyAgreementAsync(kid, ct).ConfigureAwait(false); + if (handle is not null) + return handle; + } + + var jwk = await _secrets.FindAsync(kid, ct).ConfigureAwait(false); + if (jwk is null || string.IsNullOrEmpty(jwk.D) || string.IsNullOrEmpty(jwk.Crv)) + return null; + + var priv = Base64Url.Decode(jwk.D); + try + { + // RawEcdhKey copies the scalar internally, so the decoded buffer can be wiped immediately. + return new RawEcdhKey(jwk.Crv, priv, _crypto); + } + finally + { + CryptographicOperations.ZeroMemory(priv); + } + } +} diff --git a/src/DidComm.Core/Secrets/SyncSecretsAdapter.cs b/src/DidComm.Core/Secrets/SyncSecretsAdapter.cs deleted file mode 100644 index db6e5c7..0000000 --- a/src/DidComm.Core/Secrets/SyncSecretsAdapter.cs +++ /dev/null @@ -1,27 +0,0 @@ -namespace DidComm.Secrets; - -/// -/// Bridges the public async contract to the internal -/// synchronous the JOSE composition layer consumes -/// (see PRD §7 / Phase 3 plan: the envelope layer stays sync). The synchronous methods run the -/// underlying async resolver on the thread pool via -/// and block on the result, so a consumer resolver's inner await never resumes on a captured -/// — avoiding the classic sync-over-async -/// deadlock if the facade is ever invoked under a custom/legacy UI context. -/// -internal sealed class SyncSecretsAdapter : IInternalSecretsLookup -{ - private readonly ISecretsResolver _inner; - - public SyncSecretsAdapter(ISecretsResolver inner) - { - ArgumentNullException.ThrowIfNull(inner); - _inner = inner; - } - - public Jwk? TryGet(string kid) - => Task.Run(() => _inner.FindAsync(kid)).GetAwaiter().GetResult(); - - public IReadOnlyList FindPresent(IEnumerable kids) - => Task.Run(() => _inner.FindPresentAsync(kids)).GetAwaiter().GetResult(); -} diff --git a/src/DidComm.Extensions.DependencyInjection/DidCommBuilder.cs b/src/DidComm.Extensions.DependencyInjection/DidCommBuilder.cs index 2e5de99..c6075c0 100644 --- a/src/DidComm.Extensions.DependencyInjection/DidCommBuilder.cs +++ b/src/DidComm.Extensions.DependencyInjection/DidCommBuilder.cs @@ -184,23 +184,32 @@ public DidCommBuilder UseNetDidResolver(Action? configure = null) /// /// Register a as the singleton. - /// Required — FR-SEC-02 fails fast if no resolver is registered. + /// Required — FR-SEC-02 fails fast if no resolver is registered. When + /// also implements (e.g. a keystore-backed resolver), the same + /// singleton is surfaced as that capability too, so the facade routes signing / ECDH through the + /// non-extractable handles (FR-SEC-06). /// /// A concrete implementation. public DidCommBuilder UseSecretsResolver() where T : class, ISecretsResolver { Services.AddSingleton(); + if (typeof(IOpaqueKeyResolver).IsAssignableFrom(typeof(T))) + Services.AddSingleton(sp => (IOpaqueKeyResolver)sp.GetRequiredService()); return this; } /// - /// Register an already-constructed instance as a singleton. + /// Register an already-constructed instance as a singleton. When the + /// instance also implements , it is surfaced as that capability too + /// (non-extractable signing / ECDH; FR-SEC-06). /// /// The resolver to register. public DidCommBuilder UseSecretsResolver(ISecretsResolver instance) { ArgumentNullException.ThrowIfNull(instance); Services.AddSingleton(instance); + if (instance is IOpaqueKeyResolver opaque) + Services.AddSingleton(opaque); return this; } diff --git a/src/DidComm.Extensions.DependencyInjection/DidCommServiceCollectionExtensions.cs b/src/DidComm.Extensions.DependencyInjection/DidCommServiceCollectionExtensions.cs index 2b763d5..0d067fe 100644 --- a/src/DidComm.Extensions.DependencyInjection/DidCommServiceCollectionExtensions.cs +++ b/src/DidComm.Extensions.DependencyInjection/DidCommServiceCollectionExtensions.cs @@ -52,7 +52,12 @@ public static IServiceCollection AddDidComm( sp.GetService(), sp.GetService(), sp.GetRequiredService>().Value, - sp.GetRequiredService())); + sp.GetRequiredService(), + // Opaque (non-extractable / keystore) key operations, when a resolver provides them + // (FR-SEC-06). Optional: null falls back to the extractable ISecretsResolver path, and the + // facade additionally auto-detects the capability when the ISecretsResolver itself + // implements IOpaqueKeyResolver (e.g. NetDidKeyStoreSecretsResolver). + sp.GetService())); if (!IsRegistered(services)) { diff --git a/tasks/lessons.md b/tasks/lessons.md index 7dc28d7..5fe1be9 100644 --- a/tasks/lessons.md +++ b/tasks/lessons.md @@ -575,3 +575,33 @@ Format per entry: unbounded (network) tail reachable only when the secret is present, say plainly that the floor does not close it and name the compensating control — never argue "the other class can't reach it" as if that hid the signal. Always re-run the break-it adversarial pass on the *revised* fix, not just the first. + +## L-026 — Verify upstream is actually DONE before planning multi-repo work; and re-run the break-it pass on the opaque seam + +- **Lesson:** Issue #45 (opaque HSM/KMS custody for DIDComm) looked like a deep multi-repo change — its + own first analysis "over-attributed the crypto work" to `didcomm-dotnet` and implied I'd have to modify + `DataProofsDotnet.Jose` and cut a new release. Two things were true that only a *source-grounded* check + revealed: (a) the only real upstream change (an async `IEcdhKey` ECDH seam, dataproofs#13) was **already + implemented, merged, tagged `v1.1.0`, and even on nuget.org** — so #45 collapsed to single-repo wiring; + and (b) signing was already opaque-capable for free because the JWS layer signs through NetCrypto's + `ISigner` and `EcdsaSignatureCodec.EnsureIeeeP1363` normalizes any encoding. The headline cost was not + crypto at all — it was making the **unpack path async** and wiring `PeekRecipients → ParseAsync` with a + constant-work decoy. Separately: the adversarial pass on my *finished* seam found a HIGH defect I'd have + shipped — a keystore `KeyNotFoundException`/`CryptographicException` from the opaque derive escaped + `UnpackAsync` raw, but **only on the held path** (the in-process decoy can never throw it), re-creating + the exact recipient-possession oracle the constant-work design exists to kill — plus a redundant second + `GetInfoAsync` round-trip that leaked held-ness by timing on a slow store. +- **Why:** Multi-repo issues are often described from the *requester's* mental model, which front-loads the + scary part. Grounding scope in file:line + checking the dependency's git tags / nuget feed before + designing keeps me from planning (and a maintainer from approving) work that's already shipped. And an + opaque/keystore seam shifts *new exception and latency surfaces* into a path that previously only ran + in-process — those asymmetries (an exception type, or an extra I/O round-trip, that appears on the + held branch but never the unheld one) are themselves side channels, even when the crypto is identical. +- **How to apply:** (1) Before proposing a multi-repo plan, read the upstream code/issue *and* check its + release state (`git tag`, the nuget flatcontainer index) — confirm what's already done. (2) When routing + a secret op through a new opaque backing, audit the *failure* and *latency* parity between the opaque + (held) path and the in-process/decoy (unheld) path, not just the success path: fold every backing-store + fault into the same uniform `CryptoException` the decoy path produces (exclude only cancellation), and + equalize/​minimize pre-crypto round-trips. (3) Always run the break-it adversarial subagent on the + *finished* seam and add a regression test per finding (here: a handle whose `DeriveAsync` throws + `KeyNotFoundException`/`CryptographicException` must yield the uniform `CryptoException`). diff --git a/tasks/todo20260622-113605.md b/tasks/todo20260622-113605.md new file mode 100644 index 0000000..152a7c7 --- /dev/null +++ b/tasks/todo20260622-113605.md @@ -0,0 +1,185 @@ +# Issue #45 — Opaque (non-extractable) signing & key agreement: wire DIDComm crypto to a keystore (HSM/KMS) + +**Milestone:** 1.1.0 · **Depends on:** dataproofs-dotnet#13 (ships first) · **Unblocks:** net-wallet-sdk Phase 3 (Messaging) + +> Status of this file: PLAN — awaiting explicit approval before any source/test/doc edits (CLAUDE.md §Workflow). + +--- + +## 1. Evaluation summary (grounded in file:line, three repos) + +DIDComm carries **no envelope crypto of its own** — it resolves DIDs → public JWKs, pulls **private** JWKs from `ISecretsResolver`, and hands them to `DataProofsDotnet.Jose`. Only **two** operations actually touch the private key: + +| Operation | Today | Opaque path needs | +|---|---|---| +| **Sign** (signed env, inner JWS, `from_prior` JWT) | `JwsSignerFactory.FromPrivateJwk` → NetCrypto `KeyPairSigner` (an `ISigner`) → `JwsBuilder` | **didcomm only** — build the `JwsSigner` from a keystore `ISigner` (`IKeyStore.CreateSignerAsync`). DataProofs already signs through `ISigner` + `EcdsaSignatureCodec.EnsureIeeeP1363`. No DataProofs change. | +| **ECDH** authcrypt send | `EnvelopeWriter` → `JweBuilder.BuildEcdh1PuA256Kw(senderPrivateJwk)` (decodes `d` inside DataProofs) | the new `BuildEcdh1PuA256KwAsync(IEcdhKey senderKey, …)` | +| **ECDH** authcrypt/anoncrypt receive | `EnvelopeReader.Unpack` → `JweParser.Parse(secretsLookup, …)` (decodes recipient `d` inside DataProofs) | the new `JweParser.ParseAsync(IEcdhKey recipientKey, …)` after `PeekRecipients` selection | + +anoncrypt **send** uses a library-generated ephemeral key — no private key, no change (`BuildEcdhEsA256Kw` stays as-is). + +### What changed since the original issue (the rescope) +- **crypto-dotnet (NetCrypto): no change.** `IKeyStore.DeriveSharedSecretAsync` returns raw `Z` (X25519 + P-256/384/521); `IKeyStore.CreateSignerAsync` → `ISigner`; both confirmed at `IKeyStore.cs:19/22/25/43`. NetCrypto **1.1.0 is on nuget.org**. +- **dataproofs-dotnet#13: DONE in source.** The async `IEcdhKey` seam is implemented and tagged `v1.1.0` (commit `6ecaddf`). New public API (verified in `PublicAPI.Unshipped.txt`): + - `Encryption.IEcdhKey { string Crv; ValueTask DeriveAsync(peerPublicKey, ct) }` + - `Encryption.RawEcdhKey(crv, privateKey, JoseCryptoProvider)` — in-process impl (byte-identical to today). + - `JweBuilder.BuildEcdh1PuA256KwAsync(plaintext, recipients, IEcdhKey senderKey, skid, enc, provider, typ, ct)`. + - `JweParser.PeekRecipients(packed) → JwePeekResult { Algorithm, Skid, RecipientKids }` (public metadata, **no key**). + - `JweParser.ParseAsync(packed, IEcdhKey recipientKey, IJweSenderKeyResolver?, provider, ct)` + `ParseCompactAsync(...)`. + - The sync `JweParser.Parse(IJweRecipientKeyResolver, …)` **remains** (back-compat). + - Bundled with #12 "**constant-work JWE decrypt**" (decoy-key path) — see §6 timing note. +- **This issue (didcomm) = wiring.** Consume `IEcdhKey`/`ISigner` handles instead of private `Jwk`; the headline effort is **making the unpack path async** (`EnvelopeReader.Unpack` is fully synchronous today). + +### ⚠️ Hard gating fact (release train) +**DataProofs.Jose 1.1.0 is NOT on nuget.org** (public feed has ≤ 1.0.1). didcomm references it as a **NuGet package**, so: +- **Local dev / CI / publish of didcomm 1.1.0 is blocked on publishing DataProofs.Jose 1.1.0 to nuget.org first** (dataproofs#13 "ships first" — implemented + tagged; the remaining step is `dotnet nuget push`, which the maintainer controls). +- We can develop locally against a built 1.1.0 `.nupkg` (local feed) before the public push. + +Public API impact on didcomm: **additive only.** `DidCommClient.PackSignedAsync/PackEncryptedAsync/UnpackAsync` are already `async Task<…>`; flipping internals to async is not a breaking change — it removes the documented sync-over-async deadlock caveat. New public surface = one optional capability interface + adapter wiring. + +--- + +## 2. Design — the opaque seam (recommended) + +**Resolvers return operation handles** (per the rescope), exposed as an *optional capability* a resolver MAY also implement. Existing `ISecretsResolver` (FindAsync/FindPresentAsync) is untouched, so every current implementer and the whole extractable path keep working byte-for-byte. + +```csharp +namespace DidComm.Secrets; + +/// Optional capability an ISecretsResolver MAY also implement to perform DIDComm's two private-key +/// operations WITHOUT exposing key material (HSM / KMS / keychain / MPC). When the registered +/// resolver implements this, the facade routes signing + ECDH through these handles instead of +/// extracting a private Jwk. Returns null when the kid is not held opaquely (facade then falls back +/// to the extractable ISecretsResolver). +public interface IOpaqueKeyResolver +{ + Task ResolveSignerAsync(string kid, CancellationToken ct = default); + Task ResolveKeyAgreementAsync(string kid, string crv, CancellationToken ct = default); +} +``` + +Rationale for handles over the original raw-primitive `IOpaqueKeyOperations(sign/derive)`: +- DataProofs consumes `IEcdhKey` directly and NetCrypto provides `ISigner` directly → **no per-call adapter** in the hot path; the secure boundary objects flow straight through. +- Keeps DataProofs DID-agnostic (handle = `crv` + derive); keeps NetCrypto unaware of JOSE. +- Alternative (default-interface methods on `ISecretsResolver`) considered and rejected: it forces a `JoseCryptoProvider` into the contract's default body and muddies the extractable/opaque split. + +**Facade selection rule (per kid):** if the registered resolver implements `IOpaqueKeyResolver` **and** it returns a non-null handle for the kid → use the opaque handle; else build the handle from the extractable `ISecretsResolver` (`RawEcdhKey` from `d`+`crv`; `JwsSigner` from the private `Jwk` via the existing factory). A wallet may mix opaque and extractable keys. + +--- + +## 3. Work plan (checklist) + +### Phase 0 — Prerequisite (cross-repo, maintainer-controlled) +- [ ] Confirm/publish **DataProofs.Jose 1.1.0 → nuget.org** (dataproofs#13). Until then, set up local consumption (Phase 1). +- [ ] (No NetCrypto change — already 1.1.0 on nuget.org.) + +### Phase 1 — Consume DataProofs.Jose 1.1.0 +- [ ] `Directory.Packages.props`: `DataProofsDotnet.Jose` `1.0.1 → 1.1.0` (update the inline comment). +- [ ] Dev consumption until the public push: add a local feed (e.g. `dotnet pack` of dataproofs `v1.1.0` into `./local-packages`, referenced from `nuget.config`). **Do not** convert to a permanent `ProjectReference` (didcomm ships as a package). Document the temporary feed so it's removed once 1.1.0 is public. +- [ ] Restore + build to verify the new API surface resolves. + +### Phase 2 — Opaque seam (`DidComm.Core/Secrets`) +- [ ] Add `IOpaqueKeyResolver` (above). +- [ ] Add an internal `OpaqueKeyMaterial` resolver/helper that, given the registered `ISecretsResolver` (+ optional `IOpaqueKeyResolver`) and the `JoseCryptoProvider`, yields per-kid: + - `Task GetEcdhKeyAsync(kid, crv, ct)` — opaque handle if available, else `RawEcdhKey` from `FindAsync(kid)`. + - `Task GetSignerAsync(kid, ct)` — `JwsSigner(opaque ISigner, kid)` if available, else `JwsSignerFactory.FromPrivateJwk(FindAsync(kid))`. + - `Task> FindPresentAsync(kids, ct)` — union of opaque-held + extractable-held (for sender/recipient selection). + +### Phase 3 — Pack path (already async; route through handles) +- [ ] `EnvelopeWriter`: `PackAuthcrypt` → `await JweBuilder.BuildEcdh1PuA256KwAsync(innerBytes, recipients, senderEcdhKey, skid, enc, provider, MediaTypes.Encrypted, ct)`. Replace `PackEncryptedParameters.SenderPrivateJwk` with `IEcdhKey? SenderKey` (+ keep `Skid`). +- [ ] `EnvelopeWriter.SignAsync`: accept pre-built `JwsSigner`s (from the facade) instead of `IReadOnlyList`; drop the in-method `JwsSignerFactory.FromPrivateJwk` loop. Update `PackSignedParameters` / `PackEncryptedParameters.SignerPrivateJwks` → `IReadOnlyList`. +- [ ] `DidCommClient`: + - `PickSignerPrivateKeyAsync` → `PickSignerAsync` returning a `JwsSigner` (opaque or extractable). Used by `PackSignedAsync`, sign-then-encrypt, and `from_prior`. + - authcrypt sender (`:211–219`): resolve an `IEcdhKey` for the chosen `skid` (opaque or `RawEcdhKey`) instead of `FindAsync` → private `Jwk`. `FindPresentAsync` sender-filter logic stays (now via the Phase-2 helper). +- [ ] `FromPriorBuilder.BuildAsync`: take a `JwsSigner` (resolved by the facade) rather than a private `Jwk`, so rotation JWT signing is opaque-capable too. +- [ ] anoncrypt send / `ForwardWrapper` / `ProtectSender` outer layer: **unchanged** (ephemeral-only). + +### Phase 4 — Unpack path → async (the main effort) +- [ ] `EnvelopeReader.Unpack` → `UnpackAsync(... , CancellationToken ct)`. For each `EnvelopeKind.Encrypted` layer: + 1. `var peek = JweParser.PeekRecipients(current)` (kids + skid + alg — no key). + 2. Select the held recipient kid via the Phase-2 `FindPresentAsync(peek.RecipientKids)`; pick its `crv` from the resolved/peeked metadata. + 3. Build the recipient `IEcdhKey` (opaque or `RawEcdhKey`). + 4. `var jweResult = await JweParser.ParseAsync(current, recipientKey, senderLookup, cryptoProvider, ct)`. + - **Constant-work (preserve #12):** when **no** recipient kid is held, still call `ParseAsync` with a **decoy** `IEcdhKey` (don't branch out early) so held-vs-unheld stays time-invariant at the envelope layer — this is the upstream root-cause fix #35/#12 reference; wiring it correctly is part of #45. (HTTP floor #35 remains the outer backstop.) + - Keep all existing exception mapping (`MalformedJoseException`/`JoseCryptoException`/`ArgumentException` → `MalformedMessageException`/`CryptoException`). +- [ ] Replace `IInternalSecretsLookup` + `SyncSecretsAdapter` (recipient path) with the async Phase-2 helper. Retire `SyncSecretsAdapter` if no other caller remains; the JWS verify path and sender lookup stay public-key (`senderLookup` flows to `ParseAsync` as `IJweSenderKeyResolver?` unchanged). +- [ ] `DidCommClient.UnpackAsync`: `await EnvelopeReader.UnpackAsync(...)`; make `BuildResolverAuthorizationPredicate` (FR-CONSIST-06) async (drop its sync-over-async); update the `UnpackAsync` XML "support boundary" remark (the deadlock caveat is now gone for the secrets path). +- [ ] Sanity-keep depth/grammar guard, addressing-consistency checks, and metadata population exactly as-is. + +### Phase 5 — NetDid adapter (opaque impl over `IKeyStore`) +- [ ] Add `KeyStoreEcdhKey : IEcdhKey` (in `DidComm.Adapters.NetDid`) wrapping `(IKeyStore, alias, crv)` → `DeriveAsync` calls `IKeyStore.DeriveSharedSecretAsync(alias, peerPub, ct)`. +- [ ] `NetDidKeyStoreSecretsResolver` also implements `IOpaqueKeyResolver`: + - `ResolveSignerAsync(kid)` → `await _keyStore.CreateSignerAsync(alias)` (returns `ISigner`), or null if alias absent. + - `ResolveKeyAgreementAsync(kid, crv)` → `new KeyStoreEcdhKey(_keyStore, alias, crv)`, or null if absent. + - kid→alias: keep **kid == alias** (current convention) + optional `Func` mapping hook on the ctor. + - Update the class XML docs — remove the "opaque path is future work / not sufficient as sole resolver" caveat; it now **is** a sufficient sole resolver for HSM-backed stores. + +### Phase 6 — DI wiring (`DidComm.Extensions.DependencyInjection`) +- [ ] `AddDidComm`/`DidCommClient` ctor: accept an optional `IOpaqueKeyResolver` (resolve from DI; commonly the same singleton as `ISecretsResolver` when it implements both). No new required registration — fully back-compat. +- [ ] Add a builder helper (e.g. `UseNetDidKeyStore(IKeyStore[, kid→alias])`) registering the keystore resolver as both `ISecretsResolver` and `IOpaqueKeyResolver`. + +### Phase 7 — Tests +- [ ] **Opaque test double** in `DidComm.TestSupport`: an `IKeyStore`-backed (or `IOpaqueKeyResolver`) resolver whose backing **throws if asked for `d`** — proving the scalar never leaves the boundary. +- [ ] Round-trip (`DidComm.InteropTests`): authcrypt / anoncrypt / signed / sign-then-encrypt / anoncrypt(authcrypt) + a **trust-ping** request/response, driven entirely through the opaque path; assert `Encrypted/Authenticated/NonRepudiation` metadata matches the extractable path. +- [ ] **Mixed custody**: some kids opaque, some extractable, same wallet. +- [ ] **Back-compat**: existing extractable suite stays green untouched. +- [ ] **Adversarial subagent** (CLAUDE.md §2): attempt to extract key bytes through the seam; attempt envelope-confusion / kid-substitution; probe the held-vs-unheld **timing** path on unpack (constant-work decoy). Report findings in this file's Review section. + +### Phase 8 — Docs, CHANGELOG, version +- [ ] PRD (`docs/didcomm-dotnet-prd.md`) — add the opaque-custody capability + FR ref; `codebase-architecture.md` — update the pack/unpack flow + the new seam. +- [ ] `NetDidKeyStoreSecretsResolver` XML (done in Phase 5) + any README router note if needed. +- [ ] `CHANGELOG.md` — rename `[Unreleased]` → `[1.1.0]` (folds in the already-staged #35 security work) and add the opaque sign/ECDH feature entry; note the DataProofs.Jose 1.1.0 dependency bump. +- [ ] `Directory.Build.props` — `DidCommVersion` `0.1.0 → 1.1.0` (last public release is 1.0.0; 1.1.0 = additive minor). **Only after plan approval**, per the user's instruction. + +### Verification (CLAUDE.md §4) +- [ ] `dotnet build` (warnings-as-errors) + **full** `dotnet test` green across all projects. +- [ ] Diff metadata/behavior opaque-vs-extractable for each envelope shape (must be identical). +- [ ] Confirm no public breaking change (PublicAPI diff is additive). +- [ ] Adversarial report triaged; lessons captured in `tasks/lessons.md` if any correction occurs. + +--- + +## 4. Files in scope +- **DidComm.Core/Secrets:** `IOpaqueKeyResolver.cs` (new), opaque/extractable handle helper (new), `ISecretsResolver.cs` (unchanged), retire `IInternalSecretsLookup.cs`/`SyncSecretsAdapter.cs` on the recipient path. +- **DidComm.Core/Composition:** `EnvelopeReader.cs` (→ async), `EnvelopeWriter.cs`, `PackParameters.cs`. +- **DidComm.Core/Facade:** `DidCommClient.cs` (signer/sender resolution, async unpack, async FR-CONSIST-06). +- **DidComm.Core/Jose/Signing:** `JwsSignerFactory.cs` (extractable factory stays; facade now also builds from opaque `ISigner`). +- **DidComm.Core/Protocols/Rotation:** `FromPriorBuilder.cs`. +- **DidComm.Adapters.NetDid:** `NetDidKeyStoreSecretsResolver.cs`, `KeyStoreEcdhKey.cs` (new). +- **DidComm.Extensions.DependencyInjection:** builder + ctor wiring. +- **tests/**: TestSupport double, InteropTests round-trips, Core.Tests seam tests. +- **Root:** `Directory.Packages.props`, `Directory.Build.props`, `CHANGELOG.md`, `docs/*`. + +## 5. Open decisions for the user +1. **Seam shape** — recommend `IOpaqueKeyResolver` (handles). Alt: original raw-primitive `IOpaqueKeyOperations`. Alt: default methods on `ISecretsResolver`. +2. **DataProofs.Jose 1.1.0 nuget.org publish** — needed before didcomm 1.1.0 can publish/CI. OK to proceed with a **local feed** for dev now and treat the public push as a release-train step? +3. **kid→alias** — recommend kid==alias + optional hook. +4. **Scope of async cleanup** — recommend also making the public-key `DidKeyServiceLookups`/FR-CONSIST-06 path async while we're in there (removes the last sync-over-async bridges) vs. leaving them. + +## 6. Risks +- **Release coupling** (DataProofs.Jose nuget) — §1 gating fact. +- **Constant-work regression** — the async selection must keep held/unheld time-invariant (Phase 4 decoy); verify with the adversarial timing probe. +- **Async cascade reach** — unpack→async touches `EnvelopeReader` + facade; contained, no public break, but broad. Keep diffs minimal and behavior-identical for the extractable path. +- **Signature encoding** — non-issue: `EcdsaSignatureCodec.EnsureIeeeP1363` normalizes DER→P1363 for any `ISigner` (incl. keystore). + +## 7. Review (implemented) + +**Outcome:** #45 delivered as single-repo wiring (dataproofs#13 was already merged + published as DataProofs.Jose 1.1.0). All acceptance criteria met. Release build clean (0 warnings/errors); **668 tests pass** (533 Core + 135 Interop), including 12 new opaque-path tests. + +**What shipped** +- `IOpaqueKeyResolver` seam (sign → `ISigner`, ECDH → `IEcdhKey`); internal `KeyOperationResolver` prefers opaque, falls back to extractable (byte-for-byte unchanged). +- Pack path routes authcrypt through `BuildEcdh1PuA256KwAsync(IEcdhKey)` and signing through pre-built `JwsSigner`s; `from_prior` gained additive `JwsSigner` overloads. +- Unpack made async end-to-end: `EnvelopeReader.UnpackAsync` does `PeekRecipients → FindPresent → ResolveKeyAgreement → ParseAsync`, always invoking `ParseAsync` (decoy when unheld) for constant-work. Retired `SyncSecretsAdapter` + `IInternalSecretsLookup`; FR-CONSIST-06 predicate now async. +- `NetDidKeyStoreSecretsResolver` implements `IOpaqueKeyResolver` over `IKeyStore` (+ `KeyStoreEcdhKey`), kid==alias + optional hook; now a sufficient sole resolver for HSM stores. +- DI honors a separately-registered `IOpaqueKeyResolver`; `UseSecretsResolver` surfaces the capability; facade auto-detects `secrets as IOpaqueKeyResolver`. + +**Decisions taken (user delegated):** `IOpaqueKeyResolver` handle interface; kid==alias + hook; broader async cleanup limited to the secret path + FR-CONSIST-06 (sender/signer public-key lookups stay sync-over-async by DataProofs contract). + +**Adversarial review (2 passes):** +- **Finding 1 (HIGH) — FIXED & re-verified:** keystore derive faults (`KeyNotFoundException`/`CryptographicException`) escaped `UnpackAsync` raw, only on the held path → exception-type possession oracle + FR-API-07 escape. Fixed by a final broad catch in the decrypt ladder folding any non-cancellation/non-contract fault into the uniform `CryptoException("JWE could not be decrypted.")`. Regression test covers both fault types. +- **Finding 2 (MEDIUM) — FIXED & re-verified:** redundant second `GetInfoAsync` on the held path. Dropped `crv` from the interface; adapter self-determines curve in one lookup; `KeyOperationResolver` tries opaque first with no crv pre-fetch. Held receive = 1 `ListAsync` + 1 `GetInfoAsync`. Docstring corrected to scope constant-work to `ParseAsync` inward (resolver prologue covered by the #35 transport floor). +- Custody, authorization, decoy-curve, and `from_prior` confirmed sound. + +**Behavior change (documented in CHANGELOG):** malformed `iv`/`tag`/`encrypted_key` lengths on decrypt now surface as `CryptoException` (uniform), not `MalformedMessageException` — the dataproofs#12 constant-work hardening, consumed here. Also lands the durable constant-work recipient selection (#42). + +**Lesson captured:** `tasks/lessons.md` L-026. diff --git a/tests/DidComm.Core.Tests/Consistency/AddressingConsistencyTests.cs b/tests/DidComm.Core.Tests/Consistency/AddressingConsistencyTests.cs index b3ab3ad..cbec575 100644 --- a/tests/DidComm.Core.Tests/Consistency/AddressingConsistencyTests.cs +++ b/tests/DidComm.Core.Tests/Consistency/AddressingConsistencyTests.cs @@ -100,18 +100,18 @@ public void Authcrypt_inner_signer_match_passes() } [Fact] - public void Resolver_authorization_with_null_resolver_short_circuits() + public async Task Resolver_authorization_with_null_resolver_short_circuits() { - AddressingConsistency.CheckResolverAuthorization( + await AddressingConsistency.CheckResolverAuthorizationAsync( "did:example:alice", "did:example:alice#k", "keyAgreement", resolverCheck: null); } [Fact] - public void Resolver_authorization_throws_when_resolver_says_no() + public async Task Resolver_authorization_throws_when_resolver_says_no() { - Action act = () => AddressingConsistency.CheckResolverAuthorization( + Func act = () => AddressingConsistency.CheckResolverAuthorizationAsync( "did:example:alice", "did:example:alice#k", "keyAgreement", - resolverCheck: (_, _, _) => false); - act.Should().Throw().WithMessage("*FR-CONSIST-06*"); + resolverCheck: (_, _, _, _) => Task.FromResult(false)); + (await act.Should().ThrowAsync()).WithMessage("*FR-CONSIST-06*"); } } diff --git a/tests/DidComm.Core.Tests/Consistency/ResolverAuthorizationTests.cs b/tests/DidComm.Core.Tests/Consistency/ResolverAuthorizationTests.cs index 38a5d88..789d170 100644 --- a/tests/DidComm.Core.Tests/Consistency/ResolverAuthorizationTests.cs +++ b/tests/DidComm.Core.Tests/Consistency/ResolverAuthorizationTests.cs @@ -6,63 +6,63 @@ namespace DidComm.Tests.Consistency; /// -/// Direct unit coverage for -/// (FR-CONSIST-06). The full pipeline integration through EnvelopeReader.Unpack is +/// Direct unit coverage for +/// (FR-CONSIST-06). The full pipeline integration through EnvelopeReader.UnpackAsync is /// covered by the interop round-trip tests, which exercise every legal envelope shape with a /// real resolver-backed predicate. /// public sealed class ResolverAuthorizationTests { [Fact] - public void Predicate_Authorized_DoesNotThrow() + public async Task Predicate_Authorized_DoesNotThrow() { - Action act = () => AddressingConsistency.CheckResolverAuthorization( + Func act = () => AddressingConsistency.CheckResolverAuthorizationAsync( "did:example:alice", "did:example:alice#key-1", "authentication", - (_, _, _) => true); + (_, _, _, _) => Task.FromResult(true)); - act.Should().NotThrow(); + await act.Should().NotThrowAsync(); } [Fact] - public void Predicate_Unauthorized_ThrowsConsistency() + public async Task Predicate_Unauthorized_ThrowsConsistency() { - Action act = () => AddressingConsistency.CheckResolverAuthorization( + Func act = () => AddressingConsistency.CheckResolverAuthorizationAsync( "did:example:alice", "did:example:alice#stolen", "authentication", - (_, _, _) => false); + (_, _, _, _) => Task.FromResult(false)); - act.Should().Throw() + (await act.Should().ThrowAsync()) .Where(e => e.Message.Contains("FR-CONSIST-06")); } [Fact] - public void Predicate_Null_ShortCircuitsToAuthorized() + public async Task Predicate_Null_ShortCircuitsToAuthorized() { - Action act = () => AddressingConsistency.CheckResolverAuthorization( + Func act = () => AddressingConsistency.CheckResolverAuthorizationAsync( "did:example:alice", "did:example:alice#any", "authentication", null); - act.Should().NotThrow(); + await act.Should().NotThrowAsync(); } [Fact] - public void Predicate_PassesAssertedTriple_Exactly() + public async Task Predicate_PassesAssertedTriple_Exactly() { (string did, string kid, string rel)? observed = null; - AddressingConsistency.CheckResolverAuthorization( + await AddressingConsistency.CheckResolverAuthorizationAsync( "did:example:alice", "did:example:alice#key-2", "keyAgreement", - (d, k, r) => + (d, k, r, _) => { observed = (d, k, r); - return true; + return Task.FromResult(true); }); observed.Should().Be(("did:example:alice", "did:example:alice#key-2", "keyAgreement")); diff --git a/tests/DidComm.Core.Tests/Envelopes/Composition/EnvelopeReaderTests.cs b/tests/DidComm.Core.Tests/Envelopes/Composition/EnvelopeReaderTests.cs index 1536bfd..02531d1 100644 --- a/tests/DidComm.Core.Tests/Envelopes/Composition/EnvelopeReaderTests.cs +++ b/tests/DidComm.Core.Tests/Envelopes/Composition/EnvelopeReaderTests.cs @@ -4,6 +4,7 @@ using DidComm.Jose; using DidComm.Jose.Signing; using DidComm.Messages; +using DidComm.Secrets; using FluentAssertions; using NetCrypto; using Xunit; @@ -27,7 +28,7 @@ public void Plaintext_round_trips_through_writer_and_reader() .Build(); var packed = EnvelopeWriter.PackPlaintext(msg); - var unpacked = EnvelopeReader.Unpack(packed, + var unpacked = EnvelopeReaderTestRunner.Unpack(packed, new DictionarySecretsLookup(Array.Empty()), senderLookup: null, signerLookup: null, @@ -51,9 +52,9 @@ public async Task Signed_round_trips_with_metadata() .Build(); var packed = await EnvelopeWriter.PackSignedAsync( - new PackSignedParameters(msg, new[] { signer.PrivateJwk })); + new PackSignedParameters(msg, new[] { signer.PrivateJwk.ToJwsSigner() })); - var unpacked = EnvelopeReader.Unpack(packed, + var unpacked = EnvelopeReaderTestRunner.Unpack(packed, new DictionarySecretsLookup(Array.Empty()), senderLookup: null, signerLookup: kid => kid == signer.PublicJwk.Kid ? signer.PublicJwk : null, @@ -78,7 +79,7 @@ public async Task Anoncrypt_round_trips_with_metadata() new PackEncryptedParameters(msg, new[] { bob.PublicJwk }, "A256GCM"), _crypto); - var unpacked = EnvelopeReader.Unpack(packed, + var unpacked = EnvelopeReaderTestRunner.Unpack(packed, new DictionarySecretsLookup(new[] { bob.PrivateJwk }), senderLookup: null, signerLookup: null, @@ -108,11 +109,11 @@ public async Task Authcrypt_round_trips_with_metadata() var packed = await EnvelopeWriter.PackEncryptedAsync( new PackEncryptedParameters( msg, new[] { bob.PublicJwk }, "A256CBC-HS512", - SenderPrivateJwk: alice.PrivateJwk, + SenderKey: alice.PrivateJwk.ToEcdhKey(_crypto), Skid: alice.PublicJwk.Kid), _crypto); - var unpacked = EnvelopeReader.Unpack(packed, + var unpacked = EnvelopeReaderTestRunner.Unpack(packed, new DictionarySecretsLookup(new[] { bob.PrivateJwk }), senderLookup: new DictionarySenderKeyLookup(new[] { alice.PublicJwk }), signerLookup: null, @@ -141,10 +142,10 @@ public async Task Anoncrypt_then_sign_unwraps_to_inner_plaintext() var packed = await EnvelopeWriter.PackEncryptedAsync( new PackEncryptedParameters( msg, new[] { bob.PublicJwk }, "A256GCM", - SignerPrivateJwks: new[] { signer.PrivateJwk }), + Signers: new[] { signer.PrivateJwk.ToJwsSigner() }), _crypto); - var unpacked = EnvelopeReader.Unpack(packed, + var unpacked = EnvelopeReaderTestRunner.Unpack(packed, new DictionarySecretsLookup(new[] { bob.PrivateJwk }), senderLookup: null, signerLookup: _ => signer.PublicJwk, @@ -172,12 +173,12 @@ public async Task Anoncrypt_authcrypt_protect_sender_unwraps_recursively() var packed = await EnvelopeWriter.PackEncryptedAsync( new PackEncryptedParameters( msg, new[] { bob.PublicJwk }, "A256CBC-HS512", - SenderPrivateJwk: alice.PrivateJwk, + SenderKey: alice.PrivateJwk.ToEcdhKey(_crypto), Skid: alice.PublicJwk.Kid, ProtectSender: true), _crypto); - var unpacked = EnvelopeReader.Unpack(packed, + var unpacked = EnvelopeReaderTestRunner.Unpack(packed, new DictionarySecretsLookup(new[] { bob.PrivateJwk }), senderLookup: new DictionarySenderKeyLookup(new[] { alice.PublicJwk }), signerLookup: null, @@ -201,7 +202,7 @@ public async Task Consistency_check_blocks_recipient_kid_not_in_to_header() new PackEncryptedParameters(msg, new[] { bob.PublicJwk }, "A256GCM"), _crypto); - Action act = () => EnvelopeReader.Unpack(packed, + Action act = () => EnvelopeReaderTestRunner.Unpack(packed, new DictionarySecretsLookup(new[] { bob.PrivateJwk }), senderLookup: null, signerLookup: null, @@ -227,11 +228,11 @@ public async Task Consistency_check_blocks_authcrypt_skid_not_matching_plaintext var packed = await EnvelopeWriter.PackEncryptedAsync( new PackEncryptedParameters( msg, new[] { bob.PublicJwk }, "A256CBC-HS512", - SenderPrivateJwk: alice.PrivateJwk, + SenderKey: alice.PrivateJwk.ToEcdhKey(_crypto), Skid: alice.PublicJwk.Kid), _crypto); - Action act = () => EnvelopeReader.Unpack(packed, + Action act = () => EnvelopeReaderTestRunner.Unpack(packed, new DictionarySecretsLookup(new[] { bob.PrivateJwk }), senderLookup: new DictionarySenderKeyLookup(new[] { alice.PublicJwk }), signerLookup: null, @@ -258,12 +259,12 @@ public async Task Consistency_check_blocks_authcrypt_sign_inner_signer_not_match var packed = await EnvelopeWriter.PackEncryptedAsync( new PackEncryptedParameters( msg, new[] { bob.PublicJwk }, "A256CBC-HS512", - SenderPrivateJwk: alice.PrivateJwk, + SenderKey: alice.PrivateJwk.ToEcdhKey(_crypto), Skid: alice.PublicJwk.Kid, - SignerPrivateJwks: new[] { carol.PrivateJwk }), + Signers: new[] { carol.PrivateJwk.ToJwsSigner() }), _crypto); - Action act = () => EnvelopeReader.Unpack(packed, + Action act = () => EnvelopeReaderTestRunner.Unpack(packed, new DictionarySecretsLookup(new[] { bob.PrivateJwk }), senderLookup: new DictionarySenderKeyLookup(new[] { alice.PublicJwk }), signerLookup: kid => kid == carol.PublicJwk.Kid ? carol.PublicJwk : null, @@ -307,7 +308,7 @@ public async Task Anoncrypt_of_anoncrypt_is_rejected_as_illegal_composition() new PackEncryptedParameters(EmptyMessage(), new[] { bob.PublicJwk }, "A256GCM"), _crypto); var outer = AnoncryptWrap(inner, bob.PublicJwk); - Action act = () => EnvelopeReader.Unpack(outer, + Action act = () => EnvelopeReaderTestRunner.Unpack(outer, new DictionarySecretsLookup(new[] { bob.PrivateJwk }), senderLookup: null, signerLookup: null, _crypto); act.Should().Throw().WithMessage("*Illegal*composition*"); @@ -321,10 +322,10 @@ public async Task Authcrypt_of_authcrypt_is_rejected_as_illegal_composition() var bob = TestKeyMaterial.Generate(KeyType.X25519, "did:example:bob#x"); var inner = await EnvelopeWriter.PackEncryptedAsync( new PackEncryptedParameters(EmptyMessage(), new[] { bob.PublicJwk }, "A256CBC-HS512", - SenderPrivateJwk: alice.PrivateJwk, Skid: alice.PublicJwk.Kid), _crypto); + SenderKey: alice.PrivateJwk.ToEcdhKey(_crypto), Skid: alice.PublicJwk.Kid), _crypto); var outer = AuthcryptWrap(inner, bob.PublicJwk, alice.PrivateJwk, alice.PublicJwk.Kid!); - Action act = () => EnvelopeReader.Unpack(outer, + Action act = () => EnvelopeReaderTestRunner.Unpack(outer, new DictionarySecretsLookup(new[] { bob.PrivateJwk }), senderLookup: new DictionarySenderKeyLookup(new[] { alice.PublicJwk }), signerLookup: null, _crypto); @@ -343,7 +344,7 @@ public async Task Sign_outside_encrypt_is_rejected_as_illegal_composition() new PackEncryptedParameters(EmptyMessage(), new[] { bob.PublicJwk }, "A256GCM"), _crypto); var outer = await SignWrapAsync(inner, signer.PrivateJwk); - Action act = () => EnvelopeReader.Unpack(outer, + Action act = () => EnvelopeReaderTestRunner.Unpack(outer, new DictionarySecretsLookup(new[] { bob.PrivateJwk }), senderLookup: null, signerLookup: _ => signer.PublicJwk, _crypto); @@ -357,10 +358,10 @@ public async Task Signed_of_signed_is_rejected_as_illegal_composition() // [Sign, Sign, Plaintext] — double signature is not a legal shape. var s1 = TestKeyMaterial.Generate(KeyType.Ed25519, "did:example:alice#k1"); var s2 = TestKeyMaterial.Generate(KeyType.Ed25519, "did:example:alice#k2"); - var inner = await EnvelopeWriter.PackSignedAsync(new PackSignedParameters(EmptyMessage(), new[] { s1.PrivateJwk })); + var inner = await EnvelopeWriter.PackSignedAsync(new PackSignedParameters(EmptyMessage(), new[] { s1.PrivateJwk.ToJwsSigner() })); var outer = await SignWrapAsync(inner, s2.PrivateJwk); - Action act = () => EnvelopeReader.Unpack(outer, + Action act = () => EnvelopeReaderTestRunner.Unpack(outer, new DictionarySecretsLookup(Array.Empty()), senderLookup: null, signerLookup: kid => kid == s1.PublicJwk.Kid ? s1.PublicJwk : kid == s2.PublicJwk.Kid ? s2.PublicJwk : null, @@ -381,11 +382,11 @@ public async Task Authcrypt_of_sign_is_accepted_on_receive_FrEnv03() var packed = await EnvelopeWriter.PackEncryptedAsync( new PackEncryptedParameters(EmptyMessage(), new[] { bob.PublicJwk }, "A256CBC-HS512", - SenderPrivateJwk: aliceKa.PrivateJwk, + SenderKey: aliceKa.PrivateJwk.ToEcdhKey(_crypto), Skid: aliceKa.PublicJwk.Kid, - SignerPrivateJwks: new[] { aliceSign.PrivateJwk }), _crypto); + Signers: new[] { aliceSign.PrivateJwk.ToJwsSigner() }), _crypto); - var unpacked = EnvelopeReader.Unpack(packed, + var unpacked = EnvelopeReaderTestRunner.Unpack(packed, new DictionarySecretsLookup(new[] { bob.PrivateJwk }), senderLookup: new DictionarySenderKeyLookup(new[] { aliceKa.PublicJwk }), signerLookup: kid => kid == aliceSign.PublicJwk.Kid ? aliceSign.PublicJwk : null, @@ -408,12 +409,12 @@ public async Task Anoncrypt_authcrypt_sign_is_accepted_on_receive() var packed = await EnvelopeWriter.PackEncryptedAsync( new PackEncryptedParameters(EmptyMessage(), new[] { bob.PublicJwk }, "A256CBC-HS512", - SenderPrivateJwk: aliceKa.PrivateJwk, + SenderKey: aliceKa.PrivateJwk.ToEcdhKey(_crypto), Skid: aliceKa.PublicJwk.Kid, - SignerPrivateJwks: new[] { aliceSign.PrivateJwk }, + Signers: new[] { aliceSign.PrivateJwk.ToJwsSigner() }, ProtectSender: true), _crypto); - var unpacked = EnvelopeReader.Unpack(packed, + var unpacked = EnvelopeReaderTestRunner.Unpack(packed, new DictionarySecretsLookup(new[] { bob.PrivateJwk }), senderLookup: new DictionarySenderKeyLookup(new[] { aliceKa.PublicJwk }), signerLookup: kid => kid == aliceSign.PublicJwk.Kid ? aliceSign.PublicJwk : null, @@ -426,13 +427,16 @@ public async Task Anoncrypt_authcrypt_sign_is_accepted_on_receive() } [Fact] - public async Task Wrong_length_iv_throws_MalformedMessageException_honoring_the_unpack_contract() + public async Task Wrong_length_iv_is_a_uniform_decrypt_failure_under_constant_work() { - // 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. + // Constant-work decrypt (dataproofs #12, consumed via the async ParseAsync path): a non-canonical + // iv length is folded into the SAME uniform "JWE could not be decrypted." failure + // (JoseCryptoException -> CryptoException) as a wrong-key / tampered-ciphertext failure, so a + // malformed iv cannot be distinguished from a decrypt failure by exception type or message — + // closing that oracle. (Pre-1.1.0 the synchronous parser surfaced the iv length as an + // ArgumentException -> MalformedMessageException; the constant-work path deliberately does not.) + // The ArgumentException -> MalformedMessageException boundary guard (#22, FR-API-07) remains + // covered by the signed-path test below. var bob = TestKeyMaterial.Generate(KeyType.X25519, "did:example:bob#x"); var packed = await EnvelopeWriter.PackEncryptedAsync( new PackEncryptedParameters(EmptyMessage(), new[] { bob.PublicJwk }, "A256GCM"), _crypto); @@ -441,10 +445,10 @@ public async Task Wrong_length_iv_throws_MalformedMessageException_honoring_the_ jwe["iv"] = DidComm.Jose.Base64Url.Encode(new byte[5]); // valid base64url, wrong length var tampered = jwe.ToJsonString(); - Action act = () => EnvelopeReader.Unpack(tampered, + Action act = () => EnvelopeReaderTestRunner.Unpack(tampered, new DictionarySecretsLookup(new[] { bob.PrivateJwk }), senderLookup: null, signerLookup: null, _crypto); - act.Should().Throw(); + act.Should().Throw(); } [Fact] @@ -455,9 +459,9 @@ public async Task ArgumentException_from_the_delegated_parse_maps_to_MalformedMe // 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 })); + var packed = await EnvelopeWriter.PackSignedAsync(new PackSignedParameters(EmptyMessage(), new[] { signer.PrivateJwk.ToJwsSigner() })); - Action act = () => EnvelopeReader.Unpack(packed, + Action act = () => EnvelopeReaderTestRunner.Unpack(packed, new DictionarySecretsLookup(Array.Empty()), senderLookup: null, signerLookup: _ => throw new ArgumentException("boom from a buggy lookup"), @@ -479,9 +483,9 @@ public async Task AnonymousSender_reflects_the_outermost_encrypt_layer_not_accum var packed = await EnvelopeWriter.PackEncryptedAsync( new PackEncryptedParameters(EmptyMessage(), new[] { bob.PublicJwk }, "A256CBC-HS512", - SenderPrivateJwk: alice.PrivateJwk, Skid: alice.PublicJwk.Kid, ProtectSender: true), _crypto); + SenderKey: alice.PrivateJwk.ToEcdhKey(_crypto), Skid: alice.PublicJwk.Kid, ProtectSender: true), _crypto); - var unpacked = EnvelopeReader.Unpack(packed, + var unpacked = EnvelopeReaderTestRunner.Unpack(packed, new DictionarySecretsLookup(new[] { bob.PrivateJwk }), senderLookup: new DictionarySenderKeyLookup(new[] { alice.PublicJwk }), signerLookup: null, _crypto); @@ -490,4 +494,53 @@ public async Task AnonymousSender_reflects_the_outermost_encrypt_layer_not_accum unpacked.Authenticated.Should().BeTrue(); // inner authcrypt bound the sender unpacked.SenderKid.Should().Be(alice.PublicJwk.Kid); } + + [Theory] + [InlineData(typeof(KeyNotFoundException))] + [InlineData(typeof(System.Security.Cryptography.CryptographicException))] + public async Task Opaque_recipient_handle_fault_maps_to_uniform_CryptoException(Type faultType) + { + // Adversarial regression (issue #45 review, Finding 1): an opaque (keystore/HSM) recipient handle + // can fault in ways the in-process decoy path never can — a KeyNotFoundException when the kid is + // rotated out between selection and derive, or an enclave CryptographicException. UnpackAsync MUST + // fold these into the SAME uniform CryptoException as any decrypt failure, so no raw exception + // escapes the FR-API-07 contract and the held path leaks no possession oracle by exception type. + var bob = TestKeyMaterial.Generate(KeyType.X25519, "did:example:bob#x"); + var packed = await EnvelopeWriter.PackEncryptedAsync( + new PackEncryptedParameters(EmptyMessage(), new[] { bob.PublicJwk }, "A256GCM"), _crypto); + + var fault = (Exception)Activator.CreateInstance(faultType)!; + var faulting = new FaultingOpaqueResolver(bob.PublicJwk, fault); + + Action act = () => EnvelopeReaderTestRunner.Unpack(packed, faulting, senderLookup: null, signerLookup: null, _crypto); + + act.Should().Throw(); + } + + // Reports the recipient kid as held and returns a key-agreement handle whose derive throws — modelling + // a keystore key that vanishes (or an enclave that errors) between selection and the ECDH operation. + private sealed class FaultingOpaqueResolver : ISecretsResolver, IOpaqueKeyResolver + { + private readonly Jwk _publicJwk; + private readonly Exception _fault; + public FaultingOpaqueResolver(Jwk publicJwk, Exception fault) { _publicJwk = publicJwk; _fault = fault; } + + public Task FindAsync(string kid, CancellationToken ct = default) + => Task.FromResult(kid == _publicJwk.Kid ? _publicJwk : null); + public Task> FindPresentAsync(IEnumerable kids, CancellationToken ct = default) + => Task.FromResult>(kids.Where(k => k == _publicJwk.Kid).ToArray()); + public Task ResolveSignerAsync(string kid, CancellationToken ct = default) + => Task.FromResult(null); + public Task ResolveKeyAgreementAsync(string kid, CancellationToken ct = default) + => Task.FromResult(new ThrowingEcdhKey(_publicJwk.Crv!, _fault)); + } + + private sealed class ThrowingEcdhKey : DpEnc.IEcdhKey + { + private readonly Exception _fault; + public ThrowingEcdhKey(string crv, Exception fault) { Crv = crv; _fault = fault; } + public string Crv { get; } + public ValueTask DeriveAsync(ReadOnlyMemory peerPublicKey, CancellationToken ct = default) + => throw _fault; + } } diff --git a/tests/DidComm.Core.Tests/Envelopes/Encryption/AnoncryptRoundTripTests.cs b/tests/DidComm.Core.Tests/Envelopes/Encryption/AnoncryptRoundTripTests.cs index 4491a34..5802dce 100644 --- a/tests/DidComm.Core.Tests/Envelopes/Encryption/AnoncryptRoundTripTests.cs +++ b/tests/DidComm.Core.Tests/Envelopes/Encryption/AnoncryptRoundTripTests.cs @@ -13,7 +13,7 @@ namespace DidComm.Tests.Envelopes.Encryption; /// Envelope-level anoncrypt round trips. The JWE build/parse primitives (mixed-curve rejection, apv /// recompute, wire-format tamper detection) now live in DataProofsDotnet.Jose and are covered there; /// these tests exercise the DIDComm anoncrypt envelope through -/// + . +/// + . /// public sealed class AnoncryptRoundTripTests { @@ -45,7 +45,7 @@ public async Task Pack_then_unpack_round_trips_across_all_supported_combinations _crypto); var secrets = new DictionarySecretsLookup(new[] { bob.PrivateJwk }); - var result = EnvelopeReader.Unpack(packed, secrets, senderLookup: null, signerLookup: null, _crypto); + var result = EnvelopeReaderTestRunner.Unpack(packed, secrets, senderLookup: null, signerLookup: null, _crypto); result.Authenticated.Should().BeFalse(); result.AnonymousSender.Should().BeTrue(); @@ -71,7 +71,7 @@ public async Task Multi_recipient_same_curve_yields_one_jwe_with_multiple_wraps( // Each recipient can independently decrypt. foreach (var owner in new[] { bob1, bob2, bob3 }) { - var result = EnvelopeReader.Unpack( + var result = EnvelopeReaderTestRunner.Unpack( packed, new DictionarySecretsLookup(new[] { owner.PrivateJwk }), senderLookup: null, @@ -110,7 +110,7 @@ public async Task No_matching_recipient_kid_throws() var unrelated = TestKeyMaterial.Generate(KeyType.X25519, "did:example:eve#x"); var secrets = new DictionarySecretsLookup(new[] { unrelated.PrivateJwk }); - Action act = () => EnvelopeReader.Unpack(packed, secrets, senderLookup: null, signerLookup: null, _crypto); + Action act = () => EnvelopeReaderTestRunner.Unpack(packed, secrets, senderLookup: null, signerLookup: null, _crypto); act.Should().Throw(); } } diff --git a/tests/DidComm.Core.Tests/Envelopes/Encryption/AuthcryptRoundTripTests.cs b/tests/DidComm.Core.Tests/Envelopes/Encryption/AuthcryptRoundTripTests.cs index b103366..0080fb8 100644 --- a/tests/DidComm.Core.Tests/Envelopes/Encryption/AuthcryptRoundTripTests.cs +++ b/tests/DidComm.Core.Tests/Envelopes/Encryption/AuthcryptRoundTripTests.cs @@ -13,7 +13,7 @@ namespace DidComm.Tests.Envelopes.Encryption; /// Envelope-level authcrypt round trips. The JWE build/parse primitives (the FR-ENC-09 CBC-HMAC pin, /// apu↔skid binding, crit rejection, wire-format validation) now live in DataProofsDotnet.Jose and /// are covered there; these tests exercise the DIDComm authcrypt envelope through -/// + , including +/// + , including /// the higher-level behavioral guarantees the envelope layer is responsible for. /// public sealed class AuthcryptRoundTripTests @@ -41,14 +41,14 @@ public async Task Pack_then_unpack_round_trips_on_every_curve(KeyType keyType) var packed = await EnvelopeWriter.PackEncryptedAsync( new PackEncryptedParameters( msg, new[] { bob.PublicJwk }, "A256CBC-HS512", - SenderPrivateJwk: alice.PrivateJwk, + SenderKey: alice.PrivateJwk.ToEcdhKey(_crypto), Skid: alice.PublicJwk.Kid), _crypto); var secrets = new DictionarySecretsLookup(new[] { bob.PrivateJwk }); var senderLookup = new DictionarySenderKeyLookup(new[] { alice.PublicJwk }); - var result = EnvelopeReader.Unpack(packed, secrets, senderLookup, signerLookup: null, _crypto); + var result = EnvelopeReaderTestRunner.Unpack(packed, secrets, senderLookup, signerLookup: null, _crypto); result.Authenticated.Should().BeTrue(); result.KeyWrap.Should().Be("ECDH-1PU+A256KW"); @@ -69,7 +69,7 @@ public async Task Authcrypt_refuses_a256gcm_per_fr_enc_09() Func act = () => EnvelopeWriter.PackEncryptedAsync( new PackEncryptedParameters( msg, new[] { bob.PublicJwk }, "A256GCM", - SenderPrivateJwk: alice.PrivateJwk, + SenderKey: alice.PrivateJwk.ToEcdhKey(_crypto), Skid: alice.PublicJwk.Kid), _crypto); @@ -86,7 +86,7 @@ public async Task Authcrypt_refuses_mismatched_sender_and_recipient_curves() Func act = () => EnvelopeWriter.PackEncryptedAsync( new PackEncryptedParameters( msg, new[] { bobP.PublicJwk }, "A256CBC-HS512", - SenderPrivateJwk: aliceX.PrivateJwk, + SenderKey: aliceX.PrivateJwk.ToEcdhKey(_crypto), Skid: aliceX.PublicJwk.Kid), _crypto); @@ -103,14 +103,14 @@ public async Task Unpack_without_sender_lookup_throws_for_authcrypt() var packed = await EnvelopeWriter.PackEncryptedAsync( new PackEncryptedParameters( msg, new[] { bob.PublicJwk }, "A256CBC-HS512", - SenderPrivateJwk: alice.PrivateJwk, + SenderKey: alice.PrivateJwk.ToEcdhKey(_crypto), Skid: alice.PublicJwk.Kid), _crypto); var secrets = new DictionarySecretsLookup(new[] { bob.PrivateJwk }); // Authcrypt requires the sender public key to derive Zs; with no sender lookup unpack fails. - Action act = () => EnvelopeReader.Unpack(packed, secrets, senderLookup: null, signerLookup: null, _crypto); + Action act = () => EnvelopeReaderTestRunner.Unpack(packed, secrets, senderLookup: null, signerLookup: null, _crypto); act.Should().Throw(); } @@ -125,7 +125,7 @@ public async Task Tampered_tag_breaks_authcrypt_unwrap_via_kek_mismatch() var packed = await EnvelopeWriter.PackEncryptedAsync( new PackEncryptedParameters( msg, new[] { bob.PublicJwk }, "A256CBC-HS512", - SenderPrivateJwk: alice.PrivateJwk, + SenderKey: alice.PrivateJwk.ToEcdhKey(_crypto), Skid: alice.PublicJwk.Kid), _crypto); @@ -137,7 +137,7 @@ public async Task Tampered_tag_breaks_authcrypt_unwrap_via_kek_mismatch() var secrets = new DictionarySecretsLookup(new[] { bob.PrivateJwk }); var senderLookup = new DictionarySenderKeyLookup(new[] { alice.PublicJwk }); - Action act = () => EnvelopeReader.Unpack(tampered, secrets, senderLookup, signerLookup: null, _crypto); + Action act = () => EnvelopeReaderTestRunner.Unpack(tampered, secrets, senderLookup, signerLookup: null, _crypto); act.Should().Throw(); } } diff --git a/tests/DidComm.Core.Tests/Envelopes/Signing/JwsRoundTripTests.cs b/tests/DidComm.Core.Tests/Envelopes/Signing/JwsRoundTripTests.cs index e8f72d6..03aea96 100644 --- a/tests/DidComm.Core.Tests/Envelopes/Signing/JwsRoundTripTests.cs +++ b/tests/DidComm.Core.Tests/Envelopes/Signing/JwsRoundTripTests.cs @@ -12,7 +12,7 @@ namespace DidComm.Tests.Envelopes.Signing; /// /// Envelope-level signed round trips. The JWS build/parse primitives now live in /// DataProofsDotnet.Jose; these tests exercise the DIDComm signed envelope through -/// + so the +/// + so the /// from↔signer binding (FR-CONSIST-03), FR-SIG-06 inner-'to', and the flattened/general JWS shapes /// are covered at the composition seam. /// @@ -21,7 +21,7 @@ public sealed class JwsRoundTripTests private static readonly JoseCryptoProvider _crypto = new(); private static UnpackResult Unpack(string packed, Func signerLookup) => - EnvelopeReader.Unpack( + EnvelopeReaderTestRunner.Unpack( packed, new DictionarySecretsLookup(Array.Empty()), senderLookup: null, @@ -42,7 +42,7 @@ public async Task Sign_then_verify_round_trips_for_each_alg(KeyType keyType, str .Build(); var packed = await EnvelopeWriter.PackSignedAsync( - new PackSignedParameters(msg, new[] { signer.PrivateJwk })); + new PackSignedParameters(msg, new[] { signer.PrivateJwk.ToJwsSigner() })); var result = Unpack(packed, kid => kid == signer.PublicJwk.Kid ? signer.PublicJwk : null); @@ -62,7 +62,7 @@ public async Task Tampered_payload_fails_verification() .Build(); var packed = await EnvelopeWriter.PackSignedAsync( - new PackSignedParameters(msg, new[] { signer.PrivateJwk })); + new PackSignedParameters(msg, new[] { signer.PrivateJwk.ToJwsSigner() })); // Flip a byte in the base64url payload to invalidate the signature. var tampered = packed.Replace("\"payload\":\"e", "\"payload\":\"f", StringComparison.Ordinal); @@ -82,7 +82,7 @@ public async Task Verifier_with_no_matching_kid_throws_crypto() .Build(); var packed = await EnvelopeWriter.PackSignedAsync( - new PackSignedParameters(msg, new[] { signer.PrivateJwk })); + new PackSignedParameters(msg, new[] { signer.PrivateJwk.ToJwsSigner() })); Action act = () => Unpack(packed, _ => null); act.Should().Throw(); @@ -98,7 +98,7 @@ public async Task Single_signer_emits_flattened_form() .Build(); var packed = await EnvelopeWriter.PackSignedAsync( - new PackSignedParameters(msg, new[] { signer.PrivateJwk })); + new PackSignedParameters(msg, new[] { signer.PrivateJwk.ToJwsSigner() })); // Flattened JSON: top-level "signature" + "protected", no "signatures" array. packed.Should().Contain("\"signature\":"); @@ -116,7 +116,7 @@ public async Task Multiple_signers_emit_general_form_and_either_verifies() .Build(); var packed = await EnvelopeWriter.PackSignedAsync( - new PackSignedParameters(msg, new[] { signerA.PrivateJwk, signerB.PrivateJwk })); + new PackSignedParameters(msg, new[] { signerA.PrivateJwk.ToJwsSigner(), signerB.PrivateJwk.ToJwsSigner() })); packed.Should().Contain("\"signatures\":"); @@ -146,7 +146,7 @@ public async Task Sign_then_encrypt_inner_to_required_throws_when_missing() Func act = () => EnvelopeWriter.PackEncryptedAsync( new PackEncryptedParameters( msg, new[] { bob.PublicJwk }, "A256GCM", - SignerPrivateJwks: new[] { signer.PrivateJwk }), + Signers: new[] { signer.PrivateJwk.ToJwsSigner() }), _crypto); await act.Should().ThrowAsync().WithMessage("*FR-SIG-06*"); @@ -166,7 +166,7 @@ public async Task Sign_then_encrypt_inner_to_passes_when_present() var packed = await EnvelopeWriter.PackEncryptedAsync( new PackEncryptedParameters( msg, new[] { bob.PublicJwk }, "A256GCM", - SignerPrivateJwks: new[] { signer.PrivateJwk }), + Signers: new[] { signer.PrivateJwk.ToJwsSigner() }), _crypto); packed.Should().NotBeNullOrEmpty(); } @@ -181,7 +181,7 @@ public async Task Consistency_check_blocks_mismatched_signer_kid() .Build(); var packed = await EnvelopeWriter.PackSignedAsync( - new PackSignedParameters(msg, new[] { signer.PrivateJwk })); + new PackSignedParameters(msg, new[] { signer.PrivateJwk.ToJwsSigner() })); Action act = () => Unpack(packed, _ => signer.PublicJwk); act.Should().Throw().WithMessage("*FR-CONSIST-03*"); diff --git a/tests/DidComm.Core.Tests/Envelopes/TestKeyMaterial.cs b/tests/DidComm.Core.Tests/Envelopes/TestKeyMaterial.cs index 3de4a8b..f600905 100644 --- a/tests/DidComm.Core.Tests/Envelopes/TestKeyMaterial.cs +++ b/tests/DidComm.Core.Tests/Envelopes/TestKeyMaterial.cs @@ -32,7 +32,7 @@ public static TestKeyMaterial Generate(KeyType keyType, string kid) } } -internal sealed class DictionarySecretsLookup : IInternalSecretsLookup +internal sealed class DictionarySecretsLookup : ISecretsResolver { private readonly Dictionary _byKid; @@ -41,10 +41,11 @@ public DictionarySecretsLookup(IEnumerable privateJwks) _byKid = privateJwks.ToDictionary(j => j.Kid!, StringComparer.Ordinal); } - public Jwk? TryGet(string kid) => _byKid.GetValueOrDefault(kid); + public Task FindAsync(string kid, CancellationToken ct = default) + => Task.FromResult(_byKid.GetValueOrDefault(kid)); - public IReadOnlyList FindPresent(IEnumerable kids) - => kids.Where(k => _byKid.ContainsKey(k)).ToArray(); + public Task> FindPresentAsync(IEnumerable kids, CancellationToken ct = default) + => Task.FromResult>(kids.Where(k => _byKid.ContainsKey(k)).ToArray()); } internal sealed class DictionarySenderKeyLookup : IInternalSenderKeyLookup diff --git a/tests/DidComm.Core.Tests/Secrets/NetDidKeyStoreSecretsResolverTests.cs b/tests/DidComm.Core.Tests/Secrets/NetDidKeyStoreSecretsResolverTests.cs index 50d9045..de74986 100644 --- a/tests/DidComm.Core.Tests/Secrets/NetDidKeyStoreSecretsResolverTests.cs +++ b/tests/DidComm.Core.Tests/Secrets/NetDidKeyStoreSecretsResolverTests.cs @@ -1,7 +1,12 @@ using DidComm.Adapters.NetDid; +using DidComm.Protocols.Rotation; +using DataProofsDotnet.Jose.Encryption; using FluentAssertions; using NetCrypto; using Xunit; +using JoseCryptoProvider = DataProofsDotnet.Jose.JoseCryptoProvider; +using JwkConversion = DataProofsDotnet.Jose.JwkConversion; +using JwsSigner = DataProofsDotnet.Jose.Signing.JwsSigner; namespace DidComm.Tests.Secrets; @@ -47,4 +52,68 @@ public async Task FindPresentAsync_ReturnsHeldSubset() present.Should().BeEquivalentTo(new[] { "a", "b" }); } + + [Fact] + public async Task ResolveSignerAsync_ReturnsNullForUnknownKid() + { + IKeyStore store = new InMemoryKeyStore(new DefaultKeyGenerator(), new DefaultCryptoProvider()); + var resolver = new NetDidKeyStoreSecretsResolver(store); + + (await resolver.ResolveSignerAsync("nope")).Should().BeNull(); + (await resolver.ResolveKeyAgreementAsync("nope")).Should().BeNull(); + } + + [Fact] + public async Task ResolveKeyAgreementAsync_derives_the_same_Z_as_the_in_process_path() + { + // KAT-style equivalence (FR-SEC-06): the opaque keystore ECDH and the extractable RawEcdhKey + // over the SAME private key must produce a byte-identical shared secret — so an envelope packed + // for either path decrypts on the other. (We hold an in-process copy of the scalar ONLY to build + // the comparison baseline; the keystore handle never exposes it.) + var keyGen = new DefaultKeyGenerator(); + var crypto = new DefaultCryptoProvider(); + var local = keyGen.Generate(KeyType.X25519); + var peer = keyGen.Generate(KeyType.X25519); + + var store = new InMemoryKeyStore(keyGen, crypto); + await store.ImportAsync("did:example:alice#x", local); + var resolver = new NetDidKeyStoreSecretsResolver(store); + + var opaque = await resolver.ResolveKeyAgreementAsync("did:example:alice#x"); + opaque.Should().NotBeNull(); + opaque!.Crv.Should().Be("X25519"); + + var inProcess = new RawEcdhKey("X25519", local.PrivateKey, new JoseCryptoProvider()); + + var zOpaque = await opaque.DeriveAsync(peer.PublicKey); + var zInProcess = await inProcess.DeriveAsync(peer.PublicKey); + + zOpaque.Should().Equal(zInProcess); + } + + [Fact] + public async Task FromPrior_opaque_eddsa_signer_matches_the_extractable_jwt() + { + // EdDSA is deterministic, so signing the same claims with the same Ed25519 key opaquely + // (keystore ISigner) and extractably (private JWK) MUST yield byte-identical from_prior JWTs — + // proving the opaque signing overload is a drop-in, and that the rotation JWT can be minted + // without the prior DID's signing scalar leaving custody (FR-SEC-06 / issue #45 table). + var keyGen = new DefaultKeyGenerator(); + var pair = keyGen.Generate(KeyType.Ed25519); + const string kid = "did:example:alice#auth"; + var claims = new FromPriorClaims(Sub: "did:example:alice", Iss: "did:example:alice-prior", Iat: 1_700_000_000); + + var privJwk = JwkConversion.ToPrivateJwk(pair, kid); + var extractableJwt = await FromPriorBuilder.BuildAsync(claims, privJwk); + + var store = new InMemoryKeyStore(keyGen, new DefaultCryptoProvider()); + await store.ImportAsync(kid, pair); + var resolver = new NetDidKeyStoreSecretsResolver(store); + var signer = await resolver.ResolveSignerAsync(kid); + signer.Should().NotBeNull(); + + var opaqueJwt = await FromPriorBuilder.BuildAsync(claims, new JwsSigner(signer!, kid)); + + opaqueJwt.Should().Be(extractableJwt); + } } diff --git a/tests/DidComm.InteropTests/DidComm.InteropTests.csproj b/tests/DidComm.InteropTests/DidComm.InteropTests.csproj index dc4e3fa..05a1aaa 100644 --- a/tests/DidComm.InteropTests/DidComm.InteropTests.csproj +++ b/tests/DidComm.InteropTests/DidComm.InteropTests.csproj @@ -29,7 +29,9 @@ + + + + + + diff --git a/tests/DidComm.TestSupport/EnvelopeReaderTestRunner.cs b/tests/DidComm.TestSupport/EnvelopeReaderTestRunner.cs new file mode 100644 index 0000000..096732e --- /dev/null +++ b/tests/DidComm.TestSupport/EnvelopeReaderTestRunner.cs @@ -0,0 +1,31 @@ +using DidComm.Secrets; +using JoseCryptoProvider = DataProofsDotnet.Jose.JoseCryptoProvider; + +namespace DidComm.Composition; + +/// +/// Test-only synchronous bridge to the async . The +/// envelope-layer unit / interop tests drive the reader directly with a dictionary-backed +/// ; this wraps it in a (extractable +/// path — opaque handles are exercised end-to-end through the facade) and blocks on the async unpack +/// so those tests stay synchronous. Production never blocks: the facade awaits +/// directly. +/// +internal static class EnvelopeReaderTestRunner +{ + public static UnpackResult Unpack( + string packed, + ISecretsResolver secrets, + IInternalSenderKeyLookup? senderLookup, + Func? signerLookup, + JoseCryptoProvider crypto, + Func? resolverCheck = null) + { + var keyOps = new KeyOperationResolver(secrets, secrets as IOpaqueKeyResolver, crypto); + Func>? asyncCheck = + resolverCheck is null ? null : (a, b, c, _) => Task.FromResult(resolverCheck(a, b, c)); + return EnvelopeReader + .UnpackAsync(packed, keyOps, senderLookup, signerLookup, crypto, asyncCheck) + .GetAwaiter().GetResult(); + } +} diff --git a/tests/DidComm.TestSupport/TestHandles.cs b/tests/DidComm.TestSupport/TestHandles.cs new file mode 100644 index 0000000..775b8dd --- /dev/null +++ b/tests/DidComm.TestSupport/TestHandles.cs @@ -0,0 +1,25 @@ +using DidComm.Jose.Signing; +using DataProofsDotnet.Jose.Encryption; +using DpBase64Url = DataProofsDotnet.Jose.Base64Url; +using JwsSigner = DataProofsDotnet.Jose.Signing.JwsSigner; +using JoseCryptoProvider = DataProofsDotnet.Jose.JoseCryptoProvider; + +namespace DidComm.Composition; + +/// +/// Builds DataProofs operation handles from extractable private JWKs for the envelope-layer +/// tests that drive EnvelopeWriter / its parameter records directly. In production the facade +/// resolves these handles from the registered resolver — opaque (keystore) or extractable — and never +/// needs the raw scalar (FR-SEC-06); these helpers exist only so the low-level composition tests keep +/// using in-memory test keys. +/// +public static class TestHandles +{ + /// An in-process over the private scalar (the extractable path). + public static IEcdhKey ToEcdhKey(this Jwk privateJwk, JoseCryptoProvider crypto) + => new RawEcdhKey(privateJwk.Crv!, DpBase64Url.Decode(privateJwk.D!), crypto); + + /// A built from the private signer JWK (the extractable path). + public static JwsSigner ToJwsSigner(this Jwk privateJwk) + => JwsSignerFactory.FromPrivateJwk(privateJwk); +} From d4a33a72510ca924fd52f2a1fe18f9ba2856e482 Mon Sep 17 00:00:00 2001 From: Moises E Jaramillo Date: Mon, 22 Jun 2026 14:57:25 -0400 Subject: [PATCH 2/2] =?UTF-8?q?docs(secrets):=20address=20PR=20#46=20revie?= =?UTF-8?q?w=20=E2=80=94=20EC=20decoy-path=20test,=20opaque=20timing=20cav?= =?UTF-8?q?eat,=20version-lineage=20note?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- CHANGELOG.md | 4 +++ docs/didcomm-dotnet_PRD.md | 2 +- .../Secrets/IOpaqueKeyResolver.cs | 11 ++++++++ .../Composition/EnvelopeReaderTests.cs | 26 +++++++++++++++++++ 4 files changed, 42 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8042903..b54610f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ All notable changes to didcomm-dotnet are documented here. Format follows ## [1.1.0] - 2026-06-22 +> Follows the published **1.0.0** line — a normal minor bump (new opt-in feature, no breaking public API). +> The `DidCommVersion` build property had drifted to `0.1.0` in source after the 1.0.0 release; it is +> corrected to `1.1.0` here. (The 1.0.0 notes predate this changelog's per-version sectioning.) + ### Added — Opaque (non-extractable) signing & key agreement (`feat/opaque-keystore-crypto-45`) Closes **#45** (depends on, and ships after, `moisesja/dataproofs-dotnet#13`). DIDComm can now sign and diff --git a/docs/didcomm-dotnet_PRD.md b/docs/didcomm-dotnet_PRD.md index ff4a27c..8a6c18a 100644 --- a/docs/didcomm-dotnet_PRD.md +++ b/docs/didcomm-dotnet_PRD.md @@ -360,7 +360,7 @@ public sealed class DidComm { | FR-SEC-03 | MUST | Key lookup is two-phase: resolve DID via net-did → discover kids; then `ISecretsResolver` supplies the private key for sign/decrypt/1PU-sender. | Decryption requests only the kids present in `recipients`. | | FR-SEC-04 | SHOULD | Provide an optional adapter (separate package or `Core` extension) that backs `ISecretsResolver` with a `NetDid.IKeyStore`, so an application that already mints DIDs with net-did can surface those private keys to didcomm-dotnet without a second key store. The adapter must not weaken DD-02 (still no production store shipped). | A `NetDid.IKeyStore` populated via NetDid create-ops resolves the matching kid for decryption. | | FR-SEC-05 | SHOULD | Ship a test-only in-memory `ISecretsResolver` in the **test** assembly seeded from Appendix A. | Vector tests use it. | -| FR-SEC-06 | SHOULD | Support **non-extractable (opaque) custody** — HSM / cloud KMS / OS keychain / MPC / `NetCrypto.IKeyStore` — where the private scalar never leaves the secure boundary. Define an optional `IOpaqueKeyResolver` capability (`ResolveSignerAsync` → `ISigner`, `ResolveKeyAgreementAsync` → `IEcdhKey`) an `ISecretsResolver` MAY also implement; when present the facade routes the **only two** private-key operations — a raw JWS signature and an ECDH shared-secret derivation — through those handles instead of decoding a private `Jwk`. Everything downstream of the signature/`Z` (Concat-KDF, A256KW key wrap, AEAD, header assembly, signature normalization) is public-data math and is unchanged. The extractable `ISecretsResolver` path stays the default and is byte-for-byte unchanged; the two coexist and may be mixed per kid. `NetDidKeyStoreSecretsResolver` becomes a sufficient sole resolver for an HSM-backed agent. | A wallet whose keys live only in a non-extractable `IKeyStore` can authcrypt / anoncrypt / sign on send and unpack on receive with no private key bytes leaving the store (interop round-trips); the keystore resolver returns public-only JWKs (`d` absent). | +| FR-SEC-06 | SHOULD | Support **non-extractable (opaque) custody** — HSM / cloud KMS / OS keychain / MPC / `NetCrypto.IKeyStore` — where the private scalar never leaves the secure boundary. Define an optional `IOpaqueKeyResolver` capability (`ResolveSignerAsync` → `ISigner`, `ResolveKeyAgreementAsync` → `IEcdhKey`) an `ISecretsResolver` MAY also implement; when present the facade routes the **only two** private-key operations — a raw JWS signature and an ECDH shared-secret derivation — through those handles instead of decoding a private `Jwk`. Everything downstream of the signature/`Z` (Concat-KDF, A256KW key wrap, AEAD, header assembly, signature normalization) is public-data math and is unchanged. The extractable `ISecretsResolver` path stays the default and is byte-for-byte unchanged; the two coexist and may be mixed per kid. `NetDidKeyStoreSecretsResolver` becomes a sufficient sole resolver for an HSM-backed agent. **Timing caveat:** the JWE decrypt is constant-work whether or not a recipient key is held (the library always runs key agreement, with a decoy when unheld), but resolving a held key first incurs one backing-store round-trip only on the held path — on a network HSM/KMS that prologue is a residual held-vs-unheld timing signal this layer cannot mask, mitigated by fronting the receive endpoint with auth / a rate-limiter and the FR-API-07 receive-rejection floor (#35). | A wallet whose keys live only in a non-extractable `IKeyStore` can authcrypt / anoncrypt / sign on send and unpack on receive with no private key bytes leaving the store (interop round-trips); the keystore resolver returns public-only JWKs (`d` absent). | --- diff --git a/src/DidComm.Core/Secrets/IOpaqueKeyResolver.cs b/src/DidComm.Core/Secrets/IOpaqueKeyResolver.cs index 35efe3a..48f8ce1 100644 --- a/src/DidComm.Core/Secrets/IOpaqueKeyResolver.cs +++ b/src/DidComm.Core/Secrets/IOpaqueKeyResolver.cs @@ -28,6 +28,17 @@ namespace DidComm.Secrets; /// here cover only the secret operation itself; everything downstream (Concat-KDF, A256KW key wrap, /// AEAD, header assembly, signature normalization) is public-data math the JOSE layer already owns. /// +/// +/// Timing note for implementers. On receive, the JWE decrypt itself is constant-work +/// regardless of which (or whether) recipient key is held: the library always runs the full key +/// agreement, substituting a decoy when nothing is held. However, the library must first resolve +/// the held key (one call → a backing-store round-trip) only on +/// the held path, before that constant-work step. If your backing store's latency depends on whether a +/// key exists (e.g. a network HSM/KMS), that prologue is a residual held-vs-unheld timing signal this +/// layer cannot mask. Mitigate it where the threat applies — front the receive endpoint with +/// authentication / a rate-limiter, and/or rely on the transport receive-rejection floor (a constant-time +/// reject pad, issue #35). Implementations whose lookup latency is content-independent are unaffected. +/// /// public interface IOpaqueKeyResolver { diff --git a/tests/DidComm.Core.Tests/Envelopes/Composition/EnvelopeReaderTests.cs b/tests/DidComm.Core.Tests/Envelopes/Composition/EnvelopeReaderTests.cs index 02531d1..b539a9d 100644 --- a/tests/DidComm.Core.Tests/Envelopes/Composition/EnvelopeReaderTests.cs +++ b/tests/DidComm.Core.Tests/Envelopes/Composition/EnvelopeReaderTests.cs @@ -495,6 +495,32 @@ public async Task AnonymousSender_reflects_the_outermost_encrypt_layer_not_accum unpacked.SenderKid.Should().Be(alice.PublicJwk.Kid); } + [Theory] + [InlineData(KeyType.X25519)] + [InlineData(KeyType.P256)] + [InlineData(KeyType.P384)] + [InlineData(KeyType.P521)] + public async Task Unheld_recipient_on_every_curve_fails_uniformly_via_the_decoy_path(KeyType keyType) + { + // PR #46 review, point 2: the reader always drives ParseAsync — with a throwaway X25519 decoy + // handle when NO recipient key is held — and the parser substitutes its own work-curve decoy. + // So an unheld envelope on ANY agreement curve (notably the NIST curves, where our decoy curve + // differs from the envelope's work curve) fails with the SAME uniform CryptoException as a + // held-but-wrong-key envelope. This verifies the EC decoy path end-to-end from our side, not just + // the X25519 case the other tests cover. + var bob = TestKeyMaterial.Generate(keyType, "did:example:bob#enc"); + // anoncrypt (ECDH-ES) — A256GCM is legal here (FR-ENC-09 only pins authcrypt), and the curve under + // test is the recipient's, carried by the ephemeral epk the parser keys its work-curve decoy off. + var packed = await EnvelopeWriter.PackEncryptedAsync( + new PackEncryptedParameters(EmptyMessage(), new[] { bob.PublicJwk }, "A256GCM"), _crypto); + + // Hold NO recipient key: the agent cannot decrypt, so the decoy path must run and fail uniformly. + Action act = () => EnvelopeReaderTestRunner.Unpack(packed, + new DictionarySecretsLookup(Array.Empty()), senderLookup: null, signerLookup: null, _crypto); + + act.Should().Throw(); + } + [Theory] [InlineData(typeof(KeyNotFoundException))] [InlineData(typeof(System.Security.Cryptography.CryptographicException))]