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
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:
+154
-17
@@ -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})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user