TLS and cryptography

Cruft's entire cryptographic stack, the hashes, AES, RSA, the elliptic curves, the DER and X.509 decoders, and a TLS 1.3 client and server, is its own Rust with no OpenSSL, ring, or rustls underneath. This page inventories every primitive and states exactly where the young, unaudited stack's security ends.

In most runtimes the cryptography comes from OpenSSL, BoringSSL, or rustls: mature, extensively audited libraries, hardened over many years, and usually the most consequential dependency in the stack. Depending on them is the sound, conventional choice. Cruft takes a different path: its cryptographic stack is its own. From SHA-256 up through a TLS 1.3 handshake, it is Cruft's own Rust with no external crypto dependency: no ring, no rustls, no openssl, no sha2. The bignum arithmetic, the elliptic-curve field math, the AES tables, the DER codec, and the TLS state machine are all Cruft's own code. That buys one trust story instead of two, and it means the isolation model can reason about the code the runtime actually mediates. It also means the stack is young, so this page documents the full primitive inventory and states exactly where its maturity ends.

Alpha (0.0.10). This stack has never been externally audited, and its live-handshake and interop tests are not part of the default test run, so the end-to-end security posture is exercised only in manual runs, not in CI. The certificate name and date logic, the key schedule, and the primitive floor do have running unit coverage; the whole-handshake posture does not. Read the Security boundaries section before relying on Cruft's TLS for anything adversarial, and do not use it in production.

Throughout, a property that an enforcing code path or a test backs is stated as holding; one the design intends but nothing yet checks is called out as intended. In a young stack that difference is the whole story, so it is drawn inline.

The zero-dependency fact

The crypto and TLS stack carries no external Cargo dependencies. The crypto primitives and the DER codec have none at all; the X.509 layer depends only on those two siblings; the TLS transport depends only on X.509, DER, and the crypto primitives. Every SHA compression function, every Montgomery multiplication, every AES S-box lookup is in-tree, following the specification.

This is the WebAssembly and SQL-stack ownership argument at its most extreme. Cryptography is the surface where "just use the library" is most tempting and most defensible, and Cruft declined anyway, because a runtime whose crypto is a vendored library has a second trust story it does not own, and because the isolation model can only make claims about code the runtime actually mediates.

The layers

The crypto and TLS layers, surface to primitive
Crypto APIscrypto.subtle (JS prelude) and node:crypto (host facade)
Crypto primitivesall primitives, with no dependencies of their own
X.509 · DERcertificate and DER parsing
TLS transportTLS 1.3 / 1.2, built on the layers above
TLS APIscruft:tls, node:tls, node:https, and fetch

The primitive inventory

Every algorithm below is implemented in-tree.

Hashes
SHA-1SHA-256SHA-384SHA-512BLAKE2b (used by Argon2)
MAC, KDF, password
HMAC (SHA-1/256/384/512)HKDF (all four)PBKDF2-HMAC (all four)scryptArgon2id (RFC 9106)
Symmetric
AES-CBCAES-CTRAES-GCMAES-KW (RFC 3394) at 128/192/256-bit

The 384/512 hash pair share the 64-bit SHA-2 core. No MD5, no SHA-3. No ChaCha20-Poly1305 yet (the TLS constant exists but is unwired), and no DES/3DES.

Asymmetric, over Cruft's own big-integer type with Montgomery arithmetic:

  • RSA: PKCS#1 v1.5 sign/verify, PSS sign/verify (MGF1), OAEP encrypt/decrypt, and keygen (including the CRT variant).
  • ECDSA: P-256, P-384, and P-521. P-256 has a precomputed base-point table and Solinas plus Montgomery reductions for speed. All three are wired to crypto.subtle: WebCrypto generateKey, sign, and verify round-trip on P-256, P-384, and P-521 today. (One surface still lags: node:crypto's getCurves(), which lists the createECDH named curves, reports P-256 only.)
  • ECDH: generic plus a P-256 fast path.
  • Ed25519 (RFC 8032) and X25519 (RFC 7748 Montgomery ladder).

Random. getRandomValues is a real OS CSPRNG on every supported platform: /dev/urandom on Unix, BCryptGenRandom (the system-preferred RNG) on Windows, and a hard error on any target where neither is wired, so it never degrades to a weak source. Plus randomUUID v4 and a constant-time timingSafeEqual. The randomness posture is examined in the boundaries section below.

Round trips that pass (each returns true or the expected value):

await crypto.subtle.digest("SHA-256", data);                    // ✓
// HMAC sign+verify, AES-GCM encrypt+decrypt, ECDSA P-256/384/521
// sign+verify, Ed25519 sign+verify, HKDF deriveBits, RSA-OAEP,
// RSA-PSS, PBKDF2, X25519 deriveBits, AES-KW wrap/unwrap,
// SPKI/PKCS8/JWK export+reimport ... all ✓

The two JavaScript surfaces

The primitives back both crypto APIs the ecosystem uses:

  • WebCrypto (crypto.subtle) is a self-hosted JS prelude (crypto_prelude.js) exposing CryptoKey/SubtleCrypto and calling into the engine's crypto helpers. Coverage is broad, and crypto.subtle is complete for the shipped algorithm set: digest; HMAC/ECDSA/RSA-PKCS1/RSA-PSS/Ed25519 sign+verify; AES-GCM/CBC/CTR plus RSA-OAEP encrypt/decrypt; the full generateKey/deriveBits/deriveKey/wrapKey/unwrapKey matrix.
  • node:crypto (host facade) is the Node idiom: createHash/createHmac, the randomBytes/randomFillSync/randomInt family, createCipheriv/createDecipheriv (AES CBC/CTR/GCM with AAD and auth-tag), pbkdf2Sync/scryptSync, generateKeyPairSync (rsa/ec/ed25519, PKCS8/SPKI PEM export), sign/verify, publicEncrypt/privateDecrypt, createECDH, createDiffieHellman, timingSafeEqual. One gap to know: hkdfSync is not implemented and throws; use the async crypto.hkdf, or WebCrypto HKDF deriveBits, instead.

Key interchange: raw, jwk, spki, and pkcs8 are all wired, with current limits. jwk import is symmetric-only (kty:"oct"), and spki/pkcs8 for EC is P-256 only (RSA is broader). EC public-key jwk export works. getCurves reports only P-256 even though the primitives implement P-384 and P-521.

The DER codec underneath is a complete reader (strict definite-length TLV, minimal-encoding enforcement, typed accessors, OIDs) and writer. The writer exists specifically so exportKey("spki"/"pkcs8") can emit real SubjectPublicKeyInfo / PKCS#8.

TLS transport

The TLS transport is a working TLS 1.3 client and server (RFC 8446) with a TLS 1.2 client sibling (single AEAD suite, for 1.2-only edges like some registry endpoints). fetch("https://example.com") and node:https.get both complete a real handshake against public CDNs and return 200.

What is implemented:

  • Handshake: full 1-RTT client and a feed-driven non-blocking server state machine; cipher suite TLS_AES_128_GCM_SHA256 (the negotiable one), with AES-256-GCM/ChaCha20 constants present but unwired; groups X25519 / secp256r1 / secp384r1; the RFC 8446 key schedule (HKDF-Expand-Label, traffic-key derivation) in-tree.
  • SNI (client sends it), ALPN (negotiated both directions, this is what selects the HTTP/2 path for https servers), and TLS 1.2 session tickets (RFC 5077, in-memory, per-host; no TLS 1.3 PSK/0-RTT).
  • Server role: cruft:tls/node:tls/node:https createServer with PEM cert+key (EC-P256 or RSA), sharing the same transport that terminates TLS in front of the HTTP server.
  • The record layer: TLSPlaintext framing, AEAD record protection, alerts.

The host binding runs the blocking handshake on a worker thread, then registers the non-blocking session with the shared IO poll loop. cruft:tls is the primitive connect/createServer surface; node:tls is the EventEmitter TLSSocket adapter.

Security boundaries

This section walks the TLS client as it actually behaves today, and separates what an enforcing code path backs from what is only intended.

What certificate validation does now. The client walks the issuer-signature chain to a system-trusted root (depth 8, DN byte-matching, real ECDSA/RSA signature verify at each hop), then checks the leaf. Three checks are enforced and carry unit coverage:

  • Hostname / SAN matching. The subjectAltName dNSName and iPAddress entries are decoded and matched against the requested host, with correct single-left-label wildcard logic, and a CN fallback only when no SAN is present. The exact-DNS, wildcard, IP-SAN, and mismatch cases are covered.
  • Validity window. notBefore/notAfter are parsed and enforced; expired and not-yet-valid certs are rejected.
  • Downgrade-sentinel refusal. The client offers both a TLS 1.3 and a TLS 1.2 suite. When it accepts a TLS 1.2 ServerHello, it checks the RFC 8446 §4.1.3 downgrade sentinel and refuses a handshake that carries it, so an active attacker cannot silently force the weaker path. Both the TLS 1.2 and the TLS 1.1-and-below sentinel values are covered.

Two more hardening properties hold at the transport layer:

  • Malformed peer input returns an error, not a panic. The handshake phase-state transitions, the shared verified-chain cache, and the TLS 1.2 ticket store use typed errors and poison-recovering lock access, so a hostile or corrupt peer, and a poisoned shared lock, no longer take down the connection or the process. A poisoned shared lock recovers and subsequent access still succeeds.
  • The Finished-MAC comparison is constant-time. The TLS 1.3 client and server and the TLS 1.2 full/resumed Finished checks all route through a length-strict constant-time byte comparison rather than a short-circuiting !=.

Where the chain walk still falls short. These are genuine, open gaps, not resolved ones:

  • No CA-constraint enforcement. The chain walk performs no BasicConstraints cA check, no pathLenConstraint, no KeyUsage or ExtendedKeyUsage check, and no name-constraints. Nothing verifies that an intermediate is actually permitted to be a CA, so a certificate that chains to a trusted root is not prevented from acting as an issuer for another host: the classic "any valid leaf can sign for any host" escalation. This is the highest-severity certificate gap. Do not use Cruft's TLS client against an adversarial network until it closes. Its root is in the certificate parser (see the x509 section), which does not decode these extensions in the first place.
  • No revocation (OCSP/CRL), no client-certificate auth (mTLS), no TLS 1.3 resumption/0-RTT, no KeyUpdate (a received KeyUpdate is fatal). These are by-design carve-outs for the alpha, not oversights.

Only one cipher suite and one group actually work. AES-128-GCM-SHA256 over P-256 is the whole working matrix; AES-256-GCM, ChaCha20-Poly1305, X25519, and P-384 exist as constants but are never negotiated, and the 1.3 path hard-rejects them. Randomness is a real OS CSPRNG (/dev/urandom, Windows BCryptGenRandom), and the AEAD nonce construction is RFC 8446 §5.3-correct with no reuse under monotonic counters. One minor wart: the ephemeral P-256 scalar is bit-cleared rather than rejection-sampled, a negligible but real deviation from uniform generation.

The rejectUnauthorized:false toggle is not per-connection yet. A per-handshake config type exists, but the HTTP client still drives the insecure-skip decision through an ambient, thread-scoped flag it sets around a request and resets after. Treat "skip certificate validation" as a process- or thread-global mode, not a cleanly per-connection option: turning it on for one request is not guaranteed isolated from concurrent work on the same thread. Off is the default, and off is secure.

The tests do not back the live handshake. This is the most important caveat. Of the crate's integration tests, the ones that exercise a live handshake or interop are excluded from the default run, including the sole interop test against OpenSSL s_server (the P-256 scalar-mul exceeds the default per-test time budget). Only two RFC 8448 key-schedule values are asserted, and there is no differential harness against rustls or BoringSSL. So the certificate-name and date logic, the key schedule, and the transport-hardening properties above have running unit coverage, while the end-to-end security posture is unverified. The stack's declared goal is "CDN-passable" (it completes real handshakes against major CDNs), not "BoringSSL parity."

The primitive floor (web-crypto)

Everything above rests on web-crypto, the crate that implements the hashes, AEAD, MAC, KDF, RSA, ECDSA, ECDH, and the CSPRNG. It is strong exactly where it matters most and incomplete in the ways an unaudited alpha is. It has zero Cargo dependencies (genuinely std-only) and exactly one unsafe block (the Windows BCryptGenRandom FFI); on Unix it is 100% safe Rust.

What is solid:

  • The CSPRNG is done right. OS randomness on every supported platform, no weak, seeded, or deterministic fallback anywhere in the crate, and key and prime generation route through it. This is the single most important thing to get right, and it is right.
  • The secret-comparison story is correct. A branchless timingSafeEqual backs every equal-length secret comparison that matters: the AES-GCM tag check, the HMAC verify, and the RSA PKCS#1 v1.5 / PSS / OAEP-lHash checks. The AEAD tag is not compared with a naive ==.
  • ECDSA signs with a safe nonce by default. The signing path derives the per-signature nonce deterministically from the private scalar and the message hash over the in-tree HMAC (an RFC 6979-shaped default), masking and rejecting out-of-range candidates, and the nonce inversion takes a public-exponent Fermat route rather than a secret-dependent Euclid path. It does not delegate production safety to a caller-supplied k. TLS CertificateVerify, node:crypto EC signing, and the WebCrypto ECDSA helper all route through this. (Low-level explicit-nonce entry points remain for test-vector callers.)
  • RSA-OAEP decrypt does not leak a padding oracle. The decode scans the whole data block, accumulates every padding, separator, lHash, and Y-byte condition, and returns a single generic error, rather than returning early on the first bad byte. An earlier data-dependent early-return path (and a source comment that wrongly claimed it was constant-time) is gone.
  • No accept-all and no malformed-input panic on the verify paths. ECDSA verify range-checks r and s and on-curve-checks the key; PKCS#1 v1.5 verify reconstructs and compares the padding (so it is not Bleichenbacher-forgeable); signature, key, and ciphertext parsing are length-checked and return errors rather than panicking.
  • The symmetric and hash floor is vector-backed. SHA-1/256/384/512, HMAC, HKDF, PBKDF2, scrypt, Argon2id, AES (CBC/CTR/GCM/KW), Ed25519, and X25519 are checked against real published test vectors (FIPS, the relevant RFCs, SP 800-38).

Where it is intended but not yet proven:

  • It is not side-channel hardened. The RSA, ECDSA, ECDH, X25519, and Ed25519 private-key operations run over variable-time bignum and field arithmetic, and AES is table-driven (cache-timing exposed). The deterministic-nonce and OAEP fixes above closed the two catastrophic timing and oracle exposures, but the general side-channel surface is out of scope for this first cut. For a library that backs TLS, a remote or co-resident timing attacker is a real threat model, so this is a genuine gap, not a footnote. Do not run timing-sensitive private-key operations where an attacker can measure them.
  • Secrets are only partly zeroized. HMAC key pads and inner/outer buffers, and AES expanded round keys, are wiped (the AES surfaces return an owned wrapper that wipes on drop). But RSA/EC/X25519/Ed25519 private scalars, RSA CRT factors, ECDSA nonce temporaries, KDF memory, and the underlying secret big-integer storage are still left in freed heap memory. Do not assume key material is scrubbed after use.
  • The asymmetric schemes have thin external-vector coverage. A default-running differential check runs three surfaces against Node's WebCrypto: RSA-OAEP/SHA-256 ciphertext from Node decrypted by Cruft, ECDSA P-256 signatures from Cruft verified by Node, and ECDH P-256 shared-secret parity. That is a real third-party oracle floor, but it is narrow: RSA-PSS and RSASSA-PKCS1-v1_5 signatures are not yet interop-verified against Node, and there is no Wycheproof, no CAVP, and no differential harness against a full reference implementation. So interoperability for the RSA signature schemes and for P-384/P-521 rests today on internal consistency more than on a reference.
  • One low-severity operational note: the CSPRNG reads /dev/urandom as a file rather than via getrandom(2), so it can under-seed if called extremely early in boot and consumes a file descriptor, and it panics (rather than erroring) if the OS RNG cannot be read.

The randomness, the AEAD and MAC comparators, the ECDSA nonce default, and the OAEP decode are done correctly, and the symmetric floor is vector-backed. The general constant-time story and the breadth of asymmetric-scheme interop evidence are intended but not yet fully covered, so the asymmetric primitives and any timing-sensitive use are alpha.

The certificate parser (x509)

Between the primitives and the TLS chain walk sits x509, a single crate that turns certificate bytes into a structure and verifies one certificate's signature against another's key. It is competent at structure and clean on memory safety, and thin on exactly the parts that decide trust.

What is solid:

  • No unsafe on the parse path, and no panic found on malformed input. The crate has no unsafe, and every DER index goes through the asn1-der backend, which bounds-checks lengths, rejects indefinite and non-minimal encodings, enforces minimal INTEGER encoding, and refuses trailing data. A hostile certificate returns an error, not a crash. This is established by reading, not by a fuzzer (see the test note below).
  • Signature dispatch is driven by the signature OID, not by attacker choice, and a key-type/signature-type mismatch (an RSA signature OID against an EC key, or the reverse) is rejected. It covers RSA PKCS#1 v1.5 (SHA-1/256/384/512) and ECDSA P-256/P-384. It does not implement RSA-PSS, Ed25519, or P-521.

Where trust decisions are missing or thin:

  • It does not decode the extensions that constrain a CA. Only subjectAltName has a typed accessor (DNS and IP only). BasicConstraints (cA, pathLen), KeyUsage, ExtendedKeyUsage, and NameConstraints are captured as opaque raw bytes with no accessor and no OID constant. This is the root of the chain-walk gap: the TLS layer cannot check whether an intermediate is allowed to be a CA because the parser never tells it. The crate also does not reject a certificate carrying an unknown critical extension, which RFC 5280 requires.
  • Certificate name decoding is fragile. Embedded NUL bytes are not rejected, so the classic CN=example.com\0.attacker.com null-prefix survives intact; a CN encoded as BMPString silently decodes to an empty string; and TeletexString is mis-read as UTF-8. Each can cause a certificate name to be misread, and name matching is the peer-authentication decision.
  • SHA-1 certificate signatures are accepted with no gate. A collision-broken signature algorithm is honored silently.
  • The verifier is untested in a default build. Almost all of the crate's tests are excluded from the default run and gated on a local OpenSSL; the only one that runs by default is a trivial PEM-header check. There is no malformed-certificate corpus, no fuzzing, and no differential against a reference parser. Parse-then-verify of a real certificate is exercised only under a manual opt-in run, which is why the memory-safety claims above rest on reading.

x509 reads certificate structure carefully and safely, but it stops short of the extension semantics that authorize trust, and its verification path has almost no test behind it in a default build. It is the layer where the certificate-validation frontier lives.

The DER parser (asn1-der)

At the very bottom, under both the certificate parser and the TLS signature decode, is asn1-der: std-only, zero dependencies, turning raw ASN.1 DER bytes into typed values. Every hostile certificate and signature the runtime sees is decoded through it, so the whole stack rests on its robustness. It is the strongest link in the stack on memory safety, and the weakest on test evidence.

What is solid:

  • No unsafe, and no reachable panic on malformed input. Every buffer index is guarded by a prior bounds check, and every fallible path returns an error rather than panicking.
  • No memory-exhaustion via a declared length. A length field larger than the actual input is rejected before use, and the reader is zero-copy (it slices into the input, it never allocates a buffer of an attacker-declared size). This is the correct design for a hostile-input parser, and it is the thing most low-level parsers get wrong.
  • The parser is iterative, so it cannot overflow its own stack on nested input, and it rejects the common non-canonical encodings: indefinite lengths, non-minimal integers, malformed booleans, and trailing data at the top level.

Where it is thin:

  • It offers no depth limit to its callers. Because the parser does not itself recurse, the risk moves up: x509 and TLS drive the descent into nested structures with their own recursion, and a certificate with thousands of nested tags can overflow their stack. The parser gives them no bounded-depth mode to opt into.
  • It accepts some non-canonical encodings. Non-minimal long-form lengths of two or more bytes, unvalidated BIT STRING unused-bits, and constructed encodings of primitive types all pass. Any of these is a parser-differential risk: a certificate this parser accepts that a reference parser rejects (or the reverse) is exactly the kind of disagreement that defeats certificate validation.
  • String and time decoding push work upward. TeletexString is mis-decoded as UTF-8, BMPString and UniversalString are unsupported, and times are returned as raw bytes with no field validation or two-digit-year handling. These are the root of the name-decoding fragility documented above.
  • There is no fuzzing. For the lowest untrusted-bytes parser in the stack, its tests are the entire evidence base: no fuzz target, no differential harness against a reference DER library, no hostile-input corpus. The memory-safety verdict above therefore rests on reading, and the one parser-differential already found (the non-canonical acceptance) was found by reading, not by a harness that would find the rest.

The DER layer is carefully written and safe against the allocation and panic classes that sink most parsers, but it is lenient on canonical form and essentially unfuzzed, so its safety rests on reading, not yet on a fuzzer.