Skip to content

Add IKeyStore.DeriveSharedSecretAsync (ECDH key agreement) (closes #11)#14

Merged
moisesja merged 1 commit into
mainfrom
feat/keystore-key-agreement
Jun 14, 2026
Merged

Add IKeyStore.DeriveSharedSecretAsync (ECDH key agreement) (closes #11)#14
moisesja merged 1 commit into
mainfrom
feat/keystore-key-agreement

Conversation

@moisesja

Copy link
Copy Markdown
Owner

Summary

Closes #11. 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 handed to ICryptoProvider.DeriveSharedSecret as raw bytes. That capped HSM-backed deployments to sign-only and left the key-agreement key (the more sensitive long-lived secret for a messaging agent) extractable.

This PR adds the encryption-side counterpart to SignAsync:

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

It performs ECDH against a stored key-agreement private key and returns the raw shared secret Z — no KDF, truncation, or normalization — exactly like ICryptoProvider.DeriveSharedSecret, so the caller still owns the Concat-KDF / HKDF step. The private scalar never leaves the store, enabling fully non-extractable / HSM-bound decryption for JOSE ECDH-ES/ECDH-1PU and DIDComm anoncrypt/authcrypt.

Changes

  • IKeyStore — new eighth member with HSM-first XML docs (raw Z, caller owns the KDF, supported curves).
  • InMemoryKeyStore — implements it by delegating to ICryptoProvider.DeriveSharedSecret (X25519, P-256, P-384, P-521). The stored scalar is read only inside the store boundary, mirroring SignAsync.
  • PublicAPI.Unshipped.txt — records both new public members (Roslyn public-API analyzer).
  • Tests — cross-curve parity across all four ECDH curves (store Z == raw DeriveSharedSecret Z == peer's Z), private scalar never surfaced, unknown alias → KeyNotFoundException, non-ECDH stored key → ArgumentException.
  • KeyAgreement sample — demonstrates store-bound Z equals the raw DeriveSharedSecret Z (also satisfies the FR-17 API-coverage check).
  • Updated the two other IKeyStore implementations (the MiniKeyStore sample → real delegation; the RecordingKeyStore test double → forbids it, like every non-SignAsync member), PRD FR-7, and the CHANGELOG.

Design notes

  • Minimal surface. The issue floated an optional parallel IKeyAgreement abstraction (analogous to ISigner/CreateSignerAsync). Since the acceptance criteria are fully met by a single method and the issue is marked lower/forward-looking, I kept the surface minimal; a factory can be added later additively if a consumer needs a reusable agreement handle.
  • No new crypto. The new path delegates to the existing, already-tested DeriveSharedSecret, inheriting its on-curve and length validation (off-curve / wrong-length peer keys throw CryptographicException/ArgumentException from the import).

Acceptance criteria (issue #11)

  • A non-extractable key-agreement key produces the same raw Z as ICryptoProvider.DeriveSharedSecret for the extractable equivalent, without exposing the private scalar.
  • Covers X25519, P-256, P-384, P-521.

Verification

  • dotnet build -warnaserror clean; full suite 797 tests green; API coverage OK; KeyAgreement sample prints matching Z and exits 0.

🤖 Generated with Claude Code

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<byte[]> DeriveSharedSecretAsync(string alias, ReadOnlyMemory<byte> 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 <noreply@anthropic.com>
@moisesja moisesja added this to the 1.1.0 milestone Jun 14, 2026

@moisesja moisesja left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated PR review

Looks good to merge once CI is green. This is a clean, minimal, well-scoped change.

What's solid

  • Right design. A single additive method that delegates to the already-tested ICryptoProvider.DeriveSharedSecret adds no new crypto and inherits its on-curve/length validation. Keeping the scalar inside the store boundary (mirroring SignAsync) and returning raw Z with the KDF left to the caller is consistent with the existing contract. Deferring the optional IKeyAgreement factory is the right call given the acceptance criteria are met by one method.
  • Test coverage. Cross-curve parity across all four ECDH curves (store Z == raw Z == peer's Z), scalar-never-exposed, unknown alias → KeyNotFoundException, non-ECDH key → ArgumentException. Edge cases are covered.
  • Hygiene. PublicAPI.Unshipped.txt, CHANGELOG, PRD FR-7, the MiniKeyStore sample, the RecordingKeyStore test double, and the KeyAgreement sample are all updated in lockstep. Commit message is clear and links the issue.

One consideration (non-blocking)

  • Adding an eighth member to the public IKeyStore interface is a source/binary-breaking change for any external implementer. All in-repo implementers are updated here and the milestone is 1.1.0 (minor), so flagging only so it's a conscious choice — if external implementers are expected before 1.x stabilizes, a default interface method would preserve compatibility. Acceptable as-is for a foundation library at this stage.

Minor nit (optional)

  • The interface XML doc lists KeyNotFoundException and ArgumentException, but ArgumentException.ThrowIfNullOrEmpty(alias) in InMemoryKeyStore also throws ArgumentNullException/ArgumentException for a null/empty alias. ArgumentNullException derives from ArgumentException so it's technically covered, but a one-line <exception> note for the alias guard (as SignAsync presumably has) would be tidier.

Nice work — approving in spirit; leaving as a comment rather than a formal approval since CI is still in progress.


Generated by Claude Code

@moisesja moisesja self-assigned this Jun 14, 2026
@moisesja moisesja merged commit 7e79b1f into main Jun 14, 2026
4 checks passed
@moisesja moisesja deleted the feat/keystore-key-agreement branch June 14, 2026 18:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add a key-agreement (ECDH) operation to IKeyStore for non-extractable / HSM-bound decryption

1 participant