From 5c9bfb7e2d15f7ef8307d03092a431e14171022d Mon Sep 17 00:00:00 2001 From: Moises E Jaramillo Date: Sun, 14 Jun 2026 14:02:57 -0400 Subject: [PATCH] Add IKeyStore.DeriveSharedSecretAsync (ECDH key agreement) (closes #11) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit IKeyStore exposed only signing operations, so a non-extractable key-agreement private key (HSM, OS keychain) could not participate in ECDH-based decryption — the recipient's scalar had to be extracted and fed to ICryptoProvider.DeriveSharedSecret as raw bytes, capping HSM-backed deployments to sign-only. Add a key-agreement counterpart to SignAsync: Task DeriveSharedSecretAsync(string alias, ReadOnlyMemory peerPublicKey, CancellationToken ct) It performs ECDH against a stored key-agreement private key and returns the raw shared secret Z (no KDF — the caller still owns the Concat-KDF/HKDF step, matching ICryptoProvider.DeriveSharedSecret). The private scalar never leaves the store, so a non-extractable / HSM-bound key can decrypt for JOSE ECDH-ES/ECDH-1PU and DIDComm anoncrypt/authcrypt. - IKeyStore: new member with HSM-first XML docs. - InMemoryKeyStore: delegates to ICryptoProvider.DeriveSharedSecret (X25519, P-256, P-384, P-521). - PublicAPI.Unshipped.txt: record both new public members. - Tests: cross-curve parity (store Z == raw Z == peer's Z) for all four curves; private scalar never surfaced; unknown alias -> KeyNotFoundException; non-ECDH key -> ArgumentException. - KeyAgreement sample: demonstrate store-bound Z equals the raw DeriveSharedSecret Z. - Updated the two other IKeyStore implementations (MiniKeyStore sample, RecordingKeyStore double), PRD FR-7, and the CHANGELOG. The new path delegates to the existing, already-tested DeriveSharedSecret, inheriting its on-curve / length validation; it adds no new crypto. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 9 +++ netcrypto-prd.md | 11 +++- .../NetCrypto.Samples.KeyAgreement/Program.cs | 19 ++++++ samples/NetCrypto.Samples.Signers/Program.cs | 10 ++- src/NetCrypto/IKeyStore.cs | 18 ++++++ src/NetCrypto/InMemoryKeyStore.cs | 14 +++++ src/NetCrypto/PublicAPI.Unshipped.txt | 2 + .../Crypto/KeyStoreSignerAcceptanceTests.cs | 3 + .../KeyStore/InMemoryKeyStoreTests.cs | 61 +++++++++++++++++++ 9 files changed, 144 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9219175..fc201d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Targeting **1.1.0** — additive changes from the didcomm-dotnet → NetCrypto integration (#10, #11, #12). +### Added +- **`IKeyStore.DeriveSharedSecretAsync(alias, peerPublicKey, ct)`** — a key-agreement (ECDH) operation + on the key-store abstraction, the encryption-side counterpart to `SignAsync`. It performs ECDH against + a stored key-agreement private key and returns the **raw shared secret Z** (no KDF applied — the caller + still owns the Concat-KDF/HKDF step, matching `ICryptoProvider.DeriveSharedSecret`). This lets a + non-extractable / HSM-bound key participate in ECDH-based decryption (JOSE `ECDH-ES`/`ECDH-1PU`, DIDComm + anoncrypt/authcrypt) without the private scalar ever leaving the store. Implemented by `InMemoryKeyStore` + for X25519, P-256, P-384, and P-521; demonstrated in the `KeyAgreement` sample. (#11) + ### Security - **`JwkConverter.ExtractPublicKey` now documents its on-curve guarantee.** The method already validated EC `(x, y)` coordinates against the stated curve (via `EcPointValidator.EnsureOnCurve`) diff --git a/netcrypto-prd.md b/netcrypto-prd.md index dd62d63..40c585b 100644 --- a/netcrypto-prd.md +++ b/netcrypto-prd.md @@ -162,12 +162,21 @@ Migrate `DefaultKeyGenerator` verbatim: `Generate`/`FromPrivateKey`/`FromPublicK ### FR-7 — Signing and key-store abstractions -Migrate `ISigner`, `KeyPairSigner`, `KeyStoreSigner`, `IKeyStore` (all seven members: `GenerateAsync`, `ImportAsync`, `GetInfoAsync`, `SignAsync`, `CreateSignerAsync`, `ListAsync`, `DeleteAsync`) verbatim, including null-argument guards and the HSM-first doc language. +Migrate `ISigner`, `KeyPairSigner`, `KeyStoreSigner`, `IKeyStore` (the original seven members: `GenerateAsync`, `ImportAsync`, `GetInfoAsync`, `SignAsync`, `CreateSignerAsync`, `ListAsync`, `DeleteAsync`) verbatim, including null-argument guards and the HSM-first doc language. + +**Key agreement (issue #11, 1.1.0):** `IKeyStore` gains an eighth member — +`DeriveSharedSecretAsync(string alias, ReadOnlyMemory peerPublicKey, CancellationToken)` — the +key-agreement counterpart to `SignAsync`. It performs ECDH against a stored key-agreement private key +and returns the **raw shared secret Z** (no KDF; the caller still owns the Concat-KDF/HKDF step, +consistent with `ICryptoProvider.DeriveSharedSecret`), so a non-extractable (e.g. HSM-bound) key can +participate in ECDH-based decryption (JOSE `ECDH-ES`/`ECDH-1PU`, DIDComm anoncrypt/authcrypt). The +private scalar never leaves the store. **Acceptance criteria:** - [ ] Migrated tests (incl. `tests/NetDid.Core.Tests/KeyStore/`) pass unmodified. - [ ] `KeyPairSigner.SignAsync` output verifies via `ICryptoProvider.Verify` for Ed25519 and P-256. - [ ] A test-double `IKeyStore` proves `KeyStoreSigner` never reads private key material (signature delegated; only alias + public key held). +- [ ] `IKeyStore.DeriveSharedSecretAsync` returns a Z byte-for-byte identical to `ICryptoProvider.DeriveSharedSecret` for the extractable equivalent, across **X25519, P-256, P-384, P-521**, without exposing the private scalar; a non-ECDH stored key throws `ArgumentException`, an unknown alias throws `KeyNotFoundException`. ### FR-8 — JWK conversion diff --git a/samples/NetCrypto.Samples.KeyAgreement/Program.cs b/samples/NetCrypto.Samples.KeyAgreement/Program.cs index ee15815..a43f118 100644 --- a/samples/NetCrypto.Samples.KeyAgreement/Program.cs +++ b/samples/NetCrypto.Samples.KeyAgreement/Program.cs @@ -72,6 +72,25 @@ Check(aliceZp.AsSpan().SequenceEqual(bobZp), "P-256 raw Z matches on both sides"); Console.WriteLine(); +// ------------------------------------------------------- +// 3c. Key-store-bound ECDH — IKeyStore.DeriveSharedSecretAsync +// ------------------------------------------------------- +Console.WriteLine("=== Key-store-bound ECDH (IKeyStore.DeriveSharedSecretAsync) ==="); + +// When the recipient's key-agreement private key lives in a key store +// (an HSM, an OS keychain), it must never be extracted to run ECDH. +// DeriveSharedSecretAsync performs the agreement INSIDE the store and +// returns only the raw Z — byte-for-byte identical to what the extractable +// DeriveSharedSecret computes above, but the private scalar stays put. +// This is the encryption-side counterpart to IKeyStore.SignAsync. +var keyStore = new InMemoryKeyStore(keyGen, crypto); +await keyStore.ImportAsync("bob-p256", bobP); // Bob's P-256 pair now lives in the store + +var bobZstore = await keyStore.DeriveSharedSecretAsync("bob-p256", aliceP.PublicKey); +Console.WriteLine($" Bob Z via store ({bobZstore.Length} bytes): {Convert.ToHexString(bobZstore)}"); +Check(bobZstore.AsSpan().SequenceEqual(bobZp), "store-bound Z equals the raw DeriveSharedSecret Z (scalar never left the store)"); +Console.WriteLine(); + // ------------------------------------------------------- // 3b. EcPointValidator — the invalid-curve attack defense // ------------------------------------------------------- diff --git a/samples/NetCrypto.Samples.Signers/Program.cs b/samples/NetCrypto.Samples.Signers/Program.cs index c886375..119417e 100644 --- a/samples/NetCrypto.Samples.Signers/Program.cs +++ b/samples/NetCrypto.Samples.Signers/Program.cs @@ -136,9 +136,10 @@ static void Check(bool condition, string what) Environment.Exit(1); } -// A minimal IKeyStore showing the full seven-member contract. A real +// A minimal IKeyStore showing the full eight-member contract. A real // implementation would keep the key inside an HSM/keychain/vault, but the -// shape stays the same: private keys go in, only signatures come out. +// shape stays the same: private keys go in, only signatures and shared +// secrets come out. sealed class MiniKeyStore(IKeyGenerator keyGen, ICryptoProvider crypto) : IKeyStore { // alias -> key material. Private field — never handed to callers. @@ -160,6 +161,11 @@ public Task ImportAsync(string alias, KeyPair keyPair, Cancellati public Task SignAsync(string alias, ReadOnlyMemory data, CancellationToken ct = default) => Task.FromResult(crypto.Sign(_keys[alias].KeyType, _keys[alias].PrivateKey, data.Span)); + // The key-agreement door: a peer public key in, only the raw ECDH Z out — the + // private scalar never leaves the store, just like SignAsync. + public Task DeriveSharedSecretAsync(string alias, ReadOnlyMemory peerPublicKey, CancellationToken ct = default) + => Task.FromResult(crypto.DeriveSharedSecret(_keys[alias].KeyType, _keys[alias].PrivateKey, peerPublicKey.Span)); + // KeyStoreSigner needs only public facts; its SignAsync calls back here. public Task CreateSignerAsync(string alias, CancellationToken ct = default) => Task.FromResult(new KeyStoreSigner(this, alias, _keys[alias].KeyType, _keys[alias].PublicKey)); diff --git a/src/NetCrypto/IKeyStore.cs b/src/NetCrypto/IKeyStore.cs index 21859e8..902d475 100644 --- a/src/NetCrypto/IKeyStore.cs +++ b/src/NetCrypto/IKeyStore.cs @@ -24,6 +24,24 @@ public interface IKeyStore /// Create an ISigner backed by this store for the given key alias. Task CreateSignerAsync(string alias, CancellationToken ct = default); + /// + /// Perform ECDH key agreement using a stored key-agreement private key and return the raw + /// shared secret "Z". The private scalar never leaves the store — for an HSM- or keychain-backed + /// store the agreement runs inside the secure boundary, so a non-extractable key can still + /// participate in ECDH-based decryption (JOSE ECDH-ES/ECDH-1PU, DIDComm + /// anoncrypt/authcrypt). This is the key-agreement counterpart to . + /// + /// Alias of the stored key. Must be an ECDH-capable type: X25519, P-256, P-384, or P-521. + /// The peer's public key in the canonical encoding for the stored key's curve: + /// raw 32 bytes for X25519; SEC1 compressed (0x02/0x03 || X) or uncompressed (0x04 || X || Y) for the NIST curves. + /// A token to cancel the operation. + /// The raw ECDH shared secret "Z" — no KDF, truncation, or normalization is applied, byte-for-byte + /// identical to what computes for the extractable equivalent. + /// Apply a NIST SP 800-56A-conformant KDF (Concat KDF, HKDF, KMAC) before using it as keying material. + /// No key is stored under . + /// The stored key's type is not ECDH-capable, or is malformed for the curve. + Task DeriveSharedSecretAsync(string alias, ReadOnlyMemory peerPublicKey, CancellationToken ct = default); + /// List all stored key aliases. Task> ListAsync(CancellationToken ct = default); diff --git a/src/NetCrypto/InMemoryKeyStore.cs b/src/NetCrypto/InMemoryKeyStore.cs index a8b5f02..8e74986 100644 --- a/src/NetCrypto/InMemoryKeyStore.cs +++ b/src/NetCrypto/InMemoryKeyStore.cs @@ -91,6 +91,20 @@ public Task CreateSignerAsync(string alias, CancellationToken ct = defa return Task.FromResult(signer); } + /// + public Task DeriveSharedSecretAsync(string alias, ReadOnlyMemory peerPublicKey, CancellationToken ct = default) + { + ArgumentException.ThrowIfNullOrEmpty(alias); + + if (!_keys.TryGetValue(alias, out var entry)) + throw new KeyNotFoundException($"Key alias '{alias}' not found."); + + // The stored private scalar is read here but never returned: only the raw shared secret Z + // leaves the store, mirroring SignAsync. A real HSM-backed store runs the agreement in-enclave. + var z = _cryptoProvider.DeriveSharedSecret(entry.KeyPair.KeyType, entry.KeyPair.PrivateKey, peerPublicKey.Span); + return Task.FromResult(z); + } + /// public Task> ListAsync(CancellationToken ct = default) { diff --git a/src/NetCrypto/PublicAPI.Unshipped.txt b/src/NetCrypto/PublicAPI.Unshipped.txt index 7dc5c58..f772328 100644 --- a/src/NetCrypto/PublicAPI.Unshipped.txt +++ b/src/NetCrypto/PublicAPI.Unshipped.txt @@ -1 +1,3 @@ #nullable enable +NetCrypto.IKeyStore.DeriveSharedSecretAsync(string! alias, System.ReadOnlyMemory peerPublicKey, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +NetCrypto.InMemoryKeyStore.DeriveSharedSecretAsync(string! alias, System.ReadOnlyMemory peerPublicKey, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! diff --git a/tests/NetCrypto.Tests/Crypto/KeyStoreSignerAcceptanceTests.cs b/tests/NetCrypto.Tests/Crypto/KeyStoreSignerAcceptanceTests.cs index 26423a0..4628d72 100644 --- a/tests/NetCrypto.Tests/Crypto/KeyStoreSignerAcceptanceTests.cs +++ b/tests/NetCrypto.Tests/Crypto/KeyStoreSignerAcceptanceTests.cs @@ -111,6 +111,9 @@ public Task ImportAsync(string alias, KeyPair keyPair, Cancellati public Task CreateSignerAsync(string alias, CancellationToken ct = default) => throw Forbidden(nameof(CreateSignerAsync)); + public Task DeriveSharedSecretAsync(string alias, ReadOnlyMemory peerPublicKey, CancellationToken ct = default) + => throw Forbidden(nameof(DeriveSharedSecretAsync)); + public Task> ListAsync(CancellationToken ct = default) => throw Forbidden(nameof(ListAsync)); diff --git a/tests/NetCrypto.Tests/KeyStore/InMemoryKeyStoreTests.cs b/tests/NetCrypto.Tests/KeyStore/InMemoryKeyStoreTests.cs index 8b9ed7b..cd3b67d 100644 --- a/tests/NetCrypto.Tests/KeyStore/InMemoryKeyStoreTests.cs +++ b/tests/NetCrypto.Tests/KeyStore/InMemoryKeyStoreTests.cs @@ -117,6 +117,67 @@ public async Task CreateSignerAsync_NonExistentKey_Throws() await act.Should().ThrowAsync(); } + // -------- Key agreement (ECDH) — issue #11 -------- + + [Theory] + [InlineData(KeyType.X25519)] + [InlineData(KeyType.P256)] + [InlineData(KeyType.P384)] + [InlineData(KeyType.P521)] + public async Task DeriveSharedSecretAsync_MatchesRawDeriveSharedSecret_BothDirections(KeyType keyType) + { + // Acceptance criterion: a non-extractable key-agreement key in the store must produce the + // same raw Z as ICryptoProvider.DeriveSharedSecret would for the extractable equivalent. + var alice = _keyGen.Generate(keyType); // remote peer (extractable, for reference) + var bob = _keyGen.Generate(keyType); // recipient — lives in the store + await _store.ImportAsync("bob", bob); + + var zFromStore = await _store.DeriveSharedSecretAsync("bob", alice.PublicKey); + + // 1. Equals what the raw provider computes from Bob's extracted scalar. + var zRaw = _crypto.DeriveSharedSecret(keyType, bob.PrivateKey, alice.PublicKey); + zFromStore.Should().Equal(zRaw); + + // 2. Equals Alice's independent derivation — i.e. the two parties agree. + var zAlice = _crypto.DeriveSharedSecret(keyType, alice.PrivateKey, bob.PublicKey); + zFromStore.Should().Equal(zAlice); + } + + [Fact] + public async Task DeriveSharedSecretAsync_NeverExposesPrivateScalar() + { + // The store yields a correct Z, yet the only key material it surfaces is the public key. + var bob = _keyGen.Generate(KeyType.P256); + var peer = _keyGen.Generate(KeyType.P256); + await _store.ImportAsync("bob", bob); + + var z = await _store.DeriveSharedSecretAsync("bob", peer.PublicKey); + z.Should().NotBeEmpty(); + + var info = await _store.GetInfoAsync("bob"); + info!.PublicKey.Should().Equal(bob.PublicKey); // public key only — StoredKeyInfo carries no private material + } + + [Fact] + public async Task DeriveSharedSecretAsync_NonExistentKey_Throws() + { + var peer = _keyGen.Generate(KeyType.P256); + + var act = () => _store.DeriveSharedSecretAsync("nonexistent", peer.PublicKey); + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task DeriveSharedSecretAsync_NonEcdhKeyType_Throws() + { + // Ed25519 is a signature key, not ECDH-capable; the store surfaces the provider's ArgumentException. + var ed = _keyGen.Generate(KeyType.Ed25519); + await _store.ImportAsync("signing-key", ed); + + var act = () => _store.DeriveSharedSecretAsync("signing-key", new byte[32]); + await act.Should().ThrowAsync(); + } + [Fact] public async Task ListAsync_ReturnsAllAliases() {