go-p2p/docs/identity.md
Virgil 2d63a8ba18
Some checks failed
Security Scan / security (push) Successful in 9s
Test / test (push) Failing after 52s
refactor(node): add AX-native aliases for component and path APIs
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-30 19:32:26 +00:00

4.7 KiB

title description
Node Identity X25519 keypair generation, node ID derivation, and HMAC-SHA256 challenge-response authentication.

Node Identity

Every node in the mesh has a unique identity derived from an X25519 keypair. The node ID is cryptographically bound to the public key, and authentication uses HMAC-SHA256 challenge-response over a shared secret derived via ECDH.

NodeIdentity

The public identity struct carried in handshake messages and stored on disk:

type NodeIdentity struct {
    ID        string    `json:"id"`        // 32-char hex, derived from public key
    Name      string    `json:"name"`      // Human-friendly name
    PublicKey string    `json:"publicKey"` // X25519 base64
    CreatedAt time.Time `json:"createdAt"`
    Role      NodeRole  `json:"role"`      // controller, worker, or dual
}

The ID is computed as the first 16 bytes of SHA-256(publicKey), hex-encoded to produce a 32-character string.

Key Storage

Item Path Permissions
Private key ~/.local/share/lethean-desktop/node/private.key 0600
Identity config ~/.config/lethean-desktop/node.json 0644

Paths follow XDG base directories via github.com/adrg/xdg. The private key is never serialised to JSON or transmitted over the network.

NodeManager

NodeManager handles identity lifecycle -- generation, persistence, loading, and deletion. It also derives shared secrets for peer authentication.

Creating an Identity

nm, err := node.NewNodeManager()
if err != nil {
    log.Fatal(err)
}

// Generate a new identity (persists key and config to disk)
err = nm.GenerateIdentity("eu-controller-01", node.RoleController)

Internally this calls stmf.GenerateKeyPair() from the Borg library to produce the X25519 keypair.

Custom Paths (Testing)

nm, err := node.NewNodeManagerFromPaths(
    "/tmp/test/private.key",
    "/tmp/test/node.json",
)

Checking and Retrieving Identity

if nm.HasIdentity() {
    identity := nm.GetIdentity() // Returns a copy
    fmt.Println(identity.ID, identity.Name)
}

GetIdentity() returns a copy of the identity struct to prevent mutation of the internal state.

Deriving Shared Secrets

sharedSecret, err := nm.DeriveSharedSecret(peerPublicKeyBase64)

This performs X25519 ECDH with the peer's public key and hashes the result with SHA-256, producing a 32-byte symmetric key. The same shared secret is derived independently by both sides (no secret is transmitted).

Deleting an Identity

err := nm.Delete() // Removes key and config from disk, clears in-memory state

Challenge-Response Authentication

After the ECDH key exchange, nodes prove identity possession through HMAC-SHA256 challenge-response. The shared secret is never transmitted.

Functions

// Generate a 32-byte cryptographically random challenge
challenge, err := node.GenerateChallenge()

// Sign a challenge with the shared secret (HMAC-SHA256)
response := node.SignChallenge(challenge, sharedSecret)

// Verify a challenge response (constant-time comparison via hmac.Equal)
ok := node.VerifyChallenge(challenge, response, sharedSecret)

Authentication Flow

Node A (initiator)                    Node B (responder)
  |                                      |
  |--- handshake (identity + challenge) ->|
  |                                      |
  |   [B derives shared secret via ECDH]  |
  |   [B checks protocol version]        |
  |   [B checks allowlist]               |
  |   [B signs challenge with HMAC]       |
  |                                      |
  |<-- handshake_ack (identity + sig) ---|
  |                                      |
  |   [A derives shared secret via ECDH]  |
  |   [A verifies challenge response]     |
  |                                      |
  |   ---- encrypted channel open ----    |

The handshake and handshake_ack messages are sent unencrypted (they carry the public keys needed to derive the shared secret). All subsequent messages are SMSG-encrypted.

Node Roles

const (
    RoleController NodeRole = "controller"  // Manages the mesh, distributes tasks
    RoleWorker     NodeRole = "worker"      // Receives and executes workloads
    RoleDual       NodeRole = "dual"        // Both controller and worker
)
Role Behaviour
controller Sends get_stats, start_miner, stop_miner, get_logs, deploy messages
worker Handles incoming commands, runs compute tasks, reports stats
dual Participates as both controller and worker

Thread Safety

NodeManager is safe for concurrent use. A sync.RWMutex protects all internal state. GetIdentity() returns a copy rather than a pointer to the internal struct.