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
+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})
}