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
+75
View File
@@ -24,6 +24,7 @@ import (
"gitea.dooplex.hu/admin/felhom-hub/internal/notify"
"gitea.dooplex.hu/admin/felhom-hub/internal/offsite"
"gitea.dooplex.hu/admin/felhom-hub/internal/offsiteheal"
"gitea.dooplex.hu/admin/felhom-hub/internal/offsitekeys"
"gitea.dooplex.hu/admin/felhom-hub/internal/pbsdrheal"
"gitea.dooplex.hu/admin/felhom-hub/internal/poke"
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
@@ -179,6 +180,26 @@ func main() {
logger.Fatalf("[FATAL] Failed to initialize store: %v", err)
}
defer dataStore.Close()
// R-821 / decision 69 (v0.127.0): the off-site sub-account password is sealed at rest with a key
// that is NOT in the database (Secret/offsite-secret-key). Without it the hub cannot store, read or
// use any sub-account password — provisioning and the key registrar refuse (fail-closed), and the
// legacy plaintext rows stay as they are until the key arrives.
offsiteKeyReady := false
if v := os.Getenv("OFFSITE_SECRET_KEY"); v == "" {
logger.Printf("[ERROR] OFFSITE_SECRET_KEY unset — off-site passwords cannot be sealed; provisioning and the key registrar are DISABLED")
} else if key, kerr := store.ParseOffsiteSecretKey(v); kerr != nil {
logger.Printf("[ERROR] OFFSITE_SECRET_KEY invalid (%v) — provisioning and the key registrar are DISABLED", kerr)
} else if kerr := dataStore.SetOffsiteSecretKey(key); kerr != nil {
logger.Printf("[ERROR] OFFSITE_SECRET_KEY rejected (%v) — provisioning and the key registrar are DISABLED", kerr)
} else {
offsiteKeyReady = true
if n, serr := dataStore.SealLegacyOffsiteSecrets(); serr != nil {
logger.Printf("[ERROR] sealing legacy off-site secrets failed after %d row(s): %v", n, serr)
} else {
logger.Printf("[INFO] off-site secrets sealed at rest (%d legacy plaintext row(s) sealed now)", n)
}
}
logger.Printf("[INFO] Database opened at %s", dbPath)
// Phase 2 managed updates: seed the global controller-version floor fallback (config/env). A
@@ -363,6 +384,60 @@ func main() {
// unconfigured or the customer has no offsite tier.
apiHandler.SetOffsiteReissuer(webServer.ReissueOffsiteForCustomer)
// Decision 69 (v0.127.0): the off-site KEY REGISTRAR. The box sends its public key; the hub writes it
// into the sub-account's authorized_keys pinned append-only; the daily check reads every file.
if offsiteKeyReady {
keySvc := &offsitekeys.Service{
Store: dataStore, Reg: &offsitekeys.Registrar{Dialer: offsitekeys.SSHDialer{}}, Logger: logger,
Emit: dispatcher.ProcessEvent,
}
apiHandler.SetOffsiteKeyService(keySvc)
runKeyAudit := func(ctx context.Context) any {
start := time.Now()
out := keySvc.AuditAll(ctx, dataStore.OffsiteWindowOpen)
logger.Printf("[INFO] off-site key check: %d sub-account(s) read in %s", len(out), time.Since(start).Round(time.Millisecond))
type row struct {
Customer string `json:"customer"`
Lines int `json:"lines"`
Pinned int `json:"pinned"`
Findings []offsitekeys.Finding `json:"findings"`
Error string `json:"error,omitempty"`
}
var rows []row
for _, o := range out {
rr := row{Customer: o.CustomerID, Lines: o.Result.Lines, Pinned: o.Result.Pinned, Findings: o.Result.Findings}
if o.Err != nil {
rr.Error = o.Err.Error()
}
rows = append(rows, rr)
}
return rows
}
webServer.SetOffsiteKeyAudit(runKeyAudit)
webServer.SetOffsiteWindowAdmin(dataStore.GrantOffsiteWindowOnce, dataStore.SetOffsiteWindowsEnabled)
// Decision 68: a window the box never closed is closed by the hub at its 20-minute bound.
go func() {
tk := time.NewTicker(60 * time.Second)
defer tk.Stop()
for {
select {
case <-ctx.Done():
return
case <-tk.C:
sctx, cancel := context.WithTimeout(ctx, 2*time.Minute)
keySvc.SweepExpiredWindows(sctx)
cancel()
}
}
}()
go scheduleDaily(ctx, "offsite-key-audit", "07:10", func() {
actx, cancel := context.WithTimeout(ctx, 10*time.Minute)
defer cancel()
runKeyAudit(actx)
}, logger)
logger.Printf("[INFO] Off-site key registrar enabled; daily key check at 07:10 Budapest")
}
// Direction-2 immediate-sync (v0.58.0): one in-memory operator-intent notifier, shared by the
// web handlers (which Bump it after every intent write) and the API handler (which long-polls it
// at GET /api/v1/wait). In-memory BY DESIGN — a restart resets generations to zero; the box