docs/decisions.md answers "why is it built this way".
This document answers "what must a change never break", and
.semgrep/invariants.yml makes part of
the answer executable: make lint-invariants fails CI when a change
breaks a machine-checkable entry.
Each entry has four fields. Claim is the invariant. Mechanism is what enforces it. Check is what guards it, graded honestly on this scale, strongest first:
- type system — a violation does not compile.
- structural arithmetic — the property holds by construction; there is no code path that could break it without rewriting the construction.
- CBMC — proved over all inputs at the harness's documented bound.
- Lean theorem — proved over every input, unbounded, but of the
spec/model rather than the C. It reaches the C only through the differential's agreement, so it ranks here: stronger than a syntactic rule about what the code says, weaker than CBMC about what the code does. - semgrep-structural — a call-graph or state rule; catches any syntactic violation, honest or not.
- semgrep-tripwire — an identifier ban; catches honest drift, not a determined reintroduction under another name.
- convention + t-test — code review holds the line;
make timinggives statistical evidence after the fact.
Violation is what a breaking PR looks like, so review knows the smell. Machine-enforced entries name their rule id; the rest say which convention holds them.
- Claim. Record protection is the only path that seals or opens bytes, and nonce and tag sizes cannot be wrong.
- Mechanism.
aead_seal/aead_opentakenonce[12]and a fixed 16-byte tag by type; onlyrecord.ccalls them. - Check. Type system for the sizes; semgrep-structural (
inv-1-seal-only-in-record) for the single-caller rule. - Violation. A PR calls the AEAD directly from a new module "just for one message", bypassing sequence-number and length discipline.
- See decisions: Cryptography.
- Claim. The library never allocates. All state lives in the
caller's
ch_tlsand record buffer; no OS facilities are assumed. - Mechanism. Absence of any allocator call; the freestanding include set (no stdio.h, stdlib.h, time.h in library sources).
- Check. Semgrep-structural (
inv-2-no-allocator, andinv-2-freestandingfor the include set); lib-check catches an unexpected import. - Violation. A PR includes stdlib.h for a "temporary" buffer or qsort, and the SRAM story silently stops being true.
- See decisions: Memory and runtime.
- Claim. A key exchange landing on a small-order point (an all-zero shared secret) cannot be missed.
- Mechanism.
x25519()returns 0 on an all-zero shared secret and 1 otherwise. One call site compiles per build, both inhandshake.c: the hybrid secret underKEX=pq, the classic key exchange otherwise. Each fails the handshake unless it returns 1. Wycheproof's small-order battery exercises the rejection. - Check. Structural arithmetic (the check is the return value, not a side channel of it); convention holds the call site to checking it.
- Violation. A PR adds a second x25519 call site that drops the return code.
- See decisions: Cryptography.
- Claim. All randomness flows through
ch_rand_bytes, consumed at exactly three audited sites, all inhandshake.c: the key-share scalar, the ClientHello random, and the ML-KEM (d, z) seed, which only theKEX=pqbuild draws. Every draw carries the same all-zero check against a hook that writes nothing. - Mechanism. The hook is the only randomness import; the library defines no fallback.
- Check. Semgrep-structural (
inv-4-randomness-sites): noch_rand_bytescall outsidehandshake.c. - Violation. A PR conjures a nonce or padding bytes from a new call site nobody audits for seeding requirements.
- See docs/entropy.md.
- Claim. Exactly one certificate parser exists, and it accepts exactly the own-CA profile: X.509 v3, one or two CertificateEntry items with empty per-entry extensions — the leaf alone, or the leaf plus the one intermediate that signed it — anchored at the CA with the build's one algorithm, canonical DER on every decoded field, keyUsage and extendedKeyUsage required, no name matching, no clock. Any widening of that grammar is the violation, whether it lands inside the parser or beside it. Raw-pin builds compile none of it: they hash the certificate into the transcript and never read it.
- Mechanism. The length-first canonical-DER decoder in
x509_der.c(definite lengths, minimal encodings, exact-fill of every container; rejection precedes interpretation) plus the pinned profile constants inx509.c, byte-compared, never matched loosely. The only DER reader outside the cert files (der_parseinp256.c) reads exactly one ECDSA-Sig-Value and parses nothing else. - Check. Semgrep-tripwire (
inv-5-profiled-cert-parser): calls to identifiers matchingx509_,asn1_, order_outside p256.c, x509.c, and x509_der.c. Grammar widening inside the cert files is held by the boundary-pair tests and review; the rule catches a second parser growing elsewhere. - Violation. A PR accepts a second CertificateEntry, an absent-params AlgorithmIdentifier, or an unknown critical extension "for compatibility" — or "just reads the SubjectPublicKeyInfo" from a new module and the largest historical TLS bug class walks in unprofiled.
- See decisions: Trust model.
- Claim. RSA exists only as PSS verify. No v1.5 signature or encryption padding, ever — the certificate profile included.
- Mechanism. Absence;
rsa.cimplements EMSA-PSS decode only, andx509.c's pinned signature AlgorithmIdentifier names RSASSA-PSS, so a v1.5-signed leaf fails the byte compare. - Check. Semgrep-tripwire (
inv-6-no-pkcs1): the identifierpkcs1, with no cert-file exemption. - Violation. A PR adds v1.5 verify "for compatibility" with an old server, importing Bleichenbacher-shaped risk.
- See decisions: Cryptography.
- Claim.
x509_verify_leafis the parser's one public entry, called only fromhandshake_auth.c, and the library never reads a clock: certificate validity is CA reissuance policy, not a device-side time check. - Mechanism. Single-entry design — the DER primitives sit
behind the profile walker, and the walker sits behind one
function.
x509_read_timechecks the Time shape and ignores the digits.x509_read_time_epochdoes read them, but only as a counter to compare against stored state (INV-21); nothing compares a certificate to now, so no code path wants a clock. - Check. Semgrep-structural (
inv-20-cert-entry-point): nox509_verify_leafcall outside handshake_auth.c, with x509.c excluded as the definition site. Semgrep-tripwire (inv-20-no-time-calls): calls totime,clock_gettime,gettimeofday,localtime, orgmtimein library sources, complementinginv-2-freestanding's time.h include ban at the call level; test/timing_test.c callsclock_gettimelegitimately and sits in the standard excludes. - Violation. A PR compares an epoch date against a device clock the firmware cannot trust — the epoch is an ordering, not a time — or calls the verifier from a new site that skips the handshake's alert and wipe discipline.
- See decisions: Trust model.
- Claim. Under the epoch callbacks,
ch_tls.epochnever decreases within or across sessions, and it changes only after a handshake authenticates the peer. - Mechanism. Two functions, deliberately split.
epoch_checkruns on the chain verdict and only rejects — below the stored epoch isALERT_CERTIFICATE_REVOKED, not an allowed date or pastCH_EPOCH_BOUNDisALERT_BAD_CERTIFICATE— because a CA-signed certificate is public and proves nothing about who presented it. Both live inhandshake_auth.c.hsa_epoch_commitperforms the one assignment and the one store, andruncalls it afterexpect_finished, so CertificateVerify and Finished have already proved a real server is there.ch_connectrefuses an epoch that storage cannot supply or that is not an allowed epoch. - Check.
CH_ASSERT—hsa_epoch_commitfaults unlessexpect_finishedalready setserver_finished_ok, so a commit moved earlier aborts before it can write, on every CA handshake, whether or not the epoch callbacks are configured. It is a runtime abort, not a compile error: the misplaced call still builds.test_epoch_cfgcovers the config gates and asserts nothing persists at connect time; the e2eca-epoch-*legs move a real device forward, then replay the pre-bump leaf and the pre-bump ticket against it and require both to fail — they cover the replay direction, not the commit point; the x509der harness proves the reader's value stays in range, which is what keeps the stored epoch plus the bound inside uint32. Not covered: an author who moves the commit and moves or duplicates theserver_finished_ok = 1alongside it defeats the assert, and nothing checks the monotonicity comparison itself — invertingh->leaf.epoch <= t->epochleaves the assert silent, and only the e2eca-epoch-equalleg objects. - Violation. A PR moves the update back into
hsa_server_authfor symmetry with the rejects, letting a replayed certificate advance a device's persistent state (make test-invariantsruns this one, asinv21-epoch-commit-in-server-auth); or a build setsCH_EPOCH_BOUNDoutside the range the_Static_assertin cfg.h admits. - See decisions: Trust model.
- Claim. One cipher suite, one group, one version, one signature algorithm per build. The client offers exactly one of everything; the server takes it or the handshake fails closed.
- Mechanism. Absence of selection code; the PIN build flag picks the sigalg at compile time, never at runtime.
- Check. Convention; handshake_strict asserts the reject on any ServerHello that picks anything else.
- Violation. A PR accepts a second cipher suite value in ServerHello and downgrade surface exists again.
- See decisions: Protocol surface.
- Claim. No TLS 1.2, no renegotiation, no compression, no 0-RTT.
- Mechanism. Absence; the supported_versions extension pins 1.3 and the parser rejects everything else.
- Check. Convention plus handshake_strict negative cases.
- Violation. A PR handles a 1.2 alert "gracefully" instead of failing closed.
- See decisions: Protocol surface.
- Claim. chapulin never emits record padding; the padding strip on receive is the only padding code.
- Mechanism. Absence of a padding writer;
rec_sealappends content type and tag only. - Check. Convention; the RFC 8448 byte-identical replays would move on any added byte.
- Violation. A PR pads for traffic-analysis resistance and the SRAM and replay-vector numbers quietly change.
- See decisions: Protocol surface.
- Claim. Nonce = static IV xor sequence number; the counter is monotonic, wrap-guarded, reset only by rekey; KeyUpdate epochs are capped at 2^48−1.
- Mechanism. Structural arithmetic in
record.c;rec_dir_updateis the only reset; the epoch cap lives inhandle_key_update. - Check. CBMC (record and tlspost harnesses cover the guards);
Lean theorem (
Spec.Record.nonce_inj): distinct sequence numbers below 2^64 give distinct nonces, so a repeat needs a repeated counter, not a colliding construction — the counter half stays with the mechanisms below; semgrep-structural (inv-10-seq-reset-only-in-record): no.seq = 0assignment outside record.c. - Violation. A PR resets a sequence counter from the handshake layer to "fix" a desync, and a nonce repeats under one key.
- See decisions: Cryptography.
- Claim. A message joins the transcript only after acceptance, and secrets snapshot at exactly the RFC-defined points.
- Mechanism. Structural: the hash update sits after the parse
returns success; the key-schedule calls sit at fixed places in
run(). - Check. Structural arithmetic; the RFC 8448 replays pin the resulting secrets byte-for-byte.
- Violation. A PR hashes a message before validating it, and a rejected message influences derived keys.
- See decisions: Assurance.
- Claim. Send and receive directions share no buffers or key state.
- Mechanism. Structural:
rec_diris per-direction;t->txand the receive buffer are distinct objects. - Check. Structural arithmetic; CBMC's pointer checks would flag an aliased write in the harnessed paths.
- Violation. A PR reuses the receive buffer to build an outgoing message mid-handshake and a compaction step corrupts it.
- See decisions: Memory and runtime.
- Claim. Every error kills the session: alert, wipe, dead. There is no error a caller can retry past.
- Mechanism. Fail-closed policy;
tlsi_failis the single funnel. - Check. Convention; handshake_sequence's 466k-sequence run asserts no sequence revives a failed session.
- Violation. A PR returns a "soft" error that leaves keys live so the caller can retry a read.
- See decisions: Engineering.
- Claim. The client refuses: Certificate in PSK mode, CertificateRequest, psk_ke without DHE, a cookieless HRR, a second HRR, dual auth configs, and an even RSA pin.
- Mechanism. Fail-closed policy, each refusal an explicit branch with its alert.
- Check. handshake_strict table cases per refusal; CBMC proves the branches memory-safe.
- Violation. A PR relaxes one refusal for interop with a broken server.
- See decisions: Protocol surface.
- Claim. Programmer-error invariants stay armed in production; there is no NDEBUG build that strips them.
- Mechanism.
CH_ASSERTnever compiles away (ch_assert.h); a device mapsch_assert_failto its fault handler. - Check. Convention. The HKDF Wycheproof cases outside the asserted domain are excluded from the run precisely because the asserts would fault on them; the exclusion documents the boundary, it does not exercise the assert.
- Violation. A PR wraps CH_ASSERT in
#ifdef DEBUGto save bytes. - See decisions: Engineering.
- Claim. No branch and no memory index depends on a secret.
Comparisons on secret data go through
ct_memeq, selects through branchless masks. Variable time is allowed only where every input is public, stated at the call site — P-256 and RSA verify, and the HRR-magic compare in handshake_parse.c. - Mechanism. Constant-time construction; ChaCha20/Poly1305/x25519 have no table lookups by design.
- Check. Semgrep-structural (
inv-16-no-variable-time-compare) bans memcmp/strcmp in library sources, with handshake_parse.c allowlisted for its public-data compare — the allowlist is file-wide, so review holds the line on any new compare added to that file;make timing(Welch's t-test) gives statistical evidence. Semgrep cannot know a buffer is secret — the real guards remain construction and the t-test. - Violation. A PR compares a binder or tag with memcmp because the linker size looked better.
- See decisions: Cryptography.
- Claim. The ML-KEM sources contain no
/and no%operator. Every modular reduction is a Barrett or Montgomery multiply-shift against a named constant, and every loop bound is a written-out literal. - Mechanism. KyberSlash (2024) was a family of timing leaks from compilers emitting a division instruction on secret-derived values in Kyber's compression step; libraries that reduced by multiply-shift were unaffected. A rule on the operator is coarser than a rule on secrecy, and that is the point: semgrep cannot know which values are secret, so the module gives up the operators entirely and the question never comes up.
- Check. Semgrep-structural (
inv-23-no-division-in-mlkem): any/,%,/=, or%=in anmlkem*source fails the lint. - Violation. A PR compresses a coefficient with
x % MLKEM_Qbecause it reads better than the Barrett sequence, and a target's divider leaks the coefficient through its cycle count.
- Claim. The client accepts exactly the server message orders RFC 9846 §4 allows and no others: one ServerHello, one EncryptedExtensions, no certificate flight under PSK, Certificate then CertificateVerify then Finished when the server authenticates with a certificate, at most one HelloRetryRequest and only as the opening message, no ticket, key update, or application data before the Finished, and nothing after a close_notify. The order is the same whether the certificate is checked against a pinned server key (TRUST=raw) or a pinned CA (TRUST=ca).
- Mechanism.
handshake.creads the flight as a straight line —hello_exchange, thenhsa_server_auth, thenexpect_finished— and each step compares the message type against the one it expects, answeringALERT_UNEXPECTED_MESSAGEotherwise. There is no state variable to desynchronize; the order is the call order. Every one of those type checks sits outside theCH_TRUST_CAconditionals, which only add chain verification between Certificate and CertificateVerify, so both trust builds compile the same order from the same lines. - Check. Lean theorem (17 in
Spec/Handshake.lean, over every trace the model admits;accepts_decomposebounds the flight at 4 messages in the spec'spskMode and 6 in itspinnedMode);handshake_sequence_test, exhaustive over 466,286 sequences — all eleven letters to depth 5, and the six handshake letters to depth 6 so the longest flight the model admits is reached — in both auth modes, comparing the real client's verdict against that model. It links TRUST=raw only, so the CA build's order rests on the shared lines named above plus the e2e run, not on the oracle; CBMC (handshakeharness) for memory safety only, not for order. - Violation. A PR relaxes one type check to tolerate a message a peer "usually" sends early, and a flight with a skipped CertificateVerify authenticates. This is the SMACK and FREAK class: invisible to memory-safety proofs and to a golden-path e2e run.
- See decisions: Cryptography.
- Claim. Handshake secrets are wiped at CONNECTED; every failure
path wipes through
tlsi_wipe; the DRBG erases its key forward after each output. - Mechanism. Fail-closed policy plus fast-key-erasure construction in drbg.c.
- Check. Convention; the wipe sits in the single
tlsi_failfunnel, so review of that one function covers every error path. - Violation. A PR adds an early return between fail and wipe.
- See decisions: Memory and runtime.
- Claim. All state lives in the caller's
ch_tls. The library object carries no top-level mutable variable, so sessions cannot interfere and the whole stack is reentrant per session. - Mechanism. Structural; the reference DRBG (
drbg.c) is the sole documented exception and ships outside the packaged library object. - Check. Semgrep (
inv-18-no-global-mutable-state): top-level non-conststaticin library sources, drbg.c allowlisted. It is exactly the check that would have flagged the DRBG's globals automatically. Graded between structural and tripwire: it is a column-anchored regex (clang-format puts file-scope declarations at column 0), because the C grammar parses const-qualified custom typedefs inconsistently. Function-scope statics are not caught; review holds that line. - Violation. A PR caches "just one" precomputed table in a static and two sessions share fate.
- See decisions: Memory and runtime.
- Claim. No VLAs, no recursion, and no function frame over the
build's budget: 2,560 bytes for every build except
KEX=pq(measured worst there:rsa_vp1at 2,400), and 6,144 forKEX=pq(measured worst:mlk_pke_encryptat 5,744). ML-KEM's own working memory sets that ceiling — K-PKE encrypt holds three polynomial vectors and two polynomials — but chapulin's hybrid plumbing clears 2,560 as well:ch_handshakeat 3,456 andsend_client_helloat 2,672, the latter holding the re-expanded decapsulation key. A device that cannot spare the budget builds the classic key exchange. - Mechanism. Compiler-enforced:
-Wvlain global CFLAGS bans variable frames everywhere, andmake lint-stackcompiles the sources this build packages, under the defines it packages them with, at-Wframe-larger-than=$(STACK_BUDGET), so the README's stack numbers are a compile-time contract, not a bench observation. Until the hybrid build landed the recipe iterated$(SRCS)without$(LIB_DEF), so it measured the default build whatever PIN, TRUST or KEX asked for and no variant was ever checked; the pq frames are what exposed it. Host test mains are exempt from the frame budget; they keep vector tables in their frames. - Check. Type-system grade (the compiler refuses); bench/sram.sh measures the whole-call-chain peaks the README reports.
- Violation. A PR sizes a scratch buffer from a length field, or adds a frame that silently outgrows the smallest supported SRAM.
- See decisions: Memory and runtime.