Files
felhom.eu/hub/internal/api/offsite.go
T
admin f417cdede1
gates / gates (push) Successful in 29s
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
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
2026-10-03 16:57:04 +02:00

169 lines
6.5 KiB
Go

package api
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"time"
"gitea.dooplex.hu/admin/felhom-hub/internal/offsitekeys"
)
// 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
}
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})
}