Files
verdande/internal/store/passkeys.go
T

207 lines
6.7 KiB
Go
Raw Normal View History

package store
import (
"context"
"database/sql"
"errors"
"time"
)
// A Passkey is one credential on one device.
//
// One row per credential rather than per account: a person has a laptop and a
// phone, and losing one must not mean losing the way in. The name is theirs to
// write — "min bærbare" is what makes the list reviewable, and a list nobody can
// read is a list nobody revokes from.
type Passkey struct {
ID string
UserID string
CredentialID string
PublicKey []byte
AAGUID string
SignCount uint32
Discoverable bool
UserVerified bool
Name string
CreatedAt time.Time
LastUsedAt time.Time
}
func (db *DB) CreatePasskey(ctx context.Context, p *Passkey) error {
if p.ID == "" {
p.ID = NewID()
}
p.CreatedAt = time.Now().UTC()
_, err := db.ExecContext(ctx, `
INSERT INTO passkeys (id, user_id, credential_id, public_key, aaguid, sign_count,
discoverable, user_verified, name, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
p.ID, p.UserID, p.CredentialID, p.PublicKey, p.AAGUID, p.SignCount,
boolToInt(p.Discoverable), boolToInt(p.UserVerified), p.Name, p.CreatedAt.Unix())
return err
}
func (db *DB) ListPasskeys(ctx context.Context, userID string) ([]Passkey, error) {
rows, err := db.QueryContext(ctx, `
SELECT id, user_id, credential_id, public_key, aaguid, sign_count,
discoverable, user_verified, name, created_at, COALESCE(last_used_at, 0)
FROM passkeys WHERE user_id = ? ORDER BY created_at`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
return scanPasskeys(rows)
}
// PasskeyByCredentialID is how a login finds the account, before anybody has said
// who they are. The credential id is unique across the instance, which is what
// makes that possible.
func (db *DB) PasskeyByCredentialID(ctx context.Context, credentialID string) (*Passkey, error) {
rows, err := db.QueryContext(ctx, `
SELECT id, user_id, credential_id, public_key, aaguid, sign_count,
discoverable, user_verified, name, created_at, COALESCE(last_used_at, 0)
FROM passkeys WHERE credential_id = ?`, credentialID)
if err != nil {
return nil, err
}
defer rows.Close()
list, err := scanPasskeys(rows)
if err != nil {
return nil, err
}
if len(list) == 0 {
return nil, ErrNotFound
}
return &list[0], nil
}
// TouchPasskey records a use and the authenticator's counter.
//
// The counter is stored to be compared. One that goes backwards means the
// credential has been cloned, which is the single thing this design cannot
// otherwise detect — and not every authenticator keeps one, so zero means "not
// offered" rather than "suspicious".
func (db *DB) TouchPasskey(ctx context.Context, id string, signCount uint32) error {
_, err := db.ExecContext(ctx,
`UPDATE passkeys SET sign_count = ?, last_used_at = ? WHERE id = ?`,
signCount, time.Now().Unix(), id)
return err
}
func (db *DB) RenamePasskey(ctx context.Context, id, userID, name string) error {
res, err := db.ExecContext(ctx,
`UPDATE passkeys SET name = ? WHERE id = ? AND user_id = ?`, name, id, userID)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
// DeletePasskey removes one key. Scoped in the SQL rather than checked first: an
// id read in one step and deleted in the next is a window, and the reason somebody
// is on this screen at all is usually that they think a device is gone.
func (db *DB) DeletePasskey(ctx context.Context, id, userID string) error {
res, err := db.ExecContext(ctx,
`DELETE FROM passkeys WHERE id = ? AND user_id = ?`, id, userID)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
// CountPasskeys is what stops somebody removing their last way in when it is the
// only one they have.
func (db *DB) CountPasskeys(ctx context.Context, userID string) (int, error) {
var n int
err := db.QueryRowContext(ctx,
`SELECT count(*) FROM passkeys WHERE user_id = ?`, userID).Scan(&n)
return n, err
}
func scanPasskeys(rows *sql.Rows) ([]Passkey, error) {
out := []Passkey{}
for rows.Next() {
var p Passkey
var discoverable, verified int
var created, used int64
if err := rows.Scan(&p.ID, &p.UserID, &p.CredentialID, &p.PublicKey, &p.AAGUID,
&p.SignCount, &discoverable, &verified, &p.Name, &created, &used); err != nil {
return nil, err
}
p.Discoverable = discoverable == 1
p.UserVerified = verified == 1
p.CreatedAt = time.Unix(created, 0).UTC()
if used > 0 {
p.LastUsedAt = time.Unix(used, 0).UTC()
}
out = append(out, p)
}
return out, rows.Err()
}
// --- challenges -------------------------------------------------------------------
// StoreChallenge keeps the server's side of one ceremony.
//
// Server-side rather than in a cookie: the whole point of a challenge is that the
// server chose it and remembers choosing it. It is deleted when answered and swept
// when it expires — a challenge that outlives its ceremony is a replay waiting to
// be tried.
func (db *DB) StoreChallenge(ctx context.Context, userID, challenge, purpose string, session []byte, ttl time.Duration) (string, error) {
id := NewID()
now := time.Now()
_, err := db.ExecContext(ctx, `
INSERT INTO webauthn_challenges (id, user_id, challenge, purpose, session, created_at, expires_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
id, nullString(userID), challenge, purpose, session, now.Unix(), now.Add(ttl).Unix())
return id, err
}
// TakeChallenge reads a challenge and removes it in the same transaction.
//
// Taken rather than read, because a challenge is answerable exactly once. Two
// requests racing the same one would otherwise both succeed, which is the whole
// shape of a replay.
func (db *DB) TakeChallenge(ctx context.Context, id, purpose string) (userID string, session []byte, err error) {
err = db.Tx(ctx, func(tx *sql.Tx) error {
var owner sql.NullString
var expires int64
row := tx.QueryRowContext(ctx,
`SELECT user_id, session, expires_at FROM webauthn_challenges WHERE id = ? AND purpose = ?`,
id, purpose)
if err := row.Scan(&owner, &session, &expires); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return ErrNotFound
}
return err
}
if _, err := tx.ExecContext(ctx, `DELETE FROM webauthn_challenges WHERE id = ?`, id); err != nil {
return err
}
if time.Now().Unix() > expires {
return ErrNotFound
}
userID = owner.String
return nil
})
return userID, session, err
}
// PurgeChallenges is run by the nightly sweep. An answered challenge deletes
// itself; an abandoned one is what this is for.
func (db *DB) PurgeChallenges(ctx context.Context) (int64, error) {
res, err := db.ExecContext(ctx,
`DELETE FROM webauthn_challenges WHERE expires_at < ?`, time.Now().Unix())
if err != nil {
return 0, err
}
return res.RowsAffected()
}