Merge key-custody: key inventory, encrypted offline backup and restore scripts, the two-signing-key plan, a no-secrets CI check
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
commit
a96bcea470
6 changed files with 618 additions and 2 deletions
8
.github/workflows/ci.yml
vendored
8
.github/workflows/ci.yml
vendored
|
|
@ -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)
|
||||
|
|
|
|||
129
docs/security/keys.md
Normal file
129
docs/security/keys.md
Normal file
|
|
@ -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/<token>/`: 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/<token>/` 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-<date>.dmg, AES-256, hdiutil's own passphrase prompt
|
||||
# (twice: create, then the verify attach); verified by sha256, listed, detached
|
||||
tools/keys/restore.sh <dmg> --check # every file in the image against ~/.config/igneum: match / DIFFERS / missing
|
||||
tools/keys/restore.sh <dmg> --to <dir> # 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: [<fingerprint>]`, 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/<token>/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-<date>`, 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-<date>` 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.
|
||||
97
tools/ci/no-secrets-check.sh
Executable file
97
tools/ci/no-secrets-check.sh
Executable file
|
|
@ -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-<date> 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
|
||||
190
tools/keys/backup.sh
Executable file
190
tools/keys/backup.sh
Executable file
|
|
@ -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-<YYYY-MM-DD>.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 <this image> --check (compares against the live folder, prints no values)"
|
||||
echo " tools/keys/restore.sh <this image> --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 <<EOF
|
||||
|
||||
done $OUT ($(stat -f '%z' "$OUT") bytes, mode 600)
|
||||
|
||||
What to do with it now:
|
||||
1. Copy it to TWO media that are not this Mac: a USB stick kept at home and a second stick or an encrypted
|
||||
cloud folder you already trust. Check each copy's size matches. Then delete the Desktop copy.
|
||||
2. Write the passphrase on paper. Keep the paper away from both media. No passphrase, no keys: the image is
|
||||
AES-256 and nobody can open it without it.
|
||||
3. After every rotation (a new key, a new token) run this again and replace both copies; keep one old image.
|
||||
4. Test it: tools/keys/restore.sh <image> --check compares the image to the live folder without printing values.
|
||||
EOF
|
||||
116
tools/keys/restore.sh
Executable file
116
tools/keys/restore.sh
Executable file
|
|
@ -0,0 +1,116 @@
|
|||
#!/usr/bin/env bash
|
||||
# The reverse of backup.sh: open an igneum-keys-<date>.dmg and either check it against the live folder or copy its
|
||||
# files back.
|
||||
#
|
||||
# tools/keys/restore.sh <image.dmg> --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 <image.dmg> --to <folder> # copies the files into <folder> (0700 dirs, 0600 files; .pub 0644),
|
||||
# # refuses to overwrite an existing file unless --force, then runs the check
|
||||
# tools/keys/restore.sh <image.dmg> --list # names, sizes and modes inside the image
|
||||
#
|
||||
# Options: --source <folder> (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 <image.dmg> --check|--list|--to <folder>"
|
||||
[ -f "$IMG" ] || die "no file at $IMG"
|
||||
[ -n "$ACTION" ] || die "pick --check, --list or --to <folder>"
|
||||
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
|
||||
80
tools/keys/test-backup.sh
Executable file
80
tools/keys/test-backup.sh
Executable file
|
|
@ -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"
|
||||
Loading…
Reference in a new issue