diff --git a/pkg/node/identity.go b/pkg/node/identity.go index 22a98c4..31aac1c 100644 --- a/pkg/node/identity.go +++ b/pkg/node/identity.go @@ -3,6 +3,8 @@ package node import ( "crypto/ecdh" + "crypto/hmac" + "crypto/rand" "crypto/sha256" "encoding/hex" "encoding/json" @@ -16,6 +18,32 @@ import ( "github.com/adrg/xdg" ) +// ChallengeSize is the size of the challenge in bytes +const ChallengeSize = 32 + +// GenerateChallenge creates a random challenge for authentication. +func GenerateChallenge() ([]byte, error) { + challenge := make([]byte, ChallengeSize) + if _, err := rand.Read(challenge); err != nil { + return nil, fmt.Errorf("failed to generate challenge: %w", err) + } + return challenge, nil +} + +// SignChallenge creates an HMAC signature of a challenge using a shared secret. +// The signature proves possession of the shared secret without revealing it. +func SignChallenge(challenge []byte, sharedSecret []byte) []byte { + mac := hmac.New(sha256.New, sharedSecret) + mac.Write(challenge) + return mac.Sum(nil) +} + +// VerifyChallenge verifies that a challenge response was signed with the correct shared secret. +func VerifyChallenge(challenge, response, sharedSecret []byte) bool { + expected := SignChallenge(challenge, sharedSecret) + return hmac.Equal(response, expected) +} + // NodeRole defines the operational mode of a node. type NodeRole string diff --git a/pkg/node/identity_test.go b/pkg/node/identity_test.go index f86dec8..fb0dce9 100644 --- a/pkg/node/identity_test.go +++ b/pkg/node/identity_test.go @@ -216,3 +216,138 @@ func TestNodeRoles(t *testing.T) { }) } } + +func TestChallengeResponse(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) + + nm1, _ := NewNodeManagerWithPaths( + filepath.Join(tmpDir1, "private.key"), + filepath.Join(tmpDir1, "node.json"), + ) + nm1.GenerateIdentity("challenger", RoleDual) + + nm2, _ := NewNodeManagerWithPaths( + filepath.Join(tmpDir2, "private.key"), + filepath.Join(tmpDir2, "node.json"), + ) + nm2.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, _ := nm1.DeriveSharedSecret(nm2.GetIdentity().PublicKey) + secret2, _ := nm2.DeriveSharedSecret(nm1.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") + } + }) +} diff --git a/pkg/node/transport.go b/pkg/node/transport.go index 190b16e..c14f86d 100644 --- a/pkg/node/transport.go +++ b/pkg/node/transport.go @@ -196,20 +196,13 @@ func (t *Transport) Connect(peer *Peer) (*PeerConnection, error) { transport: t, } - // Perform handshake first to exchange public keys + // Perform handshake with challenge-response authentication + // This also derives and stores the shared secret in pc.SharedSecret if err := t.performHandshake(pc); err != nil { conn.Close() return nil, fmt.Errorf("handshake failed: %w", err) } - // Now derive shared secret using the received public key - sharedSecret, err := t.node.DeriveSharedSecret(pc.Peer.PublicKey) - if err != nil { - conn.Close() - return nil, fmt.Errorf("failed to derive shared secret: %w", err) - } - pc.SharedSecret = sharedSecret - // Store connection using the real peer ID from handshake t.mu.Lock() t.conns[pc.Peer.ID] = pc @@ -394,9 +387,17 @@ func (t *Transport) handleWSUpgrade(w http.ResponseWriter, r *http.Request) { conn.Close() return } + + // Sign the client's challenge to prove we have the matching private key + var challengeResponse []byte + if len(payload.Challenge) > 0 { + challengeResponse = SignChallenge(payload.Challenge, sharedSecret) + } + ackPayload := HandshakeAckPayload{ - Identity: *identity, - Accepted: true, + Identity: *identity, + ChallengeResponse: challengeResponse, + Accepted: true, } ackMsg, err := NewMessage(MsgHandshakeAck, identity.ID, peer.ID, ackPayload) @@ -451,9 +452,16 @@ func (t *Transport) performHandshake(pc *PeerConnection) error { return fmt.Errorf("node identity not initialized") } + // Generate challenge for the server to prove it has the matching private key + challenge, err := GenerateChallenge() + if err != nil { + return fmt.Errorf("generate challenge: %w", err) + } + payload := HandshakePayload{ - Identity: *identity, - Version: "1.0", + Identity: *identity, + Challenge: challenge, + Version: "1.0", } msg, err := NewMessage(MsgHandshake, identity.ID, pc.Peer.ID, payload) @@ -501,12 +509,34 @@ func (t *Transport) performHandshake(pc *PeerConnection) error { pc.Peer.Name = ackPayload.Identity.Name pc.Peer.Role = ackPayload.Identity.Role + // Verify challenge response - derive shared secret first using the peer's public key + sharedSecret, err := t.node.DeriveSharedSecret(pc.Peer.PublicKey) + if err != nil { + return fmt.Errorf("derive shared secret for challenge verification: %w", err) + } + + // Verify the server's response to our challenge + if len(ackPayload.ChallengeResponse) == 0 { + return fmt.Errorf("server did not provide challenge response") + } + if !VerifyChallenge(challenge, ackPayload.ChallengeResponse, sharedSecret) { + return fmt.Errorf("challenge response verification failed: server may not have matching private key") + } + + // Store the shared secret for later use + pc.SharedSecret = sharedSecret + // Update the peer in registry with the real identity if err := t.registry.UpdatePeer(pc.Peer); err != nil { // If update fails (peer not found with old ID), add as new t.registry.AddPeer(pc.Peer) } + logging.Debug("handshake completed with challenge-response verification", logging.Fields{ + "peer_id": pc.Peer.ID, + "peer_name": pc.Peer.Name, + }) + return nil }