Skip to content

Commit 3b392a8

Browse files
TeoSlayerteovlclaude
authored
fix(nightly): repair WSS compat-auth skew + stale enterprise-gate test assertions (#381)
fix(nightly): repair real test-suite regressions + daemon WSS auth skew The nightly suite has been red since it was created (2026-05-28) and never green. Categorising every failure showed most were either real regressions or CPU-starvation artifacts of -parallel 4, not genuine env limits. Real fixes in this commit (all fail deterministically in isolation): - daemon WSS compat auth: pkg/daemon/transport/wss signed the pre-v0.2.6 challenge shape "compat_auth:<node>:<nonce>", but beacon v0.2.6 binds the server timestamp and verifies "compat_auth:<node>:<ts>:<nonce>". Real production skew — compat daemons could not authenticate to a v0.2.6 beacon. Parse the challenge `ts` and sign the new shape; update the fake-beacon unit test to match. Fixes TestCompatRegistryTLSPinned, TestCompatRegistryTrustSystemRejectsBadCert, TestCompatDaemonDialUDPPeerThroughWSS. - invite signer: common@v0.5.7's InviteToNetwork/RespondInvite/PollInvites now always sign, so every test issuing an invite must set a signer. Set the inviter's signer before each InviteToNetwork call across the invite, RBAC, audit, enterprise-gate, network and pilotctl suites. Several rejection-path tests were passing for the wrong reason (rejected on the missing signer, not the intended authz check) — they now sign so the registry performs the real authorization. - TestAdminKicksAdmin: registry policy PILOT-266 (2026-05-29) forbids an admin kicking another admin; assert the guard and that the owner can still kick an admin. - TestInviteRequiresAdmin: premise was stale (owner invites via signature without an admin token). Reworked to assert owner-by-signature succeeds and an unauthorized outsider is rejected. - dashboard /api/stats is now admin-gated (rich payload behind requireAdminToken; anonymous uses /api/public-stats). Set an admin token and authenticate. Fixes TestDashboard{HTTPEndpoints,APIShape,BannerEndpoint,NoIPLeak}. Co-authored-by: Teodor Calin <teodor@vulturelabs.io> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 0921e03 commit 3b392a8

9 files changed

Lines changed: 160 additions & 57 deletions

pkg/daemon/transport/wss/wss.go

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -71,15 +71,16 @@ const DefaultRecvBuffer = 256
7171
// channel; everything after auth_ok is binary frames carrying raw
7272
// Pilot packets.
7373
type authChallengeMsg struct {
74-
Type string `json:"type"` // "auth_challenge"
75-
Nonce string `json:"nonce"` // 32 random bytes, hex-encoded
74+
Type string `json:"type"` // "auth_challenge"
75+
Nonce string `json:"nonce"` // 32 random bytes, hex-encoded
76+
Timestamp int64 `json:"ts"` // Unix epoch seconds, server-issued (replay window)
7677
}
7778

7879
type authReplyMsg struct {
7980
Type string `json:"type"` // "auth_reply"
8081
NodeID uint32 `json:"node_id"`
8182
PublicKey string `json:"public_key"` // base64 Ed25519 pubkey
82-
Sig string `json:"sig"` // base64 Ed25519 signature over "compat_auth:"+node_id+":"+nonce
83+
Sig string `json:"sig"` // base64 Ed25519 signature over "compat_auth:"+node_id+":"+ts+":"+nonce
8384
}
8485

8586
type authOKMsg struct {
@@ -274,10 +275,11 @@ func (t *Transport) runAuth(ctx context.Context, conn *websocket.Conn) error {
274275
return fmt.Errorf("malformed challenge: type=%q nonce-len=%d", ch.Type, len(ch.Nonce))
275276
}
276277

277-
// Sign "compat_auth:<nodeID>:<nonce>" — same shape the beacon
278-
// verifies. Binding nodeID + nonce into the signed bytes prevents
279-
// replay across different daemon identities.
280-
msg := fmt.Sprintf("compat_auth:%d:%s", t.cfg.NodeID, ch.Nonce)
278+
// Sign "compat_auth:<nodeID>:<ts>:<nonce>" — same shape the beacon
279+
// verifies (beacon >= v0.2.6). Binding nodeID + server timestamp +
280+
// nonce into the signed bytes prevents replay across identities and
281+
// bounds the auth to the server's freshness window.
282+
msg := fmt.Sprintf("compat_auth:%d:%d:%s", t.cfg.NodeID, ch.Timestamp, ch.Nonce)
281283
sig := t.cfg.Identity.Sign([]byte(msg))
282284

283285
reply := authReplyMsg{

pkg/daemon/transport/wss/zz_wss_test.go

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -106,9 +106,11 @@ func (fb *fakeBeacon) handle(w http.ResponseWriter, r *http.Request) {
106106
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
107107
defer cancel()
108108

109-
// Send auth challenge with a fixed nonce so we can assert on it.
109+
// Send auth challenge with a fixed nonce + timestamp so we can assert
110+
// on the signed payload (beacon >= v0.2.6 binds ts into the signature).
110111
nonce := "deadbeef12345678deadbeef12345678"
111-
ch := map[string]string{"type": "auth_challenge", "nonce": nonce}
112+
const ts int64 = 1700000000
113+
ch := map[string]interface{}{"type": "auth_challenge", "nonce": nonce, "ts": ts}
112114
chBytes, _ := json.Marshal(ch)
113115
if err := conn.Write(ctx, websocket.MessageText, chBytes); err != nil {
114116
fb.t.Logf("fake beacon: write challenge: %v", err)
@@ -154,7 +156,7 @@ func (fb *fakeBeacon) handle(w http.ResponseWriter, r *http.Request) {
154156
conn.Close(websocket.StatusPolicyViolation, "bad sig b64")
155157
return
156158
}
157-
signed := fmt.Sprintf("compat_auth:%d:%s", reply.NodeID, nonce)
159+
signed := fmt.Sprintf("compat_auth:%d:%d:%s", reply.NodeID, ts, nonce)
158160
if !ed25519.Verify(ed25519.PublicKey(pubBytes), []byte(signed), sigBytes) {
159161
conn.Close(websocket.StatusPolicyViolation, "sig verify failed")
160162
return

tests/zz_audit_test.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -361,7 +361,7 @@ func TestAuditInviteActions(t *testing.T) {
361361
}
362362
defer rc.Close()
363363

364-
creatorID, _ := registerTestNode(t, rc)
364+
creatorID, creatorIdentity := registerTestNode(t, rc)
365365
resp, err := rc.CreateNetwork(creatorID, "audit-invite-net", "invite", "", TestAdminToken, true)
366366
if err != nil {
367367
t.Fatalf("create network: %v", err)
@@ -370,6 +370,8 @@ func TestAuditInviteActions(t *testing.T) {
370370

371371
targetID, targetIdentity := registerTestNode(t, rc)
372372

373+
// InviteToNetwork always signs (common@v0.5.7); sign as the inviter.
374+
setClientSigner(rc, creatorIdentity)
373375
_, err = rc.InviteToNetwork(netID, creatorID, targetID, TestAdminToken)
374376
if err != nil {
375377
t.Fatalf("invite: %v", err)

tests/zz_dashboard_test.go

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,10 @@ func TestDashboardHTTPEndpoints(t *testing.T) {
8181

8282
r := registry.New("127.0.0.1:9001")
8383
defer r.Close()
84+
// /api/stats is admin-gated (rich payload moved behind requireAdminToken;
85+
// anonymous callers use /api/public-stats). Authenticate as an operator.
86+
const adminToken = "dash-http-admin-token"
87+
r.SetAdminToken(adminToken)
8488

8589
// Find a free port for the dashboard
8690
ln, err := net.Listen("tcp", "127.0.0.1:0")
@@ -96,8 +100,9 @@ func TestDashboardHTTPEndpoints(t *testing.T) {
96100
var client http.Client
97101
client.Timeout = 2 * time.Second
98102
var resp *http.Response
103+
statsURL := fmt.Sprintf("http://%s/api/stats?admin_token=%s", dashAddr, adminToken)
99104
for i := 0; i < 20; i++ {
100-
resp, err = client.Get(fmt.Sprintf("http://%s/api/stats", dashAddr))
105+
resp, err = client.Get(statsURL)
101106
if err == nil {
102107
break
103108
}
@@ -163,6 +168,9 @@ func TestDashboardNoIPLeak(t *testing.T) {
163168
go r.ListenAndServe("127.0.0.1:0")
164169
<-r.Ready()
165170
defer r.Close()
171+
// /api/stats is admin-gated; authenticate as an operator.
172+
const adminToken = "dash-leak-admin-token"
173+
r.SetAdminToken(adminToken)
166174

167175
addr := r.Addr().String()
168176
dashRegisterNode(t, addr, "leak-test")
@@ -180,8 +188,9 @@ func TestDashboardNoIPLeak(t *testing.T) {
180188
var client http.Client
181189
client.Timeout = 2 * time.Second
182190
var resp *http.Response
191+
statsURL := fmt.Sprintf("http://%s/api/stats?admin_token=%s", dashAddr, adminToken)
183192
for i := 0; i < 20; i++ {
184-
resp, err = client.Get(fmt.Sprintf("http://%s/api/stats", dashAddr))
193+
resp, err = client.Get(statsURL)
185194
if err == nil {
186195
break
187196
}
@@ -219,6 +228,11 @@ func TestDashboardAPIShape(t *testing.T) {
219228
defer r.Close()
220229

221230
r.SetDashboardToken("shape-test-token")
231+
// /api/stats is admin-gated; operators reach it with the admin token.
232+
// The dashboard `token` param still toggles per-network (authenticated)
233+
// fields on top of the admin gate.
234+
const adminToken = "shape-admin-token"
235+
r.SetAdminToken(adminToken)
222236

223237
ln, err := net.Listen("tcp", "127.0.0.1:0")
224238
if err != nil {
@@ -234,9 +248,9 @@ func TestDashboardAPIShape(t *testing.T) {
234248

235249
fetch := func(token string) map[string]interface{} {
236250
t.Helper()
237-
url := fmt.Sprintf("http://%s/api/stats", dashAddr)
251+
url := fmt.Sprintf("http://%s/api/stats?admin_token=%s", dashAddr, adminToken)
238252
if token != "" {
239-
url += "?token=" + token
253+
url += "&token=" + token
240254
}
241255
var resp *http.Response
242256
for i := 0; i < 20; i++ {
@@ -404,9 +418,9 @@ func TestDashboardBannerEndpoint(t *testing.T) {
404418
t.Fatalf("GET banner = %q, want %q", getResp.Banner, newBanner)
405419
}
406420

407-
// 6. The new banner must surface in the public /api/stats payload so
408-
// the dashboard HTML renders it.
409-
statsResp, err := client.Get(fmt.Sprintf("http://%s/api/stats", dashAddr))
421+
// 6. The new banner must surface in the /api/stats payload so the
422+
// dashboard HTML renders it (admin-gated; authenticate as operator).
423+
statsResp, err := client.Get(fmt.Sprintf("http://%s/api/stats?admin_token=%s", dashAddr, adminToken))
410424
if err != nil {
411425
t.Fatalf("GET stats: %v", err)
412426
}

tests/zz_enterprise_gate_test.go

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,7 @@ func TestEnterpriseGateInvite(t *testing.T) {
147147
rc, _, cleanup := startTestRegistryWithAdmin(t)
148148
defer cleanup()
149149

150-
ownerID, _ := registerTestNode(t, rc)
150+
ownerID, ownerIdentity := registerTestNode(t, rc)
151151
targetID, _ := registerTestNode(t, rc)
152152

153153
// Create an enterprise invite-only network (only way to get invite rule)
@@ -157,7 +157,9 @@ func TestEnterpriseGateInvite(t *testing.T) {
157157
}
158158
netID := uint16(resp["network_id"].(float64))
159159

160-
// This should succeed (enterprise network)
160+
// This should succeed (enterprise network). InviteToNetwork always signs
161+
// (common@v0.5.7); sign as the owner/inviter.
162+
setClientSigner(rc, ownerIdentity)
161163
_, err = rc.InviteToNetwork(netID, ownerID, targetID, TestAdminToken)
162164
if err != nil {
163165
t.Fatalf("invite on enterprise network should succeed: %v", err)
@@ -638,7 +640,7 @@ func TestDeleteNetworkCleansInvites(t *testing.T) {
638640
rc, _, cleanup := startTestRegistryWithAdmin(t)
639641
defer cleanup()
640642

641-
owner, _ := registerTestNode(t, rc)
643+
owner, ownerIdentity := registerTestNode(t, rc)
642644
target, targetID := registerTestNode(t, rc)
643645

644646
// Create invite-only enterprise network
@@ -648,7 +650,9 @@ func TestDeleteNetworkCleansInvites(t *testing.T) {
648650
}
649651
netID := uint16(resp["network_id"].(float64))
650652

651-
// Send invite to target
653+
// Send invite to target. InviteToNetwork always signs (common@v0.5.7);
654+
// sign as the owner/inviter.
655+
setClientSigner(rc, ownerIdentity)
652656
if _, err := rc.InviteToNetwork(netID, owner, target, TestAdminToken); err != nil {
653657
t.Fatalf("invite: %v", err)
654658
}
@@ -1824,7 +1828,10 @@ func TestAuditEnrichedTagsAndPolicy(t *testing.T) {
18241828
}
18251829
}
18261830

1827-
// TestAdminKicksAdmin verifies that an admin can kick another admin.
1831+
// TestAdminKicksAdmin verifies the admin-kick privilege policy: an admin
1832+
// may NOT kick another admin (privilege-escalation guard, PILOT-266), but
1833+
// the owner may kick an admin. Admins may still be blocked from kicking the
1834+
// owner.
18281835
func TestAdminKicksAdmin(t *testing.T) {
18291836
t.Parallel()
18301837
env := NewTestEnv(t)
@@ -1897,13 +1904,26 @@ func TestAdminKicksAdmin(t *testing.T) {
18971904
t.Fatalf("promote admin2: %v", err)
18981905
}
18991906

1900-
// Admin1 kicks Admin2 (admin kicking admin — should succeed)
1907+
// Admin1 kicks Admin2 (admin kicking admin — must be REJECTED by the
1908+
// privilege-escalation guard added in PILOT-266).
19011909
setClientSigner(rc, admin1Identity)
19021910
_, err = rc.KickMember(netID, admin1ID, admin2ID, TestAdminToken)
1911+
if err == nil {
1912+
t.Fatal("expected error: an admin must not be able to kick another admin")
1913+
}
1914+
if !strings.Contains(err.Error(), "admins cannot kick other admins") {
1915+
t.Fatalf("expected 'admins cannot kick other admins' error, got: %v", err)
1916+
}
1917+
t.Logf("admin correctly blocked from kicking another admin: %v", err)
1918+
1919+
// Owner kicks Admin2 (owner kicking admin — should succeed). This keeps
1920+
// the successful-kick code path under test.
1921+
setClientSigner(rc, ownerIdentity)
1922+
_, err = rc.KickMember(netID, ownerID, admin2ID, TestAdminToken)
19031923
if err != nil {
1904-
t.Fatalf("admin1 kick admin2: %v", err)
1924+
t.Fatalf("owner kick admin2: %v", err)
19051925
}
1906-
t.Log("admin successfully kicked another admin")
1926+
t.Log("owner successfully kicked an admin")
19071927

19081928
// Verify admin2 is no longer in the network
19091929
resp, err = rc.ListNodes(netID, TestAdminToken)
@@ -1919,6 +1939,7 @@ func TestAdminKicksAdmin(t *testing.T) {
19191939
}
19201940

19211941
// Admin cannot kick owner
1942+
setClientSigner(rc, admin1Identity)
19221943
_, err = rc.KickMember(netID, admin1ID, ownerID, TestAdminToken)
19231944
if err == nil {
19241945
t.Fatal("expected error kicking owner")

0 commit comments

Comments
 (0)