Files
NexusGate/panel-go/agenttoken_test.go
T
aaaamoooo 408b03ee68 feat(agent-auth): session tokens replace per-request scrypt (item 25)
Panel: after one scrypt-verified key auth, issue a random 24h
session token; store only its SHA-256 hash + expiry on the agent
record. authenticateAgent tries the token path first (constant-time
hash compare, active + unexpired), then the original fingerprint /
legacy VerifySecret path, and hands fresh tokens out via the
X-Agent-Session-Token response headers. Revoke() now clears token
material at all three revocation sites.

Agent: persist the token in the config dir (0600, temp+rename like
keys.json), present it as bearer, and on any 401 clear it and retry
once with the key (which re-issues). Old panels never send the
headers, so key-only operation is unchanged; old agents ignore the
headers. Version stays 0.6.8.

Tests: panel — issuance, hash-only storage, scrypt-free token path
(sabotaged KeyHash still authenticates), expiry fallback + rotation,
hash-mismatch fallback, revocation, per-record binding, key-only
rotation. Agent — capture/persist/reuse against a fake panel, 401
fallback and re-issue, old-panel key-only, client-side expiry,
double rejection surfacing without retry loops.
2026-10-21 13:12:19 +00:00

256 lines
8.7 KiB
Go

package main
// Item 25: agent session tokens. After one scrypt-verified key auth the
// panel issues a random session token (only its SHA-256 hash is stored);
// subsequent requests authenticate by hash compare. These tests drive
// the real /api/agent/heartbeat route through the smoke harness.
import (
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"time"
)
const testAgentKey = "test-agent-key-0123456789abcdef-XYZ"
func seedAgentApp(t *testing.T, key string) (*App, string) {
t.Helper()
cookieSecure = false
store := NewStore(filepath.Join(t.TempDir(), "nexusgate.json"))
if err := store.Init(); err != nil {
t.Fatalf("store init: %v", err)
}
fp := TokenFingerprint(key)
agentID := ""
_, err := store.Transaction(func(d *Data) (any, error) {
d.Servers = append(d.Servers, &Server{
ID: "srv_tok", Name: "tok", Status: "online",
CreatedAt: NowISO(), UpdatedAt: NowISO(),
})
agent := &Agent{
ID: NewID("agt"),
ServerID: "srv_tok",
KeyHash: HashSecret(key),
KeyFingerprint: &fp,
Status: "active",
CreatedAt: NowISO(),
LastSeenAt: "2000-01-01T00:00:00.000Z",
}
agentID = agent.ID
d.Agents = append(d.Agents, agent)
return nil, nil
})
if err != nil {
t.Fatalf("seed agent: %v", err)
}
return NewApp(store, NewSessionManager(12), NewOrchestrator(store)), agentID
}
func agentHeartbeat(t *testing.T, app *App, bearer string) *httptest.ResponseRecorder {
t.Helper()
return doRequest(app, http.MethodPost, "/api/agent/heartbeat", "{}", map[string]string{
"Authorization": "Bearer " + bearer,
})
}
func storedAgent(t *testing.T, app *App, agentID string) *Agent {
t.Helper()
var out *Agent
app.store.View(func(d *Data) {
for _, agent := range d.Agents {
if agent.ID == agentID {
copied := *agent
out = &copied
}
}
})
if out == nil {
t.Fatalf("agent %s not in store", agentID)
}
return out
}
func mutateAgent(t *testing.T, app *App, agentID string, fn func(*Agent)) {
t.Helper()
_, err := app.store.Transaction(func(d *Data) (any, error) {
for _, agent := range d.Agents {
if agent.ID == agentID {
fn(agent)
}
}
return nil, nil
})
if err != nil {
t.Fatalf("mutate agent: %v", err)
}
}
// Key auth succeeds and issues a session token whose hash (and only the
// hash) lands on the agent record.
func TestAgentSessionTokenIssuedOnKeyAuth(t *testing.T) {
app, agentID := seedAgentApp(t, testAgentKey)
rec := agentHeartbeat(t, app, testAgentKey)
if rec.Code != 200 {
t.Fatalf("key heartbeat: got %d (%s)", rec.Code, rec.Body.String())
}
token := rec.Header().Get(agentSessionTokenHeader)
expires := rec.Header().Get(agentSessionTokenExpiresHeader)
if token == "" || expires == "" {
t.Fatalf("no session token issued: token=%q expires=%q", token, expires)
}
if token == testAgentKey {
t.Fatal("session token must differ from the agent key")
}
stored := storedAgent(t, app, agentID)
if stored.SessionTokenHash == nil || *stored.SessionTokenHash != TokenFingerprint(token) {
t.Fatalf("stored hash does not match issued token: %+v", stored.SessionTokenHash)
}
if stored.SessionTokenExpiresAt == nil || *stored.SessionTokenExpiresAt != expires {
t.Fatalf("stored expiry %v != header expiry %q", stored.SessionTokenExpiresAt, expires)
}
expMs, ok := ParseISOMs(expires)
if !ok || expMs <= time.Now().UnixMilli() {
t.Fatalf("issued expiry not in the future: %q", expires)
}
}
// Token auth succeeds even when the key hash can no longer verify —
// proving the token path never touches scrypt — and does not re-issue.
func TestAgentSessionTokenAuthSkipsScrypt(t *testing.T) {
app, agentID := seedAgentApp(t, testAgentKey)
token := agentHeartbeat(t, app, testAgentKey).Header().Get(agentSessionTokenHeader)
if token == "" {
t.Fatal("setup: no token issued")
}
// Sabotage the key hash: any scrypt verification now fails.
mutateAgent(t, app, agentID, func(agent *Agent) {
agent.KeyHash = "scrypt:deadbeef:abcd"
})
rec := agentHeartbeat(t, app, token)
if rec.Code != 200 {
t.Fatalf("token heartbeat with sabotaged key hash: got %d", rec.Code)
}
if again := rec.Header().Get(agentSessionTokenHeader); again != "" {
t.Fatalf("token path must not re-issue, got %q", again)
}
// And the (now unverifiable) key is rejected.
if rec := agentHeartbeat(t, app, testAgentKey); rec.Code != 401 {
t.Fatalf("sabotaged key: got %d, want 401", rec.Code)
}
}
// An expired token is rejected; the key still works and re-issues a
// fresh token (rotation).
func TestAgentSessionTokenExpiryFallsBackToKey(t *testing.T) {
app, agentID := seedAgentApp(t, testAgentKey)
token := agentHeartbeat(t, app, testAgentKey).Header().Get(agentSessionTokenHeader)
mutateAgent(t, app, agentID, func(agent *Agent) {
past := ISOFromTime(time.Now().Add(-time.Hour))
agent.SessionTokenExpiresAt = &past
})
if rec := agentHeartbeat(t, app, token); rec.Code != 401 {
t.Fatalf("expired token: got %d, want 401", rec.Code)
}
rec := agentHeartbeat(t, app, testAgentKey)
if rec.Code != 200 {
t.Fatalf("key after expiry: got %d", rec.Code)
}
fresh := rec.Header().Get(agentSessionTokenHeader)
if fresh == "" || fresh == token {
t.Fatalf("expected a fresh token after expiry, got %q", fresh)
}
if rec := agentHeartbeat(t, app, fresh); rec.Code != 200 {
t.Fatalf("fresh token: got %d", rec.Code)
}
}
// A token whose stored hash no longer matches (tampered / rotated
// server-side) is rejected; the key re-issues.
func TestAgentSessionTokenHashMismatchFallsBack(t *testing.T) {
app, agentID := seedAgentApp(t, testAgentKey)
token := agentHeartbeat(t, app, testAgentKey).Header().Get(agentSessionTokenHeader)
mutateAgent(t, app, agentID, func(agent *Agent) {
wrong := TokenFingerprint("some-other-token")
agent.SessionTokenHash = &wrong
})
if rec := agentHeartbeat(t, app, token); rec.Code != 401 {
t.Fatalf("mismatched token: got %d, want 401", rec.Code)
}
rec := agentHeartbeat(t, app, testAgentKey)
if rec.Code != 200 || rec.Header().Get(agentSessionTokenHeader) == "" {
t.Fatalf("key re-issue after mismatch: code=%d token=%q", rec.Code, rec.Header().Get(agentSessionTokenHeader))
}
}
// Revoking the agent kills both the token and the key immediately, and
// Revoke() clears the stored token material.
func TestAgentSessionTokenRevokedAgentRejected(t *testing.T) {
app, agentID := seedAgentApp(t, testAgentKey)
token := agentHeartbeat(t, app, testAgentKey).Header().Get(agentSessionTokenHeader)
mutateAgent(t, app, agentID, func(agent *Agent) { agent.Revoke() })
stored := storedAgent(t, app, agentID)
if stored.SessionTokenHash != nil || stored.SessionTokenExpiresAt != nil {
t.Fatal("Revoke() must clear session token fields")
}
if rec := agentHeartbeat(t, app, token); rec.Code != 401 {
t.Fatalf("revoked agent token: got %d, want 401", rec.Code)
}
if rec := agentHeartbeat(t, app, testAgentKey); rec.Code != 401 {
t.Fatalf("revoked agent key: got %d, want 401", rec.Code)
}
}
// The token is bound to its own agent record: it must not authenticate
// a request as a different agent (observed via which record's
// LastSeenAt the heartbeat advances).
func TestAgentSessionTokenBoundToRecord(t *testing.T) {
app, agentID := seedAgentApp(t, testAgentKey)
otherKey := "other-agent-key-9876543210"
otherFP := TokenFingerprint(otherKey)
otherID := ""
_, err := app.store.Transaction(func(d *Data) (any, error) {
other := &Agent{
ID: NewID("agt"), ServerID: "srv_tok",
KeyHash: HashSecret(otherKey), KeyFingerprint: &otherFP,
Status: "active", CreatedAt: NowISO(),
LastSeenAt: "2000-01-01T00:00:00.000Z",
}
otherID = other.ID
d.Agents = append(d.Agents, other)
return nil, nil
})
if err != nil {
t.Fatalf("seed second agent: %v", err)
}
token := agentHeartbeat(t, app, testAgentKey).Header().Get(agentSessionTokenHeader)
if rec := agentHeartbeat(t, app, token); rec.Code != 200 {
t.Fatalf("token heartbeat: got %d", rec.Code)
}
if got := storedAgent(t, app, agentID).LastSeenAt; got == "2000-01-01T00:00:00.000Z" {
t.Fatal("token heartbeat did not advance its own agent's LastSeenAt")
}
if got := storedAgent(t, app, otherID).LastSeenAt; got != "2000-01-01T00:00:00.000Z" {
t.Fatalf("token heartbeat advanced the WRONG agent (LastSeenAt=%s)", got)
}
}
// Key-only agents (pre-token clients) keep working request after
// request; each key auth rotates the stored token.
func TestAgentKeyOnlyRequestsKeepWorking(t *testing.T) {
app, agentID := seedAgentApp(t, testAgentKey)
var prevHash string
for i := 0; i < 3; i++ {
rec := agentHeartbeat(t, app, testAgentKey)
if rec.Code != 200 {
t.Fatalf("key heartbeat %d: got %d", i, rec.Code)
}
stored := storedAgent(t, app, agentID)
if stored.SessionTokenHash == nil || *stored.SessionTokenHash == prevHash {
t.Fatalf("key heartbeat %d did not rotate the token", i)
}
prevHash = *stored.SessionTokenHash
}
}