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 .orphaned-[-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}) }