state: add regression test for NodeKey-rotation binding

This commit is contained in:
Kristoffer Dalby
2026-06-15 06:18:54 +00:00
parent abfd5c9236
commit 368b9e7edd
@@ -0,0 +1,74 @@
package state
import (
"testing"
"time"
"github.com/juanfont/headscale/hscontrol/db"
"github.com/juanfont/headscale/hscontrol/types"
"github.com/stretchr/testify/require"
"tailscale.com/tailcfg"
)
// TestPreAuthKeyReauthRejectsVictimNodeKey ensures the PAK re-registration
// rotation path enforces the same 1:1 NodeKey<->MachineKey binding the other
// registration paths do. NodeKeys are public (peers learn them from the
// netmap), so an authenticated attacker must not be able to rotate their own
// node's NodeKey onto a victim's NodeKey: doing so poisons the NodeStore
// NodeKey index and denies the victim service.
func TestPreAuthKeyReauthRejectsVictimNodeKey(t *testing.T) {
dbPath := t.TempDir() + "/headscale.db"
cfg := persistTestConfig(dbPath)
database, err := db.NewHeadscaleDatabase(cfg)
require.NoError(t, err)
// Attacker owns N_a under U_a with machine key M_a.
attacker := database.CreateUserForTest("attacker")
attackerNode := database.CreateRegisteredNodeForTest(attacker, "attacker-node")
attackerMachineKey := attackerNode.MachineKey
// Victim owns N_v under U_v with a distinct, public NodeKey K_v.
victim := database.CreateUserForTest("victim")
victimNode := database.CreateRegisteredNodeForTest(victim, "victim-node")
victimNodeKey := victimNode.NodeKey
require.NotEqual(t, attackerNode.NodeKey, victimNodeKey,
"precondition: attacker and victim must have distinct NodeKeys")
require.NotEqual(t, attackerNode.MachineKey, victimNode.MachineKey,
"precondition: attacker and victim must have distinct MachineKeys")
require.NoError(t, database.Close())
s, err := NewState(cfg)
require.NoError(t, err)
t.Cleanup(func() { _ = s.Close() })
// Attacker mints their own valid pre-auth key for their own user.
attackerUserID := types.UserID(attacker.ID)
pak, err := s.CreatePreAuthKey(&attackerUserID, true, false, nil, nil)
require.NoError(t, err)
// Attacker re-registers over their own noise session (machine key M_a)
// with a valid PAK but carries the victim's NodeKey K_v and a non-past
// expiry (skipping the logout-first branch).
clientExpiry := time.Now().Add(180 * 24 * time.Hour)
regReq := tailcfg.RegisterRequest{
Auth: &tailcfg.RegisterResponseAuth{AuthKey: pak.Key},
NodeKey: victimNodeKey,
Expiry: clientExpiry,
Hostinfo: &tailcfg.Hostinfo{
Hostname: "attacker-node",
},
}
_, _, err = s.HandleNodeFromPreAuthKey(regReq, attackerMachineKey)
require.ErrorIs(t, err, ErrNodeKeyInUse,
"attacker must not be able to claim the victim's NodeKey via PAK re-registration")
// The victim's NodeKey index must still resolve to the victim's node.
resolved, ok := s.nodeStore.GetNodeByNodeKey(victimNodeKey)
require.True(t, ok, "victim NodeKey must still be present in the index")
require.Equal(t, victimNode.MachineKey, resolved.MachineKey(),
"victim NodeKey must remain bound to the victim's MachineKey")
}