fix: sync temp auth key expiry boundaries
This commit is contained in:
parent
305e8a0008
commit
20a310f6ca
50 changed files with 3626 additions and 335 deletions
|
|
@ -0,0 +1,471 @@
|
|||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
|
||||
"telesrv/deploy"
|
||||
)
|
||||
|
||||
const (
|
||||
authKeyExpiryMigrationUp = "migrations/0086_auth_key_protocol_expiry.up.sql"
|
||||
authKeyExpiryMigrationDown = "migrations/0086_auth_key_protocol_expiry.down.sql"
|
||||
)
|
||||
|
||||
func TestAuthKeyProtocolExpiryMigrationBackfillAndRollbackPostgres(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
upSQL := readAuthKeyExpiryMigration(t, authKeyExpiryMigrationUp)
|
||||
downSQL := readAuthKeyExpiryMigration(t, authKeyExpiryMigrationDown)
|
||||
|
||||
tx, err := pool.Begin(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("begin auth-key expiry migration test: %v", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback(context.Background()) }()
|
||||
|
||||
if _, err := tx.Exec(ctx, downSQL); err != nil {
|
||||
t.Fatalf("return schema to 0085: %v", err)
|
||||
}
|
||||
|
||||
base := authKeyExpiryMigrationBaseID()
|
||||
tempKeyID := base
|
||||
permKeyID := base - 1
|
||||
authorizedPermKeyID := base - 2
|
||||
unknownKeyID := base - 3
|
||||
userID := base - 4
|
||||
const tempExpiresAt = 1_800_086_000
|
||||
|
||||
for _, authKeyID := range []int64{tempKeyID, permKeyID, authorizedPermKeyID, unknownKeyID} {
|
||||
insertAuthKeyExpiryMigrationKey(t, ctx, tx, authKeyID)
|
||||
}
|
||||
insertAuthKeyExpiryMigrationBinding(t, ctx, tx, tempKeyID, permKeyID, tempExpiresAt)
|
||||
insertAuthKeyExpiryMigrationUser(t, ctx, tx, userID)
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO public.authorizations (auth_key_id, user_id, hash)
|
||||
VALUES ($1, $2, $3)`, authorizedPermKeyID, userID, base-5); err != nil {
|
||||
t.Fatalf("insert authorized permanent key fixture: %v", err)
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(ctx, upSQL); err != nil {
|
||||
t.Fatalf("apply auth-key expiry migration: %v", err)
|
||||
}
|
||||
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
authKeyID int64
|
||||
want int
|
||||
}{
|
||||
{name: "bound temporary", authKeyID: tempKeyID, want: tempExpiresAt},
|
||||
{name: "binding permanent", authKeyID: permKeyID, want: 0},
|
||||
{name: "authorized permanent", authKeyID: authorizedPermKeyID, want: 0},
|
||||
{name: "unclassified legacy", authKeyID: unknownKeyID, want: -1},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
var got int
|
||||
if err := tx.QueryRow(ctx, `SELECT expires_at FROM public.auth_keys WHERE auth_key_id = $1`, test.authKeyID).Scan(&got); err != nil {
|
||||
t.Fatalf("read expires_at: %v", err)
|
||||
}
|
||||
if got != test.want {
|
||||
t.Fatalf("expires_at = %d, want %d", got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
var (
|
||||
indexPredicate string
|
||||
indexValid bool
|
||||
)
|
||||
if err := tx.QueryRow(ctx, `
|
||||
SELECT pg_get_expr(i.indpred, i.indrelid), i.indisvalid
|
||||
FROM pg_catalog.pg_index AS i
|
||||
JOIN pg_catalog.pg_class AS c ON c.oid = i.indexrelid
|
||||
JOIN pg_catalog.pg_namespace AS n ON n.oid = c.relnamespace
|
||||
WHERE n.nspname = 'public'
|
||||
AND c.relname = 'auth_keys_temporary_expiry_seek_idx'`).Scan(&indexPredicate, &indexValid); err != nil {
|
||||
t.Fatalf("inspect temporary expiry partial index: %v", err)
|
||||
}
|
||||
if !indexValid || !strings.Contains(indexPredicate, "expires_at > 0") {
|
||||
t.Fatalf("temporary expiry index valid=%v predicate=%q, want valid partial expires_at > 0", indexValid, indexPredicate)
|
||||
}
|
||||
|
||||
var (
|
||||
deleteAction string
|
||||
fkValidated bool
|
||||
)
|
||||
if err := tx.QueryRow(ctx, `
|
||||
SELECT c.confdeltype::text, c.convalidated
|
||||
FROM pg_catalog.pg_constraint AS c
|
||||
WHERE c.conrelid = 'public.temp_auth_key_bindings'::regclass
|
||||
AND c.conname = 'temp_auth_key_bindings_perm_auth_key_id_fkey'`).Scan(&deleteAction, &fkValidated); err != nil {
|
||||
t.Fatalf("inspect permanent auth-key FK: %v", err)
|
||||
}
|
||||
if deleteAction != "r" || !fkValidated {
|
||||
t.Fatalf("permanent auth-key FK delete action=%q validated=%v, want RESTRICT/true", deleteAction, fkValidated)
|
||||
}
|
||||
|
||||
assertAuthKeyExpiryMigrationForeignKeyViolation(t, ctx, tx, func(nested pgx.Tx) error {
|
||||
_, err := nested.Exec(ctx, `DELETE FROM public.auth_keys WHERE auth_key_id = $1`, permKeyID)
|
||||
return err
|
||||
})
|
||||
assertAuthKeyExpiryMigrationForeignKeyViolation(t, ctx, tx, func(nested pgx.Tx) error {
|
||||
_, err := nested.Exec(ctx, `
|
||||
INSERT INTO public.temp_auth_key_bindings (
|
||||
temp_auth_key_id, perm_auth_key_id, nonce, expires_at, encrypted_message, temp_session_id
|
||||
) VALUES ($1, $2, 86, $3, decode('86', 'hex'), 86)`, unknownKeyID, base-86, tempExpiresAt)
|
||||
return err
|
||||
})
|
||||
|
||||
if _, err := tx.Exec(ctx, downSQL); err != nil {
|
||||
t.Fatalf("roll back auth-key expiry migration: %v", err)
|
||||
}
|
||||
|
||||
var (
|
||||
expiresColumnExists bool
|
||||
expiryIndexExists bool
|
||||
permFKExists bool
|
||||
fixtureKeyCount int
|
||||
)
|
||||
if err := tx.QueryRow(ctx, `
|
||||
SELECT
|
||||
EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = 'public' AND table_name = 'auth_keys' AND column_name = 'expires_at'
|
||||
),
|
||||
to_regclass('public.auth_keys_temporary_expiry_seek_idx') IS NOT NULL,
|
||||
EXISTS (
|
||||
SELECT 1 FROM pg_catalog.pg_constraint
|
||||
WHERE conrelid = 'public.temp_auth_key_bindings'::regclass
|
||||
AND conname = 'temp_auth_key_bindings_perm_auth_key_id_fkey'
|
||||
),
|
||||
(SELECT count(*) FROM public.auth_keys WHERE auth_key_id = ANY($1::bigint[]))
|
||||
`, []int64{tempKeyID, permKeyID, authorizedPermKeyID, unknownKeyID}).Scan(
|
||||
&expiresColumnExists,
|
||||
&expiryIndexExists,
|
||||
&permFKExists,
|
||||
&fixtureKeyCount,
|
||||
); err != nil {
|
||||
t.Fatalf("inspect 0086 down result: %v", err)
|
||||
}
|
||||
if expiresColumnExists || expiryIndexExists || permFKExists || fixtureKeyCount != 4 {
|
||||
t.Fatalf("0086 down result column=%v index=%v fk=%v keys=%d, want false/false/false/4", expiresColumnExists, expiryIndexExists, permFKExists, fixtureKeyCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthKeyProtocolExpiryMigrationRejectsInvalidIdentityStatePostgres(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
upSQL := readAuthKeyExpiryMigration(t, authKeyExpiryMigrationUp)
|
||||
downSQL := readAuthKeyExpiryMigration(t, authKeyExpiryMigrationDown)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
wantMessage string
|
||||
setup func(*testing.T, context.Context, pgx.Tx, int64)
|
||||
}{
|
||||
{
|
||||
name: "nonpositive binding expiry",
|
||||
wantMessage: "invalid non-positive temporary auth key expiry",
|
||||
setup: func(t *testing.T, ctx context.Context, tx pgx.Tx, base int64) {
|
||||
insertAuthKeyExpiryMigrationKey(t, ctx, tx, base)
|
||||
insertAuthKeyExpiryMigrationKey(t, ctx, tx, base-1)
|
||||
insertAuthKeyExpiryMigrationBinding(t, ctx, tx, base, base-1, 0)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "dangling permanent key",
|
||||
wantMessage: "temporary auth key binding references missing permanent key",
|
||||
setup: func(t *testing.T, ctx context.Context, tx pgx.Tx, base int64) {
|
||||
insertAuthKeyExpiryMigrationKey(t, ctx, tx, base)
|
||||
insertAuthKeyExpiryMigrationBinding(t, ctx, tx, base, base-1, 1_800_086_001)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "self binding",
|
||||
wantMessage: "temporary auth key self-binding",
|
||||
setup: func(t *testing.T, ctx context.Context, tx pgx.Tx, base int64) {
|
||||
insertAuthKeyExpiryMigrationKey(t, ctx, tx, base)
|
||||
insertAuthKeyExpiryMigrationBinding(t, ctx, tx, base, base, 1_800_086_002)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "temporary and permanent role overlap",
|
||||
wantMessage: "auth key appears in both temporary and permanent roles",
|
||||
setup: func(t *testing.T, ctx context.Context, tx pgx.Tx, base int64) {
|
||||
for _, authKeyID := range []int64{base, base - 1, base - 2} {
|
||||
insertAuthKeyExpiryMigrationKey(t, ctx, tx, authKeyID)
|
||||
}
|
||||
insertAuthKeyExpiryMigrationBinding(t, ctx, tx, base, base-1, 1_800_086_003)
|
||||
insertAuthKeyExpiryMigrationBinding(t, ctx, tx, base-1, base-2, 1_800_086_004)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "authorization on bound temporary key",
|
||||
wantMessage: "invalid authorization on temporary auth key",
|
||||
setup: func(t *testing.T, ctx context.Context, tx pgx.Tx, base int64) {
|
||||
insertAuthKeyExpiryMigrationKey(t, ctx, tx, base)
|
||||
insertAuthKeyExpiryMigrationKey(t, ctx, tx, base-1)
|
||||
insertAuthKeyExpiryMigrationBinding(t, ctx, tx, base, base-1, 1_800_086_005)
|
||||
insertAuthKeyExpiryMigrationUser(t, ctx, tx, base-2)
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO public.authorizations (auth_key_id, user_id, hash)
|
||||
VALUES ($1, $2, $3)`, base, base-2, base-3); err != nil {
|
||||
t.Fatalf("insert temporary-key authorization fixture: %v", err)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "update state on bound temporary key",
|
||||
wantMessage: "update state references temporary auth key",
|
||||
setup: func(t *testing.T, ctx context.Context, tx pgx.Tx, base int64) {
|
||||
insertAuthKeyExpiryMigrationKey(t, ctx, tx, base)
|
||||
insertAuthKeyExpiryMigrationKey(t, ctx, tx, base-1)
|
||||
insertAuthKeyExpiryMigrationBinding(t, ctx, tx, base, base-1, 1_800_086_006)
|
||||
insertAuthKeyExpiryMigrationUser(t, ctx, tx, base-2)
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO public.update_states (auth_key_id, user_id, pts, date)
|
||||
VALUES ($1, $2, 1, 1)`, base, base-2); err != nil {
|
||||
t.Fatalf("insert temporary-key update state fixture: %v", err)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "bootstrap update job on bound temporary key",
|
||||
wantMessage: "bootstrap update job references temporary auth key",
|
||||
setup: func(t *testing.T, ctx context.Context, tx pgx.Tx, base int64) {
|
||||
insertAuthKeyExpiryMigrationBoundPair(t, ctx, tx, base, base-1, 1_800_086_007)
|
||||
userID := base - 2
|
||||
messageID := base - 3
|
||||
insertAuthKeyExpiryMigrationUser(t, ctx, tx, userID)
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO public.private_messages (
|
||||
id, sender_user_id, recipient_user_id, message_date, body
|
||||
) VALUES ($1, $2, $2, 1, 'migration-0086')`, messageID, userID); err != nil {
|
||||
t.Fatalf("insert bootstrap private message fixture: %v", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO public.message_boxes (
|
||||
owner_user_id, box_id, private_message_id, message_sender_id,
|
||||
peer_type, peer_id, from_user_id, message_date, body
|
||||
) VALUES ($1, 860086, $2, $1, 'user', $1, $1, 1, 'migration-0086')`, userID, messageID); err != nil {
|
||||
t.Fatalf("insert bootstrap message box fixture: %v", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO public.bootstrap_update_jobs (
|
||||
kind, user_id, auth_key_id, session_id, message_box_id
|
||||
) VALUES ('login_message', $1, $2, 86, 860086)`, userID, base); err != nil {
|
||||
t.Fatalf("insert temporary-key bootstrap job fixture: %v", err)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "secret qts watermark on bound temporary key",
|
||||
wantMessage: "secret qts watermark references temporary auth key",
|
||||
setup: func(t *testing.T, ctx context.Context, tx pgx.Tx, base int64) {
|
||||
insertAuthKeyExpiryMigrationBoundPair(t, ctx, tx, base, base-1, 1_800_086_008)
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO public.secret_qts_watermarks (auth_key_id, reserved_qts, confirmed_qts)
|
||||
VALUES ($1, 1, 1)`, base); err != nil {
|
||||
t.Fatalf("insert temporary-key secret qts watermark fixture: %v", err)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "encrypted message queue on bound temporary key",
|
||||
wantMessage: "encrypted message queue references temporary auth key",
|
||||
setup: func(t *testing.T, ctx context.Context, tx pgx.Tx, base int64) {
|
||||
insertAuthKeyExpiryMigrationBoundPair(t, ctx, tx, base, base-1, 1_800_086_009)
|
||||
userID := base - 2
|
||||
insertAuthKeyExpiryMigrationUser(t, ctx, tx, userID)
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO public.encrypted_message_queue (
|
||||
receiver_auth_key_id, qts, receiver_user_id, chat_id, random_id, date, bytes
|
||||
) VALUES ($1, 1, $2, 860086, $3, 1, decode('86', 'hex'))`, base, userID, base-3); err != nil {
|
||||
t.Fatalf("insert temporary-key encrypted message queue fixture: %v", err)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "encrypted state delivery on bound temporary key",
|
||||
wantMessage: "encrypted state delivery references temporary auth key",
|
||||
setup: func(t *testing.T, ctx context.Context, tx pgx.Tx, base int64) {
|
||||
insertAuthKeyExpiryMigrationBoundPair(t, ctx, tx, base, base-1, 1_800_086_010)
|
||||
userID := base - 2
|
||||
eventID := base - 3
|
||||
insertAuthKeyExpiryMigrationUser(t, ctx, tx, userID)
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO public.encrypted_state_events (
|
||||
id, target_user_id, target_auth_key_id, chat_id, event_type, date
|
||||
) VALUES ($1, $2, $3, 860086, 1, 1)`, eventID, userID, base-1); err != nil {
|
||||
t.Fatalf("insert permanent-key encrypted state event fixture: %v", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO public.encrypted_state_event_delivery (event_id, auth_key_id)
|
||||
VALUES ($1, $2)`, eventID, base); err != nil {
|
||||
t.Fatalf("insert temporary-key encrypted state delivery fixture: %v", err)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "encrypted state event on bound temporary key",
|
||||
wantMessage: "encrypted state event targets temporary auth key",
|
||||
setup: func(t *testing.T, ctx context.Context, tx pgx.Tx, base int64) {
|
||||
insertAuthKeyExpiryMigrationBoundPair(t, ctx, tx, base, base-1, 1_800_086_011)
|
||||
userID := base - 2
|
||||
insertAuthKeyExpiryMigrationUser(t, ctx, tx, userID)
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO public.encrypted_state_events (
|
||||
id, target_user_id, target_auth_key_id, chat_id, event_type, date
|
||||
) VALUES ($1, $2, $3, 860086, 1, 1)`, base-3, userID, base); err != nil {
|
||||
t.Fatalf("insert temporary-key encrypted state event fixture: %v", err)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "secret chat admin on bound temporary key",
|
||||
wantMessage: "secret chat references temporary auth key",
|
||||
setup: func(t *testing.T, ctx context.Context, tx pgx.Tx, base int64) {
|
||||
insertAuthKeyExpiryMigrationBoundPair(t, ctx, tx, base, base-1, 1_800_086_012)
|
||||
adminUserID := base - 2
|
||||
participantUserID := base - 3
|
||||
insertAuthKeyExpiryMigrationUser(t, ctx, tx, adminUserID)
|
||||
insertAuthKeyExpiryMigrationUser(t, ctx, tx, participantUserID)
|
||||
insertAuthKeyExpiryMigrationSecretChat(t, ctx, tx, base, base-1, adminUserID, participantUserID)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "secret chat participant on bound temporary key",
|
||||
wantMessage: "secret chat references temporary auth key",
|
||||
setup: func(t *testing.T, ctx context.Context, tx pgx.Tx, base int64) {
|
||||
insertAuthKeyExpiryMigrationBoundPair(t, ctx, tx, base, base-1, 1_800_086_013)
|
||||
adminUserID := base - 2
|
||||
participantUserID := base - 3
|
||||
insertAuthKeyExpiryMigrationUser(t, ctx, tx, adminUserID)
|
||||
insertAuthKeyExpiryMigrationUser(t, ctx, tx, participantUserID)
|
||||
insertAuthKeyExpiryMigrationSecretChat(t, ctx, tx, base-1, base, adminUserID, participantUserID)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for i, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
tx, err := pool.Begin(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("begin invalid-state migration test: %v", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback(context.Background()) }()
|
||||
|
||||
if _, err := tx.Exec(ctx, downSQL); err != nil {
|
||||
t.Fatalf("return schema to 0085: %v", err)
|
||||
}
|
||||
test.setup(t, ctx, tx, authKeyExpiryMigrationBaseID()-int64(i*100))
|
||||
|
||||
_, err = tx.Exec(ctx, upSQL)
|
||||
if err == nil {
|
||||
t.Fatal("0086 migration accepted invalid identity state")
|
||||
}
|
||||
var pgErr *pgconn.PgError
|
||||
if !errors.As(err, &pgErr) || pgErr.Code != "P0001" || !strings.Contains(pgErr.Message, test.wantMessage) {
|
||||
t.Fatalf("0086 migration error = %v, want SQLSTATE P0001 containing %q", err, test.wantMessage)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func readAuthKeyExpiryMigration(t *testing.T, name string) string {
|
||||
t.Helper()
|
||||
sql, err := deploy.Migrations.ReadFile(name)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", name, err)
|
||||
}
|
||||
return string(sql)
|
||||
}
|
||||
|
||||
func authKeyExpiryMigrationBaseID() int64 {
|
||||
return -(time.Now().UnixNano() & 0x3fffffffffffffff)
|
||||
}
|
||||
|
||||
func insertAuthKeyExpiryMigrationKey(t *testing.T, ctx context.Context, tx pgx.Tx, authKeyID int64) {
|
||||
t.Helper()
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO public.auth_keys (auth_key_id, body, server_salt)
|
||||
VALUES ($1, decode('86', 'hex'), 86)`, authKeyID); err != nil {
|
||||
t.Fatalf("insert auth key %d: %v", authKeyID, err)
|
||||
}
|
||||
}
|
||||
|
||||
func insertAuthKeyExpiryMigrationBinding(t *testing.T, ctx context.Context, tx pgx.Tx, tempKeyID, permKeyID int64, expiresAt int) {
|
||||
t.Helper()
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO public.temp_auth_key_bindings (
|
||||
temp_auth_key_id, perm_auth_key_id, nonce, expires_at, encrypted_message, temp_session_id
|
||||
) VALUES ($1, $2, 86, $3, decode('86', 'hex'), 86)`, tempKeyID, permKeyID, expiresAt); err != nil {
|
||||
t.Fatalf("insert temp auth-key binding %d -> %d: %v", tempKeyID, permKeyID, err)
|
||||
}
|
||||
}
|
||||
|
||||
func insertAuthKeyExpiryMigrationBoundPair(t *testing.T, ctx context.Context, tx pgx.Tx, tempKeyID, permKeyID int64, expiresAt int) {
|
||||
t.Helper()
|
||||
insertAuthKeyExpiryMigrationKey(t, ctx, tx, tempKeyID)
|
||||
insertAuthKeyExpiryMigrationKey(t, ctx, tx, permKeyID)
|
||||
insertAuthKeyExpiryMigrationBinding(t, ctx, tx, tempKeyID, permKeyID, expiresAt)
|
||||
}
|
||||
|
||||
func insertAuthKeyExpiryMigrationUser(t *testing.T, ctx context.Context, tx pgx.Tx, userID int64) {
|
||||
t.Helper()
|
||||
phone := fmt.Sprintf("+860086%d", -userID)
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO public.users (id, access_hash, phone, first_name)
|
||||
VALUES ($1, $2, $3, 'migration-0086')`, userID, userID-1, phone); err != nil {
|
||||
t.Fatalf("insert migration user %d: %v", userID, err)
|
||||
}
|
||||
}
|
||||
|
||||
func insertAuthKeyExpiryMigrationSecretChat(
|
||||
t *testing.T,
|
||||
ctx context.Context,
|
||||
tx pgx.Tx,
|
||||
adminAuthKeyID int64,
|
||||
participantAuthKeyID int64,
|
||||
adminUserID int64,
|
||||
participantUserID int64,
|
||||
) {
|
||||
t.Helper()
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO public.secret_chats (
|
||||
chat_id, admin_access_hash, participant_access_hash,
|
||||
admin_user_id, admin_auth_key_id, participant_user_id, participant_auth_key_id,
|
||||
state, random_id, date
|
||||
) VALUES (
|
||||
860086, 86, 87,
|
||||
$1, $2, $3, $4,
|
||||
'waiting', 86, 1
|
||||
)`, adminUserID, adminAuthKeyID, participantUserID, participantAuthKeyID); err != nil {
|
||||
t.Fatalf("insert temporary-key secret chat fixture: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertAuthKeyExpiryMigrationForeignKeyViolation(t *testing.T, ctx context.Context, tx pgx.Tx, action func(pgx.Tx) error) {
|
||||
t.Helper()
|
||||
nested, err := tx.Begin(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("begin FK assertion savepoint: %v", err)
|
||||
}
|
||||
defer func() { _ = nested.Rollback(context.Background()) }()
|
||||
|
||||
err = action(nested)
|
||||
if err == nil {
|
||||
t.Fatal("operation bypassed permanent auth-key RESTRICT FK")
|
||||
}
|
||||
var pgErr *pgconn.PgError
|
||||
if !errors.As(err, &pgErr) || pgErr.Code != "23503" || pgErr.ConstraintName != "temp_auth_key_bindings_perm_auth_key_id_fkey" {
|
||||
t.Fatalf("FK error = %v, want SQLSTATE 23503 from temp_auth_key_bindings_perm_auth_key_id_fkey", err)
|
||||
}
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@ import (
|
|||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
|
||||
"telesrv/internal/store"
|
||||
|
|
@ -28,14 +29,24 @@ func NewAuthKeyStore(db sqlcgen.DBTX) *AuthKeyStore {
|
|||
// Save 实现 store.AuthKeyStore。auth_key_id 以小端解释为 int64 存入 BIGINT;
|
||||
// created_at/last_used_at 交由 DB 默认值(now()),故传入的 CreatedAt 不落库。
|
||||
func (s *AuthKeyStore) Save(ctx context.Context, k store.AuthKeyData) error {
|
||||
if _, err := s.db.Exec(ctx, `
|
||||
INSERT INTO auth_keys (auth_key_id, body, server_salt)
|
||||
VALUES ($1, $2, $3)
|
||||
if !store.ValidNewAuthKeyProtocolExpiry(k.ExpiresAt) {
|
||||
return store.ErrInvalidAuthKeyProtocolExpiry
|
||||
}
|
||||
tag, err := s.db.Exec(ctx, `
|
||||
INSERT INTO auth_keys (auth_key_id, body, server_salt, expires_at)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (auth_key_id) DO UPDATE
|
||||
SET body = EXCLUDED.body, server_salt = EXCLUDED.server_salt, last_used_at = now()
|
||||
`, authKeyIDToInt64(k.ID), k.Value[:], k.ServerSalt); err != nil {
|
||||
SET server_salt = EXCLUDED.server_salt,
|
||||
last_used_at = now()
|
||||
WHERE auth_keys.body = EXCLUDED.body
|
||||
AND auth_keys.expires_at = EXCLUDED.expires_at
|
||||
`, authKeyIDToInt64(k.ID), k.Value[:], k.ServerSalt, k.ExpiresAt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("upsert auth key: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() != 1 {
|
||||
return store.ErrAuthKeyProtocolMetadataConflict
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
@ -47,6 +58,7 @@ func (s *AuthKeyStore) Get(ctx context.Context, id [8]byte) (store.AuthKeyData,
|
|||
var (
|
||||
body []byte
|
||||
serverSalt int64
|
||||
expiresAt int
|
||||
createdAt pgtype.Timestamptz
|
||||
layer int
|
||||
deviceModel string
|
||||
|
|
@ -60,8 +72,8 @@ UPDATE auth_keys
|
|||
SET last_used_at = now()
|
||||
WHERE auth_key_id = $1
|
||||
RETURNING auth_key_id, body, server_salt, created_at,
|
||||
layer, device_model, platform, system_version, api_id, app_version
|
||||
`, authKeyIDToInt64(id)).Scan(new(int64), &body, &serverSalt, &createdAt, &layer, &deviceModel, &platform, &systemVersion, &apiID, &appVersion)
|
||||
expires_at, layer, device_model, platform, system_version, api_id, app_version
|
||||
`, authKeyIDToInt64(id)).Scan(new(int64), &body, &serverSalt, &createdAt, &expiresAt, &layer, &deviceModel, &platform, &systemVersion, &apiID, &appVersion)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return store.AuthKeyData{}, false, nil
|
||||
|
|
@ -74,6 +86,7 @@ RETURNING auth_key_id, body, server_salt, created_at,
|
|||
data := store.AuthKeyData{
|
||||
ID: id,
|
||||
ServerSalt: serverSalt,
|
||||
ExpiresAt: expiresAt,
|
||||
Layer: layer,
|
||||
DeviceModel: deviceModel,
|
||||
Platform: platform,
|
||||
|
|
@ -154,10 +167,26 @@ WHERE auth_key_id = $1
|
|||
// 手写 SQL 而非 sqlc 生成:避免触碰 sqlcgen 再生成链路。
|
||||
//
|
||||
// 同时清理把本 key 当作 perm key 的 temp auth key 行:temp_auth_key_bindings.temp_auth_key_id
|
||||
// 侧有外键 ON DELETE CASCADE,删除 temp key 会自动清绑定;perm_auth_key_id 列无外键,
|
||||
// 因此被踢/登出删除 perm key 时必须先把关联 temp key 一并删掉。否则 Web/上传连接用
|
||||
// 侧有外键 ON DELETE CASCADE,删除 temp key 会自动清绑定;perm_auth_key_id 侧由
|
||||
// RESTRICT FK 防止悬空,因此被踢/销毁 perm key 时必须先把关联 temp key 一并删掉。否则 Web/上传连接用
|
||||
// raw temp key 重连时仍能进入 RPC 层,只得到 AUTH_KEY_UNREGISTERED,而不是连接层 404。
|
||||
func (s *AuthKeyStore) Delete(ctx context.Context, id [8]byte) error {
|
||||
for attempt := 0; attempt < 3; attempt++ {
|
||||
err := s.deleteAuthKeyOnce(ctx, id)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if !isPermAuthKeyDeleteRace(err) {
|
||||
return err
|
||||
}
|
||||
if _, inTx := s.db.(pgx.Tx); inTx {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("delete auth key: permanent-key binding changed during all retries")
|
||||
}
|
||||
|
||||
func (s *AuthKeyStore) deleteAuthKeyOnce(ctx context.Context, id [8]byte) error {
|
||||
keyID := authKeyIDToInt64(id)
|
||||
var touched int
|
||||
if err := s.db.QueryRow(ctx, `
|
||||
|
|
@ -177,17 +206,32 @@ WITH doomed_temp AS MATERIALIZED (
|
|||
RETURNING auth_key_id
|
||||
), deleted_temp AS (
|
||||
DELETE FROM auth_keys
|
||||
WHERE auth_key_id IN (SELECT auth_key_id FROM doomed_keys)
|
||||
WHERE auth_key_id IN (SELECT temp_auth_key_id FROM doomed_temp)
|
||||
RETURNING auth_key_id
|
||||
), deleted_key AS (
|
||||
DELETE FROM auth_keys
|
||||
WHERE auth_key_id = $1
|
||||
AND (SELECT count(*) FROM deleted_temp) >= 0
|
||||
RETURNING auth_key_id
|
||||
)
|
||||
SELECT
|
||||
(SELECT count(*) FROM deleted_update_states)::int +
|
||||
(SELECT count(*) FROM deleted_temp)::int`, keyID).Scan(&touched); err != nil {
|
||||
(SELECT count(*) FROM deleted_temp)::int +
|
||||
(SELECT count(*) FROM deleted_key)::int`, keyID).Scan(&touched); err != nil {
|
||||
return fmt.Errorf("delete auth key and temp bindings: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
const tempAuthKeyPermFKConstraint = "temp_auth_key_bindings_perm_auth_key_id_fkey"
|
||||
|
||||
func isPermAuthKeyDeleteRace(err error) bool {
|
||||
var pgErr *pgconn.PgError
|
||||
return errors.As(err, &pgErr) &&
|
||||
pgErr.Code == "23503" &&
|
||||
pgErr.ConstraintName == tempAuthKeyPermFKConstraint
|
||||
}
|
||||
|
||||
// DeleteOrphaned 回收握手已落库、但从未形成 authorization/temp binding 且当前没有
|
||||
// 活跃物理连接的旧 auth key。last_used_at 与 Get 的 UPDATE ... RETURNING 行锁配对,封住
|
||||
// active-key 快照之后新连接开始使用旧 key 的竞态;所有引用条件仍在最终 DELETE 中复核。
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package postgres
|
|||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
|
|
@ -47,7 +48,12 @@ func TestAuthKeyStoreRoundTrip(t *testing.T) {
|
|||
_, _ = pool.Exec(ctx, "DELETE FROM auth_keys WHERE auth_key_id = $1", authKeyIDToInt64(id))
|
||||
})
|
||||
|
||||
want := store.AuthKeyData{ID: id, Value: val, ServerSalt: 0x0badf00d}
|
||||
want := store.AuthKeyData{
|
||||
ID: id,
|
||||
Value: val,
|
||||
ServerSalt: 0x0badf00d,
|
||||
ExpiresAt: 1_799_999_999,
|
||||
}
|
||||
if err := NewAuthKeyStore(pool).Save(ctx, want); err != nil {
|
||||
t.Fatalf("save: %v", err)
|
||||
}
|
||||
|
|
@ -59,9 +65,18 @@ func TestAuthKeyStoreRoundTrip(t *testing.T) {
|
|||
if !found {
|
||||
t.Fatal("auth key not found after save (重启后丢失)")
|
||||
}
|
||||
if got.ID != want.ID || got.Value != want.Value || got.ServerSalt != want.ServerSalt {
|
||||
t.Fatalf("round trip mismatch: got salt=%#x value[:4]=%x, want salt=%#x value[:4]=%x",
|
||||
got.ServerSalt, got.Value[:4], want.ServerSalt, want.Value[:4])
|
||||
if got.ID != want.ID || got.Value != want.Value || got.ServerSalt != want.ServerSalt || got.ExpiresAt != want.ExpiresAt {
|
||||
t.Fatalf("round trip mismatch: got salt=%#x expires_at=%d value[:4]=%x, want salt=%#x expires_at=%d value[:4]=%x",
|
||||
got.ServerSalt, got.ExpiresAt, got.Value[:4], want.ServerSalt, want.ExpiresAt, want.Value[:4])
|
||||
}
|
||||
conflicting := want
|
||||
conflicting.ExpiresAt++
|
||||
if err := NewAuthKeyStore(pool).Save(ctx, conflicting); !errors.Is(err, store.ErrAuthKeyProtocolMetadataConflict) {
|
||||
t.Fatalf("reclassify auth key error = %v, want %v", err, store.ErrAuthKeyProtocolMetadataConflict)
|
||||
}
|
||||
got, found, err = NewAuthKeyStore(pool).Get(ctx, id)
|
||||
if err != nil || !found || got.ExpiresAt != want.ExpiresAt {
|
||||
t.Fatalf("auth key expiry changed after rejected reclassification: got=%d found=%v err=%v", got.ExpiresAt, found, err)
|
||||
}
|
||||
|
||||
var missing [8]byte
|
||||
|
|
|
|||
|
|
@ -17,22 +17,23 @@ func TestAuthKeyStoreDeleteOrphanedIsBoundedAndProtectsReferencesPostgres(t *tes
|
|||
auths := NewAuthorizationStore(pool)
|
||||
userID := createRevokeTestUser(t, ctx, pool, "orphan-auth-key")
|
||||
|
||||
newKey := func() [8]byte {
|
||||
newKey := func(expiresAt int) [8]byte {
|
||||
var id [8]byte
|
||||
if _, err := rand.Read(id[:]); err != nil {
|
||||
t.Fatalf("random auth key id: %v", err)
|
||||
}
|
||||
if err := keys.Save(ctx, store.AuthKeyData{ID: id}); err != nil {
|
||||
if err := keys.Save(ctx, store.AuthKeyData{ID: id, ExpiresAt: expiresAt}); err != nil {
|
||||
t.Fatalf("save auth key %x: %v", id, err)
|
||||
}
|
||||
t.Cleanup(func() { _ = keys.Delete(ctx, id) })
|
||||
return id
|
||||
}
|
||||
orphanOne, orphanTwo := newKey(), newKey()
|
||||
recent := newKey()
|
||||
authorized := newKey()
|
||||
temp, perm := newKey(), newKey()
|
||||
active := newKey()
|
||||
tempExpiry := int(time.Now().Add(time.Hour).Unix())
|
||||
orphanOne, orphanTwo := newKey(0), newKey(0)
|
||||
recent := newKey(0)
|
||||
authorized := newKey(0)
|
||||
temp, perm := newKey(tempExpiry), newKey(0)
|
||||
active := newKey(0)
|
||||
if _, err := pool.Exec(ctx, `
|
||||
INSERT INTO update_states (auth_key_id, user_id, pts, observed_pts)
|
||||
VALUES ($1, $3, 0, 0), ($2, $3, 0, 0)`,
|
||||
|
|
@ -45,7 +46,7 @@ VALUES ($1, $3, 0, 0), ($2, $3, 0, 0)`,
|
|||
}
|
||||
if err := NewTempAuthKeyBindingStore(pool).Save(ctx, domain.TempAuthKeyBinding{
|
||||
TempAuthKeyID: temp, PermAuthKeyID: authKeyIDToInt64(perm), Nonce: 1,
|
||||
TempSessionID: 2, ExpiresAt: int(time.Now().Add(time.Hour).Unix()), EncryptedMessage: []byte{1},
|
||||
TempSessionID: 2, ExpiresAt: tempExpiry, EncryptedMessage: []byte{1},
|
||||
}); err != nil {
|
||||
t.Fatalf("save temp binding: %v", err)
|
||||
}
|
||||
|
|
@ -107,8 +108,9 @@ func TestAuthKeyStoreDeleteCleansPermanentAndTempUpdateStatesPostgres(t *testing
|
|||
userID := createRevokeTestUser(t, ctx, pool, "auth-key-delete-state")
|
||||
perm := randomUpdateRetentionAuthKey(t)
|
||||
temp := randomUpdateRetentionAuthKey(t)
|
||||
for _, id := range [][8]byte{perm, temp} {
|
||||
if err := keys.Save(ctx, store.AuthKeyData{ID: id}); err != nil {
|
||||
tempExpiry := int(time.Now().Add(time.Hour).Unix())
|
||||
for id, expiresAt := range map[[8]byte]int{perm: 0, temp: tempExpiry} {
|
||||
if err := keys.Save(ctx, store.AuthKeyData{ID: id, ExpiresAt: expiresAt}); err != nil {
|
||||
t.Fatalf("save auth key %x: %v", id, err)
|
||||
}
|
||||
id := id
|
||||
|
|
@ -119,7 +121,7 @@ func TestAuthKeyStoreDeleteCleansPermanentAndTempUpdateStatesPostgres(t *testing
|
|||
PermAuthKeyID: authKeyIDToInt64(perm),
|
||||
Nonce: 31,
|
||||
TempSessionID: 32,
|
||||
ExpiresAt: int(time.Now().Add(time.Hour).Unix()),
|
||||
ExpiresAt: tempExpiry,
|
||||
EncryptedMessage: []byte{1},
|
||||
}); err != nil {
|
||||
t.Fatalf("save temp binding: %v", err)
|
||||
|
|
@ -147,6 +149,61 @@ SELECT
|
|||
}
|
||||
}
|
||||
|
||||
func TestTempAuthKeyRetentionUsesAuthKeyExpiryForBoundAndUnboundKeysPostgres(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
keys := NewAuthKeyStore(pool)
|
||||
bindings := NewTempAuthKeyBindingStore(pool)
|
||||
cutoff := int64(time.Now().Add(-time.Hour).Unix())
|
||||
unbound := randomUpdateRetentionAuthKey(t)
|
||||
bound := randomUpdateRetentionAuthKey(t)
|
||||
live := randomUpdateRetentionAuthKey(t)
|
||||
perm := randomUpdateRetentionAuthKey(t)
|
||||
expiries := map[[8]byte]int{
|
||||
unbound: int(cutoff - 2),
|
||||
bound: int(cutoff - 1),
|
||||
live: int(cutoff + 1),
|
||||
perm: 0,
|
||||
}
|
||||
for id, expiresAt := range expiries {
|
||||
if err := keys.Save(ctx, store.AuthKeyData{ID: id, ExpiresAt: expiresAt}); err != nil {
|
||||
t.Fatalf("save key %x: %v", id, err)
|
||||
}
|
||||
id := id
|
||||
t.Cleanup(func() { _ = keys.Delete(ctx, id) })
|
||||
}
|
||||
if err := bindings.Save(ctx, domain.TempAuthKeyBinding{
|
||||
TempAuthKeyID: bound, PermAuthKeyID: authKeyIDToInt64(perm), Nonce: 41,
|
||||
TempSessionID: 42, ExpiresAt: expiries[bound], EncryptedMessage: []byte{1},
|
||||
}); err != nil {
|
||||
t.Fatalf("save expired bound key: %v", err)
|
||||
}
|
||||
|
||||
deleted, err := bindings.DeleteExpired(ctx, cutoff, 1)
|
||||
if err != nil || deleted != 1 {
|
||||
t.Fatalf("first bounded expiry delete = %d/%v, want 1/nil", deleted, err)
|
||||
}
|
||||
if _, found, err := keys.Get(ctx, unbound); err != nil || found {
|
||||
t.Fatalf("earliest unbound temp found=%v err=%v, want deleted", found, err)
|
||||
}
|
||||
if _, found, err := keys.Get(ctx, bound); err != nil || !found {
|
||||
t.Fatalf("second expired bound temp found=%v err=%v, want retained after limit=1", found, err)
|
||||
}
|
||||
|
||||
deleted, err = bindings.DeleteExpired(ctx, cutoff, 10)
|
||||
if err != nil || deleted != 1 {
|
||||
t.Fatalf("second expiry delete = %d/%v, want 1/nil", deleted, err)
|
||||
}
|
||||
if _, found, err := bindings.GetByTemp(ctx, bound); err != nil || found {
|
||||
t.Fatalf("binding after temp key cascade found=%v err=%v, want absent", found, err)
|
||||
}
|
||||
for name, id := range map[string][8]byte{"live temp": live, "permanent": perm} {
|
||||
if _, found, err := keys.Get(ctx, id); err != nil || !found {
|
||||
t.Fatalf("%s found=%v err=%v, want retained", name, found, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthKeyGetTouchPreventsOrphanCollectionPostgres(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import (
|
|||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
"telesrv/internal/store/postgres/sqlcgen"
|
||||
)
|
||||
|
||||
|
|
@ -54,14 +55,20 @@ func (s *AuthorizationStore) Bind(ctx context.Context, a domain.Authorization) e
|
|||
// raw auth key 的并发登录/换号。
|
||||
func bindAuthorization(ctx context.Context, db sqlcgen.DBTX, a domain.Authorization) error {
|
||||
keyID := authKeyIDToInt64(a.AuthKeyID)
|
||||
var lockedKeyID int64
|
||||
var (
|
||||
lockedKeyID int64
|
||||
expiresAt int
|
||||
)
|
||||
if err := db.QueryRow(ctx, `
|
||||
SELECT auth_key_id
|
||||
SELECT auth_key_id, expires_at
|
||||
FROM auth_keys
|
||||
WHERE auth_key_id = $1
|
||||
FOR UPDATE`, keyID).Scan(&lockedKeyID); err != nil {
|
||||
FOR UPDATE`, keyID).Scan(&lockedKeyID, &expiresAt); err != nil {
|
||||
return fmt.Errorf("lock auth key for authorization: %w", err)
|
||||
}
|
||||
if expiresAt != 0 {
|
||||
return store.ErrAuthKeyNotPermanent
|
||||
}
|
||||
|
||||
if _, err := db.Exec(ctx, `
|
||||
INSERT INTO user_update_watermarks (user_id, contiguous_pts)
|
||||
|
|
@ -254,43 +261,81 @@ RETURNING auth_key_id, user_id, hash, layer, device_model, platform, system_vers
|
|||
// authorizations 通过 FK cascade 删除;update_states 没有 auth_keys FK,必须显式清理;
|
||||
// 关联 temp auth key 也显式删除,避免 raw temp key 重连。
|
||||
func (s *AuthorizationStore) RevokeByHash(ctx context.Context, userID, hash int64) (domain.Authorization, bool, error) {
|
||||
row := s.db.QueryRow(ctx, `
|
||||
WITH target AS MATERIALIZED (
|
||||
SELECT auth_key_id, user_id, hash, layer, device_model, platform, system_version, api_id, app_version, ip, password_pending, created_at, active_at
|
||||
FROM authorizations
|
||||
WHERE user_id = $1 AND hash = $2
|
||||
), deleted_temp AS (
|
||||
DELETE FROM auth_keys
|
||||
WHERE auth_key_id IN (
|
||||
SELECT temp_auth_key_id
|
||||
FROM temp_auth_key_bindings
|
||||
WHERE perm_auth_key_id IN (SELECT auth_key_id FROM target)
|
||||
)
|
||||
RETURNING auth_key_id
|
||||
), deleted_update_states AS (
|
||||
DELETE FROM update_states
|
||||
WHERE auth_key_id IN (SELECT auth_key_id FROM target)
|
||||
RETURNING auth_key_id
|
||||
), deleted_keys AS (
|
||||
DELETE FROM auth_keys
|
||||
WHERE auth_key_id IN (SELECT auth_key_id FROM target)
|
||||
RETURNING auth_key_id
|
||||
), touched AS (
|
||||
SELECT
|
||||
(SELECT count(*) FROM deleted_temp) +
|
||||
(SELECT count(*) FROM deleted_update_states) AS count
|
||||
)
|
||||
SELECT target.auth_key_id, target.user_id, target.hash, target.layer, target.device_model, target.platform,
|
||||
target.system_version, target.api_id, target.app_version, target.ip, target.password_pending,
|
||||
target.created_at, target.active_at
|
||||
FROM target
|
||||
JOIN deleted_keys USING (auth_key_id)
|
||||
CROSS JOIN touched`, userID, hash)
|
||||
a, found, err := scanRevokedAuthorization(row)
|
||||
if err != nil {
|
||||
return domain.Authorization{}, false, fmt.Errorf("revoke authorization by hash: %w", err)
|
||||
for attempt := 0; attempt < 3; attempt++ {
|
||||
var (
|
||||
a domain.Authorization
|
||||
found bool
|
||||
)
|
||||
err := s.withRevocationTx(ctx, "revoke authorization by hash", func(tx pgx.Tx) error {
|
||||
var err error
|
||||
a, found, err = revokeByHashTx(ctx, tx, userID, hash)
|
||||
return err
|
||||
})
|
||||
if err == nil {
|
||||
return a, found, nil
|
||||
}
|
||||
if !isPermAuthKeyDeleteRace(err) {
|
||||
return domain.Authorization{}, false, err
|
||||
}
|
||||
if _, inTx := s.db.(pgx.Tx); inTx {
|
||||
return domain.Authorization{}, false, err
|
||||
}
|
||||
}
|
||||
return a, found, nil
|
||||
return domain.Authorization{}, false, fmt.Errorf("revoke authorization by hash: permanent-key binding changed during all retries")
|
||||
}
|
||||
|
||||
func (s *AuthorizationStore) withRevocationTx(ctx context.Context, op string, fn func(pgx.Tx) error) error {
|
||||
if tx, ok := s.db.(pgx.Tx); ok {
|
||||
return fn(tx)
|
||||
}
|
||||
return withTx(ctx, s.db, op, fn)
|
||||
}
|
||||
|
||||
// revokeByHashTx deliberately uses separate READ COMMITTED statements. The first
|
||||
// lookup is only a candidate. Bind locks auth_keys before changing authorization
|
||||
// ownership, so revocation must lock the same parent row and then re-read the
|
||||
// owner/hash from a fresh statement snapshot. Otherwise an A->B re-login that
|
||||
// commits while revoke waits can be deleted using A's stale target snapshot.
|
||||
func revokeByHashTx(ctx context.Context, tx pgx.Tx, userID, hash int64) (domain.Authorization, bool, error) {
|
||||
var candidate int64
|
||||
if err := tx.QueryRow(ctx, `
|
||||
SELECT auth_key_id
|
||||
FROM authorizations
|
||||
WHERE user_id = $1 AND hash = $2`, userID, hash).Scan(&candidate); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.Authorization{}, false, nil
|
||||
}
|
||||
return domain.Authorization{}, false, fmt.Errorf("select revoke candidate by hash: %w", err)
|
||||
}
|
||||
|
||||
var locked int64
|
||||
if err := tx.QueryRow(ctx, `
|
||||
SELECT auth_key_id
|
||||
FROM auth_keys
|
||||
WHERE auth_key_id = $1
|
||||
FOR UPDATE`, candidate).Scan(&locked); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.Authorization{}, false, nil
|
||||
}
|
||||
return domain.Authorization{}, false, fmt.Errorf("lock revoke auth key by hash: %w", err)
|
||||
}
|
||||
|
||||
a, found, err := scanRevokedAuthorization(tx.QueryRow(ctx, `
|
||||
SELECT auth_key_id, user_id, hash, layer, device_model, platform, system_version,
|
||||
api_id, app_version, ip, password_pending, created_at, active_at
|
||||
FROM authorizations
|
||||
WHERE auth_key_id = $1 AND user_id = $2 AND hash = $3
|
||||
FOR UPDATE`, candidate, userID, hash))
|
||||
if err != nil {
|
||||
return domain.Authorization{}, false, fmt.Errorf("revalidate revoke authorization by hash: %w", err)
|
||||
}
|
||||
if !found {
|
||||
return domain.Authorization{}, false, nil
|
||||
}
|
||||
if err := deleteRevocationTargetsTx(ctx, tx, []int64{candidate}); err != nil {
|
||||
return domain.Authorization{}, false, err
|
||||
}
|
||||
return a, true, nil
|
||||
}
|
||||
|
||||
func (s *AuthorizationStore) DeleteByUserExcept(ctx context.Context, userID int64, keepAuthKeyID [8]byte) ([]domain.Authorization, error) {
|
||||
|
|
@ -323,57 +368,148 @@ RETURNING auth_key_id, user_id, hash, layer, device_model, platform, system_vers
|
|||
|
||||
// RevokeByUserExcept 批量删除协议 auth_key,保留 keepAuthKeyID 对应的当前设备。
|
||||
func (s *AuthorizationStore) RevokeByUserExcept(ctx context.Context, userID int64, keepAuthKeyID [8]byte) ([]domain.Authorization, error) {
|
||||
rows, err := s.db.Query(ctx, `
|
||||
WITH target AS MATERIALIZED (
|
||||
SELECT auth_key_id, user_id, hash, layer, device_model, platform, system_version, api_id, app_version, ip, password_pending, created_at, active_at
|
||||
FROM authorizations
|
||||
WHERE user_id = $1 AND auth_key_id <> $2
|
||||
), deleted_temp AS (
|
||||
DELETE FROM auth_keys
|
||||
WHERE auth_key_id IN (
|
||||
SELECT temp_auth_key_id
|
||||
FROM temp_auth_key_bindings
|
||||
WHERE perm_auth_key_id IN (SELECT auth_key_id FROM target)
|
||||
)
|
||||
RETURNING auth_key_id
|
||||
), deleted_update_states AS (
|
||||
DELETE FROM update_states
|
||||
WHERE auth_key_id IN (SELECT auth_key_id FROM target)
|
||||
RETURNING auth_key_id
|
||||
), deleted_keys AS (
|
||||
DELETE FROM auth_keys
|
||||
WHERE auth_key_id IN (SELECT auth_key_id FROM target)
|
||||
RETURNING auth_key_id
|
||||
), touched AS (
|
||||
SELECT
|
||||
(SELECT count(*) FROM deleted_temp) +
|
||||
(SELECT count(*) FROM deleted_update_states) AS count
|
||||
)
|
||||
SELECT target.auth_key_id, target.user_id, target.hash, target.layer, target.device_model, target.platform,
|
||||
target.system_version, target.api_id, target.app_version, target.ip, target.password_pending,
|
||||
target.created_at, target.active_at
|
||||
FROM target
|
||||
JOIN deleted_keys USING (auth_key_id)
|
||||
CROSS JOIN touched
|
||||
ORDER BY target.created_at, target.auth_key_id`, userID, authKeyIDToInt64(keepAuthKeyID))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("revoke authorizations by user: %w", err)
|
||||
for attempt := 0; attempt < 3; attempt++ {
|
||||
var out []domain.Authorization
|
||||
err := s.withRevocationTx(ctx, "revoke authorizations by user", func(tx pgx.Tx) error {
|
||||
var err error
|
||||
out, err = revokeByUserExceptTx(ctx, tx, userID, authKeyIDToInt64(keepAuthKeyID))
|
||||
return err
|
||||
})
|
||||
if err == nil {
|
||||
return out, nil
|
||||
}
|
||||
if !isPermAuthKeyDeleteRace(err) {
|
||||
return nil, err
|
||||
}
|
||||
if _, inTx := s.db.(pgx.Tx); inTx {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("revoke authorizations by user: permanent-key binding changed during all retries")
|
||||
}
|
||||
|
||||
func revokeByUserExceptTx(ctx context.Context, tx pgx.Tx, userID, keepAuthKeyID int64) ([]domain.Authorization, error) {
|
||||
candidateRows, err := tx.Query(ctx, `
|
||||
SELECT auth_key_id
|
||||
FROM authorizations
|
||||
WHERE user_id = $1 AND auth_key_id <> $2
|
||||
ORDER BY auth_key_id`, userID, keepAuthKeyID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("select revoke candidates by user: %w", err)
|
||||
}
|
||||
candidates := make([]int64, 0)
|
||||
for candidateRows.Next() {
|
||||
var id int64
|
||||
if err := candidateRows.Scan(&id); err != nil {
|
||||
candidateRows.Close()
|
||||
return nil, fmt.Errorf("scan revoke candidate by user: %w", err)
|
||||
}
|
||||
candidates = append(candidates, id)
|
||||
}
|
||||
if err := candidateRows.Err(); err != nil {
|
||||
candidateRows.Close()
|
||||
return nil, fmt.Errorf("iterate revoke candidates by user: %w", err)
|
||||
}
|
||||
candidateRows.Close()
|
||||
if len(candidates) == 0 {
|
||||
return []domain.Authorization{}, nil
|
||||
}
|
||||
|
||||
// Stable parent-row lock order matches every concurrent batch revocation and
|
||||
// serializes each candidate with Bind's auth_keys-first ownership change.
|
||||
lockRows, err := tx.Query(ctx, `
|
||||
SELECT auth_key_id
|
||||
FROM auth_keys
|
||||
WHERE auth_key_id = ANY($1::bigint[])
|
||||
ORDER BY auth_key_id
|
||||
FOR UPDATE`, candidates)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("lock revoke auth keys by user: %w", err)
|
||||
}
|
||||
for lockRows.Next() {
|
||||
var ignored int64
|
||||
if err := lockRows.Scan(&ignored); err != nil {
|
||||
lockRows.Close()
|
||||
return nil, fmt.Errorf("scan locked revoke auth key: %w", err)
|
||||
}
|
||||
}
|
||||
if err := lockRows.Err(); err != nil {
|
||||
lockRows.Close()
|
||||
return nil, fmt.Errorf("iterate locked revoke auth keys: %w", err)
|
||||
}
|
||||
lockRows.Close()
|
||||
|
||||
// This is intentionally a new statement snapshot after all parent locks.
|
||||
// Keys that changed owner while waiting are omitted and must remain intact.
|
||||
rows, err := tx.Query(ctx, `
|
||||
SELECT auth_key_id, user_id, hash, layer, device_model, platform, system_version,
|
||||
api_id, app_version, ip, password_pending, created_at, active_at
|
||||
FROM authorizations
|
||||
WHERE user_id = $1
|
||||
AND auth_key_id <> $2
|
||||
AND auth_key_id = ANY($3::bigint[])
|
||||
ORDER BY created_at, auth_key_id
|
||||
FOR UPDATE`, userID, keepAuthKeyID, candidates)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("revalidate revoke authorizations by user: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]domain.Authorization, 0)
|
||||
for rows.Next() {
|
||||
a, err := scanRevokedAuthorizationRow(rows)
|
||||
if err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, a)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
rows.Close()
|
||||
return nil, fmt.Errorf("iterate revoked authorizations: %w", err)
|
||||
}
|
||||
rows.Close()
|
||||
if len(out) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
targets := make([]int64, len(out))
|
||||
for i := range out {
|
||||
targets[i] = authKeyIDToInt64(out[i].AuthKeyID)
|
||||
}
|
||||
if err := deleteRevocationTargetsTx(ctx, tx, targets); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func deleteRevocationTargetsTx(ctx context.Context, tx pgx.Tx, authKeyIDs []int64) error {
|
||||
if len(authKeyIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
DELETE FROM auth_keys
|
||||
WHERE auth_key_id IN (
|
||||
SELECT temp_auth_key_id
|
||||
FROM temp_auth_key_bindings
|
||||
WHERE perm_auth_key_id = ANY($1::bigint[])
|
||||
)`, authKeyIDs); err != nil {
|
||||
return fmt.Errorf("delete revoked temporary auth keys: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
DELETE FROM update_states
|
||||
WHERE auth_key_id = ANY($1::bigint[])`, authKeyIDs); err != nil {
|
||||
return fmt.Errorf("delete revoked update states: %w", err)
|
||||
}
|
||||
tag, err := tx.Exec(ctx, `
|
||||
DELETE FROM auth_keys
|
||||
WHERE auth_key_id = ANY($1::bigint[])`, authKeyIDs)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete revoked permanent auth keys: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() != int64(len(authKeyIDs)) {
|
||||
return fmt.Errorf("delete revoked permanent auth keys: deleted %d of %d locked targets", tag.RowsAffected(), len(authKeyIDs))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func authorizationFromRow(row sqlcgen.Authorization) domain.Authorization {
|
||||
return domain.Authorization{
|
||||
AuthKeyID: authKeyIDFromInt64(row.AuthKeyID),
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package postgres
|
|||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
|
@ -20,9 +21,10 @@ func TestAuthorizationStoreRevokeByHashDeletesProtocolKeyCascadePostgres(t *test
|
|||
temp := revokeTestAuthKeyID(0x92)
|
||||
keys := NewAuthKeyStore(pool)
|
||||
auths := NewAuthorizationStore(pool)
|
||||
tempExpiry := int(time.Now().Add(time.Hour).Unix())
|
||||
|
||||
saveRevokeTestAuthKey(t, ctx, keys, perm)
|
||||
saveRevokeTestAuthKey(t, ctx, keys, temp)
|
||||
saveRevokeTestAuthKey(t, ctx, keys, perm, 0)
|
||||
saveRevokeTestAuthKey(t, ctx, keys, temp, tempExpiry)
|
||||
if err := auths.Bind(ctx, domain.Authorization{
|
||||
AuthKeyID: perm,
|
||||
UserID: userID,
|
||||
|
|
@ -46,7 +48,7 @@ func TestAuthorizationStoreRevokeByHashDeletesProtocolKeyCascadePostgres(t *test
|
|||
PermAuthKeyID: authKeyIDToInt64(perm),
|
||||
Nonce: 1,
|
||||
TempSessionID: 2,
|
||||
ExpiresAt: int(time.Now().Add(time.Hour).Unix()),
|
||||
ExpiresAt: tempExpiry,
|
||||
EncryptedMessage: []byte{1, 2, 3, 4},
|
||||
}); err != nil {
|
||||
t.Fatalf("save temp binding: %v", err)
|
||||
|
|
@ -77,20 +79,20 @@ func TestAuthorizationStoreRevokeByUserExceptDeletesOnlyRevokedKeysPostgres(t *t
|
|||
revokedTwo := revokeTestAuthKeyID(0xa3)
|
||||
tempForTwo := revokeTestAuthKeyID(0xa4)
|
||||
|
||||
for i, key := range [][8]byte{keep, revokedOne, revokedTwo, tempForTwo} {
|
||||
saveRevokeTestAuthKey(t, ctx, keys, key)
|
||||
if i < 3 {
|
||||
if err := auths.Bind(ctx, domain.Authorization{AuthKeyID: key, UserID: userID, Hash: int64(9100 + i)}); err != nil {
|
||||
t.Fatalf("bind auth %x: %v", key, err)
|
||||
}
|
||||
for i, key := range [][8]byte{keep, revokedOne, revokedTwo} {
|
||||
saveRevokeTestAuthKey(t, ctx, keys, key, 0)
|
||||
if err := auths.Bind(ctx, domain.Authorization{AuthKeyID: key, UserID: userID, Hash: int64(9100 + i)}); err != nil {
|
||||
t.Fatalf("bind auth %x: %v", key, err)
|
||||
}
|
||||
}
|
||||
tempExpiry := int(time.Now().Add(time.Hour).Unix())
|
||||
saveRevokeTestAuthKey(t, ctx, keys, tempForTwo, tempExpiry)
|
||||
if err := NewTempAuthKeyBindingStore(pool).Save(ctx, domain.TempAuthKeyBinding{
|
||||
TempAuthKeyID: tempForTwo,
|
||||
PermAuthKeyID: authKeyIDToInt64(revokedTwo),
|
||||
Nonce: 3,
|
||||
TempSessionID: 4,
|
||||
ExpiresAt: int(time.Now().Add(time.Hour).Unix()),
|
||||
ExpiresAt: tempExpiry,
|
||||
EncryptedMessage: []byte{5, 6, 7, 8},
|
||||
}); err != nil {
|
||||
t.Fatalf("save temp binding: %v", err)
|
||||
|
|
@ -117,7 +119,7 @@ func TestAuthorizationStoreUpdateClientInfoMergesPostgres(t *testing.T) {
|
|||
id := revokeTestAuthKeyID(0xb1)
|
||||
keys := NewAuthKeyStore(pool)
|
||||
auths := NewAuthorizationStore(pool)
|
||||
saveRevokeTestAuthKey(t, ctx, keys, id)
|
||||
saveRevokeTestAuthKey(t, ctx, keys, id, 0)
|
||||
if err := auths.Bind(ctx, domain.Authorization{
|
||||
AuthKeyID: id,
|
||||
UserID: userID,
|
||||
|
|
@ -153,6 +155,382 @@ func TestAuthorizationStoreUpdateClientInfoMergesPostgres(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestAuthorizationStoreRevokeByHashConcurrentTempBindLeavesNoDanglingStatePostgres(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
userID := createRevokeTestUser(t, ctx, pool, "bind-revoke-race")
|
||||
keys := NewAuthKeyStore(pool)
|
||||
auths := NewAuthorizationStore(pool)
|
||||
bindings := NewTempAuthKeyBindingStore(pool)
|
||||
|
||||
for attempt := 0; attempt < 24; attempt++ {
|
||||
tempExpiry := int(time.Now().Add(time.Hour).Unix()) + attempt
|
||||
perm := saveTempIdentityTestAuthKey(t, ctx, pool, keys, 0)
|
||||
temp := saveTempIdentityTestAuthKey(t, ctx, pool, keys, tempExpiry)
|
||||
hash := int64(9300 + attempt)
|
||||
if err := auths.Bind(ctx, domain.Authorization{
|
||||
AuthKeyID: perm,
|
||||
UserID: userID,
|
||||
Hash: hash,
|
||||
}); err != nil {
|
||||
t.Fatalf("attempt %d bind authorization: %v", attempt, err)
|
||||
}
|
||||
candidate := domain.TempAuthKeyBinding{
|
||||
TempAuthKeyID: temp,
|
||||
PermAuthKeyID: authKeyIDToInt64(perm),
|
||||
Nonce: int64(700 + attempt),
|
||||
TempSessionID: int64(800 + attempt),
|
||||
ExpiresAt: tempExpiry,
|
||||
EncryptedMessage: []byte("bind-revoke race"),
|
||||
}
|
||||
|
||||
start := make(chan struct{})
|
||||
bindResult := make(chan error, 1)
|
||||
type revokeResult struct {
|
||||
found bool
|
||||
err error
|
||||
}
|
||||
revokeResults := make(chan revokeResult, 1)
|
||||
go func() {
|
||||
<-start
|
||||
bindResult <- bindings.Save(ctx, candidate)
|
||||
}()
|
||||
go func() {
|
||||
<-start
|
||||
_, found, err := auths.RevokeByHash(ctx, userID, hash)
|
||||
revokeResults <- revokeResult{found: found, err: err}
|
||||
}()
|
||||
close(start)
|
||||
|
||||
bindErr := <-bindResult
|
||||
if bindErr != nil && !errors.Is(bindErr, store.ErrAuthKeyBindingInvalid) {
|
||||
t.Fatalf("attempt %d bind/revoke race bind error = %v", attempt, bindErr)
|
||||
}
|
||||
revoked := <-revokeResults
|
||||
if revoked.err != nil || !revoked.found {
|
||||
t.Fatalf("attempt %d bind/revoke race found=%v err=%v", attempt, revoked.found, revoked.err)
|
||||
}
|
||||
if _, found, err := bindings.GetByTemp(ctx, temp); err != nil || found {
|
||||
t.Fatalf("attempt %d dangling binding found=%v err=%v", attempt, found, err)
|
||||
}
|
||||
assertRevokeTestMissingAuthKey(t, ctx, keys, perm)
|
||||
assertRevokeTestNoAuthorization(t, ctx, auths, perm)
|
||||
if bindErr == nil {
|
||||
assertRevokeTestMissingAuthKey(t, ctx, keys, temp)
|
||||
} else {
|
||||
assertTempIdentityAuthKeyExpiry(t, ctx, keys, temp, tempExpiry)
|
||||
assertRevokeTestNoAuthorization(t, ctx, auths, temp)
|
||||
if err := keys.Delete(ctx, temp); err != nil {
|
||||
t.Fatalf("attempt %d clean unbound loser temp: %v", attempt, err)
|
||||
}
|
||||
assertRevokeTestMissingAuthKey(t, ctx, keys, temp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthorizationStoreRevokeByHashSkipsKeyTransferredAfterCandidateReadPostgres(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
testCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
keys := NewAuthKeyStore(pool)
|
||||
auths := NewAuthorizationStore(pool)
|
||||
bindings := NewTempAuthKeyBindingStore(pool)
|
||||
states := NewUpdateStateStore(pool)
|
||||
userA := createRevokeTestUser(t, testCtx, pool, "hash-owner-a")
|
||||
userB := createRevokeTestUser(t, testCtx, pool, "hash-owner-b")
|
||||
|
||||
tempExpiry := int(time.Now().Add(time.Hour).Unix())
|
||||
perm := saveTempIdentityTestAuthKey(t, testCtx, pool, keys, 0)
|
||||
temp := saveTempIdentityTestAuthKey(t, testCtx, pool, keys, tempExpiry)
|
||||
const (
|
||||
hashA = int64(9501)
|
||||
hashB = int64(9502)
|
||||
)
|
||||
if err := auths.Bind(testCtx, domain.Authorization{
|
||||
AuthKeyID: perm,
|
||||
UserID: userA,
|
||||
Hash: hashA,
|
||||
}); err != nil {
|
||||
t.Fatalf("bind original A authorization: %v", err)
|
||||
}
|
||||
binding := domain.TempAuthKeyBinding{
|
||||
TempAuthKeyID: temp,
|
||||
PermAuthKeyID: authKeyIDToInt64(perm),
|
||||
Nonce: 951,
|
||||
TempSessionID: 952,
|
||||
ExpiresAt: tempExpiry,
|
||||
EncryptedMessage: []byte("owner-transfer binding"),
|
||||
}
|
||||
if err := bindings.Save(testCtx, binding); err != nil {
|
||||
t.Fatalf("save temp binding before owner transfer: %v", err)
|
||||
}
|
||||
|
||||
// Bind B performs the auth_keys-first ownership change inside an open
|
||||
// transaction. Its uncommitted row is invisible to A's candidate lookup, but
|
||||
// the parent FOR UPDATE lock is the deterministic barrier for revocation.
|
||||
bindB, err := pool.Begin(testCtx)
|
||||
if err != nil {
|
||||
t.Fatalf("begin B bind transaction: %v", err)
|
||||
}
|
||||
defer func() { _ = bindB.Rollback(context.Background()) }()
|
||||
wantB := domain.Authorization{
|
||||
AuthKeyID: perm,
|
||||
UserID: userB,
|
||||
Hash: hashB,
|
||||
Layer: 227,
|
||||
DeviceModel: "owner-b-device",
|
||||
Platform: "android",
|
||||
SystemVersion: "test",
|
||||
APIID: 100,
|
||||
AppVersion: "owner-transfer",
|
||||
IP: "127.0.0.2",
|
||||
PasswordPending: true,
|
||||
}
|
||||
if err := bindAuthorization(testCtx, bindB, wantB); err != nil {
|
||||
t.Fatalf("stage B ownership transfer: %v", err)
|
||||
}
|
||||
wantBStored, found, err := NewAuthorizationStore(bindB).ByAuthKey(testCtx, perm)
|
||||
if err != nil || !found {
|
||||
t.Fatalf("read staged B authorization found=%v err=%v", found, err)
|
||||
}
|
||||
wantBState, found, err := NewUpdateStateStore(bindB).Get(testCtx, perm, userB)
|
||||
if err != nil || !found {
|
||||
t.Fatalf("read staged B update state found=%v err=%v", found, err)
|
||||
}
|
||||
|
||||
revokeConn, err := pool.Acquire(testCtx)
|
||||
if err != nil {
|
||||
t.Fatalf("acquire dedicated revoke connection: %v", err)
|
||||
}
|
||||
t.Cleanup(revokeConn.Release)
|
||||
var revokePID int
|
||||
if err := revokeConn.QueryRow(testCtx, "SELECT pg_backend_pid()").Scan(&revokePID); err != nil {
|
||||
t.Fatalf("get revoke backend pid: %v", err)
|
||||
}
|
||||
|
||||
type revokeResult struct {
|
||||
a domain.Authorization
|
||||
found bool
|
||||
err error
|
||||
}
|
||||
revokeResults := make(chan revokeResult, 1)
|
||||
go func() {
|
||||
a, found, err := NewAuthorizationStore(revokeConn).RevokeByHash(testCtx, userA, hashA)
|
||||
revokeResults <- revokeResult{a: a, found: found, err: err}
|
||||
}()
|
||||
waitForPostgresBackendLockWait(t, testCtx, pool, revokePID)
|
||||
|
||||
// Lock wait proves A's revoke already read its candidate and is now serialized
|
||||
// behind Bind B. After B commits, the fresh owner/hash revalidation must omit
|
||||
// the key instead of deleting B through A's stale candidate.
|
||||
if err := bindB.Commit(testCtx); err != nil {
|
||||
t.Fatalf("commit B ownership transfer: %v", err)
|
||||
}
|
||||
|
||||
var revoked revokeResult
|
||||
select {
|
||||
case revoked = <-revokeResults:
|
||||
case <-testCtx.Done():
|
||||
t.Fatalf("revoke did not finish after releasing FK barrier: %v", testCtx.Err())
|
||||
}
|
||||
if revoked.err != nil || revoked.found {
|
||||
t.Fatalf("stale A revoke after B transfer found=%v err=%v, want not found", revoked.found, revoked.err)
|
||||
}
|
||||
if revoked.a != (domain.Authorization{}) {
|
||||
t.Fatalf("stale A revoke returned authorization %+v, want zero", revoked.a)
|
||||
}
|
||||
|
||||
assertRevokeTestPresentAuthKey(t, testCtx, keys, perm)
|
||||
assertRevokeTestPresentAuthKey(t, testCtx, keys, temp)
|
||||
assertTempIdentityBinding(t, testCtx, bindings, binding)
|
||||
gotB, found, err := auths.ByAuthKey(testCtx, perm)
|
||||
if err != nil || !found || gotB != wantBStored {
|
||||
t.Fatalf("B authorization after stale A revoke = %+v found=%v err=%v, want %+v", gotB, found, err, wantBStored)
|
||||
}
|
||||
gotBState, found, err := states.Get(testCtx, perm, userB)
|
||||
if err != nil || !found || gotBState != wantBState {
|
||||
t.Fatalf("B update state after stale A revoke = %+v found=%v err=%v, want %+v", gotBState, found, err, wantBState)
|
||||
}
|
||||
if _, found, err := states.Get(testCtx, perm, userA); err != nil || found {
|
||||
t.Fatalf("stale A update state found=%v err=%v, want absent after B bind", found, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthorizationStoreRevokeByUserExceptPartiallySkipsTransferredCandidatePostgres(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
testCtx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
keys := NewAuthKeyStore(pool)
|
||||
auths := NewAuthorizationStore(pool)
|
||||
bindings := NewTempAuthKeyBindingStore(pool)
|
||||
states := NewUpdateStateStore(pool)
|
||||
userA := createRevokeTestUser(t, testCtx, pool, "bulk-owner-a")
|
||||
userB := createRevokeTestUser(t, testCtx, pool, "bulk-owner-b")
|
||||
|
||||
keep := saveTempIdentityTestAuthKey(t, testCtx, pool, keys, 0)
|
||||
transferred := saveTempIdentityTestAuthKey(t, testCtx, pool, keys, 0)
|
||||
revoked := saveTempIdentityTestAuthKey(t, testCtx, pool, keys, 0)
|
||||
transferredExpiry := int(time.Now().Add(time.Hour).Unix())
|
||||
revokedExpiry := transferredExpiry + 1
|
||||
transferredTemp := saveTempIdentityTestAuthKey(t, testCtx, pool, keys, transferredExpiry)
|
||||
revokedTemp := saveTempIdentityTestAuthKey(t, testCtx, pool, keys, revokedExpiry)
|
||||
|
||||
authorizationsA := []domain.Authorization{
|
||||
{AuthKeyID: keep, UserID: userA, Hash: 9601, DeviceModel: "keep-a"},
|
||||
{AuthKeyID: transferred, UserID: userA, Hash: 9602, DeviceModel: "transfer-from-a"},
|
||||
{AuthKeyID: revoked, UserID: userA, Hash: 9603, DeviceModel: "revoke-a", PasswordPending: true},
|
||||
}
|
||||
for _, authorization := range authorizationsA {
|
||||
if err := auths.Bind(testCtx, authorization); err != nil {
|
||||
t.Fatalf("bind A authorization %x: %v", authorization.AuthKeyID, err)
|
||||
}
|
||||
}
|
||||
transferredBinding := domain.TempAuthKeyBinding{
|
||||
TempAuthKeyID: transferredTemp,
|
||||
PermAuthKeyID: authKeyIDToInt64(transferred),
|
||||
Nonce: 961,
|
||||
TempSessionID: 962,
|
||||
ExpiresAt: transferredExpiry,
|
||||
EncryptedMessage: []byte("transferred candidate binding"),
|
||||
}
|
||||
revokedBinding := domain.TempAuthKeyBinding{
|
||||
TempAuthKeyID: revokedTemp,
|
||||
PermAuthKeyID: authKeyIDToInt64(revoked),
|
||||
Nonce: 963,
|
||||
TempSessionID: 964,
|
||||
ExpiresAt: revokedExpiry,
|
||||
EncryptedMessage: []byte("revoked candidate binding"),
|
||||
}
|
||||
for _, binding := range []domain.TempAuthKeyBinding{transferredBinding, revokedBinding} {
|
||||
if err := bindings.Save(testCtx, binding); err != nil {
|
||||
t.Fatalf("save candidate binding for perm %d: %v", binding.PermAuthKeyID, err)
|
||||
}
|
||||
}
|
||||
|
||||
wantKeepKey, found, err := keys.Get(testCtx, keep)
|
||||
if err != nil || !found {
|
||||
t.Fatalf("read keep key before revoke found=%v err=%v", found, err)
|
||||
}
|
||||
wantKeepAuth, found, err := auths.ByAuthKey(testCtx, keep)
|
||||
if err != nil || !found {
|
||||
t.Fatalf("read keep authorization before revoke found=%v err=%v", found, err)
|
||||
}
|
||||
wantKeepState, found, err := states.Get(testCtx, keep, userA)
|
||||
if err != nil || !found {
|
||||
t.Fatalf("read keep state before revoke found=%v err=%v", found, err)
|
||||
}
|
||||
wantRevokedAuth, found, err := auths.ByAuthKey(testCtx, revoked)
|
||||
if err != nil || !found {
|
||||
t.Fatalf("read revocable authorization before revoke found=%v err=%v", found, err)
|
||||
}
|
||||
|
||||
bindB, err := pool.Begin(testCtx)
|
||||
if err != nil {
|
||||
t.Fatalf("begin partial owner-transfer transaction: %v", err)
|
||||
}
|
||||
defer func() { _ = bindB.Rollback(context.Background()) }()
|
||||
wantB := domain.Authorization{
|
||||
AuthKeyID: transferred,
|
||||
UserID: userB,
|
||||
Hash: 9604,
|
||||
Layer: 227,
|
||||
DeviceModel: "bulk-owner-b",
|
||||
Platform: "android",
|
||||
SystemVersion: "test",
|
||||
APIID: 100,
|
||||
AppVersion: "partial-owner-transfer",
|
||||
IP: "127.0.0.3",
|
||||
}
|
||||
if err := bindAuthorization(testCtx, bindB, wantB); err != nil {
|
||||
t.Fatalf("stage partial B ownership transfer: %v", err)
|
||||
}
|
||||
wantBStored, found, err := NewAuthorizationStore(bindB).ByAuthKey(testCtx, transferred)
|
||||
if err != nil || !found {
|
||||
t.Fatalf("read staged partial B authorization found=%v err=%v", found, err)
|
||||
}
|
||||
wantBState, found, err := NewUpdateStateStore(bindB).Get(testCtx, transferred, userB)
|
||||
if err != nil || !found {
|
||||
t.Fatalf("read staged partial B state found=%v err=%v", found, err)
|
||||
}
|
||||
|
||||
revokeConn, err := pool.Acquire(testCtx)
|
||||
if err != nil {
|
||||
t.Fatalf("acquire dedicated bulk revoke connection: %v", err)
|
||||
}
|
||||
t.Cleanup(revokeConn.Release)
|
||||
var revokePID int
|
||||
if err := revokeConn.QueryRow(testCtx, "SELECT pg_backend_pid()").Scan(&revokePID); err != nil {
|
||||
t.Fatalf("get bulk revoke backend pid: %v", err)
|
||||
}
|
||||
type bulkRevokeResult struct {
|
||||
deleted []domain.Authorization
|
||||
err error
|
||||
}
|
||||
revokeResults := make(chan bulkRevokeResult, 1)
|
||||
go func() {
|
||||
deleted, err := NewAuthorizationStore(revokeConn).RevokeByUserExcept(testCtx, userA, keep)
|
||||
revokeResults <- bulkRevokeResult{deleted: deleted, err: err}
|
||||
}()
|
||||
waitForPostgresBackendLockWait(t, testCtx, pool, revokePID)
|
||||
|
||||
// The bulk candidate list now contains both old A keys. Releasing B's parent
|
||||
// lock forces a fresh owner revalidation: transferred must be omitted while
|
||||
// the unrelated candidate that still belongs to A remains revocable.
|
||||
if err := bindB.Commit(testCtx); err != nil {
|
||||
t.Fatalf("commit partial B ownership transfer: %v", err)
|
||||
}
|
||||
var result bulkRevokeResult
|
||||
select {
|
||||
case result = <-revokeResults:
|
||||
case <-testCtx.Done():
|
||||
t.Fatalf("bulk revoke did not finish after owner transfer: %v", testCtx.Err())
|
||||
}
|
||||
if result.err != nil {
|
||||
t.Fatalf("bulk revoke after partial owner transfer: %v", result.err)
|
||||
}
|
||||
if len(result.deleted) != 1 || result.deleted[0] != wantRevokedAuth {
|
||||
t.Fatalf("bulk revoked authorizations = %+v, want only %+v", result.deleted, wantRevokedAuth)
|
||||
}
|
||||
|
||||
gotKeepKey, found, err := keys.Get(testCtx, keep)
|
||||
if err != nil || !found || gotKeepKey != wantKeepKey {
|
||||
t.Fatalf("keep key after bulk revoke = %+v found=%v err=%v, want unchanged", gotKeepKey, found, err)
|
||||
}
|
||||
gotKeepAuth, found, err := auths.ByAuthKey(testCtx, keep)
|
||||
if err != nil || !found || gotKeepAuth != wantKeepAuth {
|
||||
t.Fatalf("keep authorization after bulk revoke = %+v found=%v err=%v, want %+v", gotKeepAuth, found, err, wantKeepAuth)
|
||||
}
|
||||
gotKeepState, found, err := states.Get(testCtx, keep, userA)
|
||||
if err != nil || !found || gotKeepState != wantKeepState {
|
||||
t.Fatalf("keep state after bulk revoke = %+v found=%v err=%v, want %+v", gotKeepState, found, err, wantKeepState)
|
||||
}
|
||||
|
||||
assertRevokeTestPresentAuthKey(t, testCtx, keys, transferred)
|
||||
assertRevokeTestPresentAuthKey(t, testCtx, keys, transferredTemp)
|
||||
assertTempIdentityBinding(t, testCtx, bindings, transferredBinding)
|
||||
gotB, found, err := auths.ByAuthKey(testCtx, transferred)
|
||||
if err != nil || !found || gotB != wantBStored {
|
||||
t.Fatalf("transferred B authorization = %+v found=%v err=%v, want %+v", gotB, found, err, wantBStored)
|
||||
}
|
||||
gotBState, found, err := states.Get(testCtx, transferred, userB)
|
||||
if err != nil || !found || gotBState != wantBState {
|
||||
t.Fatalf("transferred B state = %+v found=%v err=%v, want %+v", gotBState, found, err, wantBState)
|
||||
}
|
||||
if _, found, err := states.Get(testCtx, transferred, userA); err != nil || found {
|
||||
t.Fatalf("old A state for transferred key found=%v err=%v, want absent", found, err)
|
||||
}
|
||||
|
||||
assertRevokeTestMissingAuthKey(t, testCtx, keys, revoked)
|
||||
assertRevokeTestMissingAuthKey(t, testCtx, keys, revokedTemp)
|
||||
assertRevokeTestNoAuthorization(t, testCtx, auths, revoked)
|
||||
if _, found, err := bindings.GetByTemp(testCtx, revokedTemp); err != nil || found {
|
||||
t.Fatalf("revoked temp binding found=%v err=%v, want absent", found, err)
|
||||
}
|
||||
if _, found, err := states.Get(testCtx, revoked, userA); err != nil || found {
|
||||
t.Fatalf("revoked A state found=%v err=%v, want absent", found, err)
|
||||
}
|
||||
}
|
||||
|
||||
func createRevokeTestUser(t *testing.T, ctx context.Context, db *pgxpool.Pool, suffix string) int64 {
|
||||
t.Helper()
|
||||
phone := fmt.Sprintf("+1555%09d", time.Now().UnixNano()%1_000_000_000)
|
||||
|
|
@ -173,9 +551,9 @@ func revokeTestAuthKeyID(seed byte) [8]byte {
|
|||
return [8]byte{seed, seed, seed, seed, seed, seed, seed, seed}
|
||||
}
|
||||
|
||||
func saveRevokeTestAuthKey(t *testing.T, ctx context.Context, keys store.AuthKeyStore, id [8]byte) {
|
||||
func saveRevokeTestAuthKey(t *testing.T, ctx context.Context, keys store.AuthKeyStore, id [8]byte, expiresAt int) {
|
||||
t.Helper()
|
||||
if err := keys.Save(ctx, store.AuthKeyData{ID: id, ServerSalt: int64(id[0])}); err != nil {
|
||||
if err := keys.Save(ctx, store.AuthKeyData{ID: id, ServerSalt: int64(id[0]), ExpiresAt: expiresAt}); err != nil {
|
||||
t.Fatalf("save auth key %x: %v", id, err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
|
|
|
|||
|
|
@ -205,7 +205,7 @@ func TestBotStoreRoundTripPostgres(t *testing.T) {
|
|||
appauth.WithBotLogin(bots))
|
||||
var authKeyID [8]byte
|
||||
copy(authKeyID[:], fmt.Sprintf("%08d", suffix%100000000))
|
||||
if _, err := pool.Exec(ctx, "INSERT INTO auth_keys (auth_key_id, body, server_salt) VALUES ($1, $2, 0) ON CONFLICT DO NOTHING",
|
||||
if _, err := pool.Exec(ctx, "INSERT INTO auth_keys (auth_key_id, body, server_salt, expires_at) VALUES ($1, $2, 0, 0) ON CONFLICT DO NOTHING",
|
||||
authKeyIDToInt64(authKeyID), make([]byte, 256)); err != nil {
|
||||
t.Fatalf("seed auth key: %v", err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import (
|
|||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
|
|
@ -26,15 +27,32 @@ func TestBusinessStoresRoundTrip(t *testing.T) {
|
|||
if _, err := rand.Read(authBody[:]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := NewAuthKeyStore(pool).Save(ctx, store.AuthKeyData{
|
||||
authExpiry := int(time.Now().Add(time.Hour).Unix())
|
||||
keys := NewAuthKeyStore(pool)
|
||||
if err := keys.Save(ctx, store.AuthKeyData{
|
||||
ID: authID,
|
||||
Value: authBody,
|
||||
ServerSalt: 42,
|
||||
ExpiresAt: authExpiry,
|
||||
}); err != nil {
|
||||
t.Fatalf("save auth key: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(ctx, "DELETE FROM auth_keys WHERE auth_key_id = $1", authKeyIDToInt64(authID))
|
||||
_ = keys.Delete(ctx, authID)
|
||||
})
|
||||
var permAuthID [8]byte
|
||||
var permAuthBody [256]byte
|
||||
if _, err := rand.Read(permAuthID[:]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := rand.Read(permAuthBody[:]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := keys.Save(ctx, store.AuthKeyData{ID: permAuthID, Value: permAuthBody}); err != nil {
|
||||
t.Fatalf("save permanent auth key: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = keys.Delete(ctx, permAuthID)
|
||||
})
|
||||
|
||||
users := NewUserStore(pool)
|
||||
|
|
@ -164,10 +182,10 @@ func TestBusinessStoresRoundTrip(t *testing.T) {
|
|||
|
||||
if err := NewTempAuthKeyBindingStore(pool).Save(ctx, domain.TempAuthKeyBinding{
|
||||
TempAuthKeyID: authID,
|
||||
PermAuthKeyID: 12345,
|
||||
PermAuthKeyID: authKeyIDToInt64(permAuthID),
|
||||
Nonce: 67890,
|
||||
TempSessionID: 24680,
|
||||
ExpiresAt: 111,
|
||||
ExpiresAt: authExpiry,
|
||||
EncryptedMessage: []byte("binding"),
|
||||
}); err != nil {
|
||||
t.Fatalf("save temp auth key binding: %v", err)
|
||||
|
|
|
|||
|
|
@ -1,10 +0,0 @@
|
|||
-- name: GetAuthKey :one
|
||||
SELECT auth_key_id, body, server_salt, created_at
|
||||
FROM auth_keys
|
||||
WHERE auth_key_id = $1;
|
||||
|
||||
-- name: UpsertAuthKey :exec
|
||||
INSERT INTO auth_keys (auth_key_id, body, server_salt)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (auth_key_id) DO UPDATE
|
||||
SET body = EXCLUDED.body, server_salt = EXCLUDED.server_salt;
|
||||
|
|
@ -1,15 +1,21 @@
|
|||
-- name: UpsertTempAuthKeyBinding :exec
|
||||
-- name: UpsertTempAuthKeyBinding :execrows
|
||||
INSERT INTO temp_auth_key_bindings (
|
||||
temp_auth_key_id, perm_auth_key_id, nonce, temp_session_id, expires_at, encrypted_message
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
SELECT $1, $2, $3, $4, $5, $6
|
||||
FROM auth_keys AS temp_key
|
||||
JOIN auth_keys AS perm_key ON perm_key.auth_key_id = $2
|
||||
WHERE temp_key.auth_key_id = $1
|
||||
AND temp_key.expires_at = $5
|
||||
AND temp_key.expires_at > 0
|
||||
AND perm_key.expires_at = 0
|
||||
ON CONFLICT (temp_auth_key_id) DO UPDATE SET
|
||||
perm_auth_key_id = EXCLUDED.perm_auth_key_id,
|
||||
nonce = EXCLUDED.nonce,
|
||||
temp_session_id = EXCLUDED.temp_session_id,
|
||||
expires_at = EXCLUDED.expires_at,
|
||||
encrypted_message = EXCLUDED.encrypted_message,
|
||||
created_at = now();
|
||||
created_at = now()
|
||||
WHERE temp_auth_key_bindings.perm_auth_key_id = EXCLUDED.perm_auth_key_id;
|
||||
|
||||
-- name: GetTempAuthKeyBinding :one
|
||||
SELECT
|
||||
|
|
@ -23,10 +29,15 @@ FROM temp_auth_key_bindings
|
|||
WHERE temp_auth_key_id = $1;
|
||||
|
||||
-- name: DeleteExpiredTempAuthKeys :execrows
|
||||
DELETE FROM auth_keys
|
||||
WHERE auth_key_id IN (
|
||||
SELECT temp_auth_key_id
|
||||
FROM temp_auth_key_bindings
|
||||
WHERE expires_at < $1
|
||||
WITH candidates AS (
|
||||
SELECT candidate_key.auth_key_id
|
||||
FROM auth_keys AS candidate_key
|
||||
WHERE candidate_key.expires_at > 0
|
||||
AND candidate_key.expires_at < $1
|
||||
ORDER BY candidate_key.expires_at, candidate_key.auth_key_id
|
||||
LIMIT $2
|
||||
);
|
||||
FOR UPDATE SKIP LOCKED
|
||||
)
|
||||
DELETE FROM auth_keys AS k
|
||||
USING candidates AS c
|
||||
WHERE k.auth_key_id = c.auth_key_id;
|
||||
|
|
|
|||
|
|
@ -1,55 +0,0 @@
|
|||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// source: authkey.sql
|
||||
|
||||
package sqlcgen
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
const getAuthKey = `-- name: GetAuthKey :one
|
||||
SELECT auth_key_id, body, server_salt, created_at
|
||||
FROM auth_keys
|
||||
WHERE auth_key_id = $1
|
||||
`
|
||||
|
||||
type GetAuthKeyRow struct {
|
||||
AuthKeyID int64
|
||||
Body []byte
|
||||
ServerSalt int64
|
||||
CreatedAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
func (q *Queries) GetAuthKey(ctx context.Context, authKeyID int64) (GetAuthKeyRow, error) {
|
||||
row := q.db.QueryRow(ctx, getAuthKey, authKeyID)
|
||||
var i GetAuthKeyRow
|
||||
err := row.Scan(
|
||||
&i.AuthKeyID,
|
||||
&i.Body,
|
||||
&i.ServerSalt,
|
||||
&i.CreatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const upsertAuthKey = `-- name: UpsertAuthKey :exec
|
||||
INSERT INTO auth_keys (auth_key_id, body, server_salt)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (auth_key_id) DO UPDATE
|
||||
SET body = EXCLUDED.body, server_salt = EXCLUDED.server_salt
|
||||
`
|
||||
|
||||
type UpsertAuthKeyParams struct {
|
||||
AuthKeyID int64
|
||||
Body []byte
|
||||
ServerSalt int64
|
||||
}
|
||||
|
||||
func (q *Queries) UpsertAuthKey(ctx context.Context, arg UpsertAuthKeyParams) error {
|
||||
_, err := q.db.Exec(ctx, upsertAuthKey, arg.AuthKeyID, arg.Body, arg.ServerSalt)
|
||||
return err
|
||||
}
|
||||
|
|
@ -175,6 +175,7 @@ type AuthKey struct {
|
|||
ApiID int32
|
||||
AppVersion string
|
||||
LastUsedAt pgtype.Timestamptz
|
||||
ExpiresAt int32
|
||||
}
|
||||
|
||||
type Authorization struct {
|
||||
|
|
|
|||
|
|
@ -10,13 +10,18 @@ import (
|
|||
)
|
||||
|
||||
const deleteExpiredTempAuthKeys = `-- name: DeleteExpiredTempAuthKeys :execrows
|
||||
DELETE FROM auth_keys
|
||||
WHERE auth_key_id IN (
|
||||
SELECT temp_auth_key_id
|
||||
FROM temp_auth_key_bindings
|
||||
WHERE expires_at < $1
|
||||
WITH candidates AS (
|
||||
SELECT candidate_key.auth_key_id
|
||||
FROM auth_keys AS candidate_key
|
||||
WHERE candidate_key.expires_at > 0
|
||||
AND candidate_key.expires_at < $1
|
||||
ORDER BY candidate_key.expires_at, candidate_key.auth_key_id
|
||||
LIMIT $2
|
||||
FOR UPDATE SKIP LOCKED
|
||||
)
|
||||
DELETE FROM auth_keys AS k
|
||||
USING candidates AS c
|
||||
WHERE k.auth_key_id = c.auth_key_id
|
||||
`
|
||||
|
||||
type DeleteExpiredTempAuthKeysParams struct {
|
||||
|
|
@ -67,18 +72,24 @@ func (q *Queries) GetTempAuthKeyBinding(ctx context.Context, tempAuthKeyID int64
|
|||
return i, err
|
||||
}
|
||||
|
||||
const upsertTempAuthKeyBinding = `-- name: UpsertTempAuthKeyBinding :exec
|
||||
const upsertTempAuthKeyBinding = `-- name: UpsertTempAuthKeyBinding :execrows
|
||||
INSERT INTO temp_auth_key_bindings (
|
||||
temp_auth_key_id, perm_auth_key_id, nonce, temp_session_id, expires_at, encrypted_message
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
SELECT $1, $2, $3, $4, $5, $6
|
||||
FROM auth_keys AS temp_key
|
||||
JOIN auth_keys AS perm_key ON perm_key.auth_key_id = $2
|
||||
WHERE temp_key.auth_key_id = $1
|
||||
AND temp_key.expires_at = $5
|
||||
AND temp_key.expires_at > 0
|
||||
AND perm_key.expires_at = 0
|
||||
ON CONFLICT (temp_auth_key_id) DO UPDATE SET
|
||||
perm_auth_key_id = EXCLUDED.perm_auth_key_id,
|
||||
nonce = EXCLUDED.nonce,
|
||||
temp_session_id = EXCLUDED.temp_session_id,
|
||||
expires_at = EXCLUDED.expires_at,
|
||||
encrypted_message = EXCLUDED.encrypted_message,
|
||||
created_at = now()
|
||||
WHERE temp_auth_key_bindings.perm_auth_key_id = EXCLUDED.perm_auth_key_id
|
||||
`
|
||||
|
||||
type UpsertTempAuthKeyBindingParams struct {
|
||||
|
|
@ -90,8 +101,8 @@ type UpsertTempAuthKeyBindingParams struct {
|
|||
EncryptedMessage []byte
|
||||
}
|
||||
|
||||
func (q *Queries) UpsertTempAuthKeyBinding(ctx context.Context, arg UpsertTempAuthKeyBindingParams) error {
|
||||
_, err := q.db.Exec(ctx, upsertTempAuthKeyBinding,
|
||||
func (q *Queries) UpsertTempAuthKeyBinding(ctx context.Context, arg UpsertTempAuthKeyBindingParams) (int64, error) {
|
||||
result, err := q.db.Exec(ctx, upsertTempAuthKeyBinding,
|
||||
arg.TempAuthKeyID,
|
||||
arg.PermAuthKeyID,
|
||||
arg.Nonce,
|
||||
|
|
@ -99,5 +110,8 @@ func (q *Queries) UpsertTempAuthKeyBinding(ctx context.Context, arg UpsertTempAu
|
|||
arg.ExpiresAt,
|
||||
arg.EncryptedMessage,
|
||||
)
|
||||
return err
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected(), nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,10 +4,13 @@ import (
|
|||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
"telesrv/internal/store/postgres/sqlcgen"
|
||||
)
|
||||
|
||||
|
|
@ -22,25 +25,45 @@ func NewTempAuthKeyBindingStore(db sqlcgen.DBTX) *TempAuthKeyBindingStore {
|
|||
}
|
||||
|
||||
func (s *TempAuthKeyBindingStore) Save(ctx context.Context, b domain.TempAuthKeyBinding) error {
|
||||
if err := s.q.UpsertTempAuthKeyBinding(ctx, sqlcgen.UpsertTempAuthKeyBindingParams{
|
||||
if b.ExpiresAt <= 0 || int64(b.ExpiresAt) > math.MaxInt32 {
|
||||
return store.ErrAuthKeyBindingInvalid
|
||||
}
|
||||
n, err := s.q.UpsertTempAuthKeyBinding(ctx, sqlcgen.UpsertTempAuthKeyBindingParams{
|
||||
TempAuthKeyID: authKeyIDToInt64(b.TempAuthKeyID),
|
||||
PermAuthKeyID: b.PermAuthKeyID,
|
||||
Nonce: b.Nonce,
|
||||
TempSessionID: b.TempSessionID,
|
||||
ExpiresAt: int32(b.ExpiresAt),
|
||||
EncryptedMessage: b.EncryptedMessage,
|
||||
}); err != nil {
|
||||
})
|
||||
if err != nil {
|
||||
var pgErr *pgconn.PgError
|
||||
if errors.As(err, &pgErr) && pgErr.Code == "23503" {
|
||||
return store.ErrAuthKeyBindingInvalid
|
||||
}
|
||||
return fmt.Errorf("upsert temp auth key binding: %w", err)
|
||||
}
|
||||
if n == 0 {
|
||||
if current, found, getErr := s.GetByTemp(ctx, b.TempAuthKeyID); getErr != nil {
|
||||
return getErr
|
||||
} else if found && current.PermAuthKeyID != b.PermAuthKeyID {
|
||||
return store.ErrTempAuthKeyAlreadyBound
|
||||
}
|
||||
return store.ErrAuthKeyBindingInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteExpired 实现 store.TempAuthKeyBindingStore:删除 auth_keys 中过期的 temp key,
|
||||
// temp_auth_key_bindings 经 ON DELETE CASCADE 一并清除,过期 key 的入站帧随之失效。
|
||||
// DeleteExpired 实现 store.TempAuthKeyBindingStore:按 auth_keys.expires_at 的部分索引
|
||||
// 有界删除所有过期 temp key(含从未绑定的握手 key),binding 经 CASCADE 一并清除。
|
||||
// Edge 已在准确协议时刻停止使用 key;这里的 24h 宽限只控制数据库物理回收。
|
||||
func (s *TempAuthKeyBindingStore) DeleteExpired(ctx context.Context, expiredBefore int64, limit int) (int, error) {
|
||||
if limit <= 0 {
|
||||
return 0, nil
|
||||
}
|
||||
if expiredBefore <= 0 || expiredBefore > math.MaxInt32 {
|
||||
return 0, fmt.Errorf("delete expired temp auth keys: invalid expiry cutoff %d", expiredBefore)
|
||||
}
|
||||
n, err := s.q.DeleteExpiredTempAuthKeys(ctx, sqlcgen.DeleteExpiredTempAuthKeysParams{
|
||||
ExpiresAt: int32(expiredBefore),
|
||||
Limit: int32(limit),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,554 @@
|
|||
package postgres
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
func TestTempAuthKeyBindingStoreRejectsIntegerWraparoundPostgres(t *testing.T) {
|
||||
if strconv.IntSize < 64 {
|
||||
t.Skip("64-bit int required to construct an out-of-int32 expiry")
|
||||
}
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
keys := NewAuthKeyStore(pool)
|
||||
bindings := NewTempAuthKeyBindingStore(pool)
|
||||
handshakeExpiry := int(time.Now().Add(time.Hour).Unix())
|
||||
temp := saveTempIdentityTestAuthKey(t, ctx, pool, keys, handshakeExpiry)
|
||||
perm := saveTempIdentityTestAuthKey(t, ctx, pool, keys, 0)
|
||||
|
||||
overflow := domain.TempAuthKeyBinding{
|
||||
TempAuthKeyID: temp,
|
||||
PermAuthKeyID: authKeyIDToInt64(perm),
|
||||
Nonce: 901,
|
||||
TempSessionID: 902,
|
||||
ExpiresAt: int(int64(handshakeExpiry) + (int64(1) << 32)),
|
||||
EncryptedMessage: []byte("wraparound"),
|
||||
}
|
||||
if err := bindings.Save(ctx, overflow); !errors.Is(err, store.ErrAuthKeyBindingInvalid) {
|
||||
t.Fatalf("overflow binding expiry error = %v, want %v", err, store.ErrAuthKeyBindingInvalid)
|
||||
}
|
||||
if _, found, err := bindings.GetByTemp(ctx, temp); err != nil || found {
|
||||
t.Fatalf("binding after overflow found=%v err=%v, want absent", found, err)
|
||||
}
|
||||
if _, err := bindings.DeleteExpired(ctx, int64(math.MaxInt32)+1, 1); err == nil {
|
||||
t.Fatal("overflow retention cutoff succeeded, want explicit rejection")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthorizationStoreRejectsTemporaryProtocolKeyPostgres(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
keys := NewAuthKeyStore(pool)
|
||||
temp := saveTempIdentityTestAuthKey(t, ctx, pool, keys, int(time.Now().Add(time.Hour).Unix()))
|
||||
phone := fmt.Sprintf("15558%015d", time.Now().UnixNano())
|
||||
user, err := NewUserStore(pool).Create(ctx, domain.User{Phone: phone, FirstName: "TempKeyGuard"})
|
||||
if err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = $1", user.ID)
|
||||
})
|
||||
|
||||
err = NewAuthorizationStore(pool).Bind(ctx, domain.Authorization{AuthKeyID: temp, UserID: user.ID})
|
||||
if !errors.Is(err, store.ErrAuthKeyNotPermanent) {
|
||||
t.Fatalf("bind authorization to temp key error = %v, want %v", err, store.ErrAuthKeyNotPermanent)
|
||||
}
|
||||
if _, found, getErr := NewAuthorizationStore(pool).ByAuthKey(ctx, temp); getErr != nil || found {
|
||||
t.Fatalf("temporary authorization found=%v err=%v, want absent", found, getErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTempAuthKeyBindingStorePreservesHandshakeExpiryAndRejectsRebindPostgres(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
keys := NewAuthKeyStore(pool)
|
||||
bindings := NewTempAuthKeyBindingStore(pool)
|
||||
|
||||
handshakeExpiry := int(time.Now().Add(time.Hour).Unix())
|
||||
temp := saveTempIdentityTestAuthKey(t, ctx, pool, keys, handshakeExpiry)
|
||||
permA := saveTempIdentityTestAuthKey(t, ctx, pool, keys, 0)
|
||||
permB := saveTempIdentityTestAuthKey(t, ctx, pool, keys, 0)
|
||||
|
||||
first := domain.TempAuthKeyBinding{
|
||||
TempAuthKeyID: temp,
|
||||
PermAuthKeyID: authKeyIDToInt64(permA),
|
||||
Nonce: 101,
|
||||
TempSessionID: 201,
|
||||
ExpiresAt: handshakeExpiry,
|
||||
EncryptedMessage: []byte("first binding"),
|
||||
}
|
||||
if err := bindings.Save(ctx, first); err != nil {
|
||||
t.Fatalf("save first binding: %v", err)
|
||||
}
|
||||
assertTempIdentityBinding(t, ctx, bindings, first)
|
||||
assertTempIdentityAuthKeyExpiry(t, ctx, keys, temp, handshakeExpiry)
|
||||
|
||||
// The app service accepts client-specific proof expiry (TDesktop adds 30s)
|
||||
// but must normalize what it passes to the store. A direct caller cannot
|
||||
// persist proof metadata whose lifetime differs from the handshake key.
|
||||
mismatched := first
|
||||
mismatched.Nonce = 102
|
||||
mismatched.TempSessionID = 202
|
||||
mismatched.ExpiresAt = handshakeExpiry + 60
|
||||
mismatched.EncryptedMessage = []byte("mismatched replay")
|
||||
if err := bindings.Save(ctx, mismatched); !errors.Is(err, store.ErrAuthKeyBindingInvalid) {
|
||||
t.Fatalf("mismatched replay error = %v, want %v", err, store.ErrAuthKeyBindingInvalid)
|
||||
}
|
||||
assertTempIdentityBinding(t, ctx, bindings, first)
|
||||
|
||||
replayed := first
|
||||
replayed.Nonce = 103
|
||||
replayed.TempSessionID = 203
|
||||
replayed.EncryptedMessage = []byte("normalized replay")
|
||||
if err := bindings.Save(ctx, replayed); err != nil {
|
||||
t.Fatalf("replay normalized binding: %v", err)
|
||||
}
|
||||
assertTempIdentityBinding(t, ctx, bindings, replayed)
|
||||
assertTempIdentityAuthKeyExpiry(t, ctx, keys, temp, handshakeExpiry)
|
||||
|
||||
forbidden := replayed
|
||||
forbidden.PermAuthKeyID = authKeyIDToInt64(permB)
|
||||
forbidden.Nonce = 999
|
||||
forbidden.ExpiresAt = handshakeExpiry
|
||||
forbidden.EncryptedMessage = []byte("must not persist")
|
||||
if err := bindings.Save(ctx, forbidden); !errors.Is(err, store.ErrTempAuthKeyAlreadyBound) {
|
||||
t.Fatalf("cross-permanent rebind error = %v, want %v", err, store.ErrTempAuthKeyAlreadyBound)
|
||||
}
|
||||
assertTempIdentityBinding(t, ctx, bindings, replayed)
|
||||
assertTempIdentityAuthKeyExpiry(t, ctx, keys, temp, handshakeExpiry)
|
||||
}
|
||||
|
||||
func TestTempAuthKeyBindingStoreConcurrentFirstBindKeepsHandshakeExpiryPostgres(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
keys := NewAuthKeyStore(pool)
|
||||
bindings := NewTempAuthKeyBindingStore(pool)
|
||||
|
||||
// A temp key has exactly one permanent identity even when two valid bind
|
||||
// proofs race. Repeat with fresh rows so the test covers the contended first
|
||||
// bind path instead of only the already-bound fast path.
|
||||
for attempt := 0; attempt < 16; attempt++ {
|
||||
handshakeExpiry := int(time.Now().Add(30*time.Minute).Unix()) + attempt
|
||||
temp := saveTempIdentityTestAuthKey(t, ctx, pool, keys, handshakeExpiry)
|
||||
permA := saveTempIdentityTestAuthKey(t, ctx, pool, keys, 0)
|
||||
permB := saveTempIdentityTestAuthKey(t, ctx, pool, keys, 0)
|
||||
candidates := []domain.TempAuthKeyBinding{
|
||||
{
|
||||
TempAuthKeyID: temp, PermAuthKeyID: authKeyIDToInt64(permA), Nonce: 301,
|
||||
ExpiresAt: handshakeExpiry, EncryptedMessage: []byte("candidate-a"),
|
||||
},
|
||||
{
|
||||
TempAuthKeyID: temp, PermAuthKeyID: authKeyIDToInt64(permB), Nonce: 302,
|
||||
ExpiresAt: handshakeExpiry, EncryptedMessage: []byte("candidate-b"),
|
||||
},
|
||||
}
|
||||
|
||||
start := make(chan struct{})
|
||||
results := make(chan error, len(candidates))
|
||||
for _, candidate := range candidates {
|
||||
candidate := candidate
|
||||
go func() {
|
||||
<-start
|
||||
results <- bindings.Save(ctx, candidate)
|
||||
}()
|
||||
}
|
||||
close(start)
|
||||
|
||||
var success, rejected int
|
||||
for range candidates {
|
||||
err := <-results
|
||||
switch {
|
||||
case err == nil:
|
||||
success++
|
||||
case errors.Is(err, store.ErrTempAuthKeyAlreadyBound):
|
||||
rejected++
|
||||
default:
|
||||
t.Fatalf("attempt %d concurrent bind: unexpected error %v", attempt, err)
|
||||
}
|
||||
}
|
||||
if success != 1 || rejected != 1 {
|
||||
t.Fatalf("attempt %d concurrent bind outcomes: success=%d rejected=%d, want 1/1", attempt, success, rejected)
|
||||
}
|
||||
|
||||
got, found, err := bindings.GetByTemp(ctx, temp)
|
||||
if err != nil || !found {
|
||||
t.Fatalf("attempt %d get winner: found=%v err=%v", attempt, found, err)
|
||||
}
|
||||
var winner domain.TempAuthKeyBinding
|
||||
switch got.PermAuthKeyID {
|
||||
case candidates[0].PermAuthKeyID:
|
||||
winner = candidates[0]
|
||||
case candidates[1].PermAuthKeyID:
|
||||
winner = candidates[1]
|
||||
default:
|
||||
t.Fatalf("attempt %d winner perm auth key = %d, want one of the candidates", attempt, got.PermAuthKeyID)
|
||||
}
|
||||
assertTempIdentityBinding(t, ctx, bindings, winner)
|
||||
assertTempIdentityAuthKeyExpiry(t, ctx, keys, temp, handshakeExpiry)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTempAuthKeyBindingStoreRejectsMissingPermanentKeyPostgres(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
keys := NewAuthKeyStore(pool)
|
||||
bindings := NewTempAuthKeyBindingStore(pool)
|
||||
|
||||
handshakeExpiry := int(time.Now().Add(time.Hour).Unix())
|
||||
temp := saveTempIdentityTestAuthKey(t, ctx, pool, keys, handshakeExpiry)
|
||||
missingPerm := randomTempIdentityAuthKeyID(t)
|
||||
candidate := domain.TempAuthKeyBinding{
|
||||
TempAuthKeyID: temp,
|
||||
PermAuthKeyID: authKeyIDToInt64(missingPerm),
|
||||
Nonce: 401,
|
||||
TempSessionID: 402,
|
||||
ExpiresAt: handshakeExpiry,
|
||||
EncryptedMessage: []byte("missing permanent key"),
|
||||
}
|
||||
if err := bindings.Save(ctx, candidate); !errors.Is(err, store.ErrAuthKeyBindingInvalid) {
|
||||
t.Fatalf("missing permanent key error = %v, want %v", err, store.ErrAuthKeyBindingInvalid)
|
||||
}
|
||||
_, rawInsertErr := pool.Exec(ctx, `
|
||||
INSERT INTO temp_auth_key_bindings (
|
||||
temp_auth_key_id, perm_auth_key_id, nonce, temp_session_id, expires_at, encrypted_message
|
||||
) VALUES ($1, $2, $3, $4, $5, $6)`,
|
||||
authKeyIDToInt64(temp), candidate.PermAuthKeyID, candidate.Nonce,
|
||||
candidate.TempSessionID, candidate.ExpiresAt, candidate.EncryptedMessage,
|
||||
)
|
||||
var pgErr *pgconn.PgError
|
||||
if !errors.As(rawInsertErr, &pgErr) || pgErr.Code != "23503" || pgErr.ConstraintName != tempAuthKeyPermFKConstraint {
|
||||
t.Fatalf("raw missing-perm FK error = %v, want 23503/%s", rawInsertErr, tempAuthKeyPermFKConstraint)
|
||||
}
|
||||
if _, found, err := bindings.GetByTemp(ctx, temp); err != nil || found {
|
||||
t.Fatalf("binding with missing permanent key found=%v err=%v, want absent", found, err)
|
||||
}
|
||||
assertTempIdentityAuthKeyExpiry(t, ctx, keys, temp, handshakeExpiry)
|
||||
}
|
||||
|
||||
func TestTempAuthKeyBindingConcurrentWithPermanentDeleteLeavesNoDanglingStatePostgres(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
keys := NewAuthKeyStore(pool)
|
||||
bindings := NewTempAuthKeyBindingStore(pool)
|
||||
|
||||
for attempt := 0; attempt < 32; attempt++ {
|
||||
handshakeExpiry := int(time.Now().Add(time.Hour).Unix()) + attempt
|
||||
temp := saveTempIdentityTestAuthKey(t, ctx, pool, keys, handshakeExpiry)
|
||||
perm := saveTempIdentityTestAuthKey(t, ctx, pool, keys, 0)
|
||||
candidate := domain.TempAuthKeyBinding{
|
||||
TempAuthKeyID: temp,
|
||||
PermAuthKeyID: authKeyIDToInt64(perm),
|
||||
Nonce: int64(500 + attempt),
|
||||
TempSessionID: int64(600 + attempt),
|
||||
ExpiresAt: handshakeExpiry,
|
||||
EncryptedMessage: []byte("bind-delete race"),
|
||||
}
|
||||
|
||||
start := make(chan struct{})
|
||||
bindResult := make(chan error, 1)
|
||||
deleteResult := make(chan error, 1)
|
||||
go func() {
|
||||
<-start
|
||||
bindResult <- bindings.Save(ctx, candidate)
|
||||
}()
|
||||
go func() {
|
||||
<-start
|
||||
deleteResult <- keys.Delete(ctx, perm)
|
||||
}()
|
||||
close(start)
|
||||
|
||||
bindErr := <-bindResult
|
||||
if bindErr != nil && !errors.Is(bindErr, store.ErrAuthKeyBindingInvalid) {
|
||||
t.Fatalf("attempt %d bind/delete race bind error = %v", attempt, bindErr)
|
||||
}
|
||||
if err := <-deleteResult; err != nil {
|
||||
t.Fatalf("attempt %d bind/delete race delete: %v", attempt, err)
|
||||
}
|
||||
if _, found, err := bindings.GetByTemp(ctx, temp); err != nil || found {
|
||||
t.Fatalf("attempt %d dangling binding found=%v err=%v", attempt, found, err)
|
||||
}
|
||||
assertTempIdentityAuthKeyMissing(t, ctx, keys, perm)
|
||||
if bindErr == nil {
|
||||
// The binding committed first, so permanent-key deletion must have
|
||||
// observed it (or retried after the FK race) and deleted the temp key.
|
||||
assertTempIdentityAuthKeyMissing(t, ctx, keys, temp)
|
||||
} else {
|
||||
// Deletion won before the binding existed. The loser remains a valid,
|
||||
// unbound protocol temp key until its own expiry collector runs; it is
|
||||
// not allowed to acquire a binding or authorization to the deleted perm.
|
||||
assertTempIdentityAuthKeyExpiry(t, ctx, keys, temp, handshakeExpiry)
|
||||
if _, found, err := NewAuthorizationStore(pool).ByAuthKey(ctx, temp); err != nil || found {
|
||||
t.Fatalf("attempt %d loser temp authorization found=%v err=%v", attempt, found, err)
|
||||
}
|
||||
if err := keys.Delete(ctx, temp); err != nil {
|
||||
t.Fatalf("attempt %d clean unbound loser temp: %v", attempt, err)
|
||||
}
|
||||
assertTempIdentityAuthKeyMissing(t, ctx, keys, temp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthKeyStoreDeleteRetriesDeterministicPermanentBindingFKRacePostgres(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
testCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
keys := NewAuthKeyStore(pool)
|
||||
bindings := NewTempAuthKeyBindingStore(pool)
|
||||
auths := NewAuthorizationStore(pool)
|
||||
|
||||
handshakeExpiry := int(time.Now().Add(time.Hour).Unix())
|
||||
temp := saveTempIdentityTestAuthKey(t, testCtx, pool, keys, handshakeExpiry)
|
||||
perm := saveTempIdentityTestAuthKey(t, testCtx, pool, keys, 0)
|
||||
userID := createRevokeTestUser(t, testCtx, pool, "deterministic-bind-delete-race")
|
||||
if err := auths.Bind(testCtx, domain.Authorization{
|
||||
AuthKeyID: perm,
|
||||
UserID: userID,
|
||||
Hash: 9401,
|
||||
}); err != nil {
|
||||
t.Fatalf("bind permanent authorization: %v", err)
|
||||
}
|
||||
candidate := domain.TempAuthKeyBinding{
|
||||
TempAuthKeyID: temp,
|
||||
PermAuthKeyID: authKeyIDToInt64(perm),
|
||||
Nonce: 901,
|
||||
TempSessionID: 902,
|
||||
ExpiresAt: handshakeExpiry,
|
||||
EncryptedMessage: []byte("deterministic FK retry barrier"),
|
||||
}
|
||||
|
||||
deleteConn, err := pool.Acquire(testCtx)
|
||||
if err != nil {
|
||||
t.Fatalf("acquire dedicated delete connection: %v", err)
|
||||
}
|
||||
defer deleteConn.Release()
|
||||
var deletePID int
|
||||
if err := deleteConn.QueryRow(testCtx, "SELECT pg_backend_pid()").Scan(&deletePID); err != nil {
|
||||
t.Fatalf("get delete backend pid: %v", err)
|
||||
}
|
||||
|
||||
blocker, err := pool.Begin(testCtx)
|
||||
if err != nil {
|
||||
t.Fatalf("begin key-share blocker: %v", err)
|
||||
}
|
||||
defer func() { _ = blocker.Rollback(context.Background()) }()
|
||||
var lockedPermID int64
|
||||
if err := blocker.QueryRow(testCtx, `
|
||||
SELECT auth_key_id
|
||||
FROM auth_keys
|
||||
WHERE auth_key_id = $1
|
||||
FOR KEY SHARE`, authKeyIDToInt64(perm)).Scan(&lockedPermID); err != nil {
|
||||
t.Fatalf("lock permanent key FOR KEY SHARE: %v", err)
|
||||
}
|
||||
if lockedPermID != authKeyIDToInt64(perm) {
|
||||
t.Fatalf("locked permanent key = %d, want %d", lockedPermID, authKeyIDToInt64(perm))
|
||||
}
|
||||
|
||||
observedDeleteDB := &permanentKeyFKRetryObservingDB{Conn: deleteConn}
|
||||
deleteResult := make(chan error, 1)
|
||||
go func() {
|
||||
deleteResult <- NewAuthKeyStore(observedDeleteDB).Delete(testCtx, perm)
|
||||
}()
|
||||
waitForPostgresBackendLockWait(t, testCtx, pool, deletePID)
|
||||
|
||||
// This transaction already owns the compatible KEY SHARE lock needed by the
|
||||
// FK check, so it can commit a new binding while the first DELETE statement
|
||||
// remains blocked with a snapshot that cannot see that binding.
|
||||
if err := NewTempAuthKeyBindingStore(blocker).Save(testCtx, candidate); err != nil {
|
||||
t.Fatalf("save binding behind delete snapshot barrier: %v", err)
|
||||
}
|
||||
if err := blocker.Commit(testCtx); err != nil {
|
||||
t.Fatalf("commit binding and release delete blocker: %v", err)
|
||||
}
|
||||
|
||||
select {
|
||||
case err := <-deleteResult:
|
||||
if err != nil {
|
||||
t.Fatalf("delete after deterministic FK retry: %v", err)
|
||||
}
|
||||
case <-testCtx.Done():
|
||||
t.Fatalf("delete did not finish after releasing FK barrier: %v", testCtx.Err())
|
||||
}
|
||||
if observedDeleteDB.attempts != 2 || observedDeleteDB.fkViolations != 1 {
|
||||
t.Fatalf(
|
||||
"delete attempts/FK violations = %d/%d, want 2/1",
|
||||
observedDeleteDB.attempts,
|
||||
observedDeleteDB.fkViolations,
|
||||
)
|
||||
}
|
||||
|
||||
if _, found, err := bindings.GetByTemp(testCtx, temp); err != nil || found {
|
||||
t.Fatalf("binding after deterministic retry found=%v err=%v, want absent", found, err)
|
||||
}
|
||||
assertTempIdentityAuthKeyMissing(t, testCtx, keys, temp)
|
||||
assertTempIdentityAuthKeyMissing(t, testCtx, keys, perm)
|
||||
assertRevokeTestNoAuthorization(t, testCtx, auths, perm)
|
||||
}
|
||||
|
||||
type permanentKeyFKRetryObservingDB struct {
|
||||
*pgxpool.Conn
|
||||
attempts int
|
||||
fkViolations int
|
||||
}
|
||||
|
||||
func (db *permanentKeyFKRetryObservingDB) QueryRow(ctx context.Context, sql string, args ...interface{}) pgx.Row {
|
||||
db.attempts++
|
||||
return &permanentKeyFKRetryObservingRow{Row: db.Conn.QueryRow(ctx, sql, args...), db: db}
|
||||
}
|
||||
|
||||
func (db *permanentKeyFKRetryObservingDB) observeFKViolation(err error) {
|
||||
var pgErr *pgconn.PgError
|
||||
if errors.As(err, &pgErr) && pgErr.Code == "23503" && pgErr.ConstraintName == tempAuthKeyPermFKConstraint {
|
||||
db.fkViolations++
|
||||
}
|
||||
}
|
||||
|
||||
type permanentKeyFKRetryObservingRow struct {
|
||||
pgx.Row
|
||||
db *permanentKeyFKRetryObservingDB
|
||||
}
|
||||
|
||||
func (row *permanentKeyFKRetryObservingRow) Scan(dest ...any) error {
|
||||
err := row.Row.Scan(dest...)
|
||||
row.db.observeFKViolation(err)
|
||||
return err
|
||||
}
|
||||
|
||||
func waitForPostgresBackendLockWait(
|
||||
t *testing.T,
|
||||
ctx context.Context,
|
||||
pool *pgxpool.Pool,
|
||||
backendPID int,
|
||||
) {
|
||||
t.Helper()
|
||||
ticker := time.NewTicker(10 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
var waiting bool
|
||||
if err := pool.QueryRow(ctx, `
|
||||
SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_stat_activity AS activity
|
||||
WHERE activity.pid = $1
|
||||
AND activity.state = 'active'
|
||||
AND activity.wait_event_type = 'Lock'
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_locks AS waiting_lock
|
||||
WHERE waiting_lock.pid = activity.pid
|
||||
AND NOT waiting_lock.granted
|
||||
)
|
||||
)`, backendPID).Scan(&waiting); err != nil {
|
||||
t.Fatalf("observe delete backend lock wait: %v", err)
|
||||
}
|
||||
if waiting {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
t.Fatalf("backend %d never entered a PostgreSQL lock wait: %v", backendPID, ctx.Err())
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func saveTempIdentityTestAuthKey(
|
||||
t *testing.T,
|
||||
ctx context.Context,
|
||||
pool *pgxpool.Pool,
|
||||
keys store.AuthKeyStore,
|
||||
expiresAt int,
|
||||
) [8]byte {
|
||||
t.Helper()
|
||||
var id [8]byte
|
||||
var value [256]byte
|
||||
if _, err := rand.Read(id[:]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := rand.Read(value[:]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := keys.Save(ctx, store.AuthKeyData{ID: id, Value: value, ExpiresAt: expiresAt}); err != nil {
|
||||
t.Fatalf("save auth key: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = NewAuthKeyStore(pool).Delete(ctx, id)
|
||||
})
|
||||
return id
|
||||
}
|
||||
|
||||
func randomTempIdentityAuthKeyID(t *testing.T) [8]byte {
|
||||
t.Helper()
|
||||
var id [8]byte
|
||||
if _, err := rand.Read(id[:]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func assertTempIdentityBinding(
|
||||
t *testing.T,
|
||||
ctx context.Context,
|
||||
bindings store.TempAuthKeyBindingStore,
|
||||
want domain.TempAuthKeyBinding,
|
||||
) {
|
||||
t.Helper()
|
||||
got, found, err := bindings.GetByTemp(ctx, want.TempAuthKeyID)
|
||||
if err != nil {
|
||||
t.Fatalf("get binding: %v", err)
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("binding not found")
|
||||
}
|
||||
if got.TempAuthKeyID != want.TempAuthKeyID || got.PermAuthKeyID != want.PermAuthKeyID ||
|
||||
got.Nonce != want.Nonce || got.TempSessionID != want.TempSessionID || got.ExpiresAt != want.ExpiresAt ||
|
||||
!bytes.Equal(got.EncryptedMessage, want.EncryptedMessage) {
|
||||
t.Fatalf("binding mismatch: got %+v, want %+v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func assertTempIdentityAuthKeyExpiry(
|
||||
t *testing.T,
|
||||
ctx context.Context,
|
||||
keys store.AuthKeyStore,
|
||||
id [8]byte,
|
||||
want int,
|
||||
) {
|
||||
t.Helper()
|
||||
got, found, err := keys.Get(ctx, id)
|
||||
if err != nil {
|
||||
t.Fatalf("get auth key: %v", err)
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("auth key not found")
|
||||
}
|
||||
if got.ExpiresAt != want {
|
||||
t.Fatalf("auth key expires_at = %d, want %d", got.ExpiresAt, want)
|
||||
}
|
||||
}
|
||||
|
||||
func assertTempIdentityAuthKeyMissing(
|
||||
t *testing.T,
|
||||
ctx context.Context,
|
||||
keys store.AuthKeyStore,
|
||||
id [8]byte,
|
||||
) {
|
||||
t.Helper()
|
||||
if _, found, err := keys.Get(ctx, id); err != nil || found {
|
||||
t.Fatalf("auth key %x found=%v err=%v, want absent", id, found, err)
|
||||
}
|
||||
}
|
||||
|
|
@ -505,8 +505,8 @@ RETURNING id
|
|||
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", userIDs)
|
||||
})
|
||||
if _, err := pool.Exec(ctx, `
|
||||
INSERT INTO auth_keys (auth_key_id, body, server_salt)
|
||||
SELECT id, decode(repeat('00', 256), 'hex'), 0
|
||||
INSERT INTO auth_keys (auth_key_id, body, server_salt, expires_at)
|
||||
SELECT id, decode(repeat('00', 256), 'hex'), 0, 0
|
||||
FROM unnest($1::bigint[]) AS id`, authKeyIDs); err != nil {
|
||||
t.Fatalf("bulk insert old-tail auth keys: %v", err)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue