From f65db3f5c42ed06ca9e8aab3b38bf7e50cf40065 Mon Sep 17 00:00:00 2001 From: snider Date: Wed, 31 Dec 2025 15:06:52 +0000 Subject: [PATCH] feat: Implement peer allowlist for P2P security (P2P-CRIT-1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- pkg/mining/node_service.go | 132 +++++++++++++++++++++++++++++++++++++ pkg/node/peer.go | 101 +++++++++++++++++++++++++++- pkg/node/transport.go | 32 ++++++++- 3 files changed, 260 insertions(+), 5 deletions(-) diff --git a/pkg/mining/node_service.go b/pkg/mining/node_service.go index aff411b..d7dd685 100644 --- a/pkg/mining/node_service.go +++ b/pkg/mining/node_service.go @@ -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"}) +} diff --git a/pkg/node/peer.go b/pkg/node/peer.go index 02c2649..b1023f3 100644 --- a/pkg/node/peer.go +++ b/pkg/node/peer.go @@ -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. diff --git a/pkg/node/transport.go b/pkg/node/transport.go index 89d917c..190b16e 100644 --- a/pkg/node/transport.go +++ b/pkg/node/transport.go @@ -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{