NURL ships its own cryptography and TLS stack written entirely in NURL, on top of libc only — no OpenSSL, no libcrypto, no libssl. A default ./build.sh binary links libc (plus libm); the toolchain self-test confirms NEEDED = libc.so.6 for a program that uses the TLS client and server. This document describes what is implemented, how it is built, the side-channel posture, and the trust model — including what it deliberately does not promise.
One-line summary. The primitives are correct (KAT-verified, interops
with OpenSSL/curl/browsers) and the protocol enforces the standard TLS 1.3
authentication and downgrade controls. On the timing/cache side-channel
axis the EC and symmetric primitives are constant-time including operand
timing (by construction + code review, not yet statistically measured); RSA's
bigint operand timing is the one residual, covered by base blinding.
**Power/EM (DPA/template) side-channels are out of scope for the whole
software stack** — that needs hardware. Where a guarantee is narrower than
OpenSSL's (revocation, name constraints, single-trace RSA timing, all
power/EM), this document says so.
All sources live in stdlib/std/.
| Area | Module(s) | Notes |
|---|---|---|
| Hashes | hash_sha256, hash_sha512, hash_sha1, hash_md5, hash_blake3 | SHA-1/MD5 for legacy interop only (never trusted for signatures) |
| HMAC / KDF | hkdf, pbkdf2, scrypt | HKDF-Expand-Label for TLS 1.3 |
| AEAD | aes_gcm (AES-128/256-GCM), chacha20poly1305 | the two TLS 1.3 record ciphers |
| ECDH / signatures | x25519, ed25519, ecdsa_p256 (P-256 + P-384), p256_field | TweetNaCl-derived 25519 ladder over a ten-limb radix-2^25.5 field; p256_field is the dedicated constant-time fixed-limb GF(p) for the P-256 secret path |
| RSA | rsa (PKCS#1 v1.5 verify, PSS verify + sign) | built on bigint |
| Bignum | bigint | sign-magnitude, schoolbook mul / long division, modpow, modinv |
| X.509 | x509, tls_verify | DER parser + chain/host/policy verification |
| TLS | tls (client, 1.3 + 1.2 fallback), tls_server (1.3) | record layer, key schedule, handshake |
| Randomness | random (CSPRNG), rng (xoshiro256**, not crypto) | |
| Constant-time | subtle | length-independent secret comparison |
std/tls.nu / std/tls_server.nu and std/net.nu's tcp_listen_tls / tls_connect are thin layers over these. tls_connect does **verify-full by default** (chain + hostname + validity); the no-verification path is a separate, explicitly-named tls_connect_insecure.
All key, nonce, salt and blinding-factor entropy comes from one runtime bridge, nurl_rand_fill, which selects the OS CSPRNG per platform — getrandom(2) on Linux, arc4random_buf on macOS, BCryptGenRandom on Windows, /dev/urandom otherwise. Every draw — the crypto modules (std/tls.nu, std/rsa.nu, std/x509_gen.nu) and std/random.nu's convenience draws (rand_u64, rand_hex_str) alike — checks its return value and fails closed (panics) rather than proceed with predictable bytes. std/rng.nu (xoshiro256**) is a separate, clearly-marked non-cryptographic PRNG for simulations and is never used by this stack.
Every primitive has a known-answer test (KAT) in compiler/tests/ (aes_gcm_vectors, chacha20poly1305_vectors, hkdf_vectors, ecdsa_p256_*, ed25519_vectors, x25519_vectors, rsa_*, x509_*) and run on every build. Interoperability was additionally validated manually at development time (not re-run in CI): verify-full tls_connect GETs against public sites and the badssl.com negative suite; tls_server handshakes (RSA-2048 and EC P-256 leaf certs) with curl, browsers and a Python ssl client; RSASSA-PSS cross-verification with OpenSSL.
ECDSA signing derives its nonce with RFC 6979 (HMAC-SHA-256) — fully deterministic, so the nonce needs no RNG and can never be reused, and the signature is reproducible (hence KAT-testable). This is independent of the side-channel blinding in §3, which separately draws a fresh random value per signature: the nonce is deterministic, the blinding factor is random.
This is where a pure-software stack differs most from a hardware-accelerated one.
Threat model — in scope: a remote or co-resident attacker observing timing and cache access patterns across one or many operations (the realistic threat for a network-facing TLS server). The hardening has two layers: every secret-driven control flow / memory-access pattern is uniform (no branch or table index on secret data — this defeats the cache / SPA sequence attack), and on top of that the asymmetric private-key operations are blinded so any residual operand-value-dependent timing carries no signal.
Threat model — OUT of scope for the entire software stack: physical power / electromagnetic (DPA, SPA-power, template) side-channels. Software constant-time code is not a defense against these: the power a gate draws still depends on the bits flowing through it, so a table-free or bitsliced AES S-box and the P-256 field arithmetic both remain readable to an attacker with a probe, branchless or not. Defending power/EM requires masking / hiding and ultimately hardware countermeasures, which are not in this (or any pure-software) library. Everything below concerns the timing/cache axis only.
Caveat on the word "constant-time": the constant-time properties claimed here are by construction and code review — no secret-dependent branch, no secret-indexed table, and (where stated) a fixed-width representation so operand timing is value-independent. They have not yet been measured statistically (e.g. dudect / ctgrind / ctverif). The cross-checks cited below prove correctness (the code computes the right answer), which is a separate axis from constant-timeness; an empirical leakage measurement is a tracked follow-up. Hardening (timing/cache axis):
| Primitive | Countermeasure |
|---|---|
| AES S-box | Constant-time, bitsliced: the state is transposed so each 64-bit word holds one bit position of 64 bytes, and SubBytes is Boyar–Peralta's 113-gate boolean circuit evaluated on those words (the aes_ct64 construction). Every operation is a word-wide AND/XOR — no table lookup, no branch, and nothing indexed by secret data. ShiftRows and MixColumns are shifts and XORs on the same words. The key schedule (once per key, not per block) instead uses the older per-byte form: SubBytes(x) = Affine(x⁻¹ in GF(2⁸)) via a branchless GF multiply and a fixed-exponent (x²⁵⁴) inversion, verified equal to the reference S-box on all 256 inputs. |
| GHASH | Constant-time on any CPU with a constant-time multiplier. The GF(2¹²⁸) multiply is carry-less multiplication built from ordinary 64-bit integer multiplies (ghash_ctmul64): each operand is split into four interleaved bit groups so no carry crosses a kept bit, and the halves recombine by Karatsuba. No branch and no table, so nothing leaks the authentication key H through cache or control flow — but the timing rests on imul being data-independent, which holds on every mainstream 64-bit core and does not hold on some small in-order ARM cores (Cortex-M3/M0, early Cortex-A), where an early-terminating multiplier would reintroduce an operand-timing signal. |
| GCM / Poly1305 / TLS Finished tag compares | Constant-time (OR-accumulated XOR, no early exit). |
RSA modexp (bigint_modpow) | Constant control flow: a Montgomery powering ladder — exactly two modular multiplies per iteration for a fixed iteration count = bit-length of the modulus n (a public value), register choice by a constant-time conditional swap. The count depends only on the public modulus, never on the secret exponent d (no naive "multiply only on 1-bits" leak, and the loop length does not reveal d's bit length). No CRT is used (single direct modexp with the full d), so there is no CRT-reduction timing surface (Brumley–Boneh class). |
| RSA private key (PSS sign) | Base blinding on top: s = ((EM·rᵉ)ᵈ · r⁻¹) mod n for a fresh random r per signature. rᵉᵈ ≡ r (mod n), so the result is identical, but the value fed to the (still operand-time-dependent) bigint mul/rem is randomized — covering the residual timing the ladder's uniform control flow does not. The setup inverse r⁻¹ mod n is computed with the variable-time bigint_modinv, but its timing is not security-relevant: r is a fresh per-signature ephemeral that is discarded, so its magnitude leaks nothing about the key. |
| P-256 secret scalar mult (ECDSA nonce / ECDHE) | Constant-time on the timing/cache axis (std/p256_field): a dedicated fixed-8-limb (radix 2^32) GF(p) field — Montgomery (CIOS) multiply over u64 intermediates, conditional-±p add/sub, fixed-exponent Fermat inverse — never normalized, so even the operand timing is value-independent. Points use the Renes–Costello–Batina complete addition formula (a = −3), correct for all inputs incl. identity, in a fixed 4-bit window ladder: four doublings and one addition per nibble, most significant first. The ladder runs a fixed number of steps = 2·len(scalar bytes) windows (64 for a 32-byte nonce) regardless of the scalar's value, so the top bits do not change the trace. The window digit is secret, so the table is never indexed by it — all sixteen entries are read on every window and merged under an arithmetic equality mask, and digit 0 reads the identity, which the complete formula absorbs. No branch, no secret-dependent address, no operand-time dependence — no blinding needed. Verified for correctness (a separate axis from constant-timeness — see the caveat above): the in-tree test compiler/tests/p256_ct_field.nu pins the field against the bigint reference; a one-time development cross-check also matched the scalar multiply against Python cryptography. |
P-256/P-384 verify (_jmul) | Branchless ladder over the bigint field, but the scalars are public (verification), so the bigint operand timing is harmless here. |
| X25519 | Montgomery ladder with a branchless constant-time conditional swap (TweetNaCl); fixed iteration count. The field underneath is ten signed limbs at radix 2^25.5, carried by a fixed shift sequence — no branch on data anywhere in it. |
| Ed25519 | Deterministic nonce (RFC 8032), so no per-signature secret randomness to leak. The scalar multiply is a fixed 4-bit window over the complete twisted-Edwards addition: four doublings and one addition per nibble, a fixed 2·len(scalar bytes) windows regardless of the scalar. The window digit is secret, so the table is never indexed by it — all sixteen entries are read every window and merged under an arithmetic equality mask (a full 64-bit one, since the field limbs are signed), and digit 0 reads the identity, which the complete formula absorbs. |
| Secret comparison | std/subtle.nu — duration depends only on input length, never contents. |
GHASH, all tag compares* — constant-time including operand timing*: no secret-dependent branch, no secret-indexed table, and a fixed-width field representation (P-256 via std/p256_field; 25519 via ten fixed signed limbs at radix 2^25.5) so even mul/reduce duration is value-independent. This resists a single-trace timing/cache observer (not just the multi-trace remote attacker) — but, like all software constant-time code, it does not resist a power/EM (DPA/template) attacker, which is out of scope stack-wide.
powering ladder) but the underlying std/bigint mul/rem still **normalize (trim leading-zero limbs)*, so their duration tracks operand magnitude*. That operand-timing residual is covered by base blinding (every signature runs on a fresh randomized operand, so no signal aggregates across traces) — exactly OpenSSL's posture for the same arithmetic. So RSA is robust against the multi-trace remote attacker but, unlike the EC path, is not hardened against a single-trace timing/cache observer of one signature; a dedicated fixed-limb RSA modular multiply (a tracked follow-up) would close that. (Power/EM remains out of scope for RSA too — and for everything.) RSA is the legacy path; the EC path above is the primary one for modern internet-facing TLS and carries no such timing residual.
the default record cipher. AES-GCM is bitsliced, so it is constant-time without needing AES instructions and no longer pays for that with orders of magnitude: measured on 16 KB records, ~113 MB/s against ChaCha's ~370. It remains the second choice, and exists for peers that only offer AES-GCM.
tls.nu (client) and tls_server.nu implement TLS 1.3 (RFC 8446) with a TLS 1.2-ECDHE-AEAD fallback on the client. The protocol-level controls:
tls_connect checks the CertificateVerify
signature against the leaf key, the full certificate chain, the hostname, and the validity window, and closes the connection (TlsBadCert) on any failure. The non-verifying path is a separately-named opt-in.
handshake and application traffic secrets correctly separated and transcript hashes taken at the right message boundaries. The Finished MAC is verified in constant time before application keys are derived.
P-256 ECDHE shared secret (RFC 8446 §7.4.2), and the P-256 path validates the peer's key share is a valid on-curve point before use (invalid-curve guard).
RFC 8446 §4.1.3 server-random downgrade sentinel and aborts. Only AEAD suites are offered/accepted on the 1.2 path (no CBC / RC4 / export), so there is no Lucky13-style padding-oracle surface.
number; the sequence resets on every key epoch, so a nonce is never reused within a key.
x509.nu is a focused DER parser; tls_verify.nu is the chain-validation policy. Trust anchors come from the system bundle (/etc/ssl/certs/ca-certificates.crt, then /etc/pki/tls/certs/ca-bundle.crt, then /etc/ssl/cert.pem). Enforced:
key, up to a trusted root.
cA:TRUE; pathLenConstraint is honoured. This closes the classic "any leaf can sign for any host" break.
keyCertSign (when it carries a
keyUsage); a leaf carrying an EKU must assert serverAuth (or anyEKU).
subjectAltName dNSName only (never CN), with
single-leftmost-label wildcards only (*.example.com, not *.com or *foo.com), and embedded-NUL dNSNames rejected. IP-literal hosts match iPAddress SANs only.
notBefore/notAfter checked against the system clock on theleaf, every issuer, and the anchor.
the signature algorithm must match the issuer key type (no RSA-key/ECDSA-sig confusion); RSA keys below 2048 bits are rejected; PKCS#1 v1.5 verification is strict (no e=3 / BERserk trailing-garbage forgery); the presented chain is length-capped.
Not enforced (narrower than OpenSSL — name these explicitly):
fetch, so a certificate that is **revoked but otherwise valid and unexpired is accepted**. If you need revocation, terminate TLS behind a proxy that checks it, or keep the trust store tight and rotate.
technically-constrained sub-CA could issue outside its permitted name space undetected. Most software stacks also skip this; noted for completeness.
Like the borrow checker (see docs/MEMORY.md), this stack aims to be sound, not a hardware-grade side-channel-free implementation:
default; the only way to skip them is the explicitly-named insecure path. Revocation (OCSP/CRL) and X.509 name constraints are not checked — see §5 "Not enforced".
ECDSA/ECDHE, X25519, Ed25519) and all symmetric primitives are constant-time including operand timing — no secret-dependent branch, table index, or value-dependent duration — so they resist a single-trace timing/cache observer. RSA's private exponentiation has uniform control flow plus base blinding, which defeats the multi-trace remote/co-resident attacker; its bigint operand timing is the one residual on this axis (single-trace timing/cache on one RSA signature is not covered until a fixed-limb RSA multiply lands). These properties are by-construction and code-reviewed, not yet measured statistically (dudect/ctgrind — a tracked follow-up).
the entire software stack.** Software constant-time code does not defend them — a table-free or bitsliced AES S-box still leaks through power draw, and so does the P-256 field. Defending power/EM needs masking/hiding and ultimately hardware; use a hardware-backed / audited library (HSM, secure element, AES-NI + a vetted bignum) where that threat model applies.
An external audit, a fixed-limb RSA field (removing the last operand-time dependence), and statistical timing measurement (dudect-style) remain open work — see TODO.md.
Nothing special: a default ./build.sh produces binaries that link libc only. libssl/libcrypto are absent from the source tree entirely. See docs/BUILDING.md for the bootstrap and docs/NETWORKING.md for the socket layer the TLS stack sits on.