// SPDX-License-Identifier: MPL-2.0 package authsqlite import ( "context" "database/sql" "encoding/json" "errors" "strings" "time" "gamertan.com/web/auth" "gamertan.com/web/authwebauthn" ) const ( maxPasskeysPerUser = 16 maxCredentialBytes = 64 << 10 maxCeremonySessionBytes = 64 << 10 ) func (store *Store) CreatePasskeyUser(ctx context.Context, user auth.User, enrollment authwebauthn.EnrollmentToken, audit auth.AuditEvent) error { if !validPasskeyUser(user) || !validEnrollment(enrollment) || enrollment.UserID != user.ID || !validAuditEvent(audit) { return errors.New("authsqlite: invalid passkey bootstrap") } tx, err := store.db.BeginTx(ctx, nil) if err != nil { return err } defer tx.Rollback() if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_users(id,username,username_normalized,email,email_normalized,display_name,status,password_change_required,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?)`, user.ID, user.Username, normalize(user.Username), user.Email, normalize(user.Email), user.DisplayName, user.Status, 0, user.CreatedAt.Unix(), user.UpdatedAt.Unix()); err != nil { return err } if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_passkey_enrollment_tokens(token_hash,user_id,created_at,expires_at) VALUES(?,?,?,?)`, enrollment.Digest[:], enrollment.UserID, enrollment.CreatedAt.Unix(), enrollment.ExpiresAt.Unix()); err != nil { return err } if err = appendAudit(ctx, tx, audit); err != nil { return err } return tx.Commit() } func (store *Store) UserByID(ctx context.Context, userID string) (auth.User, error) { if !opaqueID(userID) { return auth.User{}, auth.ErrUserNotFound } return scanPasskeyUser(store.db.QueryRowContext(ctx, `SELECT id,username,email,display_name,status,password_change_required,created_at,updated_at FROM gwf_users WHERE id=?`, userID)) } func (store *Store) UserByIdentifier(ctx context.Context, identifier string) (auth.User, error) { identifier = strings.TrimSpace(identifier) if !text(identifier, 320, false) { return auth.User{}, auth.ErrUserNotFound } return scanPasskeyUser(store.db.QueryRowContext(ctx, `SELECT id,username,email,display_name,status,password_change_required,created_at,updated_at FROM gwf_users WHERE username_normalized=? OR email_normalized=?`, normalize(identifier), normalize(identifier))) } func (store *Store) UserByCredentialID(ctx context.Context, credentialID []byte) (auth.User, error) { if !boundedCredentialID(credentialID) { return auth.User{}, authwebauthn.ErrCredentialNotFound } user, err := scanPasskeyUser(store.db.QueryRowContext(ctx, `SELECT u.id,u.username,u.email,u.display_name,u.status,u.password_change_required,u.created_at,u.updated_at FROM gwf_users u JOIN gwf_passkey_credentials c ON c.user_id=u.id WHERE c.credential_id=?`, credentialID)) if errors.Is(err, auth.ErrUserNotFound) { return auth.User{}, authwebauthn.ErrCredentialNotFound } return user, err } func (store *Store) CredentialsByUserID(ctx context.Context, userID string) ([]authwebauthn.Credential, error) { if !opaqueID(userID) { return nil, auth.ErrUserNotFound } rows, err := store.db.QueryContext(ctx, `SELECT credential_id,label,credential_json,created_at,COALESCE(last_used_at,0) FROM gwf_passkey_credentials WHERE user_id=? ORDER BY created_at,credential_id`, userID) if err != nil { return nil, err } defer rows.Close() credentials := make([]authwebauthn.Credential, 0) for rows.Next() { var credential authwebauthn.Credential var created, used int64 if err = rows.Scan(&credential.ID, &credential.Label, &credential.Data, &created, &used); err != nil { return nil, err } credential.UserID = userID credential.CreatedAt = time.Unix(created, 0).UTC() if used != 0 { credential.LastUsedAt = time.Unix(used, 0).UTC() } credentials = append(credentials, credential) } return credentials, rows.Err() } func (store *Store) SaveCredential(ctx context.Context, credential authwebauthn.Credential, audit auth.AuditEvent) error { if !validCredential(credential, true) || !validAuditEvent(audit) { return errors.New("authsqlite: invalid passkey credential") } tx, err := store.db.BeginTx(ctx, nil) if err != nil { return err } defer tx.Rollback() var count int if err = tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM gwf_passkey_credentials WHERE user_id=?`, credential.UserID).Scan(&count); err != nil { return err } if count >= maxPasskeysPerUser { return errors.New("authsqlite: passkey credential limit reached") } if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_passkey_credentials(credential_id,user_id,label,credential_json,created_at,last_used_at) VALUES(?,?,?,?,?,NULL)`, credential.ID, credential.UserID, credential.Label, []byte(credential.Data), credential.CreatedAt.Unix()); err != nil { return err } if err = appendAudit(ctx, tx, audit); err != nil { return err } return tx.Commit() } func (store *Store) UpdateCredential(ctx context.Context, credential authwebauthn.Credential) error { if !validCredential(credential, false) || credential.LastUsedAt.IsZero() { return errors.New("authsqlite: invalid passkey credential update") } result, err := store.db.ExecContext(ctx, `UPDATE gwf_passkey_credentials SET credential_json=?,last_used_at=? WHERE credential_id=? AND user_id=?`, []byte(credential.Data), credential.LastUsedAt.Unix(), credential.ID, credential.UserID) if err != nil { return err } changed, err := result.RowsAffected() if err != nil { return err } if changed != 1 { return authwebauthn.ErrCredentialNotFound } return nil } func (store *Store) DeleteCredential(ctx context.Context, userID string, credentialID []byte, minimumRemaining int, audit auth.AuditEvent) error { if !opaqueID(userID) || !boundedCredentialID(credentialID) || minimumRemaining < 1 || minimumRemaining > maxPasskeysPerUser || !validAuditEvent(audit) { return errors.New("authsqlite: invalid passkey credential deletion") } tx, err := store.db.BeginTx(ctx, nil) if err != nil { return err } defer tx.Rollback() var count int if err = tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM gwf_passkey_credentials WHERE user_id=?`, userID).Scan(&count); err != nil { return err } if count <= minimumRemaining { if minimumRemaining == 1 { return authwebauthn.ErrLastCredential } return authwebauthn.ErrCredentialFloor } result, err := tx.ExecContext(ctx, `DELETE FROM gwf_passkey_credentials WHERE user_id=? AND credential_id=?`, userID, credentialID) if err != nil { return err } if changed, rowsErr := result.RowsAffected(); rowsErr != nil || changed != 1 { if rowsErr != nil { return rowsErr } return authwebauthn.ErrCredentialNotFound } if err = appendAudit(ctx, tx, audit); err != nil { return err } return tx.Commit() } func (store *Store) CredentialCount(ctx context.Context, userID string) (int, error) { if !opaqueID(userID) { return 0, auth.ErrUserNotFound } var count int err := store.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM gwf_passkey_credentials WHERE user_id=?`, userID).Scan(&count) return count, err } func (store *Store) CreateCeremony(ctx context.Context, ceremony authwebauthn.Ceremony) error { if !validCeremony(ceremony) { return errors.New("authsqlite: invalid passkey ceremony") } _, err := store.db.ExecContext(ctx, `INSERT INTO gwf_passkey_ceremonies(token_hash,kind,user_id,label,session_json,binding_hash,created_at,expires_at) VALUES(?,?,NULLIF(?,''),?,?,?,?,?)`, ceremony.Digest[:], ceremony.Kind, ceremony.UserID, ceremony.Label, []byte(ceremony.SessionData), ceremony.BindingDigest[:], ceremony.CreatedAt.Unix(), ceremony.ExpiresAt.Unix()) return err } func (store *Store) TakeCeremony(ctx context.Context, digest [32]byte, now time.Time) (authwebauthn.Ceremony, error) { if zeroDigest(digest) || now.IsZero() { return authwebauthn.Ceremony{}, authwebauthn.ErrCeremonyNotFound } var ceremony authwebauthn.Ceremony var userID sql.NullString var binding []byte var created, expires int64 err := store.db.QueryRowContext(ctx, `DELETE FROM gwf_passkey_ceremonies WHERE token_hash=? RETURNING kind,user_id,label,session_json,binding_hash,created_at,expires_at`, digest[:]).Scan(&ceremony.Kind, &userID, &ceremony.Label, &ceremony.SessionData, &binding, &created, &expires) if errors.Is(err, sql.ErrNoRows) { return authwebauthn.Ceremony{}, authwebauthn.ErrCeremonyNotFound } if err != nil { return authwebauthn.Ceremony{}, err } ceremony.Digest = digest ceremony.UserID = userID.String copy(ceremony.BindingDigest[:], binding) ceremony.CreatedAt, ceremony.ExpiresAt = time.Unix(created, 0).UTC(), time.Unix(expires, 0).UTC() if len(binding) != sha256Size || !now.Before(ceremony.ExpiresAt) { return authwebauthn.Ceremony{}, authwebauthn.ErrCeremonyNotFound } return ceremony, nil } func (store *Store) ConsumeEnrollmentToken(ctx context.Context, digest [32]byte, now time.Time) (auth.User, error) { if zeroDigest(digest) || now.IsZero() { return auth.User{}, authwebauthn.ErrEnrollmentNotFound } tx, err := store.db.BeginTx(ctx, nil) if err != nil { return auth.User{}, err } defer tx.Rollback() var userID string err = tx.QueryRowContext(ctx, `DELETE FROM gwf_passkey_enrollment_tokens WHERE token_hash=? AND expires_at>? RETURNING user_id`, digest[:], now.Unix()).Scan(&userID) if errors.Is(err, sql.ErrNoRows) { return auth.User{}, authwebauthn.ErrEnrollmentNotFound } if err != nil { return auth.User{}, err } user, err := scanPasskeyUser(tx.QueryRowContext(ctx, `SELECT id,username,email,display_name,status,password_change_required,created_at,updated_at FROM gwf_users WHERE id=?`, userID)) if err != nil { return auth.User{}, err } if err = tx.Commit(); err != nil { return auth.User{}, err } return user, nil } func (store *Store) RecoverUser(ctx context.Context, identifier string, enrollment authwebauthn.EnrollmentToken, audit auth.AuditEvent) (auth.User, error) { if !text(strings.TrimSpace(identifier), 320, false) || !validEnrollment(enrollment) || !validAuditEvent(audit) { return auth.User{}, errors.New("authsqlite: invalid passkey recovery") } tx, err := store.db.BeginTx(ctx, nil) if err != nil { return auth.User{}, err } defer tx.Rollback() user, err := scanPasskeyUser(tx.QueryRowContext(ctx, `SELECT id,username,email,display_name,status,password_change_required,created_at,updated_at FROM gwf_users WHERE username_normalized=? OR email_normalized=?`, normalize(identifier), normalize(identifier))) if err != nil { return auth.User{}, err } if enrollment.UserID != user.ID || audit.ResourceID != user.ID { return auth.User{}, errors.New("authsqlite: passkey recovery identity mismatch") } if _, err = tx.ExecContext(ctx, `DELETE FROM gwf_auth_sessions WHERE user_id=?`, user.ID); err != nil { return auth.User{}, err } if _, err = tx.ExecContext(ctx, `DELETE FROM gwf_passkey_ceremonies WHERE user_id=?`, user.ID); err != nil { return auth.User{}, err } if _, err = tx.ExecContext(ctx, `DELETE FROM gwf_passkey_enrollment_tokens WHERE user_id=?`, user.ID); err != nil { return auth.User{}, err } if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_passkey_enrollment_tokens(token_hash,user_id,created_at,expires_at) VALUES(?,?,?,?)`, enrollment.Digest[:], user.ID, enrollment.CreatedAt.Unix(), enrollment.ExpiresAt.Unix()); err != nil { return auth.User{}, err } if err = appendAudit(ctx, tx, audit); err != nil { return auth.User{}, err } if err = tx.Commit(); err != nil { return auth.User{}, err } return user, nil } type rowScanner interface{ Scan(...any) error } func scanPasskeyUser(row rowScanner) (auth.User, error) { var user auth.User var passwordChangeRequired int var created, updated int64 if err := row.Scan(&user.ID, &user.Username, &user.Email, &user.DisplayName, &user.Status, &passwordChangeRequired, &created, &updated); err != nil { if errors.Is(err, sql.ErrNoRows) { return auth.User{}, auth.ErrUserNotFound } return auth.User{}, err } user.PasswordChangeRequired = passwordChangeRequired == 1 user.CreatedAt, user.UpdatedAt = time.Unix(created, 0).UTC(), time.Unix(updated, 0).UTC() return user, nil } func validPasskeyUser(user auth.User) bool { return opaqueID(user.ID) && text(user.Username, 64, false) && text(user.Email, 320, false) && text(user.DisplayName, 128, false) && user.Status == "active" && !user.CreatedAt.IsZero() && !user.UpdatedAt.IsZero() } func validEnrollment(token authwebauthn.EnrollmentToken) bool { return !zeroDigest(token.Digest) && opaqueID(token.UserID) && !token.CreatedAt.IsZero() && token.ExpiresAt.After(token.CreatedAt) } func boundedCredentialID(value []byte) bool { return len(value) >= 16 && len(value) <= 1024 } func validCredential(credential authwebauthn.Credential, requireLabel bool) bool { return boundedCredentialID(credential.ID) && opaqueID(credential.UserID) && (!requireLabel || text(credential.Label, 80, false)) && len(credential.Data) > 0 && len(credential.Data) <= maxCredentialBytes && json.Valid(credential.Data) && (!requireLabel || !credential.CreatedAt.IsZero()) } func validCeremony(ceremony authwebauthn.Ceremony) bool { validKind := ceremony.Kind == authwebauthn.CeremonyRegistration || ceremony.Kind == authwebauthn.CeremonyLogin || ceremony.Kind == authwebauthn.CeremonyApproval validUser := ceremony.Kind == authwebauthn.CeremonyLogin && ceremony.UserID == "" || opaqueID(ceremony.UserID) validLabel := ceremony.Kind == authwebauthn.CeremonyRegistration && text(ceremony.Label, 80, false) || ceremony.Kind != authwebauthn.CeremonyRegistration && ceremony.Label == "" zeroBinding := zeroDigest(ceremony.BindingDigest) validBinding := ceremony.Kind == authwebauthn.CeremonyApproval && !zeroBinding || ceremony.Kind != authwebauthn.CeremonyApproval && zeroBinding return !zeroDigest(ceremony.Digest) && validKind && validUser && validLabel && validBinding && len(ceremony.SessionData) > 0 && len(ceremony.SessionData) <= maxCeremonySessionBytes && json.Valid(ceremony.SessionData) && !ceremony.CreatedAt.IsZero() && ceremony.ExpiresAt.After(ceremony.CreatedAt) } const sha256Size = 32