207 lines
6.7 KiB
Go
207 lines
6.7 KiB
Go
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()
|
||
|
|
}
|