Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<DidCommVersion>0.1.0</DidCommVersion>
<DidCommVersion>1.1.0</DidCommVersion>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);1591</NoWarn>
<Deterministic>true</Deterministic>
Expand Down
6 changes: 4 additions & 2 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,10 @@
bytes. Everything else (JWE/JWS build+parse, ECDH, AEAD, key wrap, JWK) is DataProofs. -->
<PackageVersion Include="NetCrypto" Version="1.1.0" />
<!-- JOSE composition + crypto substrate (JWE/JWS build+parse, ECDH, AEAD, key wrap, JWK).
Brings NetCrypto transitively; didcomm carries no envelope crypto of its own. 1.0.1
surfaces the verified JWS signer kid from the unprotected header (dataproofs-dotnet#10). -->
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. -->
<PackageVersion Include="DataProofsDotnet.Jose" Version="1.1.0" />
<!-- ASP.NET Core (transports) -->
<PackageVersion Include="Microsoft.AspNetCore.TestHost" Version="10.0.0-preview.1.25120.3" />
Expand Down
1 change: 1 addition & 0 deletions docs/didcomm-dotnet_PRD.md
Original file line number Diff line number Diff line change
Expand Up @@ -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). |

---

Expand Down
42 changes: 42 additions & 0 deletions src/DidComm.Adapters.NetDid/KeyStoreEcdhKey.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
using DataProofsDotnet.Jose.Encryption;
using NetCrypto;

namespace DidComm.Adapters.NetDid;

/// <summary>
/// An <see cref="IEcdhKey"/> that performs ECDH key agreement <em>inside</em> a NetCrypto
/// <see cref="IKeyStore"/> — the private scalar never leaves the store, so HSM / KMS / keychain
/// custody holds (FR-SEC-06). Wraps a <c>(keystore, alias, crv)</c> triple; <see cref="DeriveAsync"/>
/// delegates to <see cref="IKeyStore.DeriveSharedSecretAsync"/> and returns the raw, unhashed shared
/// secret <c>Z</c> that the JOSE layer feeds into the Concat-KDF (everything after <c>Z</c> is
/// public-data math).
/// </summary>
internal sealed class KeyStoreEcdhKey : IEcdhKey
{
private readonly IKeyStore _keyStore;
private readonly string _alias;

/// <summary>Wrap a key-agreement key held under <paramref name="alias"/> in <paramref name="keyStore"/>.</summary>
/// <param name="keyStore">The backing NetCrypto key store.</param>
/// <param name="alias">The store alias of the local key-agreement key.</param>
/// <param name="crv">The JWK <c>crv</c> the key agrees on (X25519 / P-256 / P-384 / P-521).</param>
public KeyStoreEcdhKey(IKeyStore keyStore, string alias, string crv)
{
ArgumentNullException.ThrowIfNull(keyStore);
ArgumentException.ThrowIfNullOrEmpty(alias);
ArgumentException.ThrowIfNullOrEmpty(crv);
_keyStore = keyStore;
_alias = alias;
Crv = crv;
}

/// <inheritdoc />
public string Crv { get; }

/// <inheritdoc />
public ValueTask<byte[]> DeriveAsync(ReadOnlyMemory<byte> 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));
}
73 changes: 53 additions & 20 deletions src/DidComm.Adapters.NetDid/NetDidKeyStoreSecretsResolver.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Optional <see cref="ISecretsResolver"/> bridge backed by a NetCrypto <see cref="IKeyStore"/>
/// (FR-SEC-04, SHOULD). Apps already holding keys in a NetCrypto key store can avoid duplicating
/// their key material into a second store.
/// <see cref="ISecretsResolver"/> bridge backed by a NetCrypto <see cref="IKeyStore"/> (FR-SEC-04)
/// that also implements <see cref="IOpaqueKeyResolver"/> 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).
/// </summary>
/// <remarks>
/// <para>
/// <strong>Scope:</strong> <see cref="IKeyStore"/> exposes <c>SignAsync</c> and
/// <c>DeriveSharedSecretAsync</c> 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
/// <em>public</em> JWK shape with no <c>d</c> component. DIDComm signing
/// (<c>EnvelopeWriter.PackSignedAsync</c>, the outer signed envelope, and the <c>from_prior</c>
/// JWT) and decryption still resolve private JWKs through <see cref="FindAsync"/> today, so this
/// adapter ships in "public-resolution only" form. It is therefore <em>not</em> sufficient as the
/// sole <see cref="ISecretsResolver"/> in a fully-functional facade; an opaque-signer / opaque-ECDH
/// path over <see cref="IKeyStore.SignAsync"/> and <see cref="IKeyStore.DeriveSharedSecretAsync"/>
/// is future work. Consumers using HSM-backed stores typically pair it with an extractable-secret
/// store for the keys that must perform crypto.
/// <strong>How it works.</strong> <see cref="IKeyStore"/> exposes <c>SignAsync</c> /
/// <c>CreateSignerAsync</c> and <c>DeriveSharedSecretAsync</c> plus public-key surfaces, but never raw
/// private-key bytes. So <see cref="FindAsync"/> surfaces only the <em>public</em> JWK shape (no
/// <c>d</c>) — enough for the facade to select keys and resolve curves — while the actual secret
/// operations run through the <see cref="IOpaqueKeyResolver"/> handles: signing through
/// <see cref="IKeyStore.CreateSignerAsync"/> (an <c>ISigner</c> that signs inside the store) and ECDH
/// through a <see cref="KeyStoreEcdhKey"/> over <see cref="IKeyStore.DeriveSharedSecretAsync"/>. The
/// facade prefers these handles whenever the registered resolver implements
/// <see cref="IOpaqueKeyResolver"/>, so this adapter is a <strong>sufficient sole resolver</strong>
/// for an HSM-backed agent — it can authcrypt / anoncrypt / sign on send and unpack on receive with
/// no private key bytes leaving the store.
/// </para>
/// <para>
/// The kid passed to <see cref="FindAsync"/> is treated as the key alias inside the
/// <see cref="IKeyStore"/>. Apps that key by DID URL should ensure their store's aliases match.
/// <strong>kid → alias.</strong> By default a DID-URL <c>kid</c> is used verbatim as the
/// <see cref="IKeyStore"/> alias (apps that key by DID URL should ensure their store's aliases match).
/// Stores that key differently can pass a <c>kidToAlias</c> mapping to the constructor.
/// </para>
/// </remarks>
public sealed class NetDidKeyStoreSecretsResolver : ISecretsResolver
public sealed class NetDidKeyStoreSecretsResolver : ISecretsResolver, IOpaqueKeyResolver
{
private readonly IKeyStore _keyStore;
private readonly Func<string, string> _kidToAlias;

/// <summary>Initialise the adapter.</summary>
/// <param name="keyStore">The backing NetCrypto key store.</param>
public NetDidKeyStoreSecretsResolver(IKeyStore keyStore)
/// <param name="kidToAlias">Optional map from a DID-URL <c>kid</c> to its store alias. Defaults to identity (the kid IS the alias).</param>
public NetDidKeyStoreSecretsResolver(IKeyStore keyStore, Func<string, string>? kidToAlias = null)
{
ArgumentNullException.ThrowIfNull(keyStore);
_keyStore = keyStore;
_kidToAlias = kidToAlias ?? (static kid => kid);
}

/// <inheritdoc />
public async Task<Jwk?> 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);
}
Expand All @@ -56,6 +63,32 @@ public async Task<IReadOnlyList<string>> FindPresentAsync(IEnumerable<string> ki
ArgumentNullException.ThrowIfNull(kids);
var aliases = await _keyStore.ListAsync(ct).ConfigureAwait(false);
var set = new HashSet<string>(aliases, StringComparer.Ordinal);
return kids.Where(set.Contains).ToArray();
return kids.Where(kid => set.Contains(_kidToAlias(kid))).ToArray();
}

/// <inheritdoc />
public async Task<ISigner?> 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);
}

/// <inheritdoc />
public async Task<IEcdhKey?> 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);
}
}
Loading
Loading