diff --git a/CHANGELOG.md b/CHANGELOG.md index 785d7d855..f5f35c767 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/hscontrol/api/v1/nodes.go b/hscontrol/api/v1/nodes.go index dbc1adfc5..c3088a013 100644 --- a/hscontrol/api/v1/nodes.go +++ b/hscontrol/api/v1/nodes.go @@ -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(), diff --git a/hscontrol/auth_test.go b/hscontrol/auth_test.go index ed294f3ca..605dd87e0 100644 --- a/hscontrol/auth_test.go +++ b/hscontrol/auth_test.go @@ -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") diff --git a/hscontrol/db/api_key.go b/hscontrol/db/api_key.go index b56f1c188..d241d7c25 100644 --- a/hscontrol/db/api_key.go +++ b/hscontrol/db/api_key.go @@ -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 } diff --git a/hscontrol/db/api_key_test.go b/hscontrol/db/api_key_test.go index a34dd94bb..e97c5af7b 100644 --- a/hscontrol/db/api_key_test.go +++ b/hscontrol/db/api_key_test.go @@ -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") +} diff --git a/hscontrol/db/credential.go b/hscontrol/db/credential.go new file mode 100644 index 000000000..979a5817d --- /dev/null +++ b/hscontrol/db/credential.go @@ -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, + } +} diff --git a/hscontrol/db/credential_test.go b/hscontrol/db/credential_test.go new file mode 100644 index 000000000..d51d82d20 --- /dev/null +++ b/hscontrol/db/credential_test.go @@ -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") +} diff --git a/hscontrol/db/db.go b/hscontrol/db/db.go index c9b94ef6c..07c370e32 100644 --- a/hscontrol/db/db.go +++ b/hscontrol/db/db.go @@ -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 { diff --git a/hscontrol/db/db_test.go b/hscontrol/db/db_test.go index 88d282c46..23f99b18d 100644 --- a/hscontrol/db/db_test.go +++ b/hscontrol/db/db_test.go @@ -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") diff --git a/hscontrol/db/migrate_credentials.go b/hscontrol/db/migrate_credentials.go new file mode 100644 index 000000000..f82568068 --- /dev/null +++ b/hscontrol/db/migrate_credentials.go @@ -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 +} diff --git a/hscontrol/db/oauth.go b/hscontrol/db/oauth.go index 6ce757bc7..d957f1fa8 100644 --- a/hscontrol/db/oauth.go +++ b/hscontrol/db/oauth.go @@ -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 } diff --git a/hscontrol/db/oauth_test.go b/hscontrol/db/oauth_test.go index 1bf8c72d4..20caab232 100644 --- a/hscontrol/db/oauth_test.go +++ b/hscontrol/db/oauth_test.go @@ -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) diff --git a/hscontrol/db/preauth_keys.go b/hscontrol/db/preauth_keys.go index e839ff507..b8d806d54 100644 --- a/hscontrol/db/preauth_keys.go +++ b/hscontrol/db/preauth_keys.go @@ -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 } diff --git a/hscontrol/db/preauth_keys_test.go b/hscontrol/db/preauth_keys_test.go index 5835758f3..256c1879a 100644 --- a/hscontrol/db/preauth_keys_test.go +++ b/hscontrol/db/preauth_keys_test.go @@ -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) +} diff --git a/hscontrol/db/schema.sql b/hscontrol/db/schema.sql index 59b601601..f4716b371 100644 --- a/hscontrol/db/schema.sql +++ b/hscontrol/db/schema.sql @@ -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--); 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( diff --git a/hscontrol/db/secret.go b/hscontrol/db/secret.go new file mode 100644 index 000000000..4bd9e14f8 --- /dev/null +++ b/hscontrol/db/secret.go @@ -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---" 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 +} diff --git a/hscontrol/db/secret_test.go b/hscontrol/db/secret_test.go new file mode 100644 index 000000000..80f0a6fe5 --- /dev/null +++ b/hscontrol/db/secret_test.go @@ -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--, +// 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) +} diff --git a/hscontrol/db/users_test.go b/hscontrol/db/users_test.go index 2a755ff3c..3f58bcd4f 100644 --- a/hscontrol/db/users_test.go +++ b/hscontrol/db/users_test.go @@ -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) }, }, diff --git a/hscontrol/mapper/tail_test.go b/hscontrol/mapper/tail_test.go index ee5da85c9..cb85aea41 100644 --- a/hscontrol/mapper/tail_test.go +++ b/hscontrol/mapper/tail_test.go @@ -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{ diff --git a/hscontrol/policy/v2/types_test.go b/hscontrol/policy/v2/types_test.go index 19134e5b3..530d15bd8 100644 --- a/hscontrol/policy/v2/types_test.go +++ b/hscontrol/policy/v2/types_test.go @@ -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"), diff --git a/hscontrol/state/ephemeral_test.go b/hscontrol/state/ephemeral_test.go index 5c7556878..96745c87f 100644 --- a/hscontrol/state/ephemeral_test.go +++ b/hscontrol/state/ephemeral_test.go @@ -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, } diff --git a/hscontrol/state/state.go b/hscontrol/state/state.go index ddb410eb7..9631b8a67 100644 --- a/hscontrol/state/state.go +++ b/hscontrol/state/state.go @@ -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 = ¶ms.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 diff --git a/hscontrol/types/common.go b/hscontrol/types/common.go index e96aef9be..69cd1279e 100644 --- a/hscontrol/types/common.go +++ b/hscontrol/types/common.go @@ -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" diff --git a/hscontrol/types/credential.go b/hscontrol/types/credential.go new file mode 100644 index 000000000..a337a8d4d --- /dev/null +++ b/hscontrol/types/credential.go @@ -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, + } +} diff --git a/hscontrol/types/node.go b/hscontrol/types/node.go index ef9467373..b0d7312ea 100644 --- a/hscontrol/types/node.go +++ b/hscontrol/types/node.go @@ -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 diff --git a/hscontrol/types/node_tags_test.go b/hscontrol/types/node_tags_test.go index a401d71d8..a9e84d31e 100644 --- a/hscontrol/types/node_tags_test.go +++ b/hscontrol/types/node_tags_test.go @@ -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"}, }, }, diff --git a/hscontrol/types/types_clone.go b/hscontrol/types/types_clone.go index 1040e2142..577dd740a 100644 --- a/hscontrol/types/types_clone.go +++ b/hscontrol/types/types_clone.go @@ -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 +}{}) diff --git a/hscontrol/types/types_view.go b/hscontrol/types/types_view.go index 6adcdda12..6b44b6c70 100644 --- a/hscontrol/types/types_view.go +++ b/hscontrol/types/types_view.go @@ -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 +}{}) diff --git a/integration/cli_apikeys_test.go b/integration/cli_apikeys_test.go index fab3a1c7d..af657e0cf 100644 --- a/integration/cli_apikeys_test.go +++ b/integration/cli_apikeys_test.go @@ -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)