hub v0.127.0: off-site key registrar (box never gets the storage password), password sealed at rest, daily key check, clean-up window (shipped off) — decisions 68-69, R-820/R-821/R-822
gates / gates (push) Successful in 29s

Part A evidence (migration spike, sftp-written repo through the pinned rclone key) and the hub
red-proofs under documentation/audits/offsite-lock-build-2026-10-03/. Manifest bump follows after
the image is built and Secret/offsite-secret-key exists.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
2026-10-03 16:56:42 +02:00
parent 5188dbdb44
commit f417cdede1
28 changed files with 2338 additions and 49 deletions
@@ -0,0 +1,40 @@
## 1. TODAY's transport (sftp:, unpinned key)
$ sftp.sh init
created restic repository 8becf8f1de at sftp:u629488-sub4@u629488-sub4.your-storagebox.de:spike-migrate
Please note that knowledge of your password is required to access
the repository. Losing your password means that your data is
irrecoverably lost.
[rc=0]
$ sftp.sh backup /d/tree --host migbox --tag app1
no parent snapshot found, will read all files
Files: 3 new, 0 changed, 0 unmodified
Dirs: 3 new, 0 changed, 0 unmodified
Added to the repository: 392.552 KiB (392.119 KiB stored)
processed 3 files, 390.639 KiB in 0:02
snapshot 9c767903 saved
[rc=0]
$ sftp.sh backup /d/tree --host migbox --tag app1
using parent snapshot 9c767903
Files: 0 new, 1 changed, 2 unmodified
Dirs: 0 new, 3 changed, 0 unmodified
Added to the repository: 1.929 KiB (1.328 KiB stored)
processed 3 files, 390.641 KiB in 0:01
snapshot 761dc658 saved
[rc=0]
$ sftp.sh snapshots
ID Time Host Tags Paths
--------------------------------------------------------------
9c767903 2026-10-03 14:04:12 migbox app1 /d/tree
761dc658 2026-10-03 14:04:18 migbox app1 /d/tree
--------------------------------------------------------------
2 snapshots
[rc=0]
@@ -0,0 +1,74 @@
## 2. key line now PINNED (same key). 3. rclone: through it
control: today's sftp: transport through the pinned key:
$ sftp.sh snapshots
Fatal: unable to open repository at sftp:u629488-sub4@u629488-sub4.your-storagebox.de:spike-migrate: unable to start the sftp session, error: unexpected EOF
[rc=1]
$ rc.sh snapshots
ID Time Host Tags Paths
--------------------------------------------------------------
9c767903 2026-10-03 14:04:12 migbox app1 /d/tree
761dc658 2026-10-03 14:04:18 migbox app1 /d/tree
--------------------------------------------------------------
2 snapshots
[rc=0]
$ rc.sh backup /d/tree --host migbox --tag app1
using parent snapshot 761dc658
Files: 0 new, 0 changed, 3 unmodified
Dirs: 0 new, 0 changed, 3 unmodified
Added to the repository: 0 B (0 B stored)
processed 3 files, 390.641 KiB in 0:00
snapshot 56667008 saved
[rc=0]
$ rc.sh restore 9c767903 --target /d/restored --include /d/tree/sub/note.txt
restoring <Snapshot 9c767903 of [/d/tree] at 2026-10-03 14:04:12.212018962 +0000 UTC by root@migbox> to /d/restored
[rc=0]
restored sftp-era file == its content at that snapshot: IDENTICAL
$ rc.sh check
using temporary cache in /tmp/restic-check-cache-456083153
create exclusive lock for repository
load indexes
check all packs
check snapshots, trees and blobs
[0:00] 100.00% 3 / 3 snapshots
no errors were found
[rc=0]
$ rc.sh check --read-data
using temporary cache in /tmp/restic-check-cache-2313441632
create exclusive lock for repository
load indexes
check all packs
check snapshots, trees and blobs
[0:00] 100.00% 3 / 3 snapshots
read all data
[0:00] 100.00% 4 / 4 packs
no errors were found
[rc=0]
## 4.
$ rc.sh forget 9c767903
unable to remove <snapshot/9c767903c1> from the repository
[0:48] 0.00% 0 / 1 files deleted
blob not removed, server response: 403 Forbidden (403)
[rc=1]
$ rc.sh snapshots
ID Time Host Tags Paths
--------------------------------------------------------------
9c767903 2026-10-03 14:04:12 migbox app1 /d/tree
761dc658 2026-10-03 14:04:18 migbox app1 /d/tree
56667008 2026-10-03 14:04:51 migbox app1 /d/tree
--------------------------------------------------------------
3 snapshots
[rc=0]
@@ -0,0 +1,60 @@
## E1: SAME key twice — append-only line FIRST, deleting line SECOND
$ rc.sh forget 56667008
unable to remove <snapshot/5666700865> from the repository
[0:48] 0.00% 0 / 1 files deleted
blob not removed, server response: 403 Forbidden (403)
[rc=1]
## E2: SAME key twice — deleting line FIRST
$ rc.sh forget 56667008 --dry-run
Would have removed the following snapshots:
{56667008}
[rc=0]
## E3: a SECOND key (window key) on its own deleting line, the box key stays pinned
$ rcw.sh forget 56667008
[0:00] 100.00% 1 / 1 files deleted
[rc=0]
$ rc.sh snapshots
ID Time Host Tags Paths
--------------------------------------------------------------
9c767903 2026-10-03 14:04:12 migbox app1 /d/tree
761dc658 2026-10-03 14:04:18 migbox app1 /d/tree
--------------------------------------------------------------
2 snapshots
[rc=0]
## E3b: pinned key still refused while the window key exists
$ rc.sh forget 761dc658
[0:48] 0.00% 0 / 1 files deleted
unable to remove <snapshot/761dc6583f> from the repository
blob not removed, server response: 403 Forbidden (403)
[rc=1]
## E2 (real): SAME key, deleting line FIRST — a real forget
$ rc.sh forget 761dc658
[0:00] 100.00% 1 / 1 files deleted
[rc=0]
$ rc.sh snapshots
ID Time Host Tags Paths
--------------------------------------------------------------
9c767903 2026-10-03 14:04:12 migbox app1 /d/tree
--------------------------------------------------------------
1 snapshots
[rc=0]
## E2 closed: deleting line removed again -> refused
$ rc.sh forget 9c767903
[0:48] 0.00% 0 / 1 files deleted
unable to remove <snapshot/9c767903c1> from the repository
blob not removed, server response: 403 Forbidden (403)
[rc=1]
@@ -0,0 +1,4 @@
## Part A teardown 2026-10-03T14:09:25Z
authorized_keys sha256 now 795e715315973740 / orig 795e715315973740
home: . .. .config .ssh felhom-repo spike-migrate
spike-migrate KEPT on purpose (1 snapshot) for Part E's planted history; .config/rclone is the provider rclone's
@@ -0,0 +1,12 @@
## pinned path ABSOLUTE (/home/spike-migrate, the descriptor's form)
ID Time Host Tags Paths
--------------------------------------------------------------
9c767903 2026-10-03 14:04:12 migbox app1 /d/tree
--------------------------------------------------------------
1 snapshots
## probe: ssh with the pinned key, stdin closed
2026/10/03 14:11:14 NOTICE: Config file "/home/.config/rclone/rclone.conf" not found - using defaults
rc=0
## probe with an UNPINNED key (control)
Command not found. Use 'help' to get a list of available commands.
rc=8
@@ -0,0 +1,9 @@
## cat config on a repo that does not exist (pinned key)
Fatal: unable to open config file: <config/> does not exist
Is there a repository at the following location?
rclone:spike-migrate
rc=1
## wrong key (window key not in the file) — transport failure shape
rclone: u629488-sub4@u629488-sub4.your-storagebox.de: Permission denied (publickey,password).
Fatal: unable to open repository at rclone:spike-migrate: error talking HTTP to rclone: Get "http://localhost/file-5577006791947779410": unexpected EOF
rc=1
@@ -0,0 +1,12 @@
# Part A exit test — written before any command (2026-10-03 evening)
Venue: `u629488-sub4` (tester-1), repo dir `spike-migrate` only. restic 0.14.0 from controller image 0.288.0.
1. A repo is created and backed up TODAY'S way: `sftp:` transport, an UNPINNED key, port 23 — 2 snapshots.
2. The key line is then replaced by the PINNED line (`command="rclone serve restic --stdio --append-only spike-migrate",restrict`).
3. Through the pinned key over `rclone:` (`-o rclone.program="ssh -p 23 … -i <key> … rclone"`), MUST succeed:
`snapshots` (both sftp-era snapshots listed), `backup` (count 2 → 3, parent = the sftp-era snapshot),
`restore` of one file from an sftp-era snapshot (bytes identical), `check` (exclusive lock taken and released),
`check --read-data` (the integrity job's full depth).
4. Through the pinned key MUST be refused: `forget <sftp-era id>` (403). Count stays 3.
5. Fail → stop and report; no build.
@@ -0,0 +1,22 @@
## RP1: SaveOneTimeSecret without sealing (the pre-v0.127.0 behaviour)
=== RUN TestOffsiteSecret_RawRowHoldsNoPassword
offsite_secret_seal_test.go:39: raw row is not sealed: "Sup3rSecretPw%"
--- FAIL: TestOffsiteSecret_RawRowHoldsNoPassword (0.02s)
## RP2: consume handler serving the password again (the pre-v0.127.0 handler)
=== RUN TestConsumePassword_RetiredReturnsNoPassword
offsite_test.go:43: consume → 200, want 410 (retired)
--- FAIL: TestConsumePassword_RetiredReturnsNoPassword (0.02s)
## RP3: audit that ignores the pin (every line counted as pinned)
=== RUN TestInstall_MigratesUnpinnedKeyAndAuditGoesClean
offsitekeys_test.go:98: before: {Lines:2 Pinned:2 Findings:[]} <nil> — want 2 unpinned findings (the decoy must be seen)
--- FAIL: TestInstall_MigratesUnpinnedKeyAndAuditGoesClean (0.00s)
=== RUN TestWindow_PrependAuditClose
offsitekeys_test.go:171: a window line with no open window must alarm: {Lines:2 Pinned:2 Findings:[]}
--- FAIL: TestWindow_PrependAuditClose (0.00s)
## restored — all green:
ok gitea.dooplex.hu/admin/felhom-hub/internal/store 2.888s
ok gitea.dooplex.hu/admin/felhom-hub/internal/api 4.047s
ok gitea.dooplex.hu/admin/felhom-hub/internal/offsitekeys 0.009s
@@ -0,0 +1,21 @@
## RPC1: retention ignores the pin (the pre-v0.289.0 'forget --prune after every run')
=== RUN TestRetention_PinnedWithoutWindowDeletesNothing
offbox_window_test.go:106: a forget ran without a window: [[forget --group-by host,tags --keep-daily 7 --keep-weekly 4 --keep-monthly 6 --prune] [forget --group-by host,tags --keep-daily 7 --keep-weekly 4 --keep-monthly 6 --prune]]
--- FAIL: TestRetention_PinnedWithoutWindowDeletesNothing (0.00s)
=== RUN TestRunOffboxBackup_PinnedNeverForgetsWithoutWindow
offbox_window_test.go:127: the pinned run reached forget 1 time(s)
--- FAIL: TestRunOffboxBackup_PinnedNeverForgetsWithoutWindow (0.00s)
## RPC2: guard without the future-date check
=== RUN TestOffsiteGuard_LabThirteenFutureFakes_Refused
offbox_window_test.go:154: window close = [{ID:7 CountBefore:16 CountAfter:16 Removed:0 Outcome:guard-refused Reason:the policy would remove snapshot r1 from 2026-10-03T12:45:38Z — younger than 8 days, which honest retention never does}]
--- FAIL: TestOffsiteGuard_LabThirteenFutureFakes_Refused (0.00s)
## RPC3: bridge trusts the registrar without proving the pin
=== RUN TestBridge_RegisteredButNotPinnedRefuses
offsiteapply_test.go:212: a key that does not reach the pinned server must refuse
--- FAIL: TestBridge_RegisteredButNotPinnedRefuses (0.00s)
## restored:
ok gitea.dooplex.hu/admin/felhom-controller/internal/backup 433.945s
ok gitea.dooplex.hu/admin/felhom-controller/internal/offsiteapply (cached)
+23
View File
@@ -117,6 +117,29 @@ shred -u /path/to/keyfile
---
## Off-site password sealing key — `Secret/offsite-secret-key` (hub v0.127.0, decision 69, R-821)
**What uses it:** `hub` env `OFFSITE_SECRET_KEY` (64 hex characters = 32 bytes). It seals every Storage Box
sub-account password in `one_time_secrets.value`; the hub's key registrar opens them to write box keys.
**Required** — the pod does not start without it.
**Create (once, before the first v0.127.0 sync) — the value never touches a file or the terminal:**
```bash
sudo kubectl -n felhom-system create secret generic offsite-secret-key \
--from-literal=OFFSITE_SECRET_KEY="$(openssl rand -hex 32)"
```
**If it is lost:** the sealed passwords cannot be opened. Nothing on the boxes breaks (their keys are installed);
the registrar and the daily check fail with `offsite_key_audit_failed`. Recover per customer with the hub's
**Re-issue offsite credentials** button (the provider resets the password; the hub seals the new one). Keep a copy
in the operator's password manager if a Re-issue round is not acceptable.
**Rotation:** not built. A new key cannot open the old rows; rotate by setting the new key and pressing Re-issue
for every off-site customer.
---
## Other committed secrets (tracked, NOT yet de-gitted — backlog)
`manifests/felhom.secret.yaml` still commits other plaintext secrets (`healthchecks-config` `SECRET_KEY`