diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 41af14874..ea5ce0451 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 @@ -67,6 +69,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" 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);