feat: Implement challenge-response authentication for P2P (P2P-CRIT-4)
- Add GenerateChallenge() for random 32-byte challenge generation - Add SignChallenge() using HMAC-SHA256 with shared secret - Add VerifyChallenge() with constant-time comparison - Update performHandshake() to send challenge and verify response - Update handleWSUpgrade() to sign incoming challenges - Add comprehensive tests for challenge-response flow The challenge-response authentication proves the peer has the matching private key for their public key by signing a random challenge with the ECDH-derived shared secret. This prevents impersonation attacks. 🤖 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
f65db3f5c4
commit
a5ed7ebee6
3 changed files with 206 additions and 13 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue