From addeb660fdcd75c4db655c1518a0c9f3f1e9ee06 Mon Sep 17 00:00:00 2001 From: igneum-labs <337424239+igneum-labs@users.noreply.github.com> Date: Mon, 5 Oct 2026 17:01:01 +0000 Subject: [PATCH 1/2] Key custody: inventory, encrypted backup and restore, no-secrets CI check docs/security/keys.md: every key the project depends on (the folder, the gh keyring, the Vercel env of three projects, the GitHub secrets) with where it lives, what it unlocks, the blast radius lost and leaked, who rotates it and the rotation status, written from the files and the scripts that read them. No value, no private fingerprint. Section 4: the second OTA signing key kept offline, the app change (a key list plus revocation in the manifest), 0.3.9 as the carrier, and the emergency path if the one key leaks today (a manifest signed with a new key is useless to 0.3.x apps; the mitigation in order). tools/keys/backup.sh: ~/Desktop/igneum-keys-.dmg, AES-256, hdiutil's own prompt (never argv, history or a file), the folder minus build-slots, dlsite-dir and pytools/, plus a README; attached read-only, every file compared by sha256, listed, detached. --dry-run lists. restore.sh: --check compares the image against the live folder without printing values, --to copies back with 0600/0644 and 0700. test-backup.sh: the end-to-end test on a scratch folder with a throwaway passphrase, 8 steps, passed. tools/ci/no-secrets-check.sh, in ci.yml: no tracked file named like a key of ~/.config/igneum, no 64-hex value assigned to a token/key/secret name outside tests and the allowlist (the OTA public key, the published Hardhat and Anvil accounts); a --self-test fires on a known-bad tree first. 776 files, 0 hits. Also: ~/.config/igneum, vercel/ and txgen/ are 0700 now (were 0755). Co-Authored-By: Claude Fable 5.1 --- .github/workflows/ci.yml | 8 +- docs/security/keys.md | 129 ++++++++++++++++++++++++ tools/ci/no-secrets-check.sh | 97 ++++++++++++++++++ tools/keys/backup.sh | 190 +++++++++++++++++++++++++++++++++++ tools/keys/restore.sh | 116 +++++++++++++++++++++ tools/keys/test-backup.sh | 80 +++++++++++++++ 6 files changed, 618 insertions(+), 2 deletions(-) create mode 100644 docs/security/keys.md create mode 100755 tools/ci/no-secrets-check.sh create mode 100755 tools/keys/backup.sh create mode 100755 tools/keys/restore.sh create mode 100755 tools/keys/test-backup.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9d37d7dc5..6d937a66b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,8 +1,10 @@ # CI on every push and pull request (private repository, free runner minutes). # # What runs: the lottery-hash crate's tests (igneum-pow, release profile), the census tool's build, the two Python -# simulators' --quick modes (each under two minutes), the site build with an internal link check, and the gh-free -# identity grep of the public export list (tools/ci/forbidden-strings.txt). +# simulators' --quick modes (each under two minutes), the site build with an internal link check, the gh-free +# identity grep of the public export list (tools/ci/forbidden-strings.txt), and the no-secrets check of the tree +# (tools/ci/no-secrets-check.sh: no file named like a key of ~/.config/igneum, no 64-hex value assigned to a +# token/key/secret name outside tests and the allowlist; docs/security/keys.md). # # What does not run, on purpose: the node fork (vendor/igneum-node*, a rusty-kaspa fork of about 500 crates with # rocksdb, blst and the execution layer) is gitignored here and too big for the free runners today (a cold build is @@ -65,6 +67,8 @@ jobs: run: bash tools/ci/copied-sources-check.sh - name: pinned guest programs match their manifest and are built only by pin-guests.sh run: bash tools/ci/pinned-guests-check.sh + - name: no secret file names and no 64-hex secrets in the tree (self-test first, then the tree) + run: bash tools/ci/no-secrets-check.sh --self-test && bash tools/ci/no-secrets-check.sh - name: faucet unit tests (validation, the daily limits, the signed transaction; keccak, RLP and secp256k1 vectors) run: node --test site/api/faucet.test.mjs - name: ship tool self-test (version bump, the dl-both and public manifest helpers) diff --git a/docs/security/keys.md b/docs/security/keys.md new file mode 100644 index 000000000..67c029f52 --- /dev/null +++ b/docs/security/keys.md @@ -0,0 +1,129 @@ +# Keys: inventory, backup, the signing-key plan (5 October 2026) + +Internal. Every key the project depends on sat unencrypted in `~/.config/igneum` on one Mac with no backup. This file is +the inventory written from the real files and the scripts that read them, the backup and restore commands, the plan for +a second OTA signing key, and the emergency path if the one key leaks. No value is written here. The only fingerprint +quoted is the public key's. Owner of every rotation below: the project lead, unless a row says otherwise. + +Modes read on 5 October 2026 18:50 UTC: every secret file 0600; the folder itself was 0755 and is 0700 since this branch +(`vercel/` and `txgen/` too). `ota-signing-key.pub`, `build-slots` and `vercel/config.json` (team ids, no token) are 0644, +which is fine. + +## 1. The inventory + +Column "lost" = the Mac dies and there is no backup. Column "leaked" = someone else holds the value. + +| Name | Where it lives | What it unlocks (readers) | If lost | If leaked | Rotate: who, how | Rotation status | +|---|---|---|---|---|---|---| +| `ota-signing-key` (Ed25519 seed, 32 bytes as hex, 65 B, 0600, made 4 Oct 12:32) | the Mac only. Never in CI, never in the repo. Its public half is compiled into every app (`app/igneum-app/src/manifest.rs:28`, `OTA_PUBLIC_KEY_HEX`, fingerprint sha256 `8f186e37b48cfddf52d2b37478c562d78a74236fc87f0a675393ed6a35baac4e`) and on the Mac as `ota-signing-key.pub` | signs the update manifest `igneum-app-latest.json` (`packaging/ota/publish-manifest.sh`, `publish-public.sh`, `tools/ship-app.mjs`), the remote jobs file `igneum-jobs.json` (`publish-jobs.sh`, `tools/jobs.mjs`; `kind: run` jobs execute on every app machine, `jobrun.rs:800`) and the Windows build inputs `payload-inputs.json` (`packaging/windows/push-inputs.sh`, verified in CI with the embedded key, `windows.yml:172`). Verified by `ota.rs:1030`, `jobrun.rs:800-811`, `igneum-ota-sign verify-inputs embedded` | no new manifest, job or Windows build can be signed. Every installed 0.3.x keeps checking hourly and never updates again. The only way forward is a hand reinstall by every miner of a build that embeds a new key: the fleet is stranded | whoever holds it signs a manifest or a jobs file the apps accept. With the manifest URL (the folder token is in every app) and a way to serve it (the Vercel token, or DNS) they run code on every machine | the project lead only. No fleet rotation path exists today (section 4): `igneum-ota-sign keygen`, change `OTA_PUBLIC_KEY_HEX`, ship, and every machine must apply that build first | never rotated; one key; backup = this branch | +| `ota-signing-key.pub` (65 B, 0644) | the Mac; the same bytes in the app | the local check of every publish (`publish-manifest.sh`, `ship-app.mjs:563`, `test-publish-jobs.sh`) | nothing: `igneum-ota-sign embedded` prints it from any app build | nothing, it is public | with the private key | with the private key | +| `relay-token` (28 B, 0600, 4 Oct 19:23) | the Mac; `RELAY_TOKEN` on Vercel `igneum-relay`; baked into the relay clients on PC 1 and PC 2 (`relay/clients/make-clients.sh:8-12`, `igneum-agent.ps1`); the phone bookmark | the relay URL `/r//`: read and post the feed, the drop box, and `POST task kind: run` on the PCs with only this token (`docs/fud-ledger.md` X23/X24, still open). Readers: `tools/relay.mjs`, `console.mjs`, `build-job.mjs`, `ship-app.mjs`, `packaging/ota/publish-jobs.sh` | generate a new one, set the env, rebuild the clients, redistribute to the PCs and the phone; nothing is unrecoverable | elevated command execution on PC 1 and PC 2, and every file on the relay | the project lead: new value into the file, `vercel env rm/add RELAY_TOKEN production --scope igneum`, deploy, `make-clients.sh`, install on both PCs, delete the old | rotated 4 Oct 2026 (the `.old-2026-10-04` copy is dead and can go) | +| `relay-key` (48 B, 0600, 4 Oct 19:23) | the Mac; `RELAY_KEY` on `igneum-relay`; in the relay clients on the PCs | the same relay, as the `x-igneum-key` header for scripts (`relay/lib/relay.mjs:34-44`; its own key since round 4 X23, no longer the intake key) | as the token | as the token | as the token | new 4 Oct 2026 | +| `dl-token` (11 B, 0600, 4 Oct 19:25; new value since the 5 Oct rotation) | the Mac; `DL_TOKEN` GitHub secret (the Windows runner writes it to `~/.config/igneum/dl-token`, `windows.yml:147`); `DL_TOKEN` on `igneum-relay` (the console); in every installed app's manifest URL (0.3.6 and later; 0.3.5 and older carry the old one) | the private downloads folder `dl//` on dl.igneum.network: installers, the manifest, the jobs file, the CI inputs. Readers: `packaging/mac/build-dmg.sh`, `packaged-config.sh`, `packaging/ota/*.sh`, `packaging/windows/*.sh`, `tools/ship-app.mjs`, `logs.mjs`, `jobs.mjs`, `console.mjs`, `build-job.mjs`, `relay.mjs`, `app/igneum-app/src/config.rs` | the folder name is in every installed app's `igneum-app.json` and in the GitHub secret's consumer; recoverable from any install | the installers and the signed files are readable (the signature still guards what the app accepts); low | the project lead, by `docs/plans/rotation-phase-2.md` (a second folder, a build that carries the new token, delete the old folder when `tools/logs.mjs --rotation` reads 0 behind) | rotated 5 Oct 2026; the old folder dies on 7 Oct (8f) | +| `dl-token.old-2026-10-05` (12 B) | the Mac; still the folder name 0.3.5 and older apps read; in 2 commits of the history (masked on master; `tools/repo/fresh-repo.sh` rewrites it) | the OLD folder until 8f | nothing | as above, low | delete on 12 Oct per 8f step 6, after the fresh repository is pushed (the rewrite reads it) | dying | +| `log-intake-key` (32 B, 0600, 4 Oct 19:25; the NEW value since 5 Oct) | the Mac; Vercel `igneum` as `LOG_INTAKE_KEY_NEXT` (new) and `LOG_INTAKE_KEY` (old) until 8f; the same pair on `igneum-relay`; GitHub secrets `LOG_INTAKE_KEY_NEXT` (new) and `LOG_INTAKE_KEY` (old); in every installed app 0.3.6 and later (`igneum-app.json`); PC build scripts via `IGNEUM_INTAKE_KEY` | `POST /api/log` on the site and `fn=upload` on the relay (`site/api/log.mjs:45`, `relay/lib/relay.mjs:44`): write-only telemetry. Readers: `packaging/mac/packaged-config.sh`, `infra/gpu-bench/upload.sh`, `tools/build-job.mjs`, `logs.mjs`, `ship-app.mjs`, `repo/fresh-repo.sh`, `app/igneum-app/src/config.rs` | Vercel and GitHub keep it write-only (Hidden), so a new key must be generated and repackaged (phase 2 again) | junk rows in Neon and junk uploads to Blob; no read; low | the project lead, by phase 2 | rotated 4 to 5 Oct 2026; the old key dies on 7 Oct (8f) | +| `log-intake-key.old-2026-10-05` (24 B) | the Mac; the 0.3.0 to 0.3.5 installs and the 0.2.0 launcher machines; in 9 commits of the history (0 tracked files on master; `fresh-repo.sh` rewrites it) | the intake, until 8f | nothing | low (write-only) | delete on 12 Oct per 8f step 6 | dying | +| `hetzner-token` (64 B, 0600, 3 Oct 22:43) | the Mac only | the Hetzner Cloud API: create and delete servers (`infra/seed-nodes/config.sh:29`, `infra/cloud-devnet/lib/common.sh:25-27`): the seed nodes and the cloud devnet, on the project lead's bill | make a new one in the Hetzner console; the servers stay | servers created on the bill, the seed nodes and the devnet deleted, every server listed | the project lead: Hetzner console, Security, API tokens: new token, write the file, delete the old | never rotated | +| `desec-token` (28 B, 0600, 3 Oct 19:34) | the Mac only | the deSEC DNS API for the igneum.network zone (`infra/seed-nodes/dns.sh:4-11`; the relay CNAME lives there, `relay/README.md:73`). CLAUDE.md says the domains sit on Vercel nameservers (3 Oct); `dns.sh` is the later file. Approximate until the project lead confirms which nameservers answer today | make a new one at deSEC | the zone: point dl, relay or the site anywhere, get a certificate for it, serve a fake manifest (the signature still guards the apps) and a fake site; high | the project lead: deSEC, token management | never rotated | +| `dev-fee-devnet.json` (249 B, 0600; purpose, address, private_key) | the Mac only | the devnet (chain 4463) funder for `tools/txgen/run.mjs` (`--funder`, line 57). Devnet coins only | devnet funds; regenerate | devnet coins; nothing real | any time: a new wallet, fund it on the devnet | none needed | +| `dev-fee-release.json` (234 B, 0600; an address only) | the Mac | `DEV_FEE_ADDRESS`, the project lead's payout address from the Igneum Wallet (`docs/design/miner-dev-fee.md:50`). No private key here: the key is in the Igneum Wallet on the project lead's Mac, outside this folder and outside this backup | the address is in the fork's `release-0.3.6` source | nothing, an address is public | not a secret. The wallet's own key needs its own backup (open) | n/a | +| `txgen/wallets.json` (3,090 B, 0600; 16 devnet wallets with keys) | the Mac only | the devnet load generator (`tools/txgen/run.mjs:56`) | regenerate | devnet coins; nothing real | any time | none needed | +| `vercel/auth.json` (397 B, 0600; token, refreshToken, expiresAt) and `vercel/config.json` (team ids, 0644) | the Mac only (`--global-config ~/.config/igneum/vercel`) | the `igneum` team: projects `igneum` (site), `igneum-dl` (downloads), `igneum-relay`; deploys, env vars (add, remove, pull the non-sensitive ones), domains. Readers: `packaging/ota/*.sh`, `packaging/windows/*.sh`, `tools/ship-app.mjs` | `vercel login` again as the igneum.network Google login; nothing unrecoverable | deploy anything to the three hosts, including a fake manifest at the real URL (still unsigned without the OTA key), read `BLOB_READ_WRITE_TOKEN`, delete projects; high | the project lead: Vercel, Settings, Tokens: revoke; `vercel logout`/`login`. The access token expires (it is OAuth with a refresh token; 5 Oct: expiry the same evening) | expires on its own; the refresh token does not | +| `env` (406 B, 0600; `DATABASE_URL`, `DATABASE_URL_UNPOOLED`) | the Mac; `DATABASE_URL` on all of `igneum` and `igneum-relay` | Neon `igneum` (soft-voice-31914738, London): telemetry rows, faucet grants, the relay feed, tasks and wake stamps, the jobs ledger. Readers: `tools/build-job.mjs`, `jobs.mjs`, `logs.mjs`, `tuning.mjs`, `observer/observer.mjs`, `infra/cloud-devnet/experiments/observer.sh`, `site/api/*.mjs`, `relay/lib/relay.mjs` | Neon console, reset the role password; nothing unrecoverable | read every upload and relay message; write rows, including relay tasks the PC agents poll and run; high | the project lead: Neon, reset password, then the file and both projects' `DATABASE_URL`, redeploy | never rotated | +| `build-slots`, `dlsite-dir`, `pytools/` | the Mac | a build cap, a local folder path, a pip copy of git-filter-repo | nothing | nothing | not secrets; excluded from the backup | n/a | +| GitHub tokens: `igneum-labs` (admin:org, repo, workflow) and `[second-owner-login]` (gist, read:org, repo, workflow) | the macOS keychain through `gh` (`gh auth status`), not in this folder | the organisation igneum-network and the repository: push, Actions, the repository secrets, the Windows runner | `gh auth login` again | push to master (the site deploys on push), rewrite secrets, run workflows that receive `DL_TOKEN` and the intake key; the runner never holds the signing key, so no release can be signed from it; high | the project lead: GitHub, Settings, Applications, revoke GitHub CLI; `gh auth login` | never rotated | +| GitHub repository secrets `DL_TOKEN`, `LOG_INTAKE_KEY`, `LOG_INTAKE_KEY_NEXT` | GitHub, write-only copies of the files above | the Windows build (`windows.yml:132-152`) | re-set from the files (`gh secret set`) | as the files | with the files; 8f step 3 drops `_NEXT` | in rotation | +| Vercel env `igneum`: `FAUCET_KEY` (two environments), `LOG_INTAKE_KEY`, `LOG_INTAKE_KEY_NEXT`, `DATABASE_URL` | Vercel, Hidden (not readable back) | `FAUCET_KEY` is the faucet wallet's private key (`site/api/faucet.mjs:2`): it exists ONLY on Vercel, not on the Mac, so it is not in this backup | the faucet wallet's funds are unreachable. Devnet today (chain 4463), so nothing real; the public testnet (4462) "gets its own key": generate THAT one on the Mac into `~/.config/igneum/faucet-testnet.json` first, then `vercel env add` from the file, so the backup covers it | the faucet's balance; a testnet drain | the project lead: new wallet, `vercel env rm/add`, move the balance | file-first rule for the testnet key (open) | +| Vercel env `igneum-relay`: `DL_TOKEN`, `LOG_INTAKE_KEY`, `LOG_INTAKE_KEY_NEXT`, `RELAY_KEY`, `RELAY_TOKEN`, `DATABASE_URL`, `BLOB_READ_WRITE_TOKEN` | Vercel. All Hidden except `BLOB_READ_WRITE_TOKEN`, which is a plain variable (`vercel env ls` prints its prefix and `vercel env pull` fetches it) | the Blob store of the relay (files, build outputs from the PCs) | regenerate in the Vercel dashboard (Storage, the Blob store, tokens) | read, write and delete every relay file; medium | the project lead: dashboard; re-add as Sensitive (`vercel env add BLOB_READ_WRITE_TOKEN production --sensitive`) so it stops being readable | recommend: make it Sensitive | +| Vercel env `igneum-dl` | none | the downloads host deploys from the folder; nothing secret in env | | | | n/a | + +Copies that live outside the Mac and are not in the backup: the relay token and key inside the client scripts on PC 1, +PC 2 and the phone; the intake key and the folder token inside every installed app's `igneum-app.json`; the dated old +values in `~/igneum-dl-old-20261005` (folder files, not keys). None of them is needed to rebuild the Mac. + +## 2. The backup and the restore + +``` +tools/keys/backup.sh --dry-run # what would go in: names, modes, sizes; creates nothing +tools/keys/backup.sh # ~/Desktop/igneum-keys-.dmg, AES-256, hdiutil's own passphrase prompt + # (twice: create, then the verify attach); verified by sha256, listed, detached +tools/keys/restore.sh --check # every file in the image against ~/.config/igneum: match / DIFFERS / missing +tools/keys/restore.sh --to # copy back (0700 dirs, 0600 files, .pub 0644), refuses to overwrite without --force +tools/keys/test-backup.sh # the end-to-end test on a scratch folder with a throwaway passphrase +``` + +The image holds every file of `~/.config/igneum` except `build-slots`, `dlsite-dir` and `pytools/`, plus `README.txt` +(the listing and a copy of this file). The passphrase never passes through argv, the shell history or a file: +`hdiutil` prompts on the terminal (`--agent` for the macOS dialog). `--stdinpass` exists for the test harness only. + +What the project lead does with the image: two copies on two media that are not this Mac (a USB stick at home and a second stick +or an encrypted cloud folder), the Desktop copy deleted, the passphrase on paper away from both media. Run it again +after every rotation and replace both copies; keep one older image. `restore.sh --check` after each run. + +Test record, 5 October 2026: `tools/keys/test-backup.sh` passed all 8 steps (dry run lists 16 files and creates +nothing; create and verify report 17 files byte-identical; `--list`; `--check` matches; a changed live file makes +`--check` fail and name the file; `--to` restores 16 files with 0600/0644 and 0700, refuses a second run without +`--force`; a wrong passphrase is refused; the raw image bytes do not contain the test values). The real folder was +run in `--dry-run` only. + +## 3. What is exposed today, and what was done + +| Finding | Fix | +|---|---| +| One copy of every key, on one disk, unencrypted | this branch: the encrypted image and the two-media rule (section 2) | +| `~/.config/igneum` was 0755 (listable by any local user; the files themselves were 0600) | `chmod 700` on the folder, `vercel/` and `txgen/` (done 5 Oct) | +| The old intake key sits in 9 commits of the history and the old folder token in 2 (0 tracked files on master) | known: `tools/repo/fresh-repo.sh` rewrites both after 8f; the fresh repository plan (`docs/plans/history-rewrite.md`). Not changed here | +| `BLOB_READ_WRITE_TOKEN` on `igneum-relay` is a plain env var, readable by anyone with project access | recommend: re-add as Sensitive (section 1) | +| `FAUCET_KEY` exists only on Vercel, write-only, no copy anywhere | recommend: the testnet faucet key is generated on the Mac into the folder first, then set from the file | +| `relay-token.old-2026-10-04` is a dead value still on disk | recommend: `rm -P` it (nothing reads it; `fresh-repo.sh` reads only the intake and dl files) | +| The dev-fee payout key lives in the Igneum Wallet, outside this folder and this backup | open: the wallet's own backup | +| Published Hardhat and Anvil developer keys in `tools/evm-smoke/smoke.mjs` and `tools/exec-attacks/lib/common.mjs` | public test vectors, allowlisted in the CI check; never fund those addresses on testnet 4462 or mainnet | +| Nothing in CI, no token in any script: every script reads a file under `~/.config/igneum` or an env var (checked: `git grep` of every file name above and of the current values, 0 hits in tracked files) | `tools/ci/no-secrets-check.sh` keeps it so (section 5) | + +## 4. The OTA signing key: today, the second key, the emergency path + +Today. One Ed25519 key signs three things: the update manifest, the jobs file and the Windows build inputs. Its public +half is one constant, `OTA_PUBLIC_KEY_HEX`, read at `ota.rs:1030`, `jobrun.rs:168,800,811` and by `igneum-ota-sign +embedded|verify-inputs`. Every app in the field trusts that one key and nothing else: on 5 October 2026 18:40 UTC the +intake shows 0.3.8 (one Mac), 0.3.7 (one Windows PC), 0.3.5 (Sam's Mac) and three machines without a version line +(`node tools/logs.mjs --rotation`). The signature file is 64 raw bytes as 128 hex, no key id. + +The second key, as the next step (one release, about two hours of work). + +| Step | What | +|---|---| +| 1 | `igneum-ota-sign keygen` a second key (K2) on the Mac with the output directed INTO a mounted `igneum-keys` image, so its private half exists only inside the encrypted copies; keep `ota-signing-key-2.pub` on disk. The current key is K1 | +| 2 | `manifest.rs`: `OTA_PUBLIC_KEYS: &[&str] = &[K1, K2]`; `verify_signature` tries each key in turn (two Ed25519 verifies, microseconds); `fingerprint()` of each; `embedded` prints both. `ota.rs`, `jobrun.rs`, `jobs.rs`, `inputs.rs` and `ota-sign.rs` take the slice instead of the constant. Tests: a manifest signed by K2 verifies, by a third key fails, the existing K1 vectors still pass. The `.sig` format does not change, so 0.3.x apps keep verifying K1 signatures of the same files | +| 3 | The same release carries revocation: the manifest gains an optional `revoked_keys: []`, signed like the rest. An app that verifies a manifest with the OTHER key and sees its current key listed writes `updates/revoked.json` and refuses that key from then on. Without this, a leaked K1 stays trusted forever (step 2 alone covers loss, not leak) | +| 4 | Ship it as 0.3.9, signed with K1, so every 0.3.5 to 0.3.8 machine takes it on its hourly check. From then on the publisher keeps signing with K1; K2 stays offline. `tools/logs.mjs --rotation` gains a column for the embedded fingerprints the app logs (it already logs `fingerprint` at `jobrun.rs:168`) | +| 5 | When every machine reports 0.3.9 or later, K2 is live as the fallback: K1 lost = mount the image, sign with K2, ship; K1 leaked = sign the next manifest with K2 with K1 in `revoked_keys` | + +If K1 leaks TODAY, before 0.3.9: a manifest signed with any new key is useless. Every 0.3.x app verifies with K1 only +(`ota.rs:1030`), rejects the new signature ("manifest signature does not verify"), stays on its version and keeps +fetching the same URL every hour, where it will accept anything K1 signed. Plainly: the fleet cannot be moved to a new +key by the update path, and it stays open to whoever holds K1 for as long as that URL serves. The mitigation, in order: + +| Order | Action | Effect | +|---|---|---| +| 1 | Take the manifest and the jobs file off the folder (`dl//igneum-app-latest.json`, `.sig`, `igneum-jobs.json`, `.sig`): a deploy of the downloads folder without them | the apps read 404 as "no manifest at the update URL yet" and stay put; the attacker's signed manifest has nowhere to be served unless they also hold the Vercel token or the DNS | +| 2 | Rotate the folder token (phase 2 again) and the Vercel token, the deSEC token, the Neon password, the relay token and key, the GitHub tokens: one leaked file from this folder means the folder was read | the old URL dies; the attacker cannot place a file at the new one | +| 3 | Build 0.3.9 (section 4 steps 1 to 3, with K1 NOT in the trusted list) and sign its manifest with K1, once, into the NEW folder; publish it before anything else goes there | every machine that applies it stops trusting K1. The race is real: a machine that fetched an attacker's K1 manifest first is owned; `logs.mjs` shows which machines moved | +| 4 | Machines that did not move (asleep, offline, or owned) are reinstalled by hand from the public links (`dl.igneum.network/public/`, `packaging/README-ship.md`) | the only path for an app that never saw step 3 | + +Until 0.3.9 ships, the K1 file is the single point of failure in both directions, and this branch's backup covers the +loss direction only. The leak direction is covered by step 3 of the plan, and by keeping K1 off this disk: the publish +scripts read `~/.config/igneum/ota-signing-key` by path, so the file can become a symlink into a mounted image that is +attached only for the minutes of a publish (a follow-up, not done here; `publish-manifest.sh:36`, `ship-app.mjs:305`). + +## 5. The CI check + +`tools/ci/no-secrets-check.sh` runs in `ci.yml` (site job) after a `--self-test` that must fire on a known-bad tree +(a tracked `ota-signing-key`, a `dl-token.old-`, an `igneum-app.json`, `FAUCET_KEY=0x<64 hex>`, `signingKey = +"<64 hex>"`, `x-igneum-key: <64 hex>`) and stay quiet on a known-good one (a sha256 next to another word, a 32-hex id, +the public key in `manifest.rs`, a `.test.mjs` vector). Over the tree it refuses any tracked file named like a key of +`~/.config/igneum` (with `.next` and `.old-` variants, `*.env`, `.env*`, a bare `env`, `auth.json`, +`wallets.json`, `igneum-relay-clients.zip`, `igneum-log-key.txt`) and any 64-hex value (optionally `0x`) assigned to a +name ending in token, key, secret, password or passphrase, outside test files, `proving/fixtures/`, +`infra/cloud-devnet/results/` and `*.log`. Allowlisted by path with the reason in the script: the OTA public key, and +the published Hardhat and Anvil accounts in the devnet tools. 5 October 2026: self-test passed, 776 files checked, +0 hits. Hits are printed with the hex masked. diff --git a/tools/ci/no-secrets-check.sh b/tools/ci/no-secrets-check.sh new file mode 100755 index 000000000..1f44b3df8 --- /dev/null +++ b/tools/ci/no-secrets-check.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash +# No secret in the tree, for CI (ci.yml) and for a pre-push look on the Mac. +# +# Two checks over the tracked files (git ls-files; the working tree when not in a git checkout): +# 1. file NAMES: nothing tracked may be named like a file of ~/.config/igneum (ota-signing-key, relay-token, +# relay-key, dl-token, log-intake-key, hetzner-token, desec-token, dev-fee-*.json, wallets.json, vercel auth.json, +# their .next and .old- variants), nor igneum-app.json (the packaged config carries the intake key), +# igneum-log-key.txt, igneum-relay-clients.zip (the relay token and key baked in), *.env, .env*, a bare `env`. +# 2. file CONTENTS: a 64-hex string (optionally 0x-prefixed) assigned to a name ending in token, key, secret, +# password or passphrase (`KEY = "<64 hex>"`, `token: <64 hex>`, `x-igneum-key: <64 hex>`), case-insensitive, +# in non-test files. Skipped: files with `test` in the name, proving/fixtures/, infra/cloud-devnet/results/, +# *.log, vendor/, node_modules/, target/. Allowlisted by path (ALLOW below, each with its reason): the OTA +# public key in the app (public by design) and the published Hardhat/Anvil developer accounts the devnet tools +# use (public test vectors; never fund them on a real network). +# +# tools/ci/no-secrets-check.sh # exit 1 on any hit, hits printed with the hex masked +# tools/ci/no-secrets-check.sh --self-test # the name rule and the content rule must fire on a known-bad case and +# # stay quiet on a known-good one (the gate rule of CLAUDE.md) +set -euo pipefail +HERE="$(cd "$(dirname "$0")" && pwd)" +REPO="$(cd "$HERE/../.." && pwd)" + +# 1. forbidden basenames (grep -E, anchored on the basename) +NAME_RULES='^(ota-signing-key|relay-token|relay-key|dl-token|log-intake-key|hetzner-token|desec-token)(\.next|\.old-[0-9-]+)?$|^(dev-fee-devnet|dev-fee-release|wallets|auth|igneum-app)\.json$|^igneum-log-key\.txt$|^igneum-relay-clients\.zip$|\.env$|^\.env|^env$|\.pem$|^id_(rsa|ed25519)$' +# the public counterpart is fine +NAME_ALLOW='^ota-signing-key\.pub$' + +# 2. a 64-hex value assigned to a secret-looking name +HEX='(0x)?[0-9a-fA-F]{64}([^0-9a-fA-F]|$)' +CONTENT_RULE="(token|key|secret|password|passphrase)[\"']?[[:space:]]*[:=][[:space:]]*[\"']?${HEX}" +# paths that may carry such a line, with the reason +ALLOW=( + 'app/igneum-app/src/manifest.rs' # OTA_PUBLIC_KEY_HEX: the public half of the signing key, compiled into every app + 'site/api/faucet.test.mjs' # Anvil developer account 0, a published test vector + 'tools/exec-attacks/lib/common.mjs' # Hardhat/Anvil developer accounts 1, 4, 5, 15, 16: published, devnet 4463 only + 'tools/evm-smoke/smoke.mjs' # the same published accounts +) +SKIP_PATH='(^|/)(vendor|node_modules|target|tests?|proving/fixtures|infra/cloud-devnet/results)(/|$)|\.log$' +is_test_name() { # a basename with "test" in it (test-publish-jobs.sh, faucet.test.mjs, notices.test.mjs), not "testnet" + local b="${1##*/}"; b="${b//testnet/}"; case "$b" in *test*) return 0 ;; *) return 1 ;; esac +} + +list_files() { + if git -C "$REPO" rev-parse --is-inside-work-tree >/dev/null 2>&1; then git -C "$REPO" ls-files; else (cd "$REPO" && find . -type f | sed 's#^\./##'); fi +} + +run_checks() { # $1 = root, reads the file list on stdin; prints hits, returns 1 on any + local root="$1" bad=0 f base + local -a content_files=() + while IFS= read -r f; do + [ -n "$f" ] || continue + base="${f##*/}" + if printf '%s' "$base" | grep -qE "$NAME_RULES" && ! printf '%s' "$base" | grep -qE "$NAME_ALLOW"; then + echo "secret file name tracked: $f"; bad=1 + fi + if ! printf '%s' "$f" | grep -qE "$SKIP_PATH" && ! is_test_name "$f"; then + local allowed=0 a; for a in "${ALLOW[@]}"; do [ "$f" = "$a" ] && allowed=1; done + [ $allowed -eq 0 ] && [ -f "$root/$f" ] && content_files+=("$f") + fi + done + if [ ${#content_files[@]} -gt 0 ]; then + local hits + hits="$(cd "$root" && printf '%s\n' "${content_files[@]}" | tr '\n' '\0' | xargs -0 grep -nIiE "$CONTENT_RULE" 2>/dev/null | sed -E 's/(0x)?[0-9a-fA-F]{64}/<64-hex>/g' | cut -c1-160 || true)" + if [ -n "$hits" ]; then echo "a 64-hex value next to token/key/secret:"; printf '%s\n' "$hits" | sed 's/^/ /'; bad=1; fi + fi + echo "checked ${#content_files[@]} files for contents" + return $bad +} + +if [ "${1:-}" = "--self-test" ]; then + T="$(mktemp -d)"; trap 'rm -rf "$T"' EXIT + mkdir -p "$T/good/src" "$T/bad/src" "$T/bad/cfg" + # a good tree: a hash next to an unrelated word, a 32-hex id, the public key in the allowlisted path, a test file + printf 'sha256 = "%s"\nlet id = "%s";\n' "$(printf 'a%.0s' $(seq 64))" "$(printf 'b%.0s' $(seq 32))" > "$T/good/src/ok.rs" + mkdir -p "$T/good/app/igneum-app/src"; printf 'pub const OTA_PUBLIC_KEY_HEX: &str = "%s";\n' "$(printf 'c%.0s' $(seq 64))" > "$T/good/app/igneum-app/src/manifest.rs" + printf 'const KEY = "0x%s";\n' "$(printf 'd%.0s' $(seq 64))" > "$T/good/src/vectors.test.mjs" + printf 'x\n' > "$T/good/src/ota-signing-key.pub" + # a bad tree: a tracked key file, and three content shapes + printf 'x\n' > "$T/bad/cfg/ota-signing-key"; printf 'x\n' > "$T/bad/cfg/dl-token.old-2026-10-05"; printf 'x\n' > "$T/bad/cfg/igneum-app.json" + printf 'FAUCET_KEY=0x%s\n' "$(printf 'e%.0s' $(seq 64))" > "$T/bad/src/a.sh" + printf 'const signingKey = "%s";\n' "$(printf 'f%.0s' $(seq 64))" > "$T/bad/src/b.mjs" + printf 'curl -H "x-igneum-key: %s"\n' "$(printf '0%.0s' $(seq 64))" > "$T/bad/src/c.md" + good_out="$( (cd "$T/good" && find . -type f | sed 's#^\./##') | run_checks "$T/good" 2>&1)" && good_rc=0 || good_rc=$? + bad_out="$( (cd "$T/bad" && find . -type f | sed 's#^\./##') | run_checks "$T/bad" 2>&1)" && bad_rc=0 || bad_rc=$? + echo "self-test good tree: rc $good_rc"; printf '%s\n' "$good_out" | sed 's/^/ /' + echo "self-test bad tree: rc $bad_rc"; printf '%s\n' "$bad_out" | sed 's/^/ /' + [ $good_rc -eq 0 ] || { echo "SELF-TEST FAILED: the good tree was flagged"; exit 1; } + [ $bad_rc -ne 0 ] || { echo "SELF-TEST FAILED: the bad tree passed"; exit 1; } + for want in 'cfg/ota-signing-key' 'cfg/dl-token.old-2026-10-05' 'cfg/igneum-app.json' 'src/a.sh' 'src/b.mjs' 'src/c.md'; do + printf '%s' "$bad_out" | grep -q "$want" || { echo "SELF-TEST FAILED: $want not reported"; exit 1; } + done + printf '%s' "$bad_out" | grep -qE '[0-9a-f]{64}' && { echo "SELF-TEST FAILED: a hex value was printed"; exit 1; } + echo "self-test passed: the name rule and the content rule fire on the bad tree and not on the good one" + exit 0 +fi + +if list_files | run_checks "$REPO"; then echo "no-secrets: 0 hits"; else echo "no-secrets: HITS (above)"; exit 1; fi diff --git a/tools/keys/backup.sh b/tools/keys/backup.sh new file mode 100755 index 000000000..2aedba477 --- /dev/null +++ b/tools/keys/backup.sh @@ -0,0 +1,190 @@ +#!/usr/bin/env bash +# Encrypted backup of ~/.config/igneum: one AES-256 disk image on the Desktop, verified, then unmounted. +# +# tools/keys/backup.sh # prompts for a passphrase on the terminal (hdiutil's own prompt, twice: +# # once to create, once to verify); nothing passes through argv, history or a file +# tools/keys/backup.sh --dry-run # lists what would go in, creates nothing +# tools/keys/backup.sh --agent # the passphrase through the macOS Security Agent dialog instead of the terminal +# +# What goes in: every file under ~/.config/igneum except build-slots, dlsite-dir (settings, not secrets) and pytools/ +# (a pip copy of git-filter-repo), with its mode and mtime, plus README.txt (the listing, no values) and the inventory +# docs/security/keys.md when this script runs from the repository. Output: ~/Desktop/igneum-keys-.dmg, +# read-only, compressed, AES-256 (hdiutil create -encryption AES-256 -format UDZO). An existing output is never +# overwritten. After the create the image is attached read-only at a private mount point, every file is compared by +# sha256 against the staged copy (counts and a match line, never a value), then detached. The staging folder is a +# 0700 mktemp directory, removed at exit. +# +# Test harness only (tools/keys/test-backup.sh): --stdinpass reads a NUL-terminated passphrase from standard input +# once and feeds it to both hdiutil calls. Never type a real passphrase through it. --source and --out point the +# script at a scratch folder and a scratch output. +# +# Values are never printed: this script prints names, sizes, modes and counts. +set -euo pipefail + +SRC="$HOME/.config/igneum" +OUT="" +DRY=0; MODE="tty" +EXCLUDE_NAMES=(build-slots dlsite-dir) # settings, not secrets +EXCLUDE_DIRS=(pytools) # a pip library (git-filter-repo), 210 KB, not a secret +HERE="$(cd "$(dirname "$0")" && pwd)" +REPO="$(cd "$HERE/../.." && pwd)" +INVENTORY="$REPO/docs/security/keys.md" + +usage() { sed -n '2,20p' "$0" | sed 's/^# \{0,1\}//'; exit 2; } +say() { printf '%s\n' "$*"; } +die() { printf 'backup: %s\n' "$*" >&2; exit 1; } + +while [ $# -gt 0 ]; do + case "$1" in + --dry-run) DRY=1 ;; + --agent) MODE="agent" ;; + --stdinpass) MODE="stdin" ;; + --source) SRC="${2:?--source needs a folder}"; shift ;; + --out) OUT="${2:?--out needs a file}"; shift ;; + -h|--help) usage ;; + *) die "unknown argument $1" ;; + esac + shift +done + +[ -d "$SRC" ] || die "no folder at $SRC" +DATE="$(date -u +%Y-%m-%d)" +[ -n "$OUT" ] || OUT="$HOME/Desktop/igneum-keys-$DATE.dmg" +case "$OUT" in *.dmg) ;; *) die "the output must end in .dmg" ;; esac +command -v hdiutil >/dev/null || die "hdiutil is not available (macOS only)" +command -v shasum >/dev/null || die "shasum is not available" + +# The file list: relative paths, sorted, excluding the non-secrets. Only regular files travel. +list_files() { + (cd "$SRC" && find . -type f -print | sed 's#^\./##' | LC_ALL=C sort) | while IFS= read -r rel; do + base="${rel##*/}"; top="${rel%%/*}" + skip=0 + for n in "${EXCLUDE_NAMES[@]}"; do [ "$rel" = "$n" ] && skip=1; done + for d in "${EXCLUDE_DIRS[@]}"; do [ "$top" = "$d" ] && [ "$top" != "$rel" ] && skip=1; done + [ "$base" = ".DS_Store" ] && skip=1 + [ $skip -eq 0 ] && printf '%s\n' "$rel" + done +} + +# One line per file: mode, size, mtime (UTC), name. No contents. +describe() { + local rel="$1" f="$SRC/$1" + printf '%s %8s bytes %s %s\n' "$(stat -f '%Sp' "$f")" "$(stat -f '%z' "$f")" "$(date -u -r "$(stat -f '%m' "$f")" +%Y-%m-%dT%H:%M:%SZ)" "$rel" +} + +FILES="$(list_files)" +COUNT="$(printf '%s\n' "$FILES" | grep -c . || true)" +[ "$COUNT" -gt 0 ] || die "nothing to back up under $SRC" + +say "source $SRC" +say "output $OUT" +say "excluded ${EXCLUDE_NAMES[*]} ${EXCLUDE_DIRS[*]}/ (not secrets)" +say "files $COUNT" +while IFS= read -r rel; do describe "$rel"; done <<< "$FILES" +WORLD="$(while IFS= read -r rel; do [[ "$(stat -f '%Sp' "$SRC/$rel")" == ???????r* ]] && printf '%s\n' "$rel"; done <<< "$FILES" | grep -v '\.pub$' || true)" +[ -z "$WORLD" ] || say "note world-readable (fine only for public files): $(printf '%s ' $WORLD)" + +if [ $DRY -eq 1 ]; then + say "dry run: nothing created. README.txt and $( [ -f "$INVENTORY" ] && echo "docs/security/keys.md" || echo "(no keys.md found)" ) would be added." + exit 0 +fi + +[ -e "$OUT" ] && die "$OUT exists; not overwriting a backup (move it or pick --out)" +mkdir -p "$(dirname "$OUT")" + +if [ "$MODE" = "tty" ] && [ ! -t 0 ]; then + die "no terminal for the passphrase prompt; run from a terminal, or --agent for the macOS dialog" +fi + +# The passphrase, test harness only: read once from standard input, NUL-terminated, kept in this process only. +PASS="" +if [ "$MODE" = "stdin" ]; then + IFS= read -r -d '' PASS || true + [ -n "$PASS" ] || die "--stdinpass: no passphrase on standard input" +fi + +STAGE="$(mktemp -d "${TMPDIR:-/tmp}/igneum-keys-stage.XXXXXX")" +chmod 700 "$STAGE" +MNT="$(mktemp -d "${TMPDIR:-/tmp}/igneum-keys-mnt.XXXXXX")" +MNT="$(cd "$MNT" && pwd -P)" # the physical path: $TMPDIR is a symlink on macOS and `mount` prints the real one +attached() { mount | grep -qF " on $MNT "; } +cleanup() { + if attached; then hdiutil detach "$MNT" -quiet >/dev/null 2>&1 || hdiutil detach "$MNT" -force -quiet >/dev/null 2>&1 || true; fi + attached || rm -rf "$MNT" + rm -rf "$STAGE" + PASS="" +} +trap cleanup EXIT + +# Stage: the files with their modes and mtimes. +while IFS= read -r rel; do + mkdir -p "$STAGE/$(dirname "$rel")" + cp -p "$SRC/$rel" "$STAGE/$rel" +done <<< "$FILES" +chmod -R u+rwX,go-rwx "$STAGE" + +# README.txt: the listing and the inventory, no values. +{ + echo "Igneum key backup, $(date -u +%Y-%m-%dT%H:%M:%SZ)" + echo "Source: $SRC on $(hostname -s)" + echo "Files ($COUNT), mode, size, mtime, name:" + while IFS= read -r rel; do describe "$rel"; done <<< "$FILES" + echo + echo "Restore: tools/keys/restore.sh --check (compares against the live folder, prints no values)" + echo " tools/keys/restore.sh --to ~/.config/igneum" + echo "Excluded on purpose: ${EXCLUDE_NAMES[*]} (settings) and ${EXCLUDE_DIRS[*]}/ (a pip library)." + if [ -f "$SRC/ota-signing-key.pub" ]; then + echo "OTA public key fingerprint (sha256 of the 32 raw bytes): $(python3 -c 'import hashlib,sys;print(hashlib.sha256(bytes.fromhex(open(sys.argv[1]).read().strip())).hexdigest())' "$SRC/ota-signing-key.pub" 2>/dev/null || echo unknown)" + fi + if [ -f "$INVENTORY" ]; then + echo; echo "----- docs/security/keys.md at $(git -C "$REPO" rev-parse --short HEAD 2>/dev/null || echo unknown) -----"; echo + cat "$INVENTORY" + fi +} > "$STAGE/README.txt" +chmod 600 "$STAGE/README.txt" + +# Create. The passphrase: hdiutil's own prompt (tty), the Security Agent (--agent), or the harness pipe (--stdinpass). +say "creating $OUT (AES-256, read-only, compressed)" +case "$MODE" in + tty) hdiutil create -srcfolder "$STAGE" -volname "igneum-keys-$DATE" -encryption AES-256 -format UDZO -quiet "$OUT" ;; + agent) hdiutil create -srcfolder "$STAGE" -volname "igneum-keys-$DATE" -encryption AES-256 -agentpass -format UDZO -quiet "$OUT" ;; + stdin) printf '%s\0' "$PASS" | hdiutil create -srcfolder "$STAGE" -volname "igneum-keys-$DATE" -encryption AES-256 -stdinpass -format UDZO -quiet "$OUT" ;; +esac +chmod 600 "$OUT" + +# Verify: attach read-only at a private mount point, compare every file by sha256 against the stage, detach. +say "verifying attaching read-only (the passphrase again)" +case "$MODE" in + tty) hdiutil attach "$OUT" -readonly -nobrowse -noautoopen -mountpoint "$MNT" -quiet ;; + agent) hdiutil attach "$OUT" -readonly -nobrowse -noautoopen -agentpass -mountpoint "$MNT" -quiet ;; + stdin) printf '%s\0' "$PASS" | hdiutil attach "$OUT" -readonly -nobrowse -noautoopen -stdinpass -mountpoint "$MNT" -quiet ;; +esac +PASS="" +attached || die "the image did not attach at $MNT; do not trust $OUT" +MOUNTED="$(cd "$MNT" && find . -type f ! -name .DS_Store -print | sed 's#^\./##' | LC_ALL=C sort)" +MCOUNT="$(printf '%s\n' "$MOUNTED" | grep -c . || true)" +say "mounted $MCOUNT files:" +while IFS= read -r rel; do printf ' %8s bytes %s\n' "$(stat -f '%z' "$MNT/$rel")" "$rel"; done <<< "$MOUNTED" +A="$(cd "$STAGE" && find . -type f -print | sed 's#^\./##' | LC_ALL=C sort | while IFS= read -r r; do shasum -a 256 "$r"; done)" +B="$(cd "$MNT" && find . -type f ! -name .DS_Store -print | sed 's#^\./##' | LC_ALL=C sort | while IFS= read -r r; do shasum -a 256 "$r"; done)" +if [ "$A" = "$B" ] && [ "$MCOUNT" -eq $((COUNT + 1)) ]; then + say "verified $MCOUNT files in the image are byte-identical to the staged copies ($COUNT secrets + README.txt)" +else + die "VERIFY FAILED: the image does not match the staged files (image $MCOUNT, expected $((COUNT + 1))); do not trust $OUT" +fi +hdiutil detach "$MNT" -quiet +attached && die "the image is still attached at $MNT; detach it by hand (hdiutil detach)" +say "detached" + +cat < --check compares the image to the live folder without printing values. +EOF diff --git a/tools/keys/restore.sh b/tools/keys/restore.sh new file mode 100755 index 000000000..3dc3f552a --- /dev/null +++ b/tools/keys/restore.sh @@ -0,0 +1,116 @@ +#!/usr/bin/env bash +# The reverse of backup.sh: open an igneum-keys-.dmg and either check it against the live folder or copy its +# files back. +# +# tools/keys/restore.sh --check # every file in the image against ~/.config/igneum, by sha256; +# # prints match / DIFFERS / missing, never a value; exit 1 on any difference +# tools/keys/restore.sh --to # copies the files into (0700 dirs, 0600 files; .pub 0644), +# # refuses to overwrite an existing file unless --force, then runs the check +# tools/keys/restore.sh --list # names, sizes and modes inside the image +# +# Options: --source (the live folder for --check, default ~/.config/igneum), --agent (passphrase through the +# macOS dialog), --stdinpass (test harness only: a NUL-terminated passphrase on standard input). The image is attached +# read-only at a private mount point and detached at exit, also on failure. README.txt and keys.md inside the image are +# documentation and are not copied or compared. +set -euo pipefail + +IMG=""; ACTION=""; DEST=""; LIVE="$HOME/.config/igneum"; MODE="tty"; FORCE=0 +die() { printf 'restore: %s\n' "$*" >&2; exit 1; } +say() { printf '%s\n' "$*"; } + +while [ $# -gt 0 ]; do + case "$1" in + --check) ACTION="check" ;; + --list) ACTION="list" ;; + --to) ACTION="restore"; DEST="${2:?--to needs a folder}"; shift ;; + --source) LIVE="${2:?--source needs a folder}"; shift ;; + --force) FORCE=1 ;; + --agent) MODE="agent" ;; + --stdinpass) MODE="stdin" ;; + -h|--help) sed -n '2,16p' "$0" | sed 's/^# \{0,1\}//'; exit 2 ;; + -*) die "unknown argument $1" ;; + *) [ -z "$IMG" ] && IMG="$1" || die "one image only" ;; + esac + shift +done +[ -n "$IMG" ] || die "which image? tools/keys/restore.sh --check|--list|--to " +[ -f "$IMG" ] || die "no file at $IMG" +[ -n "$ACTION" ] || die "pick --check, --list or --to " +command -v hdiutil >/dev/null || die "hdiutil is not available (macOS only)" +if [ "$MODE" = "tty" ] && [ ! -t 0 ]; then die "no terminal for the passphrase prompt; run from a terminal, or --agent"; fi + +PASS="" +if [ "$MODE" = "stdin" ]; then IFS= read -r -d '' PASS || true; [ -n "$PASS" ] || die "--stdinpass: no passphrase on standard input"; fi + +MNT="$(mktemp -d "${TMPDIR:-/tmp}/igneum-keys-mnt.XXXXXX")" +MNT="$(cd "$MNT" && pwd -P)" # the physical path: $TMPDIR is a symlink on macOS and `mount` prints the real one +attached() { mount | grep -qF " on $MNT "; } +cleanup() { + if attached; then hdiutil detach "$MNT" -quiet >/dev/null 2>&1 || hdiutil detach "$MNT" -force -quiet >/dev/null 2>&1 || true; fi + attached || rmdir "$MNT" 2>/dev/null || true + PASS="" +} +trap cleanup EXIT + +case "$MODE" in + tty) hdiutil attach "$IMG" -readonly -nobrowse -noautoopen -mountpoint "$MNT" -quiet ;; + agent) hdiutil attach "$IMG" -readonly -nobrowse -noautoopen -agentpass -mountpoint "$MNT" -quiet ;; + stdin) printf '%s\0' "$PASS" | hdiutil attach "$IMG" -readonly -nobrowse -noautoopen -stdinpass -mountpoint "$MNT" -quiet ;; +esac +PASS="" +attached || die "the image did not attach at $MNT (wrong passphrase, or the image is already attached: hdiutil info)" + +# The secret files inside the image: everything except the documentation. +FILES="$(cd "$MNT" && find . -type f ! -name .DS_Store ! -name README.txt ! -name keys.md -print | sed 's#^\./##' | LC_ALL=C sort)" +COUNT="$(printf '%s\n' "$FILES" | grep -c . || true)" +[ "$COUNT" -gt 0 ] || die "the image holds no files" +say "image $IMG" +say "files $COUNT (plus README.txt)" + +sha() { shasum -a 256 "$1" | cut -c1-64; } + +check_against() { + local live="$1" bad=0 rel + while IFS= read -r rel; do + if [ ! -f "$live/$rel" ]; then + printf ' %-40s %8s bytes MISSING in %s\n' "$rel" "$(stat -f '%z' "$MNT/$rel")" "$live"; bad=1 + elif [ "$(sha "$MNT/$rel")" = "$(sha "$live/$rel")" ]; then + printf ' %-40s %8s bytes match\n' "$rel" "$(stat -f '%z' "$MNT/$rel")" + else + printf ' %-40s %8s bytes DIFFERS (live %s bytes, mtime %s)\n' "$rel" "$(stat -f '%z' "$MNT/$rel")" "$(stat -f '%z' "$live/$rel")" "$(date -u -r "$(stat -f '%m' "$live/$rel")" +%Y-%m-%dT%H:%MZ)"; bad=1 + fi + done <<< "$FILES" + # live files the image does not carry (the two settings files and the pip folder are expected) + local extra + extra="$(cd "$live" && find . -type f ! -name .DS_Store -print | sed 's#^\./##' | LC_ALL=C sort | grep -v -x -F -f <(printf '%s\n' "$FILES") | grep -v -E '^(build-slots|dlsite-dir|pytools/.*)$' || true)" + if [ -n "$extra" ]; then + say " live files not in the image (a newer key? back up again):" + printf '%s\n' "$extra" | sed 's/^/ /' + bad=1 + fi + return $bad +} + +case "$ACTION" in + list) + while IFS= read -r rel; do printf ' %s %8s bytes %s\n' "$(stat -f '%Sp' "$MNT/$rel")" "$(stat -f '%z' "$MNT/$rel")" "$rel"; done <<< "$FILES" + ;; + check) + [ -d "$LIVE" ] || die "no live folder at $LIVE" + say "against $LIVE" + if check_against "$LIVE"; then say "check every file in the image matches the live folder"; else say "check DIFFERENCES found (see above)"; exit 1; fi + ;; + restore) + mkdir -p "$DEST"; chmod 700 "$DEST" + if [ $FORCE -eq 0 ]; then + while IFS= read -r rel; do [ -e "$DEST/$rel" ] && die "$DEST/$rel exists; --force to overwrite (it is replaced by the image's copy)"; done <<< "$FILES" + fi + while IFS= read -r rel; do + mkdir -p "$DEST/$(dirname "$rel")"; chmod 700 "$DEST/$(dirname "$rel")" + cp -p "$MNT/$rel" "$DEST/$rel" + case "$rel" in *.pub) chmod 644 "$DEST/$rel" ;; *) chmod 600 "$DEST/$rel" ;; esac + done <<< "$FILES" + say "restored $COUNT files into $DEST" + if check_against "$DEST"; then say "check every restored file matches the image"; else die "the restored files do not match the image"; fi + ;; +esac diff --git a/tools/keys/test-backup.sh b/tools/keys/test-backup.sh new file mode 100755 index 000000000..c0fc752cb --- /dev/null +++ b/tools/keys/test-backup.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +# End-to-end test of backup.sh and restore.sh on a SCRATCH folder with a throwaway passphrase. Never points at +# ~/.config/igneum and never uses a real passphrase: the passphrase is generated here and piped through --stdinpass. +# +# tools/keys/test-backup.sh # exit 0 when every step passes; prints each step +# +# Steps: a scratch folder with the same file names as the real one (random contents), backup --dry-run, backup +# --stdinpass, restore --list, restore --check (must match), a changed live file (check must FAIL), restore --to a +# fresh folder (modes 600/644, check must match), a wrong passphrase (attach must fail). macOS only (hdiutil). +set -euo pipefail +HERE="$(cd "$(dirname "$0")" && pwd)" +T="$(mktemp -d "${TMPDIR:-/tmp}/igneum-keys-test.XXXXXX")"; chmod 700 "$T" +trap 'rm -rf "$T"' EXIT +SRC="$T/config"; mkdir -p "$SRC/txgen" "$SRC/vercel" "$SRC/pytools" +rnd() { head -c "$1" /dev/urandom | base64 | tr -d '\n/+=' | head -c "$1"; } +for n in ota-signing-key relay-token relay-key dl-token dl-token.old-2026-10-05 log-intake-key log-intake-key.old-2026-10-05 hetzner-token desec-token env relay-token.old-2026-10-04; do + rnd 40 > "$SRC/$n"; chmod 600 "$SRC/$n" +done +printf '{"purpose":"test","private_key":"0x%s"}\n' "$(rnd 64)" > "$SRC/dev-fee-devnet.json"; chmod 600 "$SRC/dev-fee-devnet.json" +printf '{"wallets":[]}\n' > "$SRC/txgen/wallets.json"; chmod 600 "$SRC/txgen/wallets.json" +printf '{"token":"%s"}\n' "$(rnd 24)" > "$SRC/vercel/auth.json"; chmod 600 "$SRC/vercel/auth.json" +printf '{"currentTeam":"x"}\n' > "$SRC/vercel/config.json"; chmod 644 "$SRC/vercel/config.json" +printf '%s\n' "$(rnd 64 | tr -c '0-9a-f\n' 'a')" > "$SRC/ota-signing-key.pub"; chmod 644 "$SRC/ota-signing-key.pub" +printf '2\n' > "$SRC/build-slots"; chmod 644 "$SRC/build-slots" +printf '/nowhere/dlsite\n' > "$SRC/dlsite-dir"; chmod 600 "$SRC/dlsite-dir" +printf '# not a secret\n' > "$SRC/pytools/git_filter_repo.py" +PASS="test-$(rnd 24)" +OUT="$T/igneum-keys-test.dmg" +step() { printf '\n== %s\n' "$*"; } + +step "1 dry run" +"$HERE/backup.sh" --dry-run --source "$SRC" --out "$OUT" | tee "$T/dry.txt" +grep -q 'files 16$' "$T/dry.txt" || { echo "FAIL: expected 16 files in the dry run"; exit 1; } +grep -q 'build-slots' "$T/dry.txt" && grep -q 'excluded' "$T/dry.txt" || true +! grep -E '^-.* (build-slots|dlsite-dir|pytools/)' "$T/dry.txt" || { echo "FAIL: an excluded file is listed"; exit 1; } +[ ! -e "$OUT" ] || { echo "FAIL: the dry run created the image"; exit 1; } + +step "2 backup with the harness passphrase" +printf '%s\0' "$PASS" | "$HERE/backup.sh" --stdinpass --source "$SRC" --out "$OUT" | tee "$T/backup.txt" +grep -q '^verified 17 files' "$T/backup.txt" || { echo "FAIL: the verify line is missing"; exit 1; } +[ -f "$OUT" ] || { echo "FAIL: no image"; exit 1; } +[ "$(stat -f '%Sp' "$OUT")" = "-rw-------" ] || { echo "FAIL: the image is not 0600"; exit 1; } +if grep -q -F "$(cat "$SRC/relay-token")" "$T/backup.txt" "$T/dry.txt"; then echo "FAIL: a value was printed"; exit 1; fi + +step "3 restore --list" +printf '%s\0' "$PASS" | "$HERE/restore.sh" "$OUT" --stdinpass --list | tee "$T/list.txt" +grep -q 'files 16 ' "$T/list.txt" || { echo "FAIL: expected 16 files listed"; exit 1; } + +step "4 restore --check against the unchanged source (must match)" +printf '%s\0' "$PASS" | "$HERE/restore.sh" "$OUT" --stdinpass --check --source "$SRC" | tee "$T/check1.txt" +grep -q 'every file in the image matches' "$T/check1.txt" || { echo "FAIL: the check did not pass on identical files"; exit 1; } + +step "5 restore --check after a live file changes (must FAIL)" +rnd 40 > "$SRC/relay-token" +if printf '%s\0' "$PASS" | "$HERE/restore.sh" "$OUT" --stdinpass --check --source "$SRC" > "$T/check2.txt" 2>&1; then + cat "$T/check2.txt"; echo "FAIL: the check passed on a changed file"; exit 1 +fi +grep -q 'relay-token .*DIFFERS' "$T/check2.txt" || { cat "$T/check2.txt"; echo "FAIL: the changed file is not reported"; exit 1; } +echo "ok: the check failed on the changed file, as it must" + +step "6 restore --to a fresh folder" +printf '%s\0' "$PASS" | "$HERE/restore.sh" "$OUT" --stdinpass --to "$T/restored" | tee "$T/restore.txt" +grep -q 'every restored file matches the image' "$T/restore.txt" || { echo "FAIL: the restore check"; exit 1; } +[ "$(stat -f '%Sp' "$T/restored/ota-signing-key")" = "-rw-------" ] || { echo "FAIL: restored key is not 0600"; exit 1; } +[ "$(stat -f '%Sp' "$T/restored/ota-signing-key.pub")" = "-rw-r--r--" ] || { echo "FAIL: restored .pub is not 0644"; exit 1; } +[ "$(stat -f '%Sp' "$T/restored")" = "drwx------" ] || { echo "FAIL: restored folder is not 0700"; exit 1; } +[ ! -e "$T/restored/build-slots" ] || { echo "FAIL: build-slots was restored"; exit 1; } +[ ! -e "$T/restored/README.txt" ] || { echo "FAIL: README.txt was restored as a secret"; exit 1; } +if printf '%s\0' "$PASS" | "$HERE/restore.sh" "$OUT" --stdinpass --to "$T/restored" > "$T/restore2.txt" 2>&1; then echo "FAIL: overwrote without --force"; exit 1; fi +echo "ok: a second restore without --force is refused" + +step "7 a wrong passphrase must not open the image" +if printf '%s\0' "not-$PASS" | "$HERE/restore.sh" "$OUT" --stdinpass --list > "$T/wrong.txt" 2>&1; then echo "FAIL: a wrong passphrase opened the image"; exit 1; fi +echo "ok: refused" + +step "8 the image never holds a plain value" +if grep -a -q -F "$(cat "$SRC/hetzner-token")" "$OUT"; then echo "FAIL: a value is readable in the image bytes"; exit 1; fi +echo "ok: the raw image bytes do not contain the test values" + +printf '\nall steps passed (%s)\n' "$OUT" From 7d09857f9148351af2a659ee46c2e87f54be446a Mon Sep 17 00:00:00 2001 From: igneum-labs <337424239+igneum-labs@users.noreply.github.com> Date: Mon, 5 Oct 2026 17:22:22 +0000 Subject: [PATCH 2/2] observer: a watchdog forces a reconnect after ten failed ticks (the live page went stale for an hour after a node restart) Co-Authored-By: Claude Fable 5.1 --- tools/observer/observer.mjs | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/tools/observer/observer.mjs b/tools/observer/observer.mjs index e46ff9613..8db92d5b2 100644 --- a/tools/observer/observer.mjs +++ b/tools/observer/observer.mjs @@ -440,6 +440,9 @@ async function flushChain() { } async function tick(rpc) { + try { await tickInner(rpc); rpc.noteTick && rpc.noteTick(true); } catch (e) { rpc.noteTick && rpc.noteTick(false); } +} +async function tickInner(rpc) { const now = Date.now(); let dag, info, peersRes, hps; try { @@ -449,7 +452,7 @@ async function tick(rpc) { rpc.call('getConnectedPeerInfo', {}), rpc.call('estimateNetworkHashesPerSecond', { windowSize: 1000, startHash: null }).catch(() => null), ]); - } catch (e) { log('tick failed', e.message); return; } + } catch (e) { log('tick failed', e.message); throw e; } if (dag.network) network = String(dag.network).startsWith('igneum') ? dag.network : `igneum-${dag.network}`; nodeVersion = info.serverVersion || nodeVersion; if (dag.sink && dag.sink !== sinkHash) { sinkHash = dag.sink; pendingChain.add.add(dag.sink); } @@ -963,6 +966,20 @@ async function main() { }; rpc.onClose = () => { setTimeout(connect, 2000); }; await connect(); + // Watchdog (5 October 2026): after the observer node was restarted for 0.3.9 the reporter logged "rpc not connected" for + // an hour without reconnecting (the socket's close never resolved into a new connect). Ten failed ticks in a row force + // the socket shut and a fresh connect; the live page must never go stale while the node answers. + let failedTicks = 0; + rpc.noteTick = (ok) => { + failedTicks = ok ? 0 : failedTicks + 1; + if (failedTicks === 10) { + log('watchdog: 10 failed ticks, forcing a reconnect'); + failedTicks = 0; + try { rpc.ws && rpc.ws.close(); } catch { } + rpc.open = false; + setTimeout(connect, 1000); + } + }; setInterval(async () => { if (flushBusy) return; flushBusy = true; try { await flushBlocks(); await flushChain(); } finally { flushBusy = false; } }, FLUSH_EVERY_MS); setInterval(() => flushColors(rpc), FLUSH_EVERY_MS);