Timing-verification reference machine (NixOS sweep-ISO)
This is the reproducible, unattended form of the bare-metal timing runbook. The runbook is a manual checklist — echo/cpupower/taskset to quiet a machine, then run dudect by hand with whatever GCC happens to be installed. That is fine once, but the dudect statistical pass is a required pre-tag release gate — it is the check that caught the compiler-reconstructed leak recorded in advisory 0001 (issue #25), which source review and CI’s deterministic CT checks both missed (the reconstruction is compiler-specific, so CI’s toolchain stayed green while the reference toolchain leaked) — and a gate that depends on hand-configuration and a single ambient compiler is not one you can trust release after release.
The reference machine fixes that by codifying the whole measurement environment as declarative Nix: one bootable NixOS ISO that sweeps every target compiler in a single unattended boot, run from a Ventoy USB stick on quiet bare metal. Drop the stick in, boot, walk away; come back to a provenance-stamped, per-compiler report.
Scope. The plan of record with full design detail is
plans/61-reference-machine-nix.md(tracked as HLR #61). This page is the operator- and reviewer-facing summary.
Why one ISO sweeps every compiler
The #25 finding is that the leak was a (source, compiler, flags) artefact: GCC 14/15 at -O2 reconstructed the branchless select into a secret-dependent branch, while GCC ≤13 did not. So the gate’s value depends on pinning the compilers and testing across them, not just quieting the machine.
The insight that makes this one image rather than N: the quiet-machine state — core isolation, pinned frequency, boost and SMT off — is a boot-level property, whereas the compiler is a userspace property. A single boot can therefore sweep every gccN, exactly as the #25 bisection did across GCC 9.5–15.1 from one pinned nixpkgs. Rebooting per compiler buys nothing for measurement quality.
The ISO bakes the pinned nixpkgs (⇒ every gccN), the quiet-machine configuration, the harness, and a specific source revision — so it is a self-contained, reproducible certificate for one source revision across all compilers. Bumping the flake.lock nixpkgs revision is the deliberate trigger to re-run the gate.
The one non-negotiable: vanilla gcc -O2
The gem ships as source and is compiled on each user’s machine by their gcc at -O2 (extconf.rb appends it). The reference machine must certify that binary. But NixOS builds everything through nixpkgs’ cc-wrapper, which injects a hardening set. This is not hypothetical: measured on GCC 14.3.0, the nixos-25.05 default hardening (… stackclashprotection stackprotector zerocallusedregs …) changes the CT-function codegen —
| CT function | hardened | vanilla (NIX_HARDENING_ENABLE="") |
|---|---|---|
scalar_multiply_ct_internal |
80 insns | 73 insns |
jp_add_internal |
347 insns | 323 insns |
— extra register-zeroing and stack probes. Both happen to stay branchless here, but a hardened build is not the binary a stock gem install produces, and in principle hardening could mask a branch a stock build would emit (a false pass). So the gate builds with hardening off (NIX_HARDENING_ENABLE="", explicit make CC=), and proves the result two ways:
- Assembly-invariant —
security/check-ct-assembly.rb: no secret-dependentje/jne/cmovin the ladder orjp_add_internal. - Stock equivalence — the invariant is run under both the pinned nix
gccNand a stock distrogccNof the same major (nix/stock-attest.sh, in agcc:<major>Debian container); both must pass. This is checked as an invariant-result rather than a byte-for-byte golden, because GCC minor bumps legitimately reshuffle instructions without changing the security property. For defence in depth against a stock gcc outlining a leak into a new symbol the two-symbol invariant wouldn’t inspect, the stock build is also run through whole-binary ctgrind. This is a dev-time attestation (per toolchain bump); the per-boot ISO sweep certifies the nix side. Validated for nix 14.3.0 and stock gcc:15 (invariant + ctgrind).
The unattended flow
- Boot the box from the Ventoy stick → the quiet-machine state comes up (isolated core, pinned frequency, boost/SMT off, no network).
- A systemd oneshot (
timing-gate.service) runs the gate automatically — no login. - For each compiler in the set:
rake clobber→ build the extension at vanilla-O2withgccN→ verify the compiler took + the assembly-invariant →rspec→ctgrind→dudect(N runs, pinned to the isolated core withtaskset/chrt) → aggregate per operation. - A single provenance-stamped report (CPU + microcode, kernel, per-compiler
gcc --version,nixpkgsrev, source rev, achieved frequency) is written incrementally to the Ventoy partition, so an unplanned power loss loses only the in-flight compiler. systemctl poweroff.
Pass criteria (fail-closed). Every compiler that builds must pass; a leak on any building gcc reds the gate. The secret-scalar operations (scalar_multiply_ct, scalar_*) must have |
t | < 4.5 — a compiler-reconstructed branch is compiled in and shows in essentially every run (the #25 ladder leak was 20/20), so a strict op reds the gate when more than a small fraction of runs are at/over 4.5 (GATE_STRICT_OVER_PCT, default 5% — i.e. more than one run in twenty): a stable leak fails, a lone measurement transient is tolerated, and it cannot mask a leak strong enough to fire on more than 5% of runs (~100% of runs for a compiled-in branch, far above the tolerance). The sub-tolerance marginal band splits by channel: a weak partial branch is caught deterministically by ctgrind (which poisons the secret input of all four strict ops individually — ladder, scalar_mul, scalar_reduce, scalar_inv), so no strict label leans on the statistical gate alone for the branch channel; a secret-correlated operand-latency effect is invisible to ctgrind, leaving the fraction gate its only backstop — a latency leak weak enough to stay under 5% is not deterministically excluded, though it sits at the noise floor (a genuine effect fires consistently, not on 1/20; see security.md); the operand-value-artefact operations (field ops, jp_add) tolerate marginal single-run excursions and are flagged only if the aggregate mean |
t | exceeds its per-tier bound — the elevated ops (jp_add, fsub) get a wider mean bound than the near-flat field ops (fadd, fred, fneg), so a flat op can’t regress up to the widest bound unnoticed — calibrated per pinned toolchain from the authoritative sweep (the per-run max is noisy for these fast ops, so it is only a loose backstop; the operand-value artefact is toolchain-dependent — jp_add_internal ≈ 7.5 on GCC 15.2, ~24 on GCC 15.1 — see security.md and issue #74). A compiler that fails to build is SKIPPED, not fatal — but an all-SKIPPED sweep is reported as inconclusive, never a clean pass. |
Build & test logistics
The ISO is x86_64-linux, so it cannot be built natively on an aarch64 Mac. In order of preference:
nix build .#isoon any x86_64 Linux host →result/iso/*.iso.- Docker (nix-in-Linux) from macOS — ISO assembly is squashfs + xorriso, no KVM needed. This is how the pipeline is validated:
docker run -d --name nix --platform linux/amd64 \
-v "$PWD":/work -w /work nixos/nix:latest sleep infinity
# nested Docker needs the nix build sandbox relaxed:
docker exec nix bash -c 'printf "experimental-features = nix-command flakes\nsandbox = false\nfilter-syscalls = false\n" >> /etc/nix/nix.conf'
docker exec nix bash -lc 'cd /work && nix build .#iso'
nix build .#linux-builder(nix-darwin VM backend) is the cleanest native-mac option once configured.
Iterate on the automation without an ISO using nix run .#timing-gate (the same gate, offline gems, run against your checkout) or, in nix develop, bash nix/gate.sh. The measurement is only ever meaningful on quiet bare metal — a VM or Docker run validates the automation (boot → sweep → report → halt), never the timing.
Deploy by copying the ISO to the Ventoy stick (/Volumes/Ventoy); results land back on the same stick, replug into any machine to read them.
The configuration (drift-proof)
These are the actual files baked into the ISO, embedded here at docs-build time (rake docs) so they cannot drift from what ships.
flake.nix
{
description =
"secp256k1-native — reproducible timing-verification toolchain & reference machine";
# Pinned via flake.lock. The locked nixpkgs revision IS the gate's
# "known-good compiler" record: bumping this input is the deliberate trigger
# to re-run the bare-metal dudect gate (see plans/61-reference-machine-nix.md and
# docs/security.md#empirical-timing-verification).
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.05";
outputs = { self, nixpkgs }:
let
system = "x86_64-linux";
pkgs = nixpkgs.legacyPackages.${system};
# Offline gem set for the on-ISO gate — rake (clobber), rake-compiler (the
# Rakefile requires it), rspec. Baked via bundlerEnv from nix/reference-gems
# (Gemfile + Gemfile.lock + gemset.nix; regen: bundle lock && bundix). Kept
# minimal so the ISO closure and attack surface stay small (principle 3).
gateGems = pkgs.bundlerEnv {
name = "secp256k1-refmachine-gems";
ruby = pkgs.ruby_3_3;
gemdir = ./nix/reference-gems;
};
# Prototype compiler set — gcc15 (15.1.0), the family where advisory 0001
# (#25) leaked. Widen backward (gcc14, gcc13, … best-effort) once the
# prototype boots on bare metal (Phase 7): just add to this list.
gccSet = [ pkgs.gcc15 ];
# Runtime tools the gate needs, offline. gateGems provides rake/rspec;
# ruby_3_3 provides `ruby` for extconf (bundlerEnv doesn't export it). The
# coreutils/sed/grep/awk are the gate's text plumbing (a minimal ISO PATH
# otherwise lacks them).
gateTools = [ gateGems pkgs.ruby_3_3 pkgs.gnumake pkgs.binutils pkgs.valgrind pkgs.util-linux pkgs.bash pkgs.coreutils pkgs.gnused pkgs.gnugrep pkgs.gawk ] ++ gccSet;
# The ctgrind harness includes <valgrind/memcheck.h>, which nixpkgs ships in
# valgrind's `dev` output — NOT its `out` (bin/lib only), and not on the
# default include path outside a devShell. A *manually* exported
# NIX_CFLAGS_COMPILE does NOT work here: this cc-wrapper reads a salted
# variant (NIX_CFLAGS_COMPILE_<hash>), so a bare assignment is silently
# ignored (confirmed on bare metal — the header stays unfound). Pass the
# include to the ctgrind build EXPLICITLY instead, as a make variable
# (CTGRIND_VG_CFLAGS) threaded through gate.sh via GATE_CTGRIND_VG_CFLAGS.
# -isystem is codegen-neutral (a header search path only; the CT sources
# never include it), and referencing the store path here also pulls
# valgrind.dev into the ISO closure so the header is present on the box.
valgrindCFlags = "-isystem ${pkgs.lib.getDev pkgs.valgrind}/include";
in
{
# Reproducible toolchain — `nix develop`.
#
# This shell both COMPILES and MEASURES the C extension, so the gem's
# timing-sensitive codegen is reproducible. Important nuance: the
# extension is built by Ruby's configured CC (RbConfig CC), printed by the
# shellHook — pinning *that* compiler is what the gate certifies, not the
# bare `gcc` on PATH. (Refinement for the real box: pin the CC explicitly,
# e.g. to gcc15 to match where issue #25 was found.)
devShells.${system}.default = pkgs.mkShell {
packages = with pkgs; [
ruby_3_3
bundler
gcc # compiler under test
gnumake # builds the timing/ and security/ harnesses
binutils # objdump, for the disassembly branch-check
valgrind # ctgrind deterministic constant-time gate
util-linux # taskset / chrt for pinned dudect runs
pkg-config # lets native gems (psych) locate libyaml
libyaml # psych's C dependency — pulled in via yard-markdown -> rdoc
];
shellHook = ''
# Keep gem installs local and out of the git tree.
export BUNDLE_PATH="$PWD/.bundle"
# Explicit valgrind-dev include for the gate's ctgrind build (see
# valgrindCFlags) — same mechanism as the ISO, so `nix develop` matches.
export GATE_CTGRIND_VG_CFLAGS="${valgrindCFlags}"
echo "secp256k1-native timing toolchain (flake-pinned):"
echo " gcc : $(gcc --version | head -1)"
echo " ruby : $(ruby --version)"
echo " ext CC : $(ruby -rrbconfig -e 'print RbConfig::CONFIG["CC"]') <- compiles the C extension"
echo " valgrind : $(valgrind --version)"
'';
};
packages.${system} = {
# The offline gem environment (inspect with `nix build .#gate-gems`).
gate-gems = gateGems;
# The sweep-ISO — `nix build .#iso` → result/iso/*.iso.
iso = self.nixosConfigurations.sweep.config.system.build.isoImage;
};
# `nix run .#timing-gate` — the gate with the pinned toolchain, run against
# the current checkout ($PWD). Uses the offline gems (GATE_RUBY_EXEC="")
# exactly as the ISO does, so dev iteration matches the appliance.
apps.${system}.timing-gate = {
type = "app";
program = toString (pkgs.writeShellScript "timing-gate" ''
# git so the gate can stamp the working-tree source rev in its report
# (gateTools deliberately omits it — the ISO has no .git and plumbs
# GATE_SOURCE_REV from the flake instead).
export PATH=${pkgs.lib.makeBinPath (gateTools ++ [ pkgs.git ])}:$PATH
export GATE_RUBY_EXEC=""
# Explicit valgrind-dev include for the ctgrind build (see valgrindCFlags);
# a bare NIX_CFLAGS_COMPILE is ignored by the salted cc-wrapper.
export GATE_CTGRIND_VG_CFLAGS="${valgrindCFlags}"
exec ${pkgs.bash}/bin/bash nix/gate.sh "$@"
'');
};
# The unattended sweep-ISO system.
nixosConfigurations.sweep = nixpkgs.lib.nixosSystem {
inherit system;
specialArgs = {
refSource = self; # the flake source, baked into the image
inherit gateGems gccSet gateTools valgrindCFlags;
};
modules = [
./nix/reference-machine.nix
./nix/iso.nix
];
};
};
}
nix/reference-machine.nix — the quiet-machine module
# nix/reference-machine.nix
#
# Quiet-machine NixOS module for the timing-verification reference machine
# (Phase 2 of plans/61-reference-machine-nix.md). Boot-level state that makes
# the dudect pre-tag gate reproducible: one isolated CPU core with no scheduler
# / IRQ / RCU noise, pinned frequency (no DVFS/turbo jitter), no deep-idle wake
# jitter (C-states forced to POLL on that core), and SMT off.
# Imported by nix/iso.nix; also usable for a persistent box later.
#
# NB: this module does NOT disable networking — network *quiet during the
# measurement* is the sweep service's job (it stops the network daemons right
# before measuring; see nix/iso.nix), which lets the debug boot entry keep the
# network up for SSH without fighting an mkForce here.
#
# Why boot-level: the quiet state is a property of the kernel command line and
# early sysfs, NOT of userspace. That is exactly why one boot can sweep every
# target compiler (the plan's core idea) — the compiler is userspace, the quiet
# machine is the boot.
#
# mitigations= is deliberately LEFT AT THE KERNEL DEFAULT to mirror what users
# run; the *differential* |t| the gate measures is unaffected by the absolute
# cost of mitigations. Do not add `mitigations=off`.
{ config, lib, pkgs, ... }:
let
cfg = config.referenceMachine;
in
{
options.referenceMachine = {
enable = lib.mkEnableOption "quiet-machine timing-verification boot state";
isolatedCore = lib.mkOption {
type = lib.types.ints.unsigned;
default = 15;
description = ''
CPU core to isolate for pinned dudect runs. The gate pins its
measurement process here (`taskset -c <isolatedCore> chrt -f 99 …`).
Default 15 = the top core on a 16-thread box; override per machine.
'';
};
housekeepingCores = lib.mkOption {
type = lib.types.str;
default = "0-14";
example = "0-6";
description = ''
The cores that carry the OS load and hardware IRQs (everything except
the isolated core), as an `irqaffinity=` CPU list. Must be the
complement of isolatedCore for the isolation to hold.
'';
};
cpuVendor = lib.mkOption {
type = lib.types.enum [ "amd" "intel" ];
default = "amd";
description = "Selects the correct turbo/boost-disable sysfs knob.";
};
};
config = lib.mkIf cfg.enable {
# --- Boot-level isolation of the measurement core ------------------------
boot.kernelParams = [
# No scheduler load-balancing, no managed-IRQ steering, no unbound
# workqueues on the isolated core.
"isolcpus=domain,managed_irq,${toString cfg.isolatedCore}"
# Tickless + RCU callbacks offloaded → no timer/RCU softirq jitter in the
# measurement window.
"nohz_full=${toString cfg.isolatedCore}"
"rcu_nocbs=${toString cfg.isolatedCore}"
# Steer all unbound IRQs onto the housekeeping cores.
"irqaffinity=${cfg.housekeepingCores}"
# SMT off: a sibling hyperthread sharing execution units is a first-order
# source of timing variance.
"nosmt"
];
# --- Frequency pinning ---------------------------------------------------
# Governor to performance so the isolated core does not scale down.
powerManagement.cpuFreqGovernor = lib.mkForce "performance";
# Lock min=max frequency and kill turbo/boost so DVFS transitions don't
# perturb the measurement. Ordered before the gate so the sweep runs pinned;
# the report stamps the achieved frequency so residual throttling is visible.
systemd.services.reference-machine-freq-pin = {
description = "Pin CPU frequency, disable turbo/boost, and force the isolated core out of deep idle for timing stability";
wantedBy = [ "multi-user.target" ];
before = [ "timing-gate.service" ];
after = [ "sysinit.target" ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
};
path = [ pkgs.coreutils ];
script = ''
set -u
# min=max on every policy so no core scales.
for p in /sys/devices/system/cpu/cpufreq/policy*; do
[ -r "$p/cpuinfo_max_freq" ] || continue
max=$(cat "$p/cpuinfo_max_freq")
echo "$max" > "$p/scaling_min_freq" 2>/dev/null || true
echo "$max" > "$p/scaling_max_freq" 2>/dev/null || true
done
# Force the isolated core out of deep idle. A core that enters a deep
# C-state between measurements pays a frequency-ramp penalty on wake
# (empirically ~4.3 -> ~4.0 GHz for the first microseconds after C3),
# surfacing as ns-scale jitter in the fastest field ops — the dominant
# noise source on the first bare-metal run (fadd |t| 213 -> ~1 once
# disabled; the pinned min=max frequency does NOT prevent this). Disable
# every non-POLL cpuidle state on the measurement core so its idle loop
# busy-polls at the pinned frequency. Match POLL by NAME rather than
# assuming it is state0 — it is on x86, but don't rely on the index; a
# non-POLL state left enabled would reintroduce the wake/ramp jitter.
# Only the isolated core — the housekeeping cores idle normally to save power.
for s in /sys/devices/system/cpu/cpu${toString cfg.isolatedCore}/cpuidle/state*; do
[ "$(cat "$s/name" 2>/dev/null)" = POLL ] && continue
[ -w "$s/disable" ] && echo 1 > "$s/disable" 2>/dev/null || true
done
'' + lib.optionalString (cfg.cpuVendor == "amd") ''
# AMD: global boost knob (acpi-cpufreq / amd-pstate).
[ -w /sys/devices/system/cpu/cpufreq/boost ] && echo 0 > /sys/devices/system/cpu/cpufreq/boost || true
'' + lib.optionalString (cfg.cpuVendor == "intel") ''
# Intel: pstate turbo knob.
[ -w /sys/devices/system/cpu/intel_pstate/no_turbo ] && echo 1 > /sys/devices/system/cpu/intel_pstate/no_turbo || true
'';
};
# --- Network quiet is the SWEEP's job, not a hard module policy -----------
# DHCP/NTP/etc. are measurement noise, so the sweep stops the network daemons
# (incl. wpa_supplicant) right before it measures (see nix/iso.nix). This
# module deliberately does NOT touch networking, so a `debug` boot
# specialisation can leave it up for SSH without fighting an mkForce here.
# --- Console-only + autologin fallback -----------------------------------
# No display manager / X. Autologin root so an operator can intervene on the
# live console if an unattended run needs poking.
services.xserver.enable = lib.mkForce false;
services.getty.autologinUser = lib.mkDefault "root";
};
}
nix/iso.nix — the sweep-ISO
# nix/iso.nix
#
# The sweep-ISO (Phase 5 of plans/61-reference-machine-nix.md). A live NixOS ISO
# that, on boot, runs the timing gate unattended across the compiler set, writes
# a provenance-stamped report to the Ventoy USB stick, and powers off. Composed
# with installation-cd-minimal + the quiet-machine module (nix/reference-machine.nix)
# in flake.nix's nixosConfigurations.sweep.
#
# Prototype: gcc15 only (gccSet in flake.nix). Widen the set once it boots on
# bare metal (Phase 7). The measurement is meaningful ONLY on quiet bare metal —
# a VM/Docker run validates the boot→sweep→report→halt AUTOMATION, not timing.
{ config, lib, pkgs, modulesPath, refSource, gateGems, gccSet, gateTools, valgrindCFlags, ... }:
let
isolatedCore = config.referenceMachine.isolatedCore;
# Full paths to each compiler-under-test's gcc wrapper (the gate's CC list).
compilerBins = lib.concatStringsSep " " (map (g: "${g}/bin/gcc") gccSet);
# Source revision for the provenance report: the baked source is a store copy
# with no .git, and `git` isn't in gateTools, so `git rev-parse` would yield
# "unknown" on the ISO. Plumb it from the flake instead (rev when clean,
# dirtyRev when the tree is dirty, else "unknown").
srcRev = refSource.rev or refSource.dirtyRev or "unknown";
in
{
# A minimal live ISO (installation-cd-minimal) as the base image.
imports = [ "${modulesPath}/installer/cd-dvd/installation-cd-minimal.nix" ];
# Turn on the quiet-machine boot state (isolcpus, freq pin, SMT off, …).
# (It doesn't disable networking — the sweep service stops the network daemons
# before measuring; see below.)
referenceMachine.enable = true;
# referenceMachine.isolatedCore / .cpuVendor default to 15 / amd — override per
# box here when the real hardware is known.
# Smaller image + faster boot: the sweep is CPU/RAM only, no GUI.
isoImage.isoName = lib.mkForce "secp256k1-timing-sweep.iso";
# Bake the source (this flake's clean git tree) read-only into the store; the
# gate copies it to a writable tmpfs before building.
environment.etc."secp256k1-native/source".source = refSource;
# Tools available on the live console too (for manual poking if a run wedges).
environment.systemPackages = gateTools;
# The unattended gate. Ordered after the frequency pin so the sweep runs on the
# locked, isolated core; runs even with no network (source is baked).
systemd.services.timing-gate = {
description = "secp256k1-native unattended timing-verification sweep";
wantedBy = [ "multi-user.target" ];
after = [ "reference-machine-freq-pin.service" "local-fs.target" ];
wants = [ "reference-machine-freq-pin.service" ];
# The gate itself is fail-closed; the SERVICE must always reach poweroff so
# an unattended box never hangs powered-on. Hence the trap + oneshot.
serviceConfig = {
Type = "oneshot";
StandardOutput = "tty";
StandardError = "tty";
TTYPath = "/dev/tty1";
};
# The ctgrind build needs <valgrind/memcheck.h> from valgrind's `dev` output,
# which isn't on the default include path (see valgrindCFlags in flake.nix).
# gate.sh passes this to the ctgrind make as CTGRIND_VG_CFLAGS (a bare
# NIX_CFLAGS_COMPILE is ignored by the salted cc-wrapper). Systemd gives the
# service a clean env, so set it here.
environment.GATE_CTGRIND_VG_CFLAGS = valgrindCFlags;
# gateTools already provides util-linux (mount/umount/blkid) and coreutils
# (sync); no need to re-add them. Keep the PATH minimal (principle 3).
path = gateTools;
script = ''
set -u
# Power off only when we're sure the report is safe. If the operator
# expected a USB report but it didn't persist (mount failed / read-only /
# full), leave the box UP so the tmpfs copy can be recovered — an
# unattended appliance that powers off having lost the evidence is worse
# than one that waits.
no_poweroff=""
trap 'sync; umount /mnt 2>/dev/null || true; [ -n "$no_poweroff" ] || systemctl poweroff' EXIT
warn() { echo "timing-gate: $*" | tee /dev/tty1 /dev/console 2>/dev/null || true; }
# 0. Quiet: this is the UNATTENDED sweep, so stop the network + ssh daemons
# that the base image starts — DHCP/NTP/ssh activity is measurement noise.
# (The `debug` boot-menu entry skips the sweep and leaves them up for
# interactive SSH access instead.)
systemctl stop sshd.service systemd-networkd.service systemd-timesyncd.service \
NetworkManager.service dhcpcd.service 'wpa_supplicant*' 2>/dev/null || true
# 1. Writable copy of the baked source (store is read-only).
work=/run/timing-gate/src
rm -rf "$work"; mkdir -p "$work"
cp -a /etc/secp256k1-native/source/. "$work/"
chmod -R u+w "$work"
cd "$work"
# 2. Results sink: the Ventoy exFAT data partition (label "Ventoy"). No
# stick present ⇒ a bare VM run ⇒ tmpfs + poweroff is expected. Stick
# present but unmountable ⇒ operator error ⇒ tmpfs + STAY UP.
stamp="$(date -u +%Y%m%d-%H%M%S)"
out=/run/timing-gate/out-$stamp
ventoy_expected=""
if dev=$(blkid -L Ventoy 2>/dev/null); then
ventoy_expected=1
mkdir -p /mnt
if mount "$dev" /mnt 2>/dev/null; then
out=/mnt/secp256k1-timing-$stamp
else
warn "ERROR: Ventoy stick present but mount failed — results go to tmpfs; NOT powering off so they can be recovered."
no_poweroff=1
fi
fi
mkdir -p "$out"
# 3. Run the sweep. Offline gems (GATE_RUBY_EXEC=""), pinned to the
# isolated core, results written incrementally to the stick.
GATE_RUBY_EXEC="" \
GATE_COMPILERS="${compilerBins}" \
GATE_CORE="${toString isolatedCore}" \
GATE_SOURCE_REV="${srcRev}" \
GATE_OUT="$out" \
bash nix/gate.sh
gate_rc=$?
# 4. Confirm the report actually landed before powering off. A Ventoy write
# can fail silently if the partition is read-only or full — verify the
# report exists and is non-empty on the intended sink; if not, stay up.
sync
if [ -n "$ventoy_expected" ] && [ -z "$no_poweroff" ] && [ ! -s "$out/timing-report.txt" ]; then
warn "ERROR: expected the report on the Ventoy stick but $out/timing-report.txt is missing/empty (read-only or full?) — NOT powering off."
no_poweroff=1
fi
# The EXIT trap syncs, unmounts, and (unless no_poweroff) powers off.
# Exit with the gate's status (no `|| true`) so `systemctl status
# timing-gate` / the journal reflect PASS/FAIL — a detected leak must not
# read as a clean service success.
exit "$gate_rc"
'';
};
# --- Interactive debug boot entry (a specialisation ⇒ a second boot-menu
# entry the nixpkgs ISO module generates for GRUB + isolinux). The ISO menu's
# existing timeout auto-boots the default (unattended sweep); arrow to
# "debug" + enter to get a networked shell instead. Build-time, so no runtime
# tty-prompt fragility. Same quiet-machine kernel params (isolation stays on
# so you can test its effect), but: the sweep does NOT run, networking + sshd
# stay up, and the box does NOT power off — SSH in and drive `bash nix/gate.sh`
# by hand, or tune sysfs and re-run.
#
# SSH access is KEY-ONLY (PermitRootLogin=prohibit-password): add your public
# key(s) to nix/debug-ssh-authorized-keys and rebuild. Without a baked key,
# log in on the autologin root console (grab the DHCP IP with `ip a`); to
# enable SSH ad-hoc without a rebuild, append a key to
# /root/.ssh/authorized_keys there. (Password SSH is off, so `passwd` only
# helps the local console, not SSH.)
specialisation.debug.configuration = {
system.nixos.tags = [ "debug" ];
# No unattended sweep on this entry.
systemd.services.timing-gate.wantedBy = lib.mkForce [ ];
# SSH in for interactive tuning.
services.openssh = {
enable = true;
settings = {
PermitRootLogin = "prohibit-password"; # key-only root
# Defence-in-depth: this is a key-only appliance, so pin the whole
# password/interactive surface off rather than leaning on upstream
# defaults. The empty-password installer `nixos` account is remotely
# inert today ONLY because sshd's PermitEmptyPasswords defaults to no and
# the PAM auth line carries no nullok; a future base-profile change could
# silently reopen that. No mkForce needed — the installer profile leaves
# these at the module default (true), so a plain assignment overrides.
PasswordAuthentication = false;
KbdInteractiveAuthentication = false;
};
};
users.users.root.openssh.authorizedKeys.keyFiles = [ ./debug-ssh-authorized-keys ];
networking.hostName = lib.mkForce "secp256k1-debug";
# Console hint. NB: the baked source is a READ-ONLY nix store copy, so the
# gate (which clobbers + builds in-tree) must run from a WRITABLE copy.
users.motd = ''
secp256k1 reference machine — DEBUG boot: network + sshd up, sweep NOT run.
Run the gate by hand from a writable copy of the read-only baked source:
rm -rf /root/src && cp -aL /etc/secp256k1-native/source /root/src && chmod -R u+w /root/src && cd /root/src
GATE_CTGRIND_VG_CFLAGS="${valgrindCFlags}" GATE_RUBY_EXEC="" GATE_CORE=${toString isolatedCore} GATE_OUT=/root/out bash nix/gate.sh
'';
};
}
nix/gate.sh — the gate
#!/usr/bin/env bash
#
# gate.sh — the unattended timing-verification gate (Phase 4 of
# plans/61-reference-machine-nix.md). One script, two contexts:
# • dev/CI iteration in the nix devShell (fast, no core pinning), and
# • the authoritative bare-metal run as the ISO's timing-gate.service.
#
# Per compiler in the set it runs, fail-closed and fault-tolerant:
# 1. rake clobber
# 2. build the extension at VANILLA -O2 with that gcc (NIX_HARDENING_ENABLE="")
# — hardening OFF is load-bearing: nix's default hardening changes the CT
# codegen (see nix/vanilla-ext.sh), so a hardened build certifies the wrong
# binary. Explicit `make CC=` selects the compiler under test.
# 3. verify CC-took + the assembly-invariant (nix/vanilla-ext.sh)
# 4. rspec — functional
# 5. ctgrind (valgrind secret-poisoning) — deterministic constant-time
# 6. dudect timing harness, N runs, pinned to the isolated core on bare metal
# — parse per-op |t|, aggregate (n_ge_4.5, max|t|, mean|t|)
# then writes a provenance-stamped row to the results dir *incrementally* (so a
# mid-sweep power loss costs only the in-flight compiler). A compiler that fails
# to build/deps is recorded SKIPPED and the sweep continues (best-effort back to
# 9.5). Every step has a timeout so nothing hangs the unattended run.
#
# Release semantics (fail-closed, principle 1): every compiler that BUILDS must
# pass; a CT leak on any building gcc reds the gate. SKIPPED (build/dep failure)
# is not a CT result and does not block. The measurement is only meaningful on
# quiet bare metal — in Docker/VM this validates the AUTOMATION, not the timing.
#
# Env knobs
# GATE_COMPILERS space-separated CC names/paths (default: "gcc")
# GATE_CORE isolated core for taskset/chrt (default: ""=no pin)
# GATE_DUDECT_RUNS N dudect runs per compiler (default: 20)
# GATE_OUT results directory (default: ./gate-results)
# GATE_TIMEOUT per-step timeout, seconds (default: 1800)
# GATE_ARTEFACT_MEAN mean|t| bound for the elevated ops (jp_add/fsub) (default: 35)
# GATE_LENIENT_MEAN mean|t| bound for the near-flat field ops (default: 15)
# GATE_LENIENT_MAX loose max|t| gross-anomaly backstop (all lenient)(default: 100)
# GATE_STRICT_OVER_PCT strict ops: max %% of runs |t|>=4.5 tolerated (default: 5)
# GATE_MIN_CLASS_N min samples per dudect class; reject under-sampled (default: 100)
# GATE_CTGRIND_VG_CFLAGS -isystem path to <valgrind/memcheck.h> for ctgrind (default: "")
# GATE_SOURCE_REV / GATE_NIXPKGS_REV provenance overrides (default: auto)
#
# Exit: 0 all building compilers passed · 1 a building compiler leaked/failed a
# deterministic gate · 2 environment/usage error. (SKIPs alone do not fail.)
set -uo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"
# How to invoke Ruby dev tools (rake/rspec). devShell: "bundle exec" against the
# root Gemfile (gemspec puts lib/ on the load path). ISO: "" — the offline
# bundlerEnv already exposes rake/rspec on PATH. Single-dash default so an
# explicit empty GATE_RUBY_EXEC is honoured.
RUBY_EXEC="${GATE_RUBY_EXEC-bundle exec}"
# Make require 'secp256k1' / 'secp256k1_native' resolve without relying on a
# gemspec on the load path (the minimal ISO gemset has none).
export RUBYLIB="$ROOT/lib${RUBYLIB:+:$RUBYLIB}"
GATE_COMPILERS="${GATE_COMPILERS:-gcc}"
GATE_CORE="${GATE_CORE:-}"
GATE_DUDECT_RUNS="${GATE_DUDECT_RUNS:-20}"
GATE_OUT="${GATE_OUT:-$ROOT/gate-results}"
GATE_TIMEOUT="${GATE_TIMEOUT:-1800}"
GATE_ARTEFACT_MEAN="${GATE_ARTEFACT_MEAN:-35}" # large-artefact ops (jp_add, fsub): wide mean|t| bound
GATE_LENIENT_MEAN="${GATE_LENIENT_MEAN:-15}" # near-flat field ops (fadd, fred, fneg): tighter mean|t| bound
GATE_LENIENT_MAX="${GATE_LENIENT_MAX:-100}" # all lenient ops: loose gross-anomaly backstop (per-run max is noisy)
GATE_STRICT_OVER_PCT="${GATE_STRICT_OVER_PCT:-5}" # strict ops: fail if >this% of runs are |t|>=4.5 (tolerate a lone transient)
# Minimum samples per dudect class. A FULLY degenerate statistic (a class with <2
# samples, or both-variances-zero) now returns NAN from dudect_t_statistic, which
# the gate's nan/inf check reds. This floor catches the DISTINCT finite-but-weak
# case: a class with 2..minn samples yields a finite but untrustworthy t. A broken
# class generator (all measurements in one class) or a short run is rejected by
# requiring BOTH classes >= this. Default 100: far below the smallest healthy
# per-class count (scalar_inv ~500/class from 1000 measurements; field ops ~750k),
# far above the degenerate cases.
GATE_MIN_CLASS_N="${GATE_MIN_CLASS_N:-100}"
THRESHOLD="4.5"
# Three tiers, so a currently-flat op can't regress up to the widest bound
# unnoticed. The field/point ops carry a benign operand-value LATENCY artefact,
# of very different magnitude, so they get bounds scoped to the op that needs
# them, NOT one global relaxation. Two channels, two backstops: ctgrind covers
# the BRANCH/addressing channel (it poisons secrets and catches secret-dependent
# control flow, but is SILENT on data-dependent instruction latency — so it does
# NOT, on its own, certify this artefact benign); the operand-latency channel's
# benignness w.r.t. the SECRET SCALAR is established by the flat STRICT ladder
# (scalar_multiply_ct), measured end-to-end on the real full-width operand
# distribution (see tier 2). The tiers:
# 1. STRICT (scalar_*): secret-dependent inputs, MUST be flat — fail when MORE
# than GATE_STRICT_OVER_PCT%% of runs are at/over 4.5 (a reconstructed branch
# shows in ~every run, a lone transient in one; see the aggregation below).
# These plus the ctgrind pass are the security gate.
# 2. ARTEFACT (jp_add, fsub): the elevated ops — jp_add's Zen-MULTIPLIER
# operand-value latency (Z=1 vs non-trivial Z), fsub's borrow-path latency.
# Both are operand-VALUE effects ctgrind cannot see; the standalone dudect
# |t| comes from the test's deliberately magnitude-asymmetric operand classes
# (fsub: tiny-vs-full-width) — a synthetic magnitude asymmetry. Tiny
# operands DO occur on the secret path (the ladder processes the infinity
# accumulator [0,1,0] for a scalar-dependent number of leading iterations),
# but the strict k=1-vs-random ladder test maximally stresses exactly that
# and measures flat, so the effect is not secret-correlated. Secret-scalar
# correlation is caught by the flat STRICT ladder, not by this tier or
# ctgrind. Wide
# mean bound GATE_ARTEFACT_MEAN. (A realistic full-width-vs-full-width fsub
# test could retire it from this tier — issue #78.)
# 3. LENIENT (fadd, fred, fneg): near-flat, kept on a TIGHTER mean bound
# GATE_LENIENT_MEAN so a stable regression in one of them is still caught.
#
# The bounds are CALIBRATED PER PINNED TOOLCHAIN, re-derived from the authoritative
# bare-metal sweep (issue #74) whenever the compiler changes — an artefact floor,
# NOT loosen-to-green. The MEAN is the stable, gated signal; the per-run MAX is
# noisy for these ~20 ns ops (it swings run-to-run) so GATE_LENIENT_MAX is only a
# loose gross-anomaly backstop shared by both lenient tiers, not a spike detector.
# On the reference machine (gcc 15.1, quiet, random-class harness, VANILLA -O2)
# the worst op is jp_add_internal, observed across sweeps at mean ~16-24 / max
# ~37-71 (the max swings run-to-run); the ARTEFACT mean bound (35) sits above the
# mean with margin and the max backstop (100) above the noisy max envelope, the
# LENIENT bound (15) hugs the near-flat ops (mean ~0.6-5). The lenient bounds are
# NOT the primary leak detector for these ops — note a #25-magnitude leak
# (|t| ≈ 21) would slip under the 35
# artefact bound. That's fine: jp_add/fsub are point/field building blocks, not
# secret-scalar ops. A secret-correlated BRANCH in them surfaces in BOTH the
# deterministic ctgrind pass AND the STRICT scalar_multiply_ct (gated at 4.5,
# where the #25 leak read ~21 and was caught with wide margin); a secret-correlated
# operand-LATENCY effect, which ctgrind cannot see, surfaces in the STRICT ladder
# alone, measured flat end-to-end — that is the backstop for this tier;
# the lenient bounds just pin the benign artefact per toolchain and flag gross
# regressions in the near-flat ops.
# Full labels, not substrings: `scalar_add` is intentionally ABSENT — the harness
# emits no scalar_add dudect line, so listing it would falsely imply coverage.
# (If scalar_add ever gets a dudect test, add its label here.)
# Anchored (^(...)$) so a match is an EXACT full label, per the note above — a
# future dudect label that merely contained one of these as a substring must not
# be mis-tiered.
STRICT_RE='^(scalar_multiply_ct_internal|scalar_mul_internal|scalar_reduce|scalar_inv_internal)$'
# The elevated operand-value-artefact ops that get the WIDE lenient mean bound;
# every other non-strict op gets the tighter GATE_LENIENT_MEAN.
ARTEFACT_RE='^(jp_add_internal|fsub_internal)$'
# Fail-closed coverage guard (principle 1: fail closed, not open). The aggregation
# only iterates labels it actually SAW, so a strict op silently removed/renamed —
# or a harness that partially crashes yet still emits >=1 dudect line (so the
# no-output FAIL path is not hit) — would drop out of the gate unnoticed: a
# missing label yields zero over-threshold runs, which reads as a pass. We assert
# every EXPECTED label is present and red the gate if any is absent. Keep in
# lock-step with timing/timing_harness.c: adding a dudect op must add its label
# here, a deliberate acknowledgement that the gate now covers it.
GATE_EXPECT_LABELS="scalar_multiply_ct_internal scalar_mul_internal scalar_reduce scalar_inv_internal jp_add_internal fsub_internal fadd_internal fred_internal fneg_internal"
mkdir -p "$GATE_OUT"
REPORT="$GATE_OUT/timing-report.txt"
log() { printf '%s\n' "$*" | tee -a "$REPORT"; sync "$REPORT" 2>/dev/null || true; }
step() { timeout "$GATE_TIMEOUT" "$@"; }
# --- provenance header (written once) ----------------------------------------
cpu_model="$(grep -m1 'model name' /proc/cpuinfo 2>/dev/null | cut -d: -f2- | sed 's/^ //' || echo unknown)"
microcode="$(grep -m1 microcode /proc/cpuinfo 2>/dev/null | cut -d: -f2- | tr -d ' ' || echo unknown)"
kernel="$(uname -r 2>/dev/null || echo unknown)"
cur_khz="$([ -n "$GATE_CORE" ] && cat /sys/devices/system/cpu/cpu"$GATE_CORE"/cpufreq/scaling_cur_freq 2>/dev/null || echo n/a)"
src_rev="${GATE_SOURCE_REV:-$(git -C "$ROOT" rev-parse --short HEAD 2>/dev/null || echo unknown)}"
# Parse the nixpkgs node's rev specifically (ruby+JSON — always available here),
# not the first "rev" in flake.lock, which stops being nixpkgs the moment another
# input with a rev is added.
npk_rev="${GATE_NIXPKGS_REV:-$(ruby -rjson -e 'begin; print(JSON.parse(File.read(ARGV[0])).dig("nodes","nixpkgs","locked","rev").to_s[0,12]); rescue; end' "$ROOT/flake.lock" 2>/dev/null)}"
npk_rev="${npk_rev:-unknown}"
# --- machine-state diagnostics -----------------------------------------------
# Stamp what the quiet-machine config ACTUALLY did, so a single bare-metal run
# reveals a knob that didn't take (e.g. isolcpus wrong for this CPU, boost still
# on) — instead of a silent physical round-trip. All best-effort ("n/a" off the
# real box).
# Machine-wide state (always meaningful):
ms_cmdline="$(cat /proc/cmdline 2>/dev/null || echo unknown)"
if [ -r /sys/devices/system/cpu/isolated ]; then
# File present: empty contents mean genuinely no isolated CPUs.
ms_isolated="$(cat /sys/devices/system/cpu/isolated)"; ms_isolated="${ms_isolated:-<none>}"
else
# File absent (kernel doesn't expose it) — can't tell; don't misreport as <none>.
ms_isolated="<unknown (no /sys/.../cpu/isolated on this kernel)>"
fi
ms_online="$(cat /sys/devices/system/cpu/online 2>/dev/null || echo unknown)"
ms_smt="$(cat /sys/devices/system/cpu/smt/control 2>/dev/null || echo n/a)"
ms_boost="$(cat /sys/devices/system/cpu/cpufreq/boost 2>/dev/null || echo n/a)" # AMD/acpi-cpufreq: 1=on 0=off
ms_noturbo="$(cat /sys/devices/system/cpu/intel_pstate/no_turbo 2>/dev/null || echo n/a)" # Intel pstate: 1=turbo off
# Per-core state is only meaningful when a core is actually pinned. In dev/CI
# (GATE_CORE unset) report n/a — don't stamp core 0's stats as "the measured
# core" while the header says isolated=<none> (that would be inconsistent).
if [ -n "$GATE_CORE" ]; then
mc="$GATE_CORE"
ms_gov="$(cat /sys/devices/system/cpu/cpu"$mc"/cpufreq/scaling_governor 2>/dev/null || echo n/a)"
ms_min="$(cat /sys/devices/system/cpu/cpu"$mc"/cpufreq/scaling_min_freq 2>/dev/null || echo n/a)"
ms_max="$(cat /sys/devices/system/cpu/cpu"$mc"/cpufreq/scaling_max_freq 2>/dev/null || echo n/a)"
# Cumulative IRQ count on the isolated core SINCE BOOT (a total from
# /proc/interrupts, not a live rate) — should be ~0 if irqaffinity steered them away.
ms_irq="$(awk -v core="$mc" 'NR==1{for(i=1;i<=NF;i++) if($i=="CPU"core) col=i+1} NR>1 && col && $col ~ /^[0-9]+$/ {s+=$col} END{print (col? s+0 : "n/a")}' /proc/interrupts 2>/dev/null || echo n/a)"
else
mc="<none>"; ms_gov="n/a"; ms_min="n/a"; ms_max="n/a"; ms_irq="n/a"
fi
{
echo "=========================================================================="
echo "secp256k1-native — timing-verification reference machine report"
echo "=========================================================================="
echo "date : $(date -u '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null || echo unknown)"
echo "cpu : $cpu_model"
echo "microcode : $microcode"
echo "kernel : $kernel"
echo "isolated cpu: ${GATE_CORE:-<none — NOT bare-metal-pinned; timing is indicative only>}"
echo "cur freq : ${cur_khz} kHz (stamped to catch throttling)"
echo "source rev : $src_rev"
echo "nixpkgs rev : $npk_rev"
echo "dudect runs : $GATE_DUDECT_RUNS |t|<$THRESHOLD strict (fail if >$GATE_STRICT_OVER_PCT% of runs at/over) / mean|t|<$GATE_ARTEFACT_MEAN (jp_add,fsub) / <$GATE_LENIENT_MEAN (other field ops) / max<$GATE_LENIENT_MAX / min class n=$GATE_MIN_CLASS_N"
echo "compilers : $GATE_COMPILERS"
echo "-------------------------------- machine state (did the quiet config take?) --"
echo "cmdline : $ms_cmdline"
echo "isolated : $ms_isolated (kernel-reported isolated CPUs; want the measurement core listed)"
echo "online cpus : $ms_online"
echo "SMT : $ms_smt (want 'off'/'forceoff')"
echo "core $mc gov : $ms_gov (want 'performance')"
echo "core $mc freq : min=$ms_min max=$ms_max cur=$cur_khz kHz (want min==max==cur, no throttle)"
echo "boost/turbo : amd boost=$ms_boost (want 0) intel no_turbo=$ms_noturbo (want 1)"
echo "core $mc IRQs : $ms_irq (cumulative since boot; want ~0 — irqaffinity steered interrupts off the isolated core)"
echo "=========================================================================="
echo
} > "$REPORT"
sync "$REPORT" 2>/dev/null || true
overall_rc=0
built=0 # compilers that built + staged (and so ran the CT checks below), not
# SKIPPED — guards against a vacuous all-SKIPPED sweep reporting PASS
for cc in $GATE_COMPILERS; do
ccver="$("$cc" --version 2>/dev/null | head -1 || echo 'unknown')"
log "--------------------------------------------------------------------------"
log "compiler: $cc — $ccver"
work="$(mktemp -d)"
# --- 1+2. clobber + vanilla build (SKIPPED on failure, not fatal) ----------
if ! command -v "$cc" >/dev/null 2>&1; then
log " SKIPPED — compiler '$cc' not on PATH"; rm -rf "$work"; continue
fi
step $RUBY_EXEC rake clobber >/dev/null 2>&1
# Guarantee a from-scratch build for THIS compiler. rake clobber handles
# rake-compiler's tmp/, but the direct extconf/make below leaves ext/*.o +
# Makefile that rake doesn't track — a stale .o (newer than its .c) would be
# silently reused by the next compiler in the sweep and we'd measure the wrong
# binary. Remove them explicitly so a clobber hiccup can't corrupt the sweep.
rm -f ext/secp256k1_native/*.o ext/secp256k1_native/secp256k1_native.so \
ext/secp256k1_native/Makefile ext/secp256k1_native/mkmf.log \
lib/secp256k1_native.so
mkdir -p "$GATE_OUT" # defensive: `rake clobber` wipes tmp/; never let it eat the results dir
if ! ( cd ext/secp256k1_native \
&& NIX_HARDENING_ENABLE="" CC="$cc" step ruby extconf.rb \
&& NIX_HARDENING_ENABLE="" step make CC="$cc" ) >"$work/build.log" 2>&1; then
log " SKIPPED — extension failed to build with $cc (dep/toolchain):"
log " $(tail -1 "$work/build.log")"
rm -rf "$work"; continue
fi
# Stage the freshly-built extension; fail closed if it doesn't land so that no
# later step (rspec/ctgrind) can run against a previous compiler's .so.
if ! cp ext/secp256k1_native/secp256k1_native.so lib/secp256k1_native.so; then
log " SKIPPED — staging the built extension into lib/ failed"
rm -rf "$work"; continue
fi
# Certify the TESTED artifact directly: confirm the staged .so (what rspec /
# ctgrind / dudect actually exercise) was compiled by $cc — vanilla-ext.sh
# only certifies a separately-compiled proxy object.
so_major="$(readelf -p .comment lib/secp256k1_native.so 2>/dev/null | grep -oE '(GCC:|clang version)[^0-9]*[0-9]+' | grep -oE '[0-9]+$' | head -1)"
cc_major="$("$cc" -dumpversion 2>/dev/null | cut -d. -f1)"
if [ -n "$cc_major" ] && [ "$so_major" != "$cc_major" ]; then
# NOT a SKIP: the build succeeded but the tested artifact can't be attributed
# to $cc — a certification failure, so red the gate (fail-closed) rather than
# let other compilers carry it to PASS. Continue the sweep for visibility.
log " FAIL — staged .so built by compiler major '$so_major', not the intended '$cc_major' (cannot certify the tested artifact)"
overall_rc=1
rm -rf "$work"; continue
fi
built=$((built + 1))
cc_pass=1
# --- 3. CC-took + assembly-invariant (Phase 3) -----------------------------
if step bash "$ROOT/nix/vanilla-ext.sh" "$cc" >"$work/vanilla.log" 2>&1; then
codegen="PASS"
else
codegen="FAIL"; cc_pass=0
log " codegen : FAIL (CC-took or assembly-invariant) — see below"
sed 's/^/ /' "$work/vanilla.log" | tee -a "$REPORT" >/dev/null
fi
[ "$codegen" = PASS ] && log " codegen : PASS (vanilla -O2, CC-took, branchless)"
# --- 4. rspec --------------------------------------------------------------
if step $RUBY_EXEC rspec >"$work/rspec.log" 2>&1; then
rspec="PASS"
else
rspec="FAIL"; cc_pass=0
fi
log " rspec : $rspec ($(grep -oE '[0-9]+ examples?, [0-9]+ failures?' "$work/rspec.log" | tail -1))"
# --- 5. ctgrind ------------------------------------------------------------
# GATE_CTGRIND_VG_CFLAGS carries the valgrind-dev include (-isystem ...) for
# <valgrind/memcheck.h> when it isn't on the default path (the nix ISO/app);
# empty on a distro where the header is already found. Passed EXPLICITLY as a
# make var because a bare NIX_CFLAGS_COMPILE is ignored by nix's salted
# cc-wrapper.
# NIX_HARDENING_ENABLE="" to match the SHIPPING-SHAPED vanilla codegen (as the
# extension build above does): the nix cc-wrapper's default hardening alters
# codegen, and a compiler-reconstructed branch is optimisation- AND flag-specific.
# This standalone harness is not byte-identical to a `gem install` build (it adds
# stub-build flags and omits per-user mkmf CFLAGS; see docs/security.md), but
# building at vanilla -O2 exercises the same optimisation level and hardening-off
# codegen that ships — or the deterministic backstop would verify a hardened
# binary users do not run. (Advisory 0001's reconstruction is a vanilla -O2
# phenomenon.)
make -C security clean >/dev/null 2>&1
if NIX_HARDENING_ENABLE="" step make -C security ctgrind CC="$cc" CTGRIND_VG_CFLAGS="${GATE_CTGRIND_VG_CFLAGS:-}" >"$work/ctg.log" 2>&1 \
&& step valgrind -q --error-exitcode=1 ./security/ctgrind_harness >>"$work/ctg.log" 2>&1; then
ctgrind="PASS"
log " ctgrind : PASS"
else
ctgrind="FAIL"; cc_pass=0
log " ctgrind : FAIL — last lines:"
tail -6 "$work/ctg.log" | sed 's/^/ /' | tee -a "$REPORT" >/dev/null
fi
# --- 6. dudect timing, N runs ----------------------------------------------
# Vanilla (NIX_HARDENING_ENABLE="") too, so the measured timing is the
# shipping-shaped codegen's (same optimisation level + hardening-off, though
# not byte-identical — see the ctgrind note above), not the hardened wrapper
# default — the calibrated bounds must describe the binary users run.
make -C timing clean >/dev/null 2>&1
if ! NIX_HARDENING_ENABLE="" step make -C timing CC="$cc" >"$work/timing.log" 2>&1; then
log " dudect : FAIL — timing harness build failed"; cc_pass=0
else
: > "$work/dudect.raw"
pin=""
[ -n "$GATE_CORE" ] && pin="taskset -c $GATE_CORE chrt -f 99"
runs_ok=0
for run in $(seq 1 "$GATE_DUDECT_RUNS"); do
# Capture per run so a timeout/crash is DETECTED, not swallowed. CRUCIAL:
# timing_harness returns 1 whenever ANY op trips its OWN internal |t|>=4.5
# (e.g. jp_add_internal's operand-value artefact, which exceeds 4.5), so exit
# 0 AND 1 are both NORMAL completions — we re-derive PASS/FAIL from the
# t-values below,
# so the harness's own verdict is not the run-success signal. A real
# failure is a timeout (`timeout` → 124), a signal (>128), or no output.
step $pin ./timing/timing_harness >"$work/run" 2>/dev/null; hrc=$?
got=$(grep -c '^dudect:' "$work/run")
if { [ "$hrc" -eq 0 ] || [ "$hrc" -eq 1 ]; } && [ "$got" -gt 0 ]; then
grep '^dudect:' "$work/run" >> "$work/dudect.raw"
runs_ok=$((runs_ok + 1))
else
log " dudect : note — run $run discarded (harness exit $hrc, $got lines)"
fi
done
if [ "$runs_ok" -eq 0 ]; then
# Fail closed (principle 1): zero usable dudect output — every run
# crashed/timed out — is inconclusive, never a pass.
log " dudect : FAIL — no dudect output from $GATE_DUDECT_RUNS run(s) (crash/timeout)"
cc_pass=0
else
short=0
if [ "$runs_ok" -lt "$GATE_DUDECT_RUNS" ]; then
# Fail closed (principle 1): a degraded run-set is inconclusive, not a
# pass. With nexp=runs_ok the per-label count check alone CANNOT catch
# this — e.g. 19/20 runs time out and the one survivor emits every label
# once, so count==nexp==1 and the strict fraction sees 0/1 over — the
# statistical basis (N independent runs) is gone. Red the gate rather
# than certify on a shrunken run-set. (Fewer runs by design? lower
# GATE_DUDECT_RUNS — then all of them must still succeed.)
short=1
log " dudect : FAIL — only $runs_ok/$GATE_DUDECT_RUNS runs produced output (inconclusive; all $GATE_DUDECT_RUNS required)"
fi
# Aggregate per op: n_over threshold, max|t|, mean|t|; apply strict/lenient.
agg="$(awk -v thr="$THRESHOLD" -v strict="$STRICT_RE" -v artefact="$ARTEFACT_RE" -v amean="$GATE_ARTEFACT_MEAN" -v lmean="$GATE_LENIENT_MEAN" -v lmax="$GATE_LENIENT_MAX" -v spct="$GATE_STRICT_OVER_PCT" -v expect="$GATE_EXPECT_LABELS" -v nexp="$runs_ok" -v minn="$GATE_MIN_CLASS_N" '
/^dudect:/ {
label=$2; tv=""; c0=""; c1=""
# Scan fields once for the class counts (n0=/n1=, printed before t=) and
# the t-value. dudect prints "t=%+9.4f": a standalone "t=" then the value
# for small |t|, but GLUED ("t=+875.0000") once |t|>=100 drops the pad
# space — i.e. exactly a large leak. Take the "t=" suffix or the next
# field; the tv=="" guard sets it once so a later field cannot overwrite.
for (i=1; i<=NF; i++) {
if ($i ~ /^t=/ && tv=="") { tv=$i; sub(/^t=/,"",tv); if (tv=="") tv=$(i+1) }
else if ($i ~ /^n0=/) { c0=$i; sub(/^n0=/,"",c0) }
else if ($i ~ /^n1=/) { c1=$i; sub(/^n1=/,"",c1) }
}
if (tv=="") { badparse++; next }
# Non-finite t (a degenerate Welch denominator prints nan/inf via %f)
# must fail closed: tv+0 would coerce it to 0 (BSD awk) or nan (gawk)
# and silently count as a clean sub-threshold run — a fail-open.
if (tolower(tv) ~ /nan|inf/) { badnan++; next }
# Class-count validity floor. dudect now returns NAN for a fully
# degenerate statistic (a class with <2 samples, or both-variances-zero
# from a constant/broken timer) — the nan/inf check above reds those.
# This adds the STATISTICAL floor the nan check cannot: a class with
# 2..minn samples yields a FINITE but untrustworthy t. Reject any line
# whose class counts are missing or below minn (a broken class
# generator bucketing every measurement into one class, or an
# under-sampled run, has no valid Welch test). Fail closed.
if (c0=="" || c1=="") { badcount++; next }
if (c0+0 < minn || c1+0 < minn) { badcount++; next }
a=tv+0; if(a<0)a=-a
n[label]++; sum[label]+=a; if(a>mx[label])mx[label]=a; if(a>=thr)ov[label]++
}
END {
anyfail=0
# Fail closed on any line whose t could not be parsed — never drop it.
# Each reject reason reports separately so a failure names its true cause.
if (badparse>0) { anyfail=1; printf " %-28s %d line(s) with unparseable t <== FAIL\n","(parse-error)",badparse }
if (badnan>0) { anyfail=1; printf " %-28s %d line(s) with non-finite t (nan/inf) <== FAIL\n","(degenerate-t)",badnan }
if (badcount>0) { anyfail=1; printf " %-28s %d line(s) with missing/under-sampled class counts <== FAIL\n","(under-sampled)",badcount }
for (l in n) {
mean=sum[l]/n[l]
isstrict = (l ~ strict)
isartefact = (l ~ artefact)
# Strict ops: fail if MORE THAN spct% of runs are at/over 4.5. A
# compiler-reconstructed branch is compiled IN, so it shows in
# essentially EVERY run (the #25 ladder leak was 20/20); a lone
# measurement transient hits one run. Gating on the fraction-over
# therefore distinguishes the two WITHOUT masking a real leak (a leak
# is ~100% of runs, far above spct) while not redding the gate on a
# single blip. Scales with N: at spct=5 and N=20 it tolerates <=1
# over-threshold run (fails at 2); at small N it stays effectively
# strict (N=2 -> any run over fails). ctgrind (deterministic, vanilla
# -O2) is the load-bearing backstop for the marginal band this cannot
# resolve (a weak/rare-bit partial branch < spct% of runs) — and it
# poisons the secret inputs of ALL FOUR strict ops (ladder,
# scalar_mul, scalar_reduce, scalar_inv in ctgrind_harness.c), so no
# strict label relies on the statistical gate alone for the BRANCH
# channel. Residual: ctgrind cannot see operand LATENCY, so a
# secret-correlated latency leak weak enough to stay < spct% of runs
# is not deterministically excluded — but that sits at the noise
# floor (a genuine data-dependent effect fires consistently, like the
# 17/20 and 9/20 artefacts, not 1/20). See docs/security.md. Lenient
# ops are gated on the MEAN (the stable signal) against a per-tier
# bound — the wider amean for the elevated artefact ops (jp_add/fsub),
# the tighter lmean for the near-flat ones — plus a shared loose max
# backstop (per-run max is noisy for these fast ops, not a spike det).
# The mean/max bounds compare >= (a value exactly at the bound fails,
# fail-closed); the strict fraction uses > (tolerate exactly spct%).
if (isstrict) {
fail = (ov[l]*100 > n[l]*spct); tier = "[strict]"
} else if (isartefact) {
fail = (mean >= amean || mx[l] >= lmax); tier = "[artefact]"
} else {
fail = (mean >= lmean || mx[l] >= lmax); tier = "[lenient]"
}
if (fail) anyfail=1
printf " %-28s runs=%d %d>=%.1f max|t|=%.2f mean|t|=%.2f %s%s\n",
l, n[l], ov[l]+0, thr, mx[l], mean, tier, (fail?" <== FAIL":"")
}
# Fail closed on coverage anomalies in BOTH directions (principle 1) —
# the aggregation tiers only the labels it saw, so guard the label set:
# (a) every EXPECTED label must be present with the FULL run count.
# Absent entirely (op removed/renamed) OR short (a partial-run
# harness that emitted it in only some runs) reds the gate — a
# silently-dropped or under-sampled op, above all a strict one,
# must never pass by omission. Each healthy run emits each label
# exactly once, so the expected per-label count is nexp (=runs_ok).
# (b) every SEEN label must be EXPECTED. A new/renamed op otherwise
# falls through to [lenient] tiering silently; an unknown label
# reds the gate and forces an explicit tier decision in
# GATE_EXPECT_LABELS + the strict/artefact regexes.
ne = split(expect, elab, " ")
for (j=1; j<=ne; j++) known[elab[j]] = 1
for (j=1; j<=ne; j++) {
if (!(elab[j] in n)) {
anyfail=1
printf " %-28s MISSING from dudect output <== FAIL\n", elab[j]
} else if (nexp+0 > 0 && n[elab[j]] != nexp) {
anyfail=1
printf " %-28s incomplete: %d/%d runs <== FAIL\n", elab[j], n[elab[j]], nexp
}
}
for (l in n) if (!(l in known)) {
anyfail=1
printf " %-28s UNEXPECTED label (add to GATE_EXPECT_LABELS + tier) <== FAIL\n", l
}
exit anyfail
}' "$work/dudect.raw")"
dudect_rc=$?
[ "$short" -eq 1 ] && dudect_rc=1
log " dudect : $([ $dudect_rc -eq 0 ] && echo PASS || echo FAIL) (N=$runs_ok${GATE_CORE:+, core $GATE_CORE})"
printf '%s\n' "$agg" | tee -a "$REPORT" >/dev/null
[ $dudect_rc -ne 0 ] && cc_pass=0
fi
fi
verdict="$([ $cc_pass -eq 1 ] && echo PASS || echo FAIL)"
log " => $cc: $verdict"
[ $cc_pass -ne 1 ] && overall_rc=1
rm -rf "$work"
done
log "--------------------------------------------------------------------------"
# An all-SKIPPED sweep verified nothing — never report that as a clean PASS
# (fail-closed spirit): if not one compiler built, the run is inconclusive.
if [ "$built" -eq 0 ]; then
log "GATE: NO COMPILERS BUILT — nothing verified. Check the toolchain (ruby/gcc on PATH)."
overall_rc=2
else
log "GATE: $([ $overall_rc -eq 0 ] && echo PASS || echo FAIL) ($built/$(echo $GATE_COMPILERS | wc -w) compiler(s) built + tested; report: $REPORT)"
fi
step $RUBY_EXEC rake clobber >/dev/null 2>&1 || true
exit $overall_rc
nix/vanilla-ext.sh — the vanilla-gcc codegen certification
#!/usr/bin/env bash
#
# vanilla-ext.sh — build the CT-critical object at VANILLA gcc -O2 and prove the
# shipped codegen is what a stock `gem install` produces, then that it is
# branchless. This is the load-bearing check of the reference machine (Phase 3
# of plans/61-reference-machine-nix.md): certify the REAL binary, not a
# nix-specific one.
#
# Why "vanilla" is non-negotiable
# -------------------------------
# The gem ships as source and is compiled on each user's machine by their gcc at
# -O2 (extconf.rb appends it). NixOS builds everything through nixpkgs' cc-wrapper,
# which injects a hardening set (empirically, on nixos-25.05: bindnow format
# fortify fortify3 pic relro stackclashprotection stackprotector strictoverflow
# zerocallusedregs). That set DOES change the CT-function codegen — measured on
# gcc 14.3.0, jacobian.o:
# scalar_multiply_ct_internal : 80 insns hardened vs 73 vanilla
# jp_add_internal : 347 insns hardened vs 323 vanilla
# The extra instructions are register-zeroing (zerocallusedregs) and stack probes
# (stackclashprotection) — benign (both builds pass the assembly-invariant), but
# they mean the hardened nix build is NOT the binary a stock user runs. Worse in
# principle, hardening could mask a branch a stock build would emit. So we build
# with hardening OFF (NIX_HARDENING_ENABLE="") to certify the binary users
# actually get. (On a non-nix gcc, NIX_HARDENING_ENABLE is simply ignored.)
#
# This script needs ruby + the REAL Ruby headers (RbConfig rubyhdrdir), so it
# runs where those exist — the nix devShell and the on-ISO gate. The STOCK
# reference for the codegen-equivalence check is built SEPARATELY, with the
# timing/ruby.h stubs (no ruby-dev), in a plain gcc:<major> container — see
# nix/codegen-equivalence.sh.
#
# What this checks (all must pass; exit 1 on any failure)
# -------------------------------------------------------
# 1. CC-actually-took — the object's .comment records the compiler; its major
# must match `$CC --version`. Guards the mkmf/RbConfig trap where the build
# silently falls back to a different compiler than intended.
# 2. Assembly-invariant — security/check-ct-assembly.rb: the ladder and
# jp_add_internal contain no secret-dependent branch or cmov. This is the
# actual constant-time property; it is deterministic and is the on-ISO gate.
#
# The dev-time codegen-equivalence check (nix vanilla gccN vs a stock distro gccN
# of the same major — "passing on nix ⇒ passing on stock") is a separate driver
# that runs THIS script under both toolchains; see nix/codegen-equivalence.sh.
#
# Usage
# -----
# nix/vanilla-ext.sh [CC] # CC defaults to $CC or `gcc`
# The CT object (security/jacobian_ct.o) is built and removed internally.
#
# Exit codes: 0 pass · 1 a check failed · 2 environment/usage error
set -uo pipefail
CC="${1:-${CC:-gcc}}"
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
SRC="$ROOT/ext/secp256k1_native/jacobian.c"
CHECKER="$ROOT/security/check-ct-assembly.rb"
command -v "$CC" >/dev/null 2>&1 || { echo "vanilla-ext: FATAL — CC '$CC' not on PATH" >&2; exit 2; }
command -v objdump >/dev/null 2>&1 || { echo "vanilla-ext: FATAL — objdump not on PATH (need binutils)" >&2; exit 2; }
command -v readelf >/dev/null 2>&1 || { echo "vanilla-ext: FATAL — readelf not on PATH (need binutils; used for the CC-took check)" >&2; exit 2; }
command -v ruby >/dev/null 2>&1 || { echo "vanilla-ext: FATAL — ruby not on PATH" >&2; exit 2; }
[ -f "$SRC" ] || { echo "vanilla-ext: FATAL — source not found: $SRC" >&2; exit 2; }
[ -f "$CHECKER" ] || { echo "vanilla-ext: FATAL — checker not found: $CHECKER" >&2; exit 2; }
# Build the CT object via security/Makefile's `jacobian_ct.o` target — the
# single source of truth for the CT compile line (flags + real Ruby headers).
# Reusing it means a CT-relevant flag change there propagates here (and stays in
# lock-step with CI / run-checks.sh) rather than drifting from a hand-copied
# CFLAGS. NIX_HARDENING_ENABLE="" certifies the vanilla, stock-shaped binary.
OBJ="$ROOT/security/jacobian_ct.o"
trap 'rm -f "$OBJ"' EXIT
echo "== vanilla-ext: building CT object =="
echo " CC : $CC ($($CC --version 2>/dev/null | head -1))"
echo " via : make -C security jacobian_ct.o (vanilla, NIX_HARDENING_ENABLE=\"\")"
if ! NIX_HARDENING_ENABLE="" make -C "$ROOT/security" jacobian_ct.o CC="$CC" >/dev/null; then
echo "vanilla-ext: FAIL — compile failed (make -C security jacobian_ct.o CC=$CC)" >&2
exit 1
fi
rc=0
# --- 1. CC-actually-took -----------------------------------------------------
# Read the compiler stamp from the ELF .comment string table (readelf -p prints
# it cleanly). gcc stamps "GCC: (…) X.Y.Z"; clang stamps "clang version X.Y.Z" —
# match either so a clang compiler-under-test isn't reported as a spurious FAIL.
cc_major="$("$CC" -dumpversion 2>/dev/null | cut -d. -f1)"
obj_ver="$(readelf -p .comment "$OBJ" 2>/dev/null | grep -oE '(GCC:|clang version)[^0-9]*[0-9]+\.[0-9]+(\.[0-9]+)?' | grep -oE '[0-9]+\.[0-9]+(\.[0-9]+)?' | head -1)"
obj_major="${obj_ver%%.*}"
if [ -n "$obj_major" ] && [ "$obj_major" = "$cc_major" ]; then
echo " [1] CC-took PASS — .comment $obj_ver matches CC major $cc_major"
else
echo " [1] CC-took FAIL — .comment '$obj_ver' (major '$obj_major') != CC major '$cc_major'" >&2
rc=1
fi
# --- 2. Assembly-invariant (branchlessness) ----------------------------------
if ruby "$CHECKER" "$OBJ"; then
echo " [2] CT-invariant PASS — ladder + jp_add_internal branchless"
else
echo " [2] CT-invariant FAIL — secret-dependent branch/cmov in CT codegen (see above)" >&2
rc=1
fi
echo "== vanilla-ext: $([ $rc -eq 0 ] && echo PASS || echo FAIL) (CC=$CC) =="
exit $rc
Bump → re-run workflow
- Update
inputs.nixpkgs.url/nix flake update— this changes the pinned compiler set.flake.lockis the release’s “known-good compilers” record. - Rebuild:
nix build .#iso, copy to the Ventoy stick. - Boot the reference machine, let the sweep run, collect the report from the stick.
- Update the
security.mdtiming table with the per-compiler, provenance-stamped figures.
Relationship to the manual runbook
The bare-metal timing runbook remains the “without Nix” fallback — the same measurement performed by hand on any quiet machine. The reference machine is the automated, pinned, multi-compiler version of exactly that procedure; use the runbook when you cannot boot the ISO, and the ISO when you can.