passkeys.go
⎇
Raw
1package db
2
3import (
4 "context"
5 "database/sql"
6 "errors"
7)
8
9type Passkey struct {
10 ID int64
11 UserID int64
12 CredentialID string
13 PublicKey string
14 Counter int64
15 CreatedAt string
16}
17
18func (d *DB) ListPasskeys(ctx context.Context, userID int64) ([]Passkey, error) {
19 rows, err := d.QueryContext(ctx,
20 `SELECT id, user_id, credential_id, public_key, counter, created_at
21 FROM passkeys WHERE user_id = ?`, userID)
22 if err != nil {
23 return nil, err
24 }
25 defer rows.Close()
26 var out []Passkey
27 for rows.Next() {
28 var p Passkey
29 if err := rows.Scan(&p.ID, &p.UserID, &p.CredentialID, &p.PublicKey, &p.Counter, &p.CreatedAt); err != nil {
30 return nil, err
31 }
32 out = append(out, p)
33 }
34 return out, rows.Err()
35}
36
37func (d *DB) CreatePasskey(ctx context.Context, userID int64, credentialID, publicKey string,
38 counter int64, createdAt string,
39) error {
40 _, err := d.ExecContext(ctx,
41 `INSERT INTO passkeys (user_id, credential_id, public_key, counter, created_at)
42 VALUES (?, ?, ?, ?, ?)`,
43 userID, credentialID, publicKey, counter, createdAt)
44 return err
45}
46
47// PasskeyWithUser is a stored credential plus its owner's name and approval
48// state.
49type PasskeyWithUser struct {
50 Passkey
51 Username string
52 IsPending bool
53}
54
55func (d *DB) PasskeyByCredentialID(ctx context.Context, credentialID string) (*PasskeyWithUser, error) {
56 var p PasskeyWithUser
57 err := d.QueryRowContext(ctx,
58 `SELECT passkeys.id, passkeys.user_id, passkeys.credential_id, passkeys.public_key,
59 passkeys.counter, passkeys.created_at, users.username, users.is_pending
60 FROM passkeys
61 JOIN users ON users.id = passkeys.user_id
62 WHERE passkeys.credential_id = ?`, credentialID).
63 Scan(&p.ID, &p.UserID, &p.CredentialID, &p.PublicKey, &p.Counter, &p.CreatedAt,
64 &p.Username, &p.IsPending)
65 if errors.Is(err, sql.ErrNoRows) {
66 return nil, nil
67 }
68 if err != nil {
69 return nil, err
70 }
71 return &p, nil
72}
73
74// UpdatePasskeyCounter advances the signature counter only if it still holds
75// the expected value, which rejects replayed assertions.
76func (d *DB) UpdatePasskeyCounter(ctx context.Context, id, expected, next int64) (bool, error) {
77 res, err := d.ExecContext(ctx,
78 `UPDATE passkeys SET counter = ? WHERE id = ? AND counter = ?`, next, id, expected)
79 if err != nil {
80 return false, err
81 }
82 n, err := res.RowsAffected()
83 return n > 0, err
84}
85
86// RevokePasskey deletes one passkey, unless it is the user's last way to log
87// in. The count check and the delete share a transaction so two concurrent
88// revocations cannot both pass the guard.
89func (d *DB) RevokePasskey(ctx context.Context, userID, passkeyID int64) (revoked bool, err error) {
90 tx, err := d.BeginTx(ctx, nil)
91 if err != nil {
92 return false, err
93 }
94 defer tx.Rollback()
95
96 var remaining int
97 if err = tx.QueryRowContext(ctx,
98 `SELECT COUNT(*) FROM passkeys WHERE user_id = ? AND id != ?`,
99 userID, passkeyID).Scan(&remaining); err != nil {
100 return false, err
101 }
102 // The password is read inside the transaction, so a concurrent password
103 // removal cannot leave the account with no login method.
104 var hasPassword bool
105 if err = tx.QueryRowContext(ctx,
106 `SELECT password_hash IS NOT NULL FROM users WHERE id = ?`, userID).Scan(&hasPassword); err != nil {
107 return false, err
108 }
109 if !hasPassword && remaining == 0 {
110 return false, nil
111 }
112 if _, err = tx.ExecContext(ctx, `DELETE FROM passkeys WHERE id = ?`, passkeyID); err != nil {
113 return false, err
114 }
115 return true, tx.Commit()
116}
117
118// PasskeyOwner returns the owner of a passkey, used to check ownership before
119// a revoke.
120func (d *DB) PasskeyOwner(ctx context.Context, id int64) (int64, bool, error) {
121 var userID int64
122 err := d.QueryRowContext(ctx, `SELECT user_id FROM passkeys WHERE id = ?`, id).Scan(&userID)
123 if errors.Is(err, sql.ErrNoRows) {
124 return 0, false, nil
125 }
126 if err != nil {
127 return 0, false, err
128 }
129 return userID, true, nil
130}
131