mirror of
https://github.com/juanfont/headscale.git
synced 2026-07-08 00:50:20 +09:00
state: add regression test for NodeKey-rotation binding
This commit is contained in:
@@ -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")
|
||||
}
|
||||
Reference in New Issue
Block a user