Files
felhom.eu/hub/internal/api/offsite_test.go
T
2026-10-04 08:56:56 +02:00

163 lines
6.7 KiB
Go

package api
import (
"context"
"encoding/json"
"strings"
"gitea.dooplex.hu/admin/felhom-hub/internal/offsitekeys"
"net/http"
"net/http/httptest"
"testing"
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
)
// 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"})
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 != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
rr := httptest.NewRecorder()
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("ckey2"); rr.Code != http.StatusUnauthorized {
t.Fatalf("cross-customer key → %d, want 401", rr.Code)
}
rr := do("ckey")
if rr.Code != http.StatusGone {
t.Fatalf("consume → %d, want 410 (retired)", rr.Code)
}
if strings.Contains(rr.Body.String(), "the-transient-pw") {
t.Fatalf("the retired endpoint leaked the password: %q", rr.Body.String())
}
// 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) RequestAbandon(context.Context, string, string) (offsitekeys.AbandonStatus, error) {
return offsitekeys.AbandonStatus{State: "pending"}, f.err
}
func (f *fakeKeySvc) CancelAbandon(string, string) (int, error) { return 1, f.err }
func (f *fakeKeySvc) AbandonStatusFor(string) (offsitekeys.AbandonStatus, error) {
return offsitekeys.AbandonStatus{State: "none"}, 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"}`},
{"/api/v1/offsite/abandon-request/c1", `{"path":"/home/felhom-repo.orphaned-20261004"}`},
{"/api/v1/offsite/abandon-cancel/c1", ``},
{"/api/v1/offsite/abandon-status/c1", ``},
} {
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)
}
}
// R-833: a box cannot raise its own window cap. No box-authenticated route sets a grant, and a
// window-open body that names a cap is not a grant. Asserted on the consequence: the store holds no
// grant after every box call.
func TestOffsiteWindow_BoxCannotGrantItself(t *testing.T) {
h, st, _ := newTestHandler(t)
st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "c1", APIKey: "ckey", RetrievalPassword: "pp"})
h.SetOffsiteKeyService(&fakeKeySvc{})
for _, c := range []struct{ path, body string }{
{"/api/v1/offsite/window-open/c1", `{"count_before":40,"max_remove":400}`},
{"/api/v1/offsite/window-grant/c1", `{"max_remove":400}`},
{"/api/v1/offsite/window-large-grant/c1", `{"max_remove":400}`},
{"/offsite/window-grant/c1", `max_remove=400`},
} {
req := httptest.NewRequest(http.MethodPost, c.path, strings.NewReader(c.body))
req.Header.Set("Authorization", "Bearer ckey")
h.ServeHTTP(httptest.NewRecorder(), req)
}
if ok, n := st.TakeOffsiteWindowGrant("c1"); ok || n != 0 {
t.Fatalf("a box call left a grant (ok=%v max=%d)", ok, n)
}
}