This commit is contained in:
Kristoffer Dalby 2026-07-04 13:10:36 -04:00 committed by GitHub
commit a77b333a21
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 1175 additions and 480 deletions

View file

@ -26,6 +26,16 @@ keys remain all-access.
[#3334](https://github.com/juanfont/headscale/pull/3334)
### Credential hashing unified on Argon2id
API keys and pre-auth keys now hash their secrets with Argon2id, the scheme
already used for OAuth client secrets and access tokens, through one shared
generator and verifier. Existing bcrypt-hashed keys keep working and are
transparently upgraded to Argon2id the next time they authenticate. Bcrypt
verification will be removed in 0.32, so any key left unused between now and
then must be regenerated. Legacy plaintext pre-auth keys, the format that
predates the 2025-11 bcrypt keys, are no longer accepted and must be recreated.
### BREAKING
#### API

View file

@ -626,12 +626,12 @@ func nodeFromView(view types.NodeView) Node {
return n
}
// nodePreAuthKeyFromView builds the embedded NodePreAuthKey, masking the key to
// its prefix (legacy plaintext keys are shown in full).
func nodePreAuthKeyFromView(key types.PreAuthKeyView) *NodePreAuthKey {
// nodePreAuthKeyFromView builds the embedded NodePreAuthKey from a node's
// AuthKey credential, masking the secret to its identifier prefix.
func nodePreAuthKeyFromView(key types.CredentialView) *NodePreAuthKey {
pak := &NodePreAuthKey{
ID: formatID(key.ID()),
Key: maskedPreAuthKey(key),
Key: "hskey-auth-" + key.Identifier() + "-***",
Reusable: key.Reusable(),
Ephemeral: key.Ephemeral(),
Used: key.Used(),

View file

@ -3398,8 +3398,8 @@ func TestIssue2830_ExistingNodeReregistersWithExpiredKey(t *testing.T) {
// Now expire the key by updating it in the database to have an expiry in the past.
// This simulates the real-world scenario where a key expires after initial registration.
pastExpiry := time.Now().Add(-1 * time.Hour)
err = app.state.DB().DB.Model(&types.PreAuthKey{}).
Where("id = ?", pak.ID).
err = app.state.DB().DB.Model(&types.Credential{}).
Where("kind = ? AND id = ?", types.CredentialPreAuthKey, pak.ID).
Update("expiration", pastExpiry).Error
require.NoError(t, err, "should be able to update key expiration")
@ -3840,7 +3840,7 @@ func TestDeletedPreAuthKeyNotRecreatedOnNodeUpdate(t *testing.T) {
// Verify the PreAuthKey exists in the database
var pakCount int64
err = app.state.DB().DB.Model(&types.PreAuthKey{}).Where("id = ?", pakID).Count(&pakCount).Error
err = app.state.DB().DB.Model(&types.Credential{}).Where("kind = ? AND id = ?", types.CredentialPreAuthKey, pakID).Count(&pakCount).Error
require.NoError(t, err)
require.Equal(t, int64(1), pakCount, "PreAuthKey should exist in database")
@ -3851,7 +3851,7 @@ func TestDeletedPreAuthKeyNotRecreatedOnNodeUpdate(t *testing.T) {
require.NoError(t, err, "deleting PreAuthKey should succeed")
// Verify the PreAuthKey is gone from the database
err = app.state.DB().DB.Model(&types.PreAuthKey{}).Where("id = ?", pakID).Count(&pakCount).Error
err = app.state.DB().DB.Model(&types.Credential{}).Where("kind = ? AND id = ?", types.CredentialPreAuthKey, pakID).Count(&pakCount).Error
require.NoError(t, err)
require.Equal(t, int64(0), pakCount, "PreAuthKey should be deleted from database")
t.Log("PreAuthKey deleted from database")
@ -3886,7 +3886,7 @@ func TestDeletedPreAuthKeyNotRecreatedOnNodeUpdate(t *testing.T) {
t.Log("Simulated MapRequest update completed")
// THE CRITICAL CHECK: Verify the PreAuthKey was NOT recreated
err = app.state.DB().DB.Model(&types.PreAuthKey{}).Where("id = ?", pakID).Count(&pakCount).Error
err = app.state.DB().DB.Model(&types.Credential{}).Where("kind = ? AND id = ?", types.CredentialPreAuthKey, pakID).Count(&pakCount).Error
require.NoError(t, err)
require.Equal(t, int64(0), pakCount,
"BUG: PreAuthKey was recreated! The deleted PreAuthKey should NOT reappear after node update")

View file

@ -7,9 +7,7 @@ import (
"time"
"github.com/juanfont/headscale/hscontrol/types"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
"tailscale.com/util/rands"
)
const (
@ -17,9 +15,8 @@ const (
apiKeyPrefixLength = 12
apiKeyHashLength = 64
// Legacy format constants.
// Legacy format constant: the prefix length of pre-hskey "prefix.secret" keys.
legacyAPIPrefixLength = 7
legacyAPIKeyLength = 32
)
var (
@ -32,73 +29,70 @@ var (
func (hsdb *HSDatabase) CreateAPIKey(
expiration *time.Time,
) (string, *types.APIKey, error) {
// Generate public prefix (12 chars)
prefix := rands.HexString(apiKeyPrefixLength)
// Generate secret (64 chars)
secret := rands.HexString(apiKeyHashLength)
// Full key string (shown ONCE to user)
keyStr := apiKeyPrefix + prefix + "-" + secret
// bcrypt hash of secret
hash, err := bcrypt.GenerateFromPassword([]byte(secret), bcrypt.DefaultCost)
keyStr, identifier, hash, err := generateSecret(apiKeyPrefix)
if err != nil {
return "", nil, err
}
key := types.APIKey{
Prefix: prefix,
cred := types.Credential{
Kind: types.CredentialAPIKey,
Identifier: identifier,
Hash: hash,
Expiration: expiration,
}
if err := hsdb.DB.Save(&key).Error; err != nil { //nolint:noinlineerr
if err := hsdb.DB.Save(&cred).Error; err != nil { //nolint:noinlineerr
return "", nil, fmt.Errorf("saving API key to database: %w", err)
}
return keyStr, &key, nil
return keyStr, credentialToAPIKey(&cred), nil
}
// ListAPIKeys returns the list of [types.APIKey] values for a user.
func (hsdb *HSDatabase) ListAPIKeys() ([]types.APIKey, error) {
keys := []types.APIKey{}
var creds []types.Credential
err := hsdb.DB.Find(&keys).Error
err := hsdb.DB.Where("kind = ?", types.CredentialAPIKey).Find(&creds).Error
if err != nil {
return nil, err
}
keys := make([]types.APIKey, 0, len(creds))
for i := range creds {
keys = append(keys, *credentialToAPIKey(&creds[i]))
}
return keys, nil
}
// GetAPIKey returns a [types.APIKey] for a given key.
func (hsdb *HSDatabase) GetAPIKey(prefix string) (*types.APIKey, error) {
key := types.APIKey{}
if result := hsdb.DB.First(&key, "prefix = ?", prefix); result.Error != nil {
var cred types.Credential
if result := hsdb.DB.First(&cred, "kind = ? AND identifier = ?", types.CredentialAPIKey, prefix); result.Error != nil {
return nil, result.Error
}
return &key, nil
return credentialToAPIKey(&cred), nil
}
// GetAPIKeyByID returns a [types.APIKey] for a given id.
func (hsdb *HSDatabase) GetAPIKeyByID(id uint64) (*types.APIKey, error) {
key := types.APIKey{}
var cred types.Credential
// Query on an explicit primary-key clause: a struct condition would drop a
// zero-valued ID, making the lookup unconditional and returning the first
// row instead of not-found.
if result := hsdb.DB.First(&key, "id = ?", id); result.Error != nil {
if result := hsdb.DB.First(&cred, "kind = ? AND id = ?", types.CredentialAPIKey, id); result.Error != nil {
return nil, result.Error
}
return &key, nil
return credentialToAPIKey(&cred), nil
}
// DestroyAPIKey destroys a [types.APIKey]. Returns error if the [types.APIKey]
// does not exist.
func (hsdb *HSDatabase) DestroyAPIKey(key types.APIKey) error {
if result := hsdb.DB.Unscoped().Delete(key); result.Error != nil {
if result := hsdb.DB.Unscoped().
Delete(&types.Credential{}, "kind = ? AND id = ?", types.CredentialAPIKey, key.ID); result.Error != nil {
return result.Error
}
@ -107,12 +101,9 @@ func (hsdb *HSDatabase) DestroyAPIKey(key types.APIKey) error {
// ExpireAPIKey marks a [types.APIKey] as expired.
func (hsdb *HSDatabase) ExpireAPIKey(key *types.APIKey) error {
err := hsdb.DB.Model(&key).Update("Expiration", time.Now()).Error
if err != nil {
return err
}
return nil
return hsdb.DB.Model(&types.Credential{}).
Where("kind = ? AND id = ?", types.CredentialAPIKey, key.ID).
Update("expiration", time.Now()).Error
}
func (hsdb *HSDatabase) ValidateAPIKey(keyStr string) (bool, error) {
@ -148,8 +139,8 @@ func (hsdb *HSDatabase) AuthenticateAPIKey(keyStr string) (*types.APIKey, error)
// SetAPIKeyUser sets the owning user of an API key. Used when an admin mints a
// key on behalf of a user (headscale apikeys create --user).
func (hsdb *HSDatabase) SetAPIKeyUser(keyID uint64, userID types.UserID) error {
return hsdb.DB.Model(&types.APIKey{}).
Where("id = ?", keyID).
return hsdb.DB.Model(&types.Credential{}).
Where("kind = ? AND id = ?", types.CredentialAPIKey, keyID).
Update("user_id", uint(userID)).Error
}
@ -216,21 +207,24 @@ func validateAPIKey(db *gorm.DB, keyStr string) (*types.APIKey, error) {
return nil, err
}
// Look up by prefix (indexed)
var key types.APIKey
// Look up by identifier (indexed)
var cred types.Credential
err = db.First(&key, "prefix = ?", prefix).Error
err = db.First(&cred, "kind = ? AND identifier = ?", types.CredentialAPIKey, prefix).Error
if err != nil {
return nil, fmt.Errorf("API key not found: %w", err)
}
// Verify bcrypt hash
err = bcrypt.CompareHashAndPassword(key.Hash, []byte(secret))
needsRehash, err := verifySecret(cred.Hash, secret)
if err != nil {
return nil, fmt.Errorf("invalid API key: %w", err)
}
return &key, nil
if needsRehash {
rehashToArgon2id(db, &cred, secret)
}
return credentialToAPIKey(&cred), nil
}
// validateLegacyAPIKey validates a legacy format API key (prefix.secret).
@ -246,18 +240,21 @@ func validateLegacyAPIKey(db *gorm.DB, keyStr string) (*types.APIKey, error) {
return nil, fmt.Errorf("%w: legacy prefix length mismatch", ErrAPIKeyFailedToParse)
}
var key types.APIKey
var cred types.Credential
err := db.First(&key, "prefix = ?", prefix).Error
err := db.First(&cred, "kind = ? AND identifier = ?", types.CredentialAPIKey, prefix).Error
if err != nil {
return nil, fmt.Errorf("API key not found: %w", err)
}
// Verify bcrypt (key.Hash stores bcrypt of full secret)
err = bcrypt.CompareHashAndPassword(key.Hash, []byte(secret))
needsRehash, err := verifySecret(cred.Hash, secret)
if err != nil {
return nil, fmt.Errorf("invalid API key: %w", err)
}
return &key, nil
if needsRehash {
rehashToArgon2id(db, &cred, secret)
}
return credentialToAPIKey(&cred), nil
}

View file

@ -190,9 +190,9 @@ func TestAPIKeyWithPrefix(t *testing.T) {
now := time.Now()
err = db.DB.Exec(`
INSERT INTO api_keys (prefix, hash, created_at)
VALUES (?, ?, ?)
`, legacyPrefix, hash, now).Error
INSERT INTO credentials (kind, identifier, hash, created_at)
VALUES (?, ?, ?, ?)
`, types.CredentialAPIKey, legacyPrefix, hash, now).Error
require.NoError(t, err)
// Validate legacy key
@ -273,3 +273,37 @@ func TestGetAPIKeyByIDNotFound(t *testing.T) {
require.Error(t, err)
assert.Nil(t, key)
}
// TestAPIKeyLazyRehashesBcrypt seeds a new-format key whose secret is stored as
// a legacy bcrypt hash and asserts that authenticating it upgrades the stored
// hash to argon2id, while continuing to authenticate.
func TestAPIKeyLazyRehashesBcrypt(t *testing.T) {
db, err := newSQLiteTestDB()
require.NoError(t, err)
prefix := "abcdefghijkl"
secret := strings.Repeat("a", 64)
keyStr := "hskey-api-" + prefix + "-" + secret
hash, err := bcrypt.GenerateFromPassword([]byte(secret), bcrypt.DefaultCost)
require.NoError(t, err)
err = db.DB.Exec(
`INSERT INTO credentials (kind, identifier, hash, created_at) VALUES (?, ?, ?, ?)`,
types.CredentialAPIKey, prefix, hash, time.Now(),
).Error
require.NoError(t, err)
valid, err := db.ValidateAPIKey(keyStr)
require.NoError(t, err)
assert.True(t, valid)
stored, err := db.GetAPIKey(prefix)
require.NoError(t, err)
assert.True(t, strings.HasPrefix(string(stored.Hash), "$argon2id$"),
"a bcrypt-stored key must be rehashed to argon2id on first auth")
valid, err = db.ValidateAPIKey(keyStr)
require.NoError(t, err)
assert.True(t, valid, "key must still authenticate against the upgraded hash")
}

View file

@ -0,0 +1,72 @@
package db
import (
"github.com/juanfont/headscale/hscontrol/types"
)
// credentialToAPIKey projects a unified credentials row onto the [types.APIKey]
// shape the API, state, and CLI layers still consume.
func credentialToAPIKey(c *types.Credential) *types.APIKey {
return &types.APIKey{
ID: c.ID,
Prefix: c.Identifier,
Hash: c.Hash,
UserID: c.UserID,
CreatedAt: c.CreatedAt,
Expiration: c.Expiration,
LastSeen: c.LastSeen,
}
}
// credentialToOAuthClient projects a unified credentials row onto the
// [types.OAuthClient] shape. The client id is stored as the row's identifier.
func credentialToOAuthClient(c *types.Credential) *types.OAuthClient {
return &types.OAuthClient{
ID: c.ID,
ClientID: c.Identifier,
SecretHash: c.Hash,
Scopes: c.Scopes,
Tags: c.Tags,
Description: c.Description,
UserID: c.UserID,
CreatedAt: c.CreatedAt,
Revoked: c.Revoked,
}
}
// credentialToPreAuthKey projects a unified credentials row onto the
// [types.PreAuthKey] shape. The lookup prefix is stored as the row's identifier;
// the User association is carried through when preloaded.
func credentialToPreAuthKey(c *types.Credential) *types.PreAuthKey {
return &types.PreAuthKey{
ID: c.ID,
Prefix: c.Identifier,
Hash: c.Hash,
UserID: c.UserID,
User: c.User,
Description: c.Description,
Reusable: c.Reusable,
Ephemeral: c.Ephemeral,
Used: c.Used,
Tags: c.Tags,
CreatedAt: c.CreatedAt,
Expiration: c.Expiration,
Revoked: c.Revoked,
}
}
// credentialToOAuthAccessToken projects a unified credentials row onto the
// [types.OAuthAccessToken] shape. The token's lookup prefix is stored as the
// row's identifier; ClientID links back to the issuing client's identifier.
func credentialToOAuthAccessToken(c *types.Credential) *types.OAuthAccessToken {
return &types.OAuthAccessToken{
ID: c.ID,
Prefix: c.Identifier,
Hash: c.Hash,
ClientID: c.ClientID,
Scopes: c.Scopes,
Tags: c.Tags,
Expiration: c.Expiration,
CreatedAt: c.CreatedAt,
}
}

View file

@ -0,0 +1,48 @@
package db
import (
"testing"
"time"
"github.com/juanfont/headscale/hscontrol/types"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestCredentialTableRoundTrip confirms the unified credentials table is created
// by migration (newSQLiteTestDB validates the schema with squibble) and stores
// and reads back a credential of each kind.
func TestCredentialTableRoundTrip(t *testing.T) {
db, err := newSQLiteTestDB()
require.NoError(t, err)
now := time.Now().UTC()
creds := []types.Credential{
{Kind: types.CredentialAPIKey, Identifier: "apikey000001", Hash: []byte("h1"), CreatedAt: &now},
{Kind: types.CredentialPreAuthKey, Identifier: "authkey00001", Hash: []byte("h2"), Reusable: true, Tags: []string{"tag:a"}, CreatedAt: &now},
{Kind: types.CredentialOAuthClient, Identifier: "client000001", Hash: []byte("h3"), Scopes: []string{"devices:read"}, CreatedAt: &now},
{Kind: types.CredentialOAuthToken, Identifier: "oauthtok0001", Hash: []byte("h4"), ClientID: "client000001", CreatedAt: &now},
}
for i := range creds {
require.NoError(t, db.DB.Save(&creds[i]).Error)
}
var got []types.Credential
require.NoError(t, db.DB.Order("id").Find(&got).Error)
require.Len(t, got, 4)
assert.Equal(t, types.CredentialPreAuthKey, got[1].Kind)
assert.Equal(t, []string{"tag:a"}, got[1].Tags)
assert.Equal(t, "client000001", got[3].ClientID)
// The composite (kind, identifier) index permits the same identifier under a
// different kind but rejects a duplicate within a kind.
require.NoError(t, db.DB.Save(&types.Credential{
Kind: types.CredentialAPIKey, Identifier: "client000001", Hash: []byte("h5"), CreatedAt: &now,
}).Error)
err = db.DB.Save(&types.Credential{
Kind: types.CredentialAPIKey, Identifier: "apikey000001", Hash: []byte("dup"), CreatedAt: &now,
}).Error
require.Error(t, err, "duplicate (kind, identifier) must be rejected")
}

View file

@ -900,19 +900,85 @@ WHERE user_id IS NULL
},
Rollback: func(db *gorm.DB) error { return nil },
},
{
// Create the unified credentials table. Backfill from the existing
// per-kind tables and their removal land in a later migration, so
// this step is purely additive. SQLite uses explicit DDL matching
// schema.sql; Postgres uses dialect-aware AutoMigrate.
ID: "202606271200-create-credentials",
Migrate: func(tx *gorm.DB) error {
if tx.Migrator().HasTable(&types.Credential{}) {
return nil
}
if tx.Name() != "sqlite" {
return tx.AutoMigrate(&types.Credential{})
}
err := tx.Exec(`CREATE TABLE credentials(
id integer PRIMARY KEY AUTOINCREMENT,
kind text,
identifier text,
hash blob,
user_id integer,
description text,
scopes text,
tags text,
reusable numeric,
ephemeral numeric DEFAULT false,
used numeric DEFAULT false,
last_seen datetime,
client_id text,
created_at datetime,
expiration datetime,
revoked datetime,
CONSTRAINT fk_credentials_user FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE SET NULL
)`).Error
if err != nil {
return fmt.Errorf("creating credentials table: %w", err)
}
err = tx.Exec(`CREATE UNIQUE INDEX idx_credentials_identifier ON credentials(kind, identifier)`).Error
if err != nil {
return fmt.Errorf("creating credentials index: %w", err)
}
return nil
},
Rollback: func(db *gorm.DB) error { return nil },
},
{
// Move every credential into the unified table and drop the
// per-kind tables. Pre-auth keys are backfilled FIRST preserving
// their ids so nodes.auth_key_id stays valid; the nodes FK is then
// retargeted to credentials(id). Legacy plaintext pre-auth keys
// (empty prefix) are not migrated (breaking change) and any node
// referencing one has its auth_key_id cleared first.
ID: "202606271300-migrate-to-credentials",
Migrate: func(tx *gorm.DB) error {
// Already migrated (e.g. fresh DB via InitSchema): nothing to do.
if !tx.Migrator().HasTable("pre_auth_keys") &&
!tx.Migrator().HasTable("api_keys") {
return nil
}
return migrateToCredentials(tx)
},
Rollback: func(db *gorm.DB) error { return nil },
},
},
)
migrations.InitSchema(func(tx *gorm.DB) error {
// Create all tables using AutoMigrate
// Credential is migrated before Node so the nodes.auth_key_id foreign key
// to credentials(id) can be created.
err := tx.AutoMigrate(
&types.User{},
&types.PreAuthKey{},
&types.APIKey{},
&types.Credential{},
&types.Node{},
&types.Policy{},
&types.OAuthClient{},
&types.OAuthAccessToken{},
)
if err != nil {
return err
@ -922,14 +988,11 @@ WHERE user_id IS NULL
// to ensure we can recreate them in the correct format
dropIndexes := []string{
`DROP INDEX IF EXISTS "idx_users_deleted_at"`,
`DROP INDEX IF EXISTS "idx_api_keys_prefix"`,
`DROP INDEX IF EXISTS "idx_policies_deleted_at"`,
`DROP INDEX IF EXISTS "idx_provider_identifier"`,
`DROP INDEX IF EXISTS "idx_name_provider_identifier"`,
`DROP INDEX IF EXISTS "idx_name_no_provider_identifier"`,
`DROP INDEX IF EXISTS "idx_pre_auth_keys_prefix"`,
`DROP INDEX IF EXISTS "idx_oauth_clients_client_id"`,
`DROP INDEX IF EXISTS "idx_oauth_access_tokens_prefix"`,
`DROP INDEX IF EXISTS "idx_credentials_identifier"`,
}
for _, dropSQL := range dropIndexes {
@ -942,14 +1005,11 @@ WHERE user_id IS NULL
// Recreate indexes without backticks to match schema.sql format
indexes := []string{
`CREATE INDEX idx_users_deleted_at ON users(deleted_at)`,
`CREATE UNIQUE INDEX idx_api_keys_prefix ON api_keys(prefix)`,
`CREATE INDEX idx_policies_deleted_at ON policies(deleted_at)`,
`CREATE UNIQUE INDEX idx_provider_identifier ON users(provider_identifier) WHERE provider_identifier IS NOT NULL`,
`CREATE UNIQUE INDEX idx_name_provider_identifier ON users(name, provider_identifier)`,
`CREATE UNIQUE INDEX idx_name_no_provider_identifier ON users(name) WHERE provider_identifier IS NULL`,
`CREATE UNIQUE INDEX idx_pre_auth_keys_prefix ON pre_auth_keys(prefix) WHERE prefix IS NOT NULL AND prefix != ''`,
`CREATE UNIQUE INDEX idx_oauth_clients_client_id ON oauth_clients(client_id)`,
`CREATE UNIQUE INDEX idx_oauth_access_tokens_prefix ON oauth_access_tokens(prefix)`,
`CREATE UNIQUE INDEX idx_credentials_identifier ON credentials(kind, identifier)`,
}
for _, indexSQL := range indexes {

View file

@ -41,10 +41,11 @@ func TestSQLiteMigrationAndDataValidation(t *testing.T) {
require.NoError(t, err)
assert.Len(t, users, 1, "should preserve all 1 user from original schema")
// Verify api_keys data preservation
// Verify api_keys data preservation (migrated into credentials).
var apiKeyCount int
err = hsdb.DB.Raw("SELECT COUNT(*) FROM api_keys").Scan(&apiKeyCount).Error
err = hsdb.DB.Raw("SELECT COUNT(*) FROM credentials WHERE kind = ?", types.CredentialAPIKey).
Scan(&apiKeyCount).Error
require.NoError(t, err)
assert.Equal(t, 2, apiKeyCount, "should preserve all 2 api_keys from original schema")

View file

@ -0,0 +1,138 @@
package db
import (
"fmt"
"github.com/juanfont/headscale/hscontrol/types"
"gorm.io/gorm"
)
// migrateToCredentials backfills the unified credentials table from the four
// per-kind tables and drops them. Pre-auth keys are migrated first preserving
// their ids so nodes.auth_key_id stays valid; the nodes FK is then retargeted to
// credentials(id). Runs with foreign keys enabled on SQLite (per runMigrations),
// so the steps are ordered to never leave a dangling reference.
func migrateToCredentials(tx *gorm.DB) error {
// Clear node references to legacy plaintext pre-auth keys (empty prefix);
// these are not migrated, so a node pointing at one would dangle.
err := tx.Exec(`UPDATE nodes SET auth_key_id = NULL
WHERE auth_key_id IN (SELECT id FROM pre_auth_keys WHERE prefix IS NULL OR prefix = '')`).Error
if err != nil {
return fmt.Errorf("clearing plaintext auth_key references: %w", err)
}
// Pre-auth keys first, preserving ids so nodes.auth_key_id stays valid.
err = tx.Exec(`INSERT INTO credentials
(id, kind, identifier, hash, user_id, description, reusable, ephemeral, used, tags, expiration, revoked, created_at)
SELECT id, ?, prefix, hash, user_id, description, reusable, ephemeral, used, tags, expiration, revoked, created_at
FROM pre_auth_keys WHERE prefix IS NOT NULL AND prefix != ''`, types.CredentialPreAuthKey).Error
if err != nil {
return fmt.Errorf("backfilling pre-auth keys: %w", err)
}
// Postgres does not advance the id sequence on explicit-id inserts; nudge it
// past the pre-auth ids before the auto-id inserts below. SQLite's
// AUTOINCREMENT already tracks max(id).
if tx.Name() != "sqlite" {
err = tx.Exec(`SELECT setval(pg_get_serial_sequence('credentials','id'),
GREATEST((SELECT COALESCE(MAX(id), 1) FROM credentials), 1))`).Error
if err != nil {
return fmt.Errorf("resetting credentials id sequence: %w", err)
}
}
err = tx.Exec(`INSERT INTO credentials (kind, identifier, hash, user_id, last_seen, expiration, created_at)
SELECT ?, prefix, hash, user_id, last_seen, expiration, created_at FROM api_keys`, types.CredentialAPIKey).Error
if err != nil {
return fmt.Errorf("backfilling api keys: %w", err)
}
err = tx.Exec(`INSERT INTO credentials (kind, identifier, hash, scopes, tags, description, user_id, revoked, created_at)
SELECT ?, client_id, secret_hash, scopes, tags, description, user_id, revoked, created_at FROM oauth_clients`, types.CredentialOAuthClient).Error
if err != nil {
return fmt.Errorf("backfilling oauth clients: %w", err)
}
err = tx.Exec(`INSERT INTO credentials (kind, identifier, hash, client_id, scopes, tags, expiration, created_at)
SELECT ?, prefix, hash, client_id, scopes, tags, expiration, created_at FROM oauth_access_tokens`, types.CredentialOAuthToken).Error
if err != nil {
return fmt.Errorf("backfilling oauth access tokens: %w", err)
}
if err := retargetNodesAuthKeyFK(tx); err != nil { //nolint:noinlineerr
return err
}
for _, table := range []string{"pre_auth_keys", "api_keys", "oauth_clients", "oauth_access_tokens"} {
if err := tx.Migrator().DropTable(table); err != nil { //nolint:noinlineerr
return fmt.Errorf("dropping %s: %w", table, err)
}
}
return nil
}
// retargetNodesAuthKeyFK repoints the nodes.auth_key_id foreign key from
// pre_auth_keys(id) to credentials(id). Postgres alters the constraint in place;
// SQLite, which cannot alter a foreign key, rebuilds the table. The rebuild runs
// with foreign keys enabled: no table references nodes, and every retained
// auth_key_id now points at a credentials row, so no FK toggling is required.
func retargetNodesAuthKeyFK(tx *gorm.DB) error {
if tx.Name() != "sqlite" {
err := tx.Exec(`ALTER TABLE nodes DROP CONSTRAINT IF EXISTS fk_nodes_auth_key`).Error
if err != nil {
return fmt.Errorf("dropping nodes auth_key constraint: %w", err)
}
err = tx.Exec(`ALTER TABLE nodes ADD CONSTRAINT fk_nodes_auth_key
FOREIGN KEY (auth_key_id) REFERENCES credentials(id)`).Error
if err != nil {
return fmt.Errorf("adding nodes auth_key constraint: %w", err)
}
return nil
}
stmts := []string{
`CREATE TABLE nodes_new(
id integer PRIMARY KEY AUTOINCREMENT,
machine_key text,
node_key text,
disco_key text,
endpoints text,
host_info text,
ipv4 text,
ipv6 text,
hostname text,
given_name varchar(63),
user_id integer,
register_method text,
tags text,
auth_key_id integer,
last_seen datetime,
expiry datetime,
approved_routes text,
created_at datetime,
updated_at datetime,
deleted_at datetime,
CONSTRAINT fk_nodes_user FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE,
CONSTRAINT fk_nodes_auth_key FOREIGN KEY(auth_key_id) REFERENCES credentials(id)
)`,
`INSERT INTO nodes_new
(id, machine_key, node_key, disco_key, endpoints, host_info, ipv4, ipv6, hostname, given_name, user_id, register_method, tags, auth_key_id, last_seen, expiry, approved_routes, created_at, updated_at, deleted_at)
SELECT id, machine_key, node_key, disco_key, endpoints, host_info, ipv4, ipv6, hostname, given_name, user_id, register_method, tags, auth_key_id, last_seen, expiry, approved_routes, created_at, updated_at, deleted_at
FROM nodes`,
`DROP TABLE nodes`,
`ALTER TABLE nodes_new RENAME TO nodes`,
}
for _, stmt := range stmts {
if err := tx.Exec(stmt).Error; err != nil { //nolint:noinlineerr
return fmt.Errorf("rebuilding nodes table: %w", err)
}
}
return nil
}

View file

@ -1,20 +1,14 @@
package db
import (
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"errors"
"fmt"
"runtime"
"slices"
"strings"
"time"
"github.com/juanfont/headscale/hscontrol/types"
"golang.org/x/crypto/argon2"
"gorm.io/gorm"
"tailscale.com/util/rands"
"tailscale.com/util/set"
)
@ -42,96 +36,8 @@ var (
ErrAccessTokenFailedToParse = errors.New("failed to parse oauth access token")
ErrAccessTokenExpired = errors.New("oauth access token expired")
ErrAccessTokenClientRevoked = errors.New("oauth access token issuing client revoked or deleted")
errSecretHashMalformed = errors.New("malformed secret hash")
errSecretMismatch = errors.New("secret does not match hash")
)
// Argon2id parameters, OWASP's minimum recommendation (19 MiB, 2 iterations, 1
// lane). They are encoded into every stored hash, so raising them later still
// verifies credentials stored under the old cost.
const (
argon2Time = 2
argon2Memory = 19 * 1024
argon2Threads = 1
argon2KeyLen = 32
argon2SaltLen = 16
)
// argon2Limiter bounds concurrent Argon2id computations. Each costs ~19 MiB and
// the unauthenticated OAuth token endpoint runs one per attempt, so an unbounded
// flood could exhaust memory. ponytail: a global semaphore sized to GOMAXPROCS;
// revisit only if credential hashing ever becomes a throughput bottleneck.
var argon2Limiter = make(chan struct{}, max(2, runtime.GOMAXPROCS(0)))
// hashSecret hashes a credential secret with Argon2id, encoded in PHC string
// form so the parameters travel with the hash. Argon2id is the current OWASP
// recommendation, replacing bcrypt for new credential storage.
func hashSecret(secret string) ([]byte, error) {
salt := make([]byte, argon2SaltLen)
_, err := rand.Read(salt)
if err != nil {
return nil, fmt.Errorf("generating salt: %w", err)
}
hash := argon2.IDKey([]byte(secret), salt, argon2Time, argon2Memory, argon2Threads, argon2KeyLen)
encoded := fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
argon2.Version, argon2Memory, argon2Time, argon2Threads,
base64.RawStdEncoding.EncodeToString(salt),
base64.RawStdEncoding.EncodeToString(hash),
)
return []byte(encoded), nil
}
// verifySecret reports whether secret matches a hashSecret-encoded hash. It
// reads the cost parameters from the stored hash and compares in constant time
// so a mismatch leaks no timing signal.
func verifySecret(encoded []byte, secret string) error {
parts := strings.Split(string(encoded), "$")
if len(parts) != 6 || parts[1] != "argon2id" {
return errSecretHashMalformed
}
var version int
if _, err := fmt.Sscanf(parts[2], "v=%d", &version); err != nil || version != argon2.Version { //nolint:noinlineerr
return errSecretHashMalformed
}
var (
memory, time uint32
threads uint8
)
if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &memory, &time, &threads); err != nil { //nolint:noinlineerr
return errSecretHashMalformed
}
salt, err := base64.RawStdEncoding.DecodeString(parts[4])
if err != nil {
return errSecretHashMalformed
}
want, err := base64.RawStdEncoding.DecodeString(parts[5])
if err != nil {
return errSecretHashMalformed
}
argon2Limiter <- struct{}{}
//nolint:gosec // want is a 32-byte hash read back from storage, no overflow
got := argon2.IDKey([]byte(secret), salt, time, memory, threads, uint32(len(want)))
<-argon2Limiter
if subtle.ConstantTimeCompare(got, want) != 1 {
return errSecretMismatch
}
return nil
}
// CreateOAuthClient creates a new [types.OAuthClient] and returns the plaintext
// secret (shown ONCE) alongside the stored client. creatorUserID is the user who
// created it (informational), or nil.
@ -148,19 +54,16 @@ func (hsdb *HSDatabase) CreateOAuthClient(
scopes = set.SetOf(scopes).Slice()
slices.Sort(scopes)
clientID := rands.HexString(oauthClientIDLength)
secret := rands.HexString(oauthClientSecretLength)
secretStr := types.OAuthClientPrefix + clientID + "-" + secret
hash, err := hashSecret(secret)
secretStr, clientID, hash, err := generateSecret(types.OAuthClientPrefix)
if err != nil {
return "", nil, err
}
now := time.Now().UTC()
client := types.OAuthClient{
ClientID: clientID,
SecretHash: hash,
cred := types.Credential{
Kind: types.CredentialOAuthClient,
Identifier: clientID,
Hash: hash,
Scopes: scopes,
Tags: tags,
Description: description,
@ -169,13 +72,13 @@ func (hsdb *HSDatabase) CreateOAuthClient(
}
err = hsdb.Write(func(tx *gorm.DB) error {
return tx.Save(&client).Error
return tx.Save(&cred).Error
})
if err != nil {
return "", nil, fmt.Errorf("saving oauth client: %w", err)
}
return secretStr, &client, nil
return secretStr, credentialToOAuthClient(&cred), nil
}
// AuthenticateOAuthClient validates a presented client secret and returns the
@ -207,41 +110,46 @@ func (hsdb *HSDatabase) AuthenticateOAuthClient(secretStr string) (*types.OAuthC
return nil, err
}
var client types.OAuthClient
if err := hsdb.DB.First(&client, "client_id = ?", clientID).Error; err != nil { //nolint:noinlineerr
var cred types.Credential
if err := hsdb.DB.First(&cred, "kind = ? AND identifier = ?", types.CredentialOAuthClient, clientID).Error; err != nil { //nolint:noinlineerr
return nil, ErrOAuthClientNotFound
}
if err := verifySecret(client.SecretHash, secret); err != nil { //nolint:noinlineerr
if _, err := verifySecret(cred.Hash, secret); err != nil { //nolint:noinlineerr
return nil, fmt.Errorf("invalid oauth client secret: %w", err)
}
if client.Revoked != nil {
if cred.Revoked != nil {
return nil, ErrOAuthClientRevoked
}
return &client, nil
return credentialToOAuthClient(&cred), nil
}
// GetOAuthClientByClientID returns a [types.OAuthClient] by its public client id.
func (hsdb *HSDatabase) GetOAuthClientByClientID(clientID string) (*types.OAuthClient, error) {
var client types.OAuthClient
if result := hsdb.DB.First(&client, "client_id = ?", clientID); result.Error != nil {
var cred types.Credential
if result := hsdb.DB.First(&cred, "kind = ? AND identifier = ?", types.CredentialOAuthClient, clientID); result.Error != nil {
return nil, result.Error
}
return &client, nil
return credentialToOAuthClient(&cred), nil
}
// ListOAuthClients returns every [types.OAuthClient].
func (hsdb *HSDatabase) ListOAuthClients() ([]types.OAuthClient, error) {
clients := []types.OAuthClient{}
var creds []types.Credential
err := hsdb.DB.Find(&clients).Error
err := hsdb.DB.Where("kind = ?", types.CredentialOAuthClient).Find(&creds).Error
if err != nil {
return nil, err
}
clients := make([]types.OAuthClient, 0, len(creds))
for i := range creds {
clients = append(clients, *credentialToOAuthClient(&creds[i]))
}
return clients, nil
}
@ -251,13 +159,14 @@ func (hsdb *HSDatabase) ListOAuthClients() ([]types.OAuthClient, error) {
// OAuth client has no such history and is removed outright, matching Tailscale.
func (hsdb *HSDatabase) RevokeOAuthClient(clientID string) error {
return hsdb.Write(func(tx *gorm.DB) error {
err := tx.Where("client_id = ?", clientID).
Delete(&types.OAuthAccessToken{}).Error
err := tx.Where("kind = ? AND client_id = ?", types.CredentialOAuthToken, clientID).
Delete(&types.Credential{}).Error
if err != nil {
return fmt.Errorf("deleting oauth access tokens: %w", err)
}
res := tx.Where("client_id = ?", clientID).Delete(&types.OAuthClient{})
res := tx.Where("kind = ? AND identifier = ?", types.CredentialOAuthClient, clientID).
Delete(&types.Credential{})
if res.Error != nil {
return res.Error
}
@ -278,18 +187,15 @@ func (hsdb *HSDatabase) MintAccessToken(
scopes, tags []string,
expiration *time.Time,
) (string, *types.OAuthAccessToken, error) {
prefix := rands.HexString(accessTokenPrefixLength)
secret := rands.HexString(accessTokenSecretLength)
tokenStr := types.AccessTokenPrefix + prefix + "-" + secret
hash, err := hashSecret(secret)
tokenStr, prefix, hash, err := generateSecret(types.AccessTokenPrefix)
if err != nil {
return "", nil, err
}
now := time.Now().UTC()
token := types.OAuthAccessToken{
Prefix: prefix,
cred := types.Credential{
Kind: types.CredentialOAuthToken,
Identifier: prefix,
Hash: hash,
ClientID: clientID,
Scopes: scopes,
@ -301,9 +207,9 @@ func (hsdb *HSDatabase) MintAccessToken(
// Mint inside a transaction that re-checks the client still exists and is
// not revoked, so a mint cannot complete against a client being deleted.
err = hsdb.Write(func(tx *gorm.DB) error {
var client types.OAuthClient
var client types.Credential
err := tx.First(&client, "client_id = ?", clientID).Error
err := tx.First(&client, "kind = ? AND identifier = ?", types.CredentialOAuthClient, clientID).Error
if err != nil {
return ErrOAuthClientNotFound
}
@ -312,13 +218,13 @@ func (hsdb *HSDatabase) MintAccessToken(
return ErrOAuthClientRevoked
}
return tx.Save(&token).Error
return tx.Save(&cred).Error
})
if err != nil {
return "", nil, fmt.Errorf("saving oauth access token: %w", err)
}
return tokenStr, &token, nil
return tokenStr, credentialToOAuthAccessToken(&cred), nil
}
// AuthenticateAccessToken validates a presented bearer token and returns the
@ -344,12 +250,12 @@ func (hsdb *HSDatabase) AuthenticateAccessToken(tokenStr string) (*types.OAuthAc
return nil, err
}
var token types.OAuthAccessToken
if err := hsdb.DB.First(&token, "prefix = ?", prefix).Error; err != nil { //nolint:noinlineerr
var token types.Credential
if err := hsdb.DB.First(&token, "kind = ? AND identifier = ?", types.CredentialOAuthToken, prefix).Error; err != nil { //nolint:noinlineerr
return nil, ErrAccessTokenNotFound
}
if err := verifySecret(token.Hash, secret); err != nil { //nolint:noinlineerr
if _, err := verifySecret(token.Hash, secret); err != nil { //nolint:noinlineerr
return nil, fmt.Errorf("invalid oauth access token: %w", err)
}
@ -361,8 +267,8 @@ func (hsdb *HSDatabase) AuthenticateAccessToken(tokenStr string) (*types.OAuthAc
// revoked or deleted is rejected. This closes a mint/revoke race (where a
// token could be inserted after the client's tokens were purged) and any
// orphan left by manual deletion or a future soft-revoke path.
var client types.OAuthClient
if err := hsdb.DB.First(&client, "client_id = ?", token.ClientID).Error; err != nil { //nolint:noinlineerr
var client types.Credential
if err := hsdb.DB.First(&client, "kind = ? AND identifier = ?", types.CredentialOAuthClient, token.ClientID).Error; err != nil { //nolint:noinlineerr
return nil, ErrAccessTokenClientRevoked
}
@ -370,7 +276,7 @@ func (hsdb *HSDatabase) AuthenticateAccessToken(tokenStr string) (*types.OAuthAc
return nil, ErrAccessTokenClientRevoked
}
return &token, nil
return credentialToOAuthAccessToken(&token), nil
}
// DeleteExpiredAccessTokens hard-deletes every access token that expired before
@ -378,8 +284,8 @@ func (hsdb *HSDatabase) AuthenticateAccessToken(tokenStr string) (*types.OAuthAc
// expired tokens; the hourly reaper (see app.go) calls this only to keep the
// table from growing unbounded.
func (hsdb *HSDatabase) DeleteExpiredAccessTokens(cutoff time.Time) (int64, error) {
res := hsdb.DB.Where("expiration IS NOT NULL AND expiration < ?", cutoff).
Delete(&types.OAuthAccessToken{})
res := hsdb.DB.Where("kind = ? AND expiration IS NOT NULL AND expiration < ?", types.CredentialOAuthToken, cutoff).
Delete(&types.Credential{})
return res.RowsAffected, res.Error
}

View file

@ -31,9 +31,9 @@ func TestVerifySecretConcurrent(t *testing.T) {
defer wg.Done()
if i%2 == 0 {
errs[i] = verifySecret(hash, "s3cr3t")
_, errs[i] = verifySecret(hash, "s3cr3t")
} else {
errs[i] = verifySecret(hash, "wrong")
_, errs[i] = verifySecret(hash, "wrong")
}
}(i)
}
@ -99,9 +99,14 @@ func TestHashSecretRoundTrip(t *testing.T) {
require.NoError(t, err)
assert.NotEqual(t, encoded, encoded2)
require.NoError(t, verifySecret(encoded, secret))
require.ErrorIs(t, verifySecret(encoded, "wrong-secret"), errSecretMismatch)
require.ErrorIs(t, verifySecret([]byte("not-a-phc-string"), secret), errSecretHashMalformed)
_, err = verifySecret(encoded, secret)
require.NoError(t, err)
_, err = verifySecret(encoded, "wrong-secret")
require.ErrorIs(t, err, errSecretMismatch)
_, err = verifySecret([]byte("not-a-phc-string"), secret)
require.ErrorIs(t, err, errSecretHashMalformed)
}
func TestOAuthClientRevoke(t *testing.T) {
@ -183,7 +188,7 @@ func TestAccessTokenRejectedWhenClientGone(t *testing.T) {
// Delete only the client row, leaving the token orphaned (the state a
// mint/revoke race or manual deletion would produce).
require.NoError(t, db.DB.Where("client_id = ?", client.ClientID).Delete(&types.OAuthClient{}).Error)
require.NoError(t, db.DB.Where("kind = ? AND identifier = ?", types.CredentialOAuthClient, client.ClientID).Delete(&types.Credential{}).Error)
_, err = db.AuthenticateAccessToken(tokenStr)
require.ErrorIs(t, err, ErrAccessTokenClientRevoked)
@ -196,8 +201,8 @@ func TestAccessTokenRejectedWhenClientGone(t *testing.T) {
require.NoError(t, err)
now := time.Now()
require.NoError(t, db.DB.Model(&types.OAuthClient{}).
Where("client_id = ?", client2.ClientID).Update("revoked", now).Error)
require.NoError(t, db.DB.Model(&types.Credential{}).
Where("kind = ? AND identifier = ?", types.CredentialOAuthClient, client2.ClientID).Update("revoked", now).Error)
_, err = db.AuthenticateAccessToken(tokenStr2)
require.ErrorIs(t, err, ErrAccessTokenClientRevoked)

View file

@ -8,9 +8,7 @@ import (
"time"
"github.com/juanfont/headscale/hscontrol/types"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
"tailscale.com/util/rands"
"tailscale.com/util/set"
)
@ -103,42 +101,38 @@ func CreatePreAuthKey(
now := time.Now().UTC()
prefix := rands.HexString(authKeyPrefixLength)
toBeHashed := rands.HexString(authKeyLength)
keyStr := authKeyPrefix + prefix + "-" + toBeHashed
hash, err := bcrypt.GenerateFromPassword([]byte(toBeHashed), bcrypt.DefaultCost)
keyStr, identifier, hash, err := generateSecret(authKeyPrefix)
if err != nil {
return nil, err
}
key := types.PreAuthKey{
// Set only UserID for the write so GORM does not upsert the User row; the
// User is attached afterwards for the returned projection.
cred := types.Credential{
Kind: types.CredentialPreAuthKey,
Identifier: identifier,
Hash: hash,
UserID: userID, // nil for system-created keys, or "created by" for tagged keys
User: user, // nil for system-created keys
Reusable: reusable,
Ephemeral: ephemeral,
CreatedAt: &now,
Expiration: expiration,
Tags: aclTags, // empty for user-owned keys
Prefix: prefix, // Store prefix
Hash: hash, // Store hash
}
if err := tx.Save(&key).Error; err != nil { //nolint:noinlineerr
if err := tx.Save(&cred).Error; err != nil { //nolint:noinlineerr
return nil, fmt.Errorf("creating key in database: %w", err)
}
return &types.PreAuthKeyNew{
ID: key.ID,
ID: cred.ID,
Key: keyStr,
Reusable: key.Reusable,
Ephemeral: key.Ephemeral,
Tags: key.Tags,
Expiration: key.Expiration,
CreatedAt: key.CreatedAt,
User: key.User,
Reusable: cred.Reusable,
Ephemeral: cred.Ephemeral,
Tags: cred.Tags,
Expiration: cred.Expiration,
CreatedAt: cred.CreatedAt,
User: user, // nil for system-created keys
}, nil
}
@ -146,8 +140,8 @@ func CreatePreAuthKey(
// The v2 keys API sets it after creation rather than threading it through the
// many-armed CreatePreAuthKey signature shared by every other caller.
func (hsdb *HSDatabase) SetPreAuthKeyDescription(id uint64, description string) error {
return hsdb.DB.Model(&types.PreAuthKey{}).
Where("id = ?", id).
return hsdb.DB.Model(&types.Credential{}).
Where("kind = ? AND id = ?", types.CredentialPreAuthKey, id).
Update("description", description).Error
}
@ -157,25 +151,39 @@ func (hsdb *HSDatabase) ListPreAuthKeys() ([]types.PreAuthKey, error) {
// ListPreAuthKeys returns all [types.PreAuthKey] values in the database.
func ListPreAuthKeys(tx *gorm.DB) ([]types.PreAuthKey, error) {
var keys []types.PreAuthKey
var creds []types.Credential
err := tx.Preload("User").Find(&keys).Error
err := tx.Preload("User").
Where("kind = ?", types.CredentialPreAuthKey).
Find(&creds).Error
if err != nil {
return nil, err
}
keys := make([]types.PreAuthKey, 0, len(creds))
for i := range creds {
keys = append(keys, *credentialToPreAuthKey(&creds[i]))
}
return keys, nil
}
// ListPreAuthKeysByUser returns all [types.PreAuthKey] values belonging to a specific user.
func ListPreAuthKeysByUser(tx *gorm.DB, uid types.UserID) ([]types.PreAuthKey, error) {
var keys []types.PreAuthKey
var creds []types.Credential
err := tx.Preload("User").Where("user_id = ?", uint(uid)).Find(&keys).Error
err := tx.Preload("User").
Where("kind = ? AND user_id = ?", types.CredentialPreAuthKey, uint(uid)).
Find(&creds).Error
if err != nil {
return nil, err
}
keys := make([]types.PreAuthKey, 0, len(creds))
for i := range creds {
keys = append(keys, *credentialToPreAuthKey(&creds[i]))
}
return keys, nil
}
@ -185,28 +193,22 @@ var (
)
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)
_, prefixAndSecret, found := strings.Cut(keyStr, authKeyPrefix)
if !found {
// Legacy format (plaintext) - backwards compatibility
err := tx.Preload("User").First(&pak, "key = ?", keyStr).Error
if err != nil {
return nil, ErrPreAuthKeyNotFound
}
return &pak, nil
// Legacy plaintext keys (pre-0.30) are no longer supported. An
// unprefixed string is treated as an unknown key so the registration
// handler maps it to a 401.
return nil, ErrPreAuthKeyNotFound
}
// New format: hskey-auth-{12-char-prefix}-{64-char-hash}
prefix, hash, err := parsePrefixedKey(
prefixAndHash,
// New format: hskey-auth-{12-char-prefix}-{64-char-secret}
prefix, secret, err := parsePrefixedKey(
prefixAndSecret,
authKeyPrefixLength,
authKeyLength,
ErrPreAuthKeyFailedToParse,
@ -215,19 +217,24 @@ func findAuthKey(tx *gorm.DB, keyStr string) (*types.PreAuthKey, error) {
return nil, err
}
// Look up key by prefix
err = tx.Preload("User").First(&pak, "prefix = ?", prefix).Error
// Look up key by identifier
var cred types.Credential
err = tx.Preload("User").First(&cred, "kind = ? AND identifier = ?", types.CredentialPreAuthKey, prefix).Error
if err != nil {
return nil, ErrPreAuthKeyNotFound
}
// Verify hash matches
err = bcrypt.CompareHashAndPassword(pak.Hash, []byte(hash))
needsRehash, err := verifySecret(cred.Hash, secret)
if err != nil {
return nil, fmt.Errorf("invalid auth key: %w", err)
}
return &pak, nil
if needsRehash {
rehashToArgon2id(tx, &cred, secret)
}
return credentialToPreAuthKey(&cred), nil
}
// parsePrefixedKey splits the prefix-and-secret portion of a new-format key
@ -317,15 +324,16 @@ func GetPreAuthKey(tx *gorm.DB, key string) (*types.PreAuthKey, error) {
// GetPreAuthKeyByID returns a [types.PreAuthKey] by its primary key, with the
// owning user preloaded.
func (hsdb *HSDatabase) GetPreAuthKeyByID(id uint64) (*types.PreAuthKey, error) {
pak := types.PreAuthKey{}
var cred types.Credential
// Explicit primary-key clause: a struct condition would drop a zero-valued
// ID, making the lookup unconditional and returning the first row instead
// of not-found.
if result := hsdb.DB.Preload("User").First(&pak, "id = ?", id); result.Error != nil {
if result := hsdb.DB.Preload("User").
First(&cred, "kind = ? AND id = ?", types.CredentialPreAuthKey, id); result.Error != nil {
return nil, result.Error
}
return &pak, nil
return credentialToPreAuthKey(&cred), nil
}
// DestroyPreAuthKey destroys a preauthkey. Returns error if the [types.PreAuthKey]
@ -342,7 +350,8 @@ func DestroyPreAuthKey(tx *gorm.DB, id uint64) error {
}
// Then delete the pre-auth key
res := tx.Unscoped().Delete(&types.PreAuthKey{}, id)
res := tx.Unscoped().
Delete(&types.Credential{}, "kind = ? AND id = ?", types.CredentialPreAuthKey, id)
if res.Error != nil {
return res.Error
}
@ -379,8 +388,8 @@ func (hsdb *HSDatabase) RevokePreAuthKey(id uint64) error {
// window. An already-revoked or unknown id returns [ErrPreAuthKeyNotFound], so a
// repeated DELETE is a clean 404.
func RevokePreAuthKey(tx *gorm.DB, id uint64) error {
res := tx.Model(&types.PreAuthKey{}).
Where("id = ? AND revoked IS NULL", id).
res := tx.Model(&types.Credential{}).
Where("kind = ? AND id = ? AND revoked IS NULL", types.CredentialPreAuthKey, id).
Update("revoked", time.Now())
if res.Error != nil {
return res.Error
@ -402,8 +411,8 @@ func (hsdb *HSDatabase) DestroyRevokedPreAuthKeysBefore(cutoff time.Time) (int,
err := hsdb.Write(func(tx *gorm.DB) error {
var ids []uint64
err := tx.Model(&types.PreAuthKey{}).
Where("revoked IS NOT NULL AND revoked < ?", cutoff).
err := tx.Model(&types.Credential{}).
Where("kind = ? AND revoked IS NOT NULL AND revoked < ?", types.CredentialPreAuthKey, cutoff).
Pluck("id", &ids).Error
if err != nil {
return err
@ -431,8 +440,8 @@ func (hsdb *HSDatabase) DestroyRevokedPreAuthKeysBefore(cutoff time.Time) (int,
// guard the previous code (Update("used", true) with no WHERE) would
// silently let both transactions claim the key.
func UsePreAuthKey(tx *gorm.DB, k *types.PreAuthKey) error {
res := tx.Model(&types.PreAuthKey{}).
Where("id = ? AND used = ?", k.ID, false).
res := tx.Model(&types.Credential{}).
Where("kind = ? AND id = ? AND used = ?", types.CredentialPreAuthKey, k.ID, false).
Update("used", true)
if res.Error != nil {
return fmt.Errorf("updating key used status in database: %w", res.Error)
@ -452,7 +461,9 @@ func UsePreAuthKey(tx *gorm.DB, k *types.PreAuthKey) error {
func ExpirePreAuthKey(tx *gorm.DB, id uint64) error {
now := time.Now()
res := tx.Model(&types.PreAuthKey{}).Where("id = ?", id).Update("expiration", now)
res := tx.Model(&types.Credential{}).
Where("kind = ? AND id = ?", types.CredentialPreAuthKey, id).
Update("expiration", now)
if res.Error != nil {
return res.Error
}

View file

@ -1,7 +1,6 @@
package db
import (
"fmt"
"slices"
"strings"
"testing"
@ -11,6 +10,7 @@ import (
"github.com/juanfont/headscale/hscontrol/util"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
)
@ -131,7 +131,8 @@ func TestCannotDeleteAssignedPreAuthKey(t *testing.T) {
}
db.DB.Save(&node)
err = db.DB.Delete(&types.PreAuthKey{ID: key.ID}).Error
err = db.DB.Where("kind = ? AND id = ?", types.CredentialPreAuthKey, key.ID).
Delete(&types.Credential{}).Error
require.ErrorContains(t, err, "constraint failed: FOREIGN KEY constraint failed")
}
@ -149,33 +150,14 @@ func TestPreAuthKeyAuthentication(t *testing.T) {
validateResult func(*testing.T, *types.PreAuthKey)
}{
{
name: "legacy_key_plaintext",
name: "legacy_plaintext_rejected",
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
// Plaintext pre-auth keys (pre-0.30) are no longer supported: a
// key string without the hskey-auth- prefix is treated as unknown.
return "abc123def456ghi789jkl012mno345pqr678stu901vwx234yz"
},
wantFindErr: false,
wantFindErr: true,
wantValidateErr: false,
validateResult: func(t *testing.T, pak *types.PreAuthKey) {
t.Helper()
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",
@ -308,43 +290,6 @@ func TestPreAuthKeyAuthentication(t *testing.T) {
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 {
@ -376,74 +321,30 @@ func TestPreAuthKeyAuthentication(t *testing.T) {
}
}
func TestMultipleLegacyKeysAllowed(t *testing.T) {
// TestPreAuthKeysHaveUniqueIdentifiers verifies that freshly created pre-auth
// keys get distinct identifiers in the unified credentials table.
func TestPreAuthKeysHaveUniqueIdentifiers(t *testing.T) {
db, err := newSQLiteTestDB()
require.NoError(t, err)
user, err := db.CreateUser(types.User{Name: "test-legacy"})
user, err := db.CreateUser(types.User{Name: "test-unique"})
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(user.TypedID(), true, false, nil, nil)
require.NoError(t, err)
assert.NotEmpty(t, key1.Key)
key2, err := db.CreatePreAuthKey(user.TypedID(), 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, pak1.Prefix)
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
require.Error(t, err, "duplicate non-empty prefix should be rejected")
assert.Contains(t, err.Error(), "UNIQUE constraint failed", "should fail with UNIQUE constraint error")
assert.NotEqual(t, pak1.Prefix, pak2.Prefix, "new keys should have unique identifiers")
}
// TestUsePreAuthKeyAtomicCAS verifies that UsePreAuthKey is an atomic
@ -500,3 +401,40 @@ func TestGetPreAuthKeyUnknownMapsToRecordNotFound(t *testing.T) {
require.ErrorIs(t, err, gorm.ErrRecordNotFound,
"unknown pre-auth key must map to record-not-found (handled as 401)")
}
// TestPreAuthKeyLazyRehashesBcrypt seeds a new-format key whose secret is stored
// as a legacy bcrypt hash and asserts that authenticating it upgrades the stored
// hash to argon2id, while continuing to authenticate.
func TestPreAuthKeyLazyRehashesBcrypt(t *testing.T) {
db, err := newSQLiteTestDB()
require.NoError(t, err)
user := db.CreateUserForTest("rehash-user")
prefix := "abcdefghijkl"
secret := strings.Repeat("b", 64)
keyStr := "hskey-auth-" + prefix + "-" + secret
hash, err := bcrypt.GenerateFromPassword([]byte(secret), bcrypt.DefaultCost)
require.NoError(t, err)
err = db.DB.Exec(
`INSERT INTO credentials (kind, identifier, hash, user_id, reusable, ephemeral, used, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
types.CredentialPreAuthKey, prefix, hash, user.ID, true, false, false, time.Now(),
).Error
require.NoError(t, err)
pak, err := db.GetPreAuthKey(keyStr)
require.NoError(t, err)
require.NotNil(t, pak)
reloaded, err := db.GetPreAuthKeyByID(pak.ID)
require.NoError(t, err)
assert.True(t, strings.HasPrefix(string(reloaded.Hash), "$argon2id$"),
"a bcrypt-stored key must be rehashed to argon2id on first auth")
// Still authenticates against the upgraded hash.
_, err = db.GetPreAuthKey(keyStr)
require.NoError(t, err)
}

View file

@ -38,67 +38,31 @@ CREATE UNIQUE INDEX idx_provider_identifier ON users(provider_identifier) WHERE
CREATE UNIQUE INDEX idx_name_provider_identifier ON users(name, provider_identifier);
CREATE UNIQUE INDEX idx_name_no_provider_identifier ON users(name) WHERE provider_identifier IS NULL;
CREATE TABLE pre_auth_keys(
-- Unified store for every authenticatable secret (API keys, pre-auth keys,
-- OAuth clients and access tokens), discriminated by kind. The secret is stored
-- only as an Argon2id hash; identifier is the public lookup value, unique within
-- a kind. Per-kind columns are sparse by design.
CREATE TABLE credentials(
id integer PRIMARY KEY AUTOINCREMENT,
key text,
prefix text,
kind text,
identifier text,
hash blob,
user_id integer,
description text,
scopes text,
tags text,
reusable numeric,
ephemeral numeric DEFAULT false,
used numeric DEFAULT false,
tags text,
last_seen datetime,
client_id text,
created_at datetime,
expiration datetime,
revoked datetime,
created_at datetime,
CONSTRAINT fk_pre_auth_keys_user FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE SET NULL
CONSTRAINT fk_credentials_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,
prefix text,
hash blob,
user_id integer,
expiration datetime,
last_seen datetime,
created_at datetime
);
CREATE UNIQUE INDEX idx_api_keys_prefix ON api_keys(prefix);
-- OAuth 2.0 client-credentials clients for the v2 API. client_id is public and
-- embedded in the secret (hskey-client-<client_id>-<secret>); only the bcrypt
-- hash of the secret is stored. Mirrors the api_keys security model.
CREATE TABLE oauth_clients(
id integer PRIMARY KEY AUTOINCREMENT,
client_id text,
secret_hash blob,
scopes text,
tags text,
description text,
user_id integer,
created_at datetime,
revoked datetime
);
CREATE UNIQUE INDEX idx_oauth_clients_client_id ON oauth_clients(client_id);
-- Short-lived bearer access tokens minted by an oauth_client. Stored as a bcrypt
-- hash of the secret, looked up by prefix.
CREATE TABLE oauth_access_tokens(
id integer PRIMARY KEY AUTOINCREMENT,
prefix text,
hash blob,
client_id text,
scopes text,
tags text,
expiration datetime,
created_at datetime
);
CREATE UNIQUE INDEX idx_oauth_access_tokens_prefix ON oauth_access_tokens(prefix);
CREATE UNIQUE INDEX idx_credentials_identifier ON credentials(kind, identifier);
CREATE TABLE nodes(
id integer PRIMARY KEY AUTOINCREMENT,
@ -127,7 +91,7 @@ CREATE TABLE nodes(
deleted_at datetime,
CONSTRAINT fk_nodes_user FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE,
CONSTRAINT fk_nodes_auth_key FOREIGN KEY(auth_key_id) REFERENCES pre_auth_keys(id)
CONSTRAINT fk_nodes_auth_key FOREIGN KEY(auth_key_id) REFERENCES credentials(id)
);
CREATE TABLE policies(

169
hscontrol/db/secret.go Normal file
View file

@ -0,0 +1,169 @@
package db
import (
"bytes"
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"errors"
"fmt"
"runtime"
"strings"
"golang.org/x/crypto/argon2"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
"tailscale.com/util/rands"
)
// Every credential is a "hskey-<kind>-<identifier(12)>-<secret(64)>" string: the
// identifier is the public, indexed lookup key and only the secret is hashed.
const (
keyIdentifierLength = 12
keySecretLength = 64
)
// Argon2id parameters, OWASP's minimum recommendation (19 MiB, 2 iterations, 1
// lane). They are encoded into every stored hash, so raising them later still
// verifies credentials stored under the old cost.
const (
argon2Time = 2
argon2Memory = 19 * 1024
argon2Threads = 1
argon2KeyLen = 32
argon2SaltLen = 16
)
var (
errSecretHashMalformed = errors.New("malformed secret hash")
errSecretMismatch = errors.New("secret does not match hash")
)
// argon2Limiter bounds concurrent Argon2id computations. Each costs ~19 MiB and
// the unauthenticated OAuth token endpoint runs one per attempt, so an unbounded
// flood could exhaust memory. A global semaphore sized to GOMAXPROCS;
// TODO(kradalby): revisit only if credential hashing becomes a throughput bottleneck.
var argon2Limiter = make(chan struct{}, max(2, runtime.GOMAXPROCS(0)))
// generateSecret builds a new credential string of the form
// prefix+identifier+"-"+secret, returning the full string (shown ONCE to the
// user), the public identifier used for lookup, and the Argon2id hash of the
// secret to store. It is the single generator for every credential kind.
func generateSecret(prefix string) (string, string, []byte, error) {
identifier := rands.HexString(keyIdentifierLength)
secret := rands.HexString(keySecretLength)
full := prefix + identifier + "-" + secret
hash, err := hashSecret(secret)
if err != nil {
return "", "", nil, err
}
return full, identifier, hash, nil
}
// rehashToArgon2id upgrades a credential's stored hash to Argon2id after a
// successful verify reported the secret was still under legacy bcrypt. It is
// best effort: a failed upgrade leaves the bcrypt hash in place, so the next
// authentication simply tries again. model must be a pointer to a credential
// row with its primary key set and a "hash" column.
func rehashToArgon2id(db *gorm.DB, model any, secret string) {
hash, err := hashSecret(secret)
if err != nil {
return
}
_ = db.Model(model).Update("hash", hash).Error
}
// hashSecret hashes a credential secret with Argon2id, encoded in PHC string
// form so the parameters travel with the hash. Argon2id is the current OWASP
// recommendation, replacing bcrypt for new credential storage.
func hashSecret(secret string) ([]byte, error) {
salt := make([]byte, argon2SaltLen)
_, err := rand.Read(salt)
if err != nil {
return nil, fmt.Errorf("generating salt: %w", err)
}
hash := argon2.IDKey([]byte(secret), salt, argon2Time, argon2Memory, argon2Threads, argon2KeyLen)
encoded := fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
argon2.Version, argon2Memory, argon2Time, argon2Threads,
base64.RawStdEncoding.EncodeToString(salt),
base64.RawStdEncoding.EncodeToString(hash),
)
return []byte(encoded), nil
}
// verifySecret reports whether secret matches a stored hash, transparently
// accepting both the current Argon2id PHC form and legacy bcrypt hashes. A
// matched bcrypt hash returns needsRehash=true so the caller can upgrade the
// stored hash to Argon2id on the next successful authentication.
//
// TODO(kradalby): remove the bcrypt branch in 0.32; any credential not rehashed
// by then stops authenticating (pre-announced breaking change).
func verifySecret(encoded []byte, secret string) (bool, error) {
if bytes.HasPrefix(encoded, []byte("$argon2id$")) {
return false, verifyArgon2id(encoded, secret)
}
// Legacy bcrypt hash: a clean compare means the secret is valid but stored
// under the old algorithm, so request a rehash. A mismatch is a wrong
// secret; any other bcrypt error means the stored hash is not a usable hash.
switch err := bcrypt.CompareHashAndPassword(encoded, []byte(secret)); {
case err == nil:
return true, nil
case errors.Is(err, bcrypt.ErrMismatchedHashAndPassword):
return false, errSecretMismatch
default:
return false, errSecretHashMalformed
}
}
// verifyArgon2id reads the cost parameters from the stored hash and compares in
// constant time so a mismatch leaks no timing signal.
func verifyArgon2id(encoded []byte, secret string) error {
parts := strings.Split(string(encoded), "$")
if len(parts) != 6 || parts[1] != "argon2id" {
return errSecretHashMalformed
}
var version int
if _, err := fmt.Sscanf(parts[2], "v=%d", &version); err != nil || version != argon2.Version { //nolint:noinlineerr
return errSecretHashMalformed
}
var (
memory, time uint32
threads uint8
)
if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &memory, &time, &threads); err != nil { //nolint:noinlineerr
return errSecretHashMalformed
}
salt, err := base64.RawStdEncoding.DecodeString(parts[4])
if err != nil {
return errSecretHashMalformed
}
want, err := base64.RawStdEncoding.DecodeString(parts[5])
if err != nil {
return errSecretHashMalformed
}
argon2Limiter <- struct{}{}
//nolint:gosec // want is a 32-byte hash read back from storage, no overflow
got := argon2.IDKey([]byte(secret), salt, time, memory, threads, uint32(len(want)))
<-argon2Limiter
if subtle.ConstantTimeCompare(got, want) != 1 {
return errSecretMismatch
}
return nil
}

View file

@ -0,0 +1,58 @@
package db
import (
"strings"
"testing"
"github.com/stretchr/testify/require"
"golang.org/x/crypto/bcrypt"
)
// TestVerifySecretArgon2idNoRehash verifies that an Argon2id-stored secret
// matches and reports no rehash needed.
func TestVerifySecretArgon2idNoRehash(t *testing.T) {
hash, err := hashSecret("s3cr3t")
require.NoError(t, err)
needsRehash, err := verifySecret(hash, "s3cr3t")
require.NoError(t, err)
require.False(t, needsRehash, "argon2id hash should never need a rehash")
}
// TestVerifySecretBcryptNeedsRehash verifies that a legacy bcrypt-stored secret
// still authenticates and is flagged for lazy rehash to Argon2id.
func TestVerifySecretBcryptNeedsRehash(t *testing.T) {
hash, err := bcrypt.GenerateFromPassword([]byte("s3cr3t"), bcrypt.DefaultCost)
require.NoError(t, err)
needsRehash, err := verifySecret(hash, "s3cr3t")
require.NoError(t, err)
require.True(t, needsRehash, "a matched bcrypt hash must request a rehash to argon2id")
_, err = verifySecret(hash, "wrong")
require.ErrorIs(t, err, errSecretMismatch)
}
// TestVerifySecretMalformed verifies that a hash in no recognised format is
// rejected rather than silently accepted.
func TestVerifySecretMalformed(t *testing.T) {
_, err := verifySecret([]byte("not-a-hash"), "s3cr3t")
require.Error(t, err)
}
// TestGenerateSecret verifies the unified key generation: hskey-<prefix><id>-<secret>,
// a 12-char identifier, a 64-char secret, and an argon2id hash of the secret.
func TestGenerateSecret(t *testing.T) {
full, identifier, hash, err := generateSecret("hskey-test-")
require.NoError(t, err)
require.Len(t, identifier, 12)
require.True(t, strings.HasPrefix(full, "hskey-test-"+identifier+"-"))
secret := strings.TrimPrefix(full, "hskey-test-"+identifier+"-")
require.Len(t, secret, 64)
needsRehash, err := verifySecret(hash, secret)
require.NoError(t, err)
require.False(t, needsRehash)
}

View file

@ -55,10 +55,10 @@ func TestDestroyUserErrors(t *testing.T) {
err = db.DestroyUser(types.UserID(user.ID))
require.NoError(t, err)
// Verify preauth key was deleted (need to search by prefix for new keys)
var foundPak types.PreAuthKey
// Verify preauth key credential was deleted.
var foundPak types.Credential
result := db.DB.First(&foundPak, "id = ?", pak.ID)
result := db.DB.First(&foundPak, "kind = ? AND id = ?", types.CredentialPreAuthKey, pak.ID)
assert.ErrorIs(t, result.Error, gorm.ErrRecordNotFound)
},
},

View file

@ -103,7 +103,7 @@ func TestTailNode(t *testing.T) {
Name: "mini",
},
Tags: []string{},
AuthKey: &types.PreAuthKey{},
AuthKey: &types.Credential{},
LastSeen: &lastSeen,
Expiry: &expire,
Hostinfo: &tailcfg.Hostinfo{

View file

@ -2476,7 +2476,7 @@ func TestResolvePolicy(t *testing.T) {
},
// not matching pak tag
{
AuthKey: &types.PreAuthKey{
AuthKey: &types.Credential{
Tags: []string{"tag:alsotagged"},
},
IPv4: ap("100.100.101.11"),

View file

@ -204,9 +204,8 @@ func TestPersistNodeToDBPreventsRaceCondition(t *testing.T) {
// the node could be re-inserted into the database even though it was deleted
func TestEphemeralNodeLogoutRaceCondition(t *testing.T) {
ephemeralNode := createTestNode(4, 1, "test-user", "ephemeral-node")
ephemeralNode.AuthKey = &types.PreAuthKey{
ephemeralNode.AuthKey = &types.Credential{
ID: 1,
Key: "test-key",
Ephemeral: true,
}
@ -284,9 +283,8 @@ func TestEphemeralNodeLogoutRaceCondition(t *testing.T) {
// 8. Node gets re-inserted into database instead of staying deleted.
func TestUpdateNodeFromMapRequestEphemeralLogoutSequence(t *testing.T) {
ephemeralNode := createTestNode(5, 1, "test-user", "ephemeral-node-5")
ephemeralNode.AuthKey = &types.PreAuthKey{
ephemeralNode.AuthKey = &types.Credential{
ID: 2,
Key: "test-key-2",
Ephemeral: true,
}
@ -418,9 +416,8 @@ func TestUpdateNodeDeletedInSameBatchReturnsInvalid(t *testing.T) {
// 6. persistNodeToDB must detect the node is deleted and refuse to persist.
func TestPersistNodeToDBChecksNodeStoreBeforePersist(t *testing.T) {
ephemeralNode := createTestNode(7, 1, "test-user", "ephemeral-node-7")
ephemeralNode.AuthKey = &types.PreAuthKey{
ephemeralNode.AuthKey = &types.Credential{
ID: 3,
Key: "test-key-3",
Ephemeral: true,
}

View file

@ -1905,7 +1905,7 @@ func (s *State) createAndSaveNewNode(params newNodeParams) (types.NodeView, erro
nodeToRegister.Tags = nil
}
nodeToRegister.AuthKey = params.PreAuthKey
nodeToRegister.AuthKey = params.PreAuthKey.AsCredential()
nodeToRegister.AuthKeyID = &params.PreAuthKey.ID
} else {
// Non-PreAuthKey registration (OIDC, CLI) - always user-owned
@ -1986,7 +1986,10 @@ func (s *State) createAndSaveNewNode(params newNodeParams) (types.NodeView, erro
// New node - database first to get ID, then [NodeStore]
savedNode, err := hsdb.Write(s.db.DB, func(tx *gorm.DB) (*types.Node, error) {
err := tx.Save(&nodeToRegister).Error
// Omit the AuthKey association: only auth_key_id is persisted here, the
// credential row is owned by the credential CRUD and must not be
// upserted from this node's (possibly stale) in-memory copy (#2862).
err := tx.Omit("AuthKey").Save(&nodeToRegister).Error
if err != nil {
return nil, fmt.Errorf("saving node: %w", err)
}
@ -1996,6 +1999,10 @@ func (s *State) createAndSaveNewNode(params newNodeParams) (types.NodeView, erro
if err != nil {
return nil, fmt.Errorf("using pre auth key: %w", err)
}
// UsePreAuthKey marked the key used; refresh the node's in-memory
// AuthKey so the NodeStore copy matches the database.
nodeToRegister.AuthKey = params.PreAuthKey.AsCredential()
}
return &nodeToRegister, nil
@ -2534,8 +2541,14 @@ func (s *State) HandleNodeFromPreAuthKey(
node.Expiry = nil
}
node.AuthKey = pak
node.AuthKey = pak.AsCredential()
node.AuthKeyID = &pak.ID
// If this registration will consume a single-use key (the tx below
// calls UsePreAuthKey under the same condition), reflect that in the
// cached AuthKey so the NodeStore copy matches the database.
if !pak.Reusable && !pak.Used {
node.AuthKey.Used = true
}
// Do NOT reset IsOnline here. Online status is managed exclusively by
// [State.Connect]/[State.Disconnect] in the poll session lifecycle.
// Resetting it during re-registration causes a false offline blip

View file

@ -1,7 +1,7 @@
//go:generate go tool viewer --type=User,Node,PreAuthKey
//go:generate go tool viewer --type=User,Node,PreAuthKey,Credential
package types
//go:generate go run tailscale.com/cmd/viewer --type=User,Node,PreAuthKey
//go:generate go run tailscale.com/cmd/viewer --type=User,Node,PreAuthKey,Credential
import (
"errors"

View file

@ -0,0 +1,93 @@
package types
import (
"time"
)
// Credential kinds. Every authenticatable secret in headscale is stored in the
// single credentials table, discriminated by Kind.
const (
CredentialAPIKey = "api"
CredentialPreAuthKey = "authkey"
CredentialOAuthClient = "oauth_client" //nolint:gosec // discriminator value, not a credential
CredentialOAuthToken = "oauth_token" //nolint:gosec // discriminator value, not a credential
)
// Credential is the unified storage model for every authenticatable secret:
// API keys, pre-auth keys, OAuth clients, and OAuth access tokens. Each row is
// discriminated by [Credential.Kind]. The secret is stored only as an Argon2id
// hash (legacy rows may still hold a bcrypt hash until first re-authentication).
// Identifier is the public, indexed lookup value — the 12-char prefix for API
// keys, pre-auth keys and access tokens, and the client id for OAuth clients —
// and is unique within a kind.
//
// Per-kind fields are sparse by design: Reusable/Ephemeral/Used apply to
// pre-auth keys, LastSeen to API keys, Scopes to OAuth credentials, ClientID
// links an OAuth token to its issuing client, and Tags to pre-auth keys and
// OAuth credentials.
type Credential struct {
ID uint64 `gorm:"primary_key"`
Kind string `gorm:"index:idx_credentials_identifier,unique,priority:1"`
Identifier string `gorm:"index:idx_credentials_identifier,unique,priority:2"`
Hash []byte
// UserID records the owning or creating user. Kept as a plain column with no
// foreign key for API/OAuth kinds; pre-auth keys keep the user association.
UserID *uint
User *User `gorm:"constraint:OnDelete:SET NULL;"`
Description string
Scopes []string `gorm:"serializer:json"`
Tags []string `gorm:"serializer:json"`
Reusable bool
Ephemeral bool `gorm:"default:false"`
Used bool `gorm:"default:false"`
LastSeen *time.Time
// ClientID links an OAuth access token (Kind == CredentialOAuthToken) back to
// the Identifier of its issuing OAuth client.
ClientID string
CreatedAt *time.Time
Expiration *time.Time
Revoked *time.Time
}
// TableName pins the table name so GORM's naming strategy does not pluralise it
// unexpectedly and so it matches the hand-written migration DDL and schema.sql.
func (*Credential) TableName() string { return "credentials" }
// IsTagged reports whether this credential carries tags. For a pre-auth key
// credential, a node registered with it becomes a tagged node.
func (c *Credential) IsTagged() bool {
return len(c.Tags) > 0
}
// AsCredential projects a pre-auth key back onto a [Credential] (kind
// authkey), used to set a node's AuthKey association during registration from a
// pre-auth key projection.
func (pak *PreAuthKey) AsCredential() *Credential {
if pak == nil {
return nil
}
return &Credential{
ID: pak.ID,
Kind: CredentialPreAuthKey,
Identifier: pak.Prefix,
Hash: pak.Hash,
UserID: pak.UserID,
User: pak.User,
Description: pak.Description,
Reusable: pak.Reusable,
Ephemeral: pak.Ephemeral,
Used: pak.Used,
Tags: pak.Tags,
CreatedAt: pak.CreatedAt,
Expiration: pak.Expiration,
Revoked: pak.Revoked,
}
}

View file

@ -157,12 +157,12 @@ type Node struct {
// Tags cannot be removed once set (one-way transition).
Tags Strings `gorm:"column:tags;serializer:json"`
// When a node has been created with a [PreAuthKey], we need to
// prevent the preauthkey from being deleted before the node.
// The preauthkey can define "tags" of the node so we need it
// around.
// When a node has been created with a pre-auth key, we keep the key
// credential around: it can define the node's tags and must not be deleted
// before the node. The association is to the unified [Credential] (kind
// authkey), which is what auth_key_id references.
AuthKeyID *uint64 `sql:"DEFAULT:NULL"`
AuthKey *PreAuthKey
AuthKey *Credential
Expiry *time.Time

View file

@ -49,7 +49,7 @@ func TestNodeIsTagged(t *testing.T) {
// [Node.IsTagged] only checks [Node.Tags], not [PreAuthKey.Tags].
name: "node registered with tagged authkey only - not tagged (tags should be copied)",
node: Node{
AuthKey: &PreAuthKey{
AuthKey: &Credential{
Tags: []string{"tag:database"},
},
},
@ -59,7 +59,7 @@ func TestNodeIsTagged(t *testing.T) {
name: "node with both tags and authkey tags - is tagged",
node: Node{
Tags: []string{"tag:server"},
AuthKey: &PreAuthKey{
AuthKey: &Credential{
Tags: []string{"tag:database"},
},
},
@ -102,7 +102,7 @@ func TestNodeViewIsTagged(t *testing.T) {
// with only [PreAuthKey.Tags] and no [Node.Tags] would be invalid in practice.
name: "node with only AuthKey tags - not tagged (tags should be copied)",
node: Node{
AuthKey: &PreAuthKey{
AuthKey: &Credential{
Tags: []string{"tag:web"},
},
},
@ -155,7 +155,7 @@ func TestNodeHasTag(t *testing.T) {
// [Node.HasTag] only checks [Node.Tags], not [PreAuthKey.Tags]
name: "node has tag only in authkey - returns false",
node: Node{
AuthKey: &PreAuthKey{
AuthKey: &Credential{
Tags: []string{"tag:database"},
},
},
@ -167,7 +167,7 @@ func TestNodeHasTag(t *testing.T) {
name: "node has tag in Tags but not in AuthKey",
node: Node{
Tags: []string{"tag:server"},
AuthKey: &PreAuthKey{
AuthKey: &Credential{
Tags: []string{"tag:database"},
},
},
@ -206,7 +206,7 @@ func TestNodeTagsImmutableAfterRegistration(t *testing.T) {
taggedNode := Node{
ID: 1,
Tags: []string{"tag:server"},
AuthKey: &PreAuthKey{
AuthKey: &Credential{
Tags: []string{"tag:server"},
},
RegisterMethod: util.RegisterMethodAuthKey,
@ -265,7 +265,7 @@ func TestNodeOwnershipModel(t *testing.T) {
node: Node{
ID: 3,
UserID: new(uint(5)), // "created by" user 5
AuthKey: &PreAuthKey{
AuthKey: &Credential{
Tags: []string{"tag:database"},
},
},

View file

@ -97,7 +97,7 @@ var _NodeCloneNeedsRegeneration = Node(struct {
RegisterMethod string
Tags Strings
AuthKeyID *uint64
AuthKey *PreAuthKey
AuthKey *Credential
Expiry *time.Time
LastSeen *time.Time
ApprovedRoutes Prefixes
@ -155,3 +155,56 @@ var _PreAuthKeyCloneNeedsRegeneration = PreAuthKey(struct {
Expiration *time.Time
Revoked *time.Time
}{})
// Clone makes a deep copy of Credential.
// The result aliases no memory with the original.
func (src *Credential) Clone() *Credential {
if src == nil {
return nil
}
dst := new(Credential)
*dst = *src
dst.Hash = append(src.Hash[:0:0], src.Hash...)
if dst.UserID != nil {
dst.UserID = new(*src.UserID)
}
if dst.User != nil {
dst.User = new(*src.User)
}
dst.Scopes = append(src.Scopes[:0:0], src.Scopes...)
dst.Tags = append(src.Tags[:0:0], src.Tags...)
if dst.LastSeen != nil {
dst.LastSeen = new(*src.LastSeen)
}
if dst.CreatedAt != nil {
dst.CreatedAt = new(*src.CreatedAt)
}
if dst.Expiration != nil {
dst.Expiration = new(*src.Expiration)
}
if dst.Revoked != nil {
dst.Revoked = new(*src.Revoked)
}
return dst
}
// A compilation failure here means this code must be regenerated, with the command at the top of this file.
var _CredentialCloneNeedsRegeneration = Credential(struct {
ID uint64
Kind string
Identifier string
Hash []byte
UserID *uint
User *User
Description string
Scopes []string
Tags []string
Reusable bool
Ephemeral bool
Used bool
LastSeen *time.Time
ClientID string
CreatedAt *time.Time
Expiration *time.Time
Revoked *time.Time
}{})

View file

@ -20,7 +20,7 @@ import (
"tailscale.com/types/views"
)
//go:generate go run tailscale.com/cmd/cloner -clonefunc=false -type=User,Node,PreAuthKey
//go:generate go run tailscale.com/cmd/cloner -clonefunc=false -type=User,Node,PreAuthKey,Credential
// View returns a read-only view of User.
func (p *User) View() UserView {
@ -230,13 +230,13 @@ func (v NodeView) RegisterMethod() string { return v.ж.RegisterMethod }
// Tags cannot be removed once set (one-way transition).
func (v NodeView) Tags() views.Slice[string] { return views.SliceOf(v.ж.Tags) }
// When a node has been created with a [PreAuthKey], we need to
// prevent the preauthkey from being deleted before the node.
// The preauthkey can define "tags" of the node so we need it
// around.
// When a node has been created with a pre-auth key, we keep the key
// credential around: it can define the node's tags and must not be deleted
// before the node. The association is to the unified [Credential] (kind
// authkey), which is what auth_key_id references.
func (v NodeView) AuthKeyID() views.ValuePointer[uint64] { return views.ValuePointerOf(v.ж.AuthKeyID) }
func (v NodeView) AuthKey() PreAuthKeyView { return v.ж.AuthKey.View() }
func (v NodeView) AuthKey() CredentialView { return v.ж.AuthKey.View() }
func (v NodeView) Expiry() views.ValuePointer[time.Time] { return views.ValuePointerOf(v.ж.Expiry) }
// LastSeen is when the node was last in contact with
@ -299,7 +299,7 @@ var _NodeViewNeedsRegeneration = Node(struct {
RegisterMethod string
Tags Strings
AuthKeyID *uint64
AuthKey *PreAuthKey
AuthKey *Credential
Expiry *time.Time
LastSeen *time.Time
ApprovedRoutes Prefixes
@ -440,3 +440,126 @@ var _PreAuthKeyViewNeedsRegeneration = PreAuthKey(struct {
Expiration *time.Time
Revoked *time.Time
}{})
// View returns a read-only view of Credential.
func (p *Credential) View() CredentialView {
return CredentialView{ж: p}
}
// CredentialView provides a read-only view over Credential.
//
// Its methods should only be called if `Valid()` returns true.
type CredentialView struct {
// ж is the underlying mutable value, named with a hard-to-type
// character that looks pointy like a pointer.
// It is named distinctively to make you think of how dangerous it is to escape
// to callers. You must not let callers be able to mutate it.
ж *Credential
}
// Valid reports whether v's underlying value is non-nil.
func (v CredentialView) Valid() bool { return v.ж != nil }
// AsStruct returns a clone of the underlying value which aliases no memory with
// the original.
func (v CredentialView) AsStruct() *Credential {
if v.ж == nil {
return nil
}
return v.ж.Clone()
}
// MarshalJSON implements [jsonv1.Marshaler].
func (v CredentialView) MarshalJSON() ([]byte, error) {
return jsonv1.Marshal(v.ж)
}
// MarshalJSONTo implements [jsonv2.MarshalerTo].
func (v CredentialView) MarshalJSONTo(enc *jsontext.Encoder) error {
return jsonv2.MarshalEncode(enc, v.ж)
}
// UnmarshalJSON implements [jsonv1.Unmarshaler].
func (v *CredentialView) UnmarshalJSON(b []byte) error {
if v.ж != nil {
return errors.New("already initialized")
}
if len(b) == 0 {
return nil
}
var x Credential
if err := jsonv1.Unmarshal(b, &x); err != nil {
return err
}
v.ж = &x
return nil
}
// UnmarshalJSONFrom implements [jsonv2.UnmarshalerFrom].
func (v *CredentialView) UnmarshalJSONFrom(dec *jsontext.Decoder) error {
if v.ж != nil {
return errors.New("already initialized")
}
var x Credential
if err := jsonv2.UnmarshalDecode(dec, &x); err != nil {
return err
}
v.ж = &x
return nil
}
func (v CredentialView) ID() uint64 { return v.ж.ID }
func (v CredentialView) Kind() string { return v.ж.Kind }
func (v CredentialView) Identifier() string { return v.ж.Identifier }
func (v CredentialView) Hash() views.ByteSlice[[]byte] { return views.ByteSliceOf(v.ж.Hash) }
// UserID records the owning or creating user. Kept as a plain column with no
// foreign key for API/OAuth kinds; pre-auth keys keep the user association.
func (v CredentialView) UserID() views.ValuePointer[uint] { return views.ValuePointerOf(v.ж.UserID) }
func (v CredentialView) User() UserView { return v.ж.User.View() }
func (v CredentialView) Description() string { return v.ж.Description }
func (v CredentialView) Scopes() views.Slice[string] { return views.SliceOf(v.ж.Scopes) }
func (v CredentialView) Tags() views.Slice[string] { return views.SliceOf(v.ж.Tags) }
func (v CredentialView) Reusable() bool { return v.ж.Reusable }
func (v CredentialView) Ephemeral() bool { return v.ж.Ephemeral }
func (v CredentialView) Used() bool { return v.ж.Used }
func (v CredentialView) LastSeen() views.ValuePointer[time.Time] {
return views.ValuePointerOf(v.ж.LastSeen)
}
// ClientID links an OAuth access token (Kind == CredentialOAuthToken) back to
// the Identifier of its issuing OAuth client.
func (v CredentialView) ClientID() string { return v.ж.ClientID }
func (v CredentialView) CreatedAt() views.ValuePointer[time.Time] {
return views.ValuePointerOf(v.ж.CreatedAt)
}
func (v CredentialView) Expiration() views.ValuePointer[time.Time] {
return views.ValuePointerOf(v.ж.Expiration)
}
func (v CredentialView) Revoked() views.ValuePointer[time.Time] {
return views.ValuePointerOf(v.ж.Revoked)
}
// A compilation failure here means this code must be regenerated, with the command at the top of this file.
var _CredentialViewNeedsRegeneration = Credential(struct {
ID uint64
Kind string
Identifier string
Hash []byte
UserID *uint
User *User
Description string
Scopes []string
Tags []string
Reusable bool
Ephemeral bool
Used bool
LastSeen *time.Time
ClientID string
CreatedAt *time.Time
Expiration *time.Time
Revoked *time.Time
}{})

View file

@ -72,11 +72,16 @@ func TestApiKeyCommand(t *testing.T) {
assert.Len(t, listedAPIKeys, 5)
assert.Equal(t, "1", listedAPIKeys[0].Id)
assert.Equal(t, "2", listedAPIKeys[1].Id)
assert.Equal(t, "3", listedAPIKeys[2].Id)
assert.Equal(t, "4", listedAPIKeys[3].Id)
assert.Equal(t, "5", listedAPIKeys[4].Id)
// IDs are drawn from the shared credentials table, so they are not
// guaranteed to start at 1 (pre-auth keys created during env setup take the
// first ids). Assert the five keys have distinct, non-empty ids instead.
seenIDs := make(map[string]bool)
for _, key := range listedAPIKeys {
assert.NotEmpty(t, key.Id)
assert.False(t, seenIDs[key.Id], "duplicate API key id %s", key.Id)
seenIDs[key.Id] = true
}
assert.NotEmpty(t, listedAPIKeys[0].Prefix)
assert.NotEmpty(t, listedAPIKeys[1].Prefix)