Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 9 additions & 7 deletions pkg/daemon/transport/wss/wss.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,15 +71,16 @@ const DefaultRecvBuffer = 256
// channel; everything after auth_ok is binary frames carrying raw
// Pilot packets.
type authChallengeMsg struct {
Type string `json:"type"` // "auth_challenge"
Nonce string `json:"nonce"` // 32 random bytes, hex-encoded
Type string `json:"type"` // "auth_challenge"
Nonce string `json:"nonce"` // 32 random bytes, hex-encoded
Timestamp int64 `json:"ts"` // Unix epoch seconds, server-issued (replay window)
}

type authReplyMsg struct {
Type string `json:"type"` // "auth_reply"
NodeID uint32 `json:"node_id"`
PublicKey string `json:"public_key"` // base64 Ed25519 pubkey
Sig string `json:"sig"` // base64 Ed25519 signature over "compat_auth:"+node_id+":"+nonce
Sig string `json:"sig"` // base64 Ed25519 signature over "compat_auth:"+node_id+":"+ts+":"+nonce
}

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

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

reply := authReplyMsg{
Expand Down
8 changes: 5 additions & 3 deletions pkg/daemon/transport/wss/zz_wss_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -106,9 +106,11 @@ func (fb *fakeBeacon) handle(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()

// Send auth challenge with a fixed nonce so we can assert on it.
// Send auth challenge with a fixed nonce + timestamp so we can assert
// on the signed payload (beacon >= v0.2.6 binds ts into the signature).
nonce := "deadbeef12345678deadbeef12345678"
ch := map[string]string{"type": "auth_challenge", "nonce": nonce}
const ts int64 = 1700000000
ch := map[string]interface{}{"type": "auth_challenge", "nonce": nonce, "ts": ts}
chBytes, _ := json.Marshal(ch)
if err := conn.Write(ctx, websocket.MessageText, chBytes); err != nil {
fb.t.Logf("fake beacon: write challenge: %v", err)
Expand Down Expand Up @@ -154,7 +156,7 @@ func (fb *fakeBeacon) handle(w http.ResponseWriter, r *http.Request) {
conn.Close(websocket.StatusPolicyViolation, "bad sig b64")
return
}
signed := fmt.Sprintf("compat_auth:%d:%s", reply.NodeID, nonce)
signed := fmt.Sprintf("compat_auth:%d:%d:%s", reply.NodeID, ts, nonce)
if !ed25519.Verify(ed25519.PublicKey(pubBytes), []byte(signed), sigBytes) {
conn.Close(websocket.StatusPolicyViolation, "sig verify failed")
return
Expand Down
4 changes: 3 additions & 1 deletion tests/zz_audit_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -361,7 +361,7 @@ func TestAuditInviteActions(t *testing.T) {
}
defer rc.Close()

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

targetID, targetIdentity := registerTestNode(t, rc)

// InviteToNetwork always signs (common@v0.5.7); sign as the inviter.
setClientSigner(rc, creatorIdentity)
_, err = rc.InviteToNetwork(netID, creatorID, targetID, TestAdminToken)
if err != nil {
t.Fatalf("invite: %v", err)
Expand Down
28 changes: 21 additions & 7 deletions tests/zz_dashboard_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,10 @@ func TestDashboardHTTPEndpoints(t *testing.T) {

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

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

addr := r.Addr().String()
dashRegisterNode(t, addr, "leak-test")
Expand All @@ -180,8 +188,9 @@ func TestDashboardNoIPLeak(t *testing.T) {
var client http.Client
client.Timeout = 2 * time.Second
var resp *http.Response
statsURL := fmt.Sprintf("http://%s/api/stats?admin_token=%s", dashAddr, adminToken)
for i := 0; i < 20; i++ {
resp, err = client.Get(fmt.Sprintf("http://%s/api/stats", dashAddr))
resp, err = client.Get(statsURL)
if err == nil {
break
}
Expand Down Expand Up @@ -219,6 +228,11 @@ func TestDashboardAPIShape(t *testing.T) {
defer r.Close()

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

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

fetch := func(token string) map[string]interface{} {
t.Helper()
url := fmt.Sprintf("http://%s/api/stats", dashAddr)
url := fmt.Sprintf("http://%s/api/stats?admin_token=%s", dashAddr, adminToken)
if token != "" {
url += "?token=" + token
url += "&token=" + token
}
var resp *http.Response
for i := 0; i < 20; i++ {
Expand Down Expand Up @@ -404,9 +418,9 @@ func TestDashboardBannerEndpoint(t *testing.T) {
t.Fatalf("GET banner = %q, want %q", getResp.Banner, newBanner)
}

// 6. The new banner must surface in the public /api/stats payload so
// the dashboard HTML renders it.
statsResp, err := client.Get(fmt.Sprintf("http://%s/api/stats", dashAddr))
// 6. The new banner must surface in the /api/stats payload so the
// dashboard HTML renders it (admin-gated; authenticate as operator).
statsResp, err := client.Get(fmt.Sprintf("http://%s/api/stats?admin_token=%s", dashAddr, adminToken))
if err != nil {
t.Fatalf("GET stats: %v", err)
}
Expand Down
37 changes: 29 additions & 8 deletions tests/zz_enterprise_gate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ func TestEnterpriseGateInvite(t *testing.T) {
rc, _, cleanup := startTestRegistryWithAdmin(t)
defer cleanup()

ownerID, _ := registerTestNode(t, rc)
ownerID, ownerIdentity := registerTestNode(t, rc)
targetID, _ := registerTestNode(t, rc)

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

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

owner, _ := registerTestNode(t, rc)
owner, ownerIdentity := registerTestNode(t, rc)
target, targetID := registerTestNode(t, rc)

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

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

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

// Admin1 kicks Admin2 (admin kicking admin — should succeed)
// Admin1 kicks Admin2 (admin kicking admin — must be REJECTED by the
// privilege-escalation guard added in PILOT-266).
setClientSigner(rc, admin1Identity)
_, err = rc.KickMember(netID, admin1ID, admin2ID, TestAdminToken)
if err == nil {
t.Fatal("expected error: an admin must not be able to kick another admin")
}
if !strings.Contains(err.Error(), "admins cannot kick other admins") {
t.Fatalf("expected 'admins cannot kick other admins' error, got: %v", err)
}
t.Logf("admin correctly blocked from kicking another admin: %v", err)

// Owner kicks Admin2 (owner kicking admin — should succeed). This keeps
// the successful-kick code path under test.
setClientSigner(rc, ownerIdentity)
_, err = rc.KickMember(netID, ownerID, admin2ID, TestAdminToken)
if err != nil {
t.Fatalf("admin1 kick admin2: %v", err)
t.Fatalf("owner kick admin2: %v", err)
}
t.Log("admin successfully kicked another admin")
t.Log("owner successfully kicked an admin")

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

// Admin cannot kick owner
setClientSigner(rc, admin1Identity)
_, err = rc.KickMember(netID, admin1ID, ownerID, TestAdminToken)
if err == nil {
t.Fatal("expected error kicking owner")
Expand Down
Loading
Loading