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
+16 -2
View File
@@ -49,6 +49,7 @@ type Poker interface {
// Handler handles API endpoints for report ingest and customer queries.
type Handler struct {
store *store.Store
offsiteKeys OffsiteKeyService // decision 69 key registrar (nil → 503)
apiKey string
resendAPIKey string
fromEmail string
@@ -355,6 +356,16 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
case r.Method == http.MethodPost && strings.HasPrefix(path, "/offsite/consume-password/"):
customerID := strings.TrimPrefix(path, "/offsite/consume-password/")
h.handleOffsiteConsumePassword(w, r, customerID)
case r.Method == http.MethodPost && strings.HasPrefix(path, "/offsite/register-key/"):
h.handleOffsiteRegisterKey(w, r, strings.TrimPrefix(path, "/offsite/register-key/"))
case r.Method == http.MethodPost && strings.HasPrefix(path, "/offsite/confirm-key/"):
h.handleOffsiteConfirmKey(w, r, strings.TrimPrefix(path, "/offsite/confirm-key/"))
case r.Method == http.MethodPost && strings.HasPrefix(path, "/offsite/move-aside/"):
h.handleOffsiteMoveAside(w, r, strings.TrimPrefix(path, "/offsite/move-aside/"))
case r.Method == http.MethodPost && strings.HasPrefix(path, "/offsite/window-open/"):
h.handleOffsiteWindowOpen(w, r, strings.TrimPrefix(path, "/offsite/window-open/"))
case r.Method == http.MethodPost && strings.HasPrefix(path, "/offsite/window-close/"):
h.handleOffsiteWindowClose(w, r, strings.TrimPrefix(path, "/offsite/window-close/"))
case r.Method == http.MethodGet && strings.HasPrefix(path, "/artifacts/"):
customerID := strings.TrimPrefix(path, "/artifacts/")
h.handleArtifactManifest(w, r, customerID)
@@ -2049,8 +2060,11 @@ var allowedEventTypes = map[string]bool{
"offsite_proof_empty": true,
// R-431 — the hub raises this itself; allowlisted so a hub-origin event is never 400'd.
"offsite_snapshots_dropped": true,
"crossdrive_completed": true,
"crossdrive_failed": true,
// controller v0.289.0 (decision 69): the customer-chosen deletion of set-aside history is deferred
// to the operator — the box's append-only key cannot delete. Operator-only (notify.operatorOnlyEvents).
"offbox_abandon_deferred": true,
"crossdrive_completed": true,
"crossdrive_failed": true,
// controller v0.134.1 — enlarged offsite push refused by the quota gate (warning; the controller's
// dynamic Hungarian message is customer-grade — deliberately NO customerMessages entry, which would
// discard the numbers (templates.go:129 priority)).
+154 -17
View File
@@ -1,31 +1,168 @@
package api
import (
"database/sql"
"context"
"encoding/json"
"errors"
"io"
"net/http"
"time"
"gitea.dooplex.hu/admin/felhom-hub/internal/offsitekeys"
)
// handleOffsiteConsumePassword serves the one-time transient offsite password to the controller EXACTLY
// ONCE (SLICE 1; SLICE 2 controller consumes it, installs its key, then the hub resets the box password).
// Auth = the customer's API key (same credential as config-pull); the token's customer must match the
// path. The value is returned once then marked consumed — a second call 404s. NEVER logged.
// OffsiteKeyService is the registrar seam (decision 69). nil → the key endpoints answer 503.
type OffsiteKeyService interface {
RegisterKey(ctx context.Context, customerID, pub string) (offsitekeys.InstallResult, error)
ConfirmKey(ctx context.Context, customerID, fp string) (int, error)
MoveAside(ctx context.Context, customerID string) (string, error)
OpenWindowFor(ctx context.Context, customerID string, countBefore int) (offsitekeys.WindowGrant, error)
CloseWindowFor(ctx context.Context, customerID string, r offsitekeys.WindowResult) error
}
// SetOffsiteKeyService wires the key registrar.
func (h *Handler) SetOffsiteKeyService(s OffsiteKeyService) { h.offsiteKeys = s }
// handleOffsiteConsumePassword is RETIRED (hub v0.127.0, decision 69, R-820). It used to hand the box the
// Storage Box sub-account password; that password can rewrite `.ssh/authorized_keys` and so remove the
// append-only pin from any key (measured 2026-10-03). A box now sends its PUBLIC key to
// /offsite/register-key and the hub installs it. This answers 410 with no body that could carry a secret.
// Pinned by TestConsumePassword_RetiredReturnsNoPassword.
func (h *Handler) handleOffsiteConsumePassword(w http.ResponseWriter, r *http.Request, customerID string) {
authCustomerID, isGlobal, ok := h.checkAuthCustomer(r)
if !ok || (!isGlobal && authCustomerID != customerID) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
pw, err := h.store.ConsumeOneTimeSecret(customerID)
if err == sql.ErrNoRows {
http.Error(w, "no unconsumed offsite password", http.StatusNotFound)
return
}
if err != nil {
h.logger.Printf("[ERROR] offsite consume-password %s: %v", customerID, err) // no secret
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]string{"password": pw}) // one-time; never logged
h.logger.Printf("[WARN] offsite consume-password called by %s — retired (decision 69); the box must register its public key (controller >= 0.289.0)", customerID)
http.Error(w, "gone: the hub no longer serves the storage password; register the box's public key at /api/v1/offsite/register-key/", http.StatusGone)
}
func (h *Handler) offsiteKeyAuth(w http.ResponseWriter, r *http.Request, customerID string) bool {
authCustomerID, isGlobal, ok := h.checkAuthCustomer(r)
if !ok || (!isGlobal && authCustomerID != customerID) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return false
}
if h.offsiteKeys == nil {
http.Error(w, "offsite key registrar not configured", http.StatusServiceUnavailable)
return false
}
return true
}
func offsiteKeyErr(w http.ResponseWriter, err error) {
if errors.Is(err, offsitekeys.ErrNotProvisioned) {
http.Error(w, "no provisioned off-site target", http.StatusConflict)
return
}
http.Error(w, "registrar failed: "+err.Error(), http.StatusBadGateway)
}
// handleOffsiteRegisterKey: POST {"public_key": "ssh-ed25519 AAAA… comment"} → the hub writes it into the
// sub-account's authorized_keys pinned append-only and answers {"installed":true,"fingerprint":"SHA256:…"}.
// The response carries NO credential.
func (h *Handler) handleOffsiteRegisterKey(w http.ResponseWriter, r *http.Request, customerID string) {
if !h.offsiteKeyAuth(w, r, customerID) {
return
}
var req struct {
PublicKey string `json:"public_key"`
}
body, _ := io.ReadAll(io.LimitReader(r.Body, 16<<10))
if err := json.Unmarshal(body, &req); err != nil || req.PublicKey == "" {
http.Error(w, "body must be {\"public_key\": \"…\"}", http.StatusBadRequest)
return
}
if _, _, err := offsitekeys.KeyFingerprint(req.PublicKey); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Minute)
defer cancel()
res, err := h.offsiteKeys.RegisterKey(ctx, customerID, req.PublicKey)
if err != nil {
offsiteKeyErr(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"installed": true, "fingerprint": res.Fingerprint})
}
// handleOffsiteConfirmKey: POST {"fingerprint": "SHA256:…"} → only that key's pinned line stays.
func (h *Handler) handleOffsiteConfirmKey(w http.ResponseWriter, r *http.Request, customerID string) {
if !h.offsiteKeyAuth(w, r, customerID) {
return
}
var req struct {
Fingerprint string `json:"fingerprint"`
}
body, _ := io.ReadAll(io.LimitReader(r.Body, 4<<10))
if err := json.Unmarshal(body, &req); err != nil || req.Fingerprint == "" {
http.Error(w, "body must be {\"fingerprint\": \"SHA256:…\"}", http.StatusBadRequest)
return
}
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Minute)
defer cancel()
removed, err := h.offsiteKeys.ConfirmKey(ctx, customerID, req.Fingerprint)
if err != nil {
offsiteKeyErr(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"confirmed": true, "removed": removed})
}
// handleOffsiteMoveAside: POST → the hub renames the repository to <repo>.orphaned-<date>[-n]. Never deletes.
func (h *Handler) handleOffsiteMoveAside(w http.ResponseWriter, r *http.Request, customerID string) {
if !h.offsiteKeyAuth(w, r, customerID) {
return
}
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Minute)
defer cancel()
to, err := h.offsiteKeys.MoveAside(ctx, customerID)
if err != nil {
offsiteKeyErr(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"moved_to": to})
}
// handleOffsiteWindowOpen: POST {"count_before": N} → the hub decides (weekly / operator one-shot) and,
// if granted, prepends a deleting line for the box's confirmed key for 20 minutes (decision 68).
func (h *Handler) handleOffsiteWindowOpen(w http.ResponseWriter, r *http.Request, customerID string) {
if !h.offsiteKeyAuth(w, r, customerID) {
return
}
var req struct {
CountBefore int `json:"count_before"`
}
body, _ := io.ReadAll(io.LimitReader(r.Body, 4<<10))
_ = json.Unmarshal(body, &req)
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Minute)
defer cancel()
g, err := h.offsiteKeys.OpenWindowFor(ctx, customerID, req.CountBefore)
if err != nil {
offsiteKeyErr(w, err)
return
}
writeJSON(w, http.StatusOK, g)
}
// handleOffsiteWindowClose: POST the box's result → the hub removes the deleting line and checks the count.
func (h *Handler) handleOffsiteWindowClose(w http.ResponseWriter, r *http.Request, customerID string) {
if !h.offsiteKeyAuth(w, r, customerID) {
return
}
var req offsitekeys.WindowResult
body, _ := io.ReadAll(io.LimitReader(r.Body, 8<<10))
if err := json.Unmarshal(body, &req); err != nil || req.WindowID == 0 {
http.Error(w, "body must carry window_id", http.StatusBadRequest)
return
}
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Minute)
defer cancel()
if err := h.offsiteKeys.CloseWindowFor(ctx, customerID, req); err != nil {
offsiteKeyErr(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"closed": true})
}
+96 -18
View File
@@ -1,7 +1,11 @@
package api
import (
"context"
"encoding/json"
"strings"
"gitea.dooplex.hu/admin/felhom-hub/internal/offsitekeys"
"net/http"
"net/http/httptest"
"testing"
@@ -9,14 +13,16 @@ import (
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
)
// The one-time offsite password is served once to the authenticated customer, then 404s; a wrong/absent or
// cross-customer key is rejected.
func TestOffsite_ConsumePassword(t *testing.T) {
// R-820 / decision 69: the consume endpoint is RETIRED — it answers 410 and its body carries no
// password, even with a stored, unconsumed secret and the right key. Before v0.127.0 it returned the
// sub-account password, which can rewrite authorized_keys and remove the append-only pin.
func TestConsumePassword_RetiredReturnsNoPassword(t *testing.T) {
h, st, _ := newTestHandler(t)
st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "c1", APIKey: "ckey", RetrievalPassword: "pp"})
st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "c2", APIKey: "ckey2", RetrievalPassword: "pp2"})
st.SaveOneTimeSecret("c1", "the-transient-pw")
if err := st.SaveOneTimeSecret("c1", "the-transient-pw"); err != nil {
t.Fatal(err)
}
do := func(token string) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodPost, "/api/v1/offsite/consume-password/c1", nil)
if token != "" {
@@ -26,27 +32,99 @@ func TestOffsite_ConsumePassword(t *testing.T) {
h.ServeHTTP(rr, req)
return rr
}
if rr := do(""); rr.Code != http.StatusUnauthorized {
t.Fatalf("no auth → %d, want 401", rr.Code)
}
if rr := do("wrongkey"); rr.Code != http.StatusUnauthorized {
t.Fatalf("wrong key → %d, want 401", rr.Code)
}
if rr := do("ckey2"); rr.Code != http.StatusUnauthorized {
t.Fatalf("cross-customer key → %d, want 401", rr.Code)
}
// first (authorized) consume → 200 + the password
rr := do("ckey")
if rr.Code != http.StatusOK {
t.Fatalf("consume → %d, want 200", rr.Code)
if rr.Code != http.StatusGone {
t.Fatalf("consume → %d, want 410 (retired)", rr.Code)
}
var body map[string]string
if err := json.Unmarshal(rr.Body.Bytes(), &body); err != nil || body["password"] != "the-transient-pw" {
t.Fatalf("body = %q (%v)", rr.Body.String(), err)
if strings.Contains(rr.Body.String(), "the-transient-pw") {
t.Fatalf("the retired endpoint leaked the password: %q", rr.Body.String())
}
// second consume → 404 (single use)
if rr2 := do("ckey"); rr2.Code != http.StatusNotFound {
t.Fatalf("second consume → %d, want 404", rr2.Code)
// The stored secret is untouched (still usable by the HUB's registrar).
if pw, err := st.OffsitePassword("c1"); err != nil || pw != "the-transient-pw" {
t.Fatalf("stored credential changed: %v", err)
}
}
type fakeKeySvc struct {
gotPub string
gotFP string
err error
}
func (f *fakeKeySvc) RegisterKey(_ context.Context, _ string, pub string) (offsitekeys.InstallResult, error) {
f.gotPub = pub
return offsitekeys.InstallResult{Fingerprint: "SHA256:fake"}, f.err
}
func (f *fakeKeySvc) ConfirmKey(_ context.Context, _ string, fp string) (int, error) {
f.gotFP = fp
return 1, f.err
}
func (f *fakeKeySvc) OpenWindowFor(context.Context, string, int) (offsitekeys.WindowGrant, error) {
return offsitekeys.WindowGrant{Granted: false, Reason: "not due"}, f.err
}
func (f *fakeKeySvc) CloseWindowFor(context.Context, string, offsitekeys.WindowResult) error { return f.err }
func (f *fakeKeySvc) MoveAside(context.Context, string) (string, error) {
return "/home/felhom-repo.orphaned-20261003", f.err
}
const testPub = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIK436vIXGqfs6wz4Jv/GIIo3rQuW3oNnP7nMatI92gkA box"
// Every box-facing off-site key response: auth enforced, and NO response body carries the stored
// password (the decision-69 invariant, asserted on the consequence — the bytes the box receives).
func TestOffsiteKeyEndpoints_AuthAndNoPasswordInAnyResponse(t *testing.T) {
h, st, _ := newTestHandler(t)
st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "c1", APIKey: "ckey", RetrievalPassword: "pp"})
st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "c2", APIKey: "ckey2", RetrievalPassword: "pp2"})
if err := st.SaveOneTimeSecret("c1", "the-transient-pw"); err != nil {
t.Fatal(err)
}
f := &fakeKeySvc{}
h.SetOffsiteKeyService(f)
post := func(path, token, body string) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body))
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
return rr
}
reg := `{"public_key":"` + testPub + `"}`
if rr := post("/api/v1/offsite/register-key/c1", "ckey2", reg); rr.Code != http.StatusUnauthorized {
t.Fatalf("cross-customer register → %d, want 401", rr.Code)
}
if rr := post("/api/v1/offsite/register-key/c1", "ckey", `{"public_key":"command=\"x\" `+testPub+`"}`); rr.Code != http.StatusBadRequest {
t.Fatalf("a key with options → %d, want 400 (the hub writes the options)", rr.Code)
}
for _, c := range []struct{ path, body string }{
{"/api/v1/offsite/register-key/c1", reg},
{"/api/v1/offsite/confirm-key/c1", `{"fingerprint":"SHA256:fake"}`},
{"/api/v1/offsite/move-aside/c1", ``},
{"/api/v1/offsite/window-open/c1", `{"count_before":3}`},
{"/api/v1/offsite/window-close/c1", `{"window_id":1,"count_after":3,"outcome":"nothing"}`},
} {
rr := post(c.path, "ckey", c.body)
if rr.Code != http.StatusOK {
t.Fatalf("%s → %d (%s)", c.path, rr.Code, rr.Body.String())
}
if strings.Contains(rr.Body.String(), "the-transient-pw") {
t.Fatalf("%s leaked the password: %s", c.path, rr.Body.String())
}
var m map[string]any
if err := json.Unmarshal(rr.Body.Bytes(), &m); err != nil {
t.Fatalf("%s: not JSON: %v", c.path, err)
}
if _, ok := m["password"]; ok {
t.Fatalf("%s: a password field in the response", c.path)
}
}
if f.gotPub != testPub || f.gotFP != "SHA256:fake" {
t.Fatalf("service not reached: pub=%q fp=%q", f.gotPub, f.gotFP)
}
}