diff --git a/changelog.d/changed-6326-dead-spoke-aliases.md b/changelog.d/changed-6326-dead-spoke-aliases.md new file mode 100644 index 000000000..f40c753ff --- /dev/null +++ b/changelog.d/changed-6326-dead-spoke-aliases.md @@ -0,0 +1 @@ +- Delete the production-dead `pkg/hub` spoke alias layer and the byte-identical duplicate hub test copies; hub tests now call `pkg/hub/spoke` directly (#6326). diff --git a/src/cmd/hive/agent_activity_test.go b/src/cmd/hive/agent_activity_test.go index f3593eac9..425418b9f 100644 --- a/src/cmd/hive/agent_activity_test.go +++ b/src/cmd/hive/agent_activity_test.go @@ -7,7 +7,7 @@ import ( "github.com/hivecommons/hive/pkg/agent" "github.com/hivecommons/hive/pkg/config" "github.com/hivecommons/hive/pkg/governor" - "github.com/hivecommons/hive/pkg/hub" + hub "github.com/hivecommons/hive/pkg/hub/spoke" ) // activityTestConfig builds the minimal config agentActivityFor needs: one diff --git a/src/cmd/hive/config_overrides_replay_test.go b/src/cmd/hive/config_overrides_replay_test.go index 4a811a92b..fe2511fe4 100644 --- a/src/cmd/hive/config_overrides_replay_test.go +++ b/src/cmd/hive/config_overrides_replay_test.go @@ -7,7 +7,7 @@ import ( "github.com/hivecommons/hive/pkg/config" "github.com/hivecommons/hive/pkg/dashboard" "github.com/hivecommons/hive/pkg/github" - "github.com/hivecommons/hive/pkg/hub" + hub "github.com/hivecommons/hive/pkg/hub/spoke" "github.com/hivecommons/hive/pkg/snapshot" ) diff --git a/src/cmd/hive/heartbeat_idle_cadence_test.go b/src/cmd/hive/heartbeat_idle_cadence_test.go index 35a25d70f..eb694cbf4 100644 --- a/src/cmd/hive/heartbeat_idle_cadence_test.go +++ b/src/cmd/hive/heartbeat_idle_cadence_test.go @@ -7,7 +7,7 @@ import ( "github.com/hivecommons/hive/pkg/agent" "github.com/hivecommons/hive/pkg/config" "github.com/hivecommons/hive/pkg/governor" - "github.com/hivecommons/hive/pkg/hub" + hub "github.com/hivecommons/hive/pkg/hub/spoke" ) func TestHeartbeatKickIntervalOnlyForGovernorKickedAgents(t *testing.T) { diff --git a/src/cmd/hive/provider_budget_probe_test.go b/src/cmd/hive/provider_budget_probe_test.go index 49a528553..b6c1d92d2 100644 --- a/src/cmd/hive/provider_budget_probe_test.go +++ b/src/cmd/hive/provider_budget_probe_test.go @@ -8,7 +8,7 @@ import ( "github.com/hivecommons/hive/pkg/config" "github.com/hivecommons/hive/pkg/dashboard" "github.com/hivecommons/hive/pkg/governor" - "github.com/hivecommons/hive/pkg/hub" + hub "github.com/hivecommons/hive/pkg/hub/spoke" ) // TestProviderBudgetSuppressionProbesRatherThanDeadlocks is the regression test diff --git a/src/cmd/hive/quota_exhausted_agents_test.go b/src/cmd/hive/quota_exhausted_agents_test.go index 86750630f..9de9ca03e 100644 --- a/src/cmd/hive/quota_exhausted_agents_test.go +++ b/src/cmd/hive/quota_exhausted_agents_test.go @@ -3,7 +3,7 @@ package main import ( "testing" - "github.com/hivecommons/hive/pkg/hub" + hub "github.com/hivecommons/hive/pkg/hub/spoke" ) // quotaExhaustedAgentCount must count ONLY running, unpaused agents whose diff --git a/src/pkg/hub/heartbeat_activity_test.go b/src/pkg/hub/heartbeat_activity_test.go deleted file mode 100644 index 60414a294..000000000 --- a/src/pkg/hub/heartbeat_activity_test.go +++ /dev/null @@ -1,148 +0,0 @@ -package hub - -import ( - "io" - "log/slog" - "testing" - "time" - - "github.com/hivecommons/hive/pkg/agent" - "github.com/hivecommons/hive/pkg/config" - "github.com/hivecommons/hive/pkg/governor" -) - -func activityTestLogger() *slog.Logger { - return slog.New(slog.NewTextHandler(io.Discard, nil)) -} - -func activityTestConfig() *config.Config { - return &config.Config{ - Agents: map[string]config.AgentConfig{ - "scanner": {Backend: "claude", Enabled: true}, - }, - Governor: config.GovernorConfig{ - Modes: map[string]config.ModeConfig{ - "busy": {Cadences: map[string]config.Cadence{"scanner": "1h"}}, - }, - }, - } -} - -func TestAgentActivityForRidesPauseProvenance(t *testing.T) { - cfg := activityTestConfig() - mgr := agent.NewManager(cfg.Agents, activityTestLogger(), agent.ProjectContext{}) - pausedAt := time.Date(2026, 8, 20, 9, 0, 0, 0, time.UTC) - lastPane := time.Date(2026, 8, 20, 8, 55, 0, 0, time.UTC) - proc := &agent.AgentProcess{ - Paused: true, - PausedTrigger: "dashboard-api", - PausedReason: "manual pause", - PausedBy: "owner", - PausedAt: pausedAt, - NeedsLogin: true, - QuotaExhausted: true, - LastPaneChange: lastPane, - } - - act := AgentActivityFor(mgr, cfg, governor.State{}, "busy", "scanner", proc, nil) - - if !act.Paused || act.PausedTrigger != "dashboard-api" || act.PausedReason != "manual pause" || - act.PausedBy != "owner" || !act.PausedAt.Equal(pausedAt) { - t.Errorf("pause provenance did not ride through verbatim: %+v", act) - } - if !act.NeedsLogin || !act.QuotaExhausted { - t.Error("NeedsLogin/QuotaExhausted flags were dropped") - } - if !act.LastActivityAt.Equal(lastPane) { - t.Errorf("LastActivityAt = %v, want %v", act.LastActivityAt, lastPane) - } - if !act.StartedAt.IsZero() { - t.Errorf("StartedAt = %v for a never-started process, want zero", act.StartedAt) - } - if act.Backend != "claude" { - t.Errorf("Backend = %q, want claude", act.Backend) - } -} - -func TestAgentActivityForExpectedActiveHonorsOnDemandPack(t *testing.T) { - cfg := activityTestConfig() - mgr := agent.NewManager(cfg.Agents, activityTestLogger(), agent.ProjectContext{}) - proc := &agent.AgentProcess{} - - scheduled := AgentActivityFor(mgr, cfg, governor.State{}, "busy", "scanner", proc, nil) - if !scheduled.ExpectedActive || !scheduled.Enabled { - t.Fatalf("scheduled enabled agent got ExpectedActive=%v Enabled=%v", scheduled.ExpectedActive, scheduled.Enabled) - } - - packOnDemand := AgentActivityFor(mgr, cfg, governor.State{}, "busy", "scanner", proc, map[string]bool{"scanner": true}) - if packOnDemand.ExpectedActive { - t.Error("pack-on-demand agent must never report ExpectedActive") - } - - otherMode := AgentActivityFor(mgr, cfg, governor.State{}, "idle", "scanner", proc, nil) - if otherMode.ExpectedActive { - t.Error("agent with no cadence in current mode must not report ExpectedActive") - } -} - -func TestHeartbeatKickInterval(t *testing.T) { - proc := &agent.AgentProcess{} - govState := governor.State{Cadences: map[string]governor.AgentCadence{ - "quality": {Interval: 2 * time.Hour}, - }} - if got := HeartbeatKickInterval(govState, "quality", proc, nil); got != 2*time.Hour { - t.Errorf("HeartbeatKickInterval = %v, want 2h", got) - } - govState.Cadences["quality"] = governor.AgentCadence{Interval: 2 * time.Hour, Paused: true} - if got := HeartbeatKickInterval(govState, "quality", proc, nil); got != 0 { - t.Errorf("paused cadence interval = %v, want 0", got) - } -} - -func TestQuotaExhaustedCountsAndReasons(t *testing.T) { - statuses := map[string]*agent.AgentProcess{ - "counted": {State: agent.StateRunning, QuotaExhausted: true}, - "paused": {State: agent.StateRunning, QuotaExhausted: true, Paused: true}, - "stopped": {State: agent.StateStopped, QuotaExhausted: true}, - "has-quota": {State: agent.StateRunning}, - } - if got := QuotaExhaustedProcessCount(statuses); got != 1 { - t.Errorf("QuotaExhaustedProcessCount = %d, want 1", got) - } - agents := []AgentSummary{ - {Name: "guide", State: "running", QuotaExhausted: true}, - {Name: "scanner", State: "running", QuotaExhausted: true}, - {Name: "paused", State: "paused", Paused: true, QuotaExhausted: true}, - {Name: "supervisor"}, - } - if got := QuotaExhaustedAgentCount(agents); got != 2 { - t.Errorf("QuotaExhaustedAgentCount = %d, want 2", got) - } - if got := QuotaExhaustedAgentReason(0); got != "" { - t.Errorf("QuotaExhaustedAgentReason(0) = %q, want empty", got) - } - if got := QuotaExhaustedAgentReason(3); got != "3 agent(s) out of provider quota" { - t.Errorf("QuotaExhaustedAgentReason(3) = %q", got) - } -} - -func TestProviderLimitHeartbeatFields(t *testing.T) { - reason, rebuffs, _, _ := ProviderLimitHeartbeatFields([]AgentSummary{{State: "running", QuotaExhausted: true}}, nil) - if rebuffs != 0 || reason != "1 agent(s) out of provider quota" { - t.Fatalf("pane quota fallback = %q/%d", reason, rebuffs) - } - - reason, rebuffs, _, _ = ProviderLimitHeartbeatFields(nil, func() (string, time.Time, time.Time, int) { - return "credit balance too low", time.Now(), time.Now(), 1 - }) - if rebuffs != 1 || reason != "provider spending limit reached — credit balance too low" { - t.Fatalf("single rebuff = %q/%d", reason, rebuffs) - } - - reason, rebuffs, _, _ = ProviderLimitHeartbeatFields(nil, func() (string, time.Time, time.Time, int) { - return "credit balance too low", time.Now(), time.Now(), 4 - }) - if rebuffs != 4 || reason != "provider spending limit reached — 4 refused calls: credit balance too low" { - t.Fatalf("multi rebuff = %q/%d", reason, rebuffs) - } -} diff --git a/src/pkg/hub/hub_keys_test.go b/src/pkg/hub/hub_keys_test.go index fb4d70f45..bb4a1f643 100644 --- a/src/pkg/hub/hub_keys_test.go +++ b/src/pkg/hub/hub_keys_test.go @@ -2,6 +2,8 @@ package hub import ( "testing" + + "github.com/hivecommons/hive/pkg/hub/spoke" "time" ) @@ -61,7 +63,7 @@ func TestSpokeHeartbeatKeyCannotForgeOtherDomains(t *testing.T) { // asymmetric: the spoke holds only the public key and no signing seed at all, // so this also stands in for "spoke has no private material to sign with".) forgedSSO := MintSSOToken(spokeHeartbeatKey, "victim-owner", "owner", "hive-x", now) - if _, _, err := VerifySSOToken(ssoPubKey, forgedSSO, "hive-x", now); err == nil { + if _, _, err := spoke.VerifySSOToken(ssoPubKey, forgedSSO, "hive-x", now); err == nil { t.Error("spoke heartbeat key forged a valid SSO handoff token") } diff --git a/src/pkg/hub/hub_pubkey_generations.go b/src/pkg/hub/hub_pubkey_generations.go index 11e5721d4..a487f53a4 100644 --- a/src/pkg/hub/hub_pubkey_generations.go +++ b/src/pkg/hub/hub_pubkey_generations.go @@ -6,6 +6,8 @@ import ( "os" "strings" "time" + + "github.com/hivecommons/hive/pkg/hub/spoke" ) // SSO / SESSION Ed25519 PUBLIC KEY plurality — follow-on PR #6 of the @@ -362,7 +364,7 @@ func VerifySSOTokenAcrossKeys(keys []string, token, expectedHiveID string, now t matchedIndex := -1 var matchedUser, matchedRole string for i, k := range valid { - u, r, verr := VerifySSOToken(k, token, expectedHiveID, now) + u, r, verr := spoke.VerifySSOToken(k, token, expectedHiveID, now) // Recorded unconditionally, and the loop deliberately continues. See the // TIMING note above: an early return here would make the spoke's // convergence state observable as latency. diff --git a/src/pkg/hub/image_tag_validation_test.go b/src/pkg/hub/image_tag_validation_test.go index bc95354da..34bca5b37 100644 --- a/src/pkg/hub/image_tag_validation_test.go +++ b/src/pkg/hub/image_tag_validation_test.go @@ -8,6 +8,8 @@ import ( "path/filepath" "strings" "testing" + + "github.com/hivecommons/hive/pkg/hub/spoke" ) func TestValidateImageTagRefusesMalformed(t *testing.T) { @@ -177,7 +179,7 @@ func TestSwitchImageSelfRefusesBogusTag(t *testing.T) { "ghcr.io/hivecommons/hive:", "", } { - err := SwitchImageSelf(slog.Default(), image) + err := spoke.SwitchImageSelf(slog.Default(), image) if err == nil { t.Errorf("SwitchImageSelf(%q) = nil, want refusal", image) continue diff --git a/src/pkg/hub/small_gaps_coverage_test.go b/src/pkg/hub/small_gaps_coverage_test.go index 9d7d1a7e1..663b4deb3 100644 --- a/src/pkg/hub/small_gaps_coverage_test.go +++ b/src/pkg/hub/small_gaps_coverage_test.go @@ -6,6 +6,8 @@ import ( "net/http/httptest" "strings" "testing" + + "github.com/hivecommons/hive/pkg/hub/spoke" "time" ) @@ -102,31 +104,31 @@ func TestVerifySSOTokenBranches(t *testing.T) { } // Wrong key -> bad signature. - if _, _, err := VerifySSOToken(otherPub, tok, "hiveA", now); err == nil { + if _, _, err := spoke.VerifySSOToken(otherPub, tok, "hiveA", now); err == nil { t.Error("expected bad signature with the wrong public key") } // Empty key -> no verification key error. - if _, _, err := VerifySSOToken("", tok, "hiveA", now); err == nil { + if _, _, err := spoke.VerifySSOToken("", tok, "hiveA", now); err == nil { t.Error("expected error with empty public key") } // Malformed token. - if _, _, err := VerifySSOToken(pub, "no-dot", "hiveA", now); err == nil { + if _, _, err := spoke.VerifySSOToken(pub, "no-dot", "hiveA", now); err == nil { t.Error("expected malformed token error") } // Wrong hive. - if _, _, err := VerifySSOToken(pub, tok, "hiveB", now); err == nil { + if _, _, err := spoke.VerifySSOToken(pub, tok, "hiveB", now); err == nil { t.Error("expected wrong-hive error") } // Expired. - if _, _, err := VerifySSOToken(pub, tok, "hiveA", now.Add(10*time.Minute)); err == nil { + if _, _, err := spoke.VerifySSOToken(pub, tok, "hiveA", now.Add(10*time.Minute)); err == nil { t.Error("expected expired error") } // Not yet valid. - if _, _, err := VerifySSOToken(pub, tok, "hiveA", now.Add(-10*time.Minute)); err == nil { + if _, _, err := spoke.VerifySSOToken(pub, tok, "hiveA", now.Add(-10*time.Minute)); err == nil { t.Error("expected not-yet-valid error") } // Valid round trip. - u, role, err := VerifySSOToken(pub, tok, "hiveA", now) + u, role, err := spoke.VerifySSOToken(pub, tok, "hiveA", now) if err != nil || u != "alice" || role != "owner" { t.Errorf("valid verify failed: u=%q role=%q err=%v", u, role, err) } diff --git a/src/pkg/hub/spoke_deleted_aliases.go b/src/pkg/hub/spoke_deleted_aliases.go index 195c380ef..04a863127 100644 --- a/src/pkg/hub/spoke_deleted_aliases.go +++ b/src/pkg/hub/spoke_deleted_aliases.go @@ -1,89 +1,27 @@ package hub import ( - "encoding/json" - "log/slog" - "time" - "github.com/hivecommons/hive/pkg/agent" - "github.com/hivecommons/hive/pkg/config" - "github.com/hivecommons/hive/pkg/governor" - "github.com/hivecommons/hive/pkg/hub/spoke" -) -type InferenceBudgetProvider = spoke.InferenceBudgetProvider + "time" +) -func CollectClusterHealth(logger *slog.Logger) *HeartbeatClusterHealthReport { - report := spoke.CollectClusterHealth(logger) - if report == nil { - return nil - } - var out HeartbeatClusterHealthReport - data, err := json.Marshal(report) - if err != nil { - return nil - } - if err := json.Unmarshal(data, &out); err != nil { - return nil - } - return &out -} -func OpenFDCount() int { return spoke.OpenFDCount() } -func FDSoftLimit() uint64 { return spoke.FDSoftLimit() } +// This file holds the surviving pass-through aliases from the spoke +// extraction (#6068). Only aliases with live production callers remain; +// new code should import pkg/hub/spoke directly (as pkg/dashboard already +// does) instead of adding aliases here. -func AgentActivityFor(mgr *agent.Manager, cfg *config.Config, govState governor.State, currentMode, name string, proc *agent.AgentProcess, onDemandFromPack map[string]bool) AgentActivity { - act := spoke.AgentActivityFor(mgr, cfg, govState, currentMode, name, proc, onDemandFromPack) - var out AgentActivity - data, err := json.Marshal(act) - if err != nil { - return out - } - _ = json.Unmarshal(data, &out) - return out -} -func HeartbeatKickInterval(govState governor.State, name string, proc *agent.AgentProcess, onDemandFromPack map[string]bool) time.Duration { - return spoke.HeartbeatKickInterval(govState, name, proc, onDemandFromPack) -} -func spokeAgentSummaries(agents []AgentSummary) []spoke.AgentSummary { - var out []spoke.AgentSummary - data, err := json.Marshal(agents) - if err != nil { - return nil - } - _ = json.Unmarshal(data, &out) - return out -} -func QuotaExhaustedAgentCount(agents []AgentSummary) int { - return spoke.QuotaExhaustedAgentCount(spokeAgentSummaries(agents)) -} func QuotaExhaustedProcessCount(statuses map[string]*agent.AgentProcess) int { return spoke.QuotaExhaustedProcessCount(statuses) } func QuotaExhaustedAgentReason(count int) string { return spoke.QuotaExhaustedAgentReason(count) } -func ProviderLimitHeartbeatFields(agents []AgentSummary, budget InferenceBudgetProvider) (string, int, bool, []string) { - return spoke.ProviderLimitHeartbeatFields(spokeAgentSummaries(agents), budget) -} -func HashDashboardToken(token string) string { return spoke.HashDashboardToken(token) } -func RolloutRestartSelf(logger *slog.Logger) error { return spoke.RolloutRestartSelf(logger) } -func SwitchImageSelf(logger *slog.Logger, image string) error { - return spoke.SwitchImageSelf(logger, image) -} -func UpgradeSelfToSHA(logger *slog.Logger, targetSHA string) (bool, error) { - return spoke.UpgradeSelfToSHA(logger, targetSHA) -} + +func HashDashboardToken(token string) string { return spoke.HashDashboardToken(token) } + func SelfImageReleaseChannel() string { return spoke.SelfImageReleaseChannel() } func SelfDeploymentImage() string { return spoke.SelfDeploymentImage() } + func MintSSOToken(seedHex, username, role, hiveID string, now time.Time) string { return spoke.MintSSOToken(seedHex, username, role, hiveID, now) } -func VerifySSOToken(pubHex, token, expectedHiveID string, now time.Time) (string, string, error) { - return spoke.VerifySSOToken(pubHex, token, expectedHiveID, now) -} -func TerminalSigningKey() string { return spoke.TerminalSigningKey() } -func MintTerminalAssertion(key, username, role, hiveID string, now time.Time) string { - return spoke.MintTerminalAssertion(key, username, role, hiveID, now) -} -func VerifyTerminalAssertion(key, token, expectedHiveID string, now time.Time) (string, string, error) { - return spoke.VerifyTerminalAssertion(key, token, expectedHiveID, now) -} diff --git a/src/pkg/hub/spoke_deleted_test_support_test.go b/src/pkg/hub/spoke_deleted_test_support_test.go index 949a565a7..eff2bc6e1 100644 --- a/src/pkg/hub/spoke_deleted_test_support_test.go +++ b/src/pkg/hub/spoke_deleted_test_support_test.go @@ -2,11 +2,8 @@ package hub import ( "encoding/base64" - "time" ) -const ssoTokenTTL = 90 * time.Second - var ( ssoB64 = base64.RawURLEncoding k8sTokenPath = serviceAccountDir + "/token" diff --git a/src/pkg/hub/sso_ed25519_coverage_test.go b/src/pkg/hub/sso_ed25519_coverage_test.go index 091b1269f..0fa66c2ed 100644 --- a/src/pkg/hub/sso_ed25519_coverage_test.go +++ b/src/pkg/hub/sso_ed25519_coverage_test.go @@ -5,6 +5,8 @@ import ( "encoding/hex" "strings" "testing" + + "github.com/hivecommons/hive/pkg/hub/spoke" "time" ) @@ -38,7 +40,7 @@ func TestEd25519SSOKeysAndToken(t *testing.T) { if !ed25519.Verify(ed25519.PublicKey(pubBytes), []byte(parts[0]), sig) { t.Fatal("ed25519 token signature does not verify with derived public key") } - if _, _, err := VerifySSOToken(deriveDomainKey(master, infoSSOKey), tok, "hosted-hive", now); err == nil { + if _, _, err := spoke.VerifySSOToken(deriveDomainKey(master, infoSSOKey), tok, "hosted-hive", now); err == nil { t.Fatal("legacy HMAC verifier must not accept an Ed25519 token") } } diff --git a/src/pkg/hub/sso_test.go b/src/pkg/hub/sso_test.go deleted file mode 100644 index 0d8ee0cba..000000000 --- a/src/pkg/hub/sso_test.go +++ /dev/null @@ -1,122 +0,0 @@ -package hub - -import ( - "testing" - "time" -) - -// ssoTestKeypair derives a matching {signing seed, public key} pair the way the -// hub does from a master. mint uses the seed (private material, hub-only); verify -// uses the public key (what a spoke is injected). -func ssoTestKeypair(master string) (seedHex, pubHex string) { - seedHex = SSOSigningSeedFromMaster(master) - pubHex = ssoPublicKeyFromSeed(seedHex) - return seedHex, pubHex -} - -func TestSSOTokenRoundTrip(t *testing.T) { - seed, pub := ssoTestKeypair("shared-hub-secret-abc123") - now := time.Unix(1_700_000_000, 0) - tok := MintSSOToken(seed, "alice", "owner", "hosted-hive-1", now) - if tok == "" { - t.Fatal("expected a token") - } - user, role, err := VerifySSOToken(pub, tok, "hosted-hive-1", now) - if err != nil { - t.Fatalf("verify failed: %v", err) - } - if user != "alice" || role != "owner" { - t.Fatalf("got user=%q role=%q, want alice/owner", user, role) - } -} - -// TestSSOSpokeWithPublicKeyCannotMint is the core C2 follow-up guarantee: a spoke -// holds ONLY the Ed25519 public key. Handing that public key to the mint path must -// NOT produce a token that verifies — the spoke has no private material to sign -// with, so it cannot forge an SSO-as-any-owner handoff for its own hive. -func TestSSOSpokeWithPublicKeyCannotMint(t *testing.T) { - seed, pub := ssoTestKeypair("the-hub-master") - now := time.Unix(1_700_000_000, 0) - - // A spoke, holding only `pub`, attempts to mint. Whatever comes out must not - // verify against the real public key. - forged := MintSSOToken(pub, "attacker", "owner", "hive-x", now) - if _, _, err := VerifySSOToken(pub, forged, "hive-x", now); err == nil { - t.Fatal("a token minted with only the public key must NOT verify") - } - - // Sanity: the real private seed DOES mint a token the public key verifies, so - // the negative above is about lacking the private key, not a broken primitive. - good := MintSSOToken(seed, "alice", "owner", "hive-x", now) - if _, _, err := VerifySSOToken(pub, good, "hive-x", now); err != nil { - t.Fatalf("hub-minted token failed to verify with the public key: %v", err) - } -} - -func TestSSOTokenRejectsWrongHive(t *testing.T) { - seed, pub := ssoTestKeypair("s") - now := time.Unix(1_700_000_000, 0) - tok := MintSSOToken(seed, "alice", "owner", "hive-A", now) - if _, _, err := VerifySSOToken(pub, tok, "hive-B", now); err == nil { - t.Fatal("expected rejection for a token minted for a different hive") - } -} - -func TestSSOTokenRejectsWrongKey(t *testing.T) { - seed1, _ := ssoTestKeypair("secret-1") - _, pub2 := ssoTestKeypair("secret-2") - now := time.Unix(1_700_000_000, 0) - tok := MintSSOToken(seed1, "alice", "owner", "hive-1", now) - if _, _, err := VerifySSOToken(pub2, tok, "hive-1", now); err == nil { - t.Fatal("expected rejection under a different keypair's public key") - } -} - -func TestSSOTokenRejectsExpired(t *testing.T) { - seed, pub := ssoTestKeypair("s") - now := time.Unix(1_700_000_000, 0) - tok := MintSSOToken(seed, "alice", "owner", "hive-1", now) - // Verify well past expiry (TTL + skew). - later := now.Add(ssoTokenTTL + ssoClockSkew + time.Second) - if _, _, err := VerifySSOToken(pub, tok, "hive-1", later); err == nil { - t.Fatal("expected rejection for an expired token") - } -} - -func TestSSOTokenRejectsTamper(t *testing.T) { - seed, pub := ssoTestKeypair("s") - now := time.Unix(1_700_000_000, 0) - tok := MintSSOToken(seed, "alice", "read", "hive-1", now) - // Flip a byte in the payload; signature must no longer match. - b := []byte(tok) - // mutate an early char that is part of the payload, not the '.' separator - if b[0] == 'A' { - b[0] = 'B' - } else { - b[0] = 'A' - } - if _, _, err := VerifySSOToken(pub, string(b), "hive-1", now); err == nil { - t.Fatal("expected rejection for a tampered token") - } -} - -func TestSSOTokenEmptyInputs(t *testing.T) { - seed, pub := ssoTestKeypair("s") - now := time.Unix(1_700_000_000, 0) - if MintSSOToken("", "alice", "owner", "hive-1", now) != "" { - t.Fatal("empty seed must not mint a token") - } - if MintSSOToken(seed, "", "owner", "hive-1", now) != "" { - t.Fatal("empty username must not mint a token") - } - // A non-seed value (not 32-byte hex) must fail closed on mint. - if MintSSOToken("not-a-valid-seed", "alice", "owner", "hive-1", now) != "" { - t.Fatal("a non-seed value must not mint a token") - } - if _, _, err := VerifySSOToken("", "x.y", "hive-1", now); err == nil { - t.Fatal("empty public key must not verify") - } - if _, _, err := VerifySSOToken(pub, "malformed", "hive-1", now); err == nil { - t.Fatal("malformed token must not verify") - } -} diff --git a/src/pkg/hub/terminal_key_per_hive_test.go b/src/pkg/hub/terminal_key_per_hive_test.go index 60579dec1..7cfce7ca4 100644 --- a/src/pkg/hub/terminal_key_per_hive_test.go +++ b/src/pkg/hub/terminal_key_per_hive_test.go @@ -2,6 +2,8 @@ package hub import ( "testing" + + "github.com/hivecommons/hive/pkg/hub/spoke" "time" ) @@ -106,21 +108,21 @@ func TestCrossHiveAssertionForgeryFails(t *testing.T) { // The forger mints for the VICTIM hive — the hiveID claim alone was never // the protection, since an attacker just writes the victim's ID. - forged := MintTerminalAssertion(attackerKey, "mallory-not-registered", "owner", "victim-hive", now) + forged := spoke.MintTerminalAssertion(attackerKey, "mallory-not-registered", "owner", "victim-hive", now) if forged == "" { t.Fatal("mint returned empty; test setup is wrong") } // The victim spoke verifies with ITS OWN key. - if _, _, err := VerifyTerminalAssertion(victimKey, forged, "victim-hive", now); err == nil { + if _, _, err := spoke.VerifyTerminalAssertion(victimKey, forged, "victim-hive", now); err == nil { t.Fatal("N3: an assertion forged with ANOTHER hive's key verified on the victim hive — " + "a hostile tenant can open a shell as any user on any hive") } // Control: the victim's own key still mints assertions that verify, so the // fix does not break legitimate terminal access. - legit := MintTerminalAssertion(victimKey, "alice", "owner", "victim-hive", now) - user, role, err := VerifyTerminalAssertion(victimKey, legit, "victim-hive", now) + legit := spoke.MintTerminalAssertion(victimKey, "alice", "owner", "victim-hive", now) + user, role, err := spoke.VerifyTerminalAssertion(victimKey, legit, "victim-hive", now) if err != nil || user != "alice" || role != "owner" { t.Fatalf("legitimate assertion failed to verify: user=%q role=%q err=%v", user, role, err) } @@ -157,8 +159,8 @@ func TestFleetSharedKeyWouldHaveForged(t *testing.T) { now := time.Now() shared := deriveDomainKey(n3TestMaster, infoSessionKey) // what both spokes used to hold - forged := MintTerminalAssertion(shared, "mallory", "owner", "victim-hive", now) - if _, _, err := VerifyTerminalAssertion(shared, forged, "victim-hive", now); err != nil { + forged := spoke.MintTerminalAssertion(shared, "mallory", "owner", "victim-hive", now) + if _, _, err := spoke.VerifyTerminalAssertion(shared, forged, "victim-hive", now); err != nil { t.Skip("shared-key forgery no longer reproduces; mint/verify shape changed") } t.Log("confirmed: under a fleet-shared key, an assertion minted anywhere verifies " +