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.
370 lines
13 KiB
Go
370 lines
13 KiB
Go
package db
|
|
|
|
import (
|
|
"fmt"
|
|
|
|
"github.com/juanfont/headscale/hscontrol/types"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// credentialsDDLSQLite matches schema.sql byte-for-byte (the squibble digest is
|
|
// the SQLite source of truth). Only revoked legacy rows may lack a hash, and
|
|
// every stored hash format ($sha256$, $argon2id$, bcrypt $2a$) starts with '$'.
|
|
// TODO(kradalby): in 0.31, with the credentials migration, tighten
|
|
// chk_credentials_hash to hash NOT NULL (needs a migration clearing the
|
|
// hashless revoked rows).
|
|
//
|
|
//nolint:gosec // DDL, not a credential
|
|
const credentialsDDLSQLite = `CREATE TABLE credentials(
|
|
id integer PRIMARY KEY AUTOINCREMENT,
|
|
kind text NOT NULL CHECK(kind IN ('api','authkey','oauth_client','oauth_token')),
|
|
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,
|
|
CONSTRAINT chk_credentials_hash CHECK(hash IS NOT NULL OR revoked IS NOT NULL),
|
|
CONSTRAINT chk_credentials_hash_format CHECK(hash IS NULL OR substr(CAST(hash AS TEXT), 1, 1) = '$')
|
|
)`
|
|
|
|
// credentialsDDLPostgres is the Postgres form of [credentialsDDLSQLite]; both
|
|
// InitSchema and the migration use it, so fresh and upgraded databases match.
|
|
//
|
|
//nolint:gosec // DDL, not a credential
|
|
const credentialsDDLPostgres = `CREATE TABLE credentials(
|
|
id bigserial PRIMARY KEY,
|
|
kind text NOT NULL CHECK(kind IN ('api','authkey','oauth_client','oauth_token')),
|
|
identifier text,
|
|
hash bytea,
|
|
user_id bigint,
|
|
description text,
|
|
scopes text,
|
|
tags text,
|
|
reusable boolean,
|
|
ephemeral boolean DEFAULT false,
|
|
used boolean DEFAULT false,
|
|
last_seen timestamptz,
|
|
client_id text,
|
|
created_at timestamptz,
|
|
expiration timestamptz,
|
|
revoked timestamptz,
|
|
|
|
CONSTRAINT fk_credentials_user FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE SET NULL,
|
|
CONSTRAINT chk_credentials_hash CHECK(hash IS NOT NULL OR revoked IS NOT NULL),
|
|
CONSTRAINT chk_credentials_hash_format CHECK(hash IS NULL OR substring(hash from 1 for 1) = '\x24'::bytea)
|
|
)`
|
|
|
|
// credentialIndexes are created with the table.
|
|
var credentialIndexes = []string{
|
|
`CREATE UNIQUE INDEX idx_credentials_identifier ON credentials(kind, identifier)`,
|
|
`CREATE INDEX idx_credentials_user_id ON credentials(user_id)`,
|
|
}
|
|
|
|
// ensureCredentialsTable creates the credentials table and its indexes in one
|
|
// transaction, and is a no-op once the table exists. InitSchema's records are
|
|
// written after it runs, so an interrupted first start re-runs it.
|
|
func ensureCredentialsTable(tx *gorm.DB) error {
|
|
if tx.Migrator().HasTable(&types.Credential{}) {
|
|
return nil
|
|
}
|
|
|
|
return tx.Transaction(createCredentialsTable)
|
|
}
|
|
|
|
// createCredentialsTable creates the unified credentials table and its indexes.
|
|
func createCredentialsTable(tx *gorm.DB) error {
|
|
ddl := credentialsDDLSQLite
|
|
if tx.Name() != "sqlite" {
|
|
ddl = credentialsDDLPostgres
|
|
}
|
|
|
|
err := tx.Exec(ddl).Error
|
|
if err != nil {
|
|
return fmt.Errorf("creating credentials table: %w", err)
|
|
}
|
|
|
|
for _, stmt := range credentialIndexes {
|
|
err := tx.Exec(stmt).Error
|
|
if err != nil {
|
|
return fmt.Errorf("creating credentials index: %w", err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// migrateToCredentials backfills the unified credentials table from the four
|
|
// per-kind tables and drops them. Pre-auth keys keep their ids so
|
|
// nodes.auth_key_id stays valid; the nodes FK is then retargeted to
|
|
// credentials(id). The caller runs it in one transaction, so a failure leaves
|
|
// the database untouched and the migration can be retried.
|
|
//
|
|
// TODO(kradalby): remove in 0.31 with the credentials migration.
|
|
func migrateToCredentials(tx *gorm.DB) error {
|
|
// user_id on api_keys and oauth_clients never had a foreign key and
|
|
// DestroyUser left it behind, so it can reference a deleted user. Null it
|
|
// rather than fail the new foreign key.
|
|
const ownerOrNull = `CASE WHEN user_id IN (SELECT id FROM users) THEN user_id END`
|
|
|
|
// A row without a hash can never authenticate; keep it revoked rather
|
|
// than drop it, so references to it stay valid.
|
|
const revokedIfNoHash = `CASE WHEN hash IS NULL THEN CURRENT_TIMESTAMP END`
|
|
|
|
// Legacy plaintext pre-auth keys (no prefix) land as hashless revoked
|
|
// placeholders here; hashLegacyPreAuthKeys then hashes the ones that still
|
|
// carry their key so they keep working.
|
|
err := tx.Exec(`INSERT INTO credentials
|
|
(id, kind, identifier, hash, user_id, description, reusable, ephemeral, used, tags, expiration, revoked, created_at)
|
|
SELECT id, ?,
|
|
CASE WHEN prefix IS NULL OR prefix = '' THEN 'legacy-id-' || id ELSE prefix END,
|
|
CASE WHEN prefix IS NULL OR prefix = '' THEN NULL ELSE hash END,
|
|
`+ownerOrNull+`, description, reusable, ephemeral, used, tags, expiration,
|
|
CASE WHEN prefix IS NULL OR prefix = '' OR hash IS NULL
|
|
THEN COALESCE(revoked, CURRENT_TIMESTAMP) ELSE revoked END,
|
|
created_at
|
|
FROM pre_auth_keys`, types.CredentialPreAuthKey).Error
|
|
if err != nil {
|
|
return fmt.Errorf("backfilling pre-auth keys: %w", err)
|
|
}
|
|
|
|
err = hashLegacyPreAuthKeys(tx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Continue the pre-auth key id sequence, not just max(id): ids of deleted
|
|
// keys must not be handed out again, or a stale request by id would hit a
|
|
// new key. This also moves Postgres past the explicit-id inserts above.
|
|
err = continuePreAuthKeySequence(tx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
err = tx.Exec(`INSERT INTO credentials (kind, identifier, hash, user_id, last_seen, expiration, revoked, created_at)
|
|
SELECT ?, prefix, hash, `+ownerOrNull+`, last_seen, expiration, `+revokedIfNoHash+`, created_at
|
|
FROM api_keys ORDER BY id`, 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, `+ownerOrNull+`,
|
|
CASE WHEN secret_hash IS NULL THEN COALESCE(revoked, CURRENT_TIMESTAMP) ELSE revoked END, created_at
|
|
FROM oauth_clients ORDER BY id`, 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, revoked, created_at)
|
|
SELECT ?, prefix, hash, client_id, scopes, tags, expiration, `+revokedIfNoHash+`, created_at
|
|
FROM oauth_access_tokens ORDER BY id`, types.CredentialOAuthToken).Error
|
|
if err != nil {
|
|
return fmt.Errorf("backfilling oauth access tokens: %w", err)
|
|
}
|
|
|
|
err = retargetNodesAuthKeyFK(tx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
err = tx.Exec(`CREATE INDEX idx_nodes_auth_key_id ON nodes(auth_key_id)`).Error
|
|
if err != nil {
|
|
return fmt.Errorf("creating nodes auth_key_id index: %w", err)
|
|
}
|
|
|
|
for _, table := range []string{"pre_auth_keys", "api_keys", "oauth_clients", "oauth_access_tokens"} {
|
|
err := tx.Migrator().DropTable(table)
|
|
if err != nil {
|
|
return fmt.Errorf("dropping %s: %w", table, err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// hashLegacyPreAuthKeys stores each pre-0.28 plaintext pre-auth key as a SHA-256
|
|
// hash under its derived identifier (see [legacyAuthKeyIdentifier]) and lifts
|
|
// the placeholder revocation, so the key keeps authenticating after the
|
|
// upgrade. They hold 192 bits of crypto/rand entropy, so the reasoning at
|
|
// [hashPrefixSHA256] applies. SQLite has no SHA-256 function, so this runs in Go.
|
|
//
|
|
// TODO(kradalby): remove in 0.31 with the credentials migration.
|
|
func hashLegacyPreAuthKeys(tx *gorm.DB) error {
|
|
type legacyKey struct {
|
|
ID uint64
|
|
Key string
|
|
}
|
|
|
|
var keys []legacyKey
|
|
|
|
err := tx.Raw(`SELECT id, key FROM pre_auth_keys
|
|
WHERE (prefix IS NULL OR prefix = '') AND key IS NOT NULL AND key != ''`).
|
|
Scan(&keys).Error
|
|
if err != nil {
|
|
return fmt.Errorf("reading legacy pre-auth keys: %w", err)
|
|
}
|
|
|
|
for _, k := range keys {
|
|
err := tx.Exec(`UPDATE credentials
|
|
SET identifier = ?, hash = ?, revoked = (SELECT revoked FROM pre_auth_keys WHERE id = ?)
|
|
WHERE id = ?`, legacyAuthKeyIdentifier(k.Key), hashSecret(k.Key), k.ID, k.ID).Error
|
|
if err != nil {
|
|
return fmt.Errorf("hashing legacy pre-auth key %d: %w", k.ID, 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 auth_key_id
|
|
// now points at a credentials row, so no FK toggling is required.
|
|
//
|
|
// TODO(kradalby): remove in 0.31 with the credentials migration.
|
|
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
|
|
}
|
|
|
|
// Dropping the table resets its AUTOINCREMENT counter to max(id); carry the
|
|
// old counter over so ids of deleted nodes are never handed out again.
|
|
seq, err := sqliteSequence(tx, "nodes")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
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 {
|
|
err := tx.Exec(stmt).Error
|
|
if err != nil {
|
|
return fmt.Errorf("rebuilding nodes table: %w", err)
|
|
}
|
|
}
|
|
|
|
return raiseSQLiteSequence(tx, "nodes", seq)
|
|
}
|
|
|
|
// continuePreAuthKeySequence advances the credentials id sequence to at least
|
|
// the last id pre_auth_keys ever allocated.
|
|
//
|
|
// TODO(kradalby): remove in 0.31 with the credentials migration.
|
|
func continuePreAuthKeySequence(tx *gorm.DB) error {
|
|
if tx.Name() == "sqlite" {
|
|
seq, err := sqliteSequence(tx, "pre_auth_keys")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return raiseSQLiteSequence(tx, "credentials", seq)
|
|
}
|
|
|
|
var last int64
|
|
|
|
// pg_sequences.last_value is NULL for a sequence that was never used.
|
|
err := tx.Raw(`SELECT COALESCE(MAX(last_value), 0) FROM pg_sequences
|
|
WHERE schemaname = current_schema()
|
|
AND sequencename = (SELECT relname FROM pg_class WHERE oid = pg_get_serial_sequence('pre_auth_keys', 'id')::regclass)`).
|
|
Scan(&last).Error
|
|
if err != nil {
|
|
return fmt.Errorf("reading pre_auth_keys id sequence: %w", err)
|
|
}
|
|
|
|
err = tx.Exec(`SELECT setval(pg_get_serial_sequence('credentials','id'),
|
|
GREATEST((SELECT COALESCE(MAX(id), 0) FROM credentials), ?, 1))`, last).Error
|
|
if err != nil {
|
|
return fmt.Errorf("advancing credentials id sequence: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// sqliteSequence returns the AUTOINCREMENT counter of table, 0 if unset.
|
|
//
|
|
// TODO(kradalby): remove in 0.31 with the credentials migration.
|
|
func sqliteSequence(tx *gorm.DB, table string) (int64, error) {
|
|
var seq int64
|
|
|
|
err := tx.Raw(`SELECT COALESCE(MAX(seq), 0) FROM sqlite_sequence WHERE name = ?`, table).
|
|
Scan(&seq).Error
|
|
if err != nil {
|
|
return 0, fmt.Errorf("reading %s sequence: %w", table, err)
|
|
}
|
|
|
|
return seq, nil
|
|
}
|
|
|
|
// raiseSQLiteSequence sets the AUTOINCREMENT counter of table to at least seq.
|
|
//
|
|
// TODO(kradalby): remove in 0.31 with the credentials migration.
|
|
func raiseSQLiteSequence(tx *gorm.DB, table string, seq int64) error {
|
|
res := tx.Exec(`UPDATE sqlite_sequence SET seq = MAX(seq, ?) WHERE name = ?`, seq, table)
|
|
if res.Error == nil && res.RowsAffected == 0 && seq > 0 {
|
|
// No row yet: nothing has been inserted into table.
|
|
res = tx.Exec(`INSERT INTO sqlite_sequence(name, seq) VALUES(?, ?)`, table, seq)
|
|
}
|
|
|
|
if res.Error != nil {
|
|
return fmt.Errorf("restoring %s sequence: %w", table, res.Error)
|
|
}
|
|
|
|
return nil
|
|
}
|