Skip to content

Commit 32ef4c2

Browse files
TeoSlayerteovl
andauthored
Add optional Ed25519 pubkey pinning to trusted-agents (H4) (#21)
Each agent entry may carry an optional base64 public_key that pins the node_id to a specific Ed25519 key. IsTrustedWithKey enforces the pin with a constant-time compare when present and falls back to node_id-only trust when absent, so the change is backward-compatible with every entry shipped today. IsTrusted is unchanged for key-less callers. Closes audit finding H4 (node_id-only trust lets a takeover of any trusted node_id inherit auto-approve). Inbound auto-accept enforcement needs upstream wiring; documented as a TODO at the Service call site. Co-authored-by: Teodor <teodor@vulturelabs.io>
1 parent a5585d6 commit 32ef4c2

6 files changed

Lines changed: 357 additions & 15 deletions

File tree

CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,17 @@ All notable changes to this project are documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [Unreleased]
9+
10+
### Added
11+
12+
- Optional per-entry `public_key` (base64 Ed25519) pinning a trusted
13+
`node_id` to a specific key. `IsTrustedWithKey(nodeID, pubKey)`
14+
enforces the pin with a constant-time compare when present, and falls
15+
back to `node_id`-only trust when absent (backward-compatible — every
16+
shipped entry is unpinned). `IsTrusted(nodeID)` is unchanged.
17+
Addresses audit finding H4.
18+
819
## [v0.1.0]
920

1021
Initial release.

README.md

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,20 +18,46 @@ import "github.com/pilot-protocol/trustedagents"
1818
## Usage
1919

2020
```go
21-
// Lookup:
21+
// Lookup (node_id only):
2222
name, ok := trustedagents.IsTrusted(nodeID)
2323
_ = name
2424
_ = ok
2525

26+
// Lookup with pubkey pin enforcement (preferred when the
27+
// authenticated peer key is in scope, e.g. inbound handshake):
28+
name, ok = trustedagents.IsTrustedWithKey(nodeID, peerPubKey)
29+
2630
// Daemon registration:
2731
rt.Register(trustedagents.NewService())
2832
```
2933

34+
## Optional pubkey pinning
35+
36+
Each entry may carry an optional `public_key` (base64 std-encoded
37+
Ed25519) that pins the `node_id` to a specific key:
38+
39+
```json
40+
{ "hostname": "list-agents", "node_id": 14161, "public_key": "BASE64_ED25519_PUBKEY" }
41+
```
42+
43+
`IsTrustedWithKey(nodeID, peerPubKey)` enforces the binding: if an entry
44+
has a `public_key`, the authenticated peer key must match it
45+
(constant-time compare) or the peer is **not** trusted. Entries without a
46+
`public_key` — every entry shipped today — fall back to `node_id`-only
47+
trust, so adding pins is fully backward-compatible. `IsTrusted(nodeID)`
48+
is unchanged and still answers the key-less question for callers with no
49+
peer key in scope.
50+
51+
This closes audit finding **H4**: without a pin, taking over any trusted
52+
`node_id` (or a registry mapping a trusted `node_id` to an attacker key)
53+
inherits full auto-approve trust. Enforcement at the inbound auto-accept
54+
path requires upstream wiring — see the TODO on `Service.IsTrustedWithKey`.
55+
3056
## Layout
3157

3258
| File | What it does |
3359
|---|---|
34-
| `data.go` | Embedded JSON list. `Load`, `All`, `IsTrusted(nodeID) → (description, ok)`, `SetForTest`. |
60+
| `data.go` | Embedded JSON list. `Load`, `All`, `IsTrusted(nodeID) → (name, ok)`, `IsTrustedWithKey(nodeID, pubKey) → (name, ok)`, `SetForTest`. |
3561
| `runtime.go` | `Run(ctx)` — periodic fetcher over HTTPS to `raw.githubusercontent.com`. |
3662
| `service.go` | `*Service``coreapi.Service` adapter (`Name/Order/Start/Stop` + `IsTrusted`). Build tag `!no_trustedagents`. |
3763
| `service_disabled.go` | Stub `*Service` when build tag `no_trustedagents` is set. |

data.go

Lines changed: 103 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,9 @@ package trustedagents
1818

1919
import (
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.
3343
type 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+
5170
var (
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+
5794
func 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.
71114
func 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.
81163
func 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

service.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,3 +67,29 @@ func (s *Service) Stop(ctx context.Context) error {
6767
func (s *Service) IsTrusted(nodeID uint32) (string, bool) {
6868
return IsTrusted(nodeID)
6969
}
70+
71+
// IsTrustedWithKey is the pubkey-pinned trust gate (audit finding H4).
72+
// It enforces a per-entry Ed25519 pin when one is present and otherwise
73+
// falls back to node_id-only trust. Delegates to the package-global
74+
// allowlist.
75+
//
76+
// TODO(H4 wiring): the inbound auto-accept call site lives in
77+
// protocol/plugins/handshake (handshake.go ~L639), where it calls
78+
// hm.rt.IsTrusted(peerNodeID) via the coreapi.TrustChecker interface.
79+
// The authenticated peer key IS in scope there as msg.PublicKey (it is
80+
// already stored into the TrustRecord). To actually ENFORCE pinning,
81+
// two upstream changes are needed, in this order:
82+
//
83+
// 1. common/coreapi.TrustChecker: add
84+
// IsTrustedWithKey(nodeID uint32, pubKey []byte) (string, bool).
85+
// 2. protocol/plugins/handshake: replace the auto-accept
86+
// hm.rt.IsTrusted(peerNodeID) call with
87+
// hm.rt.IsTrustedWithKey(peerNodeID, msg.PublicKey).
88+
//
89+
// Until those land, this method is callable directly but the daemon's
90+
// handshake path still routes through IsTrusted, so pins are stored and
91+
// validated on Load but not yet enforced at auto-accept. That is safe:
92+
// every shipped entry is unpinned, so behavior is unchanged.
93+
func (s *Service) IsTrustedWithKey(nodeID uint32, pubKey []byte) (string, bool) {
94+
return IsTrustedWithKey(nodeID, pubKey)
95+
}

service_disabled.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,3 +33,7 @@ func (s *Service) Stop(_ context.Context) error { return nil }
3333
// IsTrusted always reports the node as untrusted when the plugin is
3434
// disabled. Callers should treat this as a fail-closed default.
3535
func (s *Service) IsTrusted(_ uint32) (string, bool) { return "", false }
36+
37+
// IsTrustedWithKey mirrors IsTrusted's fail-closed behavior: with the
38+
// plugin disabled every peer is untrusted regardless of key.
39+
func (s *Service) IsTrustedWithKey(_ uint32, _ []byte) (string, bool) { return "", false }

0 commit comments

Comments
 (0)