Mining/pkg/node/identity.go
snider 9a781ae3f0 feat: Add multi-node P2P mining management system
Implement secure peer-to-peer communication between Mining CLI instances
for remote control of mining rigs. Uses Borg library for encryption
(SMSG, STMF, TIM) and Poindexter for KD-tree based peer selection.

Features:
- Node identity management with X25519 keypairs
- Peer registry with multi-factor optimization (ping/hops/geo/score)
- WebSocket transport with SMSG encryption
- Controller/Worker architecture for remote operations
- TIM/STIM encrypted bundles for profile/miner deployment
- CLI commands: node, peer, remote
- REST API endpoints for node/peer/remote operations
- Docker support for P2P testing with multiple nodes

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-29 19:49:33 +00:00

292 lines
7.5 KiB
Go

// Package node provides P2P node identity and communication for multi-node mining management.
package node
import (
"crypto/ecdh"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"os"
"path/filepath"
"sync"
"time"
"github.com/Snider/Borg/pkg/stmf"
"github.com/adrg/xdg"
)
// NodeRole defines the operational mode of a node.
type NodeRole string
const (
// RoleController manages remote worker nodes.
RoleController NodeRole = "controller"
// RoleWorker receives commands and runs miners.
RoleWorker NodeRole = "worker"
// RoleDual operates as both controller and worker (default).
RoleDual NodeRole = "dual"
)
// NodeIdentity represents the public identity of a node.
type NodeIdentity struct {
ID string `json:"id"` // Derived from public key (first 16 bytes hex)
Name string `json:"name"` // Human-friendly name
PublicKey string `json:"publicKey"` // X25519 base64
CreatedAt time.Time `json:"createdAt"`
Role NodeRole `json:"role"`
}
// NodeManager handles node identity operations including key generation and storage.
type NodeManager struct {
identity *NodeIdentity
privateKey []byte // Never serialized to JSON
keyPair *stmf.KeyPair
keyPath string // ~/.local/share/lethean-desktop/node/private.key
configPath string // ~/.config/lethean-desktop/node.json
mu sync.RWMutex
}
// NewNodeManager creates a new NodeManager, loading existing identity if available.
func NewNodeManager() (*NodeManager, error) {
keyPath, err := xdg.DataFile("lethean-desktop/node/private.key")
if err != nil {
return nil, fmt.Errorf("failed to get key path: %w", err)
}
configPath, err := xdg.ConfigFile("lethean-desktop/node.json")
if err != nil {
return nil, fmt.Errorf("failed to get config path: %w", err)
}
nm := &NodeManager{
keyPath: keyPath,
configPath: configPath,
}
// Try to load existing identity
if err := nm.loadIdentity(); err != nil {
// Identity doesn't exist yet, that's ok
return nm, nil
}
return nm, nil
}
// HasIdentity returns true if a node identity has been initialized.
func (n *NodeManager) HasIdentity() bool {
n.mu.RLock()
defer n.mu.RUnlock()
return n.identity != nil
}
// GetIdentity returns the node's public identity.
func (n *NodeManager) GetIdentity() *NodeIdentity {
n.mu.RLock()
defer n.mu.RUnlock()
if n.identity == nil {
return nil
}
// Return a copy to prevent mutation
identity := *n.identity
return &identity
}
// GenerateIdentity creates a new node identity with the given name and role.
func (n *NodeManager) GenerateIdentity(name string, role NodeRole) error {
n.mu.Lock()
defer n.mu.Unlock()
// Generate X25519 keypair using STMF
keyPair, err := stmf.GenerateKeyPair()
if err != nil {
return fmt.Errorf("failed to generate keypair: %w", err)
}
// Derive node ID from public key (first 16 bytes as hex = 32 char ID)
pubKeyBytes := keyPair.PublicKey()
hash := sha256.Sum256(pubKeyBytes)
nodeID := hex.EncodeToString(hash[:16])
n.identity = &NodeIdentity{
ID: nodeID,
Name: name,
PublicKey: keyPair.PublicKeyBase64(),
CreatedAt: time.Now(),
Role: role,
}
n.keyPair = keyPair
n.privateKey = keyPair.PrivateKey()
// Save private key
if err := n.savePrivateKey(); err != nil {
return fmt.Errorf("failed to save private key: %w", err)
}
// Save identity config
if err := n.saveIdentity(); err != nil {
return fmt.Errorf("failed to save identity: %w", err)
}
return nil
}
// DeriveSharedSecret derives a shared secret with a peer using X25519 ECDH.
// The result is hashed with SHA-256 for use as a symmetric key.
func (n *NodeManager) DeriveSharedSecret(peerPubKeyBase64 string) ([]byte, error) {
n.mu.RLock()
defer n.mu.RUnlock()
if n.privateKey == nil {
return nil, fmt.Errorf("node identity not initialized")
}
// Load peer's public key
peerPubKey, err := stmf.LoadPublicKeyBase64(peerPubKeyBase64)
if err != nil {
return nil, fmt.Errorf("failed to load peer public key: %w", err)
}
// Load our private key
privateKey, err := ecdh.X25519().NewPrivateKey(n.privateKey)
if err != nil {
return nil, fmt.Errorf("failed to load private key: %w", err)
}
// Derive shared secret using ECDH
sharedSecret, err := privateKey.ECDH(peerPubKey)
if err != nil {
return nil, fmt.Errorf("failed to derive shared secret: %w", err)
}
// Hash the shared secret using SHA-256 (same pattern as Borg/trix)
hash := sha256.Sum256(sharedSecret)
return hash[:], nil
}
// GetPublicKey returns the node's public key in base64 format.
func (n *NodeManager) GetPublicKey() string {
n.mu.RLock()
defer n.mu.RUnlock()
if n.identity == nil {
return ""
}
return n.identity.PublicKey
}
// savePrivateKey saves the private key to disk with restricted permissions.
func (n *NodeManager) savePrivateKey() error {
// Ensure directory exists
dir := filepath.Dir(n.keyPath)
if err := os.MkdirAll(dir, 0700); err != nil {
return fmt.Errorf("failed to create key directory: %w", err)
}
// Write private key with restricted permissions (0600)
if err := os.WriteFile(n.keyPath, n.privateKey, 0600); err != nil {
return fmt.Errorf("failed to write private key: %w", err)
}
return nil
}
// saveIdentity saves the public identity to the config file.
func (n *NodeManager) saveIdentity() error {
// Ensure directory exists
dir := filepath.Dir(n.configPath)
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("failed to create config directory: %w", err)
}
data, err := json.MarshalIndent(n.identity, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal identity: %w", err)
}
if err := os.WriteFile(n.configPath, data, 0644); err != nil {
return fmt.Errorf("failed to write identity: %w", err)
}
return nil
}
// loadIdentity loads the node identity from disk.
func (n *NodeManager) loadIdentity() error {
// Load identity config
data, err := os.ReadFile(n.configPath)
if err != nil {
return fmt.Errorf("failed to read identity: %w", err)
}
var identity NodeIdentity
if err := json.Unmarshal(data, &identity); err != nil {
return fmt.Errorf("failed to unmarshal identity: %w", err)
}
// Load private key
privateKey, err := os.ReadFile(n.keyPath)
if err != nil {
return fmt.Errorf("failed to read private key: %w", err)
}
// Reconstruct keypair from private key
keyPair, err := stmf.LoadKeyPair(privateKey)
if err != nil {
return fmt.Errorf("failed to load keypair: %w", err)
}
n.identity = &identity
n.privateKey = privateKey
n.keyPair = keyPair
return nil
}
// UpdateName updates the node's display name.
func (n *NodeManager) UpdateName(name string) error {
n.mu.Lock()
defer n.mu.Unlock()
if n.identity == nil {
return fmt.Errorf("node identity not initialized")
}
n.identity.Name = name
return n.saveIdentity()
}
// UpdateRole updates the node's operational role.
func (n *NodeManager) UpdateRole(role NodeRole) error {
n.mu.Lock()
defer n.mu.Unlock()
if n.identity == nil {
return fmt.Errorf("node identity not initialized")
}
n.identity.Role = role
return n.saveIdentity()
}
// Delete removes the node identity and keys from disk.
func (n *NodeManager) Delete() error {
n.mu.Lock()
defer n.mu.Unlock()
// Remove private key
if err := os.Remove(n.keyPath); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("failed to remove private key: %w", err)
}
// Remove identity config
if err := os.Remove(n.configPath); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("failed to remove identity: %w", err)
}
n.identity = nil
n.privateKey = nil
n.keyPair = nil
return nil
}