feat: ICapableKeyStore custody surface — release 1.6.0 (closes #26) - #27
Conversation
Adds the contract a production custody backend — cloud KMS, HSM partition,
encrypted software keystore — needs in order to sit behind IKeyStore without its
consumer inventing a parallel signer surface downstream. Purely additive: it
ships as a derived interface plus new types, so IKeyStore gains no member and
every existing store, ISigner, KeyStoreSigner and InMemoryKeyStore caller stays
source- and binary-compatible. No export operation, no KDF, no protocol
semantics, and no router/policy machinery.
- capability discovery (IKeyStoreCapabilityProvider, KeyStoreCapabilitySet)
- namespace scoping and immutable KeyInstanceId identity
- durably idempotent mutations with readable KeyMutationOutcome receipts
- algorithm/encoding-bearing SignAsync and DeriveSharedSecretAsync, which is
what finally makes EcdsaSignatureFormat reachable for a stored key
- SignBbsAsync — BBS multi-message signing by reference
- TransferableKeyMaterial: one-way import with no private-key read path
- the portable KeyStoreException / KeyStoreError taxonomy
CapableInMemoryKeyStore + InMemoryKeyStoreBackend are the reference
implementation and contract oracle, a sibling of the untouched InMemoryKeyStore.
The adversarial pass and NFR-3 sweep found four integrity gaps in a first
implementation that looked correct, all fixed here with regression tests proven
genuine by reverting each guard: provider output was length-checked but never
verified against the key it claims to speak for; unpaired surrogates collapsed
to the same UTF-8 bytes, so one caller's mutation could silently replay
another's; import never proved the public key belonged to the private key; and
a reentrant provider callback could zeroize a key mid-signature. Also fixes a
pre-existing NFR-3 leak in InMemoryKeyStore.DeriveSharedSecretAsync, where an
unusable peer point surfaced as a platform CryptographicException.
PRD FR-7b records the contract; README gains a section for backend authors;
samples/NetCrypto.Samples.CapableKeyStore is the worked example.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Review — LGTM, with a couple of non-blocking notesI read the security-critical seams end to end (
Two things worth a look — neither blocking:
On scope: this is a large PR — the full issue in one release (~7k lines). The single-PR-per-release call is explained and reasonable given the downstream custody port audits once, and holding back the tag/publish for the maintainer is the right move. Approving. Generated by Claude Code |
moisesja
left a comment
There was a problem hiding this comment.
Requesting changes. This is not ready to merge.
The existing “LGTM” comment says the provider trust boundary and exception portability hold. They do not. I reproduced four concrete failures against commit 34272d1 with an isolated executable harness:
CAPABILITY_WITH: operation=Generate; algorithm=es256-p1363
BBS_SELF_CERTIFICATION: accepted=True; length=80
IMPORT_EXCEPTION: type=System.DllNotFoundException; consumed=True
BBS_COUNT: type=System.OutOfMemoryException
-
BBS output self-certifies through the same provider (
CapableInMemoryKeyStore.cs:488-495).bbsis the injected provider that just produced the signature. A provider whoseSignreturns 80 bytes of0xABand whoseVerifyreturnstrueis accepted. The current BBS hardening test misses this because its fake lies only inSignand delegatesVerifyto the real provider. The non-BBS path correctly uses a privateDefaultCryptoProvider; BBS needs equivalent trust separation, plus a regression where the provider lies in both methods. -
Import leaks backend/platform exceptions (
CapableInMemoryKeyStore.cs:279-288). The call toIKeyGenerator.FromPrivateKeymaps onlyArgumentException. A generator throwingDllNotFoundException—the same failure used by the generate-path test—escapes directly after the transfer material has been consumed. That contradicts the public contract: “No backend exception type escapes a capable-store member.” Preserve invalid-key input asArgumentException(nameof(request)), but map backend failures toKeyStoreException(Unavailable)and add an import-specific regression. -
KeyStoreCapabilitydoes not preserve its advertised invariant underwith(KeyStoreCapabilities.cs:90-100). Starting with a valid Sign capability,valid with { Operation = KeyStoreOperation.Generate }succeeds while retainingAlgorithm == es256-p1363, even though Generate requires a null algorithm. TheOperationsetter validates only enum membership; it never revalidatesAlgorithm. This directly contradicts the comment claimingwithcannot create an impossible capability. Fix the type so every publicly constructible state satisfies the cross-property invariant, and test single-propertywithmutations in both directions. -
BBS has no finite message-count bound and allocates before its byte bound (
CapableInMemoryKeyStore.cs:441-468).MaxInputBytessums payload bytes only, so arbitrarily many empty messages remain “within” 1 MiB. The implementation allocatesnew byte[count][]before checking the total. AnIReadOnlyListreportingCount == int.MaxValueproducesOutOfMemoryExceptionimmediately; the provider is never reached. Enforce a finite message-count/resource limit before allocation/iteration and include message-count/encoding overhead in the advertised bound.
The test suite is large, but it is missing exactly these boundary cases. Add regression tests that fail on this commit and pass after the fixes.
The commit message is clear. The scope is not: 7,115 added lines, 36 files, four custody features, a version bump, and release bookkeeping in one commit is too much security-sensitive surface for one review unit. The issue itself proposed staging core custody separately from transfer+BBS; these missed defects are evidence that staging was the better choice. At minimum, fix the blockers and explain why this should remain one release PR instead of being split.
…ception portability, capability with-invariant, BBS count bound All four blocking findings verified by reproduction (each regression test fails on 34272d1) and fixed, plus the approving review's two notes: - BBS output no longer self-certifies: verification runs through an internal DefaultBbsCryptoProvider when the native suite is loadable, matching the ECDSA path's trust separation; the no-native fallback is documented as weaker. - ImportAsync maps backend/platform exceptions from IKeyGenerator.FromPrivateKey to KeyStoreException(Unavailable) instead of leaking the raw type after the material is consumed. - KeyStoreCapability re-validates the operation-algorithm pairing in the Operation init accessor, so `with { Operation = ... }` cannot produce a Generate capability carrying an algorithm or a Sign capability without one. - SignBbsAsync bounds the untrusted Messages.Count (4096) before any allocation; absurd counts (negative or int.MaxValue) are parameter faults, never OutOfMemoryException. - Legacy SignAsync XML docs state the missing return-path identity check and point at SignAsync(KeySignRequest); legacy GenerateAsync enters the reentrancy scope before invoking the generator, matching the request path. Each new guard proven genuine by revert. PRD FR-7b rules 9/11 and acceptance criteria updated; CHANGELOG records the findings and their source. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both reviews addressed in
|
moisesja
left a comment
There was a problem hiding this comment.
Re-review of b847152: still requesting changes.
What is actually fixed:
KeyStoreCapabilitynow rejects the reported cross-fieldwithmutations.int.MaxValue/negative BBS counts now fail asArgumentException("request")before allocation.DllNotFoundExceptionduring import is now wrapped asKeyStoreException(Unavailable).- The BBS forgery is rejected when the native
DefaultBbsCryptoProvideris loadable. - The two earlier non-blocking legacy-path notes were addressed.
The scope rationale is also answered. I am not asking you to split the PR at this point.
Three blockers remain.
-
BBS still self-certifies in the supported no-native configuration (
CapableInMemoryKeyStore.cs:488-499). The fallback is the same injected provider that produced the signature. In an isolated output with nolibzkryptium_ffi,DefaultBbsCryptoProvider.IsAvailable == false; an injected provider withIsAvailable == true,Sign => 80 * 0xAB, andVerify => truestill returns the fabricated signature successfully:BBS_SELF_CERTIFICATION: FAILED accepted=True; length=80Copying the native library into the same output changes the result to
KeyStoreException(Unavailable), so the behavior is conclusively configuration-dependent. Updating the PRD to call this a “residual weakness” does not close NFR-6.2. No-native is an explicitly supported deployment mode, and this store advertises BBS there when the injected provider says it is available. Either require a genuinely independent verifier for advertised BBS, or do not advertise/perform BBS through this integrity-promising surface when no independent verifier exists. Add a no-native regression; the new test is markedNativeFFI, so it cannot catch this branch. -
Backend exception portability remains type-dependent (
CapableInMemoryKeyStore.cs:203-212,275-286,1101-1102). The import fix excludes everyObjectDisposedException, including one thrown by the injected generator from insideFromPrivateKey. A live transfer then leaks raw ODE after its secret has been consumed:IMPORT_ODE: type=System.ObjectDisposedException; consumed=TrueRequest-based generation has the same structural problem. With valid caller input, an injected generator's backend failures surface as:
backend=ObjectDisposedException; surfaced=ObjectDisposedException backend=OperationCanceledException; surfaced=OperationCanceledException backend=ArgumentException; surfaced=ArgumentExceptionGeneratepasses a validated enum and gives the generator no cancellation token; these exceptions are backend failures, not caller faults or truthful cancellation. Likewise, import must distinguish an ODE raised byTransferableKeyMaterialfrom an ODE raised inside the generator instead of blanket-exempting the type. Normalize based on where the exception originated and actual store/token state, not merely its class. Add regressions for ODE, fabricated OCE, and backend-internalArgumentException. -
The BBS byte bound is still enforced after the full snapshot allocation (
CapableInMemoryKeyStore.cs:453-468). The count guard closes one OOM path but not the underlying resource-bound bug. Every message is copied beforetotalis computed. A 4,096-element list returning fresh 300 KB messages should exceed the 1 MiB cap after four reads; current code continues through 20 reads and leaks the sentinel from read 21:BBS_BYTE_BOUND: type=SentinelException; indexerReads=21The independent pass also measured a one-message 32 MiB request allocating ~33.5 MB before rejection, and a
Count == 1list with no index 0 leakingIndexOutOfRangeException. Read each item once into a localReadOnlyMemory<byte>, incrementally reject from its length before copying it, validate the header before copying, and map inconsistent-list shape faults toArgumentException("request"). Add a regression that asserts the indexer is not read after the cumulative advertised byte limit is crossed—not merely that the provider was not called.
Focused verification otherwise passes: Release build is clean with zero warnings/errors, and 157 key-store/capability/import/BBS tests pass. These are boundary omissions in tests, not general test-suite instability.
moisesja
left a comment
There was a problem hiding this comment.
Fixed the remaining review blockers in 0b629e3.
Specific changes:
- Removed the no-native same-provider BBS verification fallback. BBS is advertised only when both the producer and independent verifier are available.
- Gave the BBS producer a separate deep copy. A hostile producer could mutate shared byte-array elements and make the independent verifier certify altered input.
- Normalized generator-originated ODE, fabricated OCE, internal ArgumentException, native-load faults, and null/wrong-type/disposed/malformed outputs to KeyStoreException(Unavailable). Only the documented privateKey caller fault maps to ArgumentException(request).
- Independently re-derived generated/imported private keys before commit, rejecting same-type mismatched public/private halves.
- Enforced BBS count, header, and cumulative byte bounds before excess copying, stopped at first overflow, and normalized inconsistent list shape.
Regression proof: five original regressions fail at their intended assertions on b847152; both mismatched-pair tests also fail there; the BBS mutation test fails when its deep-copy fix is reverted. All pass with the fixes restored.
Verification:
- Release warnings-as-errors build: passed
- hardening: 53/53
- input-validation/fuzz: 325/325
- native-present: 1221/1221
- isolated no-native: 1178/1178
- independent adversarial harness: 30/30 native and 25/25 no-native, zero remaining confirmed defects
- BBS sample, API coverage, and diff check: passed
The capable-store sample still hits a local macOS Apple Crypto P-256 module-load failure, but the identical failure reproduces at pre-fix b847152; this patch did not introduce it.
Please re-review the current head.
Closes #26. Milestone 1.6.0.
What this is
IKeyStoresays "sign with the key behind this alias". That is enough for an in-memory store and not enough for a custody backend, where the consumer also needs to know what the backend can do before first use, which tenant it may touch, which key instance an alias currently holds, how to retry a mutation that may or may not have been applied, and which encoding a signature comes back in. Without that, every consumer forks its own signer surface downstream — which is what the issue's dependency gate found when it audited 1.4.0 and 1.5.0.ICapableKeyStore : IKeyStore, IKeyStoreCapabilityProvideradds exactly that, additively.IKeyStoregains no member, so every existing store implementation,ISigner,KeyStoreSigner, andInMemoryKeyStorecaller is source- and binary-compatible. No export operation anywhere, no KDF, no protocol semantics, and no router/profile/policy machinery — NetCrypto gets the self-description that makes routing possible, not the routing.Scope: the full issue in one release (rather than the staged 1.6.0/1.7.0 option), so the downstream custody port audits once.
IKeyStoreCapabilityProvider,KeyStoreCapabilitySet,KeyStoreCapability,KeyStoreOperationKeyStoreNamespaceId,KeyInstanceId,KeyOperationId;StoredKeyInfogains an optionalInstanceIdKeyMutationResult/KeyDeleteResult, and theKeyMutationOutcomereceipt taxonomySignAsync(KeySignRequest),SignBbsAsync,DeriveSharedSecretAsync(KeyAgreementRequest),KeyStoreAlgorithmsTransferableKeyMaterial— no private-key read, format, or export surfaceKeyStoreException/KeyStoreErrorwithRetryAfterCapableInMemoryKeyStoreover a sharedInMemoryKeyStoreBackend, a sibling of the untouchedInMemoryKeyStoreTwo design notes worth a reviewer's attention. The algorithm identifier binds the observable encoding (
es256-dervses256-p1363), which is what finally makes the existingEcdsaSignatureFormatdistinction reachable for a key that never leaves its store. And the backend is a separate object because the two properties it underwrites are only meaningful across instances: namespace isolation is a claim about two stores over one backend, and receipt durability is a claim about a receipt outliving the store that wrote it.What the gates found
Both AGENTS.md gates ran before this was declared done, against a first implementation that looked correct. They found four integrity gaps — all fixed here, each with a regression test proven genuine by reverting the guard and confirming the test fails at its intended assertion:
ICryptoProvidercould return a well-formed signature made under a different key — or, for DER, any bytes at all. Now verified through an internalDefaultCryptoProvider, not the injected one, so a provider cannot both forge a signature and bless it. (NFR-6.2; generalizes whatKeyStoreSigneralready does on the recoverable path.)Encoding.UTF8uses replacement fallback, so"\ud800","\udc00"andU+FFFDall encode toEF BF BD. Two different requests under one operation id were judged an honest replay: the second gotReplayed = trueand a receipt naming an alias the caller never asked for. Now rejected at the boundary with a parameter name; well-formed surrogate pairs still work.StoredKeyInfo.PublicKey— the identity downstream DID/VC code publishes — was attacker-chosen, and a 1-byte "P-256 key pair" could cross the custody boundary and be published as real.Monitoris reentrant, so a provider callback walked straight through the backend lock; a nested delete then zeroized the pinned buffer an in-flight private-key borrow was still reading.Plus a BBS bound that was measured on the enumerator and enforced on the indexer,
withexpressions bypassing every constructor check (validation moved onto theinitaccessors), and several error-attribution fixes. Full triage — including the findings I argued down with reasoning, and the long list of attacks that found nothing — is intasks/todo20260812-issue26-capable-keystore.md.This also fixes a pre-existing NFR-3 leak in
InMemoryKeyStore.DeriveSharedSecretAsync: an off-curve or low-order peer point surfaced as a platformCryptographicExceptioninstead of the parameter-namedArgumentExceptionits contract promised. Invalid-input behavior only.Verification
The 5 failures are
BbsUnavailableTests, which assert BBS is absent and fail on any machine where the native library is built. Verified pre-existing — the same 5 fail on the base commit with this change stashed, and all 5 pass on the no-native leg they were written for.Release state
Directory.Build.props→ 1.6.0, CHANGELOG[1.6.0]closed with its compare link, andPublicAPI.Unshipped.txt(215 entries) promoted intoPublicAPI.Shipped.txtper the PRD §8 per-release hygiene rule.Deliberately not done: no merge, no
v1.6.0tag, no NuGet publish.release.ymlfires onv*, so the irreversible tail is yours.🤖 Generated with Claude Code