@@ -18,8 +18,9 @@ package trustedagents
1818
1919import (
2020 "crypto/ed25519"
21- "encoding/base64 "
21+ "crypto/subtle "
2222 _ "embed"
23+ "encoding/base64"
2324 "encoding/json"
2425 "fmt"
2526 "log/slog"
@@ -30,10 +31,20 @@ import (
3031// Hostname and Address are kept for logs and `pilotctl trusted list`.
3132// Other JSON fields in the source file (tier, description, ...) are
3233// silently ignored on unmarshal — we don't care about them at runtime.
34+ //
35+ // PublicKey is OPTIONAL: a base64 (std encoding) Ed25519 public key
36+ // pinning the node_id to a specific key. When present, the inbound
37+ // auto-accept path MUST verify the authenticated peer's key equals it
38+ // (see IsTrustedWithKey). When absent — as for every entry shipped
39+ // today — trust falls back to node_id alone, preserving current
40+ // behavior. Pinning closes audit finding H4: without it, taking over a
41+ // trusted node_id (or a registry that maps a trusted node_id to an
42+ // attacker key) inherits full auto-approve trust.
3343type Agent struct {
34- Hostname string `json:"hostname"`
35- Address string `json:"address"`
36- NodeID uint32 `json:"node_id"`
44+ Hostname string `json:"hostname"`
45+ Address string `json:"address"`
46+ NodeID uint32 `json:"node_id"`
47+ PublicKey string `json:"public_key,omitempty"`
3748}
3849
3950//go:embed trusted-agents.json
@@ -48,38 +59,109 @@ func EmbeddedJSON() []byte {
4859 return out
4960}
5061
62+ // entry is the in-memory form of a trusted agent: the display name plus
63+ // the optional decoded Ed25519 pin. pubKey is nil when the source entry
64+ // had no public_key (the unpinned, node_id-only case).
65+ type entry struct {
66+ name string
67+ pubKey ed25519.PublicKey // nil == unpinned
68+ }
69+
5170var (
5271 mu sync.RWMutex
53- byNode map [uint32 ]string // node_id -> name
72+ byNode map [uint32 ]entry // node_id -> entry
5473 all []Agent
5574)
5675
76+ // decodePin parses an Agent.PublicKey field into an ed25519.PublicKey.
77+ // Empty string → (nil, nil): unpinned, not an error. A non-empty value
78+ // that is not valid base64 or not 32 bytes is an error so a malformed
79+ // pin fails the whole Load rather than silently degrading to unpinned.
80+ func decodePin (b64 string ) (ed25519.PublicKey , error ) {
81+ if b64 == "" {
82+ return nil , nil
83+ }
84+ raw , err := base64 .StdEncoding .DecodeString (b64 )
85+ if err != nil {
86+ return nil , fmt .Errorf ("public_key: bad base64: %w" , err )
87+ }
88+ if len (raw ) != ed25519 .PublicKeySize {
89+ return nil , fmt .Errorf ("public_key: want %d bytes, got %d" , ed25519 .PublicKeySize , len (raw ))
90+ }
91+ return ed25519 .PublicKey (raw ), nil
92+ }
93+
5794func init () {
5895 if err := Load (embeddedJSON ); err != nil {
5996 // CI guards this via TestEmbeddedListLoads; if it ever fires in
6097 // production, an empty list (zero auto-accepts) is the safe default.
6198 slog .Error ("trustedagents: embedded list malformed" , "err" , err )
6299 mu .Lock ()
63- byNode = map [uint32 ]string {}
100+ byNode = map [uint32 ]entry {}
64101 mu .Unlock ()
65102 }
66103}
67104
68105// IsTrusted reports whether nodeID is in the trusted-agents list. The
69106// caller MUST verify the (node_id, public_key) binding at the registry
70107// before acting on a true result — this package only checks the list.
108+ //
109+ // IsTrusted does NOT consult the optional per-entry pubkey pin: it
110+ // answers the node_id-only question for callers that have no
111+ // authenticated key in scope. Callers that DO have the authenticated
112+ // peer key (e.g. the inbound handshake auto-accept path) MUST prefer
113+ // IsTrustedWithKey so a present pin is enforced.
71114func IsTrusted (nodeID uint32 ) (string , bool ) {
72115 mu .RLock ()
73116 defer mu .RUnlock ()
74- name , ok := byNode [nodeID ]
75- return name , ok
117+ e , ok := byNode [nodeID ]
118+ if ! ok {
119+ return "" , false
120+ }
121+ return e .name , true
122+ }
123+
124+ // IsTrustedWithKey reports whether nodeID is trusted given the
125+ // authenticated peer's Ed25519 public key.
126+ //
127+ // - nodeID not in the list → ("", false)
128+ // - entry HAS a pinned PublicKey → trusted ONLY if pubKey equals
129+ // the pin (constant-time compare). A mismatch — or an empty/short
130+ // pubKey when a pin is required — is ("", false).
131+ // - entry has NO pin (every entry today) → trusted by node_id alone,
132+ // preserving IsTrusted's behavior. The unpinned match is logged at
133+ // debug so finding-H4 exposure is observable until pins are added.
134+ //
135+ // Pass the AUTHENTICATED key (the one the peer proved possession of in
136+ // the handshake), never an unverified claim — otherwise the pin adds
137+ // nothing.
138+ func IsTrustedWithKey (nodeID uint32 , pubKey []byte ) (string , bool ) {
139+ mu .RLock ()
140+ defer mu .RUnlock ()
141+ e , ok := byNode [nodeID ]
142+ if ! ok {
143+ return "" , false
144+ }
145+ if e .pubKey == nil {
146+ // Unpinned: backward-compatible node_id-only trust.
147+ slog .Debug ("trustedagents: trusting unpinned entry by node_id only" ,
148+ "node_id" , nodeID , "agent" , e .name )
149+ return e .name , true
150+ }
151+ // Pinned: require an exact, constant-time key match.
152+ if subtle .ConstantTimeCompare (e .pubKey , pubKey ) != 1 {
153+ slog .Warn ("trustedagents: pubkey pin mismatch — refusing auto-accept" ,
154+ "node_id" , nodeID , "agent" , e .name )
155+ return "" , false
156+ }
157+ return e .name , true
76158}
77159
78160// SetForTest replaces the active list with agents and returns a restore
79161// function that reloads the embedded list. Test-only — never call from
80162// production code.
81163func SetForTest (agents []Agent ) (restore func ()) {
82- idx := make (map [uint32 ]string , len (agents ))
164+ idx := make (map [uint32 ]entry , len (agents ))
83165 for _ , a := range agents {
84166 if a .NodeID == 0 {
85167 continue
@@ -90,7 +172,11 @@ func SetForTest(agents []Agent) (restore func()) {
90172 if other , exists := idx [a .NodeID ]; exists {
91173 panic (fmt .Sprintf ("SetForTest: duplicate node_id %d (hostnames %q and %q)" , a .NodeID , other , a .Hostname ))
92174 }
93- idx [a .NodeID ] = a .Hostname
175+ pin , err := decodePin (a .PublicKey )
176+ if err != nil {
177+ panic (fmt .Sprintf ("SetForTest: node_id %d (%q): %v" , a .NodeID , a .Hostname , err ))
178+ }
179+ idx [a .NodeID ] = entry {name : a .Hostname , pubKey : pin }
94180 }
95181 mu .Lock ()
96182 prevByNode , prevAll := byNode , all
@@ -197,7 +283,7 @@ func Load(raw []byte) error {
197283 if err := json .Unmarshal (raw , & doc ); err != nil {
198284 return err
199285 }
200- idx := make (map [uint32 ]string , len (doc .Agents ))
286+ idx := make (map [uint32 ]entry , len (doc .Agents ))
201287 for _ , a := range doc .Agents {
202288 if a .NodeID == 0 {
203289 continue // 0 is reserved / would silently match unset fields
@@ -206,9 +292,13 @@ func Load(raw []byte) error {
206292 continue // empty hostname: missing required field — drop
207293 }
208294 if other , exists := idx [a .NodeID ]; exists {
209- return fmt .Errorf ("duplicate node_id %d in trusted-agents list: %q and %q" , a .NodeID , other , a .Hostname )
295+ return fmt .Errorf ("duplicate node_id %d in trusted-agents list: %q and %q" , a .NodeID , other .name , a .Hostname )
296+ }
297+ pin , err := decodePin (a .PublicKey )
298+ if err != nil {
299+ return fmt .Errorf ("node_id %d (%q): %w" , a .NodeID , a .Hostname , err )
210300 }
211- idx [a .NodeID ] = a .Hostname
301+ idx [a .NodeID ] = entry { name : a .Hostname , pubKey : pin }
212302 }
213303 mu .Lock ()
214304 byNode = idx
0 commit comments