From 79bf032ecb714124941ea423bea5bae7b6c73d2b Mon Sep 17 00:00:00 2001 From: igneum-labs <337424239+igneum-labs@users.noreply.github.com> Date: Mon, 5 Oct 2026 20:46:18 +0000 Subject: [PATCH] Rig: OTA key list with revocation (the app's ota-k2 form), prover gate at the 24 GB tier, prepare-ahead documented packaging/linux/bin/igneum-rig-lib.sh: OTA_PUBLIC_KEYS (K1, the empty K2 slot), manifest_check = the app's manifest::check (ota-k2 063b988): every embedded key not revoked is tried and the signer remembered, the manifest's revoked_keys except the signer's own are recorded in /var/lib/igneum/updates/revoked.json (igneum-revoked-keys/1), a signature only a revoked key verifies is refused as "manifest signature is by a revoked key (sha256:<8 hex>)", a corrupt record reads as empty; the installer and the hourly updater go through it. selftest.sh: the app's revocation_path cases on three throwaway keys. Prover gates from provedefault.rs at 440fd59 (bench-log "proving v1", the S_p curve): PROVER_MIN_VRAM_MB and PROVER_MINE_AND_PROVE_MB 23,552; the README table states each tier's consequence. README: the miner takes the prepare-ahead path (both shipped workers answer prepare 1; exit 42 only without prepare support or on a seed-mismatch fault), so a 10-minute epoch costs no restart. igneum-node.sh and selftest.sh without mapfile (bash 3.2 on the Mac). Co-Authored-By: Claude Fable 5.1 --- packaging/linux/README.md | 29 ++++- packaging/linux/bin/igneum-node.sh | 2 +- packaging/linux/bin/igneum-rig-lib.sh | 160 ++++++++++++++++++-------- packaging/linux/bin/igneum-update.sh | 2 +- packaging/linux/install-rig.sh | 6 +- packaging/linux/selftest.sh | 41 +++++++ 6 files changed, 184 insertions(+), 56 deletions(-) diff --git a/packaging/linux/README.md b/packaging/linux/README.md index 27c4b697d..c35d9e599 100644 --- a/packaging/linux/README.md +++ b/packaging/linux/README.md @@ -72,13 +72,37 @@ config in `/etc/igneum` (`rig.conf` 0640 root:igneum, `machine-id` 16 hex, `over `log-intake-key`), data in `/var/lib/igneum` (`node`, `packs`, `proving`, `updates`), runtime state in `/run/igneum`. Logs are journald only: `journalctl -fu igneum-miner@nvidia0`. +## Keys and revocation (the app's form, branch ota-k2) + +`bin/igneum-rig-lib.sh` embeds the same key list as `app/igneum-app/src/manifest.rs` on ota-k2 (K1 today, the K2 +slot empty until `tools/keys/keygen-k2.sh` fills it; an empty slot is skipped). `manifest_check` is the app's +`manifest::check`: the signature is tried against every embedded key that is not revoked and the signer is +remembered (`MANIFEST_SIGNER_FP8`, logged as `signed by sha256:<8 hex>`); after a manifest verifies, every entry of +its `revoked_keys` (64 lowercase hex, sha256 of the 32 raw key bytes; K1's is `8f186e37...`) except the signer's own +is appended to `/var/lib/igneum/updates/revoked.json` (`{"format":"igneum-revoked-keys/1","revoked":[{"fingerprint", +"by","manifest_version","at"}]}`), and from then on a signature only that key verifies is refused as "manifest +signature is by a revoked key (sha256:<8 hex>)". A key never revokes itself, a dead key cannot revoke another, a +corrupt record reads as empty. The `.sig` format is unchanged. Both the installer and the hourly updater go through +it. When the project lead makes K2, the one line to copy here is `OTA_PUBLIC_KEY_2_HEX`. + +## The program pack at an epoch change (prepare-ahead, exit 42 as the fallback) + +`igneum-miner.sh` passes both `--prepare-packs ` and `--exit-on-seed-change`, as the app does +(`engine.rs` 1198 and 1225). The miner exits 42 at a seed change only when the worker's ready line lacks `prepare 1` +(`igneum/miner/src/main.rs` 1087: `!prepare_support && exit_on_seed_change`); with prepare support it writes the next +pack ahead and the worker hot-swaps at the boundary. Both shipped workers answer `prepare 1`: `igneum-worker-cuda` +(`proto-cuda/nvrtc/worker.cpp` 21) and `igneum-worker-opencl` (`proto-opencl/host.c` 875; `--no-prepare` exists only +to test the fallback). So a 10-minute epoch (the ca2-epoch layer) costs no miner restart on the rig; exit 42 remains +for a worker without prepare and for the `WORKER FAULT seed mismatch` path (main.rs 1375), and the pack re-export +after it is once per rig, not once per card (the lock and the 60 s age check in `igneum-miner.sh`). + ## The per-card rules and what they mean | Rule | Value | Source | Consequence per tier | |---|---|---|---| | Identities per card | 8 for 8 GiB or more (or unknown), else 2; `IDENTITIES=N` overrides | `app/igneum-app/src/detect.rs` (proving-v1), the app's rule; the HiveOS README's "8 for a big card, 2 for a small one" now has its threshold | an 8 GB card runs 8 vote keys; a 6 GB card 2 (fewer blue blocks per key, the same hashrate) | -| Prover on by default | NVIDIA card of the 16 GB tier or more (`PROVER_MIN_VRAM_MB` 15,872, that is 16,384 minus the 512 MB slack the app's old 12 GB gate used), the biggest card proves; `PROVER=on` or `off` wins | the proving agent's memory sweep of 5 October 2026 (job memsweep-pc2-pv1: 13.9 GB for an empty shard, 28.3 GB for a full prototype shard on sp1-gpu-server 6.8.1, no SP1 knob moves the floor), which set `provedefault.rs` on proving-v1 to 16 GB prove-only and 20 GB mine-and-prove; the v1-budget shard (about 7 M cycles) is measured next and may move both | 8 and 12 GB: mine only (a 12 GB card cannot prove on this build, miner paused or not). 16 GB: on, with the miner paused per shard. 24 and 32 GB: on, mining and proving at once. AMD and Intel: off (no CUDA prover) | -| Mine and prove on one card | 20,480 MB or more; under it the card's miner stops for each shard (`PROVER_PAUSE_MINER=auto`; `always` and `never` force it) | the same sweep (15.6 GB for the miner and the prover together was the earlier bench-log figure, "Step 1, the prover default and its cost") | a 16 GB card loses its hashrate for the shard's duration (10.9 s of proving on a 5090 for one shard, bench-log; the pause is the whole export-cut-prove-sign round, longer); a 24 GB card loses nothing | +| Prover on by default | NVIDIA card with 23,552 MB or more (the 24 GB tier; the app's `MIN_VRAM_MB_MINING` and `MIN_VRAM_MB_PROVE_ONLY`), the biggest card proves; `PROVER=on` or `off` wins | `app/igneum-app/src/provedefault.rs` at 440fd59; bench-log "proving v1", the S_p curve on the RTX 5090 with SP1 6.8.1's GPU prover (the proving agent's final tier numbers, 5 October 2026) | 32 GB: mines and proves today (the prototype shard 28,307 MiB alone, 30,039 beside the miner). 24 GB: proves the adopted v1 shard (20,434 MiB alone, about 22.1 GB beside the miner, approximate) from the fee switch at DAA 210,000, nothing before it (the prover waits; a proof that runs out of memory fails and the shard is left). 16 GB: off, holds only an empty shard (13,874 MiB alone, 15,670 beside the miner). 12 GB and under: off, mines only. AMD and Intel: off (no CUDA prover) | +| Mine and prove on one card | the same 23,552 MB line, so by default a proving card keeps mining; `PROVER_PAUSE_MINER=always` stops the card's miner for each shard (kept for a smaller prover build, if one is measured) | the same source | a 24 GB card loses no hashrate while it proves the v1 shard (22.1 GB together, approximate); the pause path is unexercised by default | | RAM | 8 GB to mine, 16 GB to prove (warning, not failure) | 2.3 GB inside WSL2 on PC 1 (5 October 2026), the rest approximate | a 4 to 8 GB rig board mines; proving on it is a warning until measured on bare Linux | | SP1 GPU server | downloaded by the SDK on the first `SP1_PROVER=cuda` run into the igneum user's `~/.sp1/bin` (home is `/var/lib/igneum`) | `proving/windows-wsl2/setup-wsl.sh` (134 MB for v6.8.1; 251 MB reported for CUDA 12.8; both approximate) | the first proof waits for the download; the 20 GB free-disk check covers it | | Sync wait | 3600 s cap, miners start at `synced=true` | PC 1 under WSL2, 5 October 2026: miners started during IBD rebuilt their pack on every epoch seed move | a fresh rig mines 5 to 8 min after the node starts on the devnet (runs 2 to 4 of the HiveOS test); longer on a bigger chain | @@ -94,6 +118,7 @@ Logs are journald only: `journalctl -fu igneum-miner@nvidia0`. | `check-units.sh`: the 6 unit files against the directive lists of systemd.unit/service/timer/exec(5), enumerated values, Exec paths against `bin/`, template `%i`, cross-references | 6 checked, 0 failures; `systemd-analyze verify` is NOT available here (no systemd, no Docker) and runs on the rig through the same script | | `igneum-gpus.sh` on a fake sysfs tree: 2 NVIDIA, 2 AMD, 1 Intel Arc, plus an AMD APU, an Intel iGPU, an ASPEED BMC VGA and a NIC | 5 cards in PCI order per vendor, the four non-cards excluded with a note each | | the identities rule, the vote-key labels, the prover rule (unknown VRAM off, `PROVER=on` picks nvidia0 and pauses), the OpenCL index mapping on a fake `--list` (amd1 to 1, intel0 to 2, intel1 to 3, amd2 to none) | as designed | +| the key list and revocation on three throwaway Ed25519 keys (K1, an empty slot, K2): a K1 manifest verifies with the signer named, an unknown key is refused, K1 listing itself is ignored, a K2 manifest listing K1 writes `revoked.json` in the app's shape, K1 is then refused by name, a dead K1 cannot revoke K2 back, no duplicate record, a corrupt record reads as empty | as the app's `revocation_path` test | | the LIVE signed manifest: fetched, Ed25519 verified with the OTA public key through OpenSSL 3.6.4 (`pkeyutl -rawin`, the Ubuntu path) and through python3 cryptography (the fallback); a tampered copy and a flipped signature refused by both | version 0.3.9, override with the four fields | | `install-rig.sh --dry-run`: the preflight (fails here, as expected), the manifest, the sidecar entry, the 24,179,978-byte `igneum-hive-0.3.9.tar.gz` downloaded and checked against the sidecar sha256 `7a58a30f...` and the size, the four binaries and `override-params.json` listed, the glibc floors read, then 31 printed steps (user, folders, machine id, release, symlink, override file, rig.conf, key, scripts, units, sudoers, enable, start) | nothing under / touched | | `relay/test/parse.test.mjs` with the `linux` labels | 6 tests pass | diff --git a/packaging/linux/bin/igneum-node.sh b/packaging/linux/bin/igneum-node.sh index f784fb5af..68f113fdc 100755 --- a/packaging/linux/bin/igneum-node.sh +++ b/packaging/linux/bin/igneum-node.sh @@ -14,7 +14,7 @@ override=() if [[ -s "$IGNEUM_ETC/override-params.json" ]]; then override+=("--override-params-file=$IGNEUM_ETC/override-params.json") elif [[ -s "$IGNEUM_ROOT/current/override-params.json" ]]; then override+=("--override-params-file=$IGNEUM_ROOT/current/override-params.json"); log "using the package's override-params.json (no verified copy in $IGNEUM_ETC yet)" else log "no consensus override file; the node runs the binary's defaults (fine for a fresh testnet, refused by the devnet)"; fi -mapfile -t peers < <(node_peer_args) +peers=(); while read -r p; do peers+=("$p"); done < <(node_peer_args) log "igneumd $NODE_FLAG, data $IGNEUM_VAR/node, RPC 127.0.0.1:$RPC_PORT, EVM RPC 127.0.0.1:$EVM_PORT, P2P 0.0.0.0:$P2P_PORT, peers ${peers[*]#--addpeer=}${override[0]:+, override $(tr -d ' \n' < "${override[0]#--override-params-file=}")}" exec "$BIN/igneumd" "$NODE_FLAG" "--appdir=$IGNEUM_VAR/node" "--rpclisten=127.0.0.1:$RPC_PORT" "--evm-rpclisten=127.0.0.1:$EVM_PORT" \ "--listen=0.0.0.0:$P2P_PORT" "${peers[@]}" "${override[@]}" --nodnsseed --disable-upnp --nologfiles --yes diff --git a/packaging/linux/bin/igneum-rig-lib.sh b/packaging/linux/bin/igneum-rig-lib.sh index 722146ef7..5438a6cf8 100755 --- a/packaging/linux/bin/igneum-rig-lib.sh +++ b/packaging/linux/bin/igneum-rig-lib.sh @@ -11,9 +11,17 @@ IGNEUM_VAR="${IGNEUM_VAR:-/var/lib/igneum}" # node/ (chain data), packs IGNEUM_RUN="${IGNEUM_RUN:-/run/igneum}" # prover.state, update.deferred, pack.lock, telemetry samples IGNEUM_USER="${IGNEUM_USER:-igneum}" -# The OTA public key, the constant OTA_PUBLIC_KEY_HEX in app/igneum-app/src/manifest.rs (4 October 2026). The apps -# verify the manifest bytes with it before parsing; the rig does the same. A rotated key is a new copy of this file. +# The OTA public keys, the list OTA_PUBLIC_KEYS in app/igneum-app/src/manifest.rs (branch ota-k2, c722579, +# 5 October 2026; docs/security/keys.md section 4): K1 signs everything today, K2 is empty until the project lead makes it with +# tools/keys/keygen-k2.sh, and the build that embeds it fills the second slot here too (an empty slot is skipped). +# A signature verifies when any embedded key that is not revoked verifies it. A verified manifest's revoked_keys +# (sha256 of the 32 raw key bytes, hex; K1's is 8f186e37b48cfddf52d2b37478c562d78a74236fc87f0a675393ed6a35baac4e) +# are recorded in $IGNEUM_VAR/updates/revoked.json (the app's shape, igneum-revoked-keys/1); a key never revokes +# itself, and a signature by a revoked key is refused by name from then on. The .sig format is unchanged. OTA_PUBLIC_KEY_HEX="b3c9c5bd144e9d246dc0edf897387d4f3f7ca494cd47457123d1c2f892cabddd" +OTA_PUBLIC_KEY_2_HEX="" +OTA_PUBLIC_KEYS=("$OTA_PUBLIC_KEY_HEX" "$OTA_PUBLIC_KEY_2_HEX") +REVOKED_FILE="${IGNEUM_REVOKED_FILE:-$IGNEUM_VAR/updates/revoked.json}" DL_HOST="${IGNEUM_DL_HOST:-https://dl.igneum.network}" MANIFEST_URL="$DL_HOST/dl/public/igneum-app-latest.json" # packaging/ota/publish-public.sh writes it, signed DOWNLOADS_URL="$DL_HOST/dl/public/igneum-downloads.json" # the unsigned index the site reads (same script) @@ -26,7 +34,6 @@ DEVNET_SEED="188.245.5.161:26611" TESTNET_SEEDS="seed1.testnet.igneum.network:26811,seed2.testnet.igneum.network:26811,seed3.testnet.igneum.network:26811" RIG_CONF="$IGNEUM_ETC/rig.conf" -MANIFEST_VERIFIER="" # set by verify_manifest_signature: which tool checked the signature ts() { date -u +%Y-%m-%dT%H:%M:%SZ; } log() { printf '%s %s\n' "$(ts)" "$*"; } @@ -38,7 +45,7 @@ have() { command -v "$1" >/dev/null 2>&1; } # rig.conf is KEY=VALUE, written once by install-rig.sh. Defaults first, then the file, then the derived values. load_conf() { WALLET=""; RIG_NAME="rig"; NETWORK="devnet"; PEERS=""; DEV_FEE=1; IDENTITIES=auto; VOTE=1; EXTRA="" - PROVER="auto"; PROVER_MIN_VRAM_MB=15872; PROVER_MINE_AND_PROVE_MB=20480; PROVER_PAUSE_MINER=auto; PROVER_CARD="" + PROVER="auto"; PROVER_MIN_VRAM_MB=23552; PROVER_MINE_AND_PROVE_MB=23552; PROVER_PAUSE_MINER=auto; PROVER_CARD="" SYNC_WAIT=3600; TELEMETRY_SECS=5 RELAY_INTAKE_URL=""; RELAY_KEY_FILE="$IGNEUM_ETC/log-intake-key"; PACKAGE_SOURCE="signed" if [[ -f "$RIG_CONF" ]]; then @@ -69,50 +76,104 @@ load_conf() { installed_version() { cat "$IGNEUM_ROOT/current/version" 2>/dev/null || echo none; } # ---- the signed manifest ---------------------------------------------------------------------------------------- -# Ed25519 over the manifest bytes with the OTA public key: OpenSSL 3 (Ubuntu 24.04 ships 3.0.13) through pkeyutl -rawin, -# else python3's cryptography module. Both must be absent for the check to fail closed; it never skips. -verify_manifest_signature() { # -> 0 when the signature verifies - local m="$1" s="$2" sig_hex tmp rc=1 - sig_hex="$(tr -d '[:space:]' < "$s" 2>/dev/null)" - [[ "$sig_hex" =~ ^[0-9a-fA-F]{128}$ ]] || { warn "manifest signature is not 64 bytes of hex"; return 1; } - tmp="$(mktemp -d)" - python3 - "$OTA_PUBLIC_KEY_HEX" "$sig_hex" "$tmp" <<'PY' || { rm -rf "$tmp"; return 1; } -import sys, base64, binascii -key, sig, tmp = sys.argv[1:4] -raw = binascii.unhexlify(key) -assert len(raw) == 32, "public key is not 32 bytes" -der = bytes.fromhex("302a300506032b6570032100") + raw # SubjectPublicKeyInfo for Ed25519 (RFC 8410) -open(tmp + "/pub.pem", "w").write("-----BEGIN PUBLIC KEY-----\n" + base64.b64encode(der).decode() + "\n-----END PUBLIC KEY-----\n") -open(tmp + "/sig.bin", "wb").write(binascii.unhexlify(sig)) -PY - if have openssl && openssl pkeyutl -help 2>&1 | grep -q -- '-rawin'; then - if openssl pkeyutl -verify -pubin -inkey "$tmp/pub.pem" -rawin -in "$m" -sigfile "$tmp/sig.bin" >/dev/null 2>&1; then rc=0; else rc=1; fi - MANIFEST_VERIFIER="openssl $(openssl version 2>/dev/null | awk '{print $2}')" - elif python3 -c 'import cryptography' 2>/dev/null; then - if python3 - "$OTA_PUBLIC_KEY_HEX" "$m" "$tmp/sig.bin" <<'PY' 2>/dev/null; then rc=0; else rc=1; fi -import sys, binascii -from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey -k = Ed25519PublicKey.from_public_bytes(binascii.unhexlify(sys.argv[1])) -k.verify(open(sys.argv[3], "rb").read(), open(sys.argv[2], "rb").read()) -PY - MANIFEST_VERIFIER="python3 cryptography" - else - warn "no Ed25519 verifier: openssl 3 (pkeyutl -rawin) or python3-cryptography is needed" - MANIFEST_VERIFIER="none" - fi - rm -rf "$tmp" - return $rc +# manifest_check [revoked.json]: the app's `manifest::check` (ota-k2): verify the bytes with the +# trusted keys (OTA_PUBLIC_KEYS minus the revoked record), name a revoked key that would have verified, then record +# the manifest's revoked_keys except the signer's own. Ed25519 through OpenSSL 3 (pkeyutl -rawin, Ubuntu 24.04's +# 3.0.13) or python3's cryptography module; both absent = fail closed. Sets MANIFEST_VERIFIER, MANIFEST_SIGNER_FP8 +# and MANIFEST_NEWLY_REVOKED (space-separated fingerprints); returns 0 when the signature verifies. +MANIFEST_VERIFIER=""; MANIFEST_SIGNER_FP8=""; MANIFEST_NEWLY_REVOKED="" +manifest_check() { + local m="$1" s="$2" rev="${3:-$REVOKED_FILE}" out + out="$(python3 - "$m" "$s" "$rev" "$(date +%s)" "${OTA_PUBLIC_KEYS[@]}" <<'PYCHECK' +import base64, binascii, hashlib, json, os, subprocess, sys, tempfile +manifest, sigfile, revfile, now = sys.argv[1:5]; keys = [k.strip().lower() for k in sys.argv[5:]] +def fail(msg): print("ERR " + msg); sys.exit(0) +try: sig_hex = open(sigfile).read().strip() +except OSError: fail("no signature file") +if len(sig_hex) != 128 or any(c not in "0123456789abcdefABCDEF" for c in sig_hex): fail("signature is not 64 bytes of hex") +sig = binascii.unhexlify(sig_hex); data = open(manifest, "rb").read() +def is_key(k): return len(k) == 64 and all(c in "0123456789abcdef" for c in k) +def is_fp(f): return len(f) == 64 and f == f.lower() and all(c in "0123456789abcdef" for c in f) +def fp(k): return hashlib.sha256(binascii.unhexlify(k)).hexdigest() +embedded = [k for k in keys if is_key(k)] +if not embedded: fail("no usable public key to verify with") +# the revocation record: a corrupt file reads as empty (the next revoking manifest writes it again) +revoked = [] +try: + for e in json.load(open(revfile)).get("revoked", []): + f = str(e.get("fingerprint", "")) + if is_fp(f) and f not in [r["fingerprint"] for r in revoked]: + revoked.append({"fingerprint": f, "by": str(e.get("by", "")), "manifest_version": str(e.get("manifest_version", "")), "at": int(e.get("at", 0) or 0)}) +except (OSError, ValueError, AttributeError, TypeError): revoked = [] +dead_fps = {r["fingerprint"] for r in revoked} +trusted = [k for k in embedded if fp(k) not in dead_fps]; dead = [k for k in embedded if fp(k) in dead_fps] +# the verifier: openssl 3 when its pkeyutl has -rawin, else cryptography +verifier = None +try: + h = subprocess.run(["openssl", "pkeyutl", "-help"], capture_output=True) + if b"-rawin" in h.stdout + h.stderr: + verifier = "openssl " + subprocess.run(["openssl", "version"], capture_output=True, text=True).stdout.split()[1] +except (OSError, IndexError): pass +if verifier is None: + try: + from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey; verifier = "python3 cryptography" + except ImportError: fail("no Ed25519 verifier: openssl 3 (pkeyutl -rawin) or python3-cryptography is needed") +def verifies(k): + raw = binascii.unhexlify(k) + if verifier.startswith("openssl"): + d = tempfile.mkdtemp() + try: + der = bytes.fromhex("302a300506032b6570032100") + raw # SubjectPublicKeyInfo for Ed25519, RFC 8410 + open(d + "/pub.pem", "w").write("-----BEGIN PUBLIC KEY-----\n" + base64.b64encode(der).decode() + "\n-----END PUBLIC KEY-----\n") + open(d + "/sig.bin", "wb").write(sig) + return subprocess.run(["openssl", "pkeyutl", "-verify", "-pubin", "-inkey", d + "/pub.pem", "-rawin", "-in", manifest, "-sigfile", d + "/sig.bin"], capture_output=True).returncode == 0 + finally: + for f in ("pub.pem", "sig.bin"): + try: os.remove(d + "/" + f) + except OSError: pass + os.rmdir(d) + try: Ed25519PublicKey.from_public_bytes(raw).verify(sig, data); return True + except Exception: return False +signer = next((k for k in trusted if verifies(k)), None) +if signer is None: + bad = next((k for k in dead if verifies(k)), None) + if bad: fail("manifest signature is by a revoked key (sha256:%s) [%s]" % (fp(bad)[:8], verifier)) + fail("manifest signature does not verify [%s]" % verifier) +own = fp(signer); newly = [] +try: m = json.loads(data.decode("utf-8")) +except ValueError: fail("manifest is not JSON") +for f in m.get("revoked_keys", []) or []: + f = str(f) + if not is_fp(f): fail("revoked_keys: an entry is not a lowercase sha256 fingerprint") + if f == own or f in dead_fps or f in newly: continue + newly.append(f); revoked.append({"fingerprint": f, "by": own, "manifest_version": str(m.get("version", "")), "at": int(now)}) +if newly: + os.makedirs(os.path.dirname(revfile) or ".", exist_ok=True) + tmp = revfile + ".new" + open(tmp, "w").write(json.dumps({"format": "igneum-revoked-keys/1", "revoked": [{"at": r["at"], "by": r["by"], "fingerprint": r["fingerprint"], "manifest_version": r["manifest_version"]} for r in revoked]}, sort_keys=True)) + os.replace(tmp, revfile) +print("OK %s %s %s" % (own[:8], verifier.replace(" ", "_"), " ".join(newly))) +PYCHECK +)" || { warn "manifest check did not run"; return 1; } + case "$out" in + OK*) read -r _ MANIFEST_SIGNER_FP8 MANIFEST_VERIFIER MANIFEST_NEWLY_REVOKED <<< "$out"; MANIFEST_VERIFIER="${MANIFEST_VERIFIER//_/ }" + [[ -n "$MANIFEST_NEWLY_REVOKED" ]] && log "update check: manifest $(manifest_field "$m" 'm.get("version")') signed by sha256:$MANIFEST_SIGNER_FP8 revokes signing key(s) $MANIFEST_NEWLY_REVOKED; recorded in $rev" + return 0 ;; + *) warn "${out#ERR }"; return 1 ;; + esac } +# The old name, kept for callers that only want yes or no. +verify_manifest_signature() { manifest_check "$1" "$2" "${3:-$REVOKED_FILE}"; } -# Fetches igneum-app-latest.json and its .sig into and verifies them; the manifest is then -# /igneum-app-latest.json and MANIFEST_VERIFIER names the tool that checked it. Fails closed. (Called in the -# current shell, never in $(...): the variable must reach the caller.) +# Fetches igneum-app-latest.json and its .sig into and checks them (manifest_check); the manifest is then +# /igneum-app-latest.json. Fails closed. (Called in the current shell, never in $(...): the variables must +# reach the caller.) fetch_manifest() { # local dir="$1" mkdir -p "$dir" curl -fsSL --max-time 30 -H 'Cache-Control: no-cache' -o "$dir/igneum-app-latest.json" "$MANIFEST_URL" || { warn "cannot fetch $MANIFEST_URL"; return 1; } curl -fsSL --max-time 30 -H 'Cache-Control: no-cache' -o "$dir/igneum-app-latest.json.sig" "$MANIFEST_URL.sig" || { warn "cannot fetch $MANIFEST_URL.sig"; return 1; } - verify_manifest_signature "$dir/igneum-app-latest.json" "$dir/igneum-app-latest.json.sig" || { warn "manifest signature does not verify with the OTA public key"; return 1; } + manifest_check "$dir/igneum-app-latest.json" "$dir/igneum-app-latest.json.sig" || { warn "manifest signature does not verify with the OTA public keys"; return 1; } } # One field of a verified manifest. manifest_field ; prints "" when absent. @@ -244,14 +305,15 @@ card_labels_with_identities() { # -> one label per vote key else printf '%s\n' "$base"; fi } -# The prover default (app/igneum-app/src/provedefault.rs on branch proving-v1, as the proving agent's memory sweep -# of 5 October 2026 set it, job memsweep-pc2-pv1: 13.9 GB for an empty shard and 28.3 GB for a full prototype shard -# on sp1-gpu-server 6.8.1, no SP1 knob moves the floor): on for an NVIDIA card of the 16 GB tier or more -# (PROVER_MIN_VRAM_MB 15,872 = 16,384 minus the same 512 MB slack the app's 12 GB gate used; a 12 GB card mines -# only), off otherwise; the biggest qualifying card proves. A card at PROVER_MINE_AND_PROVE_MB (20,480, the 20 GB -# line: 24 GB and up) or more mines and proves at once; a 16 GB card has its miner paused for each shard -# (PROVER_PAUSE_MINER=auto; always|never force it). The v1-budget shard measurement may move both numbers again. -# PROVER=on|off in rig.conf wins over auto. +# The prover default (app/igneum-app/src/provedefault.rs at 440fd59, 5 October 2026; bench-log "proving v1", the +# S_p curve on the RTX 5090 with SP1 6.8.1's GPU prover): on for an NVIDIA card with MIN_VRAM_MB 23,552 (the 24 GB +# tier) or more, the biggest one proves; off below with the reason. The numbers: an empty shard 13,874 MiB alone and +# 15,670 beside the miner, so a 16 GB card holds no full shard; the adopted v1 shard (30,000 pgas) 20,434 MiB alone +# and about 22.1 GB beside the miner, so a 24 GB card mines and proves it from the fee switch at DAA 210,000; the +# prototype shard before the switch 28,307 MiB alone and 30,039 beside the miner, so until then only a 32 GB card +# proves it and a 24 GB card's prover waits (a proof that runs out of memory fails and the shard is left). +# PROVER_MINE_AND_PROVE_MB is the same line, so the pause-per-shard path runs only with PROVER_PAUSE_MINER=always +# (kept for a smaller prover build, if one is measured). PROVER=on|off in rig.conf wins over auto. prover_decision() { # prints "on " or "off " local best="" best_mb=0 best_name="" line mb vendor card name mode while read -r line; do diff --git a/packaging/linux/bin/igneum-update.sh b/packaging/linux/bin/igneum-update.sh index be9d44562..2860d365e 100755 --- a/packaging/linux/bin/igneum-update.sh +++ b/packaging/linux/bin/igneum-update.sh @@ -18,7 +18,7 @@ DEFER="$IGNEUM_RUN/update.deferred" dir="$(mktemp -d)"; trap 'rm -rf "$dir"' EXIT fetch_manifest "$dir" || { log "update check: manifest unavailable or unverified; nothing changes"; exit 0; } m="$dir/igneum-app-latest.json" -log "update check: manifest $(manifest_field "$m" 'm.get("version")') ($MANIFEST_VERIFIER), published $(manifest_field "$m" 'm.get("published_at")')" +log "update check: manifest $(manifest_field "$m" 'm.get("version")') signed by sha256:$MANIFEST_SIGNER_FP8 ($MANIFEST_VERIFIER), published $(manifest_field "$m" 'm.get("published_at")')" safe_moment() { local st; st="$(cat "$IGNEUM_RUN/prover.state" 2>/dev/null || echo idle)" diff --git a/packaging/linux/install-rig.sh b/packaging/linux/install-rig.sh index fcf42e0ec..c95af9d1b 100755 --- a/packaging/linux/install-rig.sh +++ b/packaging/linux/install-rig.sh @@ -165,7 +165,7 @@ step "signed manifest ($MANIFEST_URL)" WORK="$(mktemp -d)"; trap 'rm -rf "$WORK"' EXIT if fetch_manifest "$WORK"; then m="$WORK/igneum-app-latest.json" - ok "signature verifies with the OTA public key ${OTA_PUBLIC_KEY_HEX:0:16}... ($MANIFEST_VERIFIER); version $(manifest_field "$m" 'm.get("version")'), published $(manifest_field "$m" 'm.get("published_at")'), channel $(manifest_field "$m" 'm.get("channel")')" + ok "signature verifies with embedded OTA key sha256:$MANIFEST_SIGNER_FP8 ($MANIFEST_VERIFIER; revocation record $REVOKED_FILE); version $(manifest_field "$m" 'm.get("version")'), published $(manifest_field "$m" 'm.get("published_at")'), channel $(manifest_field "$m" 'm.get("channel")')" OVERRIDE="$(manifest_override "$m")" if [[ -n "$OVERRIDE" ]]; then ok "consensus.override $OVERRIDE (the node's --override-params-file; refreshed hourly by igneum-update)"; else soft "the manifest carries no consensus.override (a fresh testnet needs none; the devnet refuses a node without it)"; fi ENTRY="$(manifest_package "$m")" @@ -244,8 +244,8 @@ IDENTITIES=$IDENT_ARG VOTE=1 EXTRA= PROVER=$PROVER_ARG -PROVER_MIN_VRAM_MB=15872 -PROVER_MINE_AND_PROVE_MB=20480 +PROVER_MIN_VRAM_MB=23552 +PROVER_MINE_AND_PROVE_MB=23552 PROVER_PAUSE_MINER=auto PROVER_CARD= SYNC_WAIT=3600 diff --git a/packaging/linux/selftest.sh b/packaging/linux/selftest.sh index f1c3e5f75..fd234604c 100755 --- a/packaging/linux/selftest.sh +++ b/packaging/linux/selftest.sh @@ -93,6 +93,47 @@ printf '%s' "$(tr -d '[:space:]' < "$manifest.sig" | sed 's/^./0/')" > "$T/live/ if verify_manifest_signature "$manifest" "$T/live/bad.sig"; then fail "a bad signature verified"; else pass "a bad signature is refused"; fi if [[ -z "$(manifest_package "$manifest")" ]]; then pass "the manifest names no linux package today (sidecar mode needed, as documented)"; else pass "the manifest names a linux package: $(manifest_package "$manifest")"; fi +echo "== key list and revocation (throwaway keys; the app's manifest::tests::revocation_path)" +K="$T/keys"; mkdir -p "$K" +python3 - "$K" <<'PYKEYS' || fail "throwaway keys need python3 cryptography" +import json, sys, hashlib +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from cryptography.hazmat.primitives import serialization +d = sys.argv[1]; raw = serialization.Encoding.Raw; pub = serialization.PublicFormat.Raw +ks = [Ed25519PrivateKey.from_private_bytes(bytes([i] * 32)) for i in (1, 2, 3)] +pubs = [k.public_key().public_bytes(raw, pub).hex() for k in ks] +open(d + "/pubs", "w").write("\n".join(pubs) + "\n") +fps = [hashlib.sha256(bytes.fromhex(p)).hexdigest() for p in pubs] +open(d + "/fps", "w").write("\n".join(fps) + "\n") +def sign(name, signer, body): + data = json.dumps(body, sort_keys=True, separators=(",", ":")).encode() + open(d + "/" + name, "wb").write(data); open(d + "/" + name + ".sig", "w").write(ks[signer].sign(data).hex()) +base = {"version": "0.9.9", "channel": "test", "platforms": {}} +sign("k1.json", 0, base) # a plain K1 manifest +sign("k2-revokes-k1.json", 1, dict(base, revoked_keys=[fps[0]])) # K2 revokes K1 +sign("k1-self.json", 0, dict(base, revoked_keys=[fps[0]])) # K1 lists itself: ignored +sign("k3.json", 2, base) # an unknown key +sign("k1-revokes-k2.json", 0, dict(base, revoked_keys=[fps[1]])) # a dead K1 tries to revoke K2 back +PYKEYS +PUBS=(); FPS=(); while read -r l; do PUBS+=("$l"); done < "$K/pubs"; while read -r l; do FPS+=("$l"); done < "$K/fps" +OTA_PUBLIC_KEYS=("${PUBS[0]}" "" "${PUBS[1]}") # K1, an empty slot, K2 +REV="$K/revoked.json" +manifest_check "$K/k1.json" "$K/k1.json.sig" "$REV" || fail "a K1 manifest verifies"; [[ "$MANIFEST_SIGNER_FP8" == "${FPS[0]:0:8}" && ! -f "$REV" ]] || fail "K1 named, no record written" +pass "a K1 manifest verifies (signer sha256:$MANIFEST_SIGNER_FP8, $MANIFEST_VERIFIER), the empty slot skipped, nothing recorded" +manifest_check "$K/k3.json" "$K/k3.json.sig" "$REV" 2>/dev/null && fail "an unknown key verified"; pass "an unknown key's manifest is refused" +manifest_check "$K/k1-self.json" "$K/k1-self.json.sig" "$REV" || fail "K1 listing itself"; [[ -z "$MANIFEST_NEWLY_REVOKED" && ! -f "$REV" ]] || fail "a key must never revoke itself" +pass "K1 listing its own fingerprint is ignored (a manifest can never leave the rig with no trusted key)" +manifest_check "$K/k2-revokes-k1.json" "$K/k2-revokes-k1.json.sig" "$REV" || fail "K2 revoking K1"; [[ "$MANIFEST_NEWLY_REVOKED" == "${FPS[0]}" ]] || fail "K1's fingerprint recorded" +python3 -c 'import json,sys; r=json.load(open(sys.argv[1])); assert r["format"]=="igneum-revoked-keys/1" and r["revoked"][0]["fingerprint"]==sys.argv[2] and r["revoked"][0]["by"]==sys.argv[3] and r["revoked"][0]["manifest_version"]=="0.9.9" and r["revoked"][0]["at"]>0, r' "$REV" "${FPS[0]}" "${FPS[1]}" || fail "revoked.json shape" +pass "a K2 manifest listing K1 records it in revoked.json (fingerprint, by, manifest_version, at)" +err="$(manifest_check "$K/k1.json" "$K/k1.json.sig" "$REV" 2>&1 >/dev/null)" && fail "a revoked K1 verified"; [[ "$err" == *"manifest signature is by a revoked key (sha256:${FPS[0]:0:8})"* ]] || fail "revoked key named: $err" +pass "K1 is then refused by name: ${err#* WARNING: }" +err="$(manifest_check "$K/k1-revokes-k2.json" "$K/k1-revokes-k2.json.sig" "$REV" 2>&1 >/dev/null)" && fail "a dead K1 revoked K2"; manifest_check "$K/k2-revokes-k1.json" "$K/k2-revokes-k1.json.sig" "$REV" || fail "K2 still verifies" +[[ "$(python3 -c 'import json,sys; print(len(json.load(open(sys.argv[1]))["revoked"]))' "$REV")" == 1 ]] || fail "no duplicate record" +pass "a dead K1 cannot revoke K2 back; K2 still verifies; the record is not duplicated" +printf 'garbage' > "$REV"; manifest_check "$K/k1.json" "$K/k1.json.sig" "$REV" || fail "corrupt record reads as empty"; pass "a corrupt revoked.json reads as empty (the next revoking manifest writes it again)" +OTA_PUBLIC_KEYS=("$OTA_PUBLIC_KEY_HEX" "$OTA_PUBLIC_KEY_2_HEX") + echo "== install-rig.sh --dry-run (downloads and verifies the live package into a scratch folder)" IGNEUM_ROOT="$T/opt" IGNEUM_ETC="$T/etc2" IGNEUM_VAR="$T/var" IGNEUM_RUN="$T/run" "$HERE/install-rig.sh" --dry-run --yes --wallet 0xDFAEA60000000000000000000000000000002C2E --name "rig 1" --allow-sidecar-sha256 --prover auto > "$T/dry.out" 2>&1 || { cat "$T/dry.out"; fail "dry run exited non-zero"; } grep -q 'downloaded and verified: igneum-hive-' "$T/dry.out" || { cat "$T/dry.out"; fail "no verified download in the dry run"; }