Files
felhom.eu/documentation/audits/hub-token-seal-2026-10-23/README.md
T
admin a91d0b2421
gates / gates (push) Successful in 13m1s
Upload watch: spike, design (08 section 9, proposed), live proofs on 9202; rows R-1244..R-1248 (register 189 -> 194); STATUS D41/D42 + the Monday list
Spike: per-app normal levels on three boxes (19-31 days), the off-site push as a line measure (a lower bound),
qBittorrent/Transmission/Plex explainers measured on 9202, a tc ceiling measured (not built). Live on 9202 with
0.342.0-rc.1: limit set; real seed explained; CPU burner and the hidden flood alarmed once each and cleared once;
the household's limit left alone. Teardown: apps removed, 9202 back on 0.341.0, receiver 9471 destroyed.
2026-10-23 22:52:41 +02:00

114 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# The hub's last plain tokens, sealed (R-975, R-1241's Felhom half) — 2026-10-23 night
Class: implementation (hub 0.168.0, built, **not deployed**) + a design for the backup copies (Part 1.4).
Architecture read: `architecture/05-hub-architecture.md` §16.2–§16.3 (sealing, the snapshot), `13-threat-model.md`
(attacker j, R-1241), `audits/plex2shell-felhom-impact-2026-10-23/README.md`. Names and places only — no value appears
here or in any file of this session. Every count below came from a read-only copy of the hub's own 14:00Z snapshot
(`VACUUM INTO`, so it holds every committed write), copied to a 0700 scratch folder and deleted after.
## 1. Every hub column that holds a secret (measured, 2026-10-23)
| Where | What | Before 0.168.0 | 0.168.0 |
|---|---|---|---|
| `hosts.api_key` | each box's hub key | sealed (R-879, 0.138.0) — 4/4 | same |
| `customer_configs.api_key`, `retrieval_password` | controller key, owner passphrase | sealed (R-879) — 5/5 each | same |
| `host_pbs_secrets.value` | PBS-DR token | sealed (R-879) — 4/4 | same |
| `one_time_secrets.value` | Storage Box sub-account password | sealed (R-821, 0.127.0) — 4/4 | same |
| `host_recovery.secret` | break-glass console password | sealed (R-133, 0.135.0) — 4/4 | same |
| **`customer_configs.config_json` → `infrastructure.cf_tunnel_token`** | Cloudflare tunnel token | **plain — 4 rows** | **sealed** |
| **`customer_configs.config_json` → `infrastructure.cf_api_token`** | Cloudflare API token | **plain — 4 rows** | **sealed** |
| **`customer_configs.config_json` → `git.token`** | git token (the form still accepts one) | none stored today | **sealed** when written |
| `host_escrow.blob`, `identity_blob`; `host_escrow_superseded.*` | escrow | age-wrapped by the household's recovery code — not the hub's to seal | same |
| `api_tokens.token_hash`, `selfbind_tokens.token_hash`, `appliance_registrations.token_hash`, `customer_claims.code_hash`, `hub_settings[operator_password_hash]` | operator tokens, bind tokens, claim codes, operator password | one-way hashes (SHA-256 / bcrypt) | same |
| `hosts.api_key_hash`, `customer_configs.api_key_hash` | lookup twins | unkeyed SHA-256 of a 256-bit random key (R-879) | same |
| `wg_peers.pubkey`, `wg_endpoints.server_pubkey`, `host_escrow.key_fingerprint`, `appliance_registrations.ssh_host_pubkeys`, `hub_settings[oob_operator_ssh_pubkey]` | public keys, fingerprints | not secret | same |
| `guests.api_key` | unused column | empty 7/7 | same |
| `appliance_registrations.pairing_code` | 6-character console code | plain, 31 rows | **NOT-A-FINDING:** it is printed on the box's screen and is only one of two factors; the other (the self-bind token) is stored as a hash. Pending registrations only. |
**Do the token VALUES sit anywhere else?** Each of the 8 distinct Cloudflare values was searched (by value, in memory,
never printed) in every column of every other table: **0 hits**. The key NAME `cf_tunnel_token` appears in one
`reports.report_json` and one `app_telemetry.issues_json` row — names, not values. `events.details_json` carries
`repo_key` (literally the word „changed") and PBS `token_id`s (a name like `user@pbs!name`, not a secret).
## 2. The change (hub 0.168.0)
- **Same mechanism, same key** (`05` §16.2): each token leaf in `config_json` holds `enc:v1:` + AES-256-GCM under
`OFFSITE_SECRET_KEY`. The rest of the JSON stays readable. `internal/store/r975_config_token_seal.go`.
- **New writes sealed:** `SaveCustomerConfig` (the only writer of `config_json`). No key and a token → refused.
- **Every reader opens through the existing helper:** `openCustomerSecrets`, which the three readers
(`GetCustomerConfig`, `GetCustomerConfigByAPIKey`, `ListCustomerConfigs`) already call. A leaf that does not open is
blanked and sets `SecretsUnreadable` (R-879's rule: never served, a save refuses the record, `configgen.Generate` refuses).
- **Old rows sealed once at start-up:** `SealLegacyConfigTokens`, logged as `config tokens sealed at rest (N legacy
plaintext token(s) sealed now)` — counts only. **No `config_version` bump** — the box's config is byte-for-byte the
same, so no box re-pulls or restarts. Expected on the live hub: N = 8 on the first start, 0 after.
- **Then one `VACUUM` + WAL checkpoint** (only when N > 0): an `UPDATE` leaves the old plaintext row in a freed block of
`hub.db` (the SQLite driver's `secure_delete` is 0 — measured). Timed on a copy of the live 534 MB database: **2.8 s**,
well inside the liveness probe's ~95 s. The volume has 2.4 GB free for it.
- **Roll-back:** `felhom-hub -unseal-box-secrets` now also opens these leaves (all or nothing), so going back to
0.167.0 stays the documented procedure (`05` §16.2). Without it an older hub would hand the box the sealed string as its
tunnel token.
**Tests, each red-proved** (the rule broken on purpose → the test failed → restored):
| Test | Pins | Red-proof (mutation → failed) |
|---|---|---|
| `TestR975_FreshWriteIsStoredSealed` | a fresh write is sealed; non-secret fields intact; no key → refused | write path unsealed → FAIL (with 5 others) |
| `TestR975_ReaderGetsTheSameValue` | all three readers + a load-modify-save return the same value | open on read removed → FAIL |
| `TestR975_MigrationSealsOnceThenNoOp` | 3 sealed, then 0; no `config_version`/`updated_at` change | migration no-op → FAIL; migration bumps the version → FAIL |
| `TestR975_DBFileWithoutKeyRevealsNoToken` | **a copy of `hub.db` + `-wal` + `-shm` greps clean**; positive control: the pre-0.168.0 rows DO show in the file | VACUUM removed → FAIL (the old row stays in a freed block) |
| `TestR975_WrongKeyBlanksAndRefusesSave` | wrong key → blanked, flagged, save refused, right key restores | not blanked → FAIL; flag ignores the tokens → FAIL |
| `TestR975_UnsealRestoresPlaintextForAnOlderHub` | roll-back opens the tokens, all or nothing | the config half skipped → FAIL |
| `TestR975_EveryFormSecretIsSealed` (web) | every leaf the operator form writes is either on a 2-name non-secret list or sealed | write path unsealed → FAIL |
| `TestR975_GeneratedConfigCarriesThePlainTunnelToken` (configgen) | the box still gets the REAL token in `controller.yaml` | open on read removed → FAIL |
Two first mutations broke the build instead of a test (an unused variable) and were redone so a test, not the
compiler, failed. The first version of the file test passed with the VACUUM removed — the token row was the last cell on
its page, so the new row overwrote it; a second customer written after it (as on the live hub) made the test honest.
**Run on a private copy of the real database** (the 14:00Z snapshot, in a 0700 scratch folder, with a THROWAWAY key —
the real one was never read; a temporary test file, deleted, never committed): the start-up sealing sealed **8** tokens
in **3.15 s** (incl. the VACUUM), a second run sealed **0**, all **5 of 5** customers' `config_json` read back equal to
the original (JSON-equal), and **0 of the 8** token values were found anywhere in the database files afterwards. A first
attempt compared whole records and showed „0 of 5 equal": the R-879 columns were sealed with the REAL key, so with the
throwaway key every record was flagged unreadable — the expected behaviour, not a defect; the second run compared the
JSON only. The copy was shredded after. Image: `gitea.dooplex.hu/admin/felhom-hub:0.168.0`
(`sha256:3bfdecaae527d4762059600cdfaad83daf4e4b5b20661fc47acf128180d87cb3`), from `5277d859`, CI run 2269 success.
**Measured on the live file (count of the key name only):** `cf_tunnel_token` occurs 12 times both in the live
`hub.db` and in the clean `VACUUM INTO` snapshot, so the live file holds no extra old copies today.
## 3. The backup copies — design only (Part 1.4)
**What exists today (measured):**
| Copy | Where | Encrypted? | Holds the tokens? |
|---|---|---|---|
| Hub snapshots `<data>/snapshots/` (2, 6-hourly) | the hub's Longhorn volume | no (the volume is `encrypted=false`) | after 0.168.0: sealed |
| **Longhorn backups of the hub volume** | `/mnt/5_hdd/backup/longhorn-pvc` (the NFS folder of R-1241) — **2 backups: 2026-10-18 03:00Z (weekly) and 2026-10-23 02:01Z (daily)**, ~4.1 GB each, `retain 1` each | **no** | **yes, in plaintext** — both predate 0.168.0 |
| The hub's own copy to ep0 (`felhom-hub-db-backup`, 6-hourly :30) | ep0 PBS, namespace `operator` | **yes, at rest:** 16 snapshots 2026-10-10 → 10-23, every `hubdb.pxar.didx` and catalog `crypt-mode: encrypt` (`index.json.blob` sign-only). The key `/etc/felhom-hub-backup/enc.key` is on DooPlex's **root disk (sdb1)**, not on the NFS disk, and on paper (S2). **In transit:** SSH port-forward `127.0.0.1:18007` + PBS TLS with a pinned fingerprint. Last push 14:31 CEST today, 7 s; the weekly restore-test last passed 2026-10-18 („4 sealed console password(s), 0 readable"). | sealed after 0.168.0 |
**The two options:**
| | (a) Encrypt the hub's Longhorn volume | (b) Take the hub volume out of the Longhorn NFS backup |
|---|---|---|
| What | A new Longhorn volume with `encrypted=true` (a StorageClass with a crypto Secret), copy `/data` across, point `manifests/hub.yaml` at it | Remove the volume from the `default` recurring-job group (one label on the Longhorn volume); the ep0 copy is the hub's only backup |
| Hub downtime | ~5–10 min (scale to 0, copy 1.5 GB, scale up) | none |
| Who | homelab (a StorageClass + Secret in `longhorn-system`) + a Felhom manifest change | homelab (the Longhorn volume label); no Felhom code |
| New key to keep | yes — the volume passphrase, on paper, and it lives in the cluster where the R-1241 attacker reaches the Longhorn API | none |
| What it fixes | the backups stay but are ciphertext; also the volume's freed blocks | no more hub copies in the NFS folder at all |
| What it loses | nothing | the hub's 90-day access log (`/data/access-log`, R-1140) and its local snapshots are no longer backed up anywhere; a lost volume is restored from ep0 (`RUNBOOK-hub-db-offsite-backup.md` §restore, restore-tested weekly) — at most ~6.5 h of hub data lost, the same as today's off-site promise |
| Does the encryption key sit next to the data? | yes (cluster Secret) | the PBS key is on another disk and on paper |
**CC's pick: (b)**, because the ep0 copy is already encrypted at rest and in transit (measured above), off-site, every 6
hours, and restore-tested weekly — the Longhorn copy adds a second, unencrypted copy in exactly the folder R-1241 found
open, and (a) would put its key in the same cluster. The access log's loss can be closed later by adding
`/data/access-log` to the ep0 push (small; not tonight). → decide row **R-1243**.
**The plain copies already in the folder** (the two hub-volume backups above): they hold today's tokens in plaintext.
Deleting backups is the operator's decision. Without action they rotate out by themselves after 0.168.0 is deployed —
the daily one at the next 04:00 run, the weekly one at the next Sunday 05:00 run (`retain 1` each) — **but only if the
volume stays in the backup job** (option a, or no decision). With option (b) the last two copies stay until someone
deletes them. Either way the tokens in them should then be considered exposed only if R-1241's door is used — the
Plex2Shell audit measured it was not. **Note:** Longhorn backs up the volume's blocks, so a block freed by the VACUUM can
still hold old plaintext in a later Longhorn backup; only (a) or (b) removes that — not measured, reasoned.