-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathdata.go
More file actions
323 lines (300 loc) · 11.1 KB
/
Copy pathdata.go
File metadata and controls
323 lines (300 loc) · 11.1 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
// 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, embedded at build time and
// refreshed hourly from raw.githubusercontent.com by
// plugins/trustedagents.Run. Authenticity is handled in two tiers:
// unsigned lists are accepted over TLS with a warning (backward-compatible);
// signed lists require embeddedPubKey to be configured and undergo full
// Ed25519 verification via VerifyAndStripSig before the payload is trusted.
//
// Adding an agent: edit trusted-agents.json, commit. Daemons in the
// field pick it up within ~1h. Brand-new daemons get the embedded copy
// from the binary, so the feature works on first boot even airgapped.
package trustedagents
import (
"crypto/ed25519"
"crypto/subtle"
_ "embed"
"encoding/base64"
"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() {
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 the runtime-fetched trusted-agents JSON. When all 32 bytes are zero
// the key has not been configured yet and signature verification is
// skipped (backward-compatible). Set this to the real public key once the
// signing infrastructure is in place.
//
// 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,
}
// VerifyAndStripSig checks the ed25519 signature embedded in the fetched
// JSON. If no "signature" field is present the raw body is returned as-is
// (backward-compatible with unsigned lists). If the field is present the
// signature is verified against embeddedPubKey; on mismatch an error is
// returned so the caller falls back to the embedded list.
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)
}
// No signature → accept unsigned (backward compat).
if envelope.Signature == nil || *envelope.Signature == "" {
slog.Warn("trustedagents: fetched list has no signature — " +
"accepting anyway (TLS-only trust). This will become a hard " +
"error once signing is deployed.")
return raw, nil
}
// 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
}