mirror of
https://github.com/juanfont/headscale.git
synced 2026-07-20 18:09:29 +00:00
Merge 04e8fcda79 into 048308511c
This commit is contained in:
commit
a77b333a21
29 changed files with 1175 additions and 480 deletions
10
CHANGELOG.md
10
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
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
72
hscontrol/db/credential.go
Normal file
72
hscontrol/db/credential.go
Normal 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,
|
||||
}
|
||||
}
|
||||
48
hscontrol/db/credential_test.go
Normal file
48
hscontrol/db/credential_test.go
Normal 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")
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
138
hscontrol/db/migrate_credentials.go
Normal file
138
hscontrol/db/migrate_credentials.go
Normal 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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
169
hscontrol/db/secret.go
Normal 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
|
||||
}
|
||||
58
hscontrol/db/secret_test.go
Normal file
58
hscontrol/db/secret_test.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
93
hscontrol/types/credential.go
Normal file
93
hscontrol/types/credential.go
Normal 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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"},
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}{})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}{})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue