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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)
Expand Down
11 changes: 10 additions & 1 deletion netcrypto-prd.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<byte> 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

Expand Down
19 changes: 19 additions & 0 deletions samples/NetCrypto.Samples.KeyAgreement/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
// -------------------------------------------------------
Expand Down
10 changes: 8 additions & 2 deletions samples/NetCrypto.Samples.Signers/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -160,6 +161,11 @@ public Task<StoredKeyInfo> ImportAsync(string alias, KeyPair keyPair, Cancellati
public Task<byte[]> SignAsync(string alias, ReadOnlyMemory<byte> 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<byte[]> DeriveSharedSecretAsync(string alias, ReadOnlyMemory<byte> 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<ISigner> CreateSignerAsync(string alias, CancellationToken ct = default)
=> Task.FromResult<ISigner>(new KeyStoreSigner(this, alias, _keys[alias].KeyType, _keys[alias].PublicKey));
Expand Down
18 changes: 18 additions & 0 deletions src/NetCrypto/IKeyStore.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,24 @@ public interface IKeyStore
/// <summary>Create an ISigner backed by this store for the given key alias.</summary>
Task<ISigner> CreateSignerAsync(string alias, CancellationToken ct = default);

/// <summary>
/// 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 <c>ECDH-ES</c>/<c>ECDH-1PU</c>, DIDComm
/// anoncrypt/authcrypt). This is the key-agreement counterpart to <see cref="SignAsync"/>.
/// </summary>
/// <param name="alias">Alias of the stored key. Must be an ECDH-capable type: X25519, P-256, P-384, or P-521.</param>
/// <param name="peerPublicKey">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.</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>The raw ECDH shared secret "Z" — no KDF, truncation, or normalization is applied, byte-for-byte
/// identical to what <see cref="ICryptoProvider.DeriveSharedSecret"/> computes for the extractable equivalent.
/// Apply a NIST SP 800-56A-conformant KDF (Concat KDF, HKDF, KMAC) before using it as keying material.</returns>
/// <exception cref="KeyNotFoundException">No key is stored under <paramref name="alias"/>.</exception>
/// <exception cref="ArgumentException">The stored key's type is not ECDH-capable, or <paramref name="peerPublicKey"/> is malformed for the curve.</exception>
Task<byte[]> DeriveSharedSecretAsync(string alias, ReadOnlyMemory<byte> peerPublicKey, CancellationToken ct = default);

/// <summary>List all stored key aliases.</summary>
Task<IReadOnlyList<string>> ListAsync(CancellationToken ct = default);

Expand Down
14 changes: 14 additions & 0 deletions src/NetCrypto/InMemoryKeyStore.cs
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,20 @@ public Task<ISigner> CreateSignerAsync(string alias, CancellationToken ct = defa
return Task.FromResult(signer);
}

/// <inheritdoc />
public Task<byte[]> DeriveSharedSecretAsync(string alias, ReadOnlyMemory<byte> 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);
}

/// <inheritdoc />
public Task<IReadOnlyList<string>> ListAsync(CancellationToken ct = default)
{
Expand Down
2 changes: 2 additions & 0 deletions src/NetCrypto/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
#nullable enable
NetCrypto.IKeyStore.DeriveSharedSecretAsync(string! alias, System.ReadOnlyMemory<byte> peerPublicKey, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task<byte[]!>!
NetCrypto.InMemoryKeyStore.DeriveSharedSecretAsync(string! alias, System.ReadOnlyMemory<byte> peerPublicKey, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task<byte[]!>!
3 changes: 3 additions & 0 deletions tests/NetCrypto.Tests/Crypto/KeyStoreSignerAcceptanceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,9 @@ public Task<StoredKeyInfo> ImportAsync(string alias, KeyPair keyPair, Cancellati
public Task<ISigner> CreateSignerAsync(string alias, CancellationToken ct = default)
=> throw Forbidden(nameof(CreateSignerAsync));

public Task<byte[]> DeriveSharedSecretAsync(string alias, ReadOnlyMemory<byte> peerPublicKey, CancellationToken ct = default)
=> throw Forbidden(nameof(DeriveSharedSecretAsync));

public Task<IReadOnlyList<string>> ListAsync(CancellationToken ct = default)
=> throw Forbidden(nameof(ListAsync));

Expand Down
61 changes: 61 additions & 0 deletions tests/NetCrypto.Tests/KeyStore/InMemoryKeyStoreTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,67 @@ public async Task CreateSignerAsync_NonExistentKey_Throws()
await act.Should().ThrowAsync<KeyNotFoundException>();
}

// -------- 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<KeyNotFoundException>();
}

[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<ArgumentException>();
}

[Fact]
public async Task ListAsync_ReturnsAllAliases()
{
Expand Down
Loading