diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0bb57a6..b54610f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,70 @@ All notable changes to didcomm-dotnet are documented here. Format follows
## [Unreleased]
+## [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
+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..8a6c18a 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. **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.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..48f8ce1
--- /dev/null
+++ b/src/DidComm.Core/Secrets/IOpaqueKeyResolver.cs
@@ -0,0 +1,66 @@
+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.
+///
+///
+/// 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
+{
+ ///
+ /// 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
+ /// crv — X25519 / 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..b539a9d 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,79 @@ 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(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))]
+ 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);
+}