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 9062ed3): 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 <noreply@anthropic.com>
This commit is contained in:
igneum-labs 2026-10-05 20:46:18 +00:00
parent 70b95d5a64
commit 217d873df3
6 changed files with 184 additions and 56 deletions

View file

@ -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 <per-card dir>` 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 |

View file

@ -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

View file

@ -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() { # <manifest file> <sig file> -> 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 <manifest> <sig> [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 <dir> and verifies them; the manifest is then
# <dir>/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 <dir> and checks them (manifest_check); the manifest is then
# <dir>/igneum-app-latest.json. Fails closed. (Called in the current shell, never in $(...): the variables must
# reach the caller.)
fetch_manifest() { # <dir>
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 <file> <python expression over m>; prints "" when absent.
@ -244,14 +305,15 @@ card_labels_with_identities() { # <card> -> 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 <card> <pause|keep> <reason>" or "off <reason>"
local best="" best_mb=0 best_name="" line mb vendor card name mode
while read -r line; do

View file

@ -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)"

View file

@ -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

View file

@ -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"; }