feat: Implement peer allowlist for P2P security (P2P-CRIT-1)

Add PeerAuthMode to control peer registration:
- PeerAuthOpen: Allow all peers (backward compatible default)
- PeerAuthAllowlist: Only allow pre-registered peers or allowlisted public keys

New features:
- PeerRegistry.SetAuthMode/GetAuthMode for mode control
- PeerRegistry.AllowPublicKey/RevokePublicKey for key management
- PeerRegistry.IsPeerAllowed check before connection acceptance
- Transport rejects unauthorized peers with proper handshake rejection

New API endpoints:
- GET/PUT /peers/auth/mode - Get/set authentication mode
- GET/POST/DELETE /peers/auth/allowlist - Manage allowlisted keys

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
snider 2025-12-31 15:06:52 +00:00
parent 87b426480b
commit f65db3f5c4
3 changed files with 260 additions and 5 deletions

View file

@ -66,6 +66,13 @@ func (ns *NodeService) SetupRoutes(router *gin.RouterGroup) {
peerGroup.POST("/:id/ping", ns.handlePingPeer)
peerGroup.POST("/:id/connect", ns.handleConnectPeer)
peerGroup.POST("/:id/disconnect", ns.handleDisconnectPeer)
// Allowlist management
peerGroup.GET("/auth/mode", ns.handleGetAuthMode)
peerGroup.PUT("/auth/mode", ns.handleSetAuthMode)
peerGroup.GET("/auth/allowlist", ns.handleListAllowlist)
peerGroup.POST("/auth/allowlist", ns.handleAddToAllowlist)
peerGroup.DELETE("/auth/allowlist/:key", ns.handleRemoveFromAllowlist)
}
// Remote operations endpoints
@ -441,3 +448,128 @@ func (ns *NodeService) handleRemoteLogs(c *gin.Context) {
}
c.JSON(http.StatusOK, logs)
}
// AuthModeResponse is the response for auth mode endpoints.
type AuthModeResponse struct {
Mode string `json:"mode"`
}
// handleGetAuthMode godoc
// @Summary Get peer authentication mode
// @Description Get the current peer authentication mode (open or allowlist)
// @Tags peers
// @Produce json
// @Success 200 {object} AuthModeResponse
// @Router /peers/auth/mode [get]
func (ns *NodeService) handleGetAuthMode(c *gin.Context) {
mode := ns.peerRegistry.GetAuthMode()
modeStr := "open"
if mode == node.PeerAuthAllowlist {
modeStr = "allowlist"
}
c.JSON(http.StatusOK, AuthModeResponse{Mode: modeStr})
}
// SetAuthModeRequest is the request for setting auth mode.
type SetAuthModeRequest struct {
Mode string `json:"mode" binding:"required"`
}
// handleSetAuthMode godoc
// @Summary Set peer authentication mode
// @Description Set the peer authentication mode (open or allowlist)
// @Tags peers
// @Accept json
// @Produce json
// @Param request body SetAuthModeRequest true "Auth mode (open or allowlist)"
// @Success 200 {object} AuthModeResponse
// @Failure 400 {object} APIError "Invalid mode"
// @Router /peers/auth/mode [put]
func (ns *NodeService) handleSetAuthMode(c *gin.Context) {
var req SetAuthModeRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
var mode node.PeerAuthMode
switch req.Mode {
case "open":
mode = node.PeerAuthOpen
case "allowlist":
mode = node.PeerAuthAllowlist
default:
respondWithError(c, http.StatusBadRequest, "INVALID_MODE", "mode must be 'open' or 'allowlist'", "")
return
}
ns.peerRegistry.SetAuthMode(mode)
c.JSON(http.StatusOK, AuthModeResponse{Mode: req.Mode})
}
// AllowlistResponse is the response for listing allowlisted keys.
type AllowlistResponse struct {
PublicKeys []string `json:"publicKeys"`
}
// handleListAllowlist godoc
// @Summary List allowlisted public keys
// @Description Get all public keys in the peer allowlist
// @Tags peers
// @Produce json
// @Success 200 {object} AllowlistResponse
// @Router /peers/auth/allowlist [get]
func (ns *NodeService) handleListAllowlist(c *gin.Context) {
keys := ns.peerRegistry.ListAllowedPublicKeys()
c.JSON(http.StatusOK, AllowlistResponse{PublicKeys: keys})
}
// AddAllowlistRequest is the request for adding a key to the allowlist.
type AddAllowlistRequest struct {
PublicKey string `json:"publicKey" binding:"required"`
}
// handleAddToAllowlist godoc
// @Summary Add public key to allowlist
// @Description Add a public key to the peer allowlist
// @Tags peers
// @Accept json
// @Produce json
// @Param request body AddAllowlistRequest true "Public key to allow"
// @Success 201 {object} map[string]string
// @Failure 400 {object} APIError "Invalid request"
// @Router /peers/auth/allowlist [post]
func (ns *NodeService) handleAddToAllowlist(c *gin.Context) {
var req AddAllowlistRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if len(req.PublicKey) < 16 {
respondWithError(c, http.StatusBadRequest, "INVALID_KEY", "public key too short", "")
return
}
ns.peerRegistry.AllowPublicKey(req.PublicKey)
c.JSON(http.StatusCreated, gin.H{"status": "added"})
}
// handleRemoveFromAllowlist godoc
// @Summary Remove public key from allowlist
// @Description Remove a public key from the peer allowlist
// @Tags peers
// @Produce json
// @Param key path string true "Public key to remove (URL-encoded)"
// @Success 200 {object} map[string]string
// @Router /peers/auth/allowlist/{key} [delete]
func (ns *NodeService) handleRemoveFromAllowlist(c *gin.Context) {
key := c.Param("key")
if key == "" {
respondWithError(c, http.StatusBadRequest, "MISSING_KEY", "public key required", "")
return
}
ns.peerRegistry.RevokePublicKey(key)
c.JSON(http.StatusOK, gin.H{"status": "removed"})
}

View file

@ -36,6 +36,16 @@ type Peer struct {
// saveDebounceInterval is the minimum time between disk writes.
const saveDebounceInterval = 5 * time.Second
// PeerAuthMode controls how unknown peers are handled
type PeerAuthMode int
const (
// PeerAuthOpen allows any peer to connect (original behavior)
PeerAuthOpen PeerAuthMode = iota
// PeerAuthAllowlist only allows pre-registered peers or those with allowed public keys
PeerAuthAllowlist
)
// PeerRegistry manages known peers with KD-tree based selection.
type PeerRegistry struct {
peers map[string]*Peer
@ -43,6 +53,11 @@ type PeerRegistry struct {
path string
mu sync.RWMutex
// Authentication settings
authMode PeerAuthMode // How to handle unknown peers
allowedPublicKeys map[string]bool // Allowlist of public keys (when authMode is Allowlist)
allowedPublicKeyMu sync.RWMutex // Protects allowedPublicKeys
// Debounce disk writes
dirty bool // Whether there are unsaved changes
saveTimer *time.Timer // Timer for debounced save
@ -74,9 +89,11 @@ func NewPeerRegistry() (*PeerRegistry, error) {
// This is primarily useful for testing to avoid xdg path caching issues.
func NewPeerRegistryWithPath(peersPath string) (*PeerRegistry, error) {
pr := &PeerRegistry{
peers: make(map[string]*Peer),
path: peersPath,
stopChan: make(chan struct{}),
peers: make(map[string]*Peer),
path: peersPath,
stopChan: make(chan struct{}),
authMode: PeerAuthOpen, // Default to open for backward compatibility
allowedPublicKeys: make(map[string]bool),
}
// Try to load existing peers
@ -90,6 +107,84 @@ func NewPeerRegistryWithPath(peersPath string) (*PeerRegistry, error) {
return pr, nil
}
// SetAuthMode sets the authentication mode for peer connections.
func (r *PeerRegistry) SetAuthMode(mode PeerAuthMode) {
r.allowedPublicKeyMu.Lock()
defer r.allowedPublicKeyMu.Unlock()
r.authMode = mode
logging.Info("peer auth mode changed", logging.Fields{"mode": mode})
}
// GetAuthMode returns the current authentication mode.
func (r *PeerRegistry) GetAuthMode() PeerAuthMode {
r.allowedPublicKeyMu.RLock()
defer r.allowedPublicKeyMu.RUnlock()
return r.authMode
}
// AllowPublicKey adds a public key to the allowlist.
func (r *PeerRegistry) AllowPublicKey(publicKey string) {
r.allowedPublicKeyMu.Lock()
defer r.allowedPublicKeyMu.Unlock()
r.allowedPublicKeys[publicKey] = true
logging.Debug("public key added to allowlist", logging.Fields{"key": publicKey[:16] + "..."})
}
// RevokePublicKey removes a public key from the allowlist.
func (r *PeerRegistry) RevokePublicKey(publicKey string) {
r.allowedPublicKeyMu.Lock()
defer r.allowedPublicKeyMu.Unlock()
delete(r.allowedPublicKeys, publicKey)
logging.Debug("public key removed from allowlist", logging.Fields{"key": publicKey[:16] + "..."})
}
// IsPublicKeyAllowed checks if a public key is in the allowlist.
func (r *PeerRegistry) IsPublicKeyAllowed(publicKey string) bool {
r.allowedPublicKeyMu.RLock()
defer r.allowedPublicKeyMu.RUnlock()
return r.allowedPublicKeys[publicKey]
}
// IsPeerAllowed checks if a peer is allowed to connect based on auth mode.
// Returns true if:
// - AuthMode is Open (allow all)
// - AuthMode is Allowlist AND (peer is pre-registered OR public key is allowlisted)
func (r *PeerRegistry) IsPeerAllowed(peerID string, publicKey string) bool {
r.allowedPublicKeyMu.RLock()
authMode := r.authMode
keyAllowed := r.allowedPublicKeys[publicKey]
r.allowedPublicKeyMu.RUnlock()
// Open mode allows everyone
if authMode == PeerAuthOpen {
return true
}
// Allowlist mode: check if peer is pre-registered
r.mu.RLock()
_, isRegistered := r.peers[peerID]
r.mu.RUnlock()
if isRegistered {
return true
}
// Check if public key is allowlisted
return keyAllowed
}
// ListAllowedPublicKeys returns all allowlisted public keys.
func (r *PeerRegistry) ListAllowedPublicKeys() []string {
r.allowedPublicKeyMu.RLock()
defer r.allowedPublicKeyMu.RUnlock()
keys := make([]string, 0, len(r.allowedPublicKeys))
for key := range r.allowedPublicKeys {
keys = append(keys, key)
}
return keys
}
// AddPeer adds a new peer to the registry.
// Note: Persistence is debounced (writes batched every 5s). Call Close() to ensure
// all changes are flushed to disk before shutdown.

View file

@ -337,10 +337,34 @@ func (t *Transport) handleWSUpgrade(w http.ResponseWriter, r *http.Request) {
return
}
// Create peer if not exists
// Check if peer is allowed to connect (allowlist check)
if !t.registry.IsPeerAllowed(payload.Identity.ID, payload.Identity.PublicKey) {
logging.Warn("peer connection rejected: not in allowlist", logging.Fields{
"peer_id": payload.Identity.ID,
"peer_name": payload.Identity.Name,
"public_key": payload.Identity.PublicKey[:16] + "...",
})
// Send rejection before closing
identity := t.node.GetIdentity()
if identity != nil {
rejectPayload := HandshakeAckPayload{
Identity: *identity,
Accepted: false,
Reason: "peer not authorized",
}
rejectMsg, _ := NewMessage(MsgHandshakeAck, identity.ID, payload.Identity.ID, rejectPayload)
if rejectData, err := json.Marshal(rejectMsg); err == nil {
conn.WriteMessage(websocket.TextMessage, rejectData)
}
}
conn.Close()
return
}
// Create peer if not exists (only if auth passed)
peer := t.registry.GetPeer(payload.Identity.ID)
if peer == nil {
// Auto-register for now (could require pre-registration)
// Auto-register the peer since they passed allowlist check
peer = &Peer{
ID: payload.Identity.ID,
Name: payload.Identity.Name,
@ -350,6 +374,10 @@ func (t *Transport) handleWSUpgrade(w http.ResponseWriter, r *http.Request) {
Score: 50,
}
t.registry.AddPeer(peer)
logging.Info("auto-registered new peer", logging.Fields{
"peer_id": peer.ID,
"peer_name": peer.Name,
})
}
pc := &PeerConnection{