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:
parent
87b426480b
commit
f65db3f5c4
3 changed files with 260 additions and 5 deletions
|
|
@ -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"})
|
||||
}
|
||||
|
|
|
|||
101
pkg/node/peer.go
101
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.
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue