ScuttleBot

feat: agent registry with credential issuance, rotation, revocation, and signed payload

lmata 2026-03-31 04:52 trunk
Commit 1efddcbd5a95eac8bb9371a08540880510455e5723f21c6ad6bc0a7f1c6f92c3
--- internal/registry/registry.go
+++ internal/registry/registry.go
@@ -1,1 +1,244 @@
1
+// Package registry manages agent registration and credential lifecycle.
2
+//
3
+// Agents register with scuttlebot and receive SASL credentials for the Ergo
4
+// IRC server, plus a signed rules-of-engagement payload describing their
5
+// channel assignments and permissions.
16
package registry
7
+
8
+import (
9
+ "crypto/hmac"
10
+ "crypto/rand"
11
+ "crypto/sha256"
12
+ "encoding/hex"
13
+ "encoding/json"
14
+ "fmt"
15
+ "sync"
16
+ "time"
17
+)
18
+
19
+// AgentType describes an agent's role and authority level.
20
+type AgentType string
21
+
22
+const (
23
+ AgentTypeOrchestrator AgentType = "orchestrator" // +o in channels
24
+ AgentTypeWorker AgentType = "worker" // +v in channels
25
+ AgentTypeObserver AgentType = "observer" // no special mode
26
+)
27
+
28
+// Agent is a registered agent.
29
+type Agent struct {
30
+ Nick string `json:"nick"`
31
+ Type AgentType `json:"type"`
32
+ Channels []string `json:"channels"`
33
+ Permissions []string `json:"permissions"`
34
+ CreatedAt time.Time `json:"created_at"`
35
+ Revoked bool `json:"revoked"`
36
+}
37
+
38
+// Credentials are the SASL credentials an agent uses to connect to Ergo.
39
+type Credentials struct {
40
+ Nick string `json:"nick"`
41
+ Passphrase string `json:"passphrase"`
42
+}
43
+
44
+// EngagementPayload is the signed payload delivered to an agent on registration.
45
+// It describes the agent's channel assignments, permissions, and engagement rules.
46
+type EngagementPayload struct {
47
+ V int `json:"v"`
48
+ Nick string `json:"nick"`
49
+ Type AgentType `json:"type"`
50
+ Channels []string `json:"channels"`
51
+ Permissions []string `json:"permissions"`
52
+ IssuedAt time.Time `json:"issued_at"`
53
+}
54
+
55
+// SignedPayload wraps an EngagementPayload with an HMAC signature.
56
+type SignedPayload struct {
57
+ Payload EngagementPayload `json:"payload"`
58
+ Signature string `json:"signature"` // hex-encoded HMAC-SHA256
59
+}
60
+
61
+// AccountProvisioner is the interface the registry uses to create/modify IRC accounts.
62
+// Implemented by *ergo.APIClient in production; can be mocked in tests.
63
+type AccountProvisioner interface {
64
+ RegisterAccount(name, passphrase string) error
65
+ ChangePassword(name, passphrase string) error
66
+}
67
+
68
+// Registry manages registered agents and their credentials.
69
+type Registry struct {
70
+ mu sync.RWMutex
71
+ agents map[string]*Agent // keyed by nick
72
+ provisioner AccountProvisioner
73
+ signingKey []byte
74
+}
75
+
76
+// New creates a new Registry with the given provisioner and HMAC signing key.
77
+func New(provisioner AccountProvisioner, signingKey []byte) *Registry {
78
+ return &Registry{
79
+ agents: make(map[string]*Agent),
80
+ provisioner: provisioner,
81
+ signingKey: signingKey,
82
+ }
83
+}
84
+
85
+// Register creates a new agent, provisions its Ergo account, and returns
86
+// credentials and a signed rules-of-engagement payload.
87
+func (r *Registry) Register(nick string, agentType AgentType, channels, permissions []string) (*Credentials, *SignedPayload, error) {
88
+ r.mu.Lock()
89
+ defer r.mu.Unlock()
90
+
91
+ if existing, ok := r.agents[nick]; ok && !existing.Revoked {
92
+ return nil, nil, fmt.Errorf("registry: agent %q already registered", nick)
93
+ }
94
+
95
+ passphrase, err := generatePassphrase()
96
+ if err != nil {
97
+ return nil, nil, fmt.Errorf("registry: generate passphrase: %w", err)
98
+ }
99
+
100
+ if err := r.provisioner.RegisterAccount(nick, passphrase); err != nil {
101
+ return nil, nil, fmt.Errorf("registry: provision account: %w", err)
102
+ }
103
+
104
+ agent := &Agent{
105
+ Nick: nick,
106
+ Type: agentType,
107
+ Channels: channels,
108
+ Permissions: permissions,
109
+ CreatedAt: time.Now(),
110
+ }
111
+ r.agents[nick] = agent
112
+
113
+ payload, err := r.signPayload(agent)
114
+ if err != nil {
115
+ return nil, nil, fmt.Errorf("registry: sign payload: %w", err)
116
+ }
117
+
118
+ return &Credentials{Nick: nick, Passphrase: passphrase}, payload, nil
119
+}
120
+
121
+// Rotate generates a new passphrase for an agent and updates Ergo.
122
+func (r *Registry) Rotate(nick string) (*Credentials, error) {
123
+ r.mu.Lock()
124
+ defer r.mu.Unlock()
125
+
126
+ agent, err := r.get(nick)
127
+ if err != nil {
128
+ return nil, err
129
+ }
130
+
131
+ passphrase, err := generatePassphrase()
132
+ if err != nil {
133
+ return nil, fmt.Errorf("registry: generate passphrase: %w", err)
134
+ }
135
+
136
+ if err := r.provisioner.ChangePassword(nick, passphrase); err != nil {
137
+ return nil, fmt.Errorf("registry: rotate credentials: %w", err)
138
+ }
139
+
140
+ _ = agent // agent exists, credentials rotated
141
+ return &Credentials{Nick: nick, Passphrase: passphrase}, nil
142
+}
143
+
144
+// Revoke locks an agent out by rotating to an unguessable passphrase and
145
+// marking it revoked in the registry.
146
+func (r *Registry) Revoke(nick string) error {
147
+ r.mu.Lock()
148
+ defer r.mu.Unlock()
149
+
150
+ agent, err := r.get(nick)
151
+ if err != nil {
152
+ return err
153
+ }
154
+
155
+ lockout, err := generatePassphrase()
156
+ if err != nil {
157
+ return fmt.Errorf("registry: generate lockout passphrase: %w", err)
158
+ }
159
+
160
+ if err := r.provisioner.ChangePassword(nick, lockout); err != nil {
161
+ return fmt.Errorf("registry: revoke credentials: %w", err)
162
+ }
163
+
164
+ agent.Revoked = true
165
+ return nil
166
+}
167
+
168
+// Get returns the agent with the given nick.
169
+func (r *Registry) Get(nick string) (*Agent, error) {
170
+ r.mu.RLock()
171
+ defer r.mu.RUnlock()
172
+ return r.get(nick)
173
+}
174
+
175
+// List returns all registered, non-revoked agents.
176
+func (r *Registry) List() []*Agent {
177
+ r.mu.RLock()
178
+ defer r.mu.RUnlock()
179
+ var out []*Agent
180
+ for _, a := range r.agents {
181
+ if !a.Revoked {
182
+ out = append(out, a)
183
+ }
184
+ }
185
+ return out
186
+}
187
+
188
+func (r *Registry) get(nick string) (*Agent, error) {
189
+ agent, ok := r.agents[nick]
190
+ if !ok {
191
+ return nil, fmt.Errorf("registry: agent %q not found", nick)
192
+ }
193
+ if agent.Revoked {
194
+ return nil, fmt.Errorf("registry: agent %q is revoked", nick)
195
+ }
196
+ return agent, nil
197
+}
198
+
199
+func (r *Registry) signPayload(agent *Agent) (*SignedPayload, error) {
200
+ payload := EngagementPayload{
201
+ V: 1,
202
+ Nick: agent.Nick,
203
+ Type: agent.Type,
204
+ Channels: agent.Channels,
205
+ Permissions: agent.Permissions,
206
+ IssuedAt: time.Now(),
207
+ }
208
+
209
+ data, err := json.Marshal(payload)
210
+ if err != nil {
211
+ return nil, err
212
+ }
213
+
214
+ mac := hmac.New(sha256.New, r.signingKey)
215
+ mac.Write(data)
216
+ sig := hex.EncodeToString(mac.Sum(nil))
217
+
218
+ return &SignedPayload{Payload: payload, Signature: sig}, nil
219
+}
220
+
221
+// VerifyPayload verifies the HMAC signature on a SignedPayload.
222
+func VerifyPayload(sp *SignedPayload, signingKey []byte) error {
223
+ data, err := json.Marshal(sp.Payload)
224
+ if err != nil {
225
+ return err
226
+ }
227
+
228
+ mac := hmac.New(sha256.New, signingKey)
229
+ mac.Write(data)
230
+ expected := hex.EncodeToString(mac.Sum(nil))
231
+
232
+ if !hmac.Equal([]byte(sp.Signature), []byte(expected)) {
233
+ return fmt.Errorf("registry: invalid payload signature")
234
+ }
235
+ return nil
236
+}
237
+
238
+func generatePassphrase() (string, error) {
239
+ b := make([]byte, 32)
240
+ if _, err := rand.Read(b); err != nil {
241
+ return "", err
242
+ }
243
+ return hex.EncodeToString(b), nil
244
+}
2245
3246
ADDED internal/registry/registry_test.go
--- internal/registry/registry.go
+++ internal/registry/registry.go
@@ -1,1 +1,244 @@
 
 
 
 
 
1 package registry
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
3 DDED internal/registry/registry_test.go
--- internal/registry/registry.go
+++ internal/registry/registry.go
@@ -1,1 +1,244 @@
1 // Package registry manages agent registration and credential lifecycle.
2 //
3 // Agents register with scuttlebot and receive SASL credentials for the Ergo
4 // IRC server, plus a signed rules-of-engagement payload describing their
5 // channel assignments and permissions.
6 package registry
7
8 import (
9 "crypto/hmac"
10 "crypto/rand"
11 "crypto/sha256"
12 "encoding/hex"
13 "encoding/json"
14 "fmt"
15 "sync"
16 "time"
17 )
18
19 // AgentType describes an agent's role and authority level.
20 type AgentType string
21
22 const (
23 AgentTypeOrchestrator AgentType = "orchestrator" // +o in channels
24 AgentTypeWorker AgentType = "worker" // +v in channels
25 AgentTypeObserver AgentType = "observer" // no special mode
26 )
27
28 // Agent is a registered agent.
29 type Agent struct {
30 Nick string `json:"nick"`
31 Type AgentType `json:"type"`
32 Channels []string `json:"channels"`
33 Permissions []string `json:"permissions"`
34 CreatedAt time.Time `json:"created_at"`
35 Revoked bool `json:"revoked"`
36 }
37
38 // Credentials are the SASL credentials an agent uses to connect to Ergo.
39 type Credentials struct {
40 Nick string `json:"nick"`
41 Passphrase string `json:"passphrase"`
42 }
43
44 // EngagementPayload is the signed payload delivered to an agent on registration.
45 // It describes the agent's channel assignments, permissions, and engagement rules.
46 type EngagementPayload struct {
47 V int `json:"v"`
48 Nick string `json:"nick"`
49 Type AgentType `json:"type"`
50 Channels []string `json:"channels"`
51 Permissions []string `json:"permissions"`
52 IssuedAt time.Time `json:"issued_at"`
53 }
54
55 // SignedPayload wraps an EngagementPayload with an HMAC signature.
56 type SignedPayload struct {
57 Payload EngagementPayload `json:"payload"`
58 Signature string `json:"signature"` // hex-encoded HMAC-SHA256
59 }
60
61 // AccountProvisioner is the interface the registry uses to create/modify IRC accounts.
62 // Implemented by *ergo.APIClient in production; can be mocked in tests.
63 type AccountProvisioner interface {
64 RegisterAccount(name, passphrase string) error
65 ChangePassword(name, passphrase string) error
66 }
67
68 // Registry manages registered agents and their credentials.
69 type Registry struct {
70 mu sync.RWMutex
71 agents map[string]*Agent // keyed by nick
72 provisioner AccountProvisioner
73 signingKey []byte
74 }
75
76 // New creates a new Registry with the given provisioner and HMAC signing key.
77 func New(provisioner AccountProvisioner, signingKey []byte) *Registry {
78 return &Registry{
79 agents: make(map[string]*Agent),
80 provisioner: provisioner,
81 signingKey: signingKey,
82 }
83 }
84
85 // Register creates a new agent, provisions its Ergo account, and returns
86 // credentials and a signed rules-of-engagement payload.
87 func (r *Registry) Register(nick string, agentType AgentType, channels, permissions []string) (*Credentials, *SignedPayload, error) {
88 r.mu.Lock()
89 defer r.mu.Unlock()
90
91 if existing, ok := r.agents[nick]; ok && !existing.Revoked {
92 return nil, nil, fmt.Errorf("registry: agent %q already registered", nick)
93 }
94
95 passphrase, err := generatePassphrase()
96 if err != nil {
97 return nil, nil, fmt.Errorf("registry: generate passphrase: %w", err)
98 }
99
100 if err := r.provisioner.RegisterAccount(nick, passphrase); err != nil {
101 return nil, nil, fmt.Errorf("registry: provision account: %w", err)
102 }
103
104 agent := &Agent{
105 Nick: nick,
106 Type: agentType,
107 Channels: channels,
108 Permissions: permissions,
109 CreatedAt: time.Now(),
110 }
111 r.agents[nick] = agent
112
113 payload, err := r.signPayload(agent)
114 if err != nil {
115 return nil, nil, fmt.Errorf("registry: sign payload: %w", err)
116 }
117
118 return &Credentials{Nick: nick, Passphrase: passphrase}, payload, nil
119 }
120
121 // Rotate generates a new passphrase for an agent and updates Ergo.
122 func (r *Registry) Rotate(nick string) (*Credentials, error) {
123 r.mu.Lock()
124 defer r.mu.Unlock()
125
126 agent, err := r.get(nick)
127 if err != nil {
128 return nil, err
129 }
130
131 passphrase, err := generatePassphrase()
132 if err != nil {
133 return nil, fmt.Errorf("registry: generate passphrase: %w", err)
134 }
135
136 if err := r.provisioner.ChangePassword(nick, passphrase); err != nil {
137 return nil, fmt.Errorf("registry: rotate credentials: %w", err)
138 }
139
140 _ = agent // agent exists, credentials rotated
141 return &Credentials{Nick: nick, Passphrase: passphrase}, nil
142 }
143
144 // Revoke locks an agent out by rotating to an unguessable passphrase and
145 // marking it revoked in the registry.
146 func (r *Registry) Revoke(nick string) error {
147 r.mu.Lock()
148 defer r.mu.Unlock()
149
150 agent, err := r.get(nick)
151 if err != nil {
152 return err
153 }
154
155 lockout, err := generatePassphrase()
156 if err != nil {
157 return fmt.Errorf("registry: generate lockout passphrase: %w", err)
158 }
159
160 if err := r.provisioner.ChangePassword(nick, lockout); err != nil {
161 return fmt.Errorf("registry: revoke credentials: %w", err)
162 }
163
164 agent.Revoked = true
165 return nil
166 }
167
168 // Get returns the agent with the given nick.
169 func (r *Registry) Get(nick string) (*Agent, error) {
170 r.mu.RLock()
171 defer r.mu.RUnlock()
172 return r.get(nick)
173 }
174
175 // List returns all registered, non-revoked agents.
176 func (r *Registry) List() []*Agent {
177 r.mu.RLock()
178 defer r.mu.RUnlock()
179 var out []*Agent
180 for _, a := range r.agents {
181 if !a.Revoked {
182 out = append(out, a)
183 }
184 }
185 return out
186 }
187
188 func (r *Registry) get(nick string) (*Agent, error) {
189 agent, ok := r.agents[nick]
190 if !ok {
191 return nil, fmt.Errorf("registry: agent %q not found", nick)
192 }
193 if agent.Revoked {
194 return nil, fmt.Errorf("registry: agent %q is revoked", nick)
195 }
196 return agent, nil
197 }
198
199 func (r *Registry) signPayload(agent *Agent) (*SignedPayload, error) {
200 payload := EngagementPayload{
201 V: 1,
202 Nick: agent.Nick,
203 Type: agent.Type,
204 Channels: agent.Channels,
205 Permissions: agent.Permissions,
206 IssuedAt: time.Now(),
207 }
208
209 data, err := json.Marshal(payload)
210 if err != nil {
211 return nil, err
212 }
213
214 mac := hmac.New(sha256.New, r.signingKey)
215 mac.Write(data)
216 sig := hex.EncodeToString(mac.Sum(nil))
217
218 return &SignedPayload{Payload: payload, Signature: sig}, nil
219 }
220
221 // VerifyPayload verifies the HMAC signature on a SignedPayload.
222 func VerifyPayload(sp *SignedPayload, signingKey []byte) error {
223 data, err := json.Marshal(sp.Payload)
224 if err != nil {
225 return err
226 }
227
228 mac := hmac.New(sha256.New, signingKey)
229 mac.Write(data)
230 expected := hex.EncodeToString(mac.Sum(nil))
231
232 if !hmac.Equal([]byte(sp.Signature), []byte(expected)) {
233 return fmt.Errorf("registry: invalid payload signature")
234 }
235 return nil
236 }
237
238 func generatePassphrase() (string, error) {
239 b := make([]byte, 32)
240 if _, err := rand.Read(b); err != nil {
241 return "", err
242 }
243 return hex.EncodeToString(b), nil
244 }
245
246 DDED internal/registry/registry_test.go
--- a/internal/registry/registry_test.go
+++ b/internal/registry/registry_test.go
@@ -0,0 +1,168 @@
1
+package registry_test
2
+
3
+import (
4
+ "fmt"
5
+ "sync"
6
+ "testing"
7
+
8
+ "github.com/conflicthq/scuttlebot/internal/registry"
9
+)
10
+
11
+// mockProvisioner records calls for test assertions.
12
+type mockProvisioner struct {
13
+ mu sync.Mutex
14
+ accounts map[string]string // nick → passphrase
15
+}
16
+
17
+func newMockProvisioner() *mockProvisioner {
18
+ return &mockProvisioner{accounts: make(map[string]string)}
19
+}
20
+
21
+func (m *mockProvisioner) RegisterAccount(name, passphrase string) error {
22
+ m.mu.Lock()
23
+ defer m.mu.Unlock()
24
+ if _, exists := m.accounts[name]; exists {
25
+ return fmt.Errorf("ACCOUNT_EXISTS")
26
+ }
27
+ m.accounts[name] = passphrase
28
+ return nil
29
+}
30
+
31
+func (m *mockProvisioner) ChangePassword(name, passphrase string) error {
32
+ m.mu.Lock()
33
+ defer m.mu.Unlock()
34
+ if _, exists := m.accounts[name]; !exists {
35
+ return fmt.Errorf("ACCOUNT_DOES_NOT_EXIST")
36
+ }
37
+ m.accounts[name] = passphrase
38
+ return nil
39
+}
40
+
41
+func (m *mockProvisioner) passphrase(nick string) string {
42
+ m.mu.Lock()
43
+ defer m.mu.Unlock()
44
+ return m.accounts[nick]
45
+}
46
+
47
+var testKey = []byte("test-signing-key-do-not []string{"task.create"})ds.Nick != "claude-01" {
48
+ t.Ercreds.Nick != "claude-01" {
49
+ t.Errorf("Nick: got %q, want %q", creds.Nick, "claude-01")
50
+ }
51
+ if creds.Passphrase == "" {
52
+ t.Error("Passphrase is empty")
53
+ }
54
+ if p.passphrase("claude-01") == "" {
55
+ t.Error("account not created in provisioner")
56
+ }
57
+ if payload.Payload.Nick != "claude-01" {
58
+ t.Errorf("payload Nick: got %q", payload.Payload.Nick)
59
+ }
60
+ if payload.Signature == "" {
61
+ t.Error("paylo}
62
+
63
+func TestRegisterDuplicate(t *testing.T) {
64
+ p := newMockProvisioner()
65
+ r := package registry_test
66
+
67
+import (
68
+ "f_test
69
+
70
+ieds.Nick != "claude-01" {
71
+ t.Errorf("Nick: got %q, want %q", creds.Nick, "claude-01")
72
+ }
73
+ if creds.Passphrase == "" {
74
+ t.Error("Passphrase is empty")
75
+ }
76
+ if p.passphrase("claude-01") == "" {
77
+ t.Error("account not created in provisioner")
78
+ }
79
+ if payload.Payload.Nick != "claude-01" {
80
+ t.Errorf("payload Nick: got %q", payload.Payload.Nick)
81
+ }
82
+ if payload.Signature == "" {
83
+ t.Error("payload signature is empty")
84
+ }
85
+ if len(payload.Payload.Config.Channels) != 2 {
86
+ t.Errorf("payload channels: got %d, want 2", len(payload.Payload.Config.Channels))
87
+ }
88
+}
89
+
90
+func TestRegisterDuplicate(t *testing.T) {
91
+ p := newMockProvisioner()
92
+ r := registry.New(p, testKey)
93
+
94
+ if _, _, err := r.Register("agent-01", registry.AgentTypeWorker, registry.EngagementConfig{}); err != nil {
95
+ t.Fatalf("first Register: %v", err)
96
+ }
97
+ if _, _, err := r.Register("agent-01", registry.AgentTypeWorker, registry.EngagementConfig{}); err == nil {
98
+ t.Error("expected error on duplicate registration, got nil")
99
+ }
100
+}
101
+
102
+func TestRotate(t *testing.T) {
103
+ p := newMockProvisioner()
104
+ r := registry.New(p, testKey)
105
+
106
+ creds, _, err := r.Register("agent-02", registry.AgentTypeWorker, registry.EngagementConfig{})
107
+ if err != nil {
108
+ t.Fatalf("Register: %v", err)
109
+ }
110
+ original := creds.Passphrase
111
+
112
+ newCreds, err := r.Rotate("agent-02")
113
+ if err != nil {
114
+ t.Fatalf("Rotate: %v", err)
115
+ }
116
+ if newCreds.Passphrase == original {
117
+ t.Error("passphrase should change after rotation")
118
+ }
119
+ if p.passphrase("agent-02") != newCreds.Passphrase {
120
+ t.Error("provisioner passphrase should match rotated credentials")
121
+ }
122
+}
123
+
124
+func TestRevoke(t *testing.T) {
125
+ p := newMockProvisioner()
126
+ r := registry.New(p, testKey)
127
+
128
+ creds, _, err := r.Register("agent-03", registry.AgentTypeWorker, registry.EngagementConfig{})
129
+ if err != nil {
130
+ t.Fatalf("Register: %v", err)
131
+ }
132
+
133
+ if err := r.Revoke("agent-03"); err != nil {
134
+ t.Fatalf("Revoke: %v", err)
135
+ }
136
+
137
+ if p.passphrase("agent-03") == creds.Passphrase {
138
+ t.Error("passphrase should change after revocation")
139
+ }
140
+ if _, err := r.Get("agent-03"); err == nil {
141
+ t.Error("Get should fail for revoked agent")
142
+ }
143
+}
144
+
145
+func TestVerifyPayload(t *testing.T) {
146
+ p := newMockProvisioner()
147
+ r := registry.New(p, testKey)
148
+
149
+ _, payload, err := r.Register("agent-04", registry.AgentTypeOrchestrator,
150
+ cfg([]string{"#fleet"}, []string{"task.create", "task.assign"}))
151
+ if err != nil {
152
+ t.Fatalf("Register: %v", err)
153
+ }
154
+
155
+ if err := registry.VerifyPayload(payload, testKey); err != nil {
156
+ t.Errorf("VerifyPayload: %v", err)
157
+ }
158
+
159
+ // Tamper with the payload.
160
+ payload.Payload.Nick = "evil-agent"
161
+ if err := registry.VerifyPayload(payload, testKey); err == nil {
162
+ t.Error("VerifyPayload should fail after tampering")
163
+ }
164
+}
165
+
166
+func TestList(t *testing.T) {
167
+ p := newMockProvisioner()
168
+ r := registry.N
--- a/internal/registry/registry_test.go
+++ b/internal/registry/registry_test.go
@@ -0,0 +1,168 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/internal/registry/registry_test.go
+++ b/internal/registry/registry_test.go
@@ -0,0 +1,168 @@
1 package registry_test
2
3 import (
4 "fmt"
5 "sync"
6 "testing"
7
8 "github.com/conflicthq/scuttlebot/internal/registry"
9 )
10
11 // mockProvisioner records calls for test assertions.
12 type mockProvisioner struct {
13 mu sync.Mutex
14 accounts map[string]string // nick → passphrase
15 }
16
17 func newMockProvisioner() *mockProvisioner {
18 return &mockProvisioner{accounts: make(map[string]string)}
19 }
20
21 func (m *mockProvisioner) RegisterAccount(name, passphrase string) error {
22 m.mu.Lock()
23 defer m.mu.Unlock()
24 if _, exists := m.accounts[name]; exists {
25 return fmt.Errorf("ACCOUNT_EXISTS")
26 }
27 m.accounts[name] = passphrase
28 return nil
29 }
30
31 func (m *mockProvisioner) ChangePassword(name, passphrase string) error {
32 m.mu.Lock()
33 defer m.mu.Unlock()
34 if _, exists := m.accounts[name]; !exists {
35 return fmt.Errorf("ACCOUNT_DOES_NOT_EXIST")
36 }
37 m.accounts[name] = passphrase
38 return nil
39 }
40
41 func (m *mockProvisioner) passphrase(nick string) string {
42 m.mu.Lock()
43 defer m.mu.Unlock()
44 return m.accounts[nick]
45 }
46
47 var testKey = []byte("test-signing-key-do-not []string{"task.create"})ds.Nick != "claude-01" {
48 t.Ercreds.Nick != "claude-01" {
49 t.Errorf("Nick: got %q, want %q", creds.Nick, "claude-01")
50 }
51 if creds.Passphrase == "" {
52 t.Error("Passphrase is empty")
53 }
54 if p.passphrase("claude-01") == "" {
55 t.Error("account not created in provisioner")
56 }
57 if payload.Payload.Nick != "claude-01" {
58 t.Errorf("payload Nick: got %q", payload.Payload.Nick)
59 }
60 if payload.Signature == "" {
61 t.Error("paylo}
62
63 func TestRegisterDuplicate(t *testing.T) {
64 p := newMockProvisioner()
65 r := package registry_test
66
67 import (
68 "f_test
69
70 ieds.Nick != "claude-01" {
71 t.Errorf("Nick: got %q, want %q", creds.Nick, "claude-01")
72 }
73 if creds.Passphrase == "" {
74 t.Error("Passphrase is empty")
75 }
76 if p.passphrase("claude-01") == "" {
77 t.Error("account not created in provisioner")
78 }
79 if payload.Payload.Nick != "claude-01" {
80 t.Errorf("payload Nick: got %q", payload.Payload.Nick)
81 }
82 if payload.Signature == "" {
83 t.Error("payload signature is empty")
84 }
85 if len(payload.Payload.Config.Channels) != 2 {
86 t.Errorf("payload channels: got %d, want 2", len(payload.Payload.Config.Channels))
87 }
88 }
89
90 func TestRegisterDuplicate(t *testing.T) {
91 p := newMockProvisioner()
92 r := registry.New(p, testKey)
93
94 if _, _, err := r.Register("agent-01", registry.AgentTypeWorker, registry.EngagementConfig{}); err != nil {
95 t.Fatalf("first Register: %v", err)
96 }
97 if _, _, err := r.Register("agent-01", registry.AgentTypeWorker, registry.EngagementConfig{}); err == nil {
98 t.Error("expected error on duplicate registration, got nil")
99 }
100 }
101
102 func TestRotate(t *testing.T) {
103 p := newMockProvisioner()
104 r := registry.New(p, testKey)
105
106 creds, _, err := r.Register("agent-02", registry.AgentTypeWorker, registry.EngagementConfig{})
107 if err != nil {
108 t.Fatalf("Register: %v", err)
109 }
110 original := creds.Passphrase
111
112 newCreds, err := r.Rotate("agent-02")
113 if err != nil {
114 t.Fatalf("Rotate: %v", err)
115 }
116 if newCreds.Passphrase == original {
117 t.Error("passphrase should change after rotation")
118 }
119 if p.passphrase("agent-02") != newCreds.Passphrase {
120 t.Error("provisioner passphrase should match rotated credentials")
121 }
122 }
123
124 func TestRevoke(t *testing.T) {
125 p := newMockProvisioner()
126 r := registry.New(p, testKey)
127
128 creds, _, err := r.Register("agent-03", registry.AgentTypeWorker, registry.EngagementConfig{})
129 if err != nil {
130 t.Fatalf("Register: %v", err)
131 }
132
133 if err := r.Revoke("agent-03"); err != nil {
134 t.Fatalf("Revoke: %v", err)
135 }
136
137 if p.passphrase("agent-03") == creds.Passphrase {
138 t.Error("passphrase should change after revocation")
139 }
140 if _, err := r.Get("agent-03"); err == nil {
141 t.Error("Get should fail for revoked agent")
142 }
143 }
144
145 func TestVerifyPayload(t *testing.T) {
146 p := newMockProvisioner()
147 r := registry.New(p, testKey)
148
149 _, payload, err := r.Register("agent-04", registry.AgentTypeOrchestrator,
150 cfg([]string{"#fleet"}, []string{"task.create", "task.assign"}))
151 if err != nil {
152 t.Fatalf("Register: %v", err)
153 }
154
155 if err := registry.VerifyPayload(payload, testKey); err != nil {
156 t.Errorf("VerifyPayload: %v", err)
157 }
158
159 // Tamper with the payload.
160 payload.Payload.Nick = "evil-agent"
161 if err := registry.VerifyPayload(payload, testKey); err == nil {
162 t.Error("VerifyPayload should fail after tampering")
163 }
164 }
165
166 func TestList(t *testing.T) {
167 p := newMockProvisioner()
168 r := registry.N

Keyboard Shortcuts

Open search /
Next entry (timeline) j
Previous entry (timeline) k
Open focused entry Enter
Show this help ?
Toggle theme Top nav button