# onecrypto docs, all pages (OneCrypto 0.1.0-preview.1) Every page of https://recursiveai.org/onecrypto/docs/ and https://recursiveai.org/onecrypto/cookbook/ as markdown, getting started first. Index: https://recursiveai.org/onecrypto/llms.txt # Getting started with OneCrypto Install OneCrypto 0.1.0-preview.1 for .NET 10, load a key and certificate file with one call, and sign and verify with the key. OneCrypto is cryptography for .NET (not cryptocurrency), written from the specifications on the base class library alone. ## Before you use it - **Not audited.** This library has not been independently audited. It is a preview: the API can change between previews. - **Path validation is a preview.** The RFC 5280 certificate path validator “has had an internal review; its follow-ups are still landing, so don’t rely on it for trust decisions yet.” - **Constant time, where stated.** Timing tests cover the secret data of specific primitives; no constant-time claim is made for key handling yet, nor for Argon2id, Argon2d, scrypt or bcrypt-pbkdf. The exact list: [Conformance](https://recursiveai.org/onecrypto/docs/conformance/). ## Install The package targets `net10.0`, so you need the .NET 10 SDK, and `dotnet add package` needs `--prerelease` or the exact version. ```shell dotnet new console -o OneCryptoStart cd OneCryptoStart dotnet add package RecursiveAI.OneCrypto --version 0.1.0-preview.1 ``` One package is on NuGet: **`RecursiveAI.OneCrypto`**. It holds ASN.1 and PEM, X.509 certificates, requests, CRLs and OCSP, CMS, RFC 3161 time-stamps, PKCS#8, PKCS#12 and OpenSSH keys, `Crypto.Load`, `CryptoPolicy`, the key interfaces, the certificate authority, time-stamp authority and OCSP responder handlers, and the primitives .NET lacks: Ed25519, Ed448, X25519, X448, OCB, EAX, AES key wrap, Argon2, scrypt, bcrypt-pbkdf and Camellia. It depends on nothing but .NET. OpenPGP, AdES and PAdES signatures, age and minisign files, SSH signatures, PKCS#11 and cloud keys are separate packages that are planned, not published. ## A first program Make a certificate and key with OpenSSL and pack them into a `.pfx`, as a server or a signing service would have: ```shell openssl req -x509 -newkey ed25519 -nodes -days 30 -subj "/CN=demo" \ -keyout key.pem -out cert.pem openssl pkcs12 -export -inkey key.pem -in cert.pem -passout pass:demo -out demo.pfx ``` Put this in `Program.cs` and run `dotnet run`: ```csharp using System.Text; using RecursiveAI.OneCrypto; using RecursiveAI.OneCrypto.Keys; // One call for any key or certificate file: PEM, DER, PKCS#8, PKCS#12, OpenSSH using LoadedItems file = Crypto.Load("demo.pfx", _ => "demo"); Certificate cert = file.Certificates[0]; LoadedPrivateKey key = file.PrivateKeys[0]; Console.WriteLine($"{file.Format}: {cert.Subject}, key {key.Algorithm}"); // Sign with the private key, verify with the certificate's public key byte[] data = Encoding.UTF8.GetBytes("hello"); ISigningKey signer = key.Signing!; byte[] signature = signer.Sign(data, signer.DefaultSpec); SignatureSpec spec = signer.DefaultSpec; SignatureVerification check = spec.Verify(cert.PublicKey, data, signature); Console.WriteLine($"{spec}, {signature.Length}-byte signature: {check.Status}"); ``` ```text PKCS#12: CN=demo, key Ed25519 Ed25519, 64-byte signature: Valid ``` .NET can’t load an Ed25519 key or certificate at all; OneCrypto signs with it here. `Crypto.Load` decides the format from the content, asks the callback for a password only when something is encrypted, and returns typed lists: `Certificates`, `PrivateKeys`, `PublicKeys`, `Crls`, `SshPublicKeys` and more. Dispose the result to zero the private keys. ## Where things are - **Primitives** are in `RecursiveAI.OneCrypto.Primitives`: `Ed25519`, `X25519`, `Argon2`, `Scrypt` and the rest, as span one-shots and key objects. - **Formats** are in the root namespace and `RecursiveAI.OneCrypto.Pkcs`: `Certificate`, `SignedData`, `EnvelopedData`, `AuthEnvelopedData`, `PrivateKeyInfo`, `Pkcs12File`. - **Keys** are in `RecursiveAI.OneCrypto.Keys`: `ISigningKey`, `IDecryptionKey`, `IAgreementKey`, `SignatureSpec`, `PrivateKey`. - **Which algorithms work where:** .NET’s own cryptography is used first; the library’s own code fills only the gaps listed in [Platform availability](https://recursiveai.org/onecrypto/docs/availability/). Anything else throws `AlgorithmNotSupportedException` naming the algorithm and platform. ## Next - [Cookbook](https://recursiveai.org/onecrypto/cookbook/): Ed25519, X25519, Argon2, PEM, PKCS#12, SSH keys and CMS, each a complete program checked against OpenSSL or ssh-keygen. - [Conformance](https://recursiveai.org/onecrypto/docs/conformance/): what is tested, against what, the speed table with its three exceptions, and the constant-time evidence. - [Changelog](https://recursiveai.org/onecrypto/docs/changelog/). # OneCrypto Cryptography (not cryptocurrency) for .NET: message formats, certificates and the primitives the platform lacks, written from the specifications on the base class library alone. OneCrypto is unaffiliated with other products named OneCrypto. **Status: preview (0.1), not yet independently audited.** The API may still change between previews. What is tested, against what, and where the library deviates is in [docs/conformance.md](https://recursiveai.org/onecrypto/docs/conformance/); behaviour is specified in [docs/](https://recursiveai.org/onecrypto/docs/docs-map/). ```csharp using RecursiveAI.OneCrypto; using LoadedItems file = Crypto.Load("server.pfx", _ => password); // any key or certificate file ``` ## Packages All packages share one version. | Package | Contents | Status | |---|---|---| | `RecursiveAI.OneCrypto` | ASN.1 and PEM, X.509/CSR/CRL/OCSP, CMS, RFC 3161, PKCS#8/#12, OpenSSH keys, `CryptoPolicy`, key interfaces with two-phase signing, CA/TSA/OCSP-responder handlers, `Crypto.Load`, the primitives the platform lacks (Ed25519/Ed448, X25519/X448, OCB, EAX, AES-KW, Argon2, scrypt, bcrypt-pbkdf, Camellia, legacy decrypt-only ciphers); path validation as a preview. Depends on nothing but the base class library | preview | | `RecursiveAI.OneCrypto.OpenPgp` | OpenPGP (RFC 9580, RFC 4880, LibrePGP) | planned | | `RecursiveAI.OneCrypto.Online` | network clients behind a pluggable, guarded transport | planned | | `RecursiveAI.OneCrypto.Keys` | PKCS#11, gpg-agent and ssh-agent key backends | planned | | `RecursiveAI.OneCrypto.AdES` | CAdES, XAdES, XML-DSig, ASiC and ETSI validation | planned | | `RecursiveAI.OneCrypto.Files` | age, minisign, signify, SSHSIG, Sigstore bundle verification | planned | | `RecursiveAI.OneCrypto.Pdf` | PAdES and PDF signature validation on OnePDF | planned | | `RecursiveAI.OneCrypto.AzureKeyVault`, `.AwsKms`, `.GoogleKms` | cloud key backends | planned | | `onecrypto` (dotnet tool) | `sop`, inspection and convenience verbs | planned | ## Building The repository pins its SDK in `global.json` (.NET 10). dotnet build dotnet test Build output goes to `$ONECRYPTO_ARTIFACTS` when that variable is set, otherwise to `artifacts/` in the checkout. Other projects: `tests/AotSmoke` (NativeAOT publish and run), `tests/WasmSmoke` (browser-wasm, needs the `wasm-tools` workload; see its `run.sh`), `fuzz/` (SharpFuzz targets under libFuzzer, see `fuzz/README.md`), `bench/` (BenchmarkDotNet). ## Clean-room rule OneCrypto is written from published specifications, standards and test vectors. Other implementations are used only as black-box programs to compare behaviour; their source is not read. Third-party test material and its terms are listed in `THIRD-PARTY-NOTICES.md`. ## Licence MIT OR Apache-2.0, at your option. See `LICENSE-MIT` and `LICENSE-APACHE`. Copyright (c) Recursive AI, LLC. # docs Normative, language-neutral notes on behaviour: verification semantics, policy presets, resource limits and the methods behind the tests and benchmarks. - [conformance.md](https://recursiveai.org/onecrypto/docs/conformance/): what is tested, against what, documented deviations, speed against the bar, constant-time evidence. - [cms.md](https://recursiveai.org/onecrypto/docs/cms/): CMS content types, verification rules, limits, failing closed, streaming and the openssl matrix. - [issuance.md](https://recursiveai.org/onecrypto/docs/issuance/): the certificate authority, its profiles, serial numbers, CRLs and delta CRLs, and two-phase issuing. - [tsp.md](https://recursiveai.org/onecrypto/docs/tsp/): RFC 3161 requests, responses and TSTInfo, the verifier's rules and the time-stamp authority handler. - [ocsp-responder.md](https://recursiveai.org/onecrypto/docs/ocsp-responder/): the OCSP responder handler, its status mapping, preproduced responses and the openssl checks. - [load.md](https://recursiveai.org/onecrypto/docs/load/): `Crypto.Load`: the formats it reads, the sniffing decision table, password rules, limits and errors. - [legacy-ciphers.md](https://recursiveai.org/onecrypto/docs/legacy-ciphers/): Camellia and the decrypt-only CAST5, IDEA, Blowfish and Twofish; how they avoid secret-indexed tables, their cost, and how they are checked. - [kdf.md](https://recursiveai.org/onecrypto/docs/kdf/): Argon2, scrypt and bcrypt-pbkdf: behaviour, limits, side channels, tests. - [keys.md](https://recursiveai.org/onecrypto/docs/keys/): key interfaces, signature specs, operating-system keys and two-phase signing (with the serialised state). - [policy.md](https://recursiveai.org/onecrypto/docs/policy/): `CryptoPolicy`, the reference-time contract, the dated rules and the presets. # Platform availability What each algorithm resolves to on each platform. The library asks the platform's cryptography first. Where the platform lacks an algorithm and the library has a narrow managed fallback, the call succeeds through the fallback. Otherwise it throws `AlgorithmNotSupportedException`, which names the algorithm, the platform (operating system, architecture, runtime, and the provider fact that decided it, such as the loaded OpenSSL version) and the reason. **Provided by** - **platform**: the .NET base class library, backed by the operating system's cryptography. - **managed fallback**: the library's own code, used because the platform has no implementation. - **not supported**: the platform has none and the library has no fallback. ## Which fallbacks exist, and why only those - **SHA-3 and SHAKE** (FIPS 202) and **ChaCha20-Poly1305** (RFC 8439): constant time by construction. The platform has no SHA3-224 anywhere, so SHA3-224 is always the managed Keccak. - **RSA verification** (PKCS#1 v1.5 and PSS, RFC 8017 section 8) and **ECDSA verification** (P-256, P-384, P-521 and the brainpoolP256r1, P384r1, P512r1 curves of RFC 5639; FIPS 186-5 section 6.4.2): public-key operations on public data only. The managed verifier is also used for RSA-PSS parameter sets the platform cannot express (a salt length other than the digest size, or a mask-function hash that differs from the message hash) and for PKCS#1 signatures over SHA-3 digests. - **No fallback ever** for signing, decryption, key agreement or key generation of RSA, ECDSA or ECDH, for AES, and for ML-KEM, ML-DSA and SLH-DSA. These report `AlgorithmNotSupportedException` where the platform lacks them. The browser has hashes and HMAC only: parsing, inspecting and verifying work there through the fallbacks, and everything else reports `AlgorithmNotSupportedException`. ## Keeping the table honest Each section below is generated from the dispatch code by the test `AvailabilityTests`, which fails when the section for the platform it runs on differs from what the code reports. To update a section after a deliberate change, run that test on the platform with `ONECRYPTO_AVAILABILITY_UPDATE=1`. ## Platforms ### linux-x64-openssl-3.0 | Algorithm | Provided by | Note | | --- | --- | --- | | SHA3-224 | managed fallback | the platform has no SHA3-224 at all | | SHA3-256 | platform | | | SHA3-384 | platform | | | SHA3-512 | platform | | | SHAKE128 | platform | | | SHAKE256 | platform | | | ChaCha20-Poly1305 | platform | | | RSA verify (PKCS#1 v1.5, PSS) | platform | verify-only managed fallback where the platform cannot | | ECDSA verify P-256, P-384, P-521 | platform | verify-only managed fallback where the platform cannot | | ECDSA verify brainpoolP256r1 | platform | verify-only managed fallback where the platform cannot | | ECDSA verify brainpoolP384r1 | platform | verify-only managed fallback where the platform cannot | | ECDSA verify brainpoolP512r1 | platform | verify-only managed fallback where the platform cannot | | AES (ECB, CBC, GCM), and OCB, EAX and key wrap over it | platform | | | AES-GCM (CMS AuthEnvelopedData, PKCS #8 and OpenSSH keys) | platform | | | AES-CCM (CMS AuthEnvelopedData when asked for) | platform | | | RSA sign, decrypt | platform | no fallback: private-key operations are never done in managed code | | ECDSA, ECDH sign and agree | platform | no fallback: private-key operations are never done in managed code | | ML-KEM | not supported | no fallback | | ML-DSA | not supported | no fallback | | SLH-DSA | not supported | no fallback; the platform's API is a preview | ### linux-x64-openssl-3.5 | Algorithm | Provided by | Note | | --- | --- | --- | | SHA3-224 | managed fallback | the platform has no SHA3-224 at all | | SHA3-256 | platform | | | SHA3-384 | platform | | | SHA3-512 | platform | | | SHAKE128 | platform | | | SHAKE256 | platform | | | ChaCha20-Poly1305 | platform | | | RSA verify (PKCS#1 v1.5, PSS) | platform | verify-only managed fallback where the platform cannot | | ECDSA verify P-256, P-384, P-521 | platform | verify-only managed fallback where the platform cannot | | ECDSA verify brainpoolP256r1 | platform | verify-only managed fallback where the platform cannot | | ECDSA verify brainpoolP384r1 | platform | verify-only managed fallback where the platform cannot | | ECDSA verify brainpoolP512r1 | platform | verify-only managed fallback where the platform cannot | | AES (ECB, CBC, GCM), and OCB, EAX and key wrap over it | platform | | | AES-GCM (CMS AuthEnvelopedData, PKCS #8 and OpenSSH keys) | platform | | | AES-CCM (CMS AuthEnvelopedData when asked for) | platform | | | RSA sign, decrypt | platform | no fallback: private-key operations are never done in managed code | | ECDSA, ECDH sign and agree | platform | no fallback: private-key operations are never done in managed code | | ML-KEM | platform | no fallback | | ML-DSA | platform | no fallback | | SLH-DSA | platform | no fallback; the platform's API is a preview | ### osx-arm64 | Algorithm | Provided by | Note | | --- | --- | --- | | SHA3-224 | managed fallback | the platform has no SHA3-224 at all | | SHA3-256 | managed fallback | | | SHA3-384 | managed fallback | | | SHA3-512 | managed fallback | | | SHAKE128 | managed fallback | | | SHAKE256 | managed fallback | | | ChaCha20-Poly1305 | platform | | | RSA verify (PKCS#1 v1.5, PSS) | platform | verify-only managed fallback where the platform cannot | | ECDSA verify P-256, P-384, P-521 | platform | verify-only managed fallback where the platform cannot | | ECDSA verify brainpoolP256r1 | managed fallback | verify-only managed fallback where the platform cannot | | ECDSA verify brainpoolP384r1 | managed fallback | verify-only managed fallback where the platform cannot | | ECDSA verify brainpoolP512r1 | managed fallback | verify-only managed fallback where the platform cannot | | AES (ECB, CBC, GCM), and OCB, EAX and key wrap over it | platform | | | AES-GCM (CMS AuthEnvelopedData, PKCS #8 and OpenSSH keys) | platform | | | AES-CCM (CMS AuthEnvelopedData when asked for) | not supported | | | RSA sign, decrypt | platform | no fallback: private-key operations are never done in managed code | | ECDSA, ECDH sign and agree | platform | no fallback: private-key operations are never done in managed code | | ML-KEM | not supported | no fallback | | ML-DSA | not supported | no fallback | | SLH-DSA | not supported | no fallback; the platform's API is a preview | ### browser-wasm | Algorithm | Provided by | Note | | --- | --- | --- | | SHA3-224 | managed fallback | the platform has no SHA3-224 at all | | SHA3-256 | managed fallback | | | SHA3-384 | managed fallback | | | SHA3-512 | managed fallback | | | SHAKE128 | managed fallback | | | SHAKE256 | managed fallback | | | ChaCha20-Poly1305 | managed fallback | | | RSA verify (PKCS#1 v1.5, PSS) | managed fallback | verify-only managed fallback where the platform cannot | | ECDSA verify P-256, P-384, P-521 | managed fallback | verify-only managed fallback where the platform cannot | | ECDSA verify brainpoolP256r1 | managed fallback | verify-only managed fallback where the platform cannot | | ECDSA verify brainpoolP384r1 | managed fallback | verify-only managed fallback where the platform cannot | | ECDSA verify brainpoolP512r1 | managed fallback | verify-only managed fallback where the platform cannot | | AES (ECB, CBC, GCM), and OCB, EAX and key wrap over it | not supported | | | AES-GCM (CMS AuthEnvelopedData, PKCS #8 and OpenSSH keys) | not supported | | | AES-CCM (CMS AuthEnvelopedData when asked for) | not supported | | | RSA sign, decrypt | not supported | no fallback: private-key operations are never done in managed code | | ECDSA, ECDH sign and agree | not supported | no fallback: private-key operations are never done in managed code | | ML-KEM | not supported | no fallback | | ML-DSA | not supported | no fallback | | SLH-DSA | not supported | no fallback; the platform's API is a preview | # Loading key and certificate files: `Crypto.Load` Behaviour of `RecursiveAI.OneCrypto.Crypto.Load`, `LoadAsync` and `TryLoad`. The loader only decides which parser to call and gathers what the parsers return; every format is read by the type that owns it (`Certificate`, `PrivateKeyInfo`, `Pkcs12File`, `SshPrivateKey` and so on), so a rule written elsewhere (`pkcs.md`, `x509.md`, `asn1.md`) applies unchanged. ## Result `LoadedItems`, in file order, with typed lists: `Certificates`, `Crls`, `CertificateRequests`, `OcspResponses`, `PublicKeys` (`PublicKeyInfo`), `PrivateKeys` (`LoadedPrivateKey`), `SshPublicKeys`, `SshCertificates`; `Format` names the container that was detected and `Count` is the total. Dispose it to zero the private keys. A `LoadedPrivateKey` carries `Signing` (`ISigningKey`), `Decryption` (`IDecryptionKey`) and `Agreement` (`IAgreementKey`) as the algorithm allows, the `PublicKey`, the pinned PKCS#8 `Info`, and `Comment`, `FriendlyName`, `Certificate` (PKCS#12 `localKeyId` match) and `SshPublicKey` where the container has them. | Algorithm | `Signing` | `Decryption` | `Agreement` | |---|---|---|---| | RSA (also RSASSA-PSS / RSAES-OAEP keys) | yes | yes (one `IRsaKey`) | | | EC P-256/384/521 | ECDSA | | ECDH | | Ed25519, Ed448, ML-DSA | yes | | | | X25519, X448 | | | yes | | ML-KEM | | yes | | | DSA and others | none; `Info` still holds the key | | | Platform-dependent algorithms (ML-DSA, ML-KEM, Brainpool, ...) that the platform lacks give `CryptoLoadError.Unsupported`. ## The decision table Sniffing reads the first byte, the PEM labels and the outline of the ASN.1 structure. It picks exactly one parser per item and calls it. It never calls several and keeps the one that did not throw. 1. Empty or white space only: `Empty`. A UTF-8 byte order mark is skipped. 2. First byte `0x30` (no leading white space): binary ASN.1, DER or BER. Trailing white space and zero bytes are allowed; other trailing data is `Malformed` (one file holds one element). Text that happens to start with the character `0` is therefore read as binary; real files do not. 3. Starts with `openssh-key-v1\0`: the binary OpenSSH private key form. 4. First four bytes a small big-endian length, then `ssh-`, `ecdsa-sha2-` or `sk-`: an OpenSSH public key or certificate wire blob (RFC 4253 §6.6). 5. Otherwise text. If it holds a `-----BEGIN ` line: PEM (RFC 7468, lax mode: any line length and whitespace, `Proc-Type`/`DEK-Info` headers), every block read in order, text outside blocks ignored. Else if it holds `---- BEGIN SSH2 PUBLIC KEY ----`: RFC 4716. Else each non-blank, non-`#` line is an `authorized_keys` line (options allowed), a certificate when it contains `-cert-v01@openssh.com`. A first line that is not a key is `Unrecognized`; a later bad line is `Malformed` and names its line. ### PEM labels | Label | Read as | |---|---| | `CERTIFICATE`, `X509 CERTIFICATE` | `Certificate` | | `TRUSTED CERTIFICATE` | `Certificate` (the first element; OpenSSL's trust settings after it are not read) | | `X509 CRL` | `CertificateRevocationList` | | `CERTIFICATE REQUEST`, `NEW CERTIFICATE REQUEST` | `CertificateSigningRequest` | | `OCSP RESPONSE` | `OcspResponse` | | `PUBLIC KEY` | `PublicKeyInfo` | | `RSA PUBLIC KEY` | PKCS#1 `RSAPublicKey` (RFC 8017 A.1.1), wrapped as `SubjectPublicKeyInfo` (RFC 3279 §2.3.1) | | `PRIVATE KEY`, `ENCRYPTED PRIVATE KEY` | PKCS#8 (RFC 5958), PBES2 and the PKCS#12 PBEs | | `RSA PRIVATE KEY`, `EC PRIVATE KEY`, `DSA PRIVATE KEY` | PKCS#1 / SEC 1 (RFC 5915) / DSA, with the legacy `DEK-Info` encryption | | `OPENSSH PRIVATE KEY` | `SshPrivateKey` | | `PKCS7`, `CMS` | SignedData certificate bag | | `EC PARAMETERS`, `DH PARAMETERS`, `DSA PARAMETERS`, `X9.42 DH PARAMETERS`, `PARAMETERS` | skipped (OpenSSL writes them beside keys) | | `ATTRIBUTE CERTIFICATE` | `Unsupported`; use `AttributeCertificate.Parse` | | `PGP ...` and any other label | `Unrecognized`; OpenPGP armor is read by the OpenPGP package, not here | ### DER outline rules With X the outer SEQUENCE and c0, c1, ... its children, in order: 1. c0 is a SEQUENCE and X has exactly three children, the last a BIT STRING, c1 a SEQUENCE: a signed structure, told by the first child t0 of c0 (RFC 5280 §4.1.1, §5.1.1): * t0 is `[0]`: `Certificate`. * t0 is a SEQUENCE: a v1 `CertificateRevocationList`. * t0 is an INTEGER and the next element is a SEQUENCE whose first child is an OID: the element after the issuer name is a SEQUENCE (validity) for a v1 `Certificate`, a time for a v2 `CertificateRevocationList`. * t0 is an INTEGER and the next element is a SEQUENCE whose first child is a SET or that is empty: `CertificateSigningRequest` (RFC 2986 §4.1). * t0 is an INTEGER and the next element is a SEQUENCE with a context-tagged first child: an attribute certificate (`Unsupported`). 2. c0 is an ENUMERATED and X has at most two children: `OcspResponse` (RFC 6960 §4.2.1). 3. c0 is an INTEGER: * c1 is a SEQUENCE whose second child is `[0]`: PKCS#12 `Pfx` (RFC 7292 §4). A `PrivateKeyInfo` algorithm identifier never has a context-tagged second child. * c1 a SEQUENCE and c2 an OCTET STRING: PKCS#8 `PrivateKeyInfo`. * c1 an OCTET STRING: SEC 1 `ECPrivateKey`. * c1 an INTEGER: two children are a PKCS#1 `RSAPublicKey`, nine or more an `RSAPrivateKey`, six a DSA private key. 4. c0 is a SEQUENCE and X has two children: a BIT STRING second is a `SubjectPublicKeyInfo`, an OCTET STRING second is an `EncryptedPrivateKeyInfo`. 5. c0 is an OID and c1 is `[0]`: a CMS `ContentInfo`. Only SignedData with no signers and no content (a `.p7b`/`.p7c` certificate bag) is loaded; any other content type, or a SignedData that carries signatures or content, is `Unsupported` and points at the CMS API. 6. Anything else is `Unrecognized`, with the outline in the reason. BER with indefinite lengths is read in the outline and by the parsers (PKCS#12 and PKCS#7 files from some tools use it). ## Passwords The callback receives a short description of the encrypted item (for example `PEM block 2 (ENCRYPTED PRIVATE KEY)`, `OpenSSH private key ssh-ed25519 SHA256:...`) and returns the password, or null. * It is called at most once per encrypted item. * A password that opened an earlier item is tried on later ones first, so one passphrase for a whole bundle asks once. * A PKCS#12 file for which no password is supplied (no callback, or the callback returns null) is tried with the empty password, both of its encodings (`pkcs.md`). A supplied password is never replaced by the empty one. * No callback, or null returned: `PasswordRequired`. A password that does not open the item: `WrongPasswordOrCorrupt` (a wrong password and damage cannot be told apart). * Any failure fails the whole load; nothing from the file is returned, and keys already made are disposed. * Passwords are managed strings; the library cannot erase them. A PKCS#12 file without a MAC is refused (`Unsupported`), as in `pkcs.md`. ## Limits On by default and taken from `PkcsLimits.Default`: input 64 MiB, ASN.1 depth 24, PBKDF2/PKCS#12 iterations 5,000,000, scrypt and bcrypt costs as in `pkcs.md`, 4096 PKCS#12 bags. An input over a limit is `LimitExceeded` before the expensive work. The loader has no option to change them. ## Errors Bad input raises only `CryptoLoadException` (`Error`: `Empty`, `Unrecognized`, `Malformed`, `Unsupported`, `PasswordRequired`, `WrongPasswordOrCorrupt`, `LimitExceeded`), whose `Considered` lists the formats looked at, in table order, each with the reason it did not apply or what was wrong. `TryLoad` returns it. I/O errors reading a stream or path propagate. A mutation test over the generated corpus and a fuzz target (`load`) check that nothing else escapes. ## Not loaded here OpenPGP keys and messages (armor labels `PGP ...` are reported as unrecognised, with that reason; the OpenPGP package will have its own loader), age, minisign and Sigstore files, attribute certificates, CMS content other than certificate bags, network sources. There is no extension registry: the table is static code, which keeps the loader trim- and AOT-safe. ## How it is checked `CryptoLoadTests` generates the corpus when it runs: OpenSSL (keys of every type in PKCS#8 PEM/DER, encrypted PKCS#8 with PBKDF2 and scrypt, traditional and `DEK-Info` forms, SubjectPublicKeyInfo and PKCS#1 public keys, certificates with and without surrounding text, requests, a CRL and an OCSP response, `.p7b`, PKCS#12 in default, 3DES/SHA-1 and empty-password forms), `ssh-keygen` (RSA, ECDSA and Ed25519 keys with and without passphrase, public keys, RFC 4716, certificates, `authorized_keys`) and the platform's exporters. Each file must load as the right type with the right algorithm and public key, and every private key must sign and verify, decrypt or agree. A mutation test then loads damaged copies of every file and accepts only `CryptoLoadException`. # Keys, signature specs and two-phase signing Behaviour of the key interfaces (`RecursiveAI.OneCrypto.Keys`). A key is something that *does* an operation; the library never asks for key bytes. ## Interfaces | Interface | Operations | Backends in the core package | |---|---|---| | `IKey` | `PublicKey` (`PublicKeyInfo`), `Algorithm`, `Capabilities`, `Dispose` | all | | `ISigningKey` | `Sign(data, spec)`, `SignDigest(digest, spec)`, `Supports(spec)`, `DefaultSpec`, async twins | RSA, ECDSA, ML-DSA (platform), Ed25519, Ed448 (library) | | `IDecryptionKey` | `Decrypt(ciphertext, spec)` (RSA PKCS #1 v1.5, RSA-OAEP, ML-KEM decapsulation), `Supports(spec)`, async twin | RSA, ML-KEM (platform) | | `IAgreementKey` | `Agree(peerPublicKey)`, async twin | ECDH (platform), X25519, X448 (library) | | `IRsaKey` | `ISigningKey` and `IDecryptionKey` | RSA | - The async methods are default interface members that call the sync ones; token, agent and remote backends override both. - Each async method takes a `CancellationToken`, checked before work starts. A backend that talks to a device or service passes it on. - `PrivateKey.From…(key, ownsKey: false)` wraps a BCL key (`RSA`, `ECDsa`, `ECDiffieHellman`, `MLDsa`, `MLKem`) or one of the library's `Ed25519PrivateKey`, `Ed448PrivateKey`, `X25519PrivateKey`, `X448PrivateKey`. With `ownsKey: true` disposing the wrapper disposes the wrapped key; otherwise the caller keeps ownership and must keep the key alive while the wrapper is used. BCL keys stay in their handles: nothing exports key bytes. The library's own keys live in pinned buffers that are zeroed on dispose. A garbage-collected runtime cannot promise that no other copy of a secret was ever made. - **Operating-system keys.** `PrivateKey.FromCertificate(X509Certificate2)` takes the private key from a certificate the platform holds (a PFX loaded with its key, the user's certificate store or keychain, CNG on Windows) through `GetRSAPrivateKey()` and `GetECDsaPrivateKey()`. The returned key owns its handle. The platform's own PIN or consent prompts, if any, pass through to the user; the library adds none. `PrivateKey.TryFromStore(certificate, …)` looks the certificate up by comparing its encoding in a store (default `My`, `CurrentUser`), read-only. ML-DSA keys from the store are not offered: the platform accessor for them is still experimental. - Disposed wrappers throw `ObjectDisposedException`. Wrappers are safe to use from several threads to the extent the wrapped BCL key is. ## Signature specs `SignatureSpec` fixes everything about a signature except the key: | Kind | Hash | Signs | Output | |---|---|---|---| | `RsaPkcs1` | SHA-1, 256, 384, 512 | digest | RSASSA-PKCS1-v1_5 (RFC 8017 §8.2) | | `RsaPss` | same | digest | RSASSA-PSS, MGF1 on the same hash, salt = digest length (RFC 8017 §8.1). The platform signs with no other salt; verification accepts any salt and MGF1 hash named in a certificate's parameters | | `Ecdsa` | same | digest | DER `SEQUENCE { r, s }` (RFC 3279 §2.2.3) or fixed-width `r || s` (`Rfc4754`, IEEE P1363) | | `Ed25519` | – | message | 64 octets, pure EdDSA (RFC 8032 §5.1.6) | | `Ed448` | – | message + context (default empty) | 114 octets (RFC 8032 §5.2.6) | | `MlDsa` | – | message + context (default empty) | FIPS 204 pure signing; the parameter set follows the key | - `Context` is at most 255 octets. X.509 (RFC 8410 §3, RFC 9881) signs with an empty one; a spec with a context has no X.509 identifier (`ToAlgorithmIdentifier` throws `NotSupportedException`), as does a P1363 ECDSA spec. - `SignatureSpec.DefaultFor(publicKey)`: RSA gets PKCS #1 v1.5 with SHA-256 (PSS for an RSA-PSS key), ECDSA the hash that matches the curve (≤256 bits SHA-256, ≤384 SHA-384, otherwise SHA-512), the others their only form. - `spec.Verify(publicKey, data, signature)` checks a signature with the platform's public-key operations, the verify-only fallbacks and the library's curves, and returns a `SignatureVerification` (status, reason, RFC reference). It never throws for hostile input. A P1363 signature of the wrong length for the curve is `Invalid`. DER ECDSA signatures must be strict DER. - SHA-224 and SHA-3 are not offered for signing: the platform's signing API does not take them everywhere. They still verify in certificates (see x509.md). ## Two-phase signing `PreparedSignature` is the first half of a signature: 1. **Prepare** (`PreparedSignature.Prepare(publicKey, spec, data, policy, at, purpose)`, or `PrepareDigest` when the caller hashed a large input). The policy is asked first (`CheckSignature` for new data at `at`); a rejection throws `PolicyViolationException` with the decision report. The state records the spec, the public key, what must be signed (`ToSign`: the digest for RSA and ECDSA, the message otherwise), the data (when known) and a purpose label. 2. **Serialise** with `Encode()`; the bytes can travel, wait, and be read again by `Decode` / `TryDecode` in any process. 3. **Sign anywhere.** Whoever holds the key signs `ToSign` under `Spec` (`SignDigest` or `Sign` on an `ISigningKey`, an HSM, a remote service) and returns the raw signature. 4. **Complete** with `Complete(signature)`: the signature is verified against the recorded public key and data, and returned; a signature that does not verify (wrong key, other data, flipped bit, wrong encoding) throws `InvalidSignatureException` carrying the `SignatureVerification`. `TryComplete` returns the result instead. A digest-only state verifies the digest. `SignWith(ISigningKey)` runs 3 and 4 in one process; it refuses a key whose public key is not the recorded one. ### Encoding (version 1) DER, no reflection serialiser: ``` PreparedSignature ::= SEQUENCE { version INTEGER, -- 1 purpose UTF8String, -- "raw", "x509.certificate", "x509.request", "x509.crl" spec SEQUENCE { kind INTEGER, hash UTF8String, format INTEGER, context OCTET STRING }, publicKey SubjectPublicKeyInfo, input ENUMERATED { digest(1), message(2) }, toSign OCTET STRING, data OCTET STRING -- empty after PrepareDigest } ``` `kind`: 1 RSA PKCS #1, 2 RSA-PSS, 3 ECDSA, 4 Ed25519, 5 Ed448, 6 ML-DSA. `format`: 0 DER, 1 P1363. `hash` is the .NET hash name (`SHA256`) or empty. A reader refuses an unknown version (the message names it), an input kind that does not fit the scheme, a digest of the wrong length, a `data` whose hash is not `toSign`, and any damage, without throwing from `TryDecode`. A later version changes the version number; older readers refuse it rather than guess. The state holds the data for message schemes and for `Prepare`: treat it like the document. It never contains private key material. ### X.509 `CertificateBuilder.PrepareSelfSigned / Prepare`, `CertificateSigningRequestBuilder.Prepare` and `RevocationListBuilder.Prepare` build the to-be-signed structure and return a state whose `Data` is that DER; `Complete(state, signature)` on the same builder checks the signature against the signer's key and returns the parsed object. The purpose label stops a certificate state completing a CRL. The structures have the signer's `signatureAlgorithm` (`spec.ToAlgorithmIdentifier(key)`) in both places (RFC 5280 §4.1.1.2). The one-shot builders still take an `X509Signer`: `X509Signer.FromKey(ISigningKey, SignatureSpec?)` makes one from any key interface, and `FromRsa`, `FromECDsa`, `FromMLDsa` and `FromDelegate` remain as conveniences. `Key` and `Spec` expose what it was made from. Code that signs other structures (CMS) should take an `ISigningKey` and a `SignatureSpec` and call `spec.ToAlgorithmIdentifier(key.PublicKey)` for the identifier it writes. ## Not here PKCS #11, gpg-agent, ssh-agent, CSC and the KMS adapters implement these same interfaces in their own packages. The library has no key store and no agent of its own. ## Use in CMS and X.509 CMS signers (`CmsSignerSpec.Key`), CMS recipients (`CmsDecryptionKey.FromKey`) and `X509Signer.FromKey` all sit on these interfaces. Two-phase CMS signing uses `PreparedSignature` with purpose `cms.signerinfo` (one per signer, inside `PreparedSignedData`, see `cms.md`); the OCSP request and response builders have the same `Prepare` / `Complete` pair as the certificate, request and list builders (purposes `ocsp.request`, `ocsp.response`; the certificates sent along are given again at completion). # Key containers: PKCS#8, PKCS#12 and OpenSSH keys Normative behaviour of `RecursiveAI.OneCrypto.Pkcs` and `RecursiveAI.OneCrypto.Wire.Ssh`. Clause numbers are those of the RFC or document named. ## What is read and written | Container | Reads | Writes | |---|---|---| | `PrivateKeyInfo` (RFC 5208, RFC 5958 `OneAsymmetricKey`) | v1 and v2 (public key, attributes) for any algorithm; typed access for RSA, EC, Ed25519/Ed448, X25519/X448, ML-DSA, ML-KEM | the same, from platform keys or this library's key types | | `EncryptedPrivateKeyInfo` | PBES2 (RFC 8018 §6.2) with PBKDF2 (HMAC-SHA-1/256/384/512) or scrypt (RFC 7914 §7), AES-128/192/256-CBC, AES-128/192/256-GCM (RFC 5084), 3DES-CBC; the PKCS#12 PBEs with 3DES (2- and 3-key) and RC2-40/128 | PBES2 only: AES-CBC or AES-GCM with PBKDF2 (SHA-1/256/384/512) or scrypt | | PEM private keys | `PRIVATE KEY`, `ENCRYPTED PRIVATE KEY`, `RSA PRIVATE KEY`, `EC PRIVATE KEY`, the last two also with `Proc-Type`/`DEK-Info` (AES-128/192/256-CBC, DES-EDE3-CBC) | `PRIVATE KEY`, `ENCRYPTED PRIVATE KEY` | | PKCS#12 (RFC 7292) | password-integrity MAC (HMAC with the Appendix B derivation over SHA-1/256/384/512) and PBMAC1 (RFC 9579); data and encryptedData safes; key, shrouded key, cert, CRL, secret and nested safe-contents bags; friendlyName and localKeyId | key and secret bags in a data safe, certificates and CRLs in an encrypted safe (PBES2), MAC of either kind | | OpenSSH `openssh-key-v1` (PROTOCOL.key) | RSA, ECDSA P-256/384/521, Ed25519, FIDO `sk-` keys; ciphers `none`, `aes{128,192,256}-ctr`, `aes{128,192,256}-cbc`, `aes{128,256}-gcm@openssh.com`; KDF `none` and `bcrypt` | RSA, ECDSA, Ed25519 with `none`, `aes{128,192,256}-ctr` or `aes{128,256}-gcm@openssh.com` | | OpenSSH public keys and certificates | blob, `authorized_keys` line (with options), RFC 4716 file, `*-cert-v01@openssh.com` (RSA, ECDSA, Ed25519) | blob, line, RFC 4716 | SHA-224 (as a PBKDF2 PRF, MAC hash or PKCS#12 derivation hash) is not read because the platform has no SHA-224. Not read, with a typed answer (`PkcsStatus.UnsupportedAlgorithm`, `SshError.Unsupported`): the RC4 PKCS#12 PBEs and the PKCS#5 v1.5 password schemes (obsolete; the platform has no RC4), PKCS#12 public-key integrity and privacy modes (signedData, envelopedData), `chacha20-poly1305@openssh.com` and `3des-cbc` private key files, files with more than one OpenSSH key, SLH-DSA private keys, `ssh-dss`. ## Losslessness A `PrivateKeyInfo` keeps its encoding as received in a pinned buffer and returns it unchanged. Certificates inside a PKCS#12 file keep their bytes. OpenSSH blobs and certificates keep the received bytes; the signed part of a certificate is a slice of them. ## Secrets Keys live in pinned buffers (`PrivateKeyInfo`, secret bags, decrypted safes, OpenSSH private sections) that are zeroed on `Dispose` or when parsing ends. Every decryption writes into such a buffer and releases it only when the padding, tag, MAC or check integers have verified. A wrong password and damaged data cannot be told apart and give `PkcsStatus.WrongPasswordOrCorrupt` with no plaintext. The RSA CRT exponents that OpenSSH files omit are recomputed with constant-time shift-and-subtract reduction (`SecretMod`), not `BigInteger`. A garbage-collected runtime cannot promise that no other copy of a secret exists. ## Passwords PBES2 and PBMAC1 use the password as UTF-8 (RFC 8018 §3, RFC 9579 §3). The PKCS#12 derivations use a BMPString: UTF-16BE with a terminating zero character (RFC 7292 Appendix B.1). An empty PKCS#12 password has two encodings in the wild (the terminator alone, or no bytes); when the password is empty both are tried, MAC first. Writers always use the terminator form. PEM legacy encryption derives the key with EVP_BytesToKey over MD5 with one iteration and the first eight IV bytes as salt (the `EVP_BytesToKey(3)` manual page); it is read-only. ## Limits (`PkcsLimits`, on by default) | Limit | Default | Applies to | |---|---|---| | `MaxEncodedSize` | 64 MiB | any container | | `MaxIterations` | 5,000,000 | PBKDF2 and PKCS#12-derivation iteration counts read from a file, checked before any derivation | | `MaxSaltLength` | 1024 bytes | salts read from a file | | `MaxSafes`, `MaxBags`, `MaxSafeNesting` | 256, 4096, 4 | PKCS#12 structure | | `Kdf` (`KdfLimits`) | memory 2 GiB, scrypt work 2^26, bcrypt rounds 4096 | scrypt parameters in PBES2, bcrypt rounds in OpenSSH keys | | ASN.1 | depth 24 | outer structure | An OpenSSH private key file is limited to 1 MiB (OpenSSH itself stops at 256 KiB). A parameter above its limit gives `LimitExceeded` before the expensive step runs. ## Integrity of PKCS#12 `Pkcs12File.TryOpen` verifies the MAC before decrypting anything and refuses a file with no MAC (`PkcsStatus.IntegrityMissing`) unless `requireMac` is false. A file that opens has every safe decrypted and every bag read; there is no partial result. ## Write defaults PBKDF2-HMAC-SHA-256, 600,000 iterations, AES-256-CBC, a 16-byte random salt, for shrouded keys, the certificate safe and the MAC. The MAC is the classic HMAC-SHA-256 with the PKCS#12 derivation because every reader supports it; PBMAC1 is one option away. OpenSSH keys use `aes256-ctr` and 16 bcrypt rounds, the `ssh-keygen` defaults. Triple DES and RC2 are never written. ## Experiments that settled behaviour the specifications leave open * `aes*-gcm@openssh.com` private key files authenticate the encrypted section with an empty additional-data string and a 16-byte tag directly after the section (checked against files `ssh-keygen -Z aes256-gcm@openssh.com` writes, both directions). * OpenSSL's `pkey -traditional` and `-` write the `DEK-Info` form; `rsa`/`ec` write PKCS#8 in OpenSSL 3. * `ssh-keygen -s` writes `rsa-sha2-512` signatures for RSA CAs; `ssh-rsa` (SHA-1) certificate signatures are reported `UnsupportedAlgorithm`, never `Valid`. ## Preview parts ML-DSA and ML-KEM private keys go through the platform's PKCS#8 calls, which the platform marks experimental. They are confined to one file (`PqKeys.cs`) behind one suppression, and the methods say so. SLH-DSA private keys are not handled. # ASN.1 and PEM reading and writing Normative behaviour of `RecursiveAI.OneCrypto.Asn1` and `RecursiveAI.OneCrypto.Pem`. Clause numbers are ITU-T X.690 (02/2021) unless another document is named. ## Rules | Rules | Accepts | |---|---| | `Ber` | Every valid BER encoding: long-form lengths with leading zeros (8.1.3.5), indefinite lengths for constructed elements (8.1.3.6), constructed BIT STRING, OCTET STRING and character strings (8.6.4, 8.7.3, 8.23.6), any non-zero BOOLEAN as TRUE (8.2.2), UTCTime and GeneralizedTime with an offset, without seconds, or with a fraction. | | `Cer` | BER restricted by clause 9 and 11: indefinite length on constructed elements and definite on primitives, fewest length octets, primitive strings of at most 1000 octets, the clause 11 value rules. SET OF order is checked; SET order is not. | | `Der` | BER restricted by clause 10 and 11: definite lengths in the fewest octets, no constructed strings, BOOLEAN TRUE is 0xFF, unused BIT STRING bits are zero (11.2.1), SET OF members ascending (11.6; the shorter encoding is padded with zero octets for the comparison), times end in `Z` with seconds present and no trailing zero in a fraction (11.7, 11.8). | Under every rule: identifier octets use the single-octet form for tag numbers below 31 and the first subsequent group is not 0x80 (8.1.2.2, 8.1.2.4.2); the length octet 0xFF is refused (8.1.3.5 c); an indefinite length on a primitive is refused; INTEGER and ENUMERATED are minimal (8.3.2); OBJECT IDENTIFIER subidentifiers are minimal (8.19.2); NULL is empty; a BIT STRING has 0 to 7 unused bits and none when empty (8.6.2.2, 8.6.2.3); universal tag 0 is not an element (8.1.5). A SET (as against SET OF) is not checked for the tag order of 10.3: the order depends on the ASN.1 module, which the reader does not have. ## Reading `Asn1Reader` is a forward-only `ref struct` over a `ReadOnlySpan`. Reads of fixed-size and span-returning types allocate nothing. `ReadEncodedValue` returns the element exactly as it appears, header and end-of-contents included: hash that, never a re-encoding. `Asn1Element` holds the same slices as `ReadOnlyMemory` for the model layers. `Asn1StreamReader` yields tokens from a `Stream` in constant memory (16 KiB buffer) and gives a constructed OCTET STRING of any size as a `Stream`. Every `TryRead…` leaves the position unchanged on failure and sets `LastFailure` (reason and offset). The throwing twin raises `Asn1ParseException` with the same reason and offset. Reading never throws another exception type on malformed input. ### Limits (`Asn1Limits`, on by default) | Limit | Default | Applies to | |---|---|---| | `MaxDepth` | 64 | nesting of constructed elements, including the scan for the end of an indefinite length, and the stream reader's open elements | | `MaxScanElements` | 1,048,576 | elements examined to find one indefinite end, and segments of one constructed string | | `MaxTotalSize` | 64 GiB | a span longer than this is refused; the stream reader stops after this many octets | | `MaxCopiedLength` | 256 MiB | arrays built by joining constructed strings or by `Asn1StreamReader.ReadContent` | | `MaxStringNesting` | 4 | nested levels of constructed string segments | An element length is checked against the data that remains before a slice or array is made, so a hostile length cannot cause an allocation. Finding the end of an indefinite-length element is linear: definite-length children are skipped whole and open indefinite elements are counted, not recursed into. ### Differences from `System.Formats.Asn1.AsnReader` Found by the differential run (`tests/OneCrypto.Tests/Asn1/DifferentialTests.cs`) and kept on purpose: 1. **Universal tag 0** is rejected as an element (8.1.5 reserves it for the end-of-contents octets). `AsnReader` reads it as an ordinary element. The differential harness makes the oracle refuse it too, so that this one cause does not hide others. 2. **BIT STRING unused bits** are returned as received in BER (8.6.2.4 leaves their value unspecified, and lossless handling keeps them); `AsnReader` clears them. The harness masks them before comparing. 3. **GeneralizedTime fractions** are exact to 100 ns; `AsnReader` computes them in floating point and is occasionally one tick (100 ns) lower. The harness accepts a one-tick difference. 4. **Fractions of a minute or hour** in BER GeneralizedTime (X.680 clause 46) are refused; `AsnReader` reads them. No profile in use writes them. 5. **Object identifier arcs** are decoded up to 128 bits (enough for `2.25.`); larger arcs give `ValueOutOfRange`. `AsnReader` has no limit. The lenient `Asn1Values.TryOid` keeps a 64-bit limit. 6. **UniversalString** is decoded (UCS-4, 8.23.7); `AsnReader` does not support it. T61String and GeneralString decode as Latin-1. 7. **Long object identifiers**: this library reads any length (the span form, `ReadObjectIdentifierBytes`, allocates nothing); `AsnReader` refuses a dotted form of more than 127 characters. The harness applies the same cut-off to this library before comparing. 8. A two-digit UTCTime year of 50 to 99 is 19xx and 00 to 49 is 20xx (RFC 5280 clause 4.1.2.5.1), as in `AsnReader`'s default. A second of 60 is refused by both. ## Writing `Asn1Writer` builds DER (default) or definite-length BER in one buffer: `Push…`/`Pop` write the length in front of the content in its shortest form; a SET OF opened with `PushSetOf` is sorted when it closes (11.6). `BerStreamWriter` writes indefinite-length BER to a `Stream` so a payload of any size goes out in segments. `DerWriter` is the static helper for building small structures bottom-up and for the CMS and time-stamp code. ## PEM (RFC 7468) `PemCodec.Parse`/`ParseAll` (throwing, `PemFormatException` with reason and offset) and `TryParse`/`TryParseAll`, on text or UTF-8 bytes. * **Strict** (RFC 7468 clause 3): `-----BEGIN label-----` at the start of a line, base64 in lines of exactly 64 characters except the last (1 to 64), no whitespace inside a line, LF or CRLF line ends, no encapsulation headers, END label equal to BEGIN label. Explanatory text before, between and after blocks is skipped. * **Lax** (clause 2): any whitespace and line length in the body, a missing final newline, and the legacy `Name: value` encapsulation headers of RFC 1421 clause 4.4 as OpenSSL writes them (`Proc-Type: 4,ENCRYPTED`, `DEK-Info: cipher,iv`), which are returned as `PemBlock.Headers`; a blank line ends them. The body of an encrypted block is not decrypted here. * A decoded block is limited to 64 MiB. Base64 padding must be correct in both modes. `PemCodec.Write` writes LF line ends, 64-column lines and a trailing newline. ## Asynchronous reading and writing `Asn1StreamReader` has `TryReadTokenAsync`, `ReadTokenAsync`, `ReadAsync` and `ReadContentAsync`, and the stream returned by `OpenOctetString` has a real `ReadAsync`. They await the octets a header or run needs into the reader's own 16 KiB buffer and then apply the same synchronous logic, so both forms accept and reject the same input at the same offset. `BerStreamWriter` has `BeginIndefiniteAsync`, `EndAsync`, `WriteDefiniteAsync`, `WriteEncodedValueAsync` and `WriteOctetStringFromAsync` writing the same octets as the sync methods. Memory use is unchanged. # Cryptographic Message Syntax Normative behaviour of the CMS models in `RecursiveAI.OneCrypto` (`SignedData`, `EnvelopedData`, `AuthEnvelopedData`, `AuthenticatedData`, `EncryptedData`, `DigestedData`, `CompressedData`, `ContentInfo`) and the wire constants in `RecursiveAI.OneCrypto.Wire.Cms`. Clause numbers are RFC 5652 unless another document is named. RFC 3161 time-stamp tokens are SignedData and are read with the same code. Path building, revocation and trust decisions on the signer's certificate belong to the validator, not here. ## Shape of every message * **Immutable and lossless.** An object keeps the octets it was read from (`Encoded`) and slices them; builders write DER and return the parse of what they wrote. BER with indefinite lengths and constructed strings is read. Digests and signatures are computed over the received bytes: the encapsulated content as the OCTET STRING holds it, and the signed attributes as the received `[0]` element with its tag changed to `SET OF` (§5.4), never a re-encoding. * **Typed errors.** `CmsParseException` (derives from `FormatException`) carries the structure and a `CmsError`: `Asn1`, `UnexpectedStructure`, `UnexpectedContentType`, `Unsupported`, `InvalidValue`, `LimitExceeded`, `NoMatchingRecipient`, `DecryptionFailed`. Every `Parse` has a `TryParse`. No other exception type comes out of a reader or of the streaming calls (the fuzz targets enforce this). * **Results, not booleans.** `SignedData.Verify` returns a report per signer with one `CmsCheck { Rule, Status, Reason, Reference }` per rule. `AuthenticatedData.Verify` and `DigestedData.Verify` return a `CmsIntegrityReport` of checks the same way. ## Limits (`CmsLimits`, on by default) | Limit | Default | Applies to | |---|---|---| | `MaxSigners` | 64 | SignerInfos of a SignedData | | `MaxCertificates` | 1024 | certificates, and revocation lists, each | | `MaxAttributes` / `MaxAttributeValues` | 64 / 16 | one attribute set / one attribute | | `MaxAttributeBytes` | 4 MiB | one attribute value (a time-stamp token, revocation data) | | `MaxRecipients` | 1024 | RecipientInfos | | `MaxCountersignatureDepth` | 4 | countersignature levels followed | | `MaxContentBytes` | 1 GiB | content a one-shot call holds: encrypted content of `Decrypt`, and the whole message of `AuthEnvelopedData.DecryptStream` | | `MaxDecompressionRatio` / `MaxDecompressedBytes` | 1000 / 1 GiB | `CompressedData.Decompress` (stops at whichever is smaller) | | `MaxPbkdf2Iterations` | 10,000,000 | a PasswordRecipientInfo from a message (a cost bomb is refused before any work) | | `Asn1` | depth 32 | the ASN.1 limits of `docs/asn1.md` | The streaming readers also keep the small elements around streamed content (recipient infos, certificates, signer infos) within 64 MiB together. ## SignedData Every signature kind the platform and our primitives have: RSA PKCS #1 v1.5, RSASSA-PSS (RFC 4056), ECDSA P-256/384/521, Ed25519 and Ed448 (RFC 8419, with SHA-512 and SHAKE256 as the digest), ML-DSA (RFC 9882) where the platform has it, SLH-DSA (RFC 9814) verification through the preview path of the X.509 models. Signers are written in the order given, so report index *i* is signer *i*. Defaults of the builder: signed attributes on (content-type, message-digest, ESS signing-certificate-v2); signing time only when the spec gives one; the digest follows the signature algorithm; `rsaEncryption` is written as the signature algorithm for PKCS #1 v1.5. ### Verification rules By default the verifier takes no clock and builds no chain: the report says the signature is sound for the certificate's key and exposes the certificates, and trust is the path validator's question. With `SignedDataVerifyOptions.Chain` set (see Signer chains below) each signer is also path-validated. It checks every signer. | Rule | Passes when | Reference | |---|---|---| | `signer-certificate` | a certificate in the message or in `ExtraCertificates` matches the signer identifier (issuer and serial, or subject key identifier) | §5.3 | | `digest-algorithm` | the algorithm is available; SHA-1 is a warning | §5.1 | | `attributes` | signed attributes are well formed, no attribute is duplicated, content-type and message-digest each have one value | §5.3, §11 | | `content-type` | the attribute equals the encapsulated content type | §11.1 | | `message-digest` | the attribute equals the digest of the content, compared in constant time | §11.2 | | `signing-time` | readable (a warning otherwise) | §11.3 | | `signature` | the signature is valid over the signed attributes (or the content when there are none), and the digest and signature algorithms agree | §5.4 | | `algorithm-protection` | when present, matches the digest and signature algorithms of the SignerInfo | RFC 6211 | | `key-usage` | a signer certificate with a keyUsage extension has at least one of `AnyOfKeyUsages` (default digitalSignature or nonRepudiation; set to `None` to skip), and, when `RequiredExtendedKeyUsage` is given, that extended key usage; nothing else is assumed (for example PDF signing EKUs are the caller's to ask for) | RFC 5280 §4.2.1.3 | | `key-strength` | RSA of 2048 bits or more (a warning below) | CryptoPolicy | | `signing-certificate` | ESS signing-certificate or -v2 names the signer certificate by hash | RFC 2634 §5, RFC 5035 | | `chain` | only with `Chain` options: the signer's certificate validates to a trust anchor at the options' reference time (`Passed`; `Warning` when valid but revocation stayed unknown; `Unsupported` when the validator could not decide, for example required revocation data is missing; `Failed` otherwise) | RFC 5280 §6 | | `countersignatures` | each countersignature verifies over the signature value of its target, to `MaxCountersignatureDepth` | §11.4 | `Verify(content)`, `Verify(Stream)` and `VerifyStream` hash with every digest algorithm the signers use in one pass. A detached message with no content supplied fails the `content` rule. ### Signer chains `SignedDataVerifyOptions.Chain` takes a `ValidationOptions` (`docs/validation.md`): trust anchors, the reference time, the `CryptoPolicy`, revocation data and limits. Each signer and each countersigner whose certificate was found is validated with it, and the report gets a `chain` check and `SignerReport.Chain`, the validator's full `ValidationReport` (paths, per-rule results, revocation findings). The signer is valid only if the chain check passes or warns; the message is valid only if every signer is. * The certificates in the message and in `ExtraCertificates` join the options' `Intermediates` as an untrusted pool. The CRLs in the message, and OCSP responses carried in its revocation set as `id-ri-ocsp-response`, join the options' revocation data. Being in the message confers no trust; only the anchors do. * With no CRL or OCSP response anywhere, the validator is given no revocation data, so a chain with `RevocationRequired = None` reports nothing unknown. With `RevocationRequired` set and no usable data the chain is undecided (`Unsupported`) unless the options set `UnknownRevocationIsFatal = false`. * **Time.** The reference time is the options'. The signing-time attribute is the signer's own claim and is never used as the reference time. A signer whose certificate has expired is `Failed` at "now" and valid at an instant inside its validity: to validate as of the signature, pass a time that is proven (a time-stamp token's `genTime`, from a verified token) and, for revocation, `RevocationModel.PastTime` with the data time. The long-term-validation choice of that instant is the AdES validator's. * The signing-certificate (ESS) check is independent: a substituted certificate that chains correctly but is not the one the signer named still fails it. * RFC 3161 tokens are CMS SignedData and use the same option; `TimeStampVerifyOptions` also takes anchors directly and reports the same validation as its `trust` check. ### Streaming `SignedData.SignStream(content, output, signers, options)` writes indefinite-length BER: header, the encapsulated content as a constructed OCTET STRING of 64 KiB segments (hashed as it goes), then certificates and signer infos. Every signer must use signed attributes, because a signature over the bare content cannot be made in one pass (`NotSupportedException` otherwise). `SignedData.VerifyStream(message, contentOutput, options, limits)` reads an attached message forward in constant memory, hashing the content as it passes (primitive or segmented, definite or indefinite), then reads the certificates and signer infos that follow and verifies them. The small elements after the content are rebuilt with definite lengths to be parsed; their contents are untouched. If `contentOutput` is given the content is copied to it while being read and is **not verified until the call returns a valid report**. ## EnvelopedData and AuthEnvelopedData Content encryption: AES-128/192/256-CBC (EnvelopedData, EncryptedData), AES-128/192/256-GCM and CCM (AuthEnvelopedData, RFC 5084), Triple DES CBC read only and only with `AllowLegacyCiphers`. Content is encrypted with a random key; the version follows §6.1 (3 with a password or other recipient, 2 with a key agreement, key encryption key or unprotected attributes, else 0). | RecipientInfo | Written | Read | |---|---|---| | KeyTransRecipientInfo | RSA-OAEP with SHA-1/256/384/512 (default SHA-256, RFC 3560); PKCS #1 v1.5 only with `AllowLegacy` | both; v1.5 can be refused with `AllowPkcs1V15KeyTransport = false` | | KeyAgreeRecipientInfo | ephemeral-static ECDH P-256/384/521 with the X9.63 KDF, its hash following the curve (SHA-256, SHA-384, SHA-512; RFC 5753 §7.1.1), X25519 with HKDF-SHA-256 and X448 with HKDF-SHA-512 (RFC 8418), AES key wrap | the same plus SHA-1/224/384/512 KDFs; an `originatorKey` without curve parameters (as openssl writes) is taken to be on the recipient's curve | | KEKRecipientInfo | AES-KW (RFC 3394, our primitive) or AES-KWP (RFC 5649, the platform's) | both | | PasswordRecipientInfo | PBKDF2-HMAC-SHA-256 (default 600,000 iterations) and RFC 3211 wrapping with AES-CBC | HMAC-SHA-1/256/384/512 PRFs | | KEMRecipientInfo (RFC 9629) | ML-KEM with HKDF-SHA-256 and AES-256 key wrap (RFC 9936), preview, where the platform has ML-KEM | the same, with HKDF-SHA-256/384/512 | | OtherRecipientInfo | preserved on read, not opened | | ### Failing closed * **No plaintext before the check.** `Decrypt` returns bytes only after the whole message has been decrypted and, for AuthEnvelopedData, the tag has verified over the ciphertext and the DER of the authenticated attributes (RFC 5083 §2.2). On any failure the buffer is zeroed and nothing is returned. * **One error.** After a recipient info matched, every failure (wrong key, damaged wrapped key, bad padding, bad tag, a missing content-type attribute) raises the same `CmsParseException` with `CmsError.DecryptionFailed` and the same message. Only "no recipient info is meant for this key" is told apart, because that is a property of the public message. * **RSA PKCS #1 v1.5 (RFC 3218 §2.3).** When key transport fails, a random key of the right length stands in, decryption runs on, and the result is rejected at the end whatever its padding looks like. A wrong-length key counts as a failure. * **CBC without authentication.** EnvelopedData cannot detect changes that leave the padding valid. Flipping a bit in a block changes the plaintext of the next block and garbles that one; only the last block's padding is checked. Prefer AuthEnvelopedData. * **Cost bombs.** The PBKDF2 iteration count of a message is capped (`MaxPbkdf2Iterations`). ### Streaming and memory * `EnvelopedData.EncryptStream` writes indefinite-length BER in constant memory (AES-CBC through the platform's transform, one OCTET STRING segment per write). `EnvelopedData.DecryptStream` reads forward in constant memory and writes plaintext as it is decrypted. CBC can only be checked at the last block, so the plaintext reaches the output **before** the padding is known to be good; only the final block is held back. When the call throws, discard what was written. This releases no more than a caller gets from the one-shot call on a message with a bad last block (the failure itself); the failure is the same `DecryptionFailed` for a wrong key, bad padding and a failed v1.5 unwrap, and it comes only after the whole content has been read, so the caller has no cheaper padding oracle than submitting whole messages. Use AuthEnvelopedData where authenticity matters. * The platform's AES-GCM and AES-CCM are one-shot. `AuthEnvelopedData.EncryptStream` and `DecryptStream` accept streams but hold the message in memory, bounded by `MaxContentBytes` (default 1 GiB). Peak memory is about the message size plus the plaintext (about 2 GiB for a 1 GiB message). Nothing reaches the output unless the tag verifies. A caller who needs bounded memory should split the data into separately encrypted chunks. * Measured: a 1 GiB payload through `SignStream` + `VerifyStream` and `EncryptStream` + `DecryptStream` (RSA-OAEP recipient, P-256 signer, indefinite-length BER on disk) peaked at 90 MiB working set (75 MiB before the run) on the worker, release build, .NET 10.0.402. ## AuthenticatedData, DigestedData, EncryptedData, CompressedData * **AuthenticatedData** (§9): HMAC-SHA-256/384/512 (HMAC-SHA-1 is read with a warning), key delivered through any RecipientInfo; with authenticated attributes the MAC covers their DER and the attributes carry the content type and the content digest. The MAC and digest are compared in constant time. * **DigestedData** (§7): proves integrity against accident only. * **EncryptedData** (§8): AES-CBC under a key both sides hold. * **CompressedData** (RFC 3274): zlib. `Decompress` stops with `LimitExceeded` at `MaxDecompressedBytes` or `MaxDecompressionRatio` times the compressed size. ## Differences found against openssl 3.5.9 Settled by experiment (black box, `openssl cms`, 3.5.9 built with its standard toolchain, with the platform libraries on the same OpenSSL): * Ed25519, Ed448 and ML-DSA signatures cannot be made or verified by `openssl cms` without signed attributes ("invalid eddsa instance"); those cases are checked against the attribute form only. Ed25519 and ML-DSA need `-md sha512`, Ed448 `-md shake256`. * `openssl cms` leaves the curve parameters out of `originatorKey` in a KeyAgreeRecipientInfo. RFC 5753 asks for them; we accept their absence. * `openssl cms` 3.5.9 cannot make or open X25519 or X448 key agreement, or KEMRecipientInfo with ML-KEM; those forms are checked among our own readers and writers only (we know of no published vectors for RFC 8418 or RFC 9629). The newer OpenSSL 3.6.2 interoperates for ML-KEM-768 in both directions (checked, not the pinned version) and still not for X25519/X448. * The 3.5.9 build used here has no zlib, so CompressedData was checked against the distribution's openssl 3.5.5 (`cms -compress`/`-uncompress`). * `openssl cms` ignores countersignatures; ours verify them. * `openssl cms -encrypt` applies `-keyopt` to the `-recip` before it; an RSA-OAEP key transport needs `-keyopt rsa_padding_mode:oaep`, and the KDF of an ECDH recipient is `sha1kdf` unless `-keyopt ecdh_kdf_md:sha256`. ## Interoperability matrix Pinned OpenSSL 3.5.9 for both the command-line tool and the platform libraries, release build, on the build worker. Direction "ours→openssl" means we write and openssl reads; "openssl→ours" the reverse. P = pass, S = skipped with the reason in the test output. | Form | ours→openssl | openssl→ours | |---|---|---| | SignedData RSA PKCS #1 v1.5, RSA-PSS, ECDSA P-256/384/521, with signed attributes, attached and detached | P | P | | the same without signed attributes (RSA, ECDSA) | P | P | | Ed25519, Ed448, ML-DSA-65 with signed attributes, attached and detached | P | P | | Ed25519, Ed448, ML-DSA-65 without signed attributes | S (openssl cannot) | S (openssl cannot) | | two signers; subject key identifier signer | P | P | | EnvelopedData KTRI RSA-OAEP (SHA-1, SHA-256), PKCS #1 v1.5 | P | P | | EnvelopedData KARI ECDH P-256, P-384 | P | P | | EnvelopedData KARI X25519, X448; KEMRI ML-KEM-768 | S (openssl cannot open) | S (openssl cannot make) | | EnvelopedData KEKRI, PWRI | P | P | | AuthEnvelopedData AES-256-GCM, KTRI and KARI; tampered ciphertext refused | P | P | | CompressedData (openssl 3.5.5 with zlib) | P | P | | streamed EnvelopedData and SignedData (indefinite-length BER) | P | P | ## Keys, two-phase signing and policy ### Signing and decrypting through the key interfaces * `CmsSignerSpec` takes a `Signer` (an `X509Signer`) or a `Key` (an `ISigningKey`) with an optional `SignatureSpec`. The key is never given the content: for RSA and ECDSA it is handed the digest of the DER signed attributes (`SignDigest`), for Ed25519, Ed448 and ML-DSA the DER of the signed attributes itself (`Sign`). A signer without signed attributes signs the content itself. A bare delegate `X509Signer` still signs the attributes with its function. * `CmsDecryptionKey` stays as the adapter the recipient code reads through. `CmsDecryptionKey.FromKey(IKey, certificate, ownsKey)` wraps anything behind `IDecryptionKey` (RSA PKCS #1 v1.5 and OAEP, ML-KEM) or `IAgreementKey` (ECDH, X25519, X448); `FromRsa`, `FromECDiffieHellman`, `FromX25519`, `FromX448` and `FromMLKem` are shortcuts over the platform and library keys and go through the same adapter. A failed unwrap of any kind becomes "no key", so the RFC 3218 §2.3 random content key and the uniform `DecryptionFailed` hold for every backend. * Key operations are synchronous calls on the adapter; only the stream I/O of the async twins is asynchronous. Signing keys are asked through `SignAsync`/`SignDigestAsync` by `SignStreamAsync`. ### Two-phase SignedData `SignedData.Prepare(content, signers, at, options)` and `SignedData.PrepareStream(stream, signers, at, options)` build everything except the signature values and return a `PreparedSignedData`. Each signer needs its certificate and either a key or a `SignatureSpec`; no key is used. `PreparedSignedData.Signatures` holds one `PreparedSignature` (purpose `cms.signerinfo`) per signer: the signed attributes are `Data`, and `ToSign` is their digest (RSA, ECDSA) or themselves (EdDSA, ML-DSA). `Complete(signatures)` verifies each signature against its signer's public key before it assembles the message, so a damaged, swapped or wrong-key signature raises `InvalidSignatureException` and nothing is written. * Attached content prepared from memory is kept in the state; detached content is not. Content prepared from a stream keeps only its digests: a detached message completes with `Complete`, an attached one with `CompleteStream(signatures, content, output)`, which writes indefinite-length BER and refuses (`CryptographicException`, after writing: discard the output) when the content no longer hashes to what was signed. * The state is DER: `SEQUENCE { version 1, "cms.signeddata", contentType, detached, contentRetained, cmsVersion, digestAlgorithms, encapsulatedContentInfo, certificateParts, contentDigests, signers }`, each signer `SEQUENCE { PreparedSignature, certificate, identifierKind, digestOid, hasSignedAttributes, unsignedAttributes }`. It re-encodes to the octets it was read from (digests in ascending OID order). Unknown versions and damage are reported by `TryDecode`. * The policy of `SignedDataOptions` (default `Standard`) is asked at preparation. ### Policy * **Sign.** `SignedDataOptions.Policy` (default `Standard`) judges each signer's signature hash, key and SignerInfo digest as new data (no reference time: new-data rules do not depend on the date). SHA-1 and MD5 and RSA under 2048 bits are refused with `PolicyViolationException` carrying the decisions and rule ids. The ESS signing-certificate hash only names a certificate and is not judged. * **Verify.** `SignedDataVerifyOptions.Policy` with `ReferenceTime` (both, or `ArgumentException`: the library never reads a clock). Each `SignerReport.Policy` holds the decisions at that instant, and the `policy` check is `Passed`, `Warning` (flagged) or `Failed` (rejected, so the signer is not valid). Without a policy the report keeps the plain `digest-algorithm` and `key-strength` warnings. * **Encrypt and decrypt.** `CmsEncryptOptions.Policy` (default `Standard`) judges the content cipher and each recipient's public key as new data; `CmsDecryptOptions.Policy` with `ReferenceTime` judges the cipher the message names and the key that opens it, before any key operation. Triple DES follows `LegacyCiphers`. * **CBC EnvelopedData.** S/MIME uses it everywhere, so `Standard` cannot refuse it. `CryptoPolicy.CheckContentCipher` returns a *flagged* decision (`cipher.cbc.unauthenticated.encrypt` / `.decrypt`): `Standard` reads and writes it, `Strict` (which turns flags into rejections) refuses it, and `AllowUnauthenticatedEncryption` / `AllowUnauthenticatedDecryption` accept it without a flag. The bare "unauthenticated encryption fails closed" rule (`CheckCipher`) is for formats with no integrity container at all, such as the OpenPGP SED packet. ### Async twins `SignedData.SignStreamAsync` / `VerifyStreamAsync`, `EnvelopedData.EncryptStreamAsync` / `DecryptStreamAsync`, `AuthEnvelopedData.EncryptStreamAsync` / `DecryptStreamAsync` and `CompressedData.DecompressStreamAsync` take a `CancellationToken` and share their rules with the sync forms. They read through `Asn1StreamReader.ReadTokenAsync` / `ReadAsync` / `ReadContentAsync` and write through `BerStreamWriter`'s `…Async` methods; none wraps the other form. A cancelled sign or encrypt leaves a partial message in the output (discard it). A cancelled CBC decryption has released only the plaintext of earlier blocks, never the final one; a cancelled AuthEnvelopedData decryption released nothing (the AEAD is one-shot, so the message and plaintext are held in memory up to `MaxContentBytes`, exactly as in the sync form). ### CompressedData streaming `CompressedData.DecompressStream` / `DecompressStreamAsync` inflate a forward-only message in constant memory with the same bounds as `Decompress`: `MaxDecompressedBytes`, and `MaxDecompressionRatio` times the compressed octets read so far. They stop with `LimitExceeded` and leave a partial output, which the caller discards. # X.509, certification requests, CRLs, OCSP and attribute certificates Normative behaviour of the PKI models in `RecursiveAI.OneCrypto` (the `Certificate`, `CertificateSigningRequest`, `CertificateRevocationList`, `OcspRequest`, `OcspResponse`, `OcspBasicResponse` and `AttributeCertificate` types and the names, general names, extensions and public keys they hold), with the typed extension values in `RecursiveAI.OneCrypto.X509`. Clause numbers are RFC 5280 unless another document is named. These types describe and check data. Path building, validity at a time, revocation decisions and trust belong to the validator, which builds on them. ## Shape of every model * **Immutable and lossless.** An object keeps the octets it was read from (`Encoded`) and decodes from them on first use. A built object is the parse of the DER the builder wrote, so there is one code path. Unknown extensions, attributes and algorithms are preserved with their criticality and parameters. * **Signatures cover the received bytes.** `tbsCertificate`, `tbsCertList`, `certificationRequestInfo`, `tbsRequest`, `tbsResponseData` and `acinfo` are kept as slices of the input and passed to the verifier as they are. Nothing is re-encoded to be hashed. A certificate in BER (indefinite lengths, long-form lengths) is read and verified over its own octets; `Certificate.IsDer` says whether it is strict DER. * **Parsing reads BER, builders write DER.** The readers apply the BER rules of X.690 and keep a typed error for anything else. After a successful `Parse`, the structure is sound: the framing of every algorithm identifier, name, extension list, CRL entry, attribute and embedded certificate has been walked, so the accessors that decode them cannot fail. The one exception is the *content* of an extension value: `Certificate.BasicConstraints`, `Extensions.Get()` and their kind throw `X509ParseException` for a malformed value, and `Extensions.TryGet()` reports it as false. The certificate itself stays usable. * **Typed errors.** `X509ParseException` (derives from `FormatException`) carries the structure name and an `X509Error` (`Asn1`, `UnexpectedStructure`, `UnsupportedVersion`, `InvalidValue`, `LimitExceeded`); an ASN.1 failure is its inner exception with the offset. Each `Parse` has a `TryParse`. No other exception type comes out of a reader. * **Results, not booleans.** `VerifySignature` returns `SignatureVerification { Status, Algorithm, Reason, Reference }` with `Status` one of `Valid`, `Invalid`, `AlgorithmNotSupported`, `KeyMismatch`, `Malformed`. Reasons name the clause that decided them. ## Limits (`X509Limits`, on by default) | Limit | Default | Applies to | |---|---|---| | `MaxEncodedSize` | 1 MiB | a certificate, request, attribute certificate or OCSP message | | `MaxCrlSize` | 256 MiB | a CRL | | `MaxExtensions` | 128 | one extension list | | `MaxNameElements` | 64 | RDNs in a name, and attributes in one RDN | | `MaxNameLength` | 64 KiB | one encoded name | | `MaxListEntries` | 10,000 | entries of any list inside an extension value, OCSP request and response lists, attributes of a request or attribute certificate | | `MaxEmbeddedCertificates` | 64 | certificates inside one OCSP message | | `Asn1` | depth 32 | the ASN.1 limits of `docs/asn1.md` | A serial number longer than 64 octets is refused (RFC 5280 limits it to 20; real certificates exceed that, so the reader is lenient and the builder is not). ## Names `DistinguishedName` keeps the encoded `Name`. `ToString()` is RFC 4514: relative names in reverse order, keywords only for the types RFC 4514 §3 lists (others are dotted OIDs), the escapes of §2.4, and `#` plus the hex of the BER encoding for values that are not string types. `FromString` reads that syntax (and a few common keywords: `S`, `E`, `emailAddress`, `serialNumber`). Built names use PrintableString for country and serial number, IA5String for e-mail and domain component, UTF8String otherwise. `Matches` is the comparison of §7.1 in its practical form: equal attribute types, string values equal after trimming, collapsing inner white space and upper-casing, other values octet for octet, the attributes of a multi-valued RDN as a set. It does not apply the full RFC 4518 mapping; names that differ only by that mapping are not matched. ## Times UTCTime years 50 to 99 read as 19xx and 00 to 49 as 20xx (§4.1.2.5.1). Built times use UTCTime for 1950 to 2049 and GeneralizedTime otherwise, to whole seconds. ## Public keys and signature algorithms `PublicKeyInfo` classifies the algorithm: RSA, RSA-PSS and RSA-OAEP keys, EC keys (any named curve; `CurveOid`), Ed25519, Ed448, X25519, X448 (RFC 8410), ML-DSA, ML-KEM, SLH-DSA, composite ML-DSA (recognised by the draft's identifiers, preview), and `Unknown` for everything else, which is kept as received. `VerifySignature` rules: | Algorithm | Behaviour | |---|---| | RSA PKCS #1 v1.5 (SHA-1/224/256/384/512, SHA3, MD5 read) | platform `RSA`, key must be `rsaEncryption`. The hash is not judged here; a policy decides whether SHA-1 or MD5 is acceptable. | | RSASSA-PSS | parameters read per RFC 4055 §3.1. Supported: MGF1 on any hash the library knows, trailer 1, any salt length. The platform's own PSS check handles only a salt as long as the digest with MGF1 on the same hash; every other parameter set goes to the library's verify-only RSA (public data only). A key that carries PSS parameters pins hash, MGF hash and minimum salt (RFC 4055 §1.2). | | ECDSA (SHA-1/224/256/384/512, SHA3) | platform `ECDsa`. The signature must be strict DER of two positive INTEGERs (RFC 3279 §2.2.3), checked by the library first because the platform also accepts unpadded and BER integers (Wycheproof `MissingZero` cases). A curve the platform lacks gives `AlgorithmNotSupported`. | | Ed25519, Ed448 | through the library's Edwards-curve primitives; `AlgorithmNotSupported` where a build has none. | | ML-DSA-44/65/87 | platform `MLDsa` (empty context, RFC 9881) where the platform has it, otherwise `AlgorithmNotSupported` naming the platform. | | SLH-DSA | verified through the platform class `SlhDsa`, which is still an evaluation API: the single use sits in one file under one documented warning suppression, results carry "(preview)" in their reference, and where the platform lacks it the answer is `AlgorithmNotSupported`. | | Composite ML-DSA | recognised, never verified (`AlgorithmNotSupported`). | An outer signature algorithm that differs from the one inside the signed structure, or a signature BIT STRING with unused bits, is `Invalid` (§4.1.1.2, §4.1.1.3). ## Extensions Typed values (`IExtensionValue`, read with `Extensions.Get()`, written with `Extension.Create`): basicConstraints, keyUsage, extKeyUsage, subjectAltName, issuerAltName, subjectKeyIdentifier, authorityKeyIdentifier, nameConstraints, certificatePolicies, policyMappings, policyConstraints, inhibitAnyPolicy, cRLDistributionPoints, freshestCRL, authorityInfoAccess, subjectInfoAccess; for CRLs cRLNumber, deltaCRLIndicator, issuingDistributionPoint and the entry extensions cRLReason, invalidityDate, certificateIssuer; for OCSP the nonce and no-check. Every GeneralName form is decoded (otherName, rfc822Name, dNSName, x400Address and ediPartyName kept undecoded, directoryName, URI, iPAddress of 4/8/16/32 octets, registeredID). Duplicate extensions are kept in order; `ExtensionCollection.HasDuplicates` reports them (§4.2 forbids them; the validator decides). The OCSP nonce is written as an OCTET STRING inside `extnValue` (RFC 6960 §4.4.1, RFC 8954) and read both in that form and as raw octets. ## Builders Plain options objects and static methods; no fluent chains. `CertificateBuilder.CreateSelfSigned/Create`, `CertificateSigningRequestBuilder.Create`, `RevocationListBuilder.Create` (complete and delta), `OcspRequestBuilder.Create`, `OcspResponseBuilder.CreateSuccessful/CreateError`. They sign through `X509Signer`, a signature spec bound to a key: `FromKey` takes any key interface (see keys.md), and `FromRsa` (PKCS #1 or PSS), `FromECDsa`, `FromMLDsa` and `FromDelegate` are conveniences. A private key never leaves its object. Certificates, requests and revocation lists also build in two phases (`Prepare`, then `Complete`; keys.md). `CertificateOptions` adds a subjectKeyIdentifier (RFC 7093 method 1: the left 160 bits of the SHA-256 of the key bits) and an authorityKeyIdentifier unless given; serial numbers default to 159 random bits. ## OCSP `OcspCertId.Create` defaults to SHA-1, which every responder must accept (RFC 6960 §4.3); the hash of the issuer name is taken over the certificate's own issuer field as received, and the key hash over the issuer's key bits. `OcspCertId.Matches` recomputes the hashes with the identifier's own algorithm. A response whose status is `successful` must carry response bytes and any other status must not (§4.2.1); both directions are refused. Checking the responder's authority, the validity window and the nonce against a request is the caller's or the validator's job. ## Platform conversion `Certificate.ToX509Certificate2()` and `Certificate.FromX509Certificate2` pass the same octets. `TryToX509Certificate2` reports false for what the platform cannot load. A certificate held only as an `X509Certificate2` keeps its private key in the platform object; the model never sees it. ## Differences from `X509Certificate2` (x509-limbo, 30,379 distinct certificates and 18 CRLs) Compared where both load: subject and issuer octets, serial number (leading zeros trimmed), validity, extension identifiers and criticality in order, SPKI algorithm and key bits. Of 30,379 distinct certificates in the pinned x509-limbo, 30,377 parse here and the platform loads all of them with no difference in any compared field. The other two (`pathological::nc-dos-1` and `nc-dos-3`) are refused with `X509Error.LimitExceeded` because their names have more than `MaxNameElements` relative names; the platform loads them. This is the one documented deviation: the limit is on by default and can be raised by passing `X509Limits`. ## Policy at signing and verification The one-shot builders ask a `CryptoPolicy` (default `Standard`) before they sign: a SHA-1 or MD5 certificate, request, list, OCSP request or response is refused with `PolicyViolationException` (rule `hash.sha-1.new`), as is an RSA key under 2048 bits. Pass a permissive policy to build old data on purpose. `Certificate.VerifySignature(issuerKey, policy, at)` adds the policy's decisions to the result and ends as `SignatureStatus.PolicyRejected` when a correct signature is refused at `at`. The OCSP request and response builders have a two-phase `Prepare` / `Complete` like the other builders. # RFC 3161 time-stamping `RecursiveAI.OneCrypto.Tsp`: the client messages, a verifier, and an authority handler. No network and no clock: bytes in, bytes out. Fetching a token over HTTP is the online package's job. ## Messages - `TimeStampRequest` (`TimeStampReq`, RFC 3161 §2.4.1): version 1, message imprint, policy, nonce, certReq, extensions. `Create` takes a `HashAlgorithmName` (SHA-256/384/512 or SHA-3), the digest (its length must fit the hash) and options; `NewNonce()` gives 8 random octets. `Parse` is BER-lenient and limit-checked; `TryParse` is the non-throwing twin; the error is `TimeStampParseException`. - `TimeStampResponse` (`TimeStampResp`, §2.4.2): status (`TimeStampStatus`), status strings, failure bits (`TimeStampFailure`, named after PKIFailureInfo with the value `1 << bit`) and the token's bytes as received. - `TstInfo` (§2.4.2): policy, imprint, serial number (positive), genTime (fractions kept), accuracy, ordering, nonce, tsa, extensions. `Encoded` is the structure as received, which is what the token's signature covers. - Limits (`TimeStampLimits`): request 64 KiB, response 1 MiB, nonce 64 octets, imprint 128 octets, nesting depth 16. The seed's `Rfc3161.BuildRequest` and `TryExtractToken` stay (they serve the PDF bridge) and write and read the same messages. ## Verifying a response (`TimeStampVerifier.Verify`) Reports one `CmsCheck` per rule (rule name, status, reason, RFC section), and never throws for bad input. The rules: | Rule | What it checks | |---|---| | `status` | granted or grantedWithMods (the latter is a warning); otherwise the refusal and its failure bits are the reason (§2.4.2) | | `token` | a CMS SignedData holding a TSTInfo that parses | | `message-imprint` | same hash algorithm and digest as the request (§2.4.2) | | `nonce` | the request's nonce is echoed; a request without a nonce is `NotApplicable` (a replayed token cannot be told from a fresh one); an unrequested nonce warns | | `policy-id` | when the request asked for a policy, the token is under it | | `single-signer` | exactly one SignerInfo (§2.4.2) | | `signature` | the CMS layer's verification: message digest, signature, key usage, and extended key usage allowing id-kp-timeStamping | | `tsa-eku` | the certificate has one extended key usage extension, critical, naming only id-kp-timeStamping (§2.3) | | `ess-cert-id` | a signing-certificate or signing-certificate-v2 attribute names the signer's certificate by hash (RFC 5035 §5.4; RFC 5816) | | `tsa-name` | when the token has a `tsa` name it identifies the signer's certificate (§2.4.2) | | `tsa-validity` | genTime lies within the certificate's validity | | `policy` | `CryptoPolicy` (default `Standard`) on the imprint hash and the token signature at genTime, or at `ReferenceTime` when the caller passes one | | `trust` | `NotApplicable` without `TrustAnchors`. With anchors, the authority's certificate goes through `PathValidator` as of the token's genTime (time-stamping EKU required, the token's and `ExtraCertificates` as the pool, `Revocation` data if supplied): `Failed` when no path validates, `Warning` when it does but revocation of the certificate is not known good (offline is never reported as plain `Passed`), `Passed` when the revocation data says good | `IsValid` means no rule failed or went undecided; it means the authority is trusted only when anchors were supplied and `trust` passed or warned. A token made without certReq has no certificate in it, and the report says `tsa-certificate` is undecided until the caller passes it in `ExtraCertificates`. A bare token (a ContentInfo) is accepted as an answer, with a warning. ## The authority handler (`TimeStampAuthority`) `Handle(requestBytes, now)` returns a `TimeStampResponse`, never throws for bad input. The certificate must carry a critical extended key usage naming only id-kp-timeStamping (§2.3), checked when the handler is built. - **Refusals** are status `rejection` with failure bits: unparsable request or an imprint of the wrong length → `badDataFormat`; a hash not in `AcceptedHashes` (SHA-256/384/512; SHA-1 is not) → `badAlg`; a policy that is neither the default nor in `AcceptedPolicies` → `unacceptedPolicy`; a critical request extension → `unacceptedExtension` (non-critical ones are ignored); the authority's certificate not valid at `now` → `timeNotAvailable`; a serial source that fails or returns zero or more than 20 octets, a signature algorithm the policy rejects, or a signing error → `systemFailure`. - **Granted tokens**: SignedData with eContentType id-ct-TSTInfo, one signer, a signing-certificate-v2 attribute (ESSCertIDv2, RFC 5816), no signing time, the nonce echoed, the policy the client asked for (else the default), accuracy and ordering as configured, the `tsa` name from the certificate subject. On certReq the token carries the authority's certificate and `ChainCertificates`; otherwise no certificates. - **Serial numbers** come only from the `ITimeStampSerialSource` the host passes. `CounterTimeStampSerialSource` (in memory) and `RandomTimeStampSerialSource` (128 random bits) are provided; uniqueness across processes is the host's. - **Time** is `now`, written as genTime in whole seconds by default (`GenTimeFractionDigits` adds up to 6 digits, no trailing zeros, §2.4.2). - **Two-phase**: `Prepare(request, now)` returns a `PreparedTimeStamp`: either `Rejection`, or a `PreparedSignature` for the signed attributes. `Encode()` / `PreparedTimeStamp.Decode` serialise it (`SEQUENCE { version, preparedSignature, tstInfo, certReq, certificates }`), and `Complete(signature)` checks the signature and returns the response. Signing attributes are deterministic, so completion replays the token build around the supplied signature. ## Checked against OpenSSL `ts` (3.5.9 pinned; also 3.6.2 locally): `ts -query` requests answered by our handler and accepted by `ts -verify` (SHA-256/384/512 × RSA, ECDSA P-256 × nonce / no nonce × certReq / no certReq), and requests we build answered by `ts -reply` and accepted by our verifier (same matrix). `openssl ts` has no EdDSA: it verifies through PKCS #7, and `ts -reply` answers a rejection for an Ed25519 key. Ed25519 tokens from our authority are therefore checked by `openssl cms -verify` (signature and content), and the Ed25519 rows of the other direction are skipped. Tests: `tests/OneCrypto.Tests/Issuance/TimeStampTests.cs`; fuzz target `tsa`. # CryptoPolicy: algorithm and key-size policy `CryptoPolicy` (`RecursiveAI.OneCrypto.Policy`) decides which algorithms, key sizes and options the library accepts. It is an immutable record: derive a variant with `with`. ```csharp CryptoPolicy p = CryptoPolicy.Standard with { MinRsaBitsNew = 3072 }; ``` ## Reference time Every validity question takes an explicit `DateTimeOffset at`. The policy never reads a clock; a caller that wants "now" passes `timeProvider.GetUtcNow()`. The instant means: - for a fresh validation: the moment of validation; - for a validation in the past (the AdES past-time model): the instant being asked about, for example the time of the signature timestamp; - for new data (`Sign`, `Encrypt`): the date does not matter, the "new data" rules apply. ## Decisions and reports Each question returns a `PolicyDecision`: `Verdict` (`Accepted`, `Flagged`, `Rejected`), `Use`, `Algorithm`, a stable `Rule` id (`hash.sha-1.rejected-from`), a `Reason` with the dates involved, and the `Reference` it comes from. Accepted-by-default decisions have empty rule, reason and reference. `CheckSignature` returns a `PolicyReport` with one decision per component (the hash named by the signature algorithm, including the hash inside RSASSA-PSS parameters, and the signer's key) and the worst verdict. `Flagged` means allowed but deprecated: callers surface it. An operation that is refused throws `PolicyViolationException` carrying the report. Validators and verifiers add the decisions to their per-signature results. ## Rules in `Standard` | Rule | Behaviour | Source | |---|---|---| | SHA-1 (signature hash) | never for new signatures (`hash.sha-1.new`); on verification accepted before 2011-01-01, flagged from it (`hash.sha-1.flagged-from`), rejected from 2031-01-01 (`hash.sha-1.rejected-from`) | NIST SP 800-131A Rev. 2 §3 (deprecated from 2011, retirement planned by 2030-12-31); RFC 9155 | | SHA-1 inside HMAC | accepted (no collision resistance needed) | RFC 6194 §3 | | MD5 | never for new; flagged from ~1999 and rejected from 2009-01-01 | RFC 6151 (certificate forgery, Dec 2008) | | RSA modulus, new data (sign, encrypt) | under 2048 bits rejected (`key.rsa.min-new`) | SP 800-131A Rev. 2 §3; SP 800-57-1 Rev. 5 Table 2 | | RSA modulus, existing data (verify, decrypt) | under 1024 rejected (`key.rsa.min-verify`); under 2048 accepted before 2014-01-01, flagged from it (`key.rsa.weak-from`) | same | | EC keys | under 224 bits rejected; under 256 flagged for new data | SP 800-57-1 Rev. 5 Table 2 | | Legacy ciphers (CAST5, IDEA, Blowfish, Twofish, 3DES) | decrypt flagged, never encrypt (`LegacyCipherMode.DecryptOnly`) | RFC 9580 §9.3 | | DES, RC2, RC4 | rejected | RFC 8429, RFC 7465 | | Unauthenticated encryption (bare CBC/CFB, OpenPGP SED) | rejected both ways; `AllowUnauthenticatedDecryption` / `AllowUnauthenticatedEncryption` opt in (SED decrypts only with the first) | RFC 9580 §5.7 | | PBKDF2 iterations | under 1,000 rejected, under 10,000 flagged | RFC 8018 §4.2; SP 800-132 | | Simple S2K (no iteration) | flagged | RFC 9580 §3.7.1 | | Argon2 memory | over `KdfLimits.MaxMemoryBytes` rejected | RFC 9106 §4 | ## `Strict` No SHA-1 or MD5 at any time; RSA at least 3072 bits for new data and 2048 to verify; EC at least 256 bits; legacy ciphers neither encrypt nor decrypt; PBKDF2 at least 100,000 iterations; every `Flagged` becomes `Rejected` (`FlagAsRejected`), with the reason noted. ## Limits The policy gathers the parser limits so one object configures a run: `Asn1Limits`, `X509Limits` and `KdfLimits` (each defaulting to the type's `Default`). The parsers take their limits as explicit parameters and use the defaults when given none; a caller passes `policy.X509Limits` and so on to apply the policy's. The limits are on whether or not a policy is used. ## Customising dates `HashRules` is a list of `AlgorithmRule(Name, FlagFrom, RejectFrom, RejectNew, Reference)`; replace it to move a cutoff. `RsaWeakFrom` moves the RSA deprecation date (`null` flags every sub-2048 key). The dates above are this library's choices from the cited documents, not a legal standard: a validation profile (EN 319 102-1, CA/B Forum) overrides them with its own. ## Where the library asks Every builder that signs new data takes an optional `CryptoPolicy` (default `Standard`): `CertificateBuilder.Create` / `CreateSelfSigned`, `CertificateSigningRequestBuilder.Create`, `RevocationListBuilder.Create`, `OcspRequestBuilder.Create`, `OcspResponseBuilder.CreateSuccessful`, `SignedDataOptions.Policy` for CMS and `CmsEncryptOptions.Policy` for encryption. A refusal is a `PolicyViolationException` listing the decisions. The two-phase `Prepare` methods take the policy and a time explicitly; their `Complete` does not ask again. Verification asks only when given a policy and a time: `Certificate.VerifySignature(key, policy, at)` and `PublicKeyInfo.VerifySignature(algorithm, data, signature, policy, at)` return a `SignatureVerification` whose `Policy` holds the decisions and whose status is `PolicyRejected` when the signature is correct but refused at `at` (not valid). CMS: `SignedDataVerifyOptions.Policy` / `ReferenceTime` and `CmsDecryptOptions.Policy` / `ReferenceTime`; see `cms.md`. `CheckContentCipher` (CBC inside a container that is meant to carry integrity) is flagged under `Standard`, rejected under `Strict`, and accepted when the matching `AllowUnauthenticated…` switch is on. `WebPki` is what WebPKI clients accept in certificate signatures: SHA-1 rejected from 2017-01-01 (CA/Browser Forum Baseline Requirements §7.1.3.2), RSA under 2048 bits rejected from 2014-01-01 (§6.1.5). The path validator applies it besides the caller's policy when the server-authentication profile is on. `Permissive` accepts every algorithm and key size the library can verify, with no dated cutoffs: the policy of conformance suites and of reading old data. The path validator takes a policy in `ValidationOptions.Policy` (default `Standard`) and asks `CheckSignature` for every certificate signature at the reference time; its per-certificate results carry the decision's rule id (`docs/validation.md`). # Issuing certificates and CRLs `RecursiveAI.OneCrypto.Issuance`: a certificate authority as a library object. It is I/O-free and reads no clock. Every time (notBefore, thisUpdate) is an argument, every key is an `X509Signer` (so in memory, in the operating system, on a token or remote), and every signing step has a two-phase form. ## The CA `CertificateAuthority(Certificate, X509Signer, CertificateAuthorityOptions?)` refuses a certificate that is not a CA certificate (basicConstraints cA, RFC 5280 §4.2.1.9) or whose key usage lacks keyCertSign (§4.2.1.3), and a signer whose key is not the certificate's. `CertificateAuthority.CreateRoot` makes a self-signed root under the root profile. Issuing is `Issue(CertificateSigningRequest, CertificateIssueOptions)` or `Issue(PublicKeyInfo, DistinguishedName, CertificateIssueOptions)`. - **From a request**: the request's signature is checked first (proof of possession, RFC 2986 §3); a bad one raises `IssuanceException` with rule `csr-signature`. Only the subject, the key and a subject-alternative-name request are taken from the request. Every other requested extension (a `CA:TRUE`, an extended key usage) is ignored: the profile decides what the certificate says. - **Serial numbers** are random: 16 octets from the system generator by default (at least 9 are required), with the top bit cleared and one bit fixed, leaving at least 70 random bits and a positive INTEGER of constant length (RFC 5280 §4.1.2.2; CA/Browser Forum TLS Baseline Requirements §7.1 asks for 64 bits). - **Validity**: `NotBefore` is the caller's; the length is `Validity`, or the profile's longest cut back to the end of the issuing certificate. A requested validity beyond either limit is refused (`validity`, `issuer-validity`). The issuing certificate must itself be valid at notBefore (RFC 5280 §6.1.3). - **Path length**: a CA certificate can only be issued under the issuer's constraint (`path-length`); the written value is the stricter of the requested one and the issuer's minus one. - **Policy**: the subject key (`CryptoPolicy.CheckKey`, new data) and the CA's signature algorithm are checked at notBefore; a rejection raises `PolicyViolationException`. `Standard` refuses RSA under 2048 bits and SHA-1. - **Extensions written**: basicConstraints (critical), keyUsage (critical), extendedKeyUsage, subjectAlternativeName (critical when the subject is empty, RFC 5280 §4.2.1.6), id-pkix-ocsp-nocheck for responder profiles, subjectKeyIdentifier and authorityKeyIdentifier (RFC 7093 method 1 / the issuer's), authorityInfoAccess (OCSP and caIssuers URLs) and cRLDistributionPoints from the CA options. `AdditionalExtensions` adds more (name constraints, policies); repeating one the CA writes is refused (`extensions`). - **Two phases**: `PrepareIssue(…, SignatureSpec)` returns a `PreparedSignature` whose `ToSign` any key holder can sign; `CompleteIssue` checks the signature against the CA key and returns the certificate. The state serialises (docs/keys.md). ## Profiles A `CertificateProfile` is data: key usage, extended key usages (and whether critical), basic constraints, subject alternative name rules and the longest validity, with an optional `ValiditySchedule` of dated limits. Presets: | Profile | Key usage | EKU | SAN | Longest validity | |---|---|---|---|---| | `Root` | keyCertSign, cRLSign; CA | none | none | 25 years | | `Intermediate` | keyCertSign, cRLSign; CA | none | none | 10 years | | `TlsServer` | digitalSignature | serverAuth | required: DNS or IP | schedule of TLS BR §6.3.2 (v2.3.1): 398 days before 2026-03-15, 200 days, 100 days from 2027-03-15, 47 days from 2029-03-15 | | `TlsClient` | digitalSignature | clientAuth | optional: DNS, e-mail, URI, IP | 398 days | | `SMime` | digitalSignature, nonRepudiation (+ keyEncipherment for RSA) | emailProtection | required: e-mail | 825 days | | `CodeSigning` | digitalSignature | codeSigning | none | 1185 days | | `OcspSigning` | digitalSignature | id-kp-OCSPSigning, ocsp-nocheck | none | 90 days | | `TimeStamping` | digitalSignature | id-kp-timeStamping, critical (RFC 3161 §2.3) | none | 15 years | Only the TLS server schedule comes from a requirements document; the other limits are our defaults for a development or private CA, meant to be overridden by building your own profile. ## CRLs and delta CRLs (RFC 5280 §5) `IssueCrl(CrlIssueOptions)`: `ThisUpdate`, `NextUpdate` (required: a list without one can never be known to be current), the `Number` (cRLNumber, at most 20 octets), the entries (`RevokedCertificateInfo`: serial, revocation date, reason code, invalidity date) and optionally the issuingDistributionPoint (URL, only-user / only-CA certificates) and the freshestCRL URL of the delta. With `DeltaBaseNumber` set it is a delta CRL: a critical deltaCRLIndicator, a number greater than the base, no freshestCRL on the delta (§5.2.4, §5.2.6). `removeFromCRL` is refused outside a delta (§5.3.1). The CA certificate must permit cRLSign and be valid at thisUpdate. `PrepareCrl` / `CompleteCrl` are the two-phase form. ## Checked against OpenSSL 3.5.9 `verify -x509_strict` (with `-purpose` where openssl has one) accepts a root → intermediate → leaf chain for each profile and key type; `crl -verify` accepts our CRL and delta CRL; `verify -crl_check -extended_crl -use_deltas` revokes through the base and delta lists as it should (openssl only reads a delta CRL, whose indicator is critical, with `-extended_crl`). The tests are in `tests/OneCrypto.Tests/Issuance/CertificateAuthorityTests.cs`. # OCSP responder (RFC 6960) `RecursiveAI.OneCrypto.Issuance.OcspResponder`: a message handler. Request octets in, a signed `OcspResponse` out; no network, no clock, no database. The host owns the transport (HTTP POST, or GET with the base64 path of RFC 6960 Appendix A, and the caching headers) and the revocation facts. ## Configuration (`OcspResponderOptions`) - `Issuer`: the CA the responder answers for. A CertID names a certificate by the hash of the issuer's name and key (§4.1.1); the responder recomputes both with the CertID's hash (SHA-1, SHA-256, SHA-384, SHA-512) and answers only for its own CA. - `ResponderCertificate` and `Signer`: either the `Issuer` itself (the CA answers directly), or a **delegated responder** certificate the CA issued with id-kp-OCSPSigning (§4.2.2.2); the handler checks that at construction (issued by the CA, signature valid, EKU present, key matches the signer) and includes the certificate in every response. The `OcspSigning` profile adds id-pkix-ocsp-nocheck (§4.2.2.2.1). - `ResponderId`: `ByKey` (SHA-1 of the key bits, the default) or `ByName` (§4.2.2.3). - `Source` (`IRevocationStatusSource`): certificate id and request time in, `RevocationStatus` out (good, revoked with time and reason, unknown; `ThisUpdate`, `NextUpdate`), or null for unknown. `InMemoryRevocationSource` is a small implementation for development CAs and tests. - `Preproduced` (`IPreproducedOcspResponses`), `MaxRequests` (16), `RequireSignedRequests`, `AuthorizeRequest`, `Policy` and `Limits` (64 KiB). ## Behaviour `Handle(requestBytes, now)` never throws for bad input; errors the client can cause are statuses (§2.3): | Situation | Response | |---|---| | unparsable or oversized request, no certificate ids, more than `MaxRequests`, nonce shorter than 1 or longer than 32 octets (RFC 8954 §2.1) | `malformedRequest` | | `RequireSignedRequests` and the request is unsigned | `sigRequired` | | `AuthorizeRequest` returns false | `unauthorized` (a host callback that throws: `internalError`). The callback is where a signed request's signature is checked and the requestor is matched to an allow-list | | every certificate asked about belongs to another CA | `unauthorized` (§2.3, RFC 5019 §2.2.3); in a mixed request the foreign ones are answered `unknown` | | the source says the answer is already stale (nextUpdate not after now) | `tryLater` | | the source throws, answers a future thisUpdate, revoked without a time, the responder certificate is not valid at `now`, or the policy rejects the signature | `internalError` | | otherwise | `successful`: one SingleResponse per request, in order, `producedAt = now`, the nonce echoed byte for byte | **Preproduced responses** (RFC 5019 §2.2.2): `Preproduce(ids, now)` signs a response without a nonce; store it with an `IPreproducedOcspResponses`. A request for one certificate without a nonce is answered with the stored response while it is current (thisUpdate ≤ now < nextUpdate, signature valid under the responder key); a request with a nonce is always signed fresh, since a stored response cannot echo it. **Two-phase**: `Prepare(request, now)` returns a `PreparedOcspResponse`: `Immediate` (an error or a preproduced response) or a `PreparedSignature` of the ResponseData. `Encode()`/`Decode` serialise it (`SEQUENCE { version, preparedSignature, certificates }`); `Complete(signature)` checks the signature and assembles the BasicOCSPResponse around the signed bytes. ## Checked against OpenSSL `ocsp` (3.5.9 pinned; 3.6.2 locally), CA-signed and delegated (RSA, P-256, Ed25519), good / revoked / unknown, with and without a nonce: requests we build answered by `ocsp -index … -rsigner` and verified by us (signature under the responder key, responder id, nonce echo, certificate id); requests from `ocsp -reqout` answered by our handler and accepted by `ocsp -respin … -reqin … -verify_other` (with `-reqin` alone, which checks the nonce echo, and with `-issuer -cert -no_nonce`, which prints the status; naming `-cert` together with `-reqin` makes openssl build a new request with a new nonce). Tests: `tests/OneCrypto.Tests/Issuance/OcspResponderTests.cs`; fuzz target `ocspresp`. ## Checking a response on the client side There is no separate OCSP verifier: `PathValidator` takes our `OcspBasicResponse` in `RevocationData.OcspResponses` and checks the responder (the issuing CA, or a delegate with id-kp-OCSPSigning issued by it) and the status of the certificate, reporting it per certificate (`docs/validation.md`). Tests run responses of CA-signed and delegated responders (P-256, Ed25519) through it, expecting good and revoked. # Path building and validation Normative behaviour of `PathValidator` in `RecursiveAI.OneCrypto.Validation` (core package). Clause numbers are RFC 5280 unless another document is named. The validator builds on the models of `docs/x509.md` and the signature dispatch of `docs/fallbacks.md`. ## What it is One function, `PathValidator.Validate(target, options)`, builds candidate paths from a certificate to a trust anchor and validates each by RFC 5280 §6.1, with revocation per §6.3 and RFC 6960. The verdict is a pure function of the inputs and an explicit `ReferenceTime`: nothing reads a clock, the operating system's chain engine is never consulted, and nothing is fetched. `OsTrustStore.LoadRoots()` can supply anchors; it is a source of certificates, not an engine, so the same inputs give the same verdict on every platform. Bad input is a report, never an exception. `ValidateAsync` is the same call plus two optional interfaces. When `CertificateSource` or `RevocationSource` is set it asks them for missing issuers and revocation data (given the `caIssuers`, CRL and OCSP locations in the certificates) and validates again, at most `MaxSourceRounds` times. Without a source it does no I/O. The interfaces are all this layer knows about fetching. ## Inputs | Option | Meaning | |---|---| | `TrustAnchors` | Certificates, or bare name and key (RFC 5937). Only these confer trust. | | `Intermediates` | An untrusted pool. | | `ReferenceTime` | Required. Validity, algorithm cutoffs and revocation freshness are judged at this instant. | | `RevocationDataTime` | When the revocation data is judged fresh (default: the reference time). | | `Revocation`, `RevocationRequired`, `UnknownRevocationIsFatal`, `RevocationModel` | See Revocation. | | `Policy` | The `CryptoPolicy` (`docs/policy.md`) asked about every certificate signature at the reference time: the hash its algorithm names (including the hash inside RSASSA-PSS parameters) and the issuing key. Default `CryptoPolicy.Standard`; `Strict` and `Permissive` (no cutoffs: what RFC 5280 requires and nothing more) are the other presets, and `with` derives variants. | | `RequiredExtendedKeyUsage`, `RequiredKeyUsage` | What the target must permit; extended key usage is chained through every CA that has the extension. | | `InitialPolicySet`, `InitialExplicitPolicy`, `InitialPolicyMappingInhibit`, `InitialAnyPolicyInhibit` | §6.1.1 (c) to (h). | | `ApplyAnchorNameConstraints`, `ApplyAnchorPolicyConstraints`, `CheckAnchorValidity` | What of an anchor certificate beyond its name and key is used (defaults: name constraints and validity yes, policy extensions no). | | `MaxIntermediates` | Hard cap on non-self-issued intermediates. | | `StrictStructure` | Default true. See Structure rules. | | `WebPki` | The server-authentication profile. | | `Limits` | See Limits. | ## Path building Candidates come from the anchors, then the pool. A candidate must have a subject equal to the issuer name (§7.1 comparison) and its key must verify the signature. Order is deterministic: anchors before pool; candidates whose subjectKeyIdentifier equals the authorityKeyIdentifier first (RFC 4158 §4.2), then those valid at the reference time, then those that can issue (a CA by basicConstraints, with keyCertSign if keyUsage is present: a decoy that cannot is tried after every candidate that can, so a pool full of them cannot use up the budget before the real issuer), then later `notAfter`, then the encoding. Depth-first search with backtracking; a certificate may appear once in a path and so may a subject-and-key pair, which removes the cycles that mutual cross-certification makes. Signature verifications are cached per certificate and key. A target that is itself an anchor certificate is a path of length one. Certificates with no issuer are listed in `Unchained`, and their `authorityInfoAccess` `caIssuers` locations in `IssuerLocations`; they are not fetched. The first path that validates ends the search. When none does and no limit was reached, the verdict is `Invalid`; when a work limit stopped the search (`LimitReached`) it is `Indeterminate` with a `Limit` result, because "no path found" is then not proof that none exists (RFC 4158 §3). The report carries the paths that were completed (at most `MaxReportedPaths`), the candidates refused with the reason, and the verdict of the path that got furthest. ## Rules applied to every certificate Each result carries a rule, an outcome (`Pass`, `Warning`, `Fail`, `Indeterminate`, `NotApplicable`), a reason in words and a clause. | Rule | What is checked | Clause | |---|---|---| | IssuerName | issuer equals the working issuer name | §6.1.3 (a)(4) | | Signature | verifies over the received `tbsCertificate` octets with the issuer's key | §6.1.3 (a)(1) | | Algorithm | the `CryptoPolicy`: one result per decision that is not a plain accept, carrying the policy's rule id (`RuleResult.PolicyRule`, for example `hash.sha-1.rejected-from`, `key.rsa.min-verify`) and its standard as the clause; `Rejected` fails, `Flagged` is a warning; a certificate with no finding gets one pass naming the policy. The key judged is the issuer's, on the signature it made, so a 1024-bit intermediate is reported on the certificates it signed. | `CryptoPolicy` | | Validity | `notBefore <= t <= notAfter` at the reference time truncated to whole seconds (certificate times have no fractions) | §6.1.3 (a)(2) | | NameConstraints | subject and subjectAltName against every constraint set above: directoryName (RDN-prefix), dNSName, rfc822Name, URI host, iPAddress; subject `emailAddress` and `SmtpUTF8Mailbox` (RFC 8398 §6) as rfc822Name; a trailing dot on a dNSName or URI host and percent-encoding in a URI host are normalised first (RFC 3986 §2.1, §3.2.2); self-issued intermediates exempt; a wildcard dNSName overlaps an excluded subtree it could reach; a critical constraint on a name form that cannot be evaluated fails | §6.1.3 (b)(c), §4.2.1.10 | | Policy | the valid_policy_tree with mappings, `inhibitAnyPolicy`, `requireExplicitPolicy`, `inhibitPolicyMapping`, the user-initial-policy-set, bounded by `MaxPolicyNodes` | §6.1.3 (d)–(f), §6.1.4, §6.1.5 | | BasicConstraints, PathLengthConstraint | CA certificates assert cA; pathLenConstraint counts non-self-issued intermediates; an anchor's own constraint applies | §6.1.4 (k)–(m) | | KeyUsage | keyCertSign on CAs, if keyUsage is present | §6.1.4 (n) | | ExtendedKeyUsage | the required purpose is allowed by every extKeyUsage in the chain, and by the target's own when the target is itself a trust anchor | §4.2.1.12 | | CriticalExtension | every critical extension is one the validator processes | §6.1.4 (o), §6.1.5 (f) | | Structure | see below | §4 | | TrustAnchor | the anchor certificate is valid at the reference time; with `StrictStructure` its form is checked too | RFC 5937 §3 | | Revocation | see below | §6.3, RFC 6960 | | HostName, WebPkiProfile | profile only | RFC 9525, Baseline Requirements | ### Structure rules Always: a malformed extension value, a repeated extension, `notAfter` before `notBefore`. With `StrictStructure` (default): a non-empty issuer; a positive serial number of at most 20 octets (not checked on an anchor, which is trusted for its name and key); a critical subjectAltName or an empty subject without one; extensions only in version 3; subjectKeyIdentifier, authorityKeyIdentifier and authorityInfoAccess not critical, policyConstraints critical; a CA certificate with subjectKeyIdentifier and a critical basicConstraints; an authorityKeyIdentifier with a keyIdentifier on every certificate that is not self-issued (not on an anchor); keyCertSign only with cA; nameConstraints only in CAs, critical (unless the web profile is on, where browsers accept it non-critical), with at least one subtree, no minimum or maximum and no leading period in a dNSName constraint; dNSName and rfc822Name in preferred syntax; signature-only keys (ML-DSA, SLH-DSA, Ed25519, Ed448) without key-agreement or encipherment usage; an anchor certificate that is version 3 with basicConstraints and no unrecognised critical extension. ## Revocation Data is supplied (`RevocationData`: CRLs and basic OCSP responses); nothing is fetched. `RevocationRequired` chooses which certificates need it (`None`, `EndEntity`, `AllCertificates`); data that is supplied is used for every certificate regardless. Each certificate gets a `RevocationReport`: `Good`, `Revoked` (with time and reason) or `Unknown` with the reason (no data, stale, not yet valid, out of scope, unauthorised signer, an unusable CRL). Offline, with no data, a certificate is `Unknown`, never `Good`. `Unknown` for a certificate that `RevocationRequired` covers makes the verdict `Indeterminate` by default (`UnknownRevocationIsFatal` is true: a path nothing has shown to be unrevoked is not reported valid). A caller who sets it false gets `Valid` with `ValidationReport.RevocationSummary` = `Unknown` and reads the status itself. With `RevocationRequired` at `None` nothing is required and the verdict is the path's. CRLs (§6.3.3): applicable by issuer, issuingDistributionPoint against the certificate's distribution points (full and relative names, `cRLIssuer`, indirect CRLs with `certificateIssuer`), `onlyContainsUserCerts`, `onlyContainsCACerts`, `onlyContainsAttributeCerts`, and reason scope; good only when the usable CRLs together cover all reasons. A CRL is usable when it has no unrecognised critical extension, thisUpdate is not after and nextUpdate (mandatory, §5.1.2.5) not before the data time, its signer's keyUsage has cRLSign, and the signature verifies. A signer other than the issuing certificate (a separate CRL-signing certificate of the same name, or an indirect CRL's issuer) is validated as a path of its own with the caller's revocation settings, a few levels deep, and only to the trust anchor of the certificate's own path (§6.3.3 (f)): another trusted root cannot vouch for a CA it does not certify. A CRL with a critical entry extension other than reasonCode, invalidityDate and certificateIssuer is unusable for every certificate (§5.3). A certificate found on a CRL is revoked whatever the entry's reason; the CRL's reason scope only decides what it can show good (§6.3.3 (i)-(j)). With `StrictStructure`, a CRL without cRLNumber, with it critical, or listing a serial twice is unusable. Delta CRLs apply to the complete CRL with the same issuer and issuingDistributionPoint whose `BaseCRLNumber` does not exceed its number (§5.2.4); `removeFromCRL` lifts a hold. Serial numbers compare as integers. OCSP (RFC 6960): the response must name the certificate by `CertID` (own hash algorithm), be signed by the issuing CA, by a responder the caller trusts, or by a delegated responder with a certificate issued by the CA that carries `id-kp-OCSPSigning` and is valid; the signature verifies; `thisUpdate` is not in the future and the response is current until `nextUpdate` (or `OcspMaxAge`, default seven days, when there is none). The delegated responder's own revocation is not chased (the `id-pkix-ocsp-nocheck` case needs no check; without it OpenSSL-compatible leniency is used and the report says nothing about the responder). Nonce checking belongs to the caller who made the request. ## Time models `ReferenceTime` is any instant. `RevocationModel.Current` is RFC 5280: a certificate on a CRL is revoked whatever the date. `RevocationModel.PastTime` validates as of an earlier instant with revocation data collected later (`RevocationDataTime`): a certificate revoked after the reference time for a reason other than keyCompromise, cACompromise, aACompromise or unspecified is good at the reference time; a "good" answer needs data issued at or after the reference time. This is the intent of ETSI EN 319 102-1 §5.6.2.1 [agent reading]. What it does not do, and the AdES validation does: the long-term-validation algorithm (choosing the best signature time from timestamps and archive timestamps, the grace period for revocation publication, `invalidityDate`, validating the revocation data's own past-time chain). Those inputs reach the validator as the reference time and the data time. ## Server-authentication profile `WebPkiProfile` is a layer of extra rules, not a second validator. With `EnforceBaselineRequirements` the `CryptoPolicy.WebPki` cutoffs (SHA-1 certificate signatures rejected from 2017-01-01, RSA under 2048 bits from 2014-01-01) apply besides `Policy`, and a certificate must satisfy both. It requires, on top of the RFC 5280 rules: serverAuth in every extKeyUsage of the chain (and, with `EnforceBaselineRequirements`, a target with a non-critical extKeyUsage that names serverAuth and not anyExtendedKeyUsage); a dNSName or iPAddress subjectAltName; a target that is not a CA and whose keyUsage lacks keyCertSign and cRLSign; RSA keys of at least 2048 bits that are a multiple of 8, ECDSA keys on a named P-256, P-384 or P-521; no extKeyUsage on a root and a root authorityKeyIdentifier that only repeats its subjectKeyIdentifier; a non-critical subjectAltName when the subject is not empty. Host matching (RFC 9525 §6.3): subjectAltName only, never the common name; IP addresses exactly; a wildcard is only a whole left-most label standing for exactly one label, and at least two labels follow it; that label may be an A-label host (`*.example.test` matches `xn--bcher-kva.example.test`), because RFC 9525 §6.3 forbids only a wildcard inside an IDN label, which is never a pattern here. `RequireCommonNameInSan` (off by default) adds the Baseline Requirements' rule that a commonName repeats a subjectAltName entry. ## Limits All on by default and reported (`LimitReached`), never thrown. | Limit | Default | |---|---| | `MaxPathLength` | 16 certificates | | `MaxCandidatesPerCertificate` | 64 | | `MaxSearchSteps` | 20,000 | | `MaxSignatureChecks` | 256 (cached per certificate and key) | | `MaxPathsValidated` | 32 | | `MaxReportedPaths` | 16 | | `MaxPolicyNodes` | 4,000 | | `MaxNameConstraintComparisons` | 2,000,000 per path | | `MaxRevocationObjectsPerCertificate` | 64 | | `MaxSourceRounds` | 8 | Parsing limits are `X509Limits` (`docs/x509.md`). The two published denial-of-service shapes are tested (`ValidatorTests`): a policy-tree explosion (twelve CAs each asserting ten policies and mapping every policy to the others, 10^12 nodes without the bound) ends at the node limit with a `Policy` failure; a lattice of cross-certificates (eight levels of six keys, every key certified by every key above, 6^8 paths) ends at the step limit with `LimitReached`. Both finish in a fraction of a second. ## Test results Pins: x509-limbo `1080e565`, NIST PKITS v1.0.1 (April 14, 2011; `PKITS_data.zip` sha256 `592f6603…665665`, `PKITS.pdf` sha256 `506913f4…a74d9ea`), BetterTLS `bc5adea1` (tests exported by the generator built from that commit; root CA fixed per cache), OpenSSL 3.5.9, Go 1.26.8. The harnesses are `tests/OneCrypto.Tests/Validation/{LimboTests,PkitsTests,BetterTlsTests,DifferentialCases}.cs`; `tools/fetch-pkits.sh`, `tools/fetch-bettertls.sh`, `tools/run-validation-vectors.sh` and `tools/diff/` fetch and run them. ### x509-limbo 9,802 testcases, each run with the web profile for `webpki::` and `bettertls::` and with plain RFC 5280 rules (plus host matching) for the others; CRLs supplied with a testcase are required for every certificate and unknown status is fatal. | Category | Testcases | Agree | Documented deviations | |---|---|---|---| | bettertls | 9,572 | 9,572 | 0 | | rfc5280 | 109 | 108 | 1 | | webpki | 56 | 54 | 2 | | crl | 17 | 17 | 0 | | online | 14 | 14 | 0 | | pathlen | 13 | 13 | 0 | | pathological | 11 | 11 | 0 | | rfc9881 | 6 | 6 (3 only on a platform with ML-DSA) | 0 | | cve | 3 | 3 | 0 | | invalid | 1 | 1 | 0 | Deviations (asserted by the test, which fails on any other difference): * `rfc5280::aki::cross-signed-root-missing-aki` (expected failure, `pedantic-rfc5280`): a trust anchor is trusted for its name and key (RFC 5937 §2); its authorityKeyIdentifier is not demanded. Demanding it would reject `cve::cve-2024-0567`, where the trusted root is a cross-signed certificate without one and the expectation is success. * `webpki::san::public-suffix-multi-label-wildcard-san` and `…-private-namespace-wildcard-san` (`pedantic-public-suffix-wildcard`): deciding that `*.s3.amazonaws.com` covers a registry-controlled suffix needs the Public Suffix List, which the library does not carry. Wildcards directly under a single label (`*.com`) are refused. * `rfc9881::ml-dsa-*`: need a platform with ML-DSA (OpenSSL 3.5 or newer); on one without it the signature check reports `AlgorithmNotSupported` naming the platform and the testcase is skipped in the assertion. ### NIST PKITS 247 runs of 224 tests in sections 4.1 to 4.16 (the manifest `Data/pkits-tests.json` is generated from the PKITS document by `tools/extract-pkits.py`; where the document states a result as conditions on the inputs, the run uses the default settings or the numbered settings the document lists). Reference time 2020-06-01; revocation required for every certificate with unknown status fatal; `StrictStructure` off, because PKITS predates the profile rules about key identifiers and criticality. | Section | Runs | Agree | |---|---|---| | 4.1 Signature verification | 6 | 4 | | 4.2 Validity periods | 8 | 8 | | 4.3 Name chaining | 11 | 11 | | 4.4 Basic revocation | 21 | 21 | | 4.5 Self-issued certificates | 8 | 8 | | 4.6 Basic constraints | 17 | 17 | | 4.7 Key usage | 5 | 5 | | 4.8 to 4.12 Policies, mappings, inhibit | all | all | | 4.13 Name constraints | 38 | 38 | | 4.14 Distribution points | 35 | 35 | | 4.15 Delta CRLs | 10 | 10 | | 4.16 Private extensions | 2 | 2 | Totals: 245 of 247 agree. The two deviations are 4.1.4 and 4.1.5 (valid DSA signatures, including parameter inheritance): the library has no DSA verification, so the paths are refused (a certificate with a DSA key reports `AlgorithmNotSupported`); 4.1.6 (invalid DSA signature) is refused for the same reason. ### BetterTLS Exported with the generator at `bc5adea1` and run with the web profile and the test's host name: nameconstraints 9,491 of 9,491 agree (590 accept, 7,759 reject, 230 accepts and 912 rejects marked warnings by the suite); pathbuilding 81 of 81 agree (45 accept, 36 reject). ## Differential against OpenSSL and Go `tools/diff/export-cases.py` writes the chains of limbo (the 9,785 testcases without CRLs) and the two BetterTLS suites (9,572; 81 + 9,491) as 19,357 cases; `openssl_driver.py` runs `openssl verify` 3.5.9 (`-attime`, `-purpose sslserver` for the web profile, `-verify_hostname` / `-verify_ip`, `-verify_depth`), `tools/diff/main.go` runs Go's `crypto/x509` (`Verify` with `CurrentTime`, key usages, then `VerifyHostname`), and the library's `DifferentialCases` test runs ours. `compare.py` prints the matrix. All three agree on 18,916 of 19,357 cases. The other 441 are differences between one or both oracles and us. In 437 of them ours equals the published expected result of the corpus; the other 4 are the deviations above (the cross-signed root, and three ML-DSA cases that the platform of this run cannot verify; they pass on OpenSSL 3.5). The 441 split by the oracle's own message: * **OpenSSL accepts, ours rejects (193 cases: 122 limbo, 71 BetterTLS).** The oracle accepts a rule violation that RFC 5280 or the Baseline Requirements forbid and the corpus expects rejected: key identifiers and criticality, zero, negative or over-long serial numbers, subjectAltName syntax, nameConstraints in a non-CA, root extKeyUsage and authorityKeyIdentifier fields, key types and sizes (DSA, P-192, RSA bit lengths that are not a multiple of 8, 1024-bit RSA), a CA used as a server certificate, anyExtendedKeyUsage and critical extKeyUsage on a server certificate, SHA-1 signatures at a reference time after 2017, and host names matched against the commonName when the certificate has no dNSName entry (OpenSSL falls back to the subject; RFC 9525 §2 forbids it). * **OpenSSL rejects, ours accepts (229 cases).** 148 are "permitted subtree violation" that come from the commonName: for a leaf whose only subjectAltName is an iPAddress and whose subject is `CN=test.localhost`, with a constraint permitting `DNS:example.com`, OpenSSL reports "permitted subtree violation" because it applies DNS constraints to a host-name-shaped commonName; the names RFC 5280 §4.2.1.10 constrains are the subject's distinguished name and the subjectAltName entries, and the corpus expects acceptance (checked by hand on bettertls cases 05245, 05247 and 05248). 33 more are "excluded subtree violation", checked case by case (see below): 32 are the same commonName effect as the 148, and one is an alternative-path case. The rest are OpenSSL's `unable to get local issuer certificate` (31), `unsuitable certificate purpose` (8), `unable to get issuer certificate` (3), `certificate has expired` (2) and `invalid CA certificate` (4) on chains the corpus expects accepted (alternative paths through the pool that OpenSSL did not try, and its purpose checks on CA certificates); these were not examined case by case beyond the corpus expectation. * **Go accepts, ours rejects (44 cases):** the same pedantic rules as OpenSSL; `crypto/x509` does not enforce them. * **Go rejects, ours accepts (5 cases):** Go's messages are "unhandled critical extension" (2), "too many intermediates for path length constraint" (1), "a root or intermediate certificate is not authorized to sign" (1) and "certificate has expired or is not yet valid" (1: the reference time is 5 ms past `notAfter`; certificate times have whole-second granularity, ours truncates the reference time to the second, and limbo marks the case ambiguous and expects acceptance). The first four match the corpus expectation and were not examined further. ### The 33 "excluded subtree violation" cases Inspected with `tools/diff/explain.py` on the OpenSSL 3.5.9 run of 2026-10-31. Thirty-two are 16 BetterTLS name-constraints tests (5194, 5196, 5203, 5205, 5221, 5223, 5230, 5232, 5926, 5928, 5935, 5937, 5980, 5982, 5989, 5991), each present twice in the differential under its two corpus names. In every one the leaf's only subjectAltName is `IP Address:127.0.0.1` and its subject is `CN=test.localhost` or `CN=bad.example.com`; the intermediate carries a critical nameConstraints with an excluded `DNS:localhost` or `DNS:bad.example.com` (some also with permitted and excluded IP ranges that the address satisfies). OpenSSL applies the excluded DNS subtree to the host-name-shaped commonName and reports "excluded subtree violation"; the leaf has no dNSName, so RFC 5280 §4.2.1.10 has nothing to exclude, and Go agrees with us. The corpus expects acceptance. The thirty-third is limbo `nc-forbids-same-chain-ica`, the alternative-path class of the table below (the path through the constrained intermediate violates, the one through the other issuer does not). Two further lines contain the phrase only in our own message (OpenSSL accepts, ours rejects, corpus expects rejection): `cve-2025-61727` and `nc-forbids-dnsname-wildcard-san`, a wildcard dNSName inside an excluded subtree, which OpenSSL does not test. ### OpenSSL rejects, ours accepts, checked per class Every class below was checked against the certificates (`tools/diff/explain.py` prints subject, issuer, validity, subjectAltName, basicConstraints, keyUsage, extKeyUsage and nameConstraints of a case; `tools/diff/classes.py` groups the disagreements by oracle message). The corpus expects acceptance in all of these cases except one (`cross-signed-root-missing-aki`, a documented deviation). | Class (OpenSSL message) | Cases | What the certificates show | RFC reading | |---|---|---|---| | permitted / excluded subtree violation | 181 (148 + 33) | In 176 the leaf has no dNSName at all (an iPAddress-only subjectAltName, subject `CN=test.localhost`); OpenSSL applies the DNS constraint to the commonName. In the other 5 the leaf has a dNSName that the constraint permits, and a second candidate issuer sits in the pool: the path through the constrained intermediate violates, the path through the other issuer does not (`nc-forbids-same-chain-ica`, `nc-forbids-alternate-chain-ica`, pathbuilding tc50, tc56). | RFC 5280 §4.2.1.10 constrains the subject distinguished name and subjectAltName entries, and a commonName is not a dNSName; §6.1 is applied to a path, and a valid path exists. | | unable to get (local) issuer certificate | 34 | Cross-sign pools (BetterTLS pathbuilding; limbo `cve-2024-0567`, `root-and-intermediate-swapped`): two intermediates with one subject, one issued by an untrusted root. OpenSSL follows the candidate whose issuer is absent and does not go back; a path to the trust anchor through the other candidate exists (read through on pathbuilding 00002). | RFC 5280 §6.1 validates a given path; any valid path suffices, and building is left to the implementation. | | unsuitable certificate purpose | 8 | A candidate intermediate carries `extKeyUsage emailProtection` only; the alternative intermediate has no EKU (pathbuilding 00051). | §4.2.1.12: an absent EKU allows every purpose, so the path through the alternative is valid. | | invalid CA certificate | 4 | A candidate issuer has keyCertSign but no basicConstraints (pathbuilding 00052); a candidate with basicConstraints exists. | §4.2.1.9, §6.1.4(k): the unsuitable candidate is skipped, the other path is valid. | | certificate has expired | 4 | Two (pathbuilding 00049, 00055): an alternative intermediate expired an hour before the reference time, and a valid one exists. Two (`notafter-exact`, `notafter-fractional`): the reference time equals `notAfter` (fractional: 5 ms later, truncated to the second). | §4.1.2.5: the validity period runs from notBefore through notAfter, inclusive; certificate times have whole-second granularity. | The OpenSSL-accepts classes (193) are each one named rule of our report with its section in the message, and all have the corpus expectation REJECT; the large ones belong to the web profile: a server certificate needs a dNSName or iPAddress (73), no dNSName entry matches the host (64, commonName fallback, RFC 9525 §2) and SHA-1 after the cutoff (6). Go-rejects (5): `nc-forbids-othername-noop` and `permitted-dn-match` carry a critical extension Go does not handle, and `self-issued-certs-pathlen` and `permitted-self-issued` need the self-issued exemptions (name constraints: §6.1.3 (b), (c) and §4.2.1.10; path length: §6.1.4 (l)), which Go does not apply. `python3 tools/diff/classes.py ours.tsv openssl.tsv go.tsv cases.jsonl` regenerates the full class list. The per-case disagreement list is regenerated by `compare.py` and is not committed. ## Determinism and platforms No dependence on the platform beyond signature verification, which is by the dispatch of `docs/fallbacks.md`: the same path gives the same verdict wherever the algorithm is available, and `AlgorithmNotSupported` names the platform where it is not. The validator uses no reflection, no clock, no `X509Chain`; `ChainDiagnostics.Compare` runs `X509Chain` offline beside it and reports where the two disagree, informationally. ## Left to later work AIA, CRL and OCSP fetching (the `.Online` package behind `ICertificateSource` and `IRevocationSource`); the EN 319 102-1 validation process and TS 119 102-2 reports; certificate-transparency and trusted-list inputs; DSA and Brainpool-less platforms; the Public Suffix List for wildcards. # Validator verdict review An independent read of the path validator's verdict logic (`src/OneCrypto/Validation/*`, behaviour in `docs/validation.md`) against RFC 5280 §6, RFC 6960 and the Baseline Requirements, written by someone who did not write it. Method: the code was read rule by rule; adversarial chains were built with the library's own builders; every finding has a test that asserts the behaviour the RFC asks for, so it fails today and carries `Skip = "review finding Rn"`. Tests: `tests/OneCrypto.Tests/Validation/ReviewFindingTests.cs` (findings), `ReviewCoverageTests.cs` (what was found correct, running), `ReviewSupport.cs` (chain helpers). Nothing under `src/` was changed. Un-skipping a test is how a fix is accepted. **Status.** All eight findings and O1 are fixed, and the finding tests run (no Skip). R1: candidates that cannot issue are tried last and a search stopped by a limit with no valid path is `Indeterminate`; R2: the CRL signer is validated to the path's own anchor; R3: a listed entry counts whatever its reason; R4: a critical unknown entry extension makes the CRL unusable (a critical `holdInstructionCode` too, since it is not processed); R5: `SmtpUTF8Mailbox` is compared as rfc822Name; R6: one trailing dot and percent-encoding are normalised before comparison; R7: the required purpose is checked on an anchor target; R8: `*.` matches an A-label host. O1: `UnknownRevocationIsFatal` now defaults to true. O7: the citations are corrected in `validation.md`, and the 33 excluded-subtree cases are explained there. The text below is the review as written. Findings: 8. By severity class: accept-invalid 6 (R2–R7), reject-valid 2 (R1, R8). R1 is also a denial-of-service shape. No wrong-reason or report-inconsistency findings; see "Reports". ## Findings | Id | Class | Impact | Clause | Test | |---|---|---|---|---| | R1 | reject-valid, DoS | medium | RFC 4158 §3; RFC 5280 §6.1 (any valid path suffices) | `R1DecoyIssuersCannotCrowdOutTheValidPath` | | R2 | accept-invalid | high (offline revocation) | RFC 5280 §6.3.3 (f) | `R2CrlSignerMustChainToTheSameAnchorAsTheCertificate` | | R3 | accept-invalid | medium | RFC 5280 §6.3.3 (i)–(j) | `R3ListedEntryOutsideTheCrlReasonScopeIsStillRevoked` | | R4 | accept-invalid | low–medium | RFC 5280 §5.3 | `R4CrlWithAnUnknownCriticalEntryExtensionIsNotUsed` | | R5 | accept-invalid | low (S/MIME use) | RFC 8398 §6, RFC 5280 §4.2.1.10 | `R5Rfc822ExclusionAppliesToSmtpUtf8Mailbox` | | R6 | accept-invalid | low | RFC 5280 §4.2.1.10, RFC 3986 §3.2.2 | `R6TrailingDotUriHostIsInsideTheExcludedSubtree` | | R7 | accept-invalid | low | RFC 5280 §4.2.1.12 (caller's purpose) | `R7RequiredPurposeAppliesWhenTheTargetIsAnAnchor` | | R8 | reject-valid | low | RFC 9525 §6.3 | `R8WildcardMatchesAnAceLabelHost` | **R1: an untrusted pool can exhaust the path budget and turn a valid chain into `Invalid`.** `Engine.Candidates` orders issuers by key identifier, validity at the reference time, then *later* `notAfter` first. The pool is attacker-supplied in the usual setting (certificates embedded in a signature or message). Forty extra certificates for the genuine CA's name and key, issued by the trusted root with a later `notAfter` but no `keyCertSign`, each complete a path that fails; the search stops at `MaxPathsValidated` (32) and the genuine CA (which expires sooner) is never tried. `Assemble` then reports `Invalid` because a path completed, although `LimitReached` is set and a valid path exists. The same steering works through `MaxCandidatesPerCertificate` (64) with decoys that merely fail signature checks. Observed: verdict `Invalid`, deciding rule `KeyUsage`, `LimitReached = true`, 16 reported paths. Fix: when a limit stopped the search and no path won, the verdict is `Indeterminate`, never `Invalid`; and rank or drop candidates that fail the cheap issuer rules (CA basicConstraints, keyCertSign, validity, same subject-and-key already seen) before descending, so duplicates of one subject-and-key pair cost one candidate. **R2: the CRL signer is validated against any anchor, not the certificate's anchor.** `UsableCrl`/`ValidateSigner` build the signer's path with all of `TrustAnchors`. RFC 5280 §6.3.3 (f) requires the trust anchor of the CRL issuer's path to be the one used for the target. Any second trusted root can certify a key for the first CA's *name* and sign an empty CRL; the validator reports `Good` for a certificate whose CA never vouched. In the test two anchors are trusted, RootB issues a CRL-signing certificate named like RootA, and a CRL signed by it makes a RootA leaf `Good` ("no applicable CRL lists the certificate…"). The risk is real wherever the anchor set is large (an OS store) and revocation data arrives from the other party. Note that with `UnknownRevocationIsFatal = true` the attack is blocked only by accident (the signer's own revocation check finds the forged CRL inapplicable); the default is non-fatal. Fix: construct the signer's sub-validation with the selected path's anchor only. **R3: a listed entry whose reason is outside the CRL's reason scope counts as good.** `StatusIn` returns `Good` ("listed with a reason outside the CRL's scope") when the entry's reason is not in the CRL's `onlySomeReasons`/DP-reasons coverage, and another CRL then supplies the missing coverage. RFC 5280 §6.3.3 (i)–(j) set `cert_status` from the entry found, with no reason test; the reason masks only decide when the answer is complete. Test: one CRL scoped to `superseded` lists the leaf as `keyCompromise`, a second CRL covers the other reasons and lists nothing; the verdict is `Valid`. The code comment cites §6.3.3 (a); that step is about CRL selection. Fix: any entry found is `Revoked`; keep coverage only for deciding `Good`. **R4: critical CRL entry extensions are ignored.** RFC 5280 §5.3: a CRL with a critical entry extension the application cannot process must not be used for any certificate. `UsableCrl` checks only CRL-level extensions. A CRL carrying an unknown critical extension on an unrelated entry still yields `Good` for the certificate. Fix: unusable when any entry has a critical extension other than the ones read (reasonCode, invalidityDate, certificateIssuer, holdInstructionCode); critical certificateIssuer only on an indirect CRL. **R5: `SmtpUTF8Mailbox` bypasses rfc822Name constraints.** `NameConstraintState.CheckName` sends `OtherName` to the "form we cannot compare" branch, which fails only when a *critical constraint of the same form* exists. RFC 8398 §6 extends rfc822Name constraints to `SmtpUTF8Mailbox` subjectAltNames. A CA excluding `excluded.test` can still be used for an otherName mailbox at that domain. Only matters to consumers that read the mailbox identity (S/MIME). Fix: compare the otherName value of `id-on-SmtpUTF8Mailbox` (1.3.6.1.5.5.7.8.9) as an rfc822Name. **R6: fully qualified hosts escape name constraints.** `UriInside` compares the URI host text exactly, so `https://example.test./x` is outside an excluded `example.test` (the trailing dot names the same host, RFC 3986 §3.2.2). `DnsInside` has the same blind spot for a dNSName with a trailing dot when `StrictStructure` is off (preferred-syntax check is the only guard). Percent-encoded reg-names were not tested. Fix: strip one trailing dot, and decode percent-encoding, in the URI host before comparison; strip a trailing dot in `DnsInside`. **R7: `RequiredExtendedKeyUsage` is skipped when the target is itself an anchor.** The `targetIsAnchor` branch of `ValidatePath` runs structure, profile, critical-extension and key-usage checks but not the extKeyUsage check. A self-signed anchor certificate whose EKU is serverAuth only is `Valid` for codeSigning. Fix: run the same EKU check in that branch. **R8: a wildcard never matches an `xn--` host.** `DnsMatches` rejects any host starting with `xn--` against `*.example.test`. RFC 9525 §6.3 forbids wildcards *within* an IDN label (`xn--*`, `b*cher`); a whole left-most label wildcard stands for exactly one label whatever it holds, and browsers match it. The behaviour is not in `docs/validation.md`. Fix: drop the `xn--` clause, or document it as a deliberate deviation. ## Observations (not findings; no test) * **O1, default is fail-open for revocation.** With defaults (`RevocationRequired = None`, `UnknownRevocationIsFatal = false`) the verdict is `Valid` and `RevocationSummary` is `NotChecked`; with `RevocationRequired` set and no data it is `Valid` with `Unknown`. This is documented and tested (`IsValid` is the path verdict). It does not meet "offline never reads as valid" for a caller that only reads `IsValid`. Suggest: `RevocationRequired != None` implies unknown is `Indeterminate` unless the caller opts out, or rename the flag so the opt-out is explicit. * **O2, a stale CRL that lists the certificate is ignored.** A CRL past `nextUpdate` that lists the certificate gives `Unknown`, not `Revoked`. Defensible under §6.3.3 (a) but the report should say the stale data named the certificate. * **O3, delegated OCSP responder revocation is not checked.** RFC 6960 §4.2.2.2.1 lets local policy decide when the responder certificate has no `nocheck`, CRL or AIA pointer; if it has a pointer the client should follow it. The code never checks. Documented as leniency; report-only suggestion. * **O4, `removeFromCRL` lifts any base entry.** RFC 5280 §5.3.1 defines it for `certificateHold` only. The delta is CA-signed, so the exposure is a CA error; a check is cheap. * **O5, work limits are per engine.** `ValidateSigner` builds a fresh `Engine` (own step and signature budgets) per CRL signer, up to three levels deep. The amplification was not measured. The pool is also deduplicated quadratically and scanned linearly per search step (`Candidates`), and every pool certificate is parsed eagerly; a pool of tens of thousands was not tried. * **O6, the accepted-policy set in `PolicyReport` lists policies from every tree level.** The RFC 5280 §6.1 output sets are defined on the final tree; the verdict is unaffected. * **O7, documentation.** `docs/validation.md`, in the Go-differential paragraph, cites §6.1.4(h) for the self-issued exemptions (§6.1.4(h) only skips the counter decrements). The name-constraint exemption is in §6.1.3 (b)/(c) and §4.2.1.10; the path-length one is §6.1.4 (l). The 33 "excluded subtree violation" differential cases are stated as not inspected one by one; they remain unexplained until someone does. ## Checked and correct Each item was read against the RFC text; those marked (T) also have a running test in `ReviewCoverageTests`. * **§6.1.3 basic path checks:** issuer-name chaining, signature over the received TBS octets, validity at the second-truncated reference time (both ends inclusive), unrecognised critical extensions on every certificate including the last (T), duplicate and malformed extensions, order of checks (a)–(f). * **Constraints:** basicConstraints cA on every issuing certificate, pathLenConstraint arithmetic including self-issued exemption and the anchor's own constraint (§6.1.4 (k)–(m)), keyCertSign (n). Name constraints: intersection of permitted sets and union of excluded sets across CAs, self-issued exemption except for the last certificate, subject DN prefix match, subject `emailAddress` as rfc822Name, dNSName label boundaries (T), IP masks of both families (T), wildcard SAN versus excluded subtree (T), URI host rules for ordinary hosts, critical constraint on an incomparable form fails, comparison budget. * **Policy (§6.1.3 (d)–(f), §6.1.4 (a),(b),(h)–(j), §6.1.5):** tree construction, anyPolicy under `inhibitAnyPolicy` and the self-issued rule, mappings (anyPolicy rejected, inhibited mapping deletes nodes and prunes), `requireExplicitPolicy`/`inhibitPolicyMapping` counters, the wrap-up intersection with the initial policy set including replacement of the anyPolicy leaf, the node bound. PKITS 4.8–4.12 agree in full. * **Extended key usage:** required purpose allowed by every EKU in the chain, `anyExtendedKeyUsage` accepted, absent EKU unrestricted. * **CRLs:** issuer match, IDP scope flags (`onlyContainsUserCerts`/`CACerts`/`AttributeCerts`, `onlySomeReasons`) (T), distribution point matching for full and relative names, indirect CRLs with `certificateIssuer` carry-over, reason coverage must be complete for `Good`, `nextUpdate` mandatory and freshness, cRLSign on the signer (T), a CRL signed by another key of the same CA accepted only through a valid, cRLSign-bearing certificate (T), a forged CRL from an untrusted same-name certificate rejected (T), delta CRL matching (issuer, IDP equality, `BaseCRLNumber`, number ordering, signature, freshness), serial numbers compared as integers. * **OCSP:** `CertID` hash algorithm limited to SHA-1/SHA-2/SHA-3 (no MD5), name and key hash computed over the issuer's received name and key bits, serial compared as integers, responder authorisation by CA, trusted responder or delegated responder with `id-kp-OCSPSigning` issued by the CA and valid (T), non-signer certificate of the CA refused (T), expired responder refused (T), `thisUpdate` in the future and stale responses ignored, a `CertID` over another issuer key does not match (T), `Revoked` from any source wins. * **Past-time model:** by reason — superseded, certificateHold and cessation after T are good at T; keyCompromise, cACompromise, unspecified and absent reason after T, and any reason at or before T, are revoked; a "good" answer needs data issued at or after T (T). * **Unknown handling:** no data, stale data and inapplicable data are `Unknown`; a required `Unknown` is `Indeterminate` when fatal; the summary is the weakest status (T). * **Path building:** an invalid path never produces `Valid`; a pool certificate is never trusted (only anchors confer trust); loops and mutual cross-certification removed by subject-and-key; a pool cannot make the builder accept something RFC 5280 §6.1 rejects, only cost work or crowd out the valid path (R1); the lattice and policy-tree explosions are bounded (existing tests). * **Reports:** `Build` derives the verdict from the first `Fail` over path-level then per-certificate results, with `Fail` outranking `Indeterminate`; `Assemble` returns `Valid` only with a winner whose results contain no `Fail` or `Indeterminate`. `AssertConsistent` in the coverage tests checks this on every scenario it runs: no `Valid` report has a failing or undecided rule, and every non-valid report has a deciding rule of the matching outcome. * **Server profile:** host matching is subjectAltName only; the commonName is never a host name (T); IP addresses by octets; wildcard only as a whole left-most label with two labels after it; profile rules are additional failures, never a second validator. * **Divergence classes in `docs/validation.md`:** the cross-signed-root and public-suffix deviations follow RFC 5937 §2 and the missing Public Suffix List respectively. The OpenSSL common-name subtree class is correct by RFC 5280 §4.2.1.10 (constrained names are the subject DN and subjectAltName entries) and RFC 9525 §2 for server identity. The alternative-path classes (issuer not found, purpose, `invalid CA`, expired alternate) are correct by §6.1 (any valid path suffices). The `notAfter`-exact class relies on whole-second truncation, which is a documented, bounded leniency of under one second. ## Not examined Signature-algorithm policy wiring (being replaced), CMS signer-chain use, the `ValidateAsync` fetch loop beyond its option copying (`With` carries every option), attribute certificates, Brainpool/PQ key handling inside the validator, and the performance of the pool scan (O5). No OpenSSL or Go experiment was needed: every finding is determined by RFC text, which is quoted above by clause. # Ed25519, Ed448, X25519 and X448 Normative notes for the curve primitives in `RecursiveAI.OneCrypto.Primitives`. The .NET base class library has none of these curves, so they are implemented here from RFC 8032 and RFC 7748. Everything else in this repository defers to the platform. ## What is implemented | Algorithm | Spec | Notes | |---|---|---| | Ed25519 | RFC 8032 section 5.1 | pure; Ed25519ctx (context 1 to 255 bytes); Ed25519ph (SHA-512 prehash, context 0 to 255 bytes) | | Ed448 | RFC 8032 section 5.2 | context 0 to 255 bytes (empty by default); Ed448ph (SHAKE256 prehash of 64 bytes) | | X25519 | RFC 7748 sections 5 and 6.1 | | | X448 | RFC 7748 sections 5 and 6.2 | | Each has span one-shots on a static class (`Ed25519.TrySign`, `Ed25519.Verify`, `X25519.TryDeriveSharedSecret`, ...) and key objects. Private keys (`Ed25519PrivateKey`, ...) hold their secret in a pinned buffer that is zeroed on `Dispose`; public keys are plain immutable byte holders. Signing is deterministic. `Verify` returns `false` for every malformed input and never throws on data; signing and key-derivation methods throw `ArgumentException` for wrong lengths or an over-long context. Ed448 hashes with SHAKE256: the platform's where it has one, otherwise a managed Keccak-f[1600] (xor, and, not and fixed rotations only, so constant time by construction) with identical output. Ed448 therefore runs on every platform. ## Verification semantics A signature verifies when all of these hold. 1. It has the exact length (64 bytes for Ed25519, 114 for Ed448) and the context is at most 255 bytes. 2. S, the second half, is **below the group order** (RFC 8032 sections 5.1.7 and 5.2.7). This removes S + L malleability. For Ed448 the 57th byte of S is padding and must be zero. 3. The public key A is a **canonical** point encoding: y below p, on the curve, and not "negative zero" (x = 0 with the sign bit set). Ed448 additionally requires the unused low bits of the last byte to be zero. 4. The **cofactorless** equation [S]B = R + [k]A holds. It is checked by computing [S]B - [k]A and comparing its encoding with the R bytes of the signature, so a **non-canonical R** can never verify. RFC 8032 allows the cofactorless form; the cofactored form [8][S]B = [8]R + [8][k]A accepts more signatures and is not used. 5. Small-order A and small-order R are **not** rejected as such: when the equation holds exactly, they verify. Evidence, against the C2SP Ed25519 edge-case set (914 vectors, flags `low_order_A/R`, `non_canonical_A/R`, `low_order_component_A/R`, `low_order_residue`, `reencoded_k`): 172 accepted and 742 rejected, matching this policy on every vector. Non-canonical A or R and `low_order_residue` are rejected; everything else is accepted. This differs from the most common deployed behaviour only in rejecting non-canonical A (the RFC requires it) and from ZIP-215 in not accepting non-canonical encodings or cofactor-only matches. Batch verification is out of scope. Wycheproof (`ed25519_test.json`, `ed448_test.json`): every `valid` case verifies and every `invalid` case is rejected. The `acceptable` cases (small-order and mixed-order public keys, Ed25519 only) follow the rule above. ## X25519 and X448 The scalar is clamped as RFC 7748 section 5 says. The u-coordinate of X25519 has its top bit ignored; values of p or more are read modulo p, as the RFC requires. The all-zero check of RFC 7748 section 6.1 is exposed as the return value of `TryDeriveSharedSecret`: `false` means the peer's key had low order and the exchange must be abandoned. The shared-secret buffer holds zeros in that case. Wycheproof: every `valid` case agrees, every `acceptable` case either agrees or reports the zero secret, every `invalid` case (X448 keys of the wrong length) is refused. ## Constant time Every path that touches a secret follows the repository's constant-time rules. - **Field arithmetic** (`Fe25519`, five 51-bit limbs; `Fe448`, eight 56-bit limbs) is straight-line code on `ulong` limbs. Products are split at the limb width so column sums are plain additions: no 128-bit type, no carry comparison, no branch. The reduction constants are fixed. Inversion and square roots are fixed exponentiation chains. The high word of each 64 x 64 product comes from the hardware multiply (`WideMul`: BMI2 `mulx`, Arm `umulh`, otherwise `Math.BigMul`), which are constant-time instructions; the portable form keeps the low word in memory and is slower, not different in result. - **Montgomery ladder** (X25519, X448): a fixed number of steps (255, 448); the two working points are swapped with a mask taken from the scalar bit, never a branch or an index. - **Public keys of X25519 and X448** come from the fixed-base tables of the Edwards curves instead of a ladder: Curve25519's u = (1 + y) / (1 - y) (RFC 7748 section 4.1), and for Curve448 the 4-isogeny u = y^2 / x^2 (section 4.2), which maps the Ed448 base point to the Curve448 base point up to sign. Tests compare both against the ladder. - **Fixed-base multiplication** (Ed25519 and Ed448 signing, key derivation): the scalar is recoded into signed 4-bit digits. Each digit selects a table entry by reading **every** entry of its row and keeping one with a mask; the sign is applied with a mask. The sequence of group operations and the memory addresses touched are the same for every scalar. The group laws used are complete, so no point (identity, doubling, inverse) takes a special path. - **Scalar arithmetic** (mod the group order): Montgomery multiplication on 64-bit limbs with a final masked conditional subtraction. Hash outputs are reduced with a fixed sequence of Montgomery steps. - **Ed448 secret scalar** is reduced modulo the group order before use. The base point has exactly that order, so [s]B = [s mod L]B, and the reduced value stays inside the fixed-base table's digit range. - Not constant time, by design and only on public data: signature verification, point decoding, and the final comparison of the recomputed R with the signature. How this is checked: `DOTNET_JitDisasm` on the secret-path methods (field multiply and square, scalar Montgomery steps, table select, fixed-base multiplication, the ladders) shows no conditional jump other than loop control over public counters, bounds checks on public indices and stack-clearing loops; the multiply and square methods contain no jump at all. Statistical timing tests are described in the repository's timing-test notes. Memory: scratch values holding secrets are zeroed before return where the code controls them. The runtime cannot promise that no other copy of a secret exists. ## Tables and start-up The fixed-base tables (256 entries for Ed25519, 448 for Ed448) are built on first use from the base point, so the first signature in a process pays a few milliseconds. They contain only public data. # Password-based key derivation Argon2 (`Argon2`), scrypt (`Scrypt`) and bcrypt-pbkdf (`BcryptPbkdf`) in `RecursiveAI.OneCrypto.Primitives`. The platform has none of them. PBKDF2, HMAC and SHA-2 come from the platform; BLAKE2b (needed inside Argon2) and Blowfish's key schedule (needed inside bcrypt-pbkdf) are ours and internal. ## Behaviour | Function | Specification | Notes | |---|---|---| | `Argon2.DeriveKey` (d, i, id) | RFC 9106, version 0x13 | All inputs of the RFC: password, salt (8 bytes or more), memory m in KiB (8 × lanes or more, rounded down to a multiple of 4 × lanes), passes t, lanes p, tag length (4 or more), secret K, associated data X. | | `Argon2.Verify` | | Recomputes and compares with `CryptographicOperations.FixedTimeEquals`. | | `Scrypt.DeriveKey` | RFC 7914 | N a power of two above 1 (and below 2^(16·r)), r ≥ 1, p ≥ 1, r × p < 2^30. PBKDF2-HMAC-SHA256 from the platform. | | `BcryptPbkdf.DeriveKey` | OpenSSH `openssh-key-v1` (`PROTOCOL.key`), construction after Provos and Mazières (1999) | Output 1 to 1024 bytes; OpenSSH takes the cipher key followed by the IV. Salt not empty. Rounds ≥ 1. | Invalid parameters raise `ArgumentException` / `ArgumentOutOfRangeException`. Parameters that are valid but too costly raise `KdfLimitExceededException` (a `CryptographicException` carrying the algorithm, parameter, requested value and limit) before anything is allocated. ## Limits `KdfLimits.Default`, used when a call passes none, because the parameters usually come from a file or message someone else wrote: | Limit | Default | Why | |---|---|---| | `MaxMemoryBytes` | 2 GiB | Large enough for the first parameter set RFC 9106 §4 recommends (m = 2^21 KiB, which OpenPGP's Argon2 S2K also uses), small enough that one hostile header cannot ask for tens of gigabytes. | | `MaxArgon2Passes` | 64 | Time cost. | | `MaxArgon2Parallelism` | 64 | | | `MaxScryptWork` | 2^26 (N × r × p) | The RFC's N = 2^20, r = 8 vector is 2^23. | | `MaxBcryptRounds` | 4096 | OpenSSH's default is 16. | Raise a limit by passing your own `KdfLimits`; the memory check also refuses sizes no .NET array can hold. ## Side channels - **Argon2i:** every memory index is computed from the pass, lane, slice and block counters and the parameters, never from the password. Timing and cache observations reveal nothing about the password through addressing. - **Argon2id:** no constant-time claim for the derivation as a whole. The first two slices of the first pass are addressed as in Argon2i, but from the third slice on, as in the RFC (§3.4), reference blocks are chosen from block contents, so password-dependent timing and cache behaviour are expected by design. A timing test of the whole derivation flags this (|t| 12 on arm64 with one pass and 64 KiB), which is why there is no Argon2id timing target. - **Argon2d, scrypt:** addresses depend on the data by definition (RFC 9106 §3.4.1.1, RFC 7914 §5). That is a property of the algorithms, not something to fix, and no constant-time claim is made. - **bcrypt-pbkdf:** Blowfish indexes S-boxes by data. A full-table scan on every lookup would cost 256 times as much, so lookups are direct; an attacker would need to share the CPU cache while a key file is being unlocked. - No branch, loop bound or early exit in any of them depends on the password. Working memory is pinned and zeroed before the call returns. A garbage-collected runtime cannot promise that no copy of a secret survives elsewhere. - The timing tests (`tests/Timing/KdfTargets.cs`) check Argon2i with one pass and 64 KiB against a fixed and a random password. ## Threads and vectors Argon2 with more than one lane runs the lanes of a slice in parallel when a segment is at least 256 blocks (1 MiB of memory per lane); smaller work runs on the calling thread. Results do not depend on the thread count. scrypt and bcrypt-pbkdf are single-threaded. Argon2's compression function has a scalar version (the reference, used everywhere AVX2 is missing) and an AVX2 version (`Argon2Avx2`, chosen once from `Avx2.IsSupported`) that computes one round on four 64-bit lanes at a time. A test runs both on random blocks and requires identical output. scrypt's Salsa20/8 stays scalar: a 128-bit vector version was measured 1.8 times slower than the scalar one on arm64, because the rotates are single instructions there and the vector round is a longer dependency chain. ## Benchmarks Rows live in `bench/OneCrypto.Benchmarks/Rows/Argon2Rows.cs` and `ScryptRows.cs`: Argon2id (m = 64 MiB, t = 3, p = 4 and m = 19 MiB, t = 2, p = 1) and scrypt (N = 2^14 and 2^17, r = 8, p = 1), 32-byte outputs, against BouncyCastle, Konscious and Isopoh (managed, gating) and the libsodium behind NSec (native, reported). `peak-kdf` in the same project prints the peak working set of one derivation per implementation, each in its own process. ## Tests - RFC 9106 §5 (Argon2d, i, id), RFC 7693 Appendix A (BLAKE2b-512 of "abc"), RFC 7914 §12 (all four vectors, the 1 GiB one when `ONECRYPTO_BIG_VECTORS=1`) and §13 (the derived key of the PKCS#8 example). - RFC 9580 Appendix A.12: the Argon2id output for t = 1, p = 4, m = 2^21 KiB decrypts the v4 SKESK of each sample message to the session key printed there (`ONECRYPTO_BIG_VECTORS=1`, 2 GiB). - `testdata/kdf/openssl-vectors.txt`, written by `tools/gen-kdf-vectors.sh` from `openssl kdf`: odd memory sizes, up to 8 lanes, tags of 4 to 1024 bytes, secret and associated data, the multi-threaded path, and scrypt with r up to 16 and p up to 5. - `testdata/bcrypt-pbkdf/`, written by `tools/gen-bcrypt-pbkdf-fixtures.sh` with `ssh-keygen`: passphrase-protected keys whose private section must decrypt with the key and IV our derivation yields (checked by the two equal check integers). - Wycheproof has no Argon2, scrypt or bcrypt files (only PBKDF2), so none is run. # AES modes: OCB3, EAX and key wrap `RecursiveAI.OneCrypto.Primitives` has `AesOcb`, `AesEax` and `AesKeyWrap`: modes over the platform's AES, for the places the platform has no mode of its own. The AES block function always comes from the platform (`Aes` ECB and CBC one-shots on spans); OneCrypto writes only the mode. AES Key Wrap with Padding (RFC 5649) is in the platform (`Aes.EncryptKeyWrapPadded`, `Aes.DecryptKeyWrapPadded`) and is not duplicated. ## Behaviour | | OCB3 (RFC 7253) | EAX (Bellare, Rogaway, Wagner 2004) | Key wrap (RFC 3394, SP 800-38F KW) | |---|---|---|---| | Key | 16, 24 or 32 bytes | 16, 24 or 32 bytes | 16, 24 or 32 bytes | | Nonce | 1 to 15 bytes | any length, including none | none | | Tag | 8 to 16 bytes, fixed per key object | 8 to 16 bytes, fixed per key object | 8-byte integrity block | | Data | any length | any length | a multiple of 8 bytes, 16 to 1 MiB | - **Tag length per key object.** OCB binds the tag length into its nonce formatting (RFC 7253 §4.2), so a ciphertext made with one tag length does not authenticate under another. Both AEAD classes therefore fix the tag length when the key object is created and reject any other length. - **Failure.** Decryption and unwrapping check the authentication value with `CryptographicOperations.FixedTimeEquals` after the whole message has been processed. On failure the destination is zeroed and `AuthenticationTagMismatchException` is thrown (the `Try…` forms return `false`). EAX computes the tag over the ciphertext first and produces no plaintext at all on failure; OCB and key wrap compute the plaintext and the check together and clear it. - **Buffers.** Input and output may be the same span (in place) or must not overlap; a partial overlap is an `ArgumentException`. Key wrap does not allow in-place use. - **Lengths** are public. Control flow depends on message, nonce and associated-data lengths and on nothing derived from the key. - **Thread safety.** A key object is not safe for concurrent use. Give each thread its own. - **Disposal.** The key-dependent tables (OCB's L table, EAX's CMAC subkeys) live in pinned buffers that `Dispose` zeroes. The AES key itself stays in the platform's handle. A garbage-collected runtime cannot guarantee that no other copy of a key exists. - **Platforms without AES** (the browser) throw `PlatformNotSupportedException` from the constructor. ## Constant-time rules applied - OCB: the L table and offsets are only XORed and passed to AES. `double` selects the reduction with a mask. The block index `i` is public, so `ntz(i)` may select a table entry. The nonce is public, so the position of the offset window inside the stretched nonce may index bytes. - EAX: CMAC subkeys use the same masked doubling; the CTR counter increments with an arithmetic carry; the tag compare is fixed-time. - Key wrap: the loop counts depend on the data length only; the integrity check is a fixed-time compare of 8 bytes. ## How it batches The platform's one-shot `Aes.EncryptEcb` sets up a cipher context and key schedule on every call, which costs more than the AES of a short message. Each key object therefore keeps long-lived ECB (and, for EAX, CBC) transforms and one working buffer of about 2 KiB, and gathers work for the whole buffer into one call. OCB needs two calls to encrypt (the nonce block with the associated data; then the data, the final partial block's pad and the tag together) and three or four to decrypt. EAX's CMAC runs on the CBC transform, which carries its chaining value from call to call; the first block of each MAC is XORed with the value the transform holds so that its effective input is the one the MAC defines, and the block after the tweak block is a per-key constant. Key wrap is inherently sequential and calls AES once per step, so it costs 6n platform calls for n key blocks. ## Verification - Vectors: RFC 7253 Appendix A (sixteen sample tuples, the 96-bit-tag tuple, and the iterated test for each key size and tag length given); the EAX paper's AES-128 vectors (Appendix G); RFC 3394 §4.1 to §4.6; Wycheproof `aes_eax_test.json`, `aes_wrap_test.json`, and `aes_kwp_test.json` (against the platform's KWP). Wycheproof has no OCB file. - A block-at-a-time EAX built straight from the paper checks the batched code across every buffer boundary. - Two-way with OpenSSL: OCB through `tools/openssl_aead.py` (the command line has no AEAD modes), key wrap through `openssl enc -id-aes*-wrap`. - Tamper tests flip every tag, ciphertext and associated-data byte and check that nothing is released. # Camellia and the legacy block ciphers `RecursiveAI.OneCrypto.Primitives` has `Camellia` (RFC 3713, 128-, 192- and 256-bit keys) and four ciphers that OneCrypto reads but never writes: `Cast5` (RFC 2144), `Idea`, `Blowfish` and `Twofish`. The platform has none of them. ## Surface | | Camellia | CAST5 | IDEA | Blowfish | Twofish | |---|---|---|---|---|---| | Key | 16, 24, 32 bytes | 5 to 16 bytes | 16 bytes | 4 to 56 bytes | 16, 24, 32 bytes | | Block | 16 | 8 | 8 | 8 | 16 | | Encrypt | ECB, CBC, CFB | none | none | none | none | | Decrypt | ECB, CBC, CFB, OpenPGP CFB | ECB, CBC, CFB, OpenPGP CFB | same | same | same | - The legacy classes have no encryption method at all. CFB decryption runs the block function forwards, so the engines can encrypt internally (the tests use that to round-trip), but nothing public reaches it. - CBC takes `PaddingMode.PKCS7` (RFC 5652 section 6.3, the default) or `PaddingMode.None`. Padding is checked over the last block with masks and no early exit; on failure the destination is zeroed before `CryptographicException`, so no plaintext leaves. Whether the padding was valid is visible only through the exception, as with any padded CBC, so protocols must authenticate before decrypting. - CFB uses full-block segments (CFB128 for the 16-byte ciphers, CFB64 for the 8-byte ones, as OpenSSL's `-cfb` does) and accepts any length; the last block's keystream is truncated. - `DecryptOpenPgpCfb(source, destination, resync)` decrypts OpenPGP symmetric data: zero IV over the random prefix and the data (RFC 9580 section 13.9). With `resync: true` it applies the older packet's variant (RFC 4880 section 13.9), reloading the feedback register with ciphertext bytes 3 to block size + 2 after the prefix. The prefix and its two check bytes are returned decrypted; the caller checks them. - Spans may be the same (in place) or must not overlap. Calls do not allocate. Key schedules live in pinned arrays that `Dispose` zeroes. ## Constant time Table-driven implementations index tables with secret bytes, which a co-resident observer of the cache can read. None of these ciphers does that, in the rounds or the key schedule: - **Camellia.** One lookup of the F-function is a vector operation on its eight bytes (sixteen, for two blocks at once). With AES instructions (see "Camellia through the AES instruction" below) the S-box is one AES round instruction between two affine maps. Without them, each byte's low nibble indexes all sixteen 16-entry rows of SBOX1 through a byte shuffle, and the high nibble selects a row through a tree of blends. SBOX2, SBOX3 and SBOX4 are derived exactly as RFC 3713 section 2.4.1 defines them (rotate the output or the input by one bit). The P-function is six shuffles. - **CAST5 and Blowfish.** Every S-box lookup compares the index with all 256 positions and keeps the match (`CtLookup.Word`), vectorised over 8 (or 4) positions per step. The Blowfish key schedule performs its 521 block encryptions the same way. - **Twofish.** The key-dependent S-boxes are never tabulated. Each S-box byte is computed from the two fixed byte permutations, which the paper builds from 4-bit permutations, and those are 16-entry shuffles; the key bytes are XORed in as the paper's h function does. The MDS and RS multiplications use shifts and masks. - **IDEA.** No tables. Multiplication modulo 65537 uses masks instead of the zero test and division, and the decryption schedule's inverses are computed as x^65535 with a fixed sequence of multiplications. - CAST5's rotation amounts come from the key; they are applied with a rotate instruction, whose time does not depend on the amount. The cost is speed; see the numbers below. `ByteShuffle` picks `pshufb` (SSSE3) or `tbl` (Arm64) from the platform's capabilities and falls back to `Vector128.Shuffle`, which gives the same results where it is the only option (it is a software loop where no vector hardware exists, with the same access pattern). JIT output was inspected with `DOTNET_JitDisasm` (full opts) for `CamelliaEngine.F`, `TwofishEngine.H`, `CtLookup.Word`, `Cast5Engine.F`, `BlowfishEngine.F` and `IdeaEngine.Mul`: the only conditional jumps are the static-initialisation check, bounds checks, and loops over public counts (stage, round, table position); no jump, `cmov` or address depends on a secret. ## Verification - Camellia: RFC 3713 Appendix A (3 vectors); Wycheproof `camellia_cbc_pkcs5_test.json` (216 tests, both directions, invalid padding rejected); OpenSSL 3.5.9 CBC and CFB both ways for all three key sizes. - CAST5: RFC 2144 Appendix B.1 (128-, 80- and 40-bit keys) and the B.2 maintenance test (one million iterations). - IDEA, Blowfish, Twofish: the published known answers (IDEA key 0001..0008; the Blowfish variable-key set; the Twofish submission's 128-, 192- and 256-bit known answers); OpenSSL 3.5.9 (legacy provider) for IDEA, CAST5 and Blowfish; GnuPG for all of them. - OpenPGP CFB: fixtures written by GnuPG 2.4 (`tools/gen_gnupg_cfb_fixtures.py`) for IDEA, CAST5, Blowfish, Twofish and Camellia 128/192/256, both the protected packet and the older one with resynchronisation. - Wycheproof has no files for CAST5, IDEA, Blowfish or Twofish, and none for Camellia ECB or CFB. ## Camellia through the AES instruction Camellia's S-box is affine-equivalent to the inversion in the AES field GF(2^8) (the design papers say as much), so an AES S-box instruction can do the substitution. The affine maps are not in RFC 3713, so they were found from the RFC's SBOX1 table by black-box search; nothing was taken from any implementation or from the designers' specification. 1. **Find P.** For every input difference a the differential `SBOX1[x] ^ SBOX1[x ^ a]` takes exactly one output difference b exactly four times; set phi(a) = b. If SBOX1(x) = N(Inv(M x ^ a0)) ^ b0 with N and M linear, Inv the AES-field inversion and Inv's own differential having the same one-in-four property, then phi(a) = N(D(M a)) for the corresponding map of Inv. Inv is fixed, so M and N are constrained by phi. 2. **Search.** A depth-first search chooses the images of the eight unit vectors under M one at a time (without loss of generality the first is 1), keeps a linear basis of the pairs (phi(v), Inv(M v)) built so far, and prunes a branch as soon as the basis is inconsistent. It finds solutions in well under a second. One solution, with P = M, has columns `1, 25, 177, 171, 167, 147, 97, 217` (column i is P applied to the unit vector with bit i set). 3. **Solve the rest.** With P fixed, the constant a0 = 8 is the only value for which `N(SBOX1[x]) ^ Inv(P x ^ a0)` is constant in x, and N is the linear map that follows from the pairs; N^-1 has columns `241, 187, 142, 9, 250, 215, 33, 225` and the output constant is `0x6E`: `SBOX1(x) = N^-1 (Inv(P x ^ 8)) ^ 0x6E`, checked on all 256 inputs. 4. **Use the AES instruction.** One AES round with a zero key computes the AES S-box, `A Inv(y) ^ 0x63`, and permutes bytes with ShiftRows. The code builds both nibble tables at start-up: before the instruction, `y = P x ^ 8` as two 16-entry shuffles; after it, the map from the AES S-box output to SBOX1 is read off the 256 values of `SBOX1[x]` against `AES(P x ^ 8)` (an affine function of the output, so a matrix and a constant) and applied as two more shuffles. SBOX2, SBOX3 and SBOX4 keep their RFC definitions (rotations on lanes). The byte shuffle of ShiftRows is folded into the P-function's index tables. 5. **Two blocks.** The vector has sixteen lanes; lanes 0 to 7 carry one block's S-box bytes and lanes 8 to 15 another's. ShiftRows maps the two groups to disjoint lanes, so ECB and CBC decryption push two blocks through each F call. The shuffle path remains for processors without AES instructions, and a test compares the two paths, one block against two blocks, for all three key sizes in both directions. ## Cost of avoiding table lookups Indicative CBC rates on a loaded machine, ratio = ours / BouncyCastle 2.7.0 (below 1 is faster). AMD Ryzen 9 7950X3D (x64) and Apple M2 (arm64). | Cipher | x64 ratio | arm64 ratio | |---|---|---| | Camellia (CBC encrypt and decrypt, 128 and 256-bit keys, 1 KiB and 64 KiB) | 1.84 to 2.21 (geomean 2.0) | 2.49 to 2.87 (geomean 2.7) | | IDEA | 1.3 to 1.4 | 1.65 | | Twofish | 8.0 to 8.1 | 13 to 14 | | CAST5 | 15 | 32 to 33 | | Blowfish | 18 to 19 | 44 to 45 | Blowfish key setup takes about 1.2 ms (521 block encryptions), CAST5 and Twofish key setup 1 to 12 microseconds. For reference OpenSSL 3.5.9 on the x64 machine: Camellia-128-CBC 310 MB/s, Camellia-256-CBC 225 MB/s, Blowfish 229 MB/s, CAST5 205 MB/s, IDEA 168 MB/s. The timing tests (`tests/Timing/LegacyCipherTargets.cs`) run data, key and key-setup kinds plus an A/A control per cipher. Camellia, CAST5, IDEA, Twofish: no leak in the indicative runs apart from the key-pool kind flagged under heavy load; see the report. The pool of key objects is placed at random slots each batch, because a fixed even/odd split showed an address effect that the A/A control caught. # Platform dispatch and managed fallbacks How the library chooses between the platform's cryptography, a narrow managed fallback and an error. The per-platform results are in [availability.md](https://recursiveai.org/onecrypto/docs/availability/). ## Order 1. **The platform first.** The .NET base class library, backed by the operating system's cryptography. Support is read from the platform's own `IsSupported` properties (`SHA3_256`, `Shake128`, `Shake256`, `ChaCha20Poly1305`, `MLKem`, `MLDsa`, `SlhDsa`). Where there is no such property, a fact about the platform is used: the browser (and WASI) has hashes and HMAC only, so AES, RSA and ECDSA are absent there; the three Brainpool curves are probed once per process by making a key, because macOS lacks them. Nothing is decided by catching an exception on a hot path. 2. **A managed fallback**, only where the platform lacks the primitive, and only for the two kinds of work that can be done safely in managed code: - *constant time by construction*: SHA-3 and SHAKE (FIPS 202), ChaCha20-Poly1305 (RFC 8439); - *public data only*: RSA and ECDSA **verification**. 3. **`AlgorithmNotSupportedException`**, naming the algorithm, the platform (operating system, architecture, runtime, and the loaded OpenSSL version on Linux) and the reason. It is a `NotSupportedException`. There is no managed RSA or ECDSA signing, decryption or key agreement, on any platform, ever. There is no managed AES. ## Behaviour that does not depend on the platform The same answer comes out whichever implementation runs. Where the platform is more lenient than the specification, the library checks first. - **ECDSA, DER signatures** are strict DER (X.690 section 10): definite and minimal lengths, minimal positive INTEGERs, no trailing bytes. Some platform verifiers accept BER-encoded signatures; the library does not. Fixed-width signatures (`r || s`) must be exactly twice the field size. `r` and `s` must lie in 1 to n-1 (FIPS 186-5 section 6.4.2). A public key that is not an uncompressed point on the curve is an `ArgumentException`, not a false result. - **RSA** signatures must be exactly as long as the modulus and numerically below it (RFC 8017 section 8.2.2 step 1). macOS accepts an unreduced signature; the library rejects it. PKCS#1 v1.5 verification builds the expected encoded message and compares it whole (RFC 8017 section 8.2.2), so no lenient reading of the DigestInfo (an absent NULL, trailing bytes) is accepted. PSS follows EMSA-PSS-VERIFY (section 9.1.2) with any salt length and a mask hash that may differ from the message hash; the platform's PSS fixes both, so other parameter sets always take the managed verifier. PKCS#1 over SHA-3 digests also does, since the platform's DigestInfo table has no SHA-3. - **RSA key bounds** on public input: modulus at most 16384 bits and odd, exponent odd, between 3 and 2^256. An out-of-bounds key is an `ArgumentException`. - **Hashes** known to the verifiers: SHA-1, SHA-256, SHA-384, SHA-512, SHA3-224/256/384/512. SHA-224, SHA-512/224 and SHA-512/256 are not offered (the platform has none; they are not needed by any format here). Whether a hash is acceptable for a signature is a policy question, answered above this layer. - **Brainpool** (brainpoolP256r1, P384r1, P512r1, RFC 5639): verification works everywhere, through the platform where it has the curve and through the managed verifier where it does not (macOS, the browser). The 224- and 320-bit Brainpool curves are not offered. - **SHA3-224** is always the managed Keccak: the platform has never offered it. ## Constant time - The Keccak permutation and ChaCha20 are xor, and, not, add and fixed rotations on 32- and 64-bit words. Poly1305 uses five 26-bit limbs with 64-bit products and explicit carries, a masked final reduction (no branch on the accumulator) and a 128-bit addition of the key half. The tag is compared with `CryptographicOperations.FixedTimeEquals` after the whole computation, and a message with a wrong tag is never decrypted (the output is zeroed). - The compiled form of `Keccak.Permute`, `ChaCha20Poly1305Managed.Block`, `Poly1305.Block` and `Poly1305.Finish` was inspected with `DOTNET_JitDisasm` under full optimisation: every conditional jump is a loop counter, a length test on public sizes or a bounds check that leads to a throw helper; none depends on key, nonce or data. Statistical timing tests for both are in `tests/Timing/FallbackTargets.cs` (see [timing.md](https://recursiveai.org/onecrypto/docs/timing/)). - The verify-only RSA and ECDSA code uses `BigInteger` and handles only public values (keys, digests, signatures). It makes no constant-time claim and has no timing test. ## Testing the fallbacks on hosts that do not need them `Platform.ForceFallbacks()` (internal, for tests) makes dispatch choose the managed implementation wherever one exists, for the current async flow. The vector tests use it, so the fallbacks run on every machine, and they also compare the managed output with the platform's where both exist. # Conformance What is checked, against what, and where the library deviates. Gaps are listed, not hidden. The numbers come from runs of the test suite and its opt-in vector and oracle tests (see each area's document). This table is updated at each release. Oracles run black-box at pinned versions: OpenSSL 3.5.9, Go 1.26.8, GnuPG 2.4.8, OpenSSH 9.6 (and 10.2), .NET 10.0. | Area | Vectors and suites | Interoperability | Documented deviations and gaps | Details | |---|---|---|---|---| | ASN.1 BER/CER/DER, PEM | `AsnReader` differential: x509-limbo 40,194, Wycheproof 62,682, 2M generated valid inputs, 0 differences; 2M mutated inputs, 27 differences, all documented | n/a | tag 0, BER unused bits kept, GeneralizedTime minute/hour fractions refused, OID arcs up to 128 bits, UniversalString | [asn1.md](https://recursiveai.org/onecrypto/docs/asn1/) | | Ed25519, Ed448, X25519, X448 | RFC 8032 21/21, RFC 7748 incl. 1,000,000 iterations, Wycheproof ed25519 210, ed448 87, x25519 518, x448 510, C2SP CCTV ed25519 914/914 | OpenSSL 3.5.9, both directions, x64 and arm64 | verification semantics: [curves.md](https://recursiveai.org/onecrypto/docs/curves/) | [curves.md](https://recursiveai.org/onecrypto/docs/curves/) | | OCB3, EAX, AES-KW | RFC 7253, EAX paper vectors, RFC 3394, Wycheproof aes_eax / aes_wrap / aes_kwp files | OpenSSL 3.5.9 | AES-KWP comes from the platform | [aes-modes.md](https://recursiveai.org/onecrypto/docs/aes-modes/) | | Argon2, scrypt, bcrypt-pbkdf | RFC 9106 3/3, RFC 7693 1/1, RFC 7914 §12 4/4 and §13, RFC 9580 A.12 3/3 | `openssl kdf` 20/20, `ssh-keygen` bcrypt keys 5/5 | Argon2d, scrypt and bcrypt-pbkdf have data-dependent memory access by design; no constant-time claim for them | [kdf.md](https://recursiveai.org/onecrypto/docs/kdf/) | | Camellia; CAST5, IDEA, Blowfish, Twofish (decrypt only) | RFC 3713 3/3, RFC 2144 B.1 3/3 and B.2, published IDEA/Blowfish/Twofish known answers, Wycheproof camellia_cbc 216/216 | OpenSSL 3.5.9 both directions; GnuPG 2.4.8 OpenPGP-CFB fixtures 14/14 | legacy ciphers are decrypt-only | [legacy-ciphers.md](https://recursiveai.org/onecrypto/docs/legacy-ciphers/) | | Platform-gap fallbacks (SHA-3/SHAKE, ChaCha20-Poly1305, verify-only RSA/ECDSA) | CAVP SHA-3/SHAKE 4,642/4,642, RFC 8439 all, Wycheproof with the fallback forced: ChaCha 316 (9 skipped), ECDSA 22,428 (4,880 skipped: SHA-224, SHAKE and Brainpool-224/320 curves), RSA PKCS#1 v1.5 9,826, PSS 4,788, 0 failures | n/a | strict DER ECDSA and exact-length RSA signatures on every platform | [fallbacks.md](https://recursiveai.org/onecrypto/docs/fallbacks/) | | X.509, CSR, CRL, OCSP models | x509-limbo 30,377/30,379 certificates (2 refused with a typed error at the name-element limit), 18/18 CRLs, 0 differences from `X509Certificate2`; Wycheproof 5,701 signatures | OpenSSL 3.5.9 both directions: RSA, RSA-PSS, P-256, P-384, Brainpool, Ed25519, Ed448, ML-DSA, SLH-DSA (preview) | SLH-DSA and composite ML-DSA are preview while the platform API is experimental | [x509.md](https://recursiveai.org/onecrypto/docs/x509/) | | Path validation (preview, under review) | x509-limbo 9,802 test cases with 3 documented deviations; NIST PKITS 245/247 (the 2 others use DSA, which is not verified); BetterTLS 9,572/9,572 | differential with `openssl verify` 3.5.9 and Go 1.26.8 `crypto/x509` over 19,357 cases: 441 differences, each class explained | the review's findings are being fixed; the validator is not yet recommended for trust decisions | [validation.md](https://recursiveai.org/onecrypto/docs/validation/), [validation-review.md](https://recursiveai.org/onecrypto/docs/validation-review/) | | CMS | content types and recipient forms tested both ways where OpenSSL supports them | `openssl cms` 3.5.9 matrix: 169 passed, 0 failed, 20 skipped (EdDSA and ML-DSA without signed attributes, X25519/X448 key agreement and ML-KEM KEM recipients, which OpenSSL 3.5.9 cannot produce or open); CompressedData against OpenSSL 3.5.5 | X25519/X448 key agreement (RFC 8418) and KEMRecipientInfo (RFC 9629) are checked by our own reader and writer only; AuthEnvelopedData decryption buffers up to a limit | [cms.md](https://recursiveai.org/onecrypto/docs/cms/) | | PKCS#8, PKCS#12, OpenSSH keys | PBES2/PBKDF2/scrypt, PBMAC1 (RFC 9579), legacy PKCS#12 PBEs (read) | OpenSSL 3.5.9 (`pkcs8`, `pkey`, `pkcs12`), the platform's PKCS#8/#12 export and import, `ssh-keygen` (keys, certificates, RFC 4716) | `chacha20-poly1305@openssh.com` key files and RC4 PKCS#12 PBEs are not read; ML-DSA/ML-KEM PKCS#8 is preview | [pkcs.md](https://recursiveai.org/onecrypto/docs/pkcs/) | | `CryptoPolicy`, keys, two-phase signing | policy decisions per preset and rule | two-phase signatures and certificates verified by OpenSSL 3.5.9 | macOS login-keychain keys need an interactive session | [policy.md](https://recursiveai.org/onecrypto/docs/policy/), [keys.md](https://recursiveai.org/onecrypto/docs/keys/) | | Issuer side: CA, CRLs and delta CRLs, RFC 3161 TSA and client messages, OCSP responder | profile, serial, status and nonce rules per RFC 5280 §5, 3161, 5816, 6960 | `openssl ts` 3.5.9 both ways (Ed25519 TSA keys ours only, OpenSSL cannot sign them), `openssl ocsp` 68/68, `openssl verify` and `crl -verify` for issued chains and CRLs | code-signing and S/MIME validity limits are our defaults | [issuance.md](https://recursiveai.org/onecrypto/docs/issuance/), [tsp.md](https://recursiveai.org/onecrypto/docs/tsp/), [ocsp-responder.md](https://recursiveai.org/onecrypto/docs/ocsp-responder/) | | `Crypto.Load` | 159-file corpus (OpenSSL, `ssh-keygen`, platform exports), 0 misclassifications | n/a | OpenPGP, age and minisign files are not loaded yet | [load.md](https://recursiveai.org/onecrypto/docs/load/) | ## Speed Ratio = OneCrypto time ÷ the fastest managed .NET library for the row (BouncyCastle 2.7.0; also `System.Formats.Asn1` for ASN.1), lower is faster. The bar is a geomean of at most 1.0 per area and no row above 1.25. Measured with BenchmarkDotNet on an AMD Ryzen 9 7950X3D (x64) and an Apple M2 (arm64), .NET 10. The x64 run shared the machine with other work and is marked load-flagged; it is repeated at the next release. | Area | x64 | arm64 | |---|---|---| | Ed25519 sign / verify | 0.55 / 0.90 | 0.48 / 0.89 | | Ed448 sign / verify | 0.52 / 0.61 | 0.71 / 0.94 | | X25519 / X448 agreement | 0.76 / 0.50 | 0.77 / 0.74 | | OCB, EAX (geomean) | 0.19 | 0.13 | | AES key wrap | 1.53 (exception 2) | 0.32 | | Argon2 / scrypt | 0.36 / 0.86 | 0.46 / 0.90 | | Camellia CBC | 1.61 (exception 1) | 1.58 (exception 1) | | ASN.1 certificate / 50k-element SEQUENCE (vs `AsnReader`) | 0.89 / 0.95 | 0.95 / 0.91 | | X.509 parse / verify (geomean) | 0.36 / 0.84 | 0.38 / 0.31 | | CMS sign / verify / envelope (geomean) | 0.18 / 0.33 / 0.47 | 0.34 / 0.18 / 0.10 | | PKCS#12 load / PKCS#8 decrypt | 0.25 / 0.31 | 0.11 / 0.22 | The curve span operations allocate nothing. Documented exceptions to the bar: 1. **Camellia** encrypts at about 2.3–2.4× and decrypts at about 1.1× BouncyCastle. OneCrypto's Camellia never indexes a table with secret data (its S-box is computed with an AES instruction and nibble shuffles), while table-driven implementations leak through the cache. CBC encryption is a serial chain that parallel blocks cannot shorten. 2. **AES key wrap on x64** is 1.53×: RFC 3394 key wrap is a chain of dependent single-block AES calls, and the cost is the platform's per-call overhead; OneCrypto does not reimplement AES. 3. **ECDSA P-256 verification**, worst row: 1.70× for a certificate and 1.66× for a 1 KiB CMS message on x64 (1.16 and 1.15 on arm64). Verification calls the platform's ECDSA and is within 4% of the platform called directly; the area geomeans meet the bar. ## Robustness Every parser has a fuzz target (`fuzz/`): asn1, pem, x509, ocsp, verify, cms, cmsenv, keys, pkcs8, pkcs12, ssh, validate, load, tsa, ocspresp. Each ran 600 s without a finding before this release (libFuzzer with SharpFuzz, one core each; 2.4 to 31 million executions per target). Every finding made during development is a committed regression test. Limits are on by default. ## Platforms The per-platform availability of each algorithm (linux-x64 with OpenSSL 3.0 and 3.5, osx-arm64, browser-wasm) is generated by the test suite and checked so it cannot drift: [availability.md](https://recursiveai.org/onecrypto/docs/availability/). NativeAOT (linux-x64) and the browser (WebAssembly under Node) smoke apps run in CI. ## Constant time Secret-handling code is written without secret-dependent branches, memory indices or early exits, and each secret-handling primitive has a dudect-style timing test (fixed-vs-random classes, Welch's t-test; [timing.md](https://recursiveai.org/onecrypto/docs/timing/)). - **Secret data** (plaintext, ciphertext, messages): at 1,000,000 measurements per class on x64 and arm64, every target passes (max |t| at most 2.99 against a threshold of 4.5): AES-EAX, AES key wrap, Blowfish, Camellia-128 and -256. Earlier, shorter runs cover Ed25519/Ed448 signing, X25519/X448, OCB, CAST5, IDEA, Twofish, ChaCha20-Poly1305 and Argon2i. Results per primitive and machine are in [timing.md](https://recursiveai.org/onecrypto/docs/timing/). - **Secret keys**: the measurement method for key-dependent timing does not yet pass its own fixed-vs-fixed control at that sample size, so no constant-time claim is made for key handling until it does. - **Argon2id** uses data-dependent memory access after its first half-pass by design (RFC 9106 §3.4); only Argon2i is data-independent. This library has not been independently audited. # Benchmarks Project: `bench/OneCrypto.Benchmarks` (BenchmarkDotNet, in-process, `MemoryDiagnoser` on). Runner: `tools/bench.sh`. ## Method - A row is one class under `bench/OneCrypto.Benchmarks/Rows/`: a class-level `[BenchmarkCategory("area")]`, a method named `Ours`, and one method per comparator named for it. Mark a native or native-backed comparator with `[BenchmarkCategory("native")]`. `Rows/Sha256Rows.cs` is the template; adding a row is adding one file. - Inputs and work are matched between `Ours` and each comparator; parameters (`[Params]`) give the sizes. - The summary compares medians. The ratio is ours ÷ comparator, so below 1 is faster. The gate uses the fastest *managed* comparator for each input. Per area it reports the geometric mean of those ratios and the worst row. - The bar: geomean at most 1.0 and no row above 1.25 against managed comparators. Native comparators are reported against a non-gating 1.5. - Allocated bytes per operation come from `MemoryDiagnoser`. Rows that must show constant memory while streaming measure their process's peak working set with `PeakMemory.Measure` and run alone in their own process. - `--quick` runs fewer iterations to check a row; use the default for numbers that are reported. ## Running `tools/bench.sh results/ --filter '*Sha256Rows*'` runs the selected rows as a heavy job on the machine's work queue (indicative numbers; the label ends in 'indicative'). `PERF_CLASS=bench` asks for an exclusive benchmark job, for the publishable run of a stage. `PERF_CORES` and `PERF_MAX` set the reservation and time limit. It leaves BenchmarkDotNet's reports, `summary.json`, `machine.txt` (CPU, cores, ISA flags, runtime), `load.log` (load before, every 5 s during, after) and `publishable.txt` in the output folder. A run is marked not publishable unless it ran as class `bench` and, on Linux, the 1-minute load average before and during it stayed at or below 2.0; on macOS, whose idle load average sits near 2, the CPU was at least 90% idle just before it started. ## Comparators (pinned, bench project only) BouncyCastle.Cryptography 2.7.0, Konscious.Security.Cryptography.Argon2 1.3.1, Isopoh.Cryptography.Argon2 2.0.0, System.Security.Cryptography.Pkcs 10.0.14, NSec.Cryptography 26.4.0 (native-backed: reported, not gating). They are restored and called through their public API only. # Timing tests Constant-time evidence for secret-handling code, in the style of Reparaz, Balasch and Verbauwhede, "Dude, is my code constant time?" (DATE 2017). Project: `tests/Timing`. Runner: `tools/timing.sh`. ## Method - Two input classes: a fixed secret and a fresh random secret per call (key-secret targets: see "Key-secret targets"). The class sequence is drawn from the CSPRNG before the run, so calls interleave at random. - Each call is timed with `Stopwatch.GetTimestamp`. Inputs are prepared in batches outside the timed region; the timed region allocates nothing. A warm-up of at least half a second runs first so tiered compilation has settled; the runner also sets `DOTNET_TieredCompilation=0`, `DOTNET_TieredPGO=0` and `DOTNET_ReadyToRun=0` for full-opt JIT. - The analysis runs Welch's t-test on the raw data and on 100 percentile crops (the k-th keeps the fastest 1 − 0.5^(10(k+1)/100) of the calls), and reports the largest |t| and which crop gave it. - Verdict: max |t| < 4.5 passes; anything else is reported as a leak. - Presets: `short` (200 000 calls) for gating merges, `full` (5 000 000) for milestones, `--n` to choose. - Timing runs are meaningless on a loaded machine. Publishable runs are exclusive benchmark jobs (`PERF_CLASS=bench`); day-to-day runs are indicative heavy jobs. `tools/perf-run.sh` also records CPU, cores and load before, during and after. ## Writing a target Add a file under `tests/Timing/` with a static method returning your targets, and add it to the list in `Targets.cs`. For one secret byte string going into one call: ```csharp TimingTarget.FromBytes("X25519", "scalar-multiply", 32, secret => s_sink ^= Run(secret)); ``` For anything with more structure, subclass `TimingTarget`: `Prepare` builds the inputs for a batch from the class sequence (fixed secret or fresh random one), `Run(i)` is the timed call and must not allocate. Keep the result in a field so the JIT cannot remove the call. ## Self-check `timing selfcheck` runs an early-exit byte compare (must be flagged) and `CryptographicOperations.FixedTimeEquals` (must pass). It is the proof that the harness sees leaks on the machine it runs on. ## Results and publishable runs `tools/timing.sh …` writes `timing.json`, `machine.txt`, `load.log` and `publishable.txt` under `` (relative to the repository root). A run is marked `publishable: NO` when it was not class `bench`, or when the 1-minute load average before or during it exceeds 2.0 (Linux) or 1.5 (macOS); set `PERF_MAX_LOAD` to change the limit. Only publishable runs count as evidence. ## Key-secret targets When the secret is a key, the cost of building the key object (schedule, tables, pinned buffers) is not what is under test, but the object's memory is what the operation reads. A first design kept a pool of 128 objects holding the fixed key and 128 holding distinct random keys, and drew from them. It could not be made reliable: the A/A control flagged on x64, and on Apple M2 the Camellia and AES-KW key kinds flagged at |t| 8 to 15 (mean difference about 0.1 percent) while their controls passed. Pool objects of one class differ from the other in more than the key (identical schedules in 128 places against 128 different ones, placement, cache footprint), so a flag could not be attributed to the key. The current design keeps everything but the key value equal: - **One fresh key object per call, built in an untimed hook.** `TimingTarget.Before(i)` runs right before each timed `Run(i)`. A key target disposes the previous object, fills one reusable key buffer, builds a new object from it (and, for the AEADs and AES-KW, the message under that key), and `Run` only decrypts, encrypts or unwraps. Both classes therefore allocate the same objects in the same pattern, run the same constructor, and time a call on an object that was built a moment ago. - **The class selects the key bytes with a mask, not a branch.** `FillKey` draws fresh random bytes for every call and then blends the fixed key over them with an all-ones or all-zero byte mask. Both classes execute the same instructions and branch history before the timed call. An earlier version copied the fixed key with an `if` for the fixed class only; with that, Twofish-128 decrypt-key on Apple M2 flagged in three of four runs (|t| 3.2 to 6.3; the sign of the mean difference changed with each run's fixed key) while its control stayed at or below 2.9, and the flags were gone once the branch became a mask. - **An A/A control per key target.** The control uses the same code with the mask forced to zero, so both classes are fresh random keys. A key kind counts only if its control passes; a flagging control means the host, not the code, and the run is repeated. - ChaCha20-Poly1305 uses the same hook and mask (one reusable key buffer). The curves take a fresh random scalar or seed per call in the random class and have no object to rebuild, so they have no control. `tools/timing-keys.sh [N]` runs the key-secret targets. `TIMING_ONLY=key` keeps only the key-related operations; `TIMING_PRIMITIVES` chooses primitives, or `Primitive/operation` entries. Blowfish builds its key object in 0.5 to 1.3 ms, so each of its targets takes 15 to 25 minutes at N = 1,000,000 and runs as its own job. ## Results (indicative) N = 1,000,000 calls per target, full-opt JIT, run as shared-host jobs (not exclusive), so every number is indicative. Values are the maximum |t| over the uncropped data and 100 crops; the pass threshold is 4.5. x64: AMD Ryzen 9 7950X3D, Linux, .NET 10.0.402 (the host was busy, load 10 to 20, during part of the runs). arm64: Apple M2, macOS, .NET 10.0.403. "Setup" is the key schedule itself, timed, with a fixed or a random key. Every key kind and control below passes on both machines. | Primitive / operation | x64 key | x64 control | arm64 key | arm64 control | |---|---|---|---|---| | Camellia-128 decrypt-key | 1.80 | 2.13 | 1.31 | 3.36 | | Camellia-128 encrypt-key | 1.58 | 2.39 | 1.45 | 0.75 | | Camellia-128 key-setup | 3.21 | | 1.56 | | | Camellia-256 decrypt-key | 2.84 | 2.06 | 1.05 | 1.58 | | Camellia-256 encrypt-key | 1.71 | 2.28 | 1.52 | 1.97 | | Camellia-256 key-setup | 1.42 | | 1.68 | | | CAST5 decrypt-key / key-setup | 2.13 / 1.65 | 1.33 | 2.26 / 2.75 | 1.64 | | IDEA decrypt-key / key-setup | 1.24 / 2.25 | 1.56 | 1.22 / 1.19 | 1.60 | | Blowfish decrypt-key / key-setup | 1.93 / 1.70 | 2.14 | 1.73 / 2.19 | 1.24 | | Twofish-128 decrypt-key / key-setup | 2.00 / 2.23 | 1.02 | 1.47 / 2.59 | 2.02 | | Twofish-256 decrypt-key / key-setup | 1.64 / 2.30 | 1.36 | 1.60 / 1.65 | 1.23 | | AES-OCB decrypt-key | 1.81 | 2.61 | 2.10 | 1.53 | | AES-EAX decrypt-key | 1.86 | 2.08 | 1.96 | 1.74 | | AES-KW unwrap-key | 2.98 | 1.95 | 2.53 | 0.77 | | ChaCha20-Poly1305 encrypt-key, 1000 B | 3.24 | 1.35 | 3.20 | 2.47 | | ChaCha20-Poly1305 encrypt-key, 17 B | 2.11 | 2.76 | 1.31 | 2.69 | | ChaCha20-Poly1305 decrypt-wrong-tag-key, 100 B | 2.09 | 0.95 | 1.80 | 1.63 | | Ed25519 sign | 2.55 | | 2.71 | | | Ed448 sign | 1.76 | | 2.25 | | | X25519 scalar-multiply / base-multiply | 1.16 / 2.60 | | 2.23 / 2.96 | | | X448 scalar-multiply / base-multiply | 1.28 / 2.50 | | 1.96 / 2.59 | | The Blowfish key-setup and the curve rows on x64 come from builds that differ from the final one only in the key-kind hook, which those targets do not use. Data-secret targets are untouched by this design and keep their last results (all pass). Before and after on the signals that started this work (maximum |t|, key kind with its control in brackets): | Signal | Pool design | Per-call object | |---|---|---| | Apple M2, Camellia-256 decrypt-key | 15.48 (2.24) | 1.05 (1.58) | | Apple M2, Camellia-256 encrypt-key | 14.28 (2.24) | 1.52 (1.97) | | Apple M2, Camellia-128 decrypt-key | 8.11 (2.32) | 1.31 (3.36) | | Apple M2, AES-KW unwrap-key | 9.34 (1.48) | 2.53 (0.77) | | Ryzen, Camellia-128 decrypt-key | 10.23 (8.95) | 1.80 (2.13) | | Ryzen, Blowfish decrypt-key | 14.10 (20.59) | 1.93 (2.14) | ### What the old signals were None of the Camellia and AES-KW signals reproduces with a fresh object per call and a masked key select. No source file under `src/` changed for these results, so the earlier flags came from how the harness built its two classes. The pool design was not taken apart further: which of its differences (identical schedules, placement, footprint) caused the Camellia and AES-KW flags is not isolated. The class-dependent copy branch was isolated for Twofish-128 (above). AES-KW runs on the platform's AES and passes. ## Argon2id Argon2i is the data-independent variant and has a timing target. Argon2id has no constant-time claim as a whole: only the first two slices of its first pass are addressed from public values, and the rest picks reference blocks from block contents (RFC 9106 §3.4), so password-dependent timing is expected. Measured once on Apple M2 (one pass, 64 KiB): |t| 12.05. The public API cannot time the data-independent part alone, so there is no Argon2id target. Argon2d and scrypt are data-dependent by design and have none either. # Changelog All notable changes are listed here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and versions follow [Semantic Versioning](https://semver.org/). ## [Unreleased] ## [0.1.0-preview.1] - 2026-11-01 First preview of `RecursiveAI.OneCrypto`, the core package. The API is not stable yet. This library has not been independently audited. What is tested, against what, and the documented deviations: [docs/conformance.md](https://recursiveai.org/onecrypto/docs/conformance/). ### Added - ASN.1 (`RecursiveAI.OneCrypto.Asn1`): BER, CER and DER readers over spans and streams (indefinite lengths in constant memory), DER and streaming BER writers, limits on by default, typed errors with `Try` forms. PEM (RFC 7468). - Primitives the platform lacks (`RecursiveAI.OneCrypto.Primitives`): Ed25519 (with ctx and ph) and Ed448 (with ph), X25519 and X448; OCB3, EAX and AES key wrap; Argon2d/i/id, scrypt and bcrypt-pbkdf with cost limits; Camellia; CAST5, IDEA, Blowfish and Twofish for decryption only. Secret-handling code avoids secret-dependent branches and table indices. - Platform dispatch: the platform's own algorithms first; verify-only RSA and ECDSA, SHA-3/SHAKE and ChaCha20-Poly1305 fallbacks only where the platform has none; otherwise `AlgorithmNotSupportedException` naming the algorithm and platform. A generated availability table per platform ([docs/availability.md](https://recursiveai.org/onecrypto/docs/availability/)). - CMS (RFC 5652 and successors): SignedData, EnvelopedData, AuthEnvelopedData, AuthenticatedData, EncryptedData, DigestedData and CompressedData; every RecipientInfo form including KEMRecipientInfo with ML-KEM (preview); RSA PKCS#1 v1.5 and PSS, ECDSA, Ed25519, Ed448 and ML-DSA signers; countersignatures and ESS signing-certificate attributes; per-signer verification reports; decryption that fails closed; streaming sign, verify, encrypt and decrypt in constant memory with synchronous and asynchronous forms; two-phase signing (`PreparedSignedData`). - PKCS#8 (plain and encrypted, PBES2 with PBKDF2 or scrypt), PKCS#12 read and write (PBMAC1 and the classic MAC; legacy PBEs read only), legacy PEM private keys, and OpenSSH public keys, private keys and certificates (`RecursiveAI.OneCrypto.Wire.Ssh`). - Issuer side in core, as I/O-free handlers over the key interfaces with two-phase signing throughout: `CertificateAuthority` (profiles as data: root, intermediate, TLS server/client, S/MIME, code signing, OCSP signing, time-stamping; issuance from a CSR or a key; unpredictable serials; AKI/SKI, AIA and CRL-DP; CRLs and delta CRLs with numbers, reasons and issuing distribution point; the TLS validity schedule of the CA/Browser Forum requirements), `TimeStampRequest`/`TimeStampResponse`/`TstInfo`, `TimeStampVerifier` (structured report per RFC 3161 rule, the authority's certificate validated by `PathValidator` against caller-supplied anchors) and `TimeStampAuthority` (RFC 3161/5816 handler with serial source, accuracy, policy selection and failure info), and `OcspResponder` (RFC 6960 handler: CA or delegated responder, byName/byKey, nonce, multi-request, preproduced responses, errors as statuses). Two-way with OpenSSL 3.5.9 `ts`, `ocsp`, `verify` and `crl`. Docs: `docs/issuance.md`, `docs/tsp.md`, `docs/ocsp-responder.md`. - `SignedDataVerifyOptions.Chain`: each signer and countersigner of a SignedData is validated by the path validator, with the message's certificates, CRLs and OCSP responses as untrusted input; `SignerReport.Chain` holds the validator's report and the `chain` check summarises it. - `Crypto.Load`: one call that takes bytes, a stream or a path and returns what a key or certificate file holds, typed (`LoadedItems`): certificates and chains, CRLs, certification requests, OCSP responses, public keys, private keys as usable `ISigningKey`/`IDecryptionKey`/`IAgreementKey`, PKCS#7 certificate bags, PKCS#12 contents, OpenSSH keys, certificates and `authorized_keys`, from PEM or DER by a fixed decision table. A password callback is asked at most once per distinct secret. Bad input raises `CryptoLoadException` (or `Crypto.TryLoad`) with the formats considered. See `docs/load.md`. - `CryptoPolicy` (`Standard`, `Strict`, dated SHA-1/MD5/RSA rules, explicit reference time, decisions with rule ids and references); key interfaces (`ISigningKey`, `IDecryptionKey`, `IAgreementKey`, `IRsaKey`) with in-memory, library-curve and operating-system backends (`PrivateKey`); `SignatureSpec`; serialisable two-phase signing (`PreparedSignature`) with two-phase certificates, requests and revocation lists. RSASSA-PSS certificates with any salt length or MGF1 hash now verify. - X.509 models in the root namespace, immutable and lossless over the received octets: `Certificate` (with `X509Certificate2` conversion both ways), `CertificateSigningRequest`, `CertificateRevocationList` (complete, delta and indirect), `OcspRequest`, `OcspResponse`, `OcspBasicResponse` and read-only `AttributeCertificate`, with names (RFC 4514), general names, public keys and typed values for the RFC 5280 extensions; builders for certificates, requests, CRLs and OCSP messages that sign through `X509Signer`; structured signature results. See `docs/x509.md`. - The RFC 5280 path builder and validator (`RecursiveAI.OneCrypto.Validation`), as a **preview**: per-rule reports for every path tried, revocation from supplied CRLs and OCSP responses (offline is never "good"), validation at any instant, an opt-in server-authentication profile and an opt-in side-by-side report from the platform's chain engine. It has had an internal review; its follow-ups are still landing, so don't rely on it for trust decisions yet. - Platform gaps in AES-GCM, AES-CCM, named curves (for example Brainpool on macOS) and ML-KEM/ML-DSA keys now surface as `AlgorithmNotSupportedException` naming the algorithm and platform, never the platform's own `PlatformNotSupportedException`; the availability table lists AES-GCM and AES-CCM (macOS has no AES-CCM; AuthEnvelopedData defaults to AES-256-GCM and writes CCM only when asked). - Package metadata, package validation, NativeAOT and browser-wasm smoke apps, fuzz targets for every parser, and benchmarks against the managed libraries this one replaces. [Unreleased]: https://github.com/recursive-ai-oss/onecrypto-csharp/compare/v0.1.0-preview.1...HEAD [0.1.0-preview.1]: https://github.com/recursive-ai-oss/onecrypto-csharp/releases/tag/v0.1.0-preview.1 # Sign and verify with Ed25519 in C# .NET has no Ed25519. OneCrypto adds it in managed code: `Ed25519PrivateKey.Generate()` makes a key, `TrySign` signs, and `PublicKey.Verify` checks. The program below also saves both keys as standard PEM files so OpenSSL can verify the same signature. ```shell dotnet new console -o Ed25519Demo && cd Ed25519Demo dotnet add package RecursiveAI.OneCrypto --version 0.1.0-preview.1 ``` OneCrypto is a preview, so `dotnet add package` needs `--version` or `--prerelease`. Put this in `Program.cs` and run `dotnet run`: ```csharp using System.Security.Cryptography; using System.Text; using RecursiveAI.OneCrypto.Pkcs; using RecursiveAI.OneCrypto.Primitives; byte[] message = Encoding.UTF8.GetBytes("Ship build 4127 to production."); File.WriteAllBytes("message.txt", message); // A new key pair; the secret sits in a pinned buffer that Dispose zeroes using Ed25519PrivateKey key = Ed25519PrivateKey.Generate(); byte[] signature = new byte[Ed25519.SignatureSize]; key.TrySign(message, signature, out int written); File.WriteAllBytes("message.sig", signature); Console.WriteLine($"Signature: {written} bytes"); // Verify with the public key alone Ed25519PublicKey publicKey = key.PublicKey; Console.WriteLine($"Valid: {publicKey.Verify(message, signature)}"); byte[] tampered = Encoding.UTF8.GetBytes("Ship build 4128 to production."); Console.WriteLine($"Tampered: {publicKey.Verify(tampered, signature)}"); // Save the keys as PKCS#8 and SubjectPublicKeyInfo PEM, as OpenSSL writes them using PrivateKeyInfo pkcs8 = PrivateKeyInfo.FromEd25519(key); File.WriteAllText("private.pem", pkcs8.ToPem()); string spki = PemEncoding.WriteString("PUBLIC KEY", pkcs8.GetPublicKey().Encoded.Span); File.WriteAllText("public.pem", spki); Console.WriteLine(spki); ``` ```text Signature: 64 bytes Valid: True Tampered: False -----BEGIN PUBLIC KEY----- MCowBQYDK2VwAyEAWuJcyMUuwtde+4CAbyGKs6ZkrcDKjpGoJEvcTPzm35c= -----END PUBLIC KEY----- ``` Ed25519 signatures are always 64 bytes and the public key is 32. Signing is deterministic: the same key and message give the same signature. `Verify` returns `false` for any malformed input rather than throwing. ## Check it with OpenSSL The files are the standard formats, so any Ed25519 implementation can check the signature. With OpenSSL 3: ```shell openssl pkeyutl -verify -pubin -inkey public.pem -rawin \ -in message.txt -sigfile message.sig ``` ```text Signature Verified Successfully ``` The other way round works too: `Crypto.Load("key.pem")` reads a key made with `openssl genpkey -algorithm ed25519`, and its `PrivateKeys[0].Info.ToEd25519()` signs with it (see [Load a PEM or PKCS#8 private key](https://recursiveai.org/onecrypto/cookbook/load-pem-private-key/)). ## Raw keys If you store raw 32-byte keys, `ExportSeed` gives the private seed and `Ed25519PrivateKey.FromSeed` reads it back; `PublicKey.ToArray()` and `Ed25519PublicKey.FromBytes` do the same for the public key. The static `Ed25519.TrySign(seed, …)` and `Ed25519.Verify(publicKey, …)` work on spans without key objects. ## Limits - **Not audited.** This library has not been independently audited. It is a preview: 0.1.0-preview.1, and the API can change. - **Timing.** The project’s timing tests cover Ed25519 signing only in “earlier, shorter runs”, and it makes “no constant-time claim … for key handling” yet. Verification is not constant time by design: it works on public data. Details: [Conformance](https://recursiveai.org/onecrypto/docs/conformance/). - **Verification rules.** OneCrypto rejects non-canonical public keys and signature values, as RFC 8032 asks, and checks the cofactorless equation. A few signatures that other libraries accept (ZIP-215 rules) fail here: [Ed25519, Ed448, X25519 and X448](https://recursiveai.org/onecrypto/docs/curves/). - **Speed.** Against BouncyCastle 2.7.0, Ed25519 signing takes 0.55 of its time and verifying 0.90 on x64 (0.48 and 0.89 on arm64), from the project’s benchmark table. The x64 run is marked load-flagged. Ed448 works the same way with `Ed448PrivateKey`. To sign documents rather than raw bytes, see [Sign a file with CMS](https://recursiveai.org/onecrypto/cookbook/sign-file-cms/). # X25519 key exchange in C# .NET’s `ECDiffieHellman` has no Curve25519. OneCrypto adds X25519 (RFC 7748): `X25519PrivateKey.Generate()` makes your key, and `TryDeriveSharedSecret` combines it with the other side’s public key. Here the other side is a key made by OpenSSL, and OpenSSL derives the same secret. ```shell dotnet new console -o X25519Demo && cd X25519Demo dotnet add package RecursiveAI.OneCrypto --version 0.1.0-preview.1 openssl genpkey -algorithm x25519 -out bob.pem openssl pkey -in bob.pem -pubout -out bob-public.pem ``` OneCrypto is a preview, so `dotnet add package` needs `--version` or `--prerelease`. Put this in `Program.cs` and run `dotnet run`: ```csharp using System.Security.Cryptography; using System.Text; using RecursiveAI.OneCrypto; using RecursiveAI.OneCrypto.Pkcs; using RecursiveAI.OneCrypto.Primitives; // The peer's public key, from a PEM file using LoadedItems bobFile = Crypto.Load("bob-public.pem"); PublicKeyInfo bobInfo = bobFile.PublicKeys[0]; Console.WriteLine($"Peer key: {bobInfo.KeyAlgorithm}"); X25519PublicKey bob = X25519PublicKey.FromBytes(bobInfo.KeyBits.Span); // Our key pair; send our public key to the peer using X25519PrivateKey alice = X25519PrivateKey.Generate(); using (PrivateKeyInfo pkcs8 = PrivateKeyInfo.FromX25519(alice)) File.WriteAllText("alice-public.pem", PemEncoding.WriteString("PUBLIC KEY", pkcs8.GetPublicKey().Encoded.Span)); byte[] shared = new byte[X25519.KeySize]; if (!alice.TryDeriveSharedSecret(bob, shared)) throw new CryptographicException("Low-order peer key: abandon the exchange."); File.WriteAllBytes("shared-csharp.bin", shared); Console.WriteLine($"Shared: {shared.Length} bytes"); // Never use the raw secret as a key: derive one with HKDF (built into .NET) byte[] aesKey = HKDF.DeriveKey(HashAlgorithmName.SHA256, shared, 32, info: Encoding.UTF8.GetBytes("example app v1 AES-256-GCM key")); Console.WriteLine($"AES key: {aesKey.Length * 8} bits"); CryptographicOperations.ZeroMemory(shared); ``` ```text Peer key: X25519 Shared: 32 bytes AES key: 256 bits ``` Both sides compute the same 32 bytes from their own private key and the other’s public key. `TryDeriveSharedSecret` returns `false` when the peer’s key has low order (the all-zero check of RFC 7748 §6.1); stop the exchange then. HKDF and AES-GCM come from .NET itself. ## Check it with OpenSSL OpenSSL takes Bob’s private key and Alice’s public key and must arrive at the same secret: ```shell openssl pkeyutl -derive -inkey bob.pem -peerkey alice-public.pem \ -out shared-openssl.bin cmp shared-csharp.bin shared-openssl.bin && echo "Same 32-byte secret" ``` ```text Same 32-byte secret ``` ## Limits - **Not audited.** This library has not been independently audited. It is a preview: 0.1.0-preview.1, and the API can change. - **Timing.** The project’s timing tests cover X25519 only in “earlier, shorter runs”, and it makes “no constant-time claim … for key handling” yet. Details: [Conformance](https://recursiveai.org/onecrypto/docs/conformance/). - **Key exchange only.** X25519 gives a shared secret, not an authenticated channel: confirm the peer’s public key some other way, for example with a signature ([Ed25519](https://recursiveai.org/onecrypto/cookbook/ed25519-sign-verify/)). - **Speed.** Against BouncyCastle 2.7.0, X25519 agreement takes 0.76 of its time on x64 and 0.77 on arm64, from the project’s benchmark table. The x64 run is marked load-flagged. X448 works the same way with `X448PrivateKey`. How the curves are implemented and checked: [Ed25519, Ed448, X25519 and X448](https://recursiveai.org/onecrypto/docs/curves/). # Hash passwords with Argon2 in C# .NET has PBKDF2 but no Argon2. OneCrypto’s `Argon2.DeriveKey` implements RFC 9106 (Argon2d, Argon2i and Argon2id), and `Argon2.Verify` recomputes a hash and compares it. The program below hashes a password with Argon2id, stores it in the usual `$argon2id$v=19$…` string, and verifies a right and a wrong password. ```shell dotnet new console -o Argon2Demo && cd Argon2Demo dotnet add package RecursiveAI.OneCrypto --version 0.1.0-preview.1 ``` OneCrypto is a preview, so `dotnet add package` needs `--version` or `--prerelease`. Put this in `Program.cs` and run `dotnet run`: ```csharp using System.Security.Cryptography; using System.Text; using RecursiveAI.OneCrypto.Primitives; // OWASP's minimum for Argon2id: 19 MiB of memory, 2 passes, 1 lane const int MemoryKiB = 19 * 1024, Passes = 2, Lanes = 1; string stored = Hash("correct horse battery staple"); Console.WriteLine(stored); Console.WriteLine($"Right password: {Verify("correct horse battery staple", stored)}"); Console.WriteLine($"Wrong password: {Verify("correct horse battery staplr", stored)}"); static string Hash(string password) { byte[] salt = RandomNumberGenerator.GetBytes(16); byte[] pwd = Encoding.UTF8.GetBytes(password); byte[] tag = Argon2.DeriveKey(Argon2Type.Argon2id, pwd, salt, MemoryKiB, Passes, Lanes, tagLength: 32); // Saved only for the OpenSSL check below File.WriteAllText("salt.hex", Convert.ToHexString(salt)); File.WriteAllBytes("tag.bin", tag); return $"$argon2id$v=19$m={MemoryKiB},t={Passes},p={Lanes}${B64(salt)}${B64(tag)}"; } static bool Verify(string password, string stored) { // $argon2id$v=19$m=19456,t=2,p=1$$ string[] f = stored.Split('$'); var p = f[3].Split(',').ToDictionary(kv => kv[..1], kv => int.Parse(kv[2..])); return Argon2.Verify(Argon2Type.Argon2id, Encoding.UTF8.GetBytes(password), FromB64(f[4]), p["m"], p["t"], p["p"], FromB64(f[5])); } // The $argon2id$ string uses base64 without padding static string B64(byte[] b) => Convert.ToBase64String(b).TrimEnd('='); static byte[] FromB64(string s) => Convert.FromBase64String(s.PadRight(s.Length + (4 - s.Length % 4) % 4, '=')); ``` ```text $argon2id$v=19$m=19456,t=2,p=1$rQZ0z6SupSxVazCJ40LjWg$nRMXNg8kcaX52RRLnW3RSlBUUH19wyaBASnOVgY8FV8 Right password: True Wrong password: False ``` The first line is what you store: the algorithm, version, parameters, salt and hash in one string, so you can raise the parameters later and still verify old hashes. `Argon2.Verify` compares the hashes with `CryptographicOperations.FixedTimeEquals`. Hashing takes tens of milliseconds by design; that cost is what slows down guessing. ## Check it with OpenSSL OpenSSL 3.2 and later have Argon2. Given the same password, salt and parameters it must produce the same 32 bytes: ```shell openssl kdf -keylen 32 -binary -out tag-openssl.bin \ -kdfopt pass:"correct horse battery staple" -kdfopt hexsalt:$(cat salt.hex) \ -kdfopt iter:2 -kdfopt memcost:19456 -kdfopt lanes:1 -kdfopt threads:1 ARGON2ID cmp tag.bin tag-openssl.bin && echo "Same Argon2id hash" ``` ```text Same Argon2id hash ``` ## Parameters and limits `Argon2.DeriveKey` takes the memory in KiB, the passes, the lanes and the tag length, plus the optional secret and associated data of RFC 9106. Parameters read from someone else’s data are capped by default (2 GiB of memory, 64 passes, 64 lanes) and raise `KdfLimitExceededException` before anything is allocated; pass your own `KdfLimits` to change that. `Scrypt.DeriveKey` (RFC 7914) works the same way. ## Limits - **Not audited.** This library has not been independently audited. It is a preview: 0.1.0-preview.1, and the API can change. - **Side channels.** Argon2id “uses data-dependent memory access after its first half-pass by design (RFC 9106 §3.4); only Argon2i is data-independent.” The project makes no constant-time claim for Argon2id, Argon2d or scrypt: [Password-based key derivation](https://recursiveai.org/onecrypto/docs/kdf/). - **No string format built in.** OneCrypto returns the raw hash; the `$argon2id$` string above is built by the program. Check the format against the other systems that will read it. - **Speed.** Against the fastest managed .NET implementation, Argon2 takes 0.36 of its time on x64 and 0.46 on arm64, from the project’s benchmark table. The x64 run is marked load-flagged. How Argon2, scrypt and bcrypt-pbkdf are implemented and tested: [Password-based key derivation](https://recursiveai.org/onecrypto/docs/kdf/). # Load a PEM or PKCS#8 private key in C# `Crypto.Load` reads a key or certificate file without being told its format: PEM or DER, PKCS#8 plain or encrypted, traditional `RSA PRIVATE KEY` and `EC PRIVATE KEY` PEM (including the old `DEK-Info` encryption), PKCS#12 and OpenSSH. Each private key comes back ready to use. The program below loads three keys made by OpenSSL, one of them Ed25519, which .NET can’t load at all. ```shell dotnet new console -o LoadKeys && cd LoadKeys dotnet add package RecursiveAI.OneCrypto --version 0.1.0-preview.1 openssl genpkey -algorithm rsa -pkeyopt rsa_keygen_bits:3072 \ -aes-256-cbc -pass pass:s3cret -out rsa-pkcs8.pem openssl ecparam -name prime256v1 -genkey -noout \ | openssl ec -aes128 -passout pass:s3cret -out ec-legacy.pem openssl genpkey -algorithm ed25519 -out ed25519.pem ``` OneCrypto is a preview, so `dotnet add package` needs `--version` or `--prerelease`. Put this in `Program.cs` and run `dotnet run`: ```csharp using System.Text; using RecursiveAI.OneCrypto; using RecursiveAI.OneCrypto.Keys; foreach (string path in new[] { "rsa-pkcs8.pem", "ec-legacy.pem", "ed25519.pem" }) { // The callback is asked for a password only if the file is encrypted using LoadedItems file = Crypto.Load(path, _ => "s3cret"); LoadedPrivateKey key = file.PrivateKeys[0]; Console.WriteLine($"{path,-14} {file.Format} {key.Algorithm,-13} " + $"{key.PublicKey?.KeySizeBits,4} bits can sign: {key.Signing is not null}"); } // Sign with the Ed25519 key and save the signature for OpenSSL using LoadedItems ed = Crypto.Load("ed25519.pem"); ISigningKey signer = ed.PrivateKeys[0].Signing!; byte[] data = Encoding.UTF8.GetBytes("release-1.4.2.tar.gz sha256 9f2c..."); File.WriteAllBytes("data.txt", data); File.WriteAllBytes("data.sig", signer.Sign(data, signer.DefaultSpec)); Console.WriteLine($"Signed with {signer.DefaultSpec}"); ``` ```text rsa-pkcs8.pem PEM Rsa 3072 bits can sign: True ec-legacy.pem PEM EllipticCurve 256 bits can sign: True ed25519.pem PEM Ed25519 256 bits can sign: True Signed with Ed25519 ``` `Format` names the container that was found. Every private key carries `Signing`, `Decryption` or `Agreement` as its algorithm allows (RSA signs and decrypts, EC signs with ECDSA and agrees with ECDH, X25519 only agrees), plus its `PublicKey`. `Info` holds the PKCS#8 form when you need it: `Info.ToRsa()` and `Info.ToECDsa()` give .NET’s own key objects, `Info.ToEd25519()` OneCrypto’s. ## Check the signature with OpenSSL ```shell openssl pkeyutl -verify -inkey ed25519.pem -rawin -in data.txt -sigfile data.sig ``` ```text Signature Verified Successfully ``` ## Wrong passwords and bad files A file that is not a key raises `CryptoLoadException`. Its `Error` says why (`PasswordRequired`, `WrongPasswordOrCorrupt`, `Unrecognized`, `Malformed`…) and `Considered` lists the formats looked at; `Crypto.TryLoad` returns `false` with the same exception instead. The password callback gets a short description of the encrypted item, and a password that opened one item is tried on the next first, so one passphrase for a whole bundle is asked once. OpenPGP armor (`-----BEGIN PGP …`) is not loaded: OpenPGP comes in a later package. ## Limits - **Not audited.** This library has not been independently audited. It is a preview: 0.1.0-preview.1, and the API can change. - **What isn’t read.** OpenPGP, age and minisign files; `chacha20-poly1305@openssh.com` key files; RC4-encrypted PKCS#12. DSA keys load, but only as `Info`, with nothing to sign. - **Writing.** `PrivateKeyInfo.Encrypt(password)` writes encrypted PKCS#8 (PBES2); the legacy `DEK-Info` PEM encryption is read only. The format decision table and every error: [Crypto.Load](https://recursiveai.org/onecrypto/docs/load/). PKCS#8 and PKCS#12 details: [PKCS](https://recursiveai.org/onecrypto/docs/pkcs/). # Convert PEM to PFX (PKCS#12) in C# Load the certificate and key with `Crypto.Load`, then write both into one password-protected file with `Pkcs12File.Create`. The program below uses a certificate and key made by OpenSSL, and opens the result with OneCrypto, with .NET’s `X509CertificateLoader` and with OpenSSL. ```shell dotnet new console -o PemToPfx && cd PemToPfx dotnet add package RecursiveAI.OneCrypto --version 0.1.0-preview.1 openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:P-256 -nodes -days 365 \ -subj "/CN=api.example.com" -keyout key.pem -out cert.pem ``` OneCrypto is a preview, so `dotnet add package` needs `--version` or `--prerelease`. Put this in `Program.cs` and run `dotnet run`: ```csharp using System.Security.Cryptography.X509Certificates; using RecursiveAI.OneCrypto; using RecursiveAI.OneCrypto.Pkcs; using LoadedItems certFile = Crypto.Load("cert.pem"); using LoadedItems keyFile = Crypto.Load("key.pem"); Certificate cert = certFile.Certificates[0]; PrivateKeyInfo key = keyFile.PrivateKeys[0].Info; // Defaults: AES-256-CBC, PBKDF2-HMAC-SHA-256 with 600,000 iterations, HMAC-SHA-256 MAC Pkcs12File pfx = Pkcs12File.Create(key, cert, chain: [], "pfx-password", friendlyName: "api.example.com"); File.WriteAllBytes("server.pfx", pfx.Encoded.ToArray()); Console.WriteLine($"Wrote server.pfx: {pfx.Encoded.Length:N0} bytes, " + $"MAC {pfx.MacKind}"); // Read it back: the MAC is checked before anything is decrypted using Pkcs12Contents contents = Pkcs12File.Parse(File.ReadAllBytes("server.pfx")) .Open("pfx-password"); Console.WriteLine($"OneCrypto: {contents.Certificates.Count} certificate " + $"({contents.Certificates[0].Subject}), {contents.PrivateKeys.Count} private key"); // .NET's own loader stops at 300,000 iterations by default: raise its limits var limits = new Pkcs12LoaderLimits { MacIterationLimit = 600_000, IndividualKdfIterationLimit = 600_000, TotalKdfIterationLimit = 2_000_000 }; using X509Certificate2 x509 = X509CertificateLoader.LoadPkcs12FromFile("server.pfx", "pfx-password", loaderLimits: limits); Console.WriteLine($".NET: {x509.Subject}, has private key: {x509.HasPrivateKey}"); ``` ```text Wrote server.pfx: 1,184 bytes, MAC Pkcs12 OneCrypto: 1 certificate (CN=api.example.com), 1 private key .NET: CN=api.example.com, has private key: True ``` Pass the intermediate certificates as `chain` so servers can send the whole chain. `Pkcs12File.Create` also takes a list of bags for files with several keys, and `Pkcs12WriteOptions` chooses the MAC (`Pbmac1` for RFC 9579), the iteration counts and whether certificates are encrypted. ## Check it with OpenSSL ```shell openssl pkcs12 -in server.pfx -passin pass:pfx-password -noout -info 2>&1 ``` ```text MAC: sha256, Iteration 600000 MAC length: 32, salt length: 16 PKCS7 Data Shrouded Keybag: PBES2, PBKDF2, AES-256-CBC, Iteration 600000, PRF hmacWithSHA256 PKCS7 Encrypted data: PBES2, PBKDF2, AES-256-CBC, Iteration 600000, PRF hmacWithSHA256 Certificate bag ``` ## Limits - **Not audited.** This library has not been independently audited. It is a preview: 0.1.0-preview.1, and the API can change. - **Iteration counts.** The defaults use 600,000 PBKDF2 iterations for the key, the certificates and the MAC. .NET’s `X509CertificateLoader` refuses more than 300,000 unless you raise its `Pkcs12LoaderLimits`, as above; other readers may have limits of their own. A lower `MacIterations` in `Pkcs12WriteOptions` makes the password cheaper to guess. Triple DES and RC2 are never written; legacy PBEs are read, except RC4. - **Limits on input.** Files read from elsewhere are bounded: 64 MiB, 5,000,000 iterations, 256 safes and 4,096 bags by default (`PkcsLimits`). A file with no MAC is refused unless you pass `requireMac: false`. How PKCS#8, PKCS#12 and OpenSSH keys are read and written: [PKCS](https://recursiveai.org/onecrypto/docs/pkcs/). To read any `.pfx` without knowing what it holds: [Load a PEM or PKCS#8 private key](https://recursiveai.org/onecrypto/cookbook/load-pem-private-key/). # Generate an SSH key in C# Make an Ed25519 key, then write it with `SshPrivateKey.Create` in the `openssh-key-v1` format that `ssh-keygen` writes, encrypted with a passphrase, and the public key as an `authorized_keys` line. OpenSSH reads both. No `ssh-keygen` process is needed. ```shell dotnet new console -o SshKeyDemo && cd SshKeyDemo dotnet add package RecursiveAI.OneCrypto --version 0.1.0-preview.1 ``` OneCrypto is a preview, so `dotnet add package` needs `--version` or `--prerelease`. Put this in `Program.cs` and run `dotnet run`: ```csharp using RecursiveAI.OneCrypto.Pkcs; using RecursiveAI.OneCrypto.Primitives; using RecursiveAI.OneCrypto.Wire.Ssh; using Ed25519PrivateKey ed = Ed25519PrivateKey.Generate(); using PrivateKeyInfo key = PrivateKeyInfo.FromEd25519(ed); // aes256-ctr with 16 bcrypt rounds, the ssh-keygen defaults SshPrivateKey sshKey = SshPrivateKey.Create(key, "deploy@build-01", "a long passphrase"); File.WriteAllText("id_ed25519", sshKey.ToPem()); if (!OperatingSystem.IsWindows()) // ssh refuses keys others can read File.SetUnixFileMode("id_ed25519", UnixFileMode.UserRead | UnixFileMode.UserWrite); string pub = sshKey.PublicKey.ToAuthorizedKey("deploy@build-01"); File.WriteAllText("id_ed25519.pub", pub + "\n"); Console.WriteLine($"Private key: {sshKey.CipherName}, {sshKey.KdfName}, " + $"encrypted: {sshKey.IsEncrypted}"); Console.WriteLine($"Fingerprint: {sshKey.PublicKey.Fingerprint}"); ``` ```text Private key: aes256-ctr, bcrypt, encrypted: True Fingerprint: SHA256:gn7ABggzAYXIw2oNXML8oHgQDVnDT4tR2Fbg4Xlboww ``` Put the `.pub` line in a server’s `~/.ssh/authorized_keys`, and the private key file where `ssh` looks for keys. `ToPem()` writes the `-----BEGIN OPENSSH PRIVATE KEY-----` form; pass an empty passphrase for an unencrypted key. RSA and ECDSA keys work the same way: build the `PrivateKeyInfo` from .NET’s `RSA` or `ECDsa` with `PrivateKeyInfo.FromAsymmetricAlgorithm`. ## Check it with ssh-keygen `ssh-keygen -y` decrypts the private key and prints its public key, which must equal the `.pub` file; `-l` prints the fingerprint: ```shell ssh-keygen -y -P "a long passphrase" -f id_ed25519 | cut -d' ' -f1-2 > from-private.pub cut -d' ' -f1-2 id_ed25519.pub | cmp - from-private.pub \ && echo "ssh-keygen reads the key" ssh-keygen -l -f id_ed25519.pub | cut -d' ' -f2 ``` ```text ssh-keygen reads the key SHA256:gn7ABggzAYXIw2oNXML8oHgQDVnDT4tR2Fbg4Xlboww ``` ## Reading keys `SshPrivateKey.Parse` (or `FromPem`) reads a key file and `Open(passphrase)` decrypts it; `SshPublicKey.ParseAuthorizedKey` reads a `.pub` or `authorized_keys` line. `Crypto.Load` reads all of these without being told the format and returns keys ready to sign: see [Load a PEM or PKCS#8 private key](https://recursiveai.org/onecrypto/cookbook/load-pem-private-key/). ## Limits - **Not audited.** This library has not been independently audited. It is a preview: 0.1.0-preview.1, and the API can change. - **Key files only.** OneCrypto reads and writes keys and certificates; it is not an SSH client, and it doesn’t talk to `ssh-agent` yet. Signing files with SSH keys (SSHSIG) comes in a later package. - **Ciphers.** `chacha20-poly1305@openssh.com` key files are not read. `bcrypt-pbkdf`, which protects the passphrase, looks up tables by data; the project says why it accepts that in [Password-based key derivation](https://recursiveai.org/onecrypto/docs/kdf/). How OpenSSH keys and certificates are read and written: [PKCS](https://recursiveai.org/onecrypto/docs/pkcs/). # Sign a file with CMS (PKCS#7) in C# `SignedData.Create` writes a CMS signature, the format behind `.p7s` files, S/MIME and signed PDFs, and `SignedData.Verify` checks one and reports each rule it applied. The program below signs a file with a certificate and key made by OpenSSL, writes a detached `.p7s`, and verifies it. ```shell dotnet new console -o CmsSign && cd CmsSign dotnet add package RecursiveAI.OneCrypto --version 0.1.0-preview.1 openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:P-256 -nodes -days 365 \ -subj "/CN=Release Signing" -addext "keyUsage=critical,digitalSignature" \ -keyout signer-key.pem -out signer.pem echo "release 1.4.2" > release.txt ``` OneCrypto is a preview, so `dotnet add package` needs `--version` or `--prerelease`. Put this in `Program.cs` and run `dotnet run`: ```csharp using System.Security.Cryptography; using RecursiveAI.OneCrypto; using LoadedItems signerFile = Crypto.Load("signer.pem"); using LoadedItems keyFile = Crypto.Load("signer-key.pem"); Certificate cert = signerFile.Certificates[0]; // Sign: a detached signature carries the certificate but not the file itself byte[] content = File.ReadAllBytes("release.txt"); var signer = new CmsSignerSpec { Certificate = cert, Key = keyFile.PrivateKeys[0].Signing, }; var options = new SignedDataOptions { Detached = true }; SignedData signed = SignedData.Create(content, [signer], options); File.WriteAllBytes("release.txt.p7s", signed.Encoded.ToArray()); Console.WriteLine($"Wrote release.txt.p7s ({signed.Encoded.Length} bytes)"); // Verify: parse the .p7s and check it against the file SignedData p7s = SignedData.Parse(File.ReadAllBytes("release.txt.p7s")); SignedDataReport report = p7s.Verify(File.ReadAllBytes("release.txt")); SignerReport s = report.Signers[0]; foreach (CmsCheck check in s.Checks) Console.WriteLine($" {check.Rule,-20} {check.Status}"); // The report proves the signature matches the certificate inside the .p7s. // Whether you trust that certificate is your decision: here, compare it with // the one you expect, by fingerprint. byte[] expected = cert.ComputeFingerprint(HashAlgorithmName.SHA256); bool pinned = s.Certificate is not null && CryptographicOperations.FixedTimeEquals( s.Certificate.ComputeFingerprint(HashAlgorithmName.SHA256), expected); Console.WriteLine($"Signature valid: {report.IsValid}, expected signer: {pinned}"); File.AppendAllText("release.txt", "tampered\n"); bool stillValid = p7s.Verify(File.ReadAllBytes("release.txt")).IsValid; Console.WriteLine($"After tampering: {stillValid}"); File.WriteAllText("release.txt", "release 1.4.2\n"); ``` ```text Wrote release.txt.p7s (822 bytes) signer-certificate Passed digest-algorithm Passed attributes Passed content-type Passed message-digest Passed signature Passed algorithm-protection NotApplicable key-usage Passed key-strength Passed signing-certificate Passed Signature valid: True, expected signer: True After tampering: False ``` Each check names a rule from RFC 5652 or a related RFC (`Reference` gives the clause, `Reason` the detail when it fails). Leave `Detached` off to embed the content in the message; `p7s.EncapsulatedContent` then returns it. RSA, Ed25519 and Ed448 keys sign the same way, and `SignStream` and `VerifyStream` handle files larger than memory. ## Check it with OpenSSL OpenSSL verifies the same `.p7s`. Here it uses the signer’s own certificate as the trusted one: ```shell openssl cms -verify -binary -inform DER -in release.txt.p7s -content release.txt \ -CAfile signer.pem -purpose any -out /dev/null 2>&1 ``` ```text CMS Verification successful ``` ## Limits - **Not audited.** This library has not been independently audited. It is a preview: 0.1.0-preview.1, and the API can change. - **Trust is not checked by default.** `Verify` takes no clock and builds no certificate chain: a valid report says the signature is sound for the certificate in the message, nothing about who issued it. Check the signer yourself, as above. OneCrypto has an RFC 5280 path validator (`SignedDataVerifyOptions.Chain`), but it is a preview: the project says it is “not yet recommended for trust decisions”, so don’t rely on it to decide whom to trust yet ([Path validation](https://recursiveai.org/onecrypto/docs/validation/)). - **Speed.** Against BouncyCastle 2.7.0, CMS signing takes 0.18 of its time and verifying 0.33 on x64 (geometric means; 0.34 and 0.18 on arm64), from the project’s benchmark table. ECDSA P-256 verification is the slowest row, 1.66× for a 1 KiB message on x64, because it calls the platform’s ECDSA ([Conformance](https://recursiveai.org/onecrypto/docs/conformance/)). Every rule, limit and the OpenSSL test matrix: [Cryptographic Message Syntax](https://recursiveai.org/onecrypto/docs/cms/). To encrypt rather than sign: [Encrypt and decrypt with CMS](https://recursiveai.org/onecrypto/cookbook/encrypt-file-cms/). # Encrypt and decrypt with CMS in C# `AuthEnvelopedData.Create` encrypts data for the holder of a certificate, in the CMS format that S/MIME mail and `.p7m` files use; `Decrypt` opens it with the private key. The content is encrypted with AES-256-GCM, which detects any change, and the AES key is wrapped with RSA-OAEP for the recipient. The program below encrypts for a certificate made by OpenSSL and also opens a file OpenSSL encrypted. ```shell dotnet new console -o CmsEncrypt && cd CmsEncrypt dotnet add package RecursiveAI.OneCrypto --version 0.1.0-preview.1 openssl req -x509 -newkey rsa:3072 -nodes -days 365 -subj "/CN=Alice" \ -keyout alice-key.pem -out alice.pem echo "Q3 numbers: draft 2" > report.txt openssl cms -encrypt -binary -aes-256-gcm -recip alice.pem \ -keyopt rsa_padding_mode:oaep -in report.txt -outform DER -out from-openssl.p7m ``` OneCrypto is a preview, so `dotnet add package` needs `--version` or `--prerelease`. Put this in `Program.cs` and run `dotnet run`: ```csharp using System.Text; using RecursiveAI.OneCrypto; using LoadedItems certFile = Crypto.Load("alice.pem"); Certificate alice = certFile.Certificates[0]; // Encrypt for Alice: AES-256-GCM content, AES key wrapped with RSA-OAEP (SHA-256) byte[] plaintext = File.ReadAllBytes("report.txt"); AuthEnvelopedData message = AuthEnvelopedData.Create(plaintext, [CmsRecipient.KeyTransport(alice)]); File.WriteAllBytes("report.p7m", message.Encoded.ToArray()); Console.WriteLine($"Encrypted report.p7m: {message.Encoded.Length} bytes, " + $"{message.RecipientInfos.Count} recipient"); // Decrypt with Alice's private key and certificate using LoadedItems keyFile = Crypto.Load("alice-key.pem"); using CmsDecryptionKey key = CmsDecryptionKey.FromKey( keyFile.PrivateKeys[0].Decryption!, alice); foreach (string path in new[] { "report.p7m", "from-openssl.p7m" }) { byte[] opened = AuthEnvelopedData.Parse(File.ReadAllBytes(path)).Decrypt(key); Console.WriteLine($"{path,-17} {Encoding.UTF8.GetString(opened).Trim()}"); } ``` ```text Encrypted report.p7m: 616 bytes, 1 recipient report.p7m Q3 numbers: draft 2 from-openssl.p7m Q3 numbers: draft 2 ``` Add more `CmsRecipient` entries to encrypt one message for several people; each opens it with their own key. EC certificates use `CmsRecipient.KeyAgreement`, and `CmsRecipient.Password` encrypts with a password instead of a certificate. `EncryptStream` and `DecryptStream` take streams. ## Check it with OpenSSL OpenSSL decrypts the file OneCrypto wrote: ```shell openssl cms -decrypt -binary -inform DER -in report.p7m \ -recip alice.pem -inkey alice-key.pem ``` ```text Q3 numbers: draft 2 ``` ## When decryption fails Decryption fails closed: no plaintext is returned until the whole message is decrypted and the GCM tag has verified. A wrong key, a damaged key or a changed byte all raise the same `CmsParseException` with `CmsError.DecryptionFailed`; only “no recipient in this message is meant for this key” is told apart. ## Limits - **Not audited.** This library has not been independently audited. It is a preview: 0.1.0-preview.1, and the API can change. - **Memory.** AES-GCM is one-shot on .NET, so `AuthEnvelopedData` holds the whole message in memory, up to 1 GiB by default (`CmsLimits.MaxContentBytes`). For larger files, split them into chunks, or use `EnvelopedData`, which streams with AES-CBC but cannot detect every change: the project recommends AuthEnvelopedData where authenticity matters. - **No sender identity.** Encryption says nothing about who sent the file. Sign it too if that matters: [Sign a file with CMS](https://recursiveai.org/onecrypto/cookbook/sign-file-cms/). - **Readers.** AuthEnvelopedData (RFC 5083) is newer than EnvelopedData; OpenSSL 3 reads it, but check older S/MIME clients before relying on it. Every recipient type, limit and the OpenSSL test matrix: [Cryptographic Message Syntax](https://recursiveai.org/onecrypto/docs/cms/).