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:
@@ -49,6 +49,7 @@ type Poker interface {
|
||||
// Handler handles API endpoints for report ingest and customer queries.
|
||||
type Handler struct {
|
||||
store *store.Store
|
||||
offsiteKeys OffsiteKeyService // decision 69 key registrar (nil → 503)
|
||||
apiKey string
|
||||
resendAPIKey string
|
||||
fromEmail string
|
||||
@@ -355,6 +356,16 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
case r.Method == http.MethodPost && strings.HasPrefix(path, "/offsite/consume-password/"):
|
||||
customerID := strings.TrimPrefix(path, "/offsite/consume-password/")
|
||||
h.handleOffsiteConsumePassword(w, r, customerID)
|
||||
case r.Method == http.MethodPost && strings.HasPrefix(path, "/offsite/register-key/"):
|
||||
h.handleOffsiteRegisterKey(w, r, strings.TrimPrefix(path, "/offsite/register-key/"))
|
||||
case r.Method == http.MethodPost && strings.HasPrefix(path, "/offsite/confirm-key/"):
|
||||
h.handleOffsiteConfirmKey(w, r, strings.TrimPrefix(path, "/offsite/confirm-key/"))
|
||||
case r.Method == http.MethodPost && strings.HasPrefix(path, "/offsite/move-aside/"):
|
||||
h.handleOffsiteMoveAside(w, r, strings.TrimPrefix(path, "/offsite/move-aside/"))
|
||||
case r.Method == http.MethodPost && strings.HasPrefix(path, "/offsite/window-open/"):
|
||||
h.handleOffsiteWindowOpen(w, r, strings.TrimPrefix(path, "/offsite/window-open/"))
|
||||
case r.Method == http.MethodPost && strings.HasPrefix(path, "/offsite/window-close/"):
|
||||
h.handleOffsiteWindowClose(w, r, strings.TrimPrefix(path, "/offsite/window-close/"))
|
||||
case r.Method == http.MethodGet && strings.HasPrefix(path, "/artifacts/"):
|
||||
customerID := strings.TrimPrefix(path, "/artifacts/")
|
||||
h.handleArtifactManifest(w, r, customerID)
|
||||
@@ -2049,8 +2060,11 @@ var allowedEventTypes = map[string]bool{
|
||||
"offsite_proof_empty": true,
|
||||
// R-431 — the hub raises this itself; allowlisted so a hub-origin event is never 400'd.
|
||||
"offsite_snapshots_dropped": true,
|
||||
"crossdrive_completed": true,
|
||||
"crossdrive_failed": true,
|
||||
// controller v0.289.0 (decision 69): the customer-chosen deletion of set-aside history is deferred
|
||||
// to the operator — the box's append-only key cannot delete. Operator-only (notify.operatorOnlyEvents).
|
||||
"offbox_abandon_deferred": true,
|
||||
"crossdrive_completed": true,
|
||||
"crossdrive_failed": true,
|
||||
// controller v0.134.1 — enlarged offsite push refused by the quota gate (warning; the controller's
|
||||
// dynamic Hungarian message is customer-grade — deliberately NO customerMessages entry, which would
|
||||
// discard the numbers (templates.go:129 priority)).
|
||||
|
||||
+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})
|
||||
}
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/offsitekeys"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
@@ -9,14 +13,16 @@ import (
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
|
||||
)
|
||||
|
||||
// The one-time offsite password is served once to the authenticated customer, then 404s; a wrong/absent or
|
||||
// cross-customer key is rejected.
|
||||
func TestOffsite_ConsumePassword(t *testing.T) {
|
||||
// R-820 / decision 69: the consume endpoint is RETIRED — it answers 410 and its body carries no
|
||||
// password, even with a stored, unconsumed secret and the right key. Before v0.127.0 it returned the
|
||||
// sub-account password, which can rewrite authorized_keys and remove the append-only pin.
|
||||
func TestConsumePassword_RetiredReturnsNoPassword(t *testing.T) {
|
||||
h, st, _ := newTestHandler(t)
|
||||
st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "c1", APIKey: "ckey", RetrievalPassword: "pp"})
|
||||
st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "c2", APIKey: "ckey2", RetrievalPassword: "pp2"})
|
||||
st.SaveOneTimeSecret("c1", "the-transient-pw")
|
||||
|
||||
if err := st.SaveOneTimeSecret("c1", "the-transient-pw"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
do := func(token string) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/offsite/consume-password/c1", nil)
|
||||
if token != "" {
|
||||
@@ -26,27 +32,99 @@ func TestOffsite_ConsumePassword(t *testing.T) {
|
||||
h.ServeHTTP(rr, req)
|
||||
return rr
|
||||
}
|
||||
|
||||
if rr := do(""); rr.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("no auth → %d, want 401", rr.Code)
|
||||
}
|
||||
if rr := do("wrongkey"); rr.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("wrong key → %d, want 401", rr.Code)
|
||||
}
|
||||
if rr := do("ckey2"); rr.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("cross-customer key → %d, want 401", rr.Code)
|
||||
}
|
||||
// first (authorized) consume → 200 + the password
|
||||
rr := do("ckey")
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("consume → %d, want 200", rr.Code)
|
||||
if rr.Code != http.StatusGone {
|
||||
t.Fatalf("consume → %d, want 410 (retired)", rr.Code)
|
||||
}
|
||||
var body map[string]string
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &body); err != nil || body["password"] != "the-transient-pw" {
|
||||
t.Fatalf("body = %q (%v)", rr.Body.String(), err)
|
||||
if strings.Contains(rr.Body.String(), "the-transient-pw") {
|
||||
t.Fatalf("the retired endpoint leaked the password: %q", rr.Body.String())
|
||||
}
|
||||
// second consume → 404 (single use)
|
||||
if rr2 := do("ckey"); rr2.Code != http.StatusNotFound {
|
||||
t.Fatalf("second consume → %d, want 404", rr2.Code)
|
||||
// The stored secret is untouched (still usable by the HUB's registrar).
|
||||
if pw, err := st.OffsitePassword("c1"); err != nil || pw != "the-transient-pw" {
|
||||
t.Fatalf("stored credential changed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
type fakeKeySvc struct {
|
||||
gotPub string
|
||||
gotFP string
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeKeySvc) RegisterKey(_ context.Context, _ string, pub string) (offsitekeys.InstallResult, error) {
|
||||
f.gotPub = pub
|
||||
return offsitekeys.InstallResult{Fingerprint: "SHA256:fake"}, f.err
|
||||
}
|
||||
func (f *fakeKeySvc) ConfirmKey(_ context.Context, _ string, fp string) (int, error) {
|
||||
f.gotFP = fp
|
||||
return 1, f.err
|
||||
}
|
||||
func (f *fakeKeySvc) OpenWindowFor(context.Context, string, int) (offsitekeys.WindowGrant, error) {
|
||||
return offsitekeys.WindowGrant{Granted: false, Reason: "not due"}, f.err
|
||||
}
|
||||
func (f *fakeKeySvc) CloseWindowFor(context.Context, string, offsitekeys.WindowResult) error { return f.err }
|
||||
func (f *fakeKeySvc) MoveAside(context.Context, string) (string, error) {
|
||||
return "/home/felhom-repo.orphaned-20261003", f.err
|
||||
}
|
||||
|
||||
const testPub = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIK436vIXGqfs6wz4Jv/GIIo3rQuW3oNnP7nMatI92gkA box"
|
||||
|
||||
// Every box-facing off-site key response: auth enforced, and NO response body carries the stored
|
||||
// password (the decision-69 invariant, asserted on the consequence — the bytes the box receives).
|
||||
func TestOffsiteKeyEndpoints_AuthAndNoPasswordInAnyResponse(t *testing.T) {
|
||||
h, st, _ := newTestHandler(t)
|
||||
st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "c1", APIKey: "ckey", RetrievalPassword: "pp"})
|
||||
st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "c2", APIKey: "ckey2", RetrievalPassword: "pp2"})
|
||||
if err := st.SaveOneTimeSecret("c1", "the-transient-pw"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f := &fakeKeySvc{}
|
||||
h.SetOffsiteKeyService(f)
|
||||
post := func(path, token, body string) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body))
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
rr := httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, req)
|
||||
return rr
|
||||
}
|
||||
reg := `{"public_key":"` + testPub + `"}`
|
||||
if rr := post("/api/v1/offsite/register-key/c1", "ckey2", reg); rr.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("cross-customer register → %d, want 401", rr.Code)
|
||||
}
|
||||
if rr := post("/api/v1/offsite/register-key/c1", "ckey", `{"public_key":"command=\"x\" `+testPub+`"}`); rr.Code != http.StatusBadRequest {
|
||||
t.Fatalf("a key with options → %d, want 400 (the hub writes the options)", rr.Code)
|
||||
}
|
||||
for _, c := range []struct{ path, body string }{
|
||||
{"/api/v1/offsite/register-key/c1", reg},
|
||||
{"/api/v1/offsite/confirm-key/c1", `{"fingerprint":"SHA256:fake"}`},
|
||||
{"/api/v1/offsite/move-aside/c1", ``},
|
||||
{"/api/v1/offsite/window-open/c1", `{"count_before":3}`},
|
||||
{"/api/v1/offsite/window-close/c1", `{"window_id":1,"count_after":3,"outcome":"nothing"}`},
|
||||
} {
|
||||
rr := post(c.path, "ckey", c.body)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("%s → %d (%s)", c.path, rr.Code, rr.Body.String())
|
||||
}
|
||||
if strings.Contains(rr.Body.String(), "the-transient-pw") {
|
||||
t.Fatalf("%s leaked the password: %s", c.path, rr.Body.String())
|
||||
}
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &m); err != nil {
|
||||
t.Fatalf("%s: not JSON: %v", c.path, err)
|
||||
}
|
||||
if _, ok := m["password"]; ok {
|
||||
t.Fatalf("%s: a password field in the response", c.path)
|
||||
}
|
||||
}
|
||||
if f.gotPub != testPub || f.gotFP != "SHA256:fake" {
|
||||
t.Fatalf("service not reached: pub=%q fp=%q", f.gotPub, f.gotFP)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user