An internal review of the glue between OneCrypto's operations and keys that live elsewhere: the PKCS #11 backend (OneCrypto.Keys/Pkcs11), the gpg-agent and ssh-agent clients (OneCrypto.Keys/Agents), the CSC remote-signing client (OneCrypto.Online/Csc), the core key interfaces and two-phase signing state (OneCrypto/Keys), the OpenPGP external-key path (PgpSecretKey.FromExternalKey, PgpReadOptions.ExternalKeys, Messages/SessionKeys.cs, Messages/MessageReader.cs) and CMS use of external keys (Cms/CmsRecipients.cs). This is an internal review by an engineer who did not write the code. It is not an external audit; the library is not independently audited.
Method: the code was read against the PKCS #11 v3.1 text, the Assuan manual, the ssh-agent draft, the CSC API v2.2 and RFCs 7748, 8017 and 9580; hostile peers were built as test doubles (a native-callable function table that plays a module, scripted keys, byte mutation of serialised states). Every finding below has a regression test; settled behaviour needed no oracle. Tests: tests/OneCrypto.Keys.Tests/HostilePkcs11ModuleTests.cs, KeyBackendGlueTests.cs, Pkcs11PgpTests.ATamperedSessionKeyPacketIsTheSameFailureThroughTheToken, tests/OneCrypto.Tests/Keys/PreparedSignatureTamperTests.cs, tests/OneCrypto.OpenPgp.Tests/KeyManagementTests.EveryTruncationAndOctetChangeOfAPgpState….
Findings
| # | Severity | Where | Finding | Spec | Test | Fix | Status |
|---|---|---|---|---|---|---|---|
| K1 | medium (signature) | Pkcs11/Pkcs11Keys.cs Pkcs11RsaKey.SignDigest, Pkcs11EcKey.SignDigest, Pkcs11EdKey.Sign | A token's signature was returned as it came. Nothing between the token and the certificate, CMS or OpenPGP packet checked it, so a faulty token (an RSA-CRT fault gives a bad signature that can also leak the key), a hostile module, or a public key that is not the token key's put a signature that does not verify into the output. The agents and CSC already check theirs. Only ECDSA length was checked. | RFC 8017 §8.1.2 note on fault attacks; PKCS #11 v3.1 §5.13 | KeyBackendGlueTests.ATokenSignatureThatDoesNotVerifyIsNeverReleased, ATokenEcdsaSignatureIsCheckedInBothEncodings | Pkcs11Key.Checked verifies every signature against PublicKey before release (a platform that cannot check the scheme at all refuses it unless the session opts out: F2). | Fixed. |
| K2 | high (memory safety) | Pkcs11/Raw.cs Find | C_FindObjects writes into a 64-entry stackalloc buffer and reports how many it found. The count was trusted: a module answering 1000 made the loop read 936 slots past the buffer. A module answering a full batch forever grew the result list without bound. | PKCS #11 v3.1 §5.15.3 (at most the count asked for) | HostilePkcs11ModuleTests.AFindResultLargerThanTheBatchIsRefusedBeforeItIsRead, AModuleThatNeverStopsFindingIsStopped | A count above the batch is a typed Pkcs11Exception; the result is capped at 4096 objects. | Fixed. |
| K3 | medium (DoS, untyped errors) | Pkcs11/Raw.cs GetAttribute, GetSlotList, GetMechanismList, Finish, DeriveEcdh | Lengths a module chooses were used as allocation sizes and slice lengths: an attribute of 2 GB − 1 allocated 2 GB, one of 2^32 + n wrapped to a small or negative size; a slot or mechanism count of 10^9 allocated 8 GB; on the second call a count larger than the array threw ArgumentOutOfRangeException; C_Sign/C_Decrypt reporting more octets than the buffer threw the same; a module answering CKR_BUFFER_TOO_SMALL with ever larger sizes was followed 1 M times; the ECDH secret read back was not checked against the field size. | PKCS #11 v3.1 §5.2 | HostilePkcs11ModuleTests (the remaining five cases) | Attributes ≤ 1 MiB, lists ≤ 65 536 entries and never above what was allocated, output length ≤ buffer, one BUFFER_TOO_SMALL retry, derived secret exactly the field size. All typed Pkcs11Exception. | Fixed. |
| K4 | medium (misreport, oracle shape) | OpenPgp/Messages/SessionKeys.cs Decrypt; Cms/CmsRecipients.cs KeyAdapter; Pkcs11/Pkcs11Keys.cs Pkcs11EcKey.Agree | Every CryptographicException from an external key was folded into "this key cannot open the packet", so a locked token (Pkcs11PinException), a removed token or an unsupported mechanism ended as "cannot decrypt": the message was blamed for the key's trouble. The other half: a bad ephemeral point made a token refuse in C_DeriveKey with a code that surfaced as a base Pkcs11Exception, not the plain exception RSA decryption raises, so the two paths did not fail alike. | RFC 9580 §13.5; RFC 8017 §7.2.2 | KeyBackendGlueTests.EveryWayAnExternalRsaKeyRefusesACiphertextIsTheSameFailureAsABadBody, ALockedTokenIsNotReportedAsAnUndecryptableMessage; Pkcs11PgpTests.ATamperedSessionKeyPacketIsTheSameFailureThroughTheToken | The contract is written on IDecryptionKey/IAgreementKey: a ciphertext the key could not use is exactly CryptographicException; anything else is the key's own trouble. OpenPGP and CMS fold the former and let the latter through. PKCS #11 ECDH maps every refusal that is not about the token, PIN or key to the plain exception. | Fixed. |
| K5 | low (suspect (a)) | SessionKeys.ExternalAgree; CmsRecipients.KeyAdapter.DeriveAgreementSecret | The in-memory X25519/X448 path refuses an all-zero shared secret; an external agreement key was believed. gpg-agent's Montgomery key does refuse it, and IAgreementKey says implementations must, but a third-party key need not. No oracle or plaintext follows (the KEK is then known to the sender, who could encrypt to the public key anyway), so low. | RFC 7748 §6.1; RFC 8418 §3 | KeyBackendGlueTests.AnAllZeroX25519ResultFromAnExternalKeyIsRefused, …X448… | Both callers refuse an all-zero result themselves. | Fixed. |
| K6 | low | Pkcs11Keys.cs Pkcs11RsaKey.Decrypt | A decryption result as long as the modulus or longer was returned; no RSAES result can be (the message is at most k − 11 / k − 2hLen − 2 octets). | RFC 8017 §7.1.2, §7.2.2 | SoftHSM2 decrypt tests keep passing; no direct test (a hostile module that answers a modulus-sized plaintext is not scripted) | Result ≥ k is the plain failure. | Fixed. |
| K7 | low | Keys/PreparedSignature.cs TryDecode, Keys/SignatureSpec.cs Decode | A stored state with a public-key bit string that claimed unused bits, or a spec naming a hash outside SHA-1/2, was accepted: two byte strings for one state, and the first octet flip tried by the test survived. | docs/keys.md (one encoding) | PreparedSignatureTamperTests.EveryTruncationAndOctetChangeIsATypedResult | A state must re-encode to the octets read (the key from its parts), and the hash name is one of the four. | Fixed. |
| K8 | low | OpenPgp/KeyManagement/PgpPreparedSignature.cs Complete | A stored state whose signed header was damaged still decoded (its parts fit each other) and then raised OpenPgpParseException from Complete, not the documented PgpException. | — | KeyManagementTests.EveryTruncationAndOctetChangeOfAPgpState… | The parse error becomes PgpException(MalformedMessage). | Fixed. |
Counts: signature 1 (K1), memory safety 1 (K2), DoS 1 (K3), misreport 1 (K4), low 4 (K5–K8). All fixed.
Follow-ups
- F1 (fixed)
PgpSecretKey.FromExternalKeycompared nothing with the key packet. It now refuses an external key whose algorithm, curve or key material differs from the packet (PgpFailure.KeyMismatch), before any message is processed. Tests:ExternalKeyPairingTests. - F2 (fixed) Where the platform cannot verify a scheme at all, gpg-agent, ssh-agent, PKCS #11 and CSC now refuse the signature with
AlgorithmNotSupportedExceptioninstead of releasing it unchecked.AllowUncheckedSignaturesonGpgAgentOptions,SshAgentOptions,Pkcs11SessionandCscKeyOptionsopts out, and weakens the check. Where verification is possible nothing changed. - F3 CSC authentication values (
CscAuthObject) and tokens are .NET strings: they are redacted in everyToString, error and recording, but cannot be zeroed. PKCS #11 PINs are copied into pinned buffers and zeroed. - F4 A call into a module that waits for a PIN pad holds the module-wide gate; the synchronous API has no cancellation, so other sessions of that module wait. Not documented yet.
- F5 The key interfaces do not apply
CryptoPolicy: calling a token or CSC key directly with a SHA-1 digest signs it, as an in-memory key does. Policy is enforced where the operation is:PreparedSignature.Prepare, CMSSignerPlan, OpenPGPSignatureBuilder, the X.509 builders. By design. - F6 A hostile agent's malformed reply during decryption (
AgentProtocolException) is not folded into the plain failure; it is a protocol fault, not a property of the message.
What was checked and found sound
- Assuan: command lines are one line (CR/LF refused) and ≤ 1000 octets; keygrips are validated as 40 hex digits before they are put in a command; descriptions are percent-escaped; replies are bounded (line 1100, reply total), data buffers wipe on growth, S-expressions are bounded in depth (8) and nodes (256); inquiries we cannot answer are cancelled. Tested in
HostileAgentTests(18 cases). - ssh-agent: reply length is checked before allocation (default 256 KiB), the key count is bounded by what the reply can hold, the reply type must be the expected one, the returned signature must be the algorithm asked for and is verified against the public key;
ssh-rsa(SHA-1) is never requested. - gpg-agent: signatures verified by default; a refusal that is not a cancel, bad passphrase or pinentry error becomes the plain decryption failure; agent plaintext is wiped after use; the Montgomery key refuses an all-zero result; EdDSA message size is limited with a typed error.
- CSC: the digest always goes to
credentials/authorize(the SAD is bound to it), no redirect is followed, the signature count must equal the digest count, every returned signature is normalised and verified against the credential's certificate key before release, tokens and SADs are redacted inToString, errors and recordings (CscClientTests,CscGuardTests,CscFixtureTests). - Two-phase state: versioned, purpose-labelled (TSA, OCSP, CMS and X.509 flows check the label), carries only public data (key, digest or message, spec), refuses a digest that does not match its data, a scheme that does not fit its key, and a signature from another key, over other data or in another encoding. Truncation and single-octet changes end in a typed refusal (K7, K8).
- Resources: PKCS #11 sessions close once under the module gate and unregister; key objects hold nothing to release; derived-secret objects are destroyed in a
finally; the agent clients use one connection per operation, closed on every path including cancellation (AnAgentThatNeverAnswersIsCutOffByTheOperationTimeout). - PKCS #11:
CKA_ALWAYS_AUTHENTICATEkeys are refused; PINs are converted into pinned buffers and zeroed;Pkcs11PinExceptionmessages carry the token's result code only.
Timing targets to re-run
None of the changed code handles a secret by branching on its value. For completeness, the OpenPGP session-key glue (SessionKeys.cs: the exception filter, the all-zero check over the shared secret, which is a fixed pass with a single test of the OR) is the only secret-adjacent edit; the existing session-key glue target covers it.