f417cdede1
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
169 lines
6.5 KiB
Go
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})
|
|
}
|