mirror of
https://github.com/juanfont/headscale.git
synced 2026-09-30 11:59:39 +09:00
393dd3e2d9
API keys, pre-auth keys and OAuth clients/tokens share one table and verify path. Secrets carry 256 bits of crypto/rand entropy, so a SHA-256 digest needs no stretching; bcrypt/argon2id rows rehash on use until 0.32.
405 lines
14 KiB
Go
405 lines
14 KiB
Go
package db
|
|
|
|
import (
|
|
"encoding/base64"
|
|
"fmt"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/juanfont/headscale/hscontrol/types"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"golang.org/x/crypto/argon2"
|
|
"golang.org/x/crypto/bcrypt"
|
|
)
|
|
|
|
// legacyArgon2idHash builds a PHC-encoded Argon2id hash in the form development
|
|
// builds stored secrets before the switch to SHA-256.
|
|
//
|
|
// TODO(kradalby): remove in 0.32 with bcrypt/Argon2id support.
|
|
func legacyArgon2idHash(secret string, memory, time uint32, threads uint8) []byte {
|
|
salt := []byte("0123456789abcdef")
|
|
sum := argon2.IDKey([]byte(secret), salt, time, memory, threads, argon2KeyLen)
|
|
|
|
return fmt.Appendf(nil, "$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
|
|
argon2.Version, memory, time, threads,
|
|
base64.RawStdEncoding.EncodeToString(salt),
|
|
base64.RawStdEncoding.EncodeToString(sum),
|
|
)
|
|
}
|
|
|
|
func TestVerifySecret(t *testing.T) {
|
|
const secret = "s3cr3t"
|
|
|
|
bcryptHash, err := bcrypt.GenerateFromPassword([]byte(secret), bcrypt.MinCost)
|
|
require.NoError(t, err)
|
|
|
|
tests := []struct {
|
|
name string
|
|
hash []byte
|
|
wantRehash bool
|
|
wantErr error
|
|
wrongSecret bool
|
|
}{
|
|
{name: "sha256", hash: hashSecret(secret)},
|
|
{name: "sha256 wrong secret", hash: hashSecret(secret), wrongSecret: true, wantErr: errSecretMismatch},
|
|
// TODO(kradalby): remove in 0.32 with bcrypt/Argon2id support.
|
|
{name: "argon2id legacy", hash: legacyArgon2idHash(secret, 19*1024, 2, 1), wantRehash: true},
|
|
{name: "argon2id wrong secret", hash: legacyArgon2idHash(secret, 19*1024, 2, 1), wrongSecret: true, wantErr: errSecretMismatch},
|
|
{name: "bcrypt legacy", hash: bcryptHash, wantRehash: true},
|
|
{name: "bcrypt wrong secret", hash: bcryptHash, wrongSecret: true, wantErr: errSecretMismatch},
|
|
{name: "nil hash", hash: nil, wantErr: errSecretHashMalformed},
|
|
{name: "garbage", hash: []byte("not-a-hash"), wantErr: errSecretHashMalformed},
|
|
{name: "truncated bcrypt", hash: bcryptHash[:20], wantErr: errSecretHashMalformed},
|
|
{name: "sha256 bad hex", hash: []byte("$sha256$zz"), wantErr: errSecretHashMalformed},
|
|
// Parameters argon2 would panic on, or that would allocate unbounded
|
|
// memory, must be rejected before hashing.
|
|
{name: "argon2id t=0", hash: []byte("$argon2id$v=19$m=19456,t=0,p=1$MDEyMzQ1Njc4OWFiY2RlZg$AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"), wantErr: errSecretHashMalformed},
|
|
{name: "argon2id p=0", hash: []byte("$argon2id$v=19$m=19456,t=2,p=0$MDEyMzQ1Njc4OWFiY2RlZg$AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"), wantErr: errSecretHashMalformed},
|
|
{name: "argon2id huge memory", hash: []byte("$argon2id$v=19$m=4294967295,t=2,p=1$MDEyMzQ1Njc4OWFiY2RlZg$AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"), wantErr: errSecretHashMalformed},
|
|
{name: "argon2id empty key", hash: []byte("$argon2id$v=19$m=19456,t=2,p=1$MDEyMzQ1Njc4OWFiY2RlZg$"), wantErr: errSecretHashMalformed},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
presented := secret
|
|
if tt.wrongSecret {
|
|
presented = "wrong"
|
|
}
|
|
|
|
needsRehash, err := verifySecret(tt.hash, presented)
|
|
if tt.wantErr != nil {
|
|
require.ErrorIs(t, err, tt.wantErr)
|
|
assert.False(t, needsRehash)
|
|
|
|
return
|
|
}
|
|
|
|
require.NoError(t, err)
|
|
assert.Equal(t, tt.wantRehash, needsRehash)
|
|
})
|
|
}
|
|
|
|
// A malformed hash must not leak a limiter slot: after more failures than
|
|
// the limiter holds, a legacy verify still completes.
|
|
for range cap(legacyHashLimiter) + 1 {
|
|
_, _ = verifySecret([]byte("$argon2id$v=19$m=19456,t=0,p=1$MDEyMzQ1Njc4OWFiY2RlZg$AAAA"), secret)
|
|
}
|
|
|
|
_, err = verifySecret(bcryptHash, secret)
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
// TestVerifySecretConcurrent runs more concurrent legacy verifications than the
|
|
// limiter admits, asserting it releases correctly (no deadlock) and stays
|
|
// correct under contention. Run with -race.
|
|
//
|
|
// TODO(kradalby): remove in 0.32 with bcrypt/Argon2id support.
|
|
func TestVerifySecretConcurrent(t *testing.T) {
|
|
hash, err := bcrypt.GenerateFromPassword([]byte("s3cr3t"), bcrypt.MinCost)
|
|
require.NoError(t, err)
|
|
|
|
const n = 64
|
|
|
|
var wg sync.WaitGroup
|
|
|
|
errs := make([]error, n)
|
|
|
|
for i := range n {
|
|
wg.Go(func() {
|
|
if i%2 == 0 {
|
|
_, errs[i] = verifySecret(hash, "s3cr3t")
|
|
} else {
|
|
_, errs[i] = verifySecret(hash, "wrong")
|
|
}
|
|
})
|
|
}
|
|
|
|
wg.Wait()
|
|
|
|
for i, e := range errs {
|
|
if i%2 == 0 {
|
|
assert.NoError(t, e, "correct secret must verify")
|
|
} else {
|
|
assert.ErrorIs(t, e, errSecretMismatch, "wrong secret must fail")
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestGenerateSecret verifies the unified key shape
|
|
// <prefix><identifier(12)>-<secret(64)> and that the stored hash verifies the
|
|
// secret without needing a rehash.
|
|
func TestGenerateSecret(t *testing.T) {
|
|
full, identifier, hash := generateSecret("hskey-test-")
|
|
|
|
require.Len(t, identifier, keyIdentifierLength)
|
|
require.True(t, strings.HasPrefix(full, "hskey-test-"+identifier+"-"))
|
|
|
|
secret := strings.TrimPrefix(full, "hskey-test-"+identifier+"-")
|
|
require.Len(t, secret, keySecretLength)
|
|
assert.True(t, strings.HasPrefix(string(hash), hashPrefixSHA256))
|
|
assert.NotContains(t, string(hash), secret, "secret must not be stored")
|
|
|
|
needsRehash, err := verifySecret(hash, secret)
|
|
require.NoError(t, err)
|
|
require.False(t, needsRehash)
|
|
}
|
|
|
|
func TestOAuthClientCreateAndAuthenticate(t *testing.T) {
|
|
db, err := newSQLiteTestDB()
|
|
require.NoError(t, err)
|
|
|
|
secret, client, err := db.CreateOAuthClient(
|
|
[]string{"auth_keys", "devices:core"},
|
|
[]string{"tag:ci"},
|
|
"my client",
|
|
nil,
|
|
)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, client)
|
|
|
|
// Secret carries the public client id as its middle segment, so it can be
|
|
// derived from the secret alone (the Tailscale get-authkey trick).
|
|
assert.True(t, strings.HasPrefix(secret, "hskey-client-"+client.ClientID+"-"))
|
|
// Scopes/tags are deduplicated and sorted for stable storage.
|
|
assert.Equal(t, []string{"auth_keys", "devices:core"}, client.Scopes)
|
|
assert.Equal(t, []string{"tag:ci"}, client.Tags)
|
|
// Only the hash is stored, never the plaintext.
|
|
assert.True(t, strings.HasPrefix(string(client.SecretHash), hashPrefixSHA256))
|
|
|
|
// The secret authenticates, deriving the client id from the secret itself.
|
|
got, err := db.AuthenticateOAuthClient(secret)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, client.ClientID, got.ClientID)
|
|
|
|
// A truncated/garbage secret does not.
|
|
_, err = db.AuthenticateOAuthClient("hskey-client-deadbeef-nope")
|
|
require.ErrorIs(t, err, ErrOAuthClientFailedToParse)
|
|
|
|
// A wrong secret for a real client id reads as an unknown client.
|
|
_, err = db.AuthenticateOAuthClient("hskey-client-" + client.ClientID + "-" + strings.Repeat("0", 64))
|
|
require.ErrorIs(t, err, ErrOAuthClientNotFound)
|
|
require.ErrorIs(t, err, errSecretMismatch)
|
|
}
|
|
|
|
// TestOAuthClientAuthenticateTailscalePrefix asserts the same stored client
|
|
// authenticates under the tskey-client- alias, and that only a leading prefix
|
|
// is recognised.
|
|
func TestOAuthClientAuthenticateTailscalePrefix(t *testing.T) {
|
|
db, err := newSQLiteTestDB()
|
|
require.NoError(t, err)
|
|
|
|
secret, client, err := db.CreateOAuthClient(
|
|
[]string{"auth_keys"},
|
|
[]string{"tag:ci"},
|
|
"",
|
|
nil,
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
rest := strings.TrimPrefix(secret, types.OAuthClientPrefix)
|
|
tsSecret := types.TailscaleOAuthClientPrefix + rest
|
|
|
|
for _, s := range []string{
|
|
tsSecret,
|
|
// Callers may pass the raw auth-key form; ?attributes are stripped.
|
|
tsSecret + "?baseURL=http://127.0.0.1:8080&ephemeral=true",
|
|
} {
|
|
got, err := db.AuthenticateOAuthClient(s)
|
|
require.NoError(t, err, s)
|
|
assert.Equal(t, client.ClientID, got.ClientID)
|
|
}
|
|
|
|
// A wrong secret under the alias parses but fails verification.
|
|
_, err = db.AuthenticateOAuthClient(
|
|
types.TailscaleOAuthClientPrefix + client.ClientID + "-" + strings.Repeat("0", 64),
|
|
)
|
|
require.Error(t, err)
|
|
require.NotErrorIs(t, err, ErrOAuthClientFailedToParse)
|
|
|
|
for _, s := range []string{
|
|
types.TailscaleOAuthClientPrefix,
|
|
"tskey-auth-" + rest,
|
|
"tskey-" + rest,
|
|
"junk-" + tsSecret,
|
|
"junk-" + secret,
|
|
types.TailscaleOAuthClientPrefix + secret,
|
|
} {
|
|
_, err := db.AuthenticateOAuthClient(s)
|
|
require.ErrorIs(t, err, ErrOAuthClientFailedToParse, s)
|
|
}
|
|
}
|
|
|
|
func TestOAuthClientRevoke(t *testing.T) {
|
|
db, err := newSQLiteTestDB()
|
|
require.NoError(t, err)
|
|
|
|
secret, client, err := db.CreateOAuthClient([]string{"auth_keys"}, []string{"tag:ci"}, "", nil)
|
|
require.NoError(t, err)
|
|
|
|
// A token minted by the client survives only until the client is revoked.
|
|
_, _, err = db.MintAccessToken(client.ClientID, client.Scopes, client.Tags, nil)
|
|
require.NoError(t, err)
|
|
|
|
require.NoError(t, db.RevokeOAuthClient(client.ClientID))
|
|
|
|
// The client no longer authenticates and a repeated revoke is a clean 404.
|
|
_, err = db.AuthenticateOAuthClient(secret)
|
|
require.Error(t, err)
|
|
require.ErrorIs(t, db.RevokeOAuthClient(client.ClientID), ErrOAuthClientNotFound)
|
|
}
|
|
|
|
func TestOAuthAccessTokenMintAuthenticateExpire(t *testing.T) {
|
|
db, err := newSQLiteTestDB()
|
|
require.NoError(t, err)
|
|
|
|
_, client, err := db.CreateOAuthClient([]string{"auth_keys"}, []string{"tag:ci"}, "", nil)
|
|
require.NoError(t, err)
|
|
|
|
future := time.Now().Add(time.Hour)
|
|
tokenStr, token, err := db.MintAccessToken(
|
|
client.ClientID,
|
|
[]string{"auth_keys"},
|
|
[]string{"tag:ci"},
|
|
&future,
|
|
)
|
|
require.NoError(t, err)
|
|
assert.True(t, strings.HasPrefix(tokenStr, "hskey-oauthtok-"))
|
|
|
|
got, err := db.AuthenticateAccessToken(tokenStr)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, client.ClientID, got.ClientID)
|
|
assert.Equal(t, []string{"auth_keys"}, got.Scopes)
|
|
assert.Equal(t, []string{"tag:ci"}, got.Tags)
|
|
|
|
// An expired token is rejected even though the row still exists.
|
|
past := time.Now().Add(-time.Hour)
|
|
expiredStr, _, err := db.MintAccessToken(client.ClientID, nil, nil, &past)
|
|
require.NoError(t, err)
|
|
_, err = db.AuthenticateAccessToken(expiredStr)
|
|
require.ErrorIs(t, err, ErrAccessTokenExpired)
|
|
|
|
// Expired credentials of other kinds share the table; the reaper must
|
|
// leave them alone.
|
|
apiKeyStr, _, err := db.CreateAPIKey(&past)
|
|
require.NoError(t, err)
|
|
|
|
pak, err := db.CreatePreAuthKey(nil, false, false, &past, []string{"tag:ci"})
|
|
require.NoError(t, err)
|
|
|
|
// The reaper deletes the expired token row; the live token is untouched.
|
|
n, err := db.DeleteExpiredAccessTokens(time.Now())
|
|
require.NoError(t, err)
|
|
assert.Equal(t, int64(1), n)
|
|
|
|
_ = token
|
|
|
|
_, err = db.AuthenticateAPIKey(apiKeyStr)
|
|
require.ErrorIs(t, err, ErrAPIKeyExpired, "expired API key must survive the token reaper")
|
|
|
|
_, err = db.GetPreAuthKeyByID(pak.ID)
|
|
require.NoError(t, err, "expired pre-auth key must survive the token reaper")
|
|
|
|
_, err = db.AuthenticateAccessToken(tokenStr)
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
// TestAccessTokenRejectedWhenClientGone asserts a token whose issuing client no
|
|
// longer exists (orphaned by a delete/revoke race) is rejected, even though the
|
|
// token row itself is valid and unexpired.
|
|
func TestAccessTokenRejectedWhenClientGone(t *testing.T) {
|
|
db, err := newSQLiteTestDB()
|
|
require.NoError(t, err)
|
|
|
|
_, client, err := db.CreateOAuthClient([]string{"auth_keys"}, []string{"tag:ci"}, "", nil)
|
|
require.NoError(t, err)
|
|
|
|
future := time.Now().Add(time.Hour)
|
|
tokenStr, _, err := db.MintAccessToken(client.ClientID, []string{"auth_keys"}, []string{"tag:ci"}, &future)
|
|
require.NoError(t, err)
|
|
|
|
_, err = db.AuthenticateAccessToken(tokenStr)
|
|
require.NoError(t, err)
|
|
|
|
// 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("kind = ? AND identifier = ?", types.CredentialOAuthClient, client.ClientID).Delete(&types.Credential{}).Error)
|
|
|
|
_, err = db.AuthenticateAccessToken(tokenStr)
|
|
require.ErrorIs(t, err, ErrAccessTokenClientRevoked)
|
|
|
|
// A soft-revoked client (row present, Revoked set) is likewise rejected.
|
|
_, client2, err := db.CreateOAuthClient([]string{"auth_keys"}, []string{"tag:ci"}, "", nil)
|
|
require.NoError(t, err)
|
|
|
|
tokenStr2, _, err := db.MintAccessToken(client2.ClientID, []string{"auth_keys"}, []string{"tag:ci"}, &future)
|
|
require.NoError(t, err)
|
|
|
|
now := time.Now()
|
|
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)
|
|
}
|
|
|
|
// TestOAuthClientCreateRejectsUnknownScopes asserts scopes are validated against
|
|
// the known vocabulary the same way tags are, instead of being stored verbatim.
|
|
func TestOAuthClientCreateRejectsUnknownScopes(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
scopes []string
|
|
valid bool
|
|
}{
|
|
{name: "known write and read scopes", scopes: []string{"auth_keys", "devices:core:read"}, valid: true},
|
|
{name: "super scopes", scopes: []string{"all", "all:read"}, valid: true},
|
|
{name: "arbitrary scope", scopes: []string{"EVIL:superuser"}},
|
|
{name: "path traversal", scopes: []string{"../../etc/passwd"}},
|
|
{name: "wildcard", scopes: []string{"*::*"}},
|
|
{name: "empty scope", scopes: []string{""}},
|
|
{name: "wrong case", scopes: []string{"AUTH_KEYS"}},
|
|
{name: "one unknown among known", scopes: []string{"auth_keys", "devices:everything"}},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
db, err := newSQLiteTestDB()
|
|
require.NoError(t, err)
|
|
|
|
_, client, err := db.CreateOAuthClient(tt.scopes, []string{"tag:ci"}, "", nil)
|
|
if tt.valid {
|
|
require.NoError(t, err)
|
|
assert.ElementsMatch(t, tt.scopes, client.Scopes)
|
|
|
|
return
|
|
}
|
|
|
|
require.ErrorIs(t, err, ErrOAuthClientScopeInvalid)
|
|
assert.Nil(t, client)
|
|
|
|
clients, err := db.ListOAuthClients()
|
|
require.NoError(t, err)
|
|
assert.Empty(t, clients, "rejected client must not be stored")
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestOAuthClientCreateReportsEveryUnknownScope asserts validation does not stop
|
|
// at the first bad scope, so a caller can fix them all in one pass.
|
|
func TestOAuthClientCreateReportsEveryUnknownScope(t *testing.T) {
|
|
db, err := newSQLiteTestDB()
|
|
require.NoError(t, err)
|
|
|
|
_, _, err = db.CreateOAuthClient(
|
|
[]string{"auth_keys", "devices:everything", "EVIL:superuser"},
|
|
[]string{"tag:ci"},
|
|
"",
|
|
nil,
|
|
)
|
|
require.ErrorIs(t, err, ErrOAuthClientScopeInvalid)
|
|
assert.Contains(t, err.Error(), "devices:everything")
|
|
assert.Contains(t, err.Error(), "EVIL:superuser")
|
|
}
|