55f7621c90
gates / gates (push) Successful in 30s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
163 lines
6.7 KiB
Go
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)
|
|
}
|
|
}
|