-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathdata.go
More file actions
336 lines (311 loc) · 11.4 KB
/
Copy pathdata.go
File metadata and controls
336 lines (311 loc) · 11.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
// SPDX-License-Identifier: AGPL-3.0-or-later
// Package trustedagents holds the build-time-embedded list of node IDs
// that the daemon auto-accepts handshake requests from. The data layer
// is utility-tier so both the daemon plugin (plugins/trustedagents)
// and the CLI (cmd/pilotctl) can read it without violating the strict
// downward layer rule.
//
// The list is plain JSON in this directory and embedded at build time.
// Runtime refresh is enabled only when the binary contains an Ed25519
// verifier key. Every fetched list must carry a valid signature; otherwise
// the reviewed build-embedded list remains active.
//
// Adding an agent: edit trusted-agents.json, review, and commit. Brand-new
// daemons get that embedded copy from the binary, so the feature works on
// first boot even airgapped. Runtime updates require a signed list and a
// verifier key compiled into the binary.
package trustedagents
import (
"crypto/ed25519"
"crypto/subtle"
_ "embed"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"log/slog"
"sync"
)
// Agent is one entry in the trusted-agents list. Match is by NodeID;
// Hostname and Address are kept for logs and `pilotctl trusted list`.
// Other JSON fields in the source file (tier, description, ...) are
// silently ignored on unmarshal — we don't care about them at runtime.
//
// PublicKey is OPTIONAL: a base64 (std encoding) Ed25519 public key
// pinning the node_id to a specific key. When present, the inbound
// auto-accept path MUST verify the authenticated peer's key equals it
// (see IsTrustedWithKey). When absent — as for every entry shipped
// today — trust falls back to node_id alone, preserving current
// behavior. Pinning closes audit finding H4: without it, taking over a
// trusted node_id (or a registry that maps a trusted node_id to an
// attacker key) inherits full auto-approve trust.
type Agent struct {
Hostname string `json:"hostname"`
Address string `json:"address"`
NodeID uint32 `json:"node_id"`
PublicKey string `json:"public_key,omitempty"`
}
//go:embed trusted-agents.json
var embeddedJSON []byte
// EmbeddedJSON returns the bytes of the embedded JSON list. Exposed for
// the plugin's HTTP refresher which needs to compare fetched bytes
// against the embedded baseline at startup.
func EmbeddedJSON() []byte {
out := make([]byte, len(embeddedJSON))
copy(out, embeddedJSON)
return out
}
// entry is the in-memory form of a trusted agent: the display name plus
// the optional decoded Ed25519 pin. pubKey is nil when the source entry
// had no public_key (the unpinned, node_id-only case).
type entry struct {
name string
pubKey ed25519.PublicKey // nil == unpinned
}
var (
mu sync.RWMutex
byNode map[uint32]entry // node_id -> entry
all []Agent
)
// decodePin parses an Agent.PublicKey field into an ed25519.PublicKey.
// Empty string → (nil, nil): unpinned, not an error. A non-empty value
// that is not valid base64 or not 32 bytes is an error so a malformed
// pin fails the whole Load rather than silently degrading to unpinned.
func decodePin(b64 string) (ed25519.PublicKey, error) {
if b64 == "" {
return nil, nil
}
raw, err := base64.StdEncoding.DecodeString(b64)
if err != nil {
return nil, fmt.Errorf("public_key: bad base64: %w", err)
}
if len(raw) != ed25519.PublicKeySize {
return nil, fmt.Errorf("public_key: want %d bytes, got %d", ed25519.PublicKeySize, len(raw))
}
return ed25519.PublicKey(raw), nil
}
func init() {
configureEmbeddedPubKey()
if err := Load(embeddedJSON); err != nil {
// CI guards this via TestEmbeddedListLoads; if it ever fires in
// production, an empty list (zero auto-accepts) is the safe default.
slog.Error("trustedagents: embedded list malformed", "err", err)
mu.Lock()
byNode = map[uint32]entry{}
mu.Unlock()
}
}
// IsTrusted reports whether nodeID is in the trusted-agents list. The
// caller MUST verify the (node_id, public_key) binding at the registry
// before acting on a true result — this package only checks the list.
//
// IsTrusted does NOT consult the optional per-entry pubkey pin: it
// answers the node_id-only question for callers that have no
// authenticated key in scope. Callers that DO have the authenticated
// peer key (e.g. the inbound handshake auto-accept path) MUST prefer
// IsTrustedWithKey so a present pin is enforced.
func IsTrusted(nodeID uint32) (string, bool) {
mu.RLock()
defer mu.RUnlock()
e, ok := byNode[nodeID]
if !ok {
return "", false
}
return e.name, true
}
// IsTrustedWithKey reports whether nodeID is trusted given the
// authenticated peer's Ed25519 public key.
//
// - nodeID not in the list → ("", false)
// - entry HAS a pinned PublicKey → trusted ONLY if pubKey equals
// the pin (constant-time compare). A mismatch — or an empty/short
// pubKey when a pin is required — is ("", false).
// - entry has NO pin (every entry today) → trusted by node_id alone,
// preserving IsTrusted's behavior. The unpinned match is logged at
// debug so finding-H4 exposure is observable until pins are added.
//
// Pass the AUTHENTICATED key (the one the peer proved possession of in
// the handshake), never an unverified claim — otherwise the pin adds
// nothing.
func IsTrustedWithKey(nodeID uint32, pubKey []byte) (string, bool) {
mu.RLock()
defer mu.RUnlock()
e, ok := byNode[nodeID]
if !ok {
return "", false
}
if e.pubKey == nil {
// Unpinned: backward-compatible node_id-only trust.
slog.Debug("trustedagents: trusting unpinned entry by node_id only",
"node_id", nodeID, "agent", e.name)
return e.name, true
}
// Pinned: require an exact, constant-time key match.
if subtle.ConstantTimeCompare(e.pubKey, pubKey) != 1 {
slog.Warn("trustedagents: pubkey pin mismatch — refusing auto-accept",
"node_id", nodeID, "agent", e.name)
return "", false
}
return e.name, true
}
// SetForTest replaces the active list with agents and returns a restore
// function that reloads the embedded list. Test-only — never call from
// production code.
func SetForTest(agents []Agent) (restore func()) {
idx := make(map[uint32]entry, len(agents))
for _, a := range agents {
if a.NodeID == 0 {
continue
}
if a.Hostname == "" {
continue
}
if other, exists := idx[a.NodeID]; exists {
panic(fmt.Sprintf("SetForTest: duplicate node_id %d (hostnames %q and %q)", a.NodeID, other, a.Hostname))
}
pin, err := decodePin(a.PublicKey)
if err != nil {
panic(fmt.Sprintf("SetForTest: node_id %d (%q): %v", a.NodeID, a.Hostname, err))
}
idx[a.NodeID] = entry{name: a.Hostname, pubKey: pin}
}
mu.Lock()
prevByNode, prevAll := byNode, all
byNode = idx
all = append([]Agent(nil), agents...)
mu.Unlock()
return func() {
mu.Lock()
byNode = prevByNode
all = prevAll
mu.Unlock()
}
}
// All returns a copy of the current list. Used by `pilotctl trusted list`.
func All() []Agent {
mu.RLock()
defer mu.RUnlock()
out := make([]Agent, len(all))
copy(out, all)
return out
}
// embeddedPubKey is the Ed25519 public key used to verify the signature on
// runtime-fetched trusted-agents JSON. When all 32 bytes are zero, runtime
// refresh is disabled and the reviewed build-embedded list remains active.
//
// To inject: go build -ldflags "-X github.com/pilot-protocol/trustedagents.embeddedPubKeyHex=<64-hex-chars>"
// Generate keypair: scripts/gen-signing-key.sh
var embeddedPubKey = ed25519.PublicKey{
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
}
// embeddedPubKeyHex is intentionally a string so release builds can inject a
// verifier with -ldflags -X. An invalid value leaves embeddedPubKey at zero,
// which safely disables runtime refresh.
var embeddedPubKeyHex string
func configureEmbeddedPubKey() {
if embeddedPubKeyHex == "" {
return
}
raw, err := hex.DecodeString(embeddedPubKeyHex)
if err != nil || len(raw) != ed25519.PublicKeySize {
slog.Error("trustedagents: invalid embedded verifier key; runtime refresh disabled",
"bytes", len(raw), "err", err)
return
}
copy(embeddedPubKey, raw)
}
// VerifyAndStripSig checks the ed25519 signature embedded in the fetched
// JSON. Both a configured verifier and a non-empty signature are required.
// Any failure leaves the caller's reviewed build-embedded list untouched.
func VerifyAndStripSig(raw []byte) ([]byte, error) {
// Decode the entire doc to extract the signature field.
var envelope struct {
Agents json.RawMessage `json:"agents"`
Signature *string `json:"signature,omitempty"`
}
if err := json.Unmarshal(raw, &envelope); err != nil {
return nil, fmt.Errorf("verify: parse: %w", err)
}
// Runtime trust updates must be authenticated. HTTPS still protects the
// transport, but it is not authorization to change an auto-trust list.
if envelope.Signature == nil || *envelope.Signature == "" {
return nil, fmt.Errorf("verify: signature is required")
}
// Public key not configured → reject: a signature exists but we
// cannot verify it. Accepting would defeat the purpose.
if isZeroKey(embeddedPubKey) {
return nil, fmt.Errorf("verify: signature present but embeddedPubKey is not configured")
}
sigBytes, err := base64.StdEncoding.DecodeString(*envelope.Signature)
if err != nil {
return nil, fmt.Errorf("verify: bad signature encoding: %w", err)
}
// Re-marshal WITHOUT the signature to produce the exact payload the
// signer committed to. The signer MUST use the same canonical form
// (json.Marshal on this struct with Agents as json.RawMessage).
envelope.Signature = nil
payload, err := json.Marshal(envelope)
if err != nil {
return nil, fmt.Errorf("verify: remarshal: %w", err)
}
if !ed25519.Verify(embeddedPubKey, payload, sigBytes) {
return nil, fmt.Errorf("verify: signature mismatch")
}
slog.Info("trustedagents: signature verified", "agents", len(raw))
return payload, nil
}
func isZeroKey(k ed25519.PublicKey) bool {
for _, b := range k {
if b != 0 {
return false
}
}
return true
}
// Load parses raw JSON and atomically replaces the active list. Safe to
// call from any goroutine. Used by plugins/trustedagents.fetchOnce
// after each successful HTTP refresh.
func Load(raw []byte) error {
var doc struct {
Agents []Agent `json:"agents"`
}
if err := json.Unmarshal(raw, &doc); err != nil {
return err
}
idx := make(map[uint32]entry, len(doc.Agents))
voided := make(map[uint32]bool) // node_ids seen more than once
for _, a := range doc.Agents {
if a.NodeID == 0 {
continue // 0 is reserved / would silently match unset fields
}
if a.Hostname == "" {
continue // empty hostname: missing required field — drop
}
if voided[a.NodeID] {
continue // a duplicate already voided this node_id (below)
}
if other, exists := idx[a.NodeID]; exists {
// Duplicate node_id: drop EVERY entry for it (the one already
// indexed and this one) rather than failing the whole list. An
// ambiguous node_id must not be trusted — neither the first
// entry nor a later pin may silently win — but a single bad row
// must not disable the entire feed.
slog.Warn("trustedagents: duplicate node_id — dropping all entries for it",
"node_id", a.NodeID, "hostnames", []string{other.name, a.Hostname})
delete(idx, a.NodeID)
voided[a.NodeID] = true
continue
}
pin, err := decodePin(a.PublicKey)
if err != nil {
return fmt.Errorf("node_id %d (%q): %w", a.NodeID, a.Hostname, err)
}
idx[a.NodeID] = entry{name: a.Hostname, pubKey: pin}
}
mu.Lock()
byNode = idx
all = doc.Agents
mu.Unlock()
return nil
}