diff --git a/CHANGELOG.md b/CHANGELOG.md index 02986867..cb849b44 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ ### Changes +- Pre-authentication keys now use bcrypt hashing for improved security. Keys are + stored as a prefix and bcrypt hash instead of plaintext. The full key is only + displayed once at creation time. When listing keys, only the prefix is shown + (e.g., `hskey-auth-{prefix}-***`). All new keys use the format + `hskey-auth-{prefix}-{secret}`. Legacy plaintext keys continue to work for + backwards compatibility. + [#2853](https://github.com/juanfont/headscale/pull/2853) - Expire nodes with a custom timestamp [#2828](https://github.com/juanfont/headscale/pull/2828) diff --git a/cmd/headscale/cli/preauthkeys.go b/cmd/headscale/cli/preauthkeys.go index c0c08831..e42fa1e3 100644 --- a/cmd/headscale/cli/preauthkeys.go +++ b/cmd/headscale/cli/preauthkeys.go @@ -88,7 +88,7 @@ var listPreAuthKeys = &cobra.Command{ tableData := pterm.TableData{ { "ID", - "Key", + "Key/Prefix", "Reusable", "Ephemeral", "Used", diff --git a/hscontrol/db/db.go b/hscontrol/db/db.go index 04c6cc0a..5633221f 100644 --- a/hscontrol/db/db.go +++ b/hscontrol/db/db.go @@ -956,6 +956,28 @@ AND auth_key_id NOT IN ( // - NEVER use gorm.AutoMigrate, write the exact migration steps needed // - AutoMigrate depends on the struct staying exactly the same, which it won't over time. // - Never write migrations that requires foreign keys to be disabled. + { + // Add columns for prefix and hash for pre auth keys, implementing + // them with the same security model as api keys. + ID: "202511011637-preauthkey-bcrypt", + Migrate: func(tx *gorm.DB) error { + if err := tx.Migrator().AddColumn(&types.PreAuthKey{}, "prefix"); err != nil { + return fmt.Errorf("adding prefix column: %w", err) + } + if err := tx.Migrator().AddColumn(&types.PreAuthKey{}, "hash"); err != nil { + return fmt.Errorf("adding hash column: %w", err) + } + + // Create partial unique index to allow multiple legacy keys (NULL/empty prefix) + // while enforcing uniqueness for new bcrypt-based keys + if err := tx.Exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_pre_auth_keys_prefix ON pre_auth_keys(prefix) WHERE prefix IS NOT NULL AND prefix != ''").Error; err != nil { + return fmt.Errorf("creating prefix index: %w", err) + } + + return nil + }, + Rollback: func(db *gorm.DB) error { return nil }, + }, }, ) diff --git a/hscontrol/db/ip_test.go b/hscontrol/db/ip_test.go index f558cdf7..3ec81c9f 100644 --- a/hscontrol/db/ip_test.go +++ b/hscontrol/db/ip_test.go @@ -6,7 +6,6 @@ import ( "strings" "testing" - "github.com/davecgh/go-spew/spew" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "github.com/juanfont/headscale/hscontrol/types" @@ -159,8 +158,6 @@ func TestIPAllocatorSequential(t *testing.T) { types.IPAllocationStrategySequential, ) - spew.Dump(alloc) - var got4s []netip.Addr var got6s []netip.Addr @@ -263,8 +260,6 @@ func TestIPAllocatorRandom(t *testing.T) { alloc, _ := NewIPAllocator(db, tt.prefix4, tt.prefix6, types.IPAllocationStrategyRandom) - spew.Dump(alloc) - for range tt.getCount { got4, got6, err := alloc.Next() if err != nil { diff --git a/hscontrol/db/preauth_keys.go b/hscontrol/db/preauth_keys.go index a36c1f13..c80a764c 100644 --- a/hscontrol/db/preauth_keys.go +++ b/hscontrol/db/preauth_keys.go @@ -1,8 +1,6 @@ package db import ( - "crypto/rand" - "encoding/hex" "errors" "fmt" "slices" @@ -10,6 +8,8 @@ import ( "time" "github.com/juanfont/headscale/hscontrol/types" + "github.com/juanfont/headscale/hscontrol/util" + "golang.org/x/crypto/bcrypt" "gorm.io/gorm" "tailscale.com/util/set" ) @@ -28,12 +28,18 @@ func (hsdb *HSDatabase) CreatePreAuthKey( ephemeral bool, expiration *time.Time, aclTags []string, -) (*types.PreAuthKey, error) { - return Write(hsdb.DB, func(tx *gorm.DB) (*types.PreAuthKey, error) { +) (*types.PreAuthKeyNew, error) { + return Write(hsdb.DB, func(tx *gorm.DB) (*types.PreAuthKeyNew, error) { return CreatePreAuthKey(tx, uid, reusable, ephemeral, expiration, aclTags) }) } +const ( + authKeyPrefix = "hskey-auth-" + authKeyPrefixLength = 12 + authKeyLength = 64 +) + // CreatePreAuthKey creates a new PreAuthKey in a user, and returns it. func CreatePreAuthKey( tx *gorm.DB, @@ -42,7 +48,7 @@ func CreatePreAuthKey( ephemeral bool, expiration *time.Time, aclTags []string, -) (*types.PreAuthKey, error) { +) (*types.PreAuthKeyNew, error) { user, err := GetUserByID(tx, uid) if err != nil { return nil, err @@ -65,14 +71,41 @@ func CreatePreAuthKey( } now := time.Now().UTC() - // TODO(kradalby): unify the key generations spread all over the code. - kstr, err := generateKey() + + prefix, err := util.GenerateRandomStringURLSafe(authKeyPrefixLength) + if err != nil { + return nil, err + } + + // Validate generated prefix (should always be valid, but be defensive) + if len(prefix) != authKeyPrefixLength { + return nil, fmt.Errorf("%w: generated prefix has invalid length: expected %d, got %d", ErrPreAuthKeyFailedToParse, authKeyPrefixLength, len(prefix)) + } + if !isValidBase64URLSafe(prefix) { + return nil, fmt.Errorf("%w: generated prefix contains invalid characters", ErrPreAuthKeyFailedToParse) + } + + toBeHashed, err := util.GenerateRandomStringURLSafe(authKeyLength) + if err != nil { + return nil, err + } + + // Validate generated hash (should always be valid, but be defensive) + if len(toBeHashed) != authKeyLength { + return nil, fmt.Errorf("%w: generated hash has invalid length: expected %d, got %d", ErrPreAuthKeyFailedToParse, authKeyLength, len(toBeHashed)) + } + if !isValidBase64URLSafe(toBeHashed) { + return nil, fmt.Errorf("%w: generated hash contains invalid characters", ErrPreAuthKeyFailedToParse) + } + + keyStr := authKeyPrefix + prefix + "-" + toBeHashed + + hash, err := bcrypt.GenerateFromPassword([]byte(toBeHashed), bcrypt.DefaultCost) if err != nil { return nil, err } key := types.PreAuthKey{ - Key: kstr, UserID: user.ID, User: *user, Reusable: reusable, @@ -80,13 +113,18 @@ func CreatePreAuthKey( CreatedAt: &now, Expiration: expiration, Tags: aclTags, + Prefix: prefix, // Store prefix + Hash: hash, // Store hash } if err := tx.Save(&key).Error; err != nil { return nil, fmt.Errorf("failed to create key in the database: %w", err) } - return &key, nil + return &types.PreAuthKeyNew{ + ID: key.ID, + Key: keyStr, + }, nil } func (hsdb *HSDatabase) ListPreAuthKeys(uid types.UserID) ([]types.PreAuthKey, error) { @@ -110,6 +148,104 @@ func ListPreAuthKeysByUser(tx *gorm.DB, uid types.UserID) ([]types.PreAuthKey, e return keys, nil } +var ErrPreAuthKeyFailedToParse = errors.New("failed to parse AuthKey") + +func findAuthKey(tx *gorm.DB, keyStr string) (*types.PreAuthKey, error) { + var pak types.PreAuthKey + + // Validate input is not empty + if keyStr == "" { + return nil, ErrPreAuthKeyFailedToParse + } + + _, prefixAndHash, found := strings.Cut(keyStr, authKeyPrefix) + + if !found { + // Legacy format (plaintext) - backwards compatibility + if err := tx.Preload("User").First(&pak, "key = ?", keyStr).Error; err != nil { + return nil, ErrPreAuthKeyNotFound + } + return &pak, nil + } + + // New format: hskey-auth-{12-char-prefix}-{64-char-hash} + // Expected minimum length: 12 (prefix) + 1 (separator) + 64 (hash) = 77 + const expectedMinLength = authKeyPrefixLength + 1 + authKeyLength + if len(prefixAndHash) < expectedMinLength { + return nil, fmt.Errorf( + "%w: key too short, expected at least %d chars after prefix, got %d", + ErrPreAuthKeyFailedToParse, + expectedMinLength, + len(prefixAndHash), + ) + } + + // Use fixed-length parsing instead of separator-based to handle dashes in base64 URL-safe + prefix := prefixAndHash[:authKeyPrefixLength] + + // Validate separator at expected position + if prefixAndHash[authKeyPrefixLength] != '-' { + return nil, fmt.Errorf( + "%w: expected separator '-' at position %d, got '%c'", + ErrPreAuthKeyFailedToParse, + authKeyPrefixLength, + prefixAndHash[authKeyPrefixLength], + ) + } + + hash := prefixAndHash[authKeyPrefixLength+1:] + + // Validate hash length + if len(hash) != authKeyLength { + return nil, fmt.Errorf( + "%w: hash length mismatch, expected %d chars, got %d", + ErrPreAuthKeyFailedToParse, + authKeyLength, + len(hash), + ) + } + + // Validate prefix contains only base64 URL-safe characters + if !isValidBase64URLSafe(prefix) { + return nil, fmt.Errorf( + "%w: prefix contains invalid characters (expected base64 URL-safe: A-Za-z0-9_-)", + ErrPreAuthKeyFailedToParse, + ) + } + + // Validate hash contains only base64 URL-safe characters + if !isValidBase64URLSafe(hash) { + return nil, fmt.Errorf( + "%w: hash contains invalid characters (expected base64 URL-safe: A-Za-z0-9_-)", + ErrPreAuthKeyFailedToParse, + ) + } + + // Look up key by prefix + if err := tx.Preload("User").First(&pak, "prefix = ?", prefix).Error; err != nil { + return nil, ErrPreAuthKeyNotFound + } + + // Verify hash matches + if err := bcrypt.CompareHashAndPassword(pak.Hash, []byte(hash)); err != nil { + return nil, fmt.Errorf("invalid auth key: %w", err) + } + + return &pak, nil +} + +// isValidBase64URLSafe checks if a string contains only base64 URL-safe characters. +func isValidBase64URLSafe(s string) bool { + for _, c := range s { + if !((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || + (c >= '0' && c <= '9') || c == '-' || c == '_') { + return false + } + } + + return true +} + func (hsdb *HSDatabase) GetPreAuthKey(key string) (*types.PreAuthKey, error) { return GetPreAuthKey(hsdb.DB, key) } @@ -117,12 +253,7 @@ func (hsdb *HSDatabase) GetPreAuthKey(key string) (*types.PreAuthKey, error) { // GetPreAuthKey returns a PreAuthKey for a given key. The caller is responsible // for checking if the key is usable (expired or used). func GetPreAuthKey(tx *gorm.DB, key string) (*types.PreAuthKey, error) { - pak := types.PreAuthKey{} - if err := tx.Preload("User").First(&pak, "key = ?", key).Error; err != nil { - return nil, ErrPreAuthKeyNotFound - } - - return &pak, nil + return findAuthKey(tx, key) } // DestroyPreAuthKey destroys a preauthkey. Returns error if the PreAuthKey @@ -158,13 +289,3 @@ func ExpirePreAuthKey(tx *gorm.DB, k *types.PreAuthKey) error { now := time.Now() return tx.Model(&types.PreAuthKey{}).Where("id = ?", k.ID).Update("expiration", now).Error } - -func generateKey() (string, error) { - size := 24 - bytes := make([]byte, size) - if _, err := rand.Read(bytes); err != nil { - return "", err - } - - return hex.EncodeToString(bytes), nil -} diff --git a/hscontrol/db/preauth_keys_test.go b/hscontrol/db/preauth_keys_test.go index 605e7442..4d029496 100644 --- a/hscontrol/db/preauth_keys_test.go +++ b/hscontrol/db/preauth_keys_test.go @@ -1,74 +1,125 @@ package db import ( + "fmt" "slices" + "strings" "testing" + "time" "github.com/juanfont/headscale/hscontrol/types" "github.com/juanfont/headscale/hscontrol/util" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "gopkg.in/check.v1" "tailscale.com/types/ptr" ) -func (*Suite) TestCreatePreAuthKey(c *check.C) { - // ID does not exist - _, err := db.CreatePreAuthKey(12345, true, false, nil, nil) - c.Assert(err, check.NotNil) +func TestCreatePreAuthKey(t *testing.T) { + tests := []struct { + name string + test func(*testing.T, *HSDatabase) + }{ + { + name: "error_invalid_user_id", + test: func(t *testing.T, db *HSDatabase) { + _, err := db.CreatePreAuthKey(12345, true, false, nil, nil) + assert.Error(t, err) + }, + }, + { + name: "success_create_and_list", + test: func(t *testing.T, db *HSDatabase) { + user, err := db.CreateUser(types.User{Name: "test"}) + require.NoError(t, err) - user, err := db.CreateUser(types.User{Name: "test"}) - c.Assert(err, check.IsNil) + key, err := db.CreatePreAuthKey(types.UserID(user.ID), true, false, nil, nil) + require.NoError(t, err) + assert.NotEmpty(t, key.Key) - key, err := db.CreatePreAuthKey(types.UserID(user.ID), true, false, nil, nil) - c.Assert(err, check.IsNil) + // List keys for the user + keys, err := db.ListPreAuthKeys(types.UserID(user.ID)) + require.NoError(t, err) + assert.Len(t, keys, 1) - // Did we get a valid key? - c.Assert(key.Key, check.NotNil) - c.Assert(len(key.Key), check.Equals, 48) + // Verify User association is populated + assert.Equal(t, user.ID, keys[0].User.ID) + }, + }, + { + name: "error_list_invalid_user_id", + test: func(t *testing.T, db *HSDatabase) { + _, err := db.ListPreAuthKeys(1000000) + assert.Error(t, err) + }, + }, + } - // Make sure the User association is populated - c.Assert(key.User.ID, check.Equals, user.ID) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + db, err := newSQLiteTestDB() + require.NoError(t, err) - // ID does not exist - _, err = db.ListPreAuthKeys(1000000) - c.Assert(err, check.NotNil) - - keys, err := db.ListPreAuthKeys(types.UserID(user.ID)) - c.Assert(err, check.IsNil) - c.Assert(len(keys), check.Equals, 1) - - // Make sure the User association is populated - c.Assert((keys)[0].User.ID, check.Equals, user.ID) + tt.test(t, db) + }) + } } -func (*Suite) TestPreAuthKeyACLTags(c *check.C) { - user, err := db.CreateUser(types.User{Name: "test8"}) - c.Assert(err, check.IsNil) +func TestPreAuthKeyACLTags(t *testing.T) { + tests := []struct { + name string + test func(*testing.T, *HSDatabase) + }{ + { + name: "reject_malformed_tags", + test: func(t *testing.T, db *HSDatabase) { + user, err := db.CreateUser(types.User{Name: "test-tags-1"}) + require.NoError(t, err) - _, err = db.CreatePreAuthKey(types.UserID(user.ID), false, false, nil, []string{"badtag"}) - c.Assert(err, check.NotNil) // Confirm that malformed tags are rejected + _, err = db.CreatePreAuthKey(types.UserID(user.ID), false, false, nil, []string{"badtag"}) + assert.Error(t, err) + }, + }, + { + name: "deduplicate_and_sort_tags", + test: func(t *testing.T, db *HSDatabase) { + user, err := db.CreateUser(types.User{Name: "test-tags-2"}) + require.NoError(t, err) - tags := []string{"tag:test1", "tag:test2"} - tagsWithDuplicate := []string{"tag:test1", "tag:test2", "tag:test2"} - _, err = db.CreatePreAuthKey(types.UserID(user.ID), false, false, nil, tagsWithDuplicate) - c.Assert(err, check.IsNil) + expectedTags := []string{"tag:test1", "tag:test2"} + tagsWithDuplicate := []string{"tag:test1", "tag:test2", "tag:test2"} - listedPaks, err := db.ListPreAuthKeys(types.UserID(user.ID)) - c.Assert(err, check.IsNil) - gotTags := listedPaks[0].Proto().GetAclTags() - slices.Sort(gotTags) - c.Assert(gotTags, check.DeepEquals, tags) + _, err = db.CreatePreAuthKey(types.UserID(user.ID), false, false, nil, tagsWithDuplicate) + require.NoError(t, err) + + listedPaks, err := db.ListPreAuthKeys(types.UserID(user.ID)) + require.NoError(t, err) + require.Len(t, listedPaks, 1) + + gotTags := listedPaks[0].Proto().GetAclTags() + slices.Sort(gotTags) + assert.Equal(t, expectedTags, gotTags) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + db, err := newSQLiteTestDB() + require.NoError(t, err) + + tt.test(t, db) + }) + } } func TestCannotDeleteAssignedPreAuthKey(t *testing.T) { db, err := newSQLiteTestDB() require.NoError(t, err) user, err := db.CreateUser(types.User{Name: "test8"}) - assert.NoError(t, err) + require.NoError(t, err) key, err := db.CreatePreAuthKey(types.UserID(user.ID), false, false, nil, []string{"tag:good"}) - assert.NoError(t, err) + require.NoError(t, err) node := types.Node{ ID: 0, @@ -79,6 +130,309 @@ func TestCannotDeleteAssignedPreAuthKey(t *testing.T) { } db.DB.Save(&node) - err = db.DB.Delete(key).Error + err = db.DB.Delete(&types.PreAuthKey{ID: key.ID}).Error require.ErrorContains(t, err, "constraint failed: FOREIGN KEY constraint failed") } + +func TestPreAuthKeyAuthentication(t *testing.T) { + db, err := newSQLiteTestDB() + require.NoError(t, err) + user := db.CreateUserForTest("test-user") + + tests := []struct { + name string + setupKey func() string // Returns key string to test + wantFindErr bool // Error when finding the key + wantValidateErr bool // Error when validating the key + validateResult func(*testing.T, *types.PreAuthKey) + }{ + { + name: "legacy_key_plaintext", + setupKey: func() string { + // Insert legacy key directly using GORM (simulate existing production key) + // Note: We use raw SQL to bypass GORM's handling and set prefix to empty string + // which simulates how legacy keys exist in production databases + legacyKey := "abc123def456ghi789jkl012mno345pqr678stu901vwx234yz" + now := time.Now() + + // Use raw SQL to insert with empty prefix to avoid UNIQUE constraint + err := db.DB.Exec(` + INSERT INTO pre_auth_keys (key, user_id, reusable, ephemeral, used, created_at) + VALUES (?, ?, ?, ?, ?, ?) + `, legacyKey, user.ID, true, false, false, now).Error + require.NoError(t, err) + + return legacyKey + }, + wantFindErr: false, + wantValidateErr: false, + validateResult: func(t *testing.T, pak *types.PreAuthKey) { + assert.Equal(t, user.ID, pak.UserID) + assert.NotEmpty(t, pak.Key) // Legacy keys have Key populated + assert.Empty(t, pak.Prefix) // Legacy keys have empty Prefix + assert.Nil(t, pak.Hash) // Legacy keys have nil Hash + }, + }, + { + name: "new_key_bcrypt", + setupKey: func() string { + // Create new key via API + keyStr, err := db.CreatePreAuthKey( + types.UserID(user.ID), + true, false, nil, []string{"tag:test"}, + ) + require.NoError(t, err) + + return keyStr.Key + }, + wantFindErr: false, + wantValidateErr: false, + validateResult: func(t *testing.T, pak *types.PreAuthKey) { + assert.Equal(t, user.ID, pak.UserID) + assert.Empty(t, pak.Key) // New keys have empty Key + assert.NotEmpty(t, pak.Prefix) // New keys have Prefix + assert.NotNil(t, pak.Hash) // New keys have Hash + assert.Len(t, pak.Prefix, 12) // Prefix is 12 chars + }, + }, + { + name: "new_key_format_validation", + setupKey: func() string { + keyStr, err := db.CreatePreAuthKey( + types.UserID(user.ID), + true, false, nil, nil, + ) + require.NoError(t, err) + + // Verify format: hskey-auth-{12-char-prefix}-{64-char-hash} + // Use fixed-length parsing since prefix/hash can contain dashes (base64 URL-safe) + assert.True(t, strings.HasPrefix(keyStr.Key, "hskey-auth-")) + + // Extract prefix and hash using fixed-length parsing like the real code does + _, prefixAndHash, found := strings.Cut(keyStr.Key, "hskey-auth-") + assert.True(t, found) + assert.GreaterOrEqual(t, len(prefixAndHash), 12+1+64) // prefix + '-' + hash minimum + + prefix := prefixAndHash[:12] + assert.Len(t, prefix, 12) // Prefix is 12 chars + assert.Equal(t, byte('-'), prefixAndHash[12]) // Separator + hash := prefixAndHash[13:] + assert.Len(t, hash, 64) // Hash is 64 chars + + return keyStr.Key + }, + wantFindErr: false, + wantValidateErr: false, + }, + { + name: "invalid_bcrypt_hash", + setupKey: func() string { + // Create valid key + key, err := db.CreatePreAuthKey( + types.UserID(user.ID), + true, false, nil, nil, + ) + require.NoError(t, err) + keyStr := key.Key + + // Return key with tampered hash using fixed-length parsing + _, prefixAndHash, _ := strings.Cut(keyStr, "hskey-auth-") + prefix := prefixAndHash[:12] + + return "hskey-auth-" + prefix + "-" + "wrong_hash_here_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + }, + wantFindErr: true, + wantValidateErr: false, + }, + { + name: "empty_key", + setupKey: func() string { + return "" + }, + wantFindErr: true, + wantValidateErr: false, + }, + { + name: "key_too_short", + setupKey: func() string { + return "hskey-auth-short" + }, + wantFindErr: true, + wantValidateErr: false, + }, + { + name: "missing_separator", + setupKey: func() string { + return "hskey-auth-ABCDEFGHIJKLabcdefghijklmnopqrstuvwxyz1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ" + }, + wantFindErr: true, + wantValidateErr: false, + }, + { + name: "hash_too_short", + setupKey: func() string { + return "hskey-auth-ABCDEFGHIJKL-short" + }, + wantFindErr: true, + wantValidateErr: false, + }, + { + name: "prefix_with_invalid_chars", + setupKey: func() string { + return "hskey-auth-ABC$EF@HIJKL-" + strings.Repeat("a", 64) + }, + wantFindErr: true, + wantValidateErr: false, + }, + { + name: "hash_with_invalid_chars", + setupKey: func() string { + return "hskey-auth-ABCDEFGHIJKL-" + "invalid$chars" + strings.Repeat("a", 54) + }, + wantFindErr: true, + wantValidateErr: false, + }, + { + name: "prefix_not_found_in_db", + setupKey: func() string { + // Create a validly formatted key but with a prefix that doesn't exist + return "hskey-auth-NotInDB12345-" + strings.Repeat("a", 64) + }, + wantFindErr: true, + wantValidateErr: false, + }, + { + name: "expired_legacy_key", + setupKey: func() string { + legacyKey := "expired_legacy_key_123456789012345678901234" + now := time.Now() + expiration := time.Now().Add(-1 * time.Hour) // Expired 1 hour ago + + // Use raw SQL to avoid UNIQUE constraint on empty prefix + err := db.DB.Exec(` + INSERT INTO pre_auth_keys (key, user_id, reusable, ephemeral, used, created_at, expiration) + VALUES (?, ?, ?, ?, ?, ?, ?) + `, legacyKey, user.ID, true, false, false, now, expiration).Error + require.NoError(t, err) + + return legacyKey + }, + wantFindErr: false, + wantValidateErr: true, + }, + { + name: "used_single_use_legacy_key", + setupKey: func() string { + legacyKey := "used_legacy_key_123456789012345678901234567" + now := time.Now() + + // Use raw SQL to avoid UNIQUE constraint on empty prefix + err := db.DB.Exec(` + INSERT INTO pre_auth_keys (key, user_id, reusable, ephemeral, used, created_at) + VALUES (?, ?, ?, ?, ?, ?) + `, legacyKey, user.ID, false, false, true, now).Error + require.NoError(t, err) + + return legacyKey + }, + wantFindErr: false, + wantValidateErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + keyStr := tt.setupKey() + + pak, err := db.GetPreAuthKey(keyStr) + + if tt.wantFindErr { + assert.Error(t, err) + return + } + + require.NoError(t, err) + require.NotNil(t, pak) + + // Check validation if needed + if tt.wantValidateErr { + err := pak.Validate() + assert.Error(t, err) + return + } + + if tt.validateResult != nil { + tt.validateResult(t, pak) + } + }) + } +} + +func TestMultipleLegacyKeysAllowed(t *testing.T) { + db, err := newSQLiteTestDB() + require.NoError(t, err) + + user, err := db.CreateUser(types.User{Name: "test-legacy"}) + require.NoError(t, err) + + // Create multiple legacy keys by directly inserting with empty prefix + // This simulates the migration scenario where existing databases have multiple + // plaintext keys without prefix/hash fields + now := time.Now() + + for i := range 5 { + legacyKey := fmt.Sprintf("legacy_key_%d_%s", i, strings.Repeat("x", 40)) + + err := db.DB.Exec(` + INSERT INTO pre_auth_keys (key, prefix, hash, user_id, reusable, ephemeral, used, created_at) + VALUES (?, '', NULL, ?, ?, ?, ?, ?) + `, legacyKey, user.ID, true, false, false, now).Error + require.NoError(t, err, "should allow multiple legacy keys with empty prefix") + } + + // Verify all legacy keys can be retrieved + var legacyKeys []types.PreAuthKey + err = db.DB.Where("prefix = '' OR prefix IS NULL").Find(&legacyKeys).Error + require.NoError(t, err) + assert.Len(t, legacyKeys, 5, "should have created 5 legacy keys") + + // Now create new bcrypt-based keys - these should have unique prefixes + key1, err := db.CreatePreAuthKey(types.UserID(user.ID), true, false, nil, nil) + require.NoError(t, err) + assert.NotEmpty(t, key1.Key) + + key2, err := db.CreatePreAuthKey(types.UserID(user.ID), true, false, nil, nil) + require.NoError(t, err) + assert.NotEmpty(t, key2.Key) + + // Verify the new keys have different prefixes + pak1, err := db.GetPreAuthKey(key1.Key) + require.NoError(t, err) + assert.NotEmpty(t, pak1.Prefix) + + pak2, err := db.GetPreAuthKey(key2.Key) + require.NoError(t, err) + assert.NotEmpty(t, pak2.Prefix) + + assert.NotEqual(t, pak1.Prefix, pak2.Prefix, "new keys should have unique prefixes") + + // Verify we cannot manually insert duplicate non-empty prefixes + duplicatePrefix := "test_prefix1" + hash1 := []byte("hash1") + hash2 := []byte("hash2") + + // First insert should succeed + err = db.DB.Exec(` + INSERT INTO pre_auth_keys (key, prefix, hash, user_id, reusable, ephemeral, used, created_at) + VALUES ('', ?, ?, ?, ?, ?, ?, ?) + `, duplicatePrefix, hash1, user.ID, true, false, false, now).Error + require.NoError(t, err, "first key with prefix should succeed") + + // Second insert with same prefix should fail + err = db.DB.Exec(` + INSERT INTO pre_auth_keys (key, prefix, hash, user_id, reusable, ephemeral, used, created_at) + VALUES ('', ?, ?, ?, ?, ?, ?, ?) + `, duplicatePrefix, hash2, user.ID, true, false, false, now).Error + assert.Error(t, err, "duplicate non-empty prefix should be rejected") + assert.Contains(t, err.Error(), "UNIQUE constraint failed", "should fail with UNIQUE constraint error") +} diff --git a/hscontrol/db/schema.sql b/hscontrol/db/schema.sql index 175e2aff..075c9d4d 100644 --- a/hscontrol/db/schema.sql +++ b/hscontrol/db/schema.sql @@ -48,6 +48,8 @@ CREATE UNIQUE INDEX idx_name_no_provider_identifier ON users( CREATE TABLE pre_auth_keys( id integer PRIMARY KEY AUTOINCREMENT, key text, + prefix text, + hash blob, user_id integer, reusable numeric, ephemeral numeric DEFAULT false, @@ -59,6 +61,7 @@ CREATE TABLE pre_auth_keys( CONSTRAINT fk_pre_auth_keys_user FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE SET NULL ); +CREATE UNIQUE INDEX idx_pre_auth_keys_prefix ON pre_auth_keys(prefix) WHERE prefix IS NOT NULL AND prefix != ''; CREATE TABLE api_keys( id integer PRIMARY KEY AUTOINCREMENT, diff --git a/hscontrol/db/suite_test.go b/hscontrol/db/suite_test.go index 0589ff81..bacdec12 100644 --- a/hscontrol/db/suite_test.go +++ b/hscontrol/db/suite_test.go @@ -9,6 +9,7 @@ import ( "testing" "github.com/juanfont/headscale/hscontrol/types" + "github.com/rs/zerolog" "gopkg.in/check.v1" "zombiezen.com/go/postgrestest" ) @@ -49,6 +50,7 @@ func (s *Suite) ResetDB(c *check.C) { // TODO(kradalby): make this a t.Helper when we dont depend // on check test framework. func newSQLiteTestDB() (*HSDatabase, error) { + var err error tmpDir, err = os.MkdirTemp("", "headscale-db-test-*") if err != nil { @@ -56,6 +58,7 @@ func newSQLiteTestDB() (*HSDatabase, error) { } log.Printf("database path: %s", tmpDir+"/headscale_test.db") + zerolog.SetGlobalLevel(zerolog.Disabled) db, err = NewHeadscaleDatabase( types.DatabaseConfig{ diff --git a/hscontrol/db/users_test.go b/hscontrol/db/users_test.go index 5b2f0c4b..91cd8daf 100644 --- a/hscontrol/db/users_test.go +++ b/hscontrol/db/users_test.go @@ -1,138 +1,257 @@ package db import ( - "strings" + "testing" "github.com/juanfont/headscale/hscontrol/types" "github.com/juanfont/headscale/hscontrol/util" - "gopkg.in/check.v1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "gorm.io/gorm" "tailscale.com/types/ptr" ) -func (s *Suite) TestCreateAndDestroyUser(c *check.C) { +func TestCreateAndDestroyUser(t *testing.T) { + db, err := newSQLiteTestDB() + require.NoError(t, err) + user := db.CreateUserForTest("test") - c.Assert(user.Name, check.Equals, "test") + assert.Equal(t, "test", user.Name) users, err := db.ListUsers() - c.Assert(err, check.IsNil) - c.Assert(len(users), check.Equals, 1) + require.NoError(t, err) + assert.Len(t, users, 1) err = db.DestroyUser(types.UserID(user.ID)) - c.Assert(err, check.IsNil) + require.NoError(t, err) _, err = db.GetUserByID(types.UserID(user.ID)) - c.Assert(err, check.NotNil) + assert.Error(t, err) } -func (s *Suite) TestDestroyUserErrors(c *check.C) { - err := db.DestroyUser(9998) - c.Assert(err, check.Equals, ErrUserNotFound) +func TestDestroyUserErrors(t *testing.T) { + tests := []struct { + name string + test func(*testing.T, *HSDatabase) + }{ + { + name: "error_user_not_found", + test: func(t *testing.T, db *HSDatabase) { + err := db.DestroyUser(9998) + assert.ErrorIs(t, err, ErrUserNotFound) + }, + }, + { + name: "success_deletes_preauthkeys", + test: func(t *testing.T, db *HSDatabase) { + user := db.CreateUserForTest("test") - user := db.CreateUserForTest("test") + pak, err := db.CreatePreAuthKey(types.UserID(user.ID), false, false, nil, nil) + require.NoError(t, err) - pak, err := db.CreatePreAuthKey(types.UserID(user.ID), false, false, nil, nil) - c.Assert(err, check.IsNil) + err = db.DestroyUser(types.UserID(user.ID)) + require.NoError(t, err) - err = db.DestroyUser(types.UserID(user.ID)) - c.Assert(err, check.IsNil) + // Verify preauth key was deleted (need to search by prefix for new keys) + var foundPak types.PreAuthKey + result := db.DB.First(&foundPak, "id = ?", pak.ID) + assert.ErrorIs(t, result.Error, gorm.ErrRecordNotFound) + }, + }, + { + name: "error_user_has_nodes", + test: func(t *testing.T, db *HSDatabase) { + user, err := db.CreateUser(types.User{Name: "test"}) + require.NoError(t, err) - result := db.DB.Preload("User").First(&pak, "key = ?", pak.Key) - // destroying a user also deletes all associated preauthkeys - c.Assert(result.Error, check.Equals, gorm.ErrRecordNotFound) + pak, err := db.CreatePreAuthKey(types.UserID(user.ID), false, false, nil, nil) + require.NoError(t, err) - user, err = db.CreateUser(types.User{Name: "test"}) - c.Assert(err, check.IsNil) + node := types.Node{ + ID: 0, + Hostname: "testnode", + UserID: user.ID, + RegisterMethod: util.RegisterMethodAuthKey, + AuthKeyID: ptr.To(pak.ID), + } + trx := db.DB.Save(&node) + require.NoError(t, trx.Error) - pak, err = db.CreatePreAuthKey(types.UserID(user.ID), false, false, nil, nil) - c.Assert(err, check.IsNil) - - node := types.Node{ - ID: 0, - Hostname: "testnode", - UserID: user.ID, - RegisterMethod: util.RegisterMethodAuthKey, - AuthKeyID: ptr.To(pak.ID), + err = db.DestroyUser(types.UserID(user.ID)) + assert.ErrorIs(t, err, ErrUserStillHasNodes) + }, + }, } - trx := db.DB.Save(&node) - c.Assert(trx.Error, check.IsNil) - err = db.DestroyUser(types.UserID(user.ID)) - c.Assert(err, check.Equals, ErrUserStillHasNodes) -} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + db, err := newSQLiteTestDB() + require.NoError(t, err) -func (s *Suite) TestRenameUser(c *check.C) { - userTest := db.CreateUserForTest("test") - c.Assert(userTest.Name, check.Equals, "test") - - users, err := db.ListUsers() - c.Assert(err, check.IsNil) - c.Assert(len(users), check.Equals, 1) - - err = db.RenameUser(types.UserID(userTest.ID), "test-renamed") - c.Assert(err, check.IsNil) - - users, err = db.ListUsers(&types.User{Name: "test"}) - c.Assert(err, check.Equals, nil) - c.Assert(len(users), check.Equals, 0) - - users, err = db.ListUsers(&types.User{Name: "test-renamed"}) - c.Assert(err, check.IsNil) - c.Assert(len(users), check.Equals, 1) - - err = db.RenameUser(99988, "test") - c.Assert(err, check.Equals, ErrUserNotFound) - - userTest2 := db.CreateUserForTest("test2") - c.Assert(userTest2.Name, check.Equals, "test2") - - want := "UNIQUE constraint failed" - err = db.RenameUser(types.UserID(userTest2.ID), "test-renamed") - if err == nil || !strings.Contains(err.Error(), want) { - c.Fatalf("expected failure with unique constraint, want: %q got: %q", want, err) + tt.test(t, db) + }) } } -func (s *Suite) TestSetMachineUser(c *check.C) { - oldUser := db.CreateUserForTest("old") - newUser := db.CreateUserForTest("new") +func TestRenameUser(t *testing.T) { + tests := []struct { + name string + test func(*testing.T, *HSDatabase) + }{ + { + name: "success_rename", + test: func(t *testing.T, db *HSDatabase) { + userTest := db.CreateUserForTest("test") + assert.Equal(t, "test", userTest.Name) - pak, err := db.CreatePreAuthKey(types.UserID(oldUser.ID), false, false, nil, nil) - c.Assert(err, check.IsNil) + users, err := db.ListUsers() + require.NoError(t, err) + assert.Len(t, users, 1) - node := types.Node{ - ID: 12, - Hostname: "testnode", - UserID: oldUser.ID, - RegisterMethod: util.RegisterMethodAuthKey, - AuthKeyID: ptr.To(pak.ID), + err = db.RenameUser(types.UserID(userTest.ID), "test-renamed") + require.NoError(t, err) + + users, err = db.ListUsers(&types.User{Name: "test"}) + require.NoError(t, err) + assert.Empty(t, users) + + users, err = db.ListUsers(&types.User{Name: "test-renamed"}) + require.NoError(t, err) + assert.Len(t, users, 1) + }, + }, + { + name: "error_user_not_found", + test: func(t *testing.T, db *HSDatabase) { + err := db.RenameUser(99988, "test") + assert.ErrorIs(t, err, ErrUserNotFound) + }, + }, + { + name: "error_duplicate_name", + test: func(t *testing.T, db *HSDatabase) { + userTest := db.CreateUserForTest("test") + userTest2 := db.CreateUserForTest("test2") + + assert.Equal(t, "test", userTest.Name) + assert.Equal(t, "test2", userTest2.Name) + + err := db.RenameUser(types.UserID(userTest2.ID), "test") + require.Error(t, err) + assert.Contains(t, err.Error(), "UNIQUE constraint failed") + }, + }, } - trx := db.DB.Save(&node) - c.Assert(trx.Error, check.IsNil) - c.Assert(node.UserID, check.Equals, oldUser.ID) - err = db.Write(func(tx *gorm.DB) error { - return AssignNodeToUser(tx, 12, types.UserID(newUser.ID)) - }) - c.Assert(err, check.IsNil) - // Reload node from database to see updated values - updatedNode, err := db.GetNodeByID(12) - c.Assert(err, check.IsNil) - c.Assert(updatedNode.UserID, check.Equals, newUser.ID) - c.Assert(updatedNode.User.Name, check.Equals, newUser.Name) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + db, err := newSQLiteTestDB() + require.NoError(t, err) - err = db.Write(func(tx *gorm.DB) error { - return AssignNodeToUser(tx, 12, 9584849) - }) - c.Assert(err, check.Equals, ErrUserNotFound) - - err = db.Write(func(tx *gorm.DB) error { - return AssignNodeToUser(tx, 12, types.UserID(newUser.ID)) - }) - c.Assert(err, check.IsNil) - // Reload node from database again to see updated values - finalNode, err := db.GetNodeByID(12) - c.Assert(err, check.IsNil) - c.Assert(finalNode.UserID, check.Equals, newUser.ID) - c.Assert(finalNode.User.Name, check.Equals, newUser.Name) + tt.test(t, db) + }) + } +} + +func TestAssignNodeToUser(t *testing.T) { + tests := []struct { + name string + test func(*testing.T, *HSDatabase) + }{ + { + name: "success_reassign_node", + test: func(t *testing.T, db *HSDatabase) { + oldUser := db.CreateUserForTest("old") + newUser := db.CreateUserForTest("new") + + pak, err := db.CreatePreAuthKey(types.UserID(oldUser.ID), false, false, nil, nil) + require.NoError(t, err) + + node := types.Node{ + ID: 12, + Hostname: "testnode", + UserID: oldUser.ID, + RegisterMethod: util.RegisterMethodAuthKey, + AuthKeyID: ptr.To(pak.ID), + } + trx := db.DB.Save(&node) + require.NoError(t, trx.Error) + assert.Equal(t, oldUser.ID, node.UserID) + + err = db.Write(func(tx *gorm.DB) error { + return AssignNodeToUser(tx, 12, types.UserID(newUser.ID)) + }) + require.NoError(t, err) + + // Reload node from database to see updated values + updatedNode, err := db.GetNodeByID(12) + require.NoError(t, err) + assert.Equal(t, newUser.ID, updatedNode.UserID) + assert.Equal(t, newUser.Name, updatedNode.User.Name) + }, + }, + { + name: "error_user_not_found", + test: func(t *testing.T, db *HSDatabase) { + oldUser := db.CreateUserForTest("old") + + pak, err := db.CreatePreAuthKey(types.UserID(oldUser.ID), false, false, nil, nil) + require.NoError(t, err) + + node := types.Node{ + ID: 12, + Hostname: "testnode", + UserID: oldUser.ID, + RegisterMethod: util.RegisterMethodAuthKey, + AuthKeyID: ptr.To(pak.ID), + } + trx := db.DB.Save(&node) + require.NoError(t, trx.Error) + + err = db.Write(func(tx *gorm.DB) error { + return AssignNodeToUser(tx, 12, 9584849) + }) + assert.ErrorIs(t, err, ErrUserNotFound) + }, + }, + { + name: "success_reassign_to_same_user", + test: func(t *testing.T, db *HSDatabase) { + user := db.CreateUserForTest("user") + + pak, err := db.CreatePreAuthKey(types.UserID(user.ID), false, false, nil, nil) + require.NoError(t, err) + + node := types.Node{ + ID: 12, + Hostname: "testnode", + UserID: user.ID, + RegisterMethod: util.RegisterMethodAuthKey, + AuthKeyID: ptr.To(pak.ID), + } + trx := db.DB.Save(&node) + require.NoError(t, trx.Error) + + err = db.Write(func(tx *gorm.DB) error { + return AssignNodeToUser(tx, 12, types.UserID(user.ID)) + }) + require.NoError(t, err) + + // Reload node from database again to see updated values + finalNode, err := db.GetNodeByID(12) + require.NoError(t, err) + assert.Equal(t, user.ID, finalNode.UserID) + assert.Equal(t, user.Name, finalNode.User.Name) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + db, err := newSQLiteTestDB() + require.NoError(t, err) + + tt.test(t, db) + }) + } } diff --git a/hscontrol/state/state.go b/hscontrol/state/state.go index c340adc2..1b41f25c 100644 --- a/hscontrol/state/state.go +++ b/hscontrol/state/state.go @@ -913,7 +913,7 @@ func (s *State) DestroyAPIKey(key types.APIKey) error { } // CreatePreAuthKey generates a new pre-authentication key for a user. -func (s *State) CreatePreAuthKey(userID types.UserID, reusable bool, ephemeral bool, expiration *time.Time, aclTags []string) (*types.PreAuthKey, error) { +func (s *State) CreatePreAuthKey(userID types.UserID, reusable bool, ephemeral bool, expiration *time.Time, aclTags []string) (*types.PreAuthKeyNew, error) { return s.db.CreatePreAuthKey(userID, reusable, ephemeral, expiration, aclTags) } diff --git a/hscontrol/types/preauth_key.go b/hscontrol/types/preauth_key.go index 659e0a76..d1f73082 100644 --- a/hscontrol/types/preauth_key.go +++ b/hscontrol/types/preauth_key.go @@ -14,8 +14,15 @@ func (e PAKError) Error() string { return string(e) } // PreAuthKey describes a pre-authorization key usable in a particular user. type PreAuthKey struct { - ID uint64 `gorm:"primary_key"` - Key string + ID uint64 `gorm:"primary_key"` + + // Legacy plaintext key (for backwards compatibility) + Key string + + // New bcrypt-based authentication + Prefix string + Hash []byte // bcrypt + UserID uint User User `gorm:"constraint:OnDelete:SET NULL;"` Reusable bool @@ -32,17 +39,41 @@ type PreAuthKey struct { Expiration *time.Time } +// PreAuthKeyNew is returned once when the key is created. +type PreAuthKeyNew struct { + ID uint64 `gorm:"primary_key"` + Key string +} + +func (key *PreAuthKeyNew) Proto() *v1.PreAuthKey { + protoKey := v1.PreAuthKey{ + Id: key.ID, + Key: key.Key, + } + + return &protoKey +} + func (key *PreAuthKey) Proto() *v1.PreAuthKey { protoKey := v1.PreAuthKey{ User: key.User.Proto(), Id: key.ID, - Key: key.Key, Ephemeral: key.Ephemeral, Reusable: key.Reusable, Used: key.Used, AclTags: key.Tags, } + // For new keys (with prefix/hash), show the prefix so users can identify the key + // For legacy keys (with plaintext key), show the full key for backwards compatibility + if key.Prefix != "" { + protoKey.Key = "hskey-auth-" + key.Prefix + "-***" + } else if key.Key != "" { + // Legacy key - show full key for backwards compatibility + // TODO: Consider hiding this in a future major version + protoKey.Key = key.Key + } + if key.Expiration != nil { protoKey.Expiration = timestamppb.New(*key.Expiration) } diff --git a/hscontrol/types/types_clone.go b/hscontrol/types/types_clone.go index 3f530dc9..7699fb8f 100644 --- a/hscontrol/types/types_clone.go +++ b/hscontrol/types/types_clone.go @@ -110,6 +110,7 @@ func (src *PreAuthKey) Clone() *PreAuthKey { } dst := new(PreAuthKey) *dst = *src + dst.Hash = append(src.Hash[:0:0], src.Hash...) dst.Tags = append(src.Tags[:0:0], src.Tags...) if dst.CreatedAt != nil { dst.CreatedAt = ptr.To(*src.CreatedAt) @@ -124,6 +125,8 @@ func (src *PreAuthKey) Clone() *PreAuthKey { var _PreAuthKeyCloneNeedsRegeneration = PreAuthKey(struct { ID uint64 Key string + Prefix string + Hash []byte UserID uint User User Reusable bool diff --git a/hscontrol/types/types_view.go b/hscontrol/types/types_view.go index 5c31eac8..076f5dbb 100644 --- a/hscontrol/types/types_view.go +++ b/hscontrol/types/types_view.go @@ -239,14 +239,16 @@ func (v *PreAuthKeyView) UnmarshalJSON(b []byte) error { return nil } -func (v PreAuthKeyView) ID() uint64 { return v.ж.ID } -func (v PreAuthKeyView) Key() string { return v.ж.Key } -func (v PreAuthKeyView) UserID() uint { return v.ж.UserID } -func (v PreAuthKeyView) User() User { return v.ж.User } -func (v PreAuthKeyView) Reusable() bool { return v.ж.Reusable } -func (v PreAuthKeyView) Ephemeral() bool { return v.ж.Ephemeral } -func (v PreAuthKeyView) Used() bool { return v.ж.Used } -func (v PreAuthKeyView) Tags() views.Slice[string] { return views.SliceOf(v.ж.Tags) } +func (v PreAuthKeyView) ID() uint64 { return v.ж.ID } +func (v PreAuthKeyView) Key() string { return v.ж.Key } +func (v PreAuthKeyView) Prefix() string { return v.ж.Prefix } +func (v PreAuthKeyView) Hash() views.ByteSlice[[]byte] { return views.ByteSliceOf(v.ж.Hash) } +func (v PreAuthKeyView) UserID() uint { return v.ж.UserID } +func (v PreAuthKeyView) User() User { return v.ж.User } +func (v PreAuthKeyView) Reusable() bool { return v.ж.Reusable } +func (v PreAuthKeyView) Ephemeral() bool { return v.ж.Ephemeral } +func (v PreAuthKeyView) Used() bool { return v.ж.Used } +func (v PreAuthKeyView) Tags() views.Slice[string] { return views.SliceOf(v.ж.Tags) } func (v PreAuthKeyView) CreatedAt() views.ValuePointer[time.Time] { return views.ValuePointerOf(v.ж.CreatedAt) } @@ -259,6 +261,8 @@ func (v PreAuthKeyView) Expiration() views.ValuePointer[time.Time] { var _PreAuthKeyViewNeedsRegeneration = PreAuthKey(struct { ID uint64 Key string + Prefix string + Hash []byte UserID uint User User Reusable bool diff --git a/integration/cli_test.go b/integration/cli_test.go index 37e3c33d..dca37570 100644 --- a/integration/cli_test.go +++ b/integration/cli_test.go @@ -337,9 +337,10 @@ func TestPreAuthKeyCommand(t *testing.T) { }, ) - assert.NotEmpty(t, listedPreAuthKeys[1].GetKey()) - assert.NotEmpty(t, listedPreAuthKeys[2].GetKey()) - assert.NotEmpty(t, listedPreAuthKeys[3].GetKey()) + // New keys show prefix after listing, so check the created keys instead + assert.NotEmpty(t, keys[0].GetKey()) + assert.NotEmpty(t, keys[1].GetKey()) + assert.NotEmpty(t, keys[2].GetKey()) assert.True(t, listedPreAuthKeys[1].GetExpiration().AsTime().After(time.Now())) assert.True(t, listedPreAuthKeys[2].GetExpiration().AsTime().After(time.Now())) @@ -370,7 +371,7 @@ func TestPreAuthKeyCommand(t *testing.T) { ) } - // Test key expiry + // Test key expiry - use the full key from creation, not the masked one from listing _, err = headscale.Execute( []string{ "headscale", @@ -378,7 +379,7 @@ func TestPreAuthKeyCommand(t *testing.T) { "--user", "1", "expire", - listedPreAuthKeys[1].GetKey(), + keys[0].GetKey(), }, ) require.NoError(t, err)