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-invariantsecurity/check-ct-assembly.rb: no secret-dependent je/jne/cmov in the ladder or jp_add_internal.
  • Stock equivalence — the invariant is run under both the pinned nix gccN and a stock distro gccN of the same major (nix/stock-attest.sh, in a gcc:<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

  1. Boot the box from the Ventoy stick → the quiet-machine state comes up (isolated core, pinned frequency, boost/SMT off, no network).
  2. A systemd oneshot (timing-gate.service) runs the gate automatically — no login.
  3. For each compiler in the set: rake clobber → build the extension at vanilla -O2 with gccN → verify the compiler took + the assembly-invariant → rspecctgrinddudect (N runs, pinned to the isolated core with taskset/chrt) → aggregate per operation.
  4. A single provenance-stamped report (CPU + microcode, kernel, per-compiler gcc --version, nixpkgs rev, source rev, achieved frequency) is written incrementally to the Ventoy partition, so an unplanned power loss loses only the in-flight compiler.
  5. 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 .#iso on 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

  1. Update inputs.nixpkgs.url / nix flake update — this changes the pinned compiler set. flake.lock is the release’s “known-good compilers” record.
  2. Rebuild: nix build .#iso, copy to the Ventoy stick.
  3. Boot the reference machine, let the sweep run, collect the report from the stick.
  4. Update the security.md timing 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.


This site uses Just the Docs, a documentation theme for Jekyll.