An internal review of every decryption path where a mistake is expensive: plaintext released before it is authenticated, error oracles, and resource exhaustion. It covers CMS (EnvelopedData, AuthEnvelopedData, EncryptedData, CompressedData and every RecipientInfo form), AES key wrap, PKCS#8 and PKCS#12 decryption, and OpenPGP message decryption (PgpMessageReader, the session-key packets, sop decrypt). 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 RFCs; adversarial inputs were built with the library's own writers, byte mutation and a few hand-assembled structures. Each finding had one committed test that failed and was skipped; the Status column says how each was fixed and which test now pins it. Tests are in tests/OneCrypto.Tests/Review/DecryptionReviewTests.cs and tests/OneCrypto.OpenPgp.Tests/Review/DecryptionReviewTests.cs. Tests without a Skip pin behaviour the review found correct. The review itself changed no source; the fixes came after (Status column). No oracle was needed: every open question was settled by a crafted input against this library (no behaviour came up that the specifications leave open), so no openssl cms, GnuPG or Sequoia run is part of this review. The one timing check (D12, D13) is a local, single-run, indicative measurement, not a timing-test campaign.
Findings
Severity classes: plaintext-before-auth, oracle, accept-tampered, DoS, report-misleading, docs-only; one extra class, disclosure, for plaintext at rest.
| # | Severity | Where | Finding | Spec | Test | Suggested fix | Status |
|---|---|---|---|---|---|---|---|
| D1 | oracle | OpenPGP MessageProcessor.OpenEncrypted / SessionKeys.Decrypt | A PKESK whose session key cannot be recovered (RSA bad padding, wrong length or bad checksum; ECDH/X25519 bad unwrap) leaves no candidate and raises PgpFailure.NoDecryptionKey ("No supplied key or password opens the message"); a recovered key that then fails the MDC or AEAD tag raises PgpFailure.CannotDecrypt ("The encrypted data could not be decrypted"). The two differ by failure class and by message text (sop decrypt keeps exit 29 for both but prints the text). For an RSA key this is the Bleichenbacher-style oracle RFC 9580 §13 and RFC 8017 §7.2.2 tell implementations to close: the attacker learns whether a chosen ciphertext decrypted to a well-formed padded, checksummed session key. docs/openpgp.md says every decryption failure is the same CannotDecrypt; it is not. The padding/checksum mask in FromChecksummed is itself branch-free, but its verdict is then shown through the error class, and the RSA decrypt raises a platform exception on bad padding (a second, timing-only signal; on OpenSSL 3.2+ the platform rejects implicitly, on other platforms it does not). | RFC 9580 §13 (and §5.1.3), RFC 8017 §7.2.2, RFC 3218 §2.3 | OpenPgp BadSessionKeyAndBadBodyAreTheSameFailure | When a PKESK is present and no candidate was recovered, substitute a random session key of a plausible length (the cipher octet of v3, otherwise the header's cipher), run the body check, and report CannotDecrypt with one message. Keep NoDecryptionKey only when no PKESK targeted any supplied key. | Fixed. A session-key packet addressed to a supplied key that cannot be unwrapped becomes a random-key decoy and ends in the same CannotDecrypt as a bad body; docs/openpgp.md corrected. Test BadSessionKeyAndBadBodyAreTheSameFailure (un-skipped); timing target pkesk-rsa-bad-padding-vs-bad-checksum. |
| D2 | DoS | OpenPGP FromSkesk | Only PgpFailure.Unsupported is caught per SKESK. A SKESK whose Argon2 cost exceeds the limits (LimitExceeded) aborts the whole read, even when a later SKESK or PKESK is the one that would open the message, so one hostile packet in front of a good message makes it unreadable. | RFC 9580 §5.3, §3.7.1.4 (limits are the reader's) | OpenPgp AHostileSkeskDoesNotMaskAGoodOne | Catch the limit failure per SKESK, skip that packet, and report LimitExceeded only when no other candidate opened the message. | Fixed. A hostile or over-budget password packet is skipped; LimitExceeded only when nothing else opened the message. Test AHostileSkeskDoesNotMaskAGoodOne. |
| D3 | accept-tampered | OpenPGP ProcessLayer | The packet-version pairing is not enforced: a v3 PKESK or v4 SKESK in front of a v2 SEIPD opens it (the key then comes from a packet that names a cipher the SEIPD also names), and a v6 PKESK/SKESK in front of a v1 SEIPD is only refused because the cipher octet is missing. RFC 9580 forbids both pairings. | RFC 9580 §5.1.1, §5.3.1 (v4), §10.3.2.1 | OpenPgp AV3PkeskMayNotOpenAV2Seipd | Record the version of each session-key packet and refuse the pairings of §10.3.2.1's table (MalformedMessage). | Fixed. Misaligned session-key packets are discarded (RFC 9580 §10.3.2.1); MalformedMessage when none is left. Test AV3PkeskMayNotOpenAV2Seipd. |
| D4 | DoS | OpenPGP Candidates | Nothing bounds the session-key packets tried: MaxPackets is 1,048,576, and each PKESK (a wildcard key ID or an anonymous v6 recipient matches every secret key) costs one private-key operation, each SKESK one KDF run per password. 20,000 copies of one X25519 PKESK take 8 s; the same with RSA keys is minutes; with SKESKs that carry maximal iterated-and-salted S2K (65 MB hashed each) or Argon2 at the limit it is hours. | RFC 9580 §10.3, §13 | OpenPgp ManySessionKeyPacketsAreRefused | Add MaxSessionKeyPackets (suggest 64) and an aggregate KDF budget to OpenPgpLimits; LimitExceeded beyond it. | Fixed. OpenPgpLimits.MaxSessionKeyPackets (64) and MaxS2kWork. Test ManySessionKeyPacketsAreRefused. |
| D5 | disclosure | SpoolStream (SEIPD v1 plaintext and every async twin) | Above 4 MiB the decrypted SEIPD v1 plaintext (and, in the async twins, all plaintext and ciphertext) waits in onecrypto-<guid>.tmp in the temp directory, created by FileStream with the process umask: observed mode rw-rw-r-- under umask 002. FileShare.None is advisory on Unix. Another local user can read the plaintext before the MDC has even been checked. Also not zeroed on delete. | RFC 9580 §13 (confidentiality of released data) | OpenPgp SpooledPlaintextIsNotReadableByOtherUsers | Create the file with UnixCreateMode = UserRead | UserWrite (the FileStreamOptions overload) in a private directory, and overwrite before deleting where cheap. Document that the temp directory must be private on Windows. | Fixed. The spool file is created 0600 on Unix, exclusive, delete-on-close, and disposed on every exit path. Test SpooledPlaintextIsNotReadableByOtherUsers. Not overwritten before deletion. |
| D6 | report-misleading | PgpReadResult | The result of a decrypt with AllowUnauthenticatedEncryption (SED, no integrity at all), of SEIPD v2 (chunk-wise AEAD) and of SEIPD v1 (MDC) differ only in CheckedBeforeRelease, which is false for both SED and v2. A caller cannot tell "decrypted and authenticated" from "decrypted with nothing checked", and Signatures empty looks the same either way. The comment on OpenSed says the report states it; it does not. | RFC 9580 §5.7, §13 | OpenPgp TheResultTellsUnauthenticatedFromAuthenticatedDecryption | Add PgpReadResult.Protection (None, Mdc, Aead) and a reason string; document that Signatures is empty when none was present and that decryption is not verification. | Fixed. PgpReadResult.Protection. Test TheResultTellsUnauthenticatedFromAuthenticatedDecryption (now asserts the values). |
| D7 | DoS (low) | OpenPGP OpenSeipd1 | The SEIPD v1 candidate loop breaks at the first candidate that passes the two-octet quick check and never goes back if the MDC then fails. A wrong candidate passes the check 1 time in 65,536, so with several candidate keys a good key is hidden with that probability. It also makes the quick check part of the error path (RFC 9580 §13.4). | RFC 9580 §13.4 | OpenPgp ASeipd1QuickCheckFalsePositiveDoesNotHideTheRightKey | Try each quick-check survivor through the full MDC before the next candidate; or drop the quick check. | Fixed. Every quick-check survivor runs the whole body and is judged by the MDC. Test ASeipd1QuickCheckFalsePositiveDoesNotHideTheRightKey. |
| D8 | accept-tampered | CMS CompressedData.Decompress and DecompressStream | A truncated zlib stream is accepted: the inflater returns the bytes it had and no error. CMS content is a bare OCTET STRING, so there is no inner framing to notice the missing tail (OpenPGP is protected because the inner packets are framed and one-pass headers must close). | RFC 3274 §1.1, RFC 1950 §2.2 | Core ATruncatedCompressedDataStreamIsAnError | Read to the end of the zlib stream and require its Adler-32 trailer (CmsError.InvalidValue); the same in the streaming and async forms. | Fixed. The zlib stream must end with the Adler-32 of its output, in the one-shot, streaming and async forms. Test ATruncatedCompressedDataStreamIsAnError. |
| D9 | report-misleading | CMS EnvelopedData.Decrypt (default options) | CmsDecryptOptions.Policy is null by default, so no policy is asked at all; with Standard the CBC decision is a flag and the call returns a bare byte[] — the flag is not reported anywhere. The content can have been changed in any block but the last with the padding still valid. See "Decision" below. | RFC 5652 §6.3, §10.2 (CBC is unauthenticated) | Core CbcWithValidPaddingDecryptsTamperedDataUnderStandardAndFailsUnderStrict (pins today's behaviour; no failing test, the fix is an API addition) | See Decision. | Fixed (API addition). DecryptWithReport returns CmsDecryptResult (Authenticated, Reasons); Standard decrypts CBC and says so, Strict refuses. Tests TheReportSaysCbcIsUnauthenticatedAndAeadIsNot, CbcWithValidPaddingDecryptsTamperedDataUnderStandardAndFailsUnderStrict (unchanged). |
| D10 | plaintext-before-auth (by design, documented) | CMS EnvelopedData.DecryptStream and async twin | CBC cannot be checked before its last block, so the plaintext reaches the caller's stream first; only the last block is held back. docs/cms.md and the XML docs say so and say to discard on failure; the review found the statement honest. Two notes: (1) the throw comes after every block is released, so a pipeline that writes to a socket has sent it; (2) with AllowLegacyCiphers, the streaming path builds an AES decryptor for a Triple-DES message and fails with a generic DecryptionFailed (by reading the code, not run; a functional bug, not a security one). | RFC 5652 §6.3 | Core CbcStreamingDecryptReleasesEarlierBlocksBeforeThePaddingIsKnown (pins the documented behaviour) | See Decision; give the streaming API a "released N octets" counter on the exception; fix the 3DES streaming path or refuse it with Unsupported. | Documented; side finding fixed. Earlier blocks still reach the output before the padding is known (docs/cms.md). Triple DES now works through the streaming call. Tests CbcStreamingDecryptReleasesEarlierBlocksBeforeThePaddingIsKnown (unchanged), TripleDesContentDecryptsThroughTheStreamingCallToo. |
| D11 | accept-tampered | CMS CmsCiphers.ReadParameters / AuthEnvelopedData.Decrypt | GCM tags of 12–16 and CCM tags of 4–16 octets are accepted. A four-octet CCM tag is a 2^-32 forgery per attempt, and the attacker chooses the length. | RFC 5084 §3.2–3.3 allow them; RFC 5083 §2.2 leaves strength to policy | Core AuthEnvelopedDataRefusesTagsShorterThanTwelveOctets | Under Standard require at least 12 octets (CryptoPolicy rule, flagged under Standard, refused under Strict at 16); document it. | Fixed. CryptoPolicy.MinAuthTagBytes (12, Strict 16) applies on every decrypt. Test AuthEnvelopedDataRefusesTagsShorterThanTwelveOctets. |
| D12 | oracle (timing) | CMS CBC (TryDecryptCbc, the streaming FlushFinalBlock), PKCS#8 PBES2 CBC, legacy PEM | The error answer is uniform, but bad padding is detected through a platform CryptographicException, which costs more than the success path: local median 15.4 µs against 9.0 µs for a 48-octet message (ratio 1.7, indicative, one run, this host). An attacker who can submit chosen CBC messages to a decryptor and time the answer has the classic padding oracle back. PBES2 and legacy PEM use their own Unpad, whose loop bound is Math.Min(pad, block) and whose if (bad != 0) is taken on the secret padding byte; this is a smaller, local leak of the last plaintext byte. | RFC 5652 §6.3; RFC 3218 §2.3 (the same discipline applies to CBC) | Core CbcBadPaddingTakesAsLongAsGoodPadding | Decrypt with PaddingMode.None and check padding with one branch-free mask over the final block (as FromChecksummed does), then fail from one place; run the dudect-style campaign on it. | Fixed. The padding check is ours and constant time (Pkcs7Padding), in CMS (one-shot and streaming), PBES2 and legacy PEM. Test CbcBadPaddingTakesAsLongAsGoodPadding (replaced: it compares the padding step, not the exception) and CbcPaddingIsCheckedEveryWay; timing target CmsCbc/cbc-bad-padding-shape. |
| D13 | DoS | CMS RecipientOpener.OpenPassword | Each PasswordRecipientInfo may ask for MaxPbkdf2Iterations (10 million) PBKDF2 iterations and a message may carry MaxRecipients (1,024) of them, tried one after the other for a wrong password: up to about 10^10 HMAC compressions, hours. Eight recipients of 400,000 iterations take 8× one derivation here. | RFC 3211 §2.3, RFC 8018 §5.2 | Core PasswordRecipientsShareOneKeyDerivationBudget | One iteration budget per Decrypt call (e.g. MaxPbkdf2Iterations in total) in CmsLimits; LimitExceeded beyond it. | Fixed. CmsLimits.MaxPbkdf2IterationsPerMessage; over-budget recipients are skipped and the call ends in LimitExceeded. Test PasswordRecipientsShareOneKeyDerivationBudget (replaced: it sets a small budget and asserts the skip and the error). |
Counts: oracle 2 (D1, D12), accept-tampered 3 (D3, D8, D11), DoS 4 (D2, D4, D7, D13), report-misleading 2 (D6, D9), plaintext-before-auth 1 (D10, by design and documented), disclosure 1 (D5). Docs: docs/openpgp.md says every decryption failure is the same CannotDecrypt (D1 contradicts it) and does not say how a caller can tell SED output from authenticated output (D6). No finding releases unauthenticated plaintext from an integrity-protected format.
Decision: what Standard means for CMS CBC (D9, D10) — proposed
Standard's "fails unauthenticated encryption closed" cannot mean refusal for CMS CBC EnvelopedData: S/MIME relies on an outer signature over the same bytes, and refusing it would break every mail client's messages. It should mean: never silently. Proposed wording for docs/policy.md and docs/cms.md:
Under
Standard, CBC EnvelopedData and EncryptedData decrypt, and the result says so: the decrypt call returns a report whoseAuthenticatedis false and whose reasons namecipher.cbc.unauthenticated.decrypt. The plainbyte[]overload is kept for callers who have an outer signature, and its documentation says that a successful call proves nothing about the content.Strictrefuses CBC. The streaming call reports the same through an exception that carries the number of octets already released.
Today's behaviour is honest in the XML docs and docs/cms.md (they say CBC is unauthenticated and the stream releases earlier blocks), but the policy flag is never surfaced, and CmsDecryptOptions.Policy defaults to none, so Standard is not what a caller gets unless they ask for it.
What was checked and found correct
By question in the brief. "Tested" means a passing test in the two review files unless another file is named.
Release before authentication
- SEIPD v1: the whole body is decrypted into the spool and the MDC (SHA-1 over everything but the 22-octet trailer, plus the trailer's two octets) is compared with
FixedTimeEqualsbefore the spool is rewound; nothing reaches the output earlier, on the sync path, the async twins (output is spooled too) andsop decrypt. Tampering, truncation and a missing or wrong MDC release zero octets (MessageTests.Seipd1MdcAndTruncationFailClosed,TamperedSeipd1ReleasesNothing). The library holds at most the spool's 4 MiB threshold plus pooled scratch in memory and puts the rest in the temporary file, so the 108 MB peak of the 1 GiB v1 decrypt is streaming through a file, not buffering the message (the peak itself was not profiled here). That is safe for authentication, with the disclosure issue of D5. - SEIPD v2 and LibrePGP OCB: a chunk is handed over only after its own tag verifies; the last, short chunk is withheld until the final tag (over the total length) verifies; truncation at the final tag, inside it, inside the last chunk's tag, and by whole chunks; swapped, duplicated and dropped chunks; one trailing octet; all fail with
PgpExceptionand are never released (AeadTruncationFailsAtEveryBoundary,AeadReorderedDuplicatedAndExtendedChunksFail,MessageTests.TamperedAeadMessagesFailClosed). The chunk index is in the nonce and in the associated data; the chunk-size octet is bounded at 16 before any buffer is allocated (ChunkSizeAboveTheSpecificationLimitIsRefusedBeforeAnyAllocation). A late failure is unmistakable to the caller:Readthrows and returns no result; but chunks that authenticated were already written (AeadReleasesVerifiedChunksBeforeALateFailureAndNeverTheBadOne), asdocs/openpgp.mdsays, andsop decrypthas by then written them to stdout before it exits with code 29. The async twins write nothing on failure. - SED: refused without
AllowUnauthenticatedEncryptionbefore any candidate or key work, whatever the key source, including a caller-supplied session key (SedNeedsTheOptInAndEveryOtherFailureIsTyped);sop decrypthas no opt-in. - AuthEnvelopedData: the AEAD is one-shot, so nothing is produced before the tag check; ciphertext, tag, and authenticated-attribute tampering and the streaming twin give
DecryptionFailedand write nothing (AuthEnvelopedDataFailsClosedOnEveryTamperingAndGivesOneError); the tag length is compared with the parameter; the content-type attribute is checked;MaxContentBytesbounds the buffer.
Error oracles
- CMS RSA PKCS#1 v1.5 (KeyTrans): a failed unwrap or a wrong-length key substitutes a random key of the right length and continues, so a bad transport and bad CBC padding end in the same exception type, code and text (
RsaPkcs1FailuresAreIndistinguishableByErrorFromBadPadding);AllowPkcs1V15KeyTransportcan turn the form off. (Residual: D12 timing, and the default stays on.) - OpenPGP session-key validation (
FromChecksummed): padding, length, algorithm octet and checksum accumulate in one mask with a fixed pass over the data; every single-octet corruption is refused (MessageTests.UnwrappedSessionKeysAreValidatedWholly). The class-level leak is D1. - AES-KW (
AesKeyWrap.TryUnwrap): the integrity value is compared withFixedTimeEquals, the key data is zeroed on failure, and lengths are public; the CMS and OpenPGP callers treat failure as "no candidate" without an exception. - Multiple recipients: in CMS each RecipientInfo is tried in its own
try, so a malformed one does not mask a good one (RecipientsThatCannotBeReadDoNotMaskAGoodOne);matchedreveals only whether an identifier matches (public). In OpenPGP the masking cases are D2 and D7. - OpenPGP quick check: used only as a candidate filter for SEIPD v1, with the same error as every other failure (D7 is its one flaw); SEIPD v2 and SKESK v6 use the AEAD tag as the check.
Key agreement
- CMS ECDH/X25519/X448 (
KeyAdapter.DeriveAgreementSecret): platform point validation for NIST/Brainpool curves; X25519/X448 all-zero outputs raise and become "no key"; the KEK comes from X9.63 or HKDF with ECC-CMS-SharedInfo (wrap algorithm, UKM, KEK length) as RFC 5753 §7 / RFC 8418 §2.1 say; the wrap algorithm is read from the message but its key size must equal the KEK length asked for. ML-KEM KEMRecipientInfo: the wrap size must equalkekLength; the KDF and wrap are bound into the info (RFC 9629 §5); implicit rejection of the platform ML-KEM gives a wrong secret, which the key unwrap then rejects. - OpenPGP X25519/X448 (
DecryptNative): low-order shared secrets give no candidate; the HKDFinfostrings and key lengths match RFC 9580 §5.1.6–5.1.7 and the key material is bound into the IKM; ECDH validates the point format and curve, the KDF hash and wrap cipher are taken from the recipient's own key (not the message), and the unwrap goes through the same checks; RFC 9980 composites combine the ML-KEM and ECDH shares with the ciphertexts and public key in the KDF, require an AES cipher octet for v3 packets and pass through the same unwrap.
Resource limits (all on by default, typed errors)
- Decompression: OpenPGP ZIP/ZLIB
InflateLimitStream(1 MiB plus 1032 × compressed octets read) withMaxNesting8 (DeeplyNestedCompressionIsRefused,ZeroesCompressedToTheDeflateLimitStillPassTheRatioCeiling); CMSMaxDecompressedBytesandMaxDecompressionRatio(CompressedDataBombsAreRefusedWithATypedError). Not checked: the CMS ratio is 1000 while OpenPGP's is 1032, so data near deflate's maximum ratio is refused by CMS (a functional inconsistency, no security effect). - Partial-length chunking: the first chunk must be at least 512 octets; bodies read as typed packets are capped at
MaxPacketBodyLength(16 MiB); streamed bodies are not buffered. - S2K and Argon2 from the message: the exponent, passes and lanes are checked against
KdfLimitsbefore allocation (MessageTests.Argon2CostBombsInPasswordPacketsAreRefusedBeforeAnyWork); iterated-and-salted S2K is capped by its one-octet count; PBKDF2 in CMS and PKCS#8/#12 is capped (MaxPbkdf2Iterations,PkcsLimits.MaxIterations, scrypt throughKdfLimits). The multiplication across packets is D4 and D13. - CMS recipient count (1,024), content size (1 GiB), the small-element budget of the streaming reader (64 MiB) and the ASN.1 limits apply to every decrypting entry.
PKCS#8 / PKCS#12 (read, not exercised by new tests): PBES2 decryption derives the key under limits, checks the GCM tag or the CBC padding, and reports one WrongPasswordOrCorrupt for a wrong password, bad padding or a body that is not a PrivateKeyInfo; PKCS#12 compares the MAC with FixedTimeEquals (the order of MAC check and decryption was not traced); legacy PEM encryption (MD5 EVP_BytesToKey) is read-only. The padding check shape is D12.
Not reviewed
LibrePGP Kyber and v5-protected secret keys (not implemented); the key backends (a later review); SKESK v5 beyond reading the code; the .Pdf and .AdES paths; the PQC composite combiner's construction beyond comparing its inputs with RFC 9980 §4.2; timing campaigns (only the two indicative measurements above); interop against openssl cms, GnuPG, Sequoia and RNP for the crafted inputs (none was needed to settle a behaviour).
Follow-ups seen, outside this review
CompressedDataratio 1000 against OpenPGP's 1032;EnvelopedData.DecryptStreamwith Triple-DES (D10).OpenAeadallocates a chunk-sized window (up to 4 MiB) and one probe reader (three chunk-sized buffers) per candidate key; with many candidates that is memory churn (bounded once D4 lands).CmsDecryptOptions.AllowPkcs1V15KeyTransportdefaults to true; a stricter default would shrink the D12/D1 exposure for new integrations.sop decryptprints the library's failure text on stderr; with D1 fixed the text is uniform.