AX Principle 1: predictable names over short names. cfg.json → config.json makes the path self-describing without a comment. Co-Authored-By: Charon <charon@lethean.io>
408 lines
11 KiB
Go
408 lines
11 KiB
Go
package node
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
)
|
|
|
|
// setupTestNodeManager creates a NodeManager with paths in a temp directory.
|
|
func setupTestNodeManager(t *testing.T) (*NodeManager, func()) {
|
|
tmpDir, err := os.MkdirTemp("", "node-identity-test")
|
|
if err != nil {
|
|
t.Fatalf("failed to create temp dir: %v", err)
|
|
}
|
|
|
|
keyPath := filepath.Join(tmpDir, "private.key")
|
|
configPath := filepath.Join(tmpDir, "node.json")
|
|
|
|
nodeManager, err := NewNodeManagerWithPaths(keyPath, configPath)
|
|
if err != nil {
|
|
os.RemoveAll(tmpDir)
|
|
t.Fatalf("failed to create node manager: %v", err)
|
|
}
|
|
|
|
cleanup := func() {
|
|
os.RemoveAll(tmpDir)
|
|
}
|
|
|
|
return nodeManager, cleanup
|
|
}
|
|
|
|
func TestIdentity_NodeManager_Good(t *testing.T) {
|
|
t.Run("NewNodeManager", func(t *testing.T) {
|
|
nodeManager, cleanup := setupTestNodeManager(t)
|
|
defer cleanup()
|
|
|
|
if nodeManager.HasIdentity() {
|
|
t.Error("new node manager should not have identity")
|
|
}
|
|
})
|
|
|
|
t.Run("GenerateIdentity", func(t *testing.T) {
|
|
nodeManager, cleanup := setupTestNodeManager(t)
|
|
defer cleanup()
|
|
|
|
err := nodeManager.GenerateIdentity("test-node", RoleDual)
|
|
if err != nil {
|
|
t.Fatalf("failed to generate identity: %v", err)
|
|
}
|
|
|
|
if !nodeManager.HasIdentity() {
|
|
t.Error("node manager should have identity after generation")
|
|
}
|
|
|
|
identity := nodeManager.GetIdentity()
|
|
if identity == nil {
|
|
t.Fatal("identity should not be nil")
|
|
}
|
|
|
|
if identity.Name != "test-node" {
|
|
t.Errorf("expected name 'test-node', got '%s'", identity.Name)
|
|
}
|
|
|
|
if identity.Role != RoleDual {
|
|
t.Errorf("expected role Dual, got '%s'", identity.Role)
|
|
}
|
|
|
|
if identity.ID == "" {
|
|
t.Error("identity ID should not be empty")
|
|
}
|
|
|
|
if identity.PublicKey == "" {
|
|
t.Error("public key should not be empty")
|
|
}
|
|
})
|
|
|
|
t.Run("LoadExistingIdentity", func(t *testing.T) {
|
|
tmpDir, err := os.MkdirTemp("", "node-load-test")
|
|
if err != nil {
|
|
t.Fatalf("failed to create temp dir: %v", err)
|
|
}
|
|
defer os.RemoveAll(tmpDir)
|
|
|
|
keyPath := filepath.Join(tmpDir, "private.key")
|
|
configPath := filepath.Join(tmpDir, "node.json")
|
|
|
|
// First, create an identity
|
|
nodeManager1, err := NewNodeManagerWithPaths(keyPath, configPath)
|
|
if err != nil {
|
|
t.Fatalf("failed to create first node manager: %v", err)
|
|
}
|
|
|
|
err = nodeManager1.GenerateIdentity("persistent-node", RoleWorker)
|
|
if err != nil {
|
|
t.Fatalf("failed to generate identity: %v", err)
|
|
}
|
|
|
|
originalID := nodeManager1.GetIdentity().ID
|
|
originalPubKey := nodeManager1.GetIdentity().PublicKey
|
|
|
|
// Create a new manager - should load existing identity
|
|
nodeManager2, err := NewNodeManagerWithPaths(keyPath, configPath)
|
|
if err != nil {
|
|
t.Fatalf("failed to create second node manager: %v", err)
|
|
}
|
|
|
|
if !nodeManager2.HasIdentity() {
|
|
t.Error("second node manager should have loaded existing identity")
|
|
}
|
|
|
|
identity := nodeManager2.GetIdentity()
|
|
if identity.ID != originalID {
|
|
t.Errorf("expected ID '%s', got '%s'", originalID, identity.ID)
|
|
}
|
|
|
|
if identity.PublicKey != originalPubKey {
|
|
t.Error("public key mismatch after reload")
|
|
}
|
|
})
|
|
|
|
t.Run("DeriveSharedSecret", func(t *testing.T) {
|
|
// Create two node managers with separate temp directories
|
|
tmpDir1, _ := os.MkdirTemp("", "node1")
|
|
tmpDir2, _ := os.MkdirTemp("", "node2")
|
|
defer os.RemoveAll(tmpDir1)
|
|
defer os.RemoveAll(tmpDir2)
|
|
|
|
// Node 1
|
|
nodeManager1, err := NewNodeManagerWithPaths(
|
|
filepath.Join(tmpDir1, "private.key"),
|
|
filepath.Join(tmpDir1, "node.json"),
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("failed to create node manager 1: %v", err)
|
|
}
|
|
err = nodeManager1.GenerateIdentity("node1", RoleDual)
|
|
if err != nil {
|
|
t.Fatalf("failed to generate identity 1: %v", err)
|
|
}
|
|
|
|
// Node 2
|
|
nodeManager2, err := NewNodeManagerWithPaths(
|
|
filepath.Join(tmpDir2, "private.key"),
|
|
filepath.Join(tmpDir2, "node.json"),
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("failed to create node manager 2: %v", err)
|
|
}
|
|
err = nodeManager2.GenerateIdentity("node2", RoleDual)
|
|
if err != nil {
|
|
t.Fatalf("failed to generate identity 2: %v", err)
|
|
}
|
|
|
|
// Derive shared secrets - should be identical
|
|
secret1, err := nodeManager1.DeriveSharedSecret(nodeManager2.GetIdentity().PublicKey)
|
|
if err != nil {
|
|
t.Fatalf("failed to derive shared secret from node 1: %v", err)
|
|
}
|
|
|
|
secret2, err := nodeManager2.DeriveSharedSecret(nodeManager1.GetIdentity().PublicKey)
|
|
if err != nil {
|
|
t.Fatalf("failed to derive shared secret from node 2: %v", err)
|
|
}
|
|
|
|
if len(secret1) != len(secret2) {
|
|
t.Errorf("shared secrets have different lengths: %d vs %d", len(secret1), len(secret2))
|
|
}
|
|
|
|
for i := range secret1 {
|
|
if secret1[i] != secret2[i] {
|
|
t.Error("shared secrets do not match")
|
|
break
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("DeleteIdentity", func(t *testing.T) {
|
|
nodeManager, cleanup := setupTestNodeManager(t)
|
|
defer cleanup()
|
|
|
|
err := nodeManager.GenerateIdentity("delete-me", RoleDual)
|
|
if err != nil {
|
|
t.Fatalf("failed to generate identity: %v", err)
|
|
}
|
|
|
|
if !nodeManager.HasIdentity() {
|
|
t.Error("should have identity before delete")
|
|
}
|
|
|
|
err = nodeManager.Delete()
|
|
if err != nil {
|
|
t.Fatalf("failed to delete identity: %v", err)
|
|
}
|
|
|
|
if nodeManager.HasIdentity() {
|
|
t.Error("should not have identity after delete")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestIdentity_NodeManager_Bad(t *testing.T) {
|
|
t.Run("GenerateIdentityOnNonWritablePath", func(t *testing.T) {
|
|
manager, err := NewNodeManagerWithPaths("/dev/null/private.key", "/dev/null/node.json")
|
|
if err != nil {
|
|
// Some systems reject the path at construction — both outcomes are acceptable
|
|
return
|
|
}
|
|
err = manager.GenerateIdentity("bad-node", RoleDual)
|
|
if err == nil {
|
|
t.Error("expected error when key path is non-writable")
|
|
}
|
|
})
|
|
|
|
t.Run("DeriveSharedSecretWithoutIdentity", func(t *testing.T) {
|
|
manager, err := NewNodeManagerWithPaths(t.TempDir()+"/key", t.TempDir()+"/config.json")
|
|
if err != nil {
|
|
t.Fatalf("failed to create node manager: %v", err)
|
|
}
|
|
_, err = manager.DeriveSharedSecret("dGVzdA==") // base64 "test"
|
|
if err == nil {
|
|
t.Error("expected error when identity not initialized")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestIdentity_NodeManager_Ugly(t *testing.T) {
|
|
t.Run("GenerateIdentityTwice", func(t *testing.T) {
|
|
manager, cleanup := setupTestNodeManager(t)
|
|
defer cleanup()
|
|
|
|
if err := manager.GenerateIdentity("first", RoleDual); err != nil {
|
|
t.Fatalf("first GenerateIdentity failed: %v", err)
|
|
}
|
|
firstID := manager.GetIdentity().ID
|
|
|
|
// Generating a second identity overwrites the first
|
|
if err := manager.GenerateIdentity("second", RoleWorker); err != nil {
|
|
t.Fatalf("second GenerateIdentity failed: %v", err)
|
|
}
|
|
secondID := manager.GetIdentity().ID
|
|
|
|
if firstID == secondID {
|
|
t.Error("expected a different ID after regenerating identity")
|
|
}
|
|
})
|
|
|
|
t.Run("DeleteNonExistentIdentity", func(t *testing.T) {
|
|
manager, cleanup := setupTestNodeManager(t)
|
|
defer cleanup()
|
|
|
|
// Delete without ever generating — must not panic
|
|
_ = manager.Delete()
|
|
})
|
|
}
|
|
|
|
func TestIdentity_NodeRoles_Good(t *testing.T) {
|
|
tests := []struct {
|
|
role NodeRole
|
|
expected string
|
|
}{
|
|
{RoleController, "controller"},
|
|
{RoleWorker, "worker"},
|
|
{RoleDual, "dual"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(string(tt.role), func(t *testing.T) {
|
|
if string(tt.role) != tt.expected {
|
|
t.Errorf("expected '%s', got '%s'", tt.expected, string(tt.role))
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestIdentity_ChallengeResponse_Good(t *testing.T) {
|
|
t.Run("GenerateChallenge", func(t *testing.T) {
|
|
challenge, err := GenerateChallenge()
|
|
if err != nil {
|
|
t.Fatalf("failed to generate challenge: %v", err)
|
|
}
|
|
|
|
if len(challenge) != ChallengeSize {
|
|
t.Errorf("expected challenge size %d, got %d", ChallengeSize, len(challenge))
|
|
}
|
|
|
|
// Ensure challenges are unique (not all zeros)
|
|
allZero := true
|
|
for _, b := range challenge {
|
|
if b != 0 {
|
|
allZero = false
|
|
break
|
|
}
|
|
}
|
|
if allZero {
|
|
t.Error("challenge should not be all zeros")
|
|
}
|
|
|
|
// Generate another and ensure they're different
|
|
challenge2, err := GenerateChallenge()
|
|
if err != nil {
|
|
t.Fatalf("failed to generate second challenge: %v", err)
|
|
}
|
|
|
|
same := true
|
|
for i := range challenge {
|
|
if challenge[i] != challenge2[i] {
|
|
same = false
|
|
break
|
|
}
|
|
}
|
|
if same {
|
|
t.Error("two generated challenges should be different")
|
|
}
|
|
})
|
|
|
|
t.Run("SignAndVerifyChallenge", func(t *testing.T) {
|
|
challenge, _ := GenerateChallenge()
|
|
sharedSecret := []byte("test-secret-key-32-bytes-long!!")
|
|
|
|
// Sign the challenge
|
|
signature := SignChallenge(challenge, sharedSecret)
|
|
|
|
if len(signature) == 0 {
|
|
t.Error("signature should not be empty")
|
|
}
|
|
|
|
// Verify should succeed with correct parameters
|
|
if !VerifyChallenge(challenge, signature, sharedSecret) {
|
|
t.Error("verification should succeed with correct parameters")
|
|
}
|
|
|
|
// Verify should fail with wrong challenge
|
|
wrongChallenge, _ := GenerateChallenge()
|
|
if VerifyChallenge(wrongChallenge, signature, sharedSecret) {
|
|
t.Error("verification should fail with wrong challenge")
|
|
}
|
|
|
|
// Verify should fail with wrong secret
|
|
wrongSecret := []byte("wrong-secret-key-32-bytes-long!")
|
|
if VerifyChallenge(challenge, signature, wrongSecret) {
|
|
t.Error("verification should fail with wrong secret")
|
|
}
|
|
|
|
// Verify should fail with tampered signature
|
|
tamperedSig := make([]byte, len(signature))
|
|
copy(tamperedSig, signature)
|
|
tamperedSig[0] ^= 0xFF // Flip bits
|
|
if VerifyChallenge(challenge, tamperedSig, sharedSecret) {
|
|
t.Error("verification should fail with tampered signature")
|
|
}
|
|
})
|
|
|
|
t.Run("SignatureIsDeterministic", func(t *testing.T) {
|
|
challenge := []byte("fixed-challenge-for-testing")
|
|
sharedSecret := []byte("fixed-secret-key-for-testing")
|
|
|
|
sig1 := SignChallenge(challenge, sharedSecret)
|
|
sig2 := SignChallenge(challenge, sharedSecret)
|
|
|
|
if len(sig1) != len(sig2) {
|
|
t.Fatal("signatures should have same length")
|
|
}
|
|
|
|
for i := range sig1 {
|
|
if sig1[i] != sig2[i] {
|
|
t.Fatal("signatures should be identical for same inputs")
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("IntegrationWithSharedSecret", func(t *testing.T) {
|
|
// Create two nodes and test end-to-end challenge-response
|
|
tmpDir1, _ := os.MkdirTemp("", "node-challenge-1")
|
|
tmpDir2, _ := os.MkdirTemp("", "node-challenge-2")
|
|
defer os.RemoveAll(tmpDir1)
|
|
defer os.RemoveAll(tmpDir2)
|
|
|
|
nodeManager1, _ := NewNodeManagerWithPaths(
|
|
filepath.Join(tmpDir1, "private.key"),
|
|
filepath.Join(tmpDir1, "node.json"),
|
|
)
|
|
nodeManager1.GenerateIdentity("challenger", RoleDual)
|
|
|
|
nodeManager2, _ := NewNodeManagerWithPaths(
|
|
filepath.Join(tmpDir2, "private.key"),
|
|
filepath.Join(tmpDir2, "node.json"),
|
|
)
|
|
nodeManager2.GenerateIdentity("responder", RoleDual)
|
|
|
|
// Challenger generates challenge
|
|
challenge, err := GenerateChallenge()
|
|
if err != nil {
|
|
t.Fatalf("failed to generate challenge: %v", err)
|
|
}
|
|
|
|
// Both derive the same shared secret
|
|
secret1, _ := nodeManager1.DeriveSharedSecret(nodeManager2.GetIdentity().PublicKey)
|
|
secret2, _ := nodeManager2.DeriveSharedSecret(nodeManager1.GetIdentity().PublicKey)
|
|
|
|
// Responder signs challenge with their derived secret
|
|
response := SignChallenge(challenge, secret2)
|
|
|
|
// Challenger verifies with their derived secret
|
|
if !VerifyChallenge(challenge, response, secret1) {
|
|
t.Error("challenge-response should verify with matching shared secrets")
|
|
}
|
|
})
|
|
}
|