Polish up Passkey interface

- Fix indentation
- Split awkward initializer into separate methods
- Rename credentials to passkeys
- Simplify authenticator registry loading
- Flatten nested errors under ActionPack::WebAuthn
- Set passkey current params only requests that use it
- Inline anemic methods
- Replace custom validation with ActiveModel::Validation
- Rename identity to holder in Passkey
- Rename credentials to passkeys in JS
- Extract framework library out of controllers
- Pass params hashes down to ActionPack
- Attempt to simplify public interface
- Push data decoding down to the classes representing the data
- Introduce has_passkeys
- Add CBOR bigint support
- Rename ActionPack::WebAuthn::Passkey to ActionPack::Passkey
- Add create_passkey_button helper
- Rename public-key to creation-options
- Add sign_in_with_passkey_button helper
- Dispatch events for the whole Passkey lifecycle
- Add ED25519 support
- Prevent crash on missing meta tag
- Validate resident key options
- Don't clobber existing ActionPack config options
- Validate cryptographic params
- Move CurrentWebAuthnRequest into ActionPack::Passkey
- Use ActiveModel::Attributes for Options objects
- Implement expiring challanges
- Add lifecycle events
- Extract param helpers into Request
- Add passkey_creation_options and passkey_request_options helpers
- Add create_passkey_challenge to make it easier to override the create method if needed
- Prefix all view helpers with passkey_
- Auto-include ActionPack::Passkey::Holder
- Make the passkey challange url configurable
- Add a reminder about Passkeys to the magic link email
This commit is contained in:
Stanko K.R.
2026-03-04 14:12:40 +01:00
parent f4fbe17b22
commit 017bcc9ce1
73 changed files with 2326 additions and 1103 deletions
+9
View File
@@ -31,4 +31,13 @@
margin-inline-start: auto;
opacity: 0;
}
[data-passkey-errors] [data-passkey-error] {
display: none;
}
[data-passkey-errors][data-passkey-error-state="error"] [data-passkey-error="error"],
[data-passkey-errors][data-passkey-error-state="cancelled"] [data-passkey-error="cancelled"] {
display: block;
}
}
@@ -8,9 +8,6 @@ module CurrentRequest
Current.user_agent = request.user_agent
Current.ip_address = request.ip
Current.referrer = request.referrer
ActionPack::WebAuthn::Current.host = request.host
ActionPack::WebAuthn::Current.origin = request.base_url
end
end
end
@@ -0,0 +1,7 @@
class My::PasskeyChallengesController < ActionPack::Passkey::ChallengesController
include Authentication
include Authorization
allow_unauthenticated_access
disallow_account_scope
end
+34
View File
@@ -0,0 +1,34 @@
class My::PasskeysController < ApplicationController
include ActionPack::Passkey::Request
before_action :set_passkey, only: %i[ edit update destroy ]
def index
@passkeys = Current.identity.passkeys.order(name: :asc, created_at: :desc)
@creation_options = passkey_creation_options(holder: Current.identity)
end
def create
passkey = Current.identity.passkeys.register(passkey_creation_params)
redirect_to edit_my_passkey_path(passkey, created: true)
end
def edit
end
def update
@passkey.update!(params.expect(passkey: [ :name ]))
redirect_to my_passkeys_path
end
def destroy
@passkey.destroy!
redirect_to my_passkeys_path
end
private
def set_passkey
@passkey = Current.identity.passkeys.find(params[:id])
end
end
@@ -1,44 +1,27 @@
class Sessions::PasskeysController < ApplicationController
include ActionPack::Passkey::Request
disallow_account_scope
require_unauthenticated_access
rate_limit to: 10, within: 3.minutes, only: :create, with: :rate_limit_exceeded
def create
credential = Identity::Credential.authenticate(
passkey: passkey_params,
challenge: session.delete(:webauthn_challenge)
)
if credential
authentication_succeeded(credential.identity)
else
authentication_failed
end
end
private
def passkey_params
params.expect(passkey: [ :id, :client_data_json, :authenticator_data, :signature ])
end
def authentication_succeeded(identity)
start_new_session_for identity
if credential = ActionPack::Passkey.authenticate(passkey_request_params)
start_new_session_for credential.holder
respond_to do |format|
format.html { redirect_to after_authentication_url }
format.json { render json: { session_token: session_token } }
end
end
def authentication_failed
alert_message = "That passkey didn't work. Try again."
else
respond_to do |format|
format.html { redirect_to new_session_path, alert: alert_message }
format.json { render json: { message: alert_message }, status: :unauthorized }
format.html { redirect_to new_session_path, alert: "That passkey didn't work. Try again." }
format.json { render json: { message: "That passkey didn't work. Try again." }, status: :unauthorized }
end
end
end
private
def rate_limit_exceeded
rate_limit_exceeded_message = "Try again later."
+3 -3
View File
@@ -1,4 +1,6 @@
class SessionsController < ApplicationController
include ActionPack::Passkey::Request
disallow_account_scope
require_unauthenticated_access except: :destroy
rate_limit to: 10, within: 3.minutes, only: :create, with: :rate_limit_exceeded
@@ -6,8 +8,7 @@ class SessionsController < ApplicationController
layout "public"
def new
@request_options = Identity::Credential.request_options
session[:webauthn_challenge] = @request_options.challenge
@request_options = passkey_request_options
end
def create
@@ -69,5 +70,4 @@ class SessionsController < ApplicationController
end
end
end
end
@@ -1,46 +0,0 @@
class Users::CredentialsController < ApplicationController
before_action :set_credential, only: %i[ edit update destroy ]
def index
@credentials = Current.identity.credentials.order(name: :asc, created_at: :desc)
@creation_options = Identity::Credential.creation_options(identity: Current.identity, display_name: Current.user.name)
session[:webauthn_challenge] = @creation_options.challenge
end
def create
credential = Current.identity.credentials.register(
passkey: passkey_params,
challenge: session.delete(:webauthn_challenge)
)
render json: { location: edit_user_credential_path(Current.user, credential, created: true) }
rescue ActionPack::WebAuthn::Authenticator::Response::InvalidResponseError => error
render json: { error: error.message }, status: :unprocessable_entity
end
def edit
end
def update
@credential.update!(credential_params)
redirect_to user_credentials_path(Current.user)
end
def destroy
@credential.destroy!
redirect_to user_credentials_path(Current.user)
end
private
def set_credential
@credential = Current.identity.credentials.find(params[:id])
end
def credential_params
params.expect(credential: [ :name ])
end
def passkey_params
params.expect(passkey: [ :client_data_json, :attestation_object, transports: [] ])
end
end
+1
View File
@@ -6,3 +6,4 @@ import "controllers"
import "lexxy"
import "@rails/actiontext"
import "lib/action_pack/passkey"
@@ -1,60 +0,0 @@
import { Controller } from "@hotwired/stimulus"
import { post } from "@rails/request.js"
import { base64urlToBuffer, bufferToBase64url } from "helpers/base64url_helpers"
export default class extends Controller {
static values = { publicKey: Object, registerUrl: String }
static targets = ["button", "error", "cancelled"]
async create() {
this.buttonTarget.disabled = true
this.errorTarget.hidden = true
this.cancelledTarget.hidden = true
try {
const publicKey = this.#prepareOptions(this.publicKeyValue)
const credential = await navigator.credentials.create({ publicKey })
await this.#registerCredential(credential)
} catch (error) {
if (error.name === "AbortError" || error.name === "NotAllowedError") {
this.cancelledTarget.hidden = false
} else {
this.errorTarget.hidden = false
}
this.buttonTarget.disabled = false
}
}
async #registerCredential(credential) {
const response = await post(this.registerUrlValue, {
body: JSON.stringify({
passkey: {
client_data_json: new TextDecoder().decode(credential.response.clientDataJSON),
attestation_object: bufferToBase64url(credential.response.attestationObject),
transports: credential.response.getTransports?.() || []
}
}),
contentType: "application/json",
responseKind: "json"
})
if (response.ok) {
const { location } = await response.json
Turbo.visit(location)
} else {
throw new Error("Registration failed")
}
}
#prepareOptions(options) {
return {
...options,
challenge: base64urlToBuffer(options.challenge),
user: { ...options.user, id: base64urlToBuffer(options.user.id) },
excludeCredentials: (options.excludeCredentials || []).map(cred => ({
...cred,
id: base64urlToBuffer(cred.id)
}))
}
}
}
@@ -1,80 +0,0 @@
import { Controller } from "@hotwired/stimulus"
import { base64urlToBuffer, bufferToBase64url } from "helpers/base64url_helpers"
export default class extends Controller {
static values = { publicKey: Object, url: String, csrfToken: String }
#abortController
connect() {
this.#attemptConditionalMediation()
}
disconnect() {
this.#abortController?.abort()
}
async #attemptConditionalMediation() {
if (!await PublicKeyCredential?.isConditionalMediationAvailable?.()) return
this.#abortController = new AbortController()
try {
const credential = await navigator.credentials.get({
publicKey: this.#prepareOptions(this.publicKeyValue),
mediation: "conditional",
signal: this.#abortController.signal
})
this.#submitAssertion(credential)
} catch (error) {
if (error.name !== "AbortError") {
console.error("Passkey error:", error)
}
}
}
#submitAssertion(credential) {
const form = document.createElement("form")
form.method = "POST"
form.action = this.urlValue
form.style.display = "none"
const fields = {
authenticity_token: this.csrfTokenValue,
"passkey[id]": credential.id,
"passkey[client_data_json]": new TextDecoder().decode(credential.response.clientDataJSON),
"passkey[authenticator_data]": bufferToBase64url(credential.response.authenticatorData),
"passkey[signature]": bufferToBase64url(credential.response.signature)
}
for (const [name, value] of Object.entries(fields)) {
const input = document.createElement("input")
input.type = "hidden"
input.name = name
input.value = value
form.appendChild(input)
}
document.body.appendChild(form)
form.submit()
}
#prepareOptions(options) {
const prepared = {
...options,
challenge: base64urlToBuffer(options.challenge)
}
if (options.allowCredentials?.length) {
prepared.allowCredentials = options.allowCredentials.map(cred => ({
...cred,
id: base64urlToBuffer(cred.id)
}))
} else {
delete prepared.allowCredentials
}
return prepared
}
}
@@ -1,12 +0,0 @@
export function base64urlToBuffer(base64url) {
const base64 = base64url.replace(/-/g, "+").replace(/_/g, "/")
const padding = "=".repeat((4 - base64.length % 4) % 4)
const binary = atob(base64 + padding)
return Uint8Array.from(binary, c => c.charCodeAt(0)).buffer
}
export function bufferToBase64url(buffer) {
const bytes = new Uint8Array(buffer)
const binary = String.fromCharCode(...bytes)
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "")
}
+246
View File
@@ -0,0 +1,246 @@
// JS companion for the ActionPack::Passkey Ruby helpers.
//
// Binds click handlers to passkey buttons and manages the WebAuthn ceremony
// lifecycle (challenge refresh, credential creation/authentication, form submission).
//
// Expected data attributes:
// [data-passkey="create"] — triggers the registration ceremony
// [data-passkey="sign_in"] — triggers the authentication ceremony
// [data-passkey-mediation="conditional"] — on a <form>, enables autofill-assisted sign in
// [data-passkey-errors] — container whose data-passkey-error-state is set on failure
// [data-passkey-error="error|cancelled"] — children shown/hidden via CSS based on error state
// [data-passkey-field="..."] — hidden fields populated before form submission
//
// Custom events (all bubble):
// passkey:start — ceremony begun
// passkey:success — credential obtained, form about to submit
// passkey:error — ceremony failed; detail: { error, cancelled }
//
// Meta tags (rendered by the Ruby form helpers):
// <meta name="passkey-creation-options"> — JSON WebAuthn creation options
// <meta name="passkey-request-options"> — JSON WebAuthn request options
// <meta name="passkey-challenge-url"> — endpoint to refresh the challenge nonce
import { register, authenticate } from "lib/action_pack/webauthn"
let listeners
let currentDocument
document.addEventListener("DOMContentLoaded", setup)
document.addEventListener("turbo:load", setup)
document.addEventListener("turbo:before-cache", teardown)
// Set error state on the nearest [data-passkey-errors] container.
// The app's CSS is responsible for showing/hiding children based on
// the data-passkey-error-state attribute ("error" or "cancelled").
document.addEventListener("passkey:error", ({ target, detail: { cancelled } }) => {
const container = target.closest("[data-passkey-errors]")
if (container) {
container.dataset.passkeyErrorState = cancelled ? "cancelled" : "error"
}
})
// Bind click handlers to passkey buttons and attempt conditional mediation.
// Guards against duplicate setup.
function setup() {
if (currentDocument !== document.documentElement) {
currentDocument = document.documentElement
listeners?.abort()
listeners = new AbortController()
for (const button of document.querySelectorAll('[data-passkey="create"]')) {
button.addEventListener("click", () => createPasskey(button), { signal: listeners.signal })
}
for (const button of document.querySelectorAll('[data-passkey="sign_in"]')) {
button.addEventListener("click", () => signInWithPasskey(button), { signal: listeners.signal })
}
attemptConditionalMediation()
}
}
// Reset transient DOM state and unbind event handlers to prevent leaks and duplicate handlers.
function teardown() {
currentDocument = null
listeners?.abort()
for (const button of document.querySelectorAll('[data-passkey][disabled]')) {
button.disabled = false
}
for (const container of document.querySelectorAll("[data-passkey-errors]")) {
delete container.dataset.passkeyErrorState
}
}
// Run the WebAuthn registration ceremony: refresh the challenge, prompt the
// browser to create a credential, fill the form's hidden fields, and submit.
async function createPasskey(button) {
const form = button.closest("form")
if (form) {
button.disabled = true
button.dispatchEvent(new CustomEvent("passkey:start", { bubbles: true }))
try {
if (!passkeysAvailable()) throw new Error("Passkeys are not supported by this browser")
const creationOptions = getCreationOptions()
if (!creationOptions) throw new Error("Missing passkey creation options")
await refreshChallenge(creationOptions)
const passkey = await register(creationOptions)
button.dispatchEvent(new CustomEvent("passkey:success", { bubbles: true }))
fillCreateForm(form, passkey)
form.submit()
} catch (error) {
button.disabled = false
const cancelled = error.name === "AbortError" || error.name === "NotAllowedError"
button.dispatchEvent(new CustomEvent("passkey:error", { bubbles: true, detail: { error, cancelled } }))
}
}
}
function passkeysAvailable() {
return !!window.PublicKeyCredential
}
// Read WebAuthn creation options from the <meta> tag rendered by
// +passkey_creation_options_meta_tag+. Returns undefined if the tag is missing.
function getCreationOptions() {
return getOptions("passkey-creation-options")
}
// Parse and return the JSON content of a <meta> tag by name.
function getOptions(name) {
const meta = document.querySelector(`meta[name="${name}"]`)
if (meta) {
return JSON.parse(meta.content)
}
}
// POST to the challenge endpoint to get a fresh nonce, preventing replay attacks
// when the page has been open for a while before the user initiates the ceremony.
async function refreshChallenge(options) {
const url = document.querySelector('meta[name="passkey-challenge-url"]')?.content
if (!url) throw new Error("Missing passkey challenge URL")
const token = document.querySelector('meta[name="csrf-token"]')?.content
const response = await fetch(url, {
method: "POST",
credentials: "same-origin",
headers: {
"X-CSRF-Token": token,
"Accept": "application/json"
}
})
if (!response.ok) throw new Error("Failed to refresh challenge")
const { challenge } = await response.json()
options.challenge = challenge
}
// Populate the registration form's hidden fields with the credential response.
// Clones the transports template input for each reported transport.
function fillCreateForm(form, passkey) {
form.querySelector('[data-passkey-field="client_data_json"]').value = passkey.client_data_json
form.querySelector('[data-passkey-field="attestation_object"]').value = passkey.attestation_object
const template = form.querySelector('[data-passkey-field="transports"]')
for (const transport of passkey.transports) {
const input = template.cloneNode()
input.value = transport
template.before(input)
}
template.remove()
}
// Run the WebAuthn authentication ceremony: refresh the challenge, prompt the
// browser to sign with an existing credential, fill the form, and submit.
async function signInWithPasskey(button) {
const form = button.closest("form")
if (form) {
button.disabled = true
button.dispatchEvent(new CustomEvent("passkey:start", { bubbles: true }))
try {
if (!passkeysAvailable()) throw new Error("Passkeys are not supported by this browser")
const requestOptions = getRequestOptions()
if (!requestOptions) throw new Error("Missing passkey request options")
await refreshChallenge(requestOptions)
const passkey = await authenticate(requestOptions)
button.dispatchEvent(new CustomEvent("passkey:success", { bubbles: true }))
fillSignInForm(form, passkey)
form.submit()
} catch (error) {
button.disabled = false
const cancelled = error.name === "AbortError" || error.name === "NotAllowedError"
button.dispatchEvent(new CustomEvent("passkey:error", { bubbles: true, detail: { error, cancelled } }))
}
}
}
// Read WebAuthn request options from the <meta> tag rendered by
// +passkey_request_options_meta_tag+. Returns undefined if the tag is missing.
function getRequestOptions() {
return getOptions("passkey-request-options")
}
// Populate the authentication form's hidden fields with the assertion response.
function fillSignInForm(form, passkey) {
form.querySelector('[data-passkey-field="id"]').value = passkey.id
form.querySelector('[data-passkey-field="client_data_json"]').value = passkey.client_data_json
form.querySelector('[data-passkey-field="authenticator_data"]').value = passkey.authenticator_data
form.querySelector('[data-passkey-field="signature"]').value = passkey.signature
}
// Start the conditional mediation (autofill) ceremony if the page opts in with
// a form[data-passkey-mediation="conditional"] and the browser supports it.
// Unlike the button-driven ceremonies, this runs automatically on page load.
async function attemptConditionalMediation() {
if (await conditionalMediationAvailable()) {
const form = document.querySelector('form[data-passkey-mediation="conditional"]')
form.dispatchEvent(new CustomEvent("passkey:start", { bubbles: true }))
const requestOptions = getRequestOptions()
try {
await refreshChallenge(requestOptions)
const passkey = await authenticate(requestOptions, { mediation: "conditional" })
form.dispatchEvent(new CustomEvent("passkey:success", { bubbles: true }))
fillSignInForm(form, passkey)
form.submit()
} catch (error) {
const cancelled = error.name === "AbortError" || error.name === "NotAllowedError"
form.dispatchEvent(new CustomEvent("passkey:error", { bubbles: true, detail: { error, cancelled } }))
}
}
}
// Check all preconditions for conditional mediation: the page has opted in,
// request options are present, the browser supports passkeys, and the browser
// supports the conditional mediation UI (autofill).
async function conditionalMediationAvailable() {
return isConditionalMediationFormPresent() &&
getRequestOptions() &&
passkeysAvailable() &&
await window.PublicKeyCredential.isConditionalMediationAvailable?.()
}
function isConditionalMediationFormPresent() {
return !!document.querySelector('form[data-passkey-mediation="conditional"]')
}
@@ -0,0 +1,83 @@
// Thin wrapper around the browser WebAuthn API (navigator.credentials).
//
// Handles the base64url ↔ ArrayBuffer conversions required by the spec so
// callers can work with plain JSON objects from the server-rendered meta tags.
// Call navigator.credentials.create() with the given creation options.
// Returns { client_data_json, attestation_object, transports } with all
// binary fields encoded as base64url strings ready for form submission.
export async function register(options) {
const publicKey = prepareCreationOptions(options)
const credential = await navigator.credentials.create({ publicKey })
return {
client_data_json: new TextDecoder().decode(credential.response.clientDataJSON),
attestation_object: bufferToBase64url(credential.response.attestationObject),
transports: credential.response.getTransports?.() || []
}
}
// Call navigator.credentials.get() with the given request options.
// Accepts an optional signal (AbortSignal) and mediation hint ("conditional"
// for autofill UI). Returns { id, client_data_json, authenticator_data, signature }
// with binary fields encoded as base64url strings.
export async function authenticate(options, { signal, mediation } = {}) {
const publicKey = prepareRequestOptions(options)
const credential = await navigator.credentials.get({ publicKey, signal, mediation })
return {
id: credential.id,
client_data_json: new TextDecoder().decode(credential.response.clientDataJSON),
authenticator_data: bufferToBase64url(credential.response.authenticatorData),
signature: bufferToBase64url(credential.response.signature)
}
}
// Convert JSON creation options into the format expected by the browser:
// decode base64url challenge, user.id, and excludeCredentials[].id into ArrayBuffers.
function prepareCreationOptions(options) {
return {
...options,
challenge: base64urlToBuffer(options.challenge),
user: { ...options.user, id: base64urlToBuffer(options.user.id) },
excludeCredentials: (options.excludeCredentials || []).map(cred => ({
...cred,
id: base64urlToBuffer(cred.id)
}))
}
}
// Convert JSON request options into the format expected by the browser:
// decode base64url challenge and allowCredentials[].id into ArrayBuffers.
// Strips allowCredentials entirely when empty so the browser prompts for
// any available credential (required for conditional mediation).
function prepareRequestOptions(options) {
const prepared = {
...options,
challenge: base64urlToBuffer(options.challenge)
}
if (options.allowCredentials?.length) {
prepared.allowCredentials = options.allowCredentials.map(cred => ({
...cred,
id: base64urlToBuffer(cred.id)
}))
} else {
delete prepared.allowCredentials
}
return prepared
}
function base64urlToBuffer(base64url) {
const base64 = base64url.replace(/-/g, "+").replace(/_/g, "/")
const padding = "=".repeat((4 - base64.length % 4) % 4)
const binary = atob(base64 + padding)
return Uint8Array.from(binary, c => c.charCodeAt(0)).buffer
}
function bufferToBase64url(buffer) {
const bytes = new Uint8Array(buffer)
const binary = String.fromCharCode(...bytes)
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "")
}
+2 -1
View File
@@ -1,8 +1,9 @@
class Identity < ApplicationRecord
include Joinable, Transferable
has_passkeys name: :email_address, display_name: -> { Current.user&.name || email_address }
has_many :access_tokens, dependent: :destroy
has_many :credentials, dependent: :destroy
has_many :magic_links, dependent: :destroy
has_many :sessions, dependent: :destroy
has_many :users, dependent: :nullify
-74
View File
@@ -1,74 +0,0 @@
class Identity::Credential < ApplicationRecord
belongs_to :identity
serialize :transports, coder: JSON, type: Array, default: []
class << self
def creation_options(identity:, display_name:)
ActionPack::WebAuthn::PublicKeyCredential::CreationOptions.new(
id: identity.id,
name: identity.email_address,
display_name: display_name,
resident_key: :required,
exclude_credentials: identity.credentials.map(&:to_public_key_credential)
)
end
def request_options(credentials: [])
ActionPack::WebAuthn::PublicKeyCredential::RequestOptions.new(credentials: credentials.map(&:to_public_key_credential))
end
def register(passkey:, challenge:, origin: ActionPack::WebAuthn::Current.origin, **attributes)
public_key_credential = ActionPack::WebAuthn::PublicKeyCredential.create(
client_data_json: passkey[:client_data_json],
attestation_object: Base64.urlsafe_decode64(passkey[:attestation_object]),
challenge: challenge,
origin: origin,
transports: Array(passkey[:transports])
)
create!(
**attributes,
name: attributes.fetch(:name, Authenticator.find_by_aaguid(public_key_credential.aaguid)&.name),
credential_id: public_key_credential.id,
public_key: public_key_credential.public_key.to_der,
sign_count: public_key_credential.sign_count,
aaguid: public_key_credential.aaguid,
backed_up: public_key_credential.backed_up,
transports: public_key_credential.transports
)
end
def authenticate(passkey:, challenge:, origin: ActionPack::WebAuthn::Current.origin)
find_by(credential_id: passkey[:id])&.authenticate(passkey: passkey, challenge: challenge, origin: origin)
end
end
def authenticate(passkey:, challenge:, origin: ActionPack::WebAuthn::Current.origin)
pkc = to_public_key_credential
pkc.authenticate(
client_data_json: passkey[:client_data_json],
authenticator_data: Base64.urlsafe_decode64(passkey[:authenticator_data]),
signature: Base64.urlsafe_decode64(passkey[:signature]),
challenge: challenge,
origin: origin
)
update!(sign_count: pkc.sign_count, backed_up: pkc.backed_up)
self
rescue ActionPack::WebAuthn::Authenticator::Response::InvalidResponseError
nil
end
def authenticator
Authenticator.find_by_aaguid(aaguid)
end
def to_public_key_credential
ActionPack::WebAuthn::PublicKeyCredential.new(
id: credential_id,
public_key: OpenSSL::PKey.read(public_key),
sign_count: sign_count,
transports: transports
)
end
end
@@ -1,12 +0,0 @@
class Identity::Credential::Authenticator < Data.define(:name, :icon)
REGISTRY = Rails.application.config_for(:passkey_aaguids).each_with_object({}) do |(_key, attrs), hash|
authenticator = new(name: attrs[:name], icon: attrs[:icon])
attrs[:aaguids].each { |aaguid| hash[aaguid] = authenticator }
end.freeze
class << self
def find_by_aaguid(aaguid)
REGISTRY[aaguid]
end
end
end
+23
View File
@@ -0,0 +1,23 @@
class Passkey::Authenticator < Data.define(:aaguids, :name, :icon)
class << self
def find_by_aaguid(aaguid)
registry[aaguid]
end
def registry
@registry ||= Hash.new.tap do |registry|
all.each do |authenticator|
authenticator.aaguids.each do |aaguid|
registry[aaguid] = authenticator
end
end
end
end
def all
Rails.application.config_for(:passkey_aaguids).each_value.map do |attrs|
new(aaguids: attrs[:aaguids], name: attrs[:name], icon: attrs[:icon])
end
end
end
end
@@ -11,6 +11,10 @@
<p>This code will work for <%= distance_of_time_in_words(MagicLink::EXPIRATION_TIME) %>.</p>
<% if account = @magic_link.identity.accounts.last %>
<p>P.S. You can make your account more secure and sign-in faster with a <%= link_to "Passkey", my_passkeys_url(script_name: account.slug) %></p>
<% end %>
<p class="footer">Need help? <%= mail_to "support@fizzy.do", "Send us an email"%>.
</p>
@@ -7,3 +7,7 @@ Please enter this 6-character verification code to finish creating your account:
<%= @magic_link.code %>
This code will work for <%= distance_of_time_in_words(MagicLink::EXPIRATION_TIME) %>.
<% if account = @magic_link.identity.accounts.last %>
P.S. If you want to sign-in faster, and make your account more secure, add a passkey: <%= my_passkeys_url(script_name: account.slug) %>
<% end %>
+1 -1
View File
@@ -11,7 +11,7 @@
<section class="panel panel--wide shadow center webhooks">
<% if @access_tokens.any? %>
<p class="margin-none-block-start">Tokens you have generated that can be used to access the Fizzy API.</p>
<table class="access_tokens_table margin-block-end-double max-width txt-small">
<table class="access-tokens margin-block-end-double max-width txt-small">
<thead>
<tr>
<th>Description</th>
+13
View File
@@ -0,0 +1,13 @@
<li class="credential" style="view-transition-name: <%= dom_id(passkey) %>">
<%= link_to edit_my_passkey_path(passkey), class: "credential__link" do %>
<% if icon = passkey.authenticator&.icon %>
<%= image_tag icon[:light], size: 24, class: "flex-item-no-shrink hide-on-dark-mode", aria: { hidden: true } %>
<%= image_tag icon[:dark], size: 24, class: "flex-item-no-shrink hide-on-light-mode", aria: { hidden: true } %>
<% else %>
<%= image_tag "passkeys/generic_light.svg", size: 24, class: "flex-item-no-shrink hide-on-dark-mode", aria: { hidden: true } %>
<%= image_tag "passkeys/generic_dark.svg", size: 24, class: "flex-item-no-shrink hide-on-light-mode", aria: { hidden: true } %>
<% end %>
<strong class="overflow-ellipsis min-width"><%= passkey.name.presence || "Passkey" %></strong>
<strong class="credential__arrow">→</strong>
<% end %>
</li>
@@ -2,7 +2,7 @@
<% content_for :header do %>
<div class="header__actions header__actions--start">
<%= back_link_to "Passkeys", user_credentials_path(Current.user), "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click" %>
<%= back_link_to "Passkeys", my_passkeys_path, "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click" %>
</div>
<h1 class="header__title" data-bridge--title-target="header"><%= @page_title %></h1>
@@ -13,7 +13,7 @@
<p class="margin-none">Your passkey has been registered. Give it a name so you can identify it later.</p>
<% end %>
<%= form_with model: @credential, scope: :credential, url: user_credential_path(Current.user, @credential), html: { class: "flex flex-column gap" } do |form| %>
<%= form_with model: @passkey, scope: :passkey, url: my_passkey_path(@passkey), html: { class: "flex flex-column gap" } do |form| %>
<div class="flex flex-column gap-half">
<strong><%= form.label "Name your passkey" %></strong>
<%= form.text_field :name, autofocus: true, class: "input", placeholder: "e.g. MacBook Pro, iPhone", data: { "1p-ignore": "" }, autocomplete: "off" %>
@@ -23,7 +23,7 @@
<% end %>
<div class="txt-align-center">
<%= button_to user_credential_path(Current.user, @credential), method: :delete,
<%= button_to my_passkey_path(@passkey), method: :delete,
class: "btn txt-negative borderless txt-small",
data: { turbo_confirm: "Are you sure you want to remove this passkey?" } do %>
<%= icon_tag "trash" %>
@@ -1,5 +1,9 @@
<% @page_title = "Passkeys" %>
<% content_for :head do %>
<%= passkey_creation_options_meta_tag(@creation_options) %>
<% end %>
<% content_for :header do %>
<div class="header__actions header__actions--start">
<%= back_link_to "My profile", user_path(Current.user), "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click" %>
@@ -8,34 +12,30 @@
<h1 class="header__title" data-bridge--title-target="header"><%= @page_title %></h1>
<% end %>
<section class="panel panel--wide shadow center"
data-controller="credential"
data-credential-public-key-value="<%= @creation_options.as_json.to_json %>"
data-credential-register-url-value="<%= user_credentials_path(Current.user) %>">
<section class="panel panel--wide shadow center" data-passkey-errors>
<p class="font-weight-medium margin-none-block-start">Passkeys let you sign in securely without a password or email code.</p>
<% if @credentials.any? %>
<% if @passkeys.any? %>
<ul class="margin-none-block-start margin-block-end-double unpad">
<%= render partial: "users/credentials/credential", collection: @credentials %>
<%= render partial: "my/passkeys/passkey", collection: @passkeys %>
</ul>
<% end %>
<footer>
<button type="button" data-action="credential#create" data-credential-target="button" class="btn btn--link center txt-medium">
<%= passkey_creation_button my_passkeys_path, class: "btn btn--link center txt-medium" do %>
<%= icon_tag "add" %>
<span>Register a passkey</span>
</button>
<% end %>
<p class="txt-small txt-subtle txt-balance margin-none-block-end">
Your browser will prompt you to create a passkey using your device's biometrics, PIN, or security key
</p>
<p data-credential-target="error" class="txt-negative" hidden>
<p data-passkey-error="error" class="txt-negative">
Something went wrong while registering your passkey.
</p>
<p data-credential-target="cancelled" class="txt-subtle" hidden>
<p data-passkey-error="cancelled" class="txt-subtle">
Passkey registration was cancelled.
Try again when you are ready.
</p>
+8 -5
View File
@@ -1,15 +1,16 @@
<% @page_title = "Enter your email" %>
<div class="panel panel--centered flex flex-column gap-half" data-controller="passkey"
data-passkey-public-key-value="<%= @request_options.as_json.to_json %>"
data-passkey-url-value="<%= session_passkey_path %>"
data-passkey-csrf-token-value="<%= form_authenticity_token %>">
<% content_for :head do %>
<%= passkey_request_options_meta_tag(@request_options) %>
<% end %>
<div class="panel panel--centered flex flex-column gap-half">
<h1 class="txt-x-large font-weight-black margin-block-end">Get into Fizzy</h1>
<%= form_with url: session_path, class: "flex flex-column gap-half txt-medium" do |form| %>
<div class="flex align-center gap">
<label class="flex align-center gap input input--actor">
<%= form.email_field :email_address, required: true, class: "input txt-large full-width", autofocus: true, autocomplete: "email webauthn", placeholder: "Enter your email address…" %>
<%= form.email_field :email_address, required: true, class: "input txt-large full-width", autofocus: true, autocomplete: "username webauthn", placeholder: "Enter your email address…" %>
</label>
</div>
@@ -25,6 +26,8 @@
</button>
<% end %>
<%= passkey_sign_in_button "Sign in with a passkey", session_passkey_path, mediation: "conditional", class: "btn btn--link center txt-medium", hidden: true %>
</div>
<% content_for :footer do %>
@@ -1,9 +0,0 @@
<li class="credential" style="view-transition-name: <%= dom_id(credential) %>">
<%= link_to edit_user_credential_path(Current.user, credential), class: "credential__link" do %>
<% authenticator = credential.authenticator %>
<%= image_tag authenticator&.icon&.[](:light) || "passkeys/generic_light.svg", size: 24, class: "flex-item-no-shrink hide-on-dark-mode", aria: { hidden: true } %>
<%= image_tag authenticator&.icon&.[](:dark) || "passkeys/generic_dark.svg", size: 24, class: "flex-item-no-shrink hide-on-light-mode", aria: { hidden: true } %>
<strong class="overflow-ellipsis min-width"><%= credential.name.presence || "Passkey" %></strong>
<strong class="credential__arrow">→</strong>
<% end %>
</li>
+1 -1
View File
@@ -41,7 +41,7 @@
<% if Current.user == @user %>
<hr class="separator--horizontal full-width flex-item-grow margin-block-start-double" style="--border-color: var(--color-ink-light);" aria-hidden="true">
<%= link_to user_credentials_path(@user), class: "btn txt-x-small center" do %>
<%= link_to my_passkeys_path, class: "btn txt-x-small center" do %>
<%= icon_tag "authentication" %>
<span>Manage passkeys</span>
<% end %>
+4
View File
@@ -1,6 +1,7 @@
require_relative "boot"
require "rails/all"
require_relative "../lib/fizzy"
require_relative "../lib/action_pack/railtie"
Bundler.require(*Rails.groups)
@@ -25,6 +26,9 @@ module Fizzy
g.orm :active_record, primary_key_type: :uuid
end
config.action_pack.passkey.draw_routes = false
config.action_pack.passkey.challenge_url = -> { my_passkey_challenge_path(script_name: "") }
config.mission_control.jobs.http_basic_auth_enabled = false
end
end
+1
View File
@@ -10,6 +10,7 @@ pin "@rails/request.js", to: "@rails--request.js" # @0.0.13
pin_all_from "app/javascript/controllers", under: "controllers"
pin_all_from "app/javascript/helpers", under: "helpers"
pin_all_from "app/javascript/lib", under: "lib"
pin_all_from "app/javascript/initializers", under: "initializers"
pin_all_from "app/javascript/bridge/initializers", under: "bridge/initializers"
pin_all_from "app/javascript/bridge/helpers", under: "bridge/helpers"
+3
View File
@@ -0,0 +1,3 @@
Rails.application.config.to_prepare do
ActionPack::Passkey.prepend ActionPackPasskeyInferNameFromAaguid
end
+2 -2
View File
@@ -15,8 +15,6 @@ Rails.application.routes.draw do
resource :avatar
resource :role
resource :events
resources :credentials, except: %i[ show new ]
resources :push_subscriptions
resources :email_addresses, param: :token do
@@ -174,8 +172,10 @@ Rails.application.routes.draw do
resource :landing
namespace :my do
resource :passkey_challenge, only: :create
resource :identity, only: :show
resources :access_tokens
resources :passkeys, except: %i[ show new ]
resources :pins
resource :timezone
resource :menu
@@ -0,0 +1,20 @@
class CreateActionPackPasskeys < ActiveRecord::Migration[8.2]
def change
create_table :action_pack_passkeys, id: :uuid do |t|
t.uuid :holder_id, null: false
t.string :holder_type, null: false
t.string :credential_id, null: false
t.binary :public_key, null: false
t.integer :sign_count, null: false, default: 0
t.string :name
t.text :transports
t.string :aaguid
t.boolean :backed_up
t.timestamps
t.index [ :holder_type, :holder_id ]
t.index :credential_id, unique: true
end
end
end
@@ -1,17 +0,0 @@
class CreateIdentityCredentials < ActiveRecord::Migration[8.2]
def change
create_table :identity_credentials, id: :uuid do |t|
t.uuid :identity_id, null: false
t.string :credential_id, null: false
t.binary :public_key, null: false
t.integer :sign_count, null: false, default: 0
t.string :name
t.text :transports
t.timestamps
t.index :identity_id
t.index :credential_id, unique: true
end
end
end
@@ -1,6 +0,0 @@
class AddAaguidAndBackedUpToIdentityCredentials < ActiveRecord::Migration[8.2]
def change
add_column :identity_credentials, :aaguid, :string
add_column :identity_credentials, :backed_up, :boolean
end
end
Generated
+17 -16
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.2].define(version: 2026_02_19_095815) do
ActiveRecord::Schema[8.2].define(version: 2026_02_18_120000) do
create_table "accesses", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.datetime "accessed_at"
t.uuid "account_id", null: false
@@ -69,6 +69,22 @@ ActiveRecord::Schema[8.2].define(version: 2026_02_19_095815) do
t.index ["external_account_id"], name: "index_accounts_on_external_account_id", unique: true
end
create_table "action_pack_passkeys", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.string "aaguid"
t.boolean "backed_up"
t.datetime "created_at", null: false
t.string "credential_id", null: false
t.uuid "holder_id", null: false
t.string "holder_type", null: false
t.string "name"
t.binary "public_key", null: false
t.integer "sign_count", default: 0, null: false
t.text "transports"
t.datetime "updated_at", null: false
t.index ["credential_id"], name: "index_action_pack_passkeys_on_credential_id", unique: true
t.index ["holder_type", "holder_id"], name: "index_action_pack_passkeys_on_holder_type_and_holder_id"
end
create_table "action_text_rich_texts", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.uuid "account_id", null: false
t.text "body", size: :long
@@ -344,21 +360,6 @@ ActiveRecord::Schema[8.2].define(version: 2026_02_19_095815) do
t.index ["identity_id"], name: "index_access_token_on_identity_id"
end
create_table "identity_credentials", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.string "aaguid"
t.boolean "backed_up"
t.datetime "created_at", null: false
t.string "credential_id", null: false
t.uuid "identity_id", null: false
t.string "name"
t.binary "public_key", null: false
t.integer "sign_count", default: 0, null: false
t.text "transports"
t.datetime "updated_at", null: false
t.index ["credential_id"], name: "index_identity_credentials_on_credential_id", unique: true
t.index ["identity_id"], name: "index_identity_credentials_on_identity_id"
end
create_table "magic_links", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.string "code", null: false
t.datetime "created_at", null: false
+16
View File
@@ -69,6 +69,22 @@ ActiveRecord::Schema[8.2].define(version: 2026_02_18_120000) do
t.index ["external_account_id"], name: "index_accounts_on_external_account_id", unique: true
end
create_table "action_pack_passkeys", id: :uuid, force: :cascade do |t|
t.string "aaguid", limit: 255
t.boolean "backed_up"
t.datetime "created_at", null: false
t.string "credential_id", limit: 255, null: false
t.uuid "holder_id", null: false
t.string "holder_type", limit: 255, null: false
t.string "name", limit: 255
t.binary "public_key", null: false
t.integer "sign_count", default: 0, null: false
t.text "transports", limit: 65535
t.datetime "updated_at", null: false
t.index ["credential_id"], name: "index_action_pack_passkeys_on_credential_id", unique: true
t.index ["holder_type", "holder_id"], name: "index_action_pack_passkeys_on_holder_type_and_holder_id"
end
create_table "action_text_rich_texts", id: :uuid, force: :cascade do |t|
t.uuid "account_id", null: false
t.text "body", limit: 4294967295
+104
View File
@@ -0,0 +1,104 @@
# ActionPack::Passkey provides WebAuthn passkey registration and authentication backed by Active Record.
#
# Passkeys are scoped to a polymorphic +holder+ (typically a User or Identity) and store the
# credential ID, public key, sign count, and transport hints needed for the WebAuthn ceremonies.
#
# == Registration
#
# Generate options for the browser's +navigator.credentials.create()+ call, then register the
# response:
#
# options = ActionPack::Passkey.creation_options(holder: current_user)
# # Pass options to the browser
#
# passkey = ActionPack::Passkey.register(params[:passkey], holder: current_user)
#
# == Authentication
#
# Generate options for the browser's +navigator.credentials.get()+ call, then authenticate the
# response:
#
# options = ActionPack::Passkey.request_options
# # Pass options to the browser
#
# passkey = ActionPack::Passkey.authenticate(params[:passkey])
#
# == Holder integration
#
# Call +has_passkeys+ in your model to set up the association and configure ceremony options
# per-holder. See ActionPack::Passkey::Holder for details.
class ActionPack::Passkey < Rails.configuration.action_pack.passkey.parent_class_name.constantize
self.table_name = "action_pack_passkeys"
belongs_to :holder, polymorphic: true
serialize :transports, coder: JSON, type: Array, default: []
class << self
# Returns a CreationOptions object for the given +holder+, suitable for passing to the
# browser's +navigator.credentials.create()+ call. Merges global defaults from the Rails
# configuration, holder-specific options from +holder.passkey_creation_options+, and any
# additional +options+ overrides.
def creation_options(holder:, **options)
ActionPack::WebAuthn::PublicKeyCredential.creation_options(
**Rails.configuration.action_pack.web_authn.default_creation_options.to_h,
**holder.passkey_creation_options.to_h,
**options
)
end
# Verifies the attestation response from the browser and persists a new passkey record.
# The +passkey+ hash should contain +client_data_json+, +attestation_object+, and +transports+
# as submitted by the registration form. The +challenge+ defaults to
# +ActionPack::WebAuthn::Current.challenge+, which is automatically populated from the session
# by ActionPack::Passkey::Request. Any additional +attributes+ (e.g. +holder+) are passed
# through to +create!+.
#
# Raises ActionPack::WebAuthn::InvalidResponseError if the attestation is invalid.
def register(passkey, challenge: ActionPack::WebAuthn::Current.challenge, **attributes)
credential = ActionPack::WebAuthn::PublicKeyCredential.register(passkey, challenge: challenge)
create!(**credential.to_h, **attributes)
end
# Returns a RequestOptions object suitable for passing to the browser's
# +navigator.credentials.get()+ call. When a +holder+ is provided, their existing credentials
# are included so the browser can offer them for selection. Merges global defaults, holder
# options, and any additional +options+ overrides.
def request_options(holder: nil, **options)
ActionPack::WebAuthn::PublicKeyCredential.request_options(
**Rails.configuration.action_pack.web_authn.default_request_options.to_h,
**holder&.passkey_request_options.to_h,
**options
)
end
# Looks up a passkey by credential ID and verifies the assertion response from the browser.
# Returns the authenticated Passkey record, or +nil+ if the credential is not found or
# verification fails.
def authenticate(passkey, challenge: ActionPack::WebAuthn::Current.challenge)
find_by(credential_id: passkey[:id])&.authenticate(passkey, challenge: challenge)
end
end
# Verifies the assertion response against this passkey's stored credential and updates the
# +sign_count+ and +backed_up+ attributes. Returns +self+ on success, or +nil+ if the
# response is invalid.
def authenticate(passkey, challenge: ActionPack::WebAuthn::Current.challenge)
credential = to_public_key_credential
credential.authenticate(passkey, challenge: challenge)
update!(sign_count: credential.sign_count, backed_up: credential.backed_up)
self
rescue ActionPack::WebAuthn::InvalidResponseError
nil
end
# Returns an ActionPack::WebAuthn::PublicKeyCredential initialized from this record's stored
# credential data.
def to_public_key_credential
ActionPack::WebAuthn::PublicKeyCredential.new(
id: credential_id,
public_key: public_key,
sign_count: sign_count,
transports: transports
)
end
end
@@ -0,0 +1,37 @@
# = Action Pack Passkey Challenges Controller
#
# Generates fresh WebAuthn challenges for passkey ceremonies. The companion
# JavaScript calls this endpoint before initiating a registration or
# authentication ceremony so that the challenge is issued just-in-time rather
# than embedded in the initial page load.
#
# The generated challenge is stored in an encrypted, HTTP-only, same-site
# cookie and simultaneously returned in the JSON response body. The cookie is
# consumed by ActionPack::Passkey::Request on the subsequent form submission.
#
# == Route
#
# By default mounted at +/rails/action_pack/passkey/challenge+ (configurable
# via +config.action_pack.passkey.routes_prefix+).
#
class ActionPack::Passkey::ChallengesController < ActionController::Base
COOKIE_NAME = :action_pack_passkey_challenge
include ActionPack::Passkey::Request
# Generates a fresh challenge, stores it in an encrypted cookie, and returns
# it as JSON. The cookie is consumed on the next passkey form submission.
def create
challenge = create_passkey_challenge
cookies.encrypted[COOKIE_NAME] = { value: challenge, httponly: true, same_site: :strict, secure: !request.local? && request.ssl? }
render json: { challenge: challenge }
end
private
def create_passkey_challenge
ActionPack::WebAuthn::PublicKeyCredential::Options.new(
challenge_expiration: Rails.configuration.action_pack.web_authn.request_challenge_expiration
).challenge
end
end
+87
View File
@@ -0,0 +1,87 @@
# View helpers for rendering passkey forms and meta tags.
#
# Include this module in your helper or ApplicationHelper to get access to:
#
# - +passkey_creation_options_meta_tag+ / +passkey_request_options_meta_tag+ — render a <meta>
# tag containing the JSON-serialized WebAuthn options for the browser credential API.
# - +passkey_creation_button+ — render a form with hidden fields for the registration ceremony.
# - +passkey_sign_in_button+ — render a form with hidden fields for the authentication
# ceremony.
module ActionPack::Passkey::FormHelper
# Renders +<meta>+ tags containing JSON-serialized creation options and the challenge endpoint
# URL for the WebAuthn registration ceremony. The companion JavaScript reads these tags to call
# +navigator.credentials.create()+.
def passkey_creation_options_meta_tag(creation_options, challenge_url: nil)
passkey_challenge_url_meta_tag(challenge_url: challenge_url) +
tag.meta(name: "passkey-creation-options", content: creation_options.to_json)
end
# Renders +<meta>+ tags containing JSON-serialized request options and the challenge endpoint
# URL for the WebAuthn authentication ceremony. The companion JavaScript reads these tags to
# call +navigator.credentials.get()+.
def passkey_request_options_meta_tag(request_options, challenge_url: nil)
passkey_challenge_url_meta_tag(challenge_url: challenge_url) +
tag.meta(name: "passkey-request-options", content: request_options.to_json)
end
# Renders a form with hidden fields for the passkey registration ceremony. The form POSTs to
# +url+ and includes hidden fields for +client_data_json+, +attestation_object+, and
# +transports+ — populated by the Stimulus controller after the browser credential API
# resolves. Accepts a +label+ string or a block for button content.
#
# Options:
# - +param+: the form parameter namespace (default: +:passkey+)
# - +form+: additional HTML attributes for the +<form>+ tag
# - All other options are passed to the +<button>+ tag
def passkey_creation_button(name = nil, url = nil, param: :passkey, form: {}, **options, &block)
url, name = name, block ? capture(&block) : nil if block_given?
form_options = form.reverse_merge(method: :post, action: url, class: "button_to")
tag.form(**form_options) do
hidden_field_tag(:authenticity_token, form_authenticity_token) +
hidden_field_tag("#{param}[client_data_json]", nil, id: nil, data: { passkey_field: "client_data_json" }) +
hidden_field_tag("#{param}[attestation_object]", nil, id: nil, data: { passkey_field: "attestation_object" }) +
hidden_field_tag("#{param}[transports][]", nil, id: nil, data: { passkey_field: "transports" }) +
tag.button(name, type: :button, data: { passkey: "create" }, **options)
end
end
# Renders a form with hidden fields for the passkey authentication ceremony. The form POSTs to
# +url+ and includes hidden fields for +id+, +client_data_json+, +authenticator_data+, and
# +signature+
# Accepts a +label+ string or a block for button content.
#
# Options:
# - +param+: the form parameter namespace (default: +:passkey+)
# - +mediation+: WebAuthn mediation hint (e.g. +"conditional"+ for autofill-assisted sign in)
# - +form+: additional HTML attributes for the +<form>+ tag
# - All other options are passed to the +<button>+ tag
def passkey_sign_in_button(name = nil, url = nil, param: :passkey, mediation: nil, form: {}, **options, &block)
url, name = name, block ? capture(&block) : nil if block_given?
form_data = {}
form_data[:passkey_mediation] = mediation if mediation
form_options = form.reverse_merge(method: :post, action: url, class: "button_to", data: form_data)
tag.form(**form_options) do
hidden_field_tag(:authenticity_token, form_authenticity_token) +
hidden_field_tag("#{param}[id]", nil, id: nil, data: { passkey_field: "id" }) +
hidden_field_tag("#{param}[client_data_json]", nil, id: nil, data: { passkey_field: "client_data_json" }) +
hidden_field_tag("#{param}[authenticator_data]", nil, id: nil, data: { passkey_field: "authenticator_data" }) +
hidden_field_tag("#{param}[signature]", nil, id: nil, data: { passkey_field: "signature" }) +
tag.button(name, type: :button, data: { passkey: "sign_in" }, **options)
end
end
private
def passkey_challenge_url_meta_tag(challenge_url: nil)
tag.meta(name: "passkey-challenge-url", content: challenge_url || default_passkey_challenge_url)
end
def default_passkey_challenge_url
if challenge_url = Rails.configuration.action_pack.passkey.challenge_url
instance_exec(&challenge_url)
else
passkey_challenge_path
end
end
end
+143
View File
@@ -0,0 +1,143 @@
# Adds passkey support to an Active Record model (the "holder" of passkeys).
#
# == Usage
#
# class User < ApplicationRecord
# has_passkeys name: :email_address, display_name: :name
# end
#
# This sets up a polymorphic +has_many :passkeys+ association and defines two methods on the
# model that supply holder-specific options for the WebAuthn ceremonies:
#
# - +passkey_creation_options+ — merged into ActionPack::Passkey.creation_options
# - +passkey_request_options+ — merged into ActionPack::Passkey.request_options
#
# == Options
#
# +has_passkeys+ accepts keyword arguments that map to WebAuthn creation or request option
# fields. Values can be symbols (sent to the record), procs (evaluated in the record's context),
# or plain values:
#
# [+name+]
# A human-readable account identifier (typically an email or username) shown by the
# authenticator when the user selects a passkey. Maps to the WebAuthn +user.name+ field.
#
# [+display_name+]
# A friendly label for the user (typically their full name) shown by the authenticator
# during passkey registration. Maps to the WebAuthn +user.displayName+ field.
#
# has_passkeys name: :email, display_name: :name
#
# For more complex configuration, pass a block that receives a ActionPack::Passkey::Holder::Config:
#
# has_passkeys do |config|
# config.creation_options { { name: email, display_name: name } }
# config.request_options { { user_verification: "required" } }
# end
module ActionPack::Passkey::Holder
extend ActiveSupport::Concern
class_methods do
# Declares that this model can hold passkeys. Sets up a polymorphic +has_many+ association
# and defines +passkey_creation_options+ and +passkey_request_options+ instance methods used
# by ActionPack::Passkey to build ceremony options.
#
# Keyword arguments matching CreationOptions or RequestOptions fields are extracted and
# turned into holder-scoped option procs automatically. An optional block yields a Config
# for more complex setup.
def has_passkeys(**options, &block)
config = Config.new(**options)
block&.call(config)
has_many config.association_name,
as: :holder,
dependent: config.dependent,
class_name: "ActionPack::Passkey"
define_method(:passkey_creation_options) do
{
id: id,
exclude_credentials: public_send(config.association_name)
}.merge(config.evaluate_creation_options(self))
end
define_method(:passkey_request_options) do
{ credentials: public_send(config.association_name) }.merge(config.evaluate_request_options(self))
end
end
end
# Configuration object yielded by +has_passkeys+ when a block is given. Allows setting
# custom association options and ceremony option blocks.
class Config
attr_accessor :association_name, :dependent
def initialize(**options)
@association_name = options.delete(:association_name) || :passkeys
@dependent = options.delete(:dependent) || :destroy
if creation_opts = extract_options_for(ActionPack::WebAuthn::PublicKeyCredential::CreationOptions, options)
@creation_options = options_to_proc(creation_opts)
end
if request_opts = extract_options_for(ActionPack::WebAuthn::PublicKeyCredential::RequestOptions, options)
@request_options = options_to_proc(request_opts)
end
end
# Sets a block to evaluate in the holder's context to produce additional request options.
#
# config.request_options { { user_verification: "required" } }
def request_options(&block)
@request_options = block
end
# Sets a block to evaluate in the holder's context to produce additional creation options.
#
# config.creation_options { { name: email, display_name: name } }
def creation_options(&block)
@creation_options = block
end
# Evaluates the request options block (if any) in the context of the given +record+. Called
# internally by the +passkey_request_options+ method defined on the holder.
def evaluate_request_options(record)
if @request_options
record.instance_exec(&@request_options)
else
{}
end
end
# Evaluates the creation options block (if any) in the context of the given +record+. Called
# internally by the +passkey_creation_options+ method defined on the holder.
def evaluate_creation_options(record)
if @creation_options
record.instance_exec(&@creation_options)
else
{}
end
end
private
def extract_options_for(klass, options)
keys = klass.attribute_names.map(&:to_sym)
extracted = options.slice(*keys)
options.except!(*keys)
extracted if extracted.any?
end
def options_to_proc(options)
proc do
options.transform_values do |value|
case value
when Symbol then send(value)
when Proc then instance_exec(&value)
else value
end
end
end
end
end
end
+81
View File
@@ -0,0 +1,81 @@
# = Action Pack Passkey Request
#
# Controller concern that sets up the WebAuthn request context and provides
# helper methods for passkey registration and authentication. Include this
# in any controller that handles passkey form submissions.
#
# == Registration example
#
# class PasskeysController < ApplicationController
# include ActionPack::Passkey::Request
#
# def new
# @creation_options = passkey_creation_options(holder: Current.user)
# end
#
# def create
# @passkey = ActionPack::Passkey.register(
# passkey_creation_params, holder: Current.user
# )
# redirect_to settings_path
# end
# end
#
# == Authentication example
#
# class SessionsController < ApplicationController
# include ActionPack::Passkey::Request
#
# def new
# @request_options = passkey_request_options
# end
#
# def create
# if passkey = ActionPack::Passkey.authenticate(passkey_request_params)
# sign_in passkey.holder
# redirect_to root_path
# else
# redirect_to new_session_path, alert: "Authentication failed"
# end
# end
# end
#
# == Before Action
#
# Automatically populates +ActionPack::WebAuthn::Current+ with the request
# host, origin, and challenge (read from the encrypted cookie set by
# ChallengesController). The cookie is deleted after being read to prevent
# replay.
#
module ActionPack::Passkey::Request
extend ActiveSupport::Concern
included do
before_action do
ActionPack::WebAuthn::Current.host = request.host
ActionPack::WebAuthn::Current.origin = request.base_url
ActionPack::WebAuthn::Current.challenge = cookies.encrypted[ActionPack::Passkey::ChallengesController::COOKIE_NAME]
cookies.delete(ActionPack::Passkey::ChallengesController::COOKIE_NAME)
end
end
# Returns strong parameters for the passkey registration ceremony.
def passkey_creation_params(param: :passkey)
params.expect(param => [ :client_data_json, :attestation_object, transports: [] ])
end
# Returns strong parameters for the passkey authentication ceremony.
def passkey_request_params(param: :passkey)
params.expect(param => [ :id, :client_data_json, :authenticator_data, :signature ])
end
# Returns RequestOptions for the authentication ceremony.
def passkey_request_options(**options)
ActionPack::Passkey.request_options(**options)
end
# Returns CreationOptions for the registration ceremony.
def passkey_creation_options(**options)
ActionPack::Passkey.creation_options(**options)
end
end
+70
View File
@@ -0,0 +1,70 @@
require_relative "web_authn"
# = Action Pack Railtie
#
# Integrates the WebAuthn and Passkey subsystems into the Rails application.
# Configures default options for WebAuthn ceremonies and sets up the passkey
# challenge endpoint route.
#
# == Configuration
#
# # config/application.rb or config/initializers/passkeys.rb
# config.action_pack.web_authn.default_creation_options = { attestation: :none }
# config.action_pack.web_authn.default_request_options = { user_verification: :required }
# config.action_pack.web_authn.creation_challenge_expiration = 10.minutes
# config.action_pack.web_authn.request_challenge_expiration = 5.minutes
#
# config.action_pack.passkey.routes_prefix = "/rails/action_pack/passkey"
# config.action_pack.passkey.draw_routes = true
#
class ActionPack::Railtie < Rails::Railtie
config.action_pack = ActiveSupport::OrderedOptions.new unless config.respond_to?(:action_pack)
config.action_pack.web_authn = ActiveSupport::OrderedOptions.new
config.action_pack.web_authn.default_request_options = {}
config.action_pack.web_authn.default_creation_options = {}
config.action_pack.web_authn.creation_challenge_expiration = 10.minutes
config.action_pack.web_authn.request_challenge_expiration = 5.minutes
config.action_pack.passkey = ActiveSupport::OrderedOptions.new
config.action_pack.passkey.parent_class_name = "ApplicationRecord"
config.action_pack.passkey.routes_prefix = "/rails/action_pack/passkey"
config.action_pack.passkey.draw_routes = true
config.action_pack.passkey.challenge_url = nil
initializer "action_pack.passkey.routes" do |app|
passkey_config = config.action_pack.passkey
app.routes.prepend do
if passkey_config.draw_routes
scope passkey_config.routes_prefix, as: :passkey do
post "/challenge" => "action_pack/passkey/challenges#create", as: :challenge
end
end
end
end
initializer "action_pack.passkey.holder" do
ActiveSupport.on_load(:active_record) do
# We need this shim because Holder is namespaced under Passkey, which is an ActiveRecort
# and can't be required before ActiveRecord is loaded.
def self.has_passkeys(**options, &block)
include ActionPack::Passkey::Holder
has_passkeys(**options, &block)
end
end
end
initializer "action_pack.passkey.form_helper" do
ActiveSupport.on_load(:action_view) do
require_relative "passkey/form_helper"
include ActionPack::Passkey::FormHelper
end
end
initializer "action_pack.passkey.request" do
ActiveSupport.on_load(:action_controller) do
require_relative "passkey/request"
end
end
end
+47
View File
@@ -1,15 +1,62 @@
# = Action Pack WebAuthn
#
# Provides a pure-Ruby implementation of the WebAuthn (Web Authentication)
# specification for passkey registration and authentication. This module
# is the top-level namespace for all WebAuthn components and provides
# shared utilities used across ceremonies.
#
# == Components
#
# [ActionPack::WebAuthn::RelyingParty]
# Identifies your application to authenticators.
#
# [ActionPack::WebAuthn::PublicKeyCredential]
# Orchestrates registration and authentication ceremonies.
#
# [ActionPack::WebAuthn::Authenticator]
# Parses and validates authenticator responses.
#
# [ActionPack::WebAuthn::CborDecoder]
# Decodes CBOR-encoded data from authenticators.
#
# [ActionPack::WebAuthn::CoseKey]
# Parses COSE public keys into OpenSSL key objects.
#
# == Extending Attestation Formats
#
# By default only the "none" attestation format is supported. Register
# additional verifiers with:
#
# ActionPack::WebAuthn.register_attestation_verifier("packed", MyPackedVerifier.new)
#
module ActionPack::WebAuthn
class InvalidResponseError < StandardError; end
class InvalidCborError < StandardError; end
class InvalidKeyError < StandardError; end
class UnsupportedKeyTypeError < StandardError; end
class InvalidOptionsError < StandardError; end
class << self
# Returns a new RelyingParty configured from the current request context.
def relying_party
RelyingParty.new
end
# Returns the MessageVerifier used to sign and verify WebAuthn challenges.
def challenge_verifier
Rails.application.message_verifier("action_pack.webauthn.challenge")
end
# Returns the registry of attestation format verifiers, keyed by format
# string (e.g., "none", "packed"). Only "none" is registered by default.
def attestation_verifiers
@attestation_verifiers ||= {
"none" => Authenticator::AttestationVerifiers::None.new
}
end
# Registers a custom attestation verifier for the given +format+.
# The +verifier+ must respond to +verify!(attestation, client_data_json:)+.
def register_attestation_verifier(format, verifier)
attestation_verifiers[format.to_s] = verifier
end
@@ -9,21 +9,20 @@
#
# # Look up the credential by ID
# credential = user.credentials.find_by!(
# credentail_id: params[:id]
# credential_id: params[:id]
# )
#
# response = ActionPack::WebAuthn::Authenticator::AssertionResponse.new(
# client_data_json: params[:response][:clientDataJSON],
# authenticator_data: params[:response][:authenticatorData],
# signature: params[:response][:signature],
# credential: credential.to_public_key_credential
# )
#
# response.validate!(
# challenge: session[:authentication_challenge],
# credential: credential.to_public_key_credential,
# challenge: ActionPack::WebAuthn::Current.challenge,
# origin: "https://example.com"
# )
#
# response.validate!
#
# == Validation
#
# In addition to the base Response validations, this class verifies:
@@ -34,37 +33,43 @@
class ActionPack::WebAuthn::Authenticator::AssertionResponse < ActionPack::WebAuthn::Authenticator::Response
attr_reader :credential, :authenticator_data, :signature
validate :client_data_type_must_be_get
validate :signature_must_be_valid
validate :sign_count_must_increase
def initialize(credential:, authenticator_data:, signature:, **attributes)
super(**attributes)
@credential = credential
@signature = signature
@signature = Base64.urlsafe_decode64(@signature) unless @signature.encoding == Encoding::BINARY
@authenticator_data = ActionPack::WebAuthn::Authenticator::Data.wrap(authenticator_data)
end
def validate!(**args)
super(**args)
unless client_data["type"] == "webauthn.get"
raise InvalidResponseError, "Client data type is not webauthn.get"
end
unless valid_signature?
raise InvalidResponseError, "Invalid signature"
end
unless sign_count_increased?
raise InvalidResponseError, "Sign count did not increase"
end
rescue ArgumentError
raise ActionPack::WebAuthn::InvalidResponseError, "Invalid base64 encoding in signature"
end
private
def valid_signature?
def client_data_type_must_be_get
unless client_data["type"] == "webauthn.get"
errors.add(:base, "Client data type is not webauthn.get")
end
end
def signature_must_be_valid
client_data_hash = Digest::SHA256.digest(client_data_json)
signed_data = authenticator_data.bytes.pack("C*") + client_data_hash
digest = credential.public_key.oid == "ED25519" ? nil : "SHA256"
credential.public_key.verify("SHA256", signature, signed_data)
unless credential.public_key.verify(digest, signature, signed_data)
errors.add(:base, "Invalid signature")
end
rescue OpenSSL::PKey::PKeyError
false
errors.add(:base, "Invalid signature")
end
def sign_count_must_increase
unless sign_count_increased?
errors.add(:base, "Sign count did not increase")
end
end
def sign_count_increased?
@@ -40,6 +40,21 @@ class ActionPack::WebAuthn::Authenticator::Attestation
delegate :credential_id, :public_key, :public_key_bytes, :sign_count, :aaguid, :backed_up?, to: :authenticator_data
# Wraps raw attestation data into an Attestation instance. Accepts an
# existing Attestation object (returned as-is), a Base64URL-encoded string,
# or raw binary.
def self.wrap(data)
if data.is_a?(self)
data
else
data = Base64.urlsafe_decode64(data) unless data.encoding == Encoding::BINARY
decode(data)
end
rescue ArgumentError
raise ActionPack::WebAuthn::InvalidResponseError, "Invalid base64 encoding in attestation object"
end
# Decodes a CBOR-encoded attestation object into an Attestation instance.
def self.decode(bytes)
cbor = ActionPack::WebAuthn::CborDecoder.decode(bytes)
@@ -8,14 +8,13 @@
#
# response = ActionPack::WebAuthn::Authenticator::AttestationResponse.new(
# client_data_json: params[:response][:clientDataJSON],
# attestation_object: params[:response][:attestationObject]
# )
#
# response.validate!(
# challenge: session[:registration_challenge],
# attestation_object: params[:response][:attestationObject],
# challenge: ActionPack::WebAuthn::Current.challenge,
# origin: "https://example.com"
# )
#
# response.validate!
#
# # Store the credential
# credential_id = response.attestation.credential_id
# public_key = response.attestation.public_key
@@ -31,32 +30,39 @@
class ActionPack::WebAuthn::Authenticator::AttestationResponse < ActionPack::WebAuthn::Authenticator::Response
attr_reader :attestation_object
validate :client_data_type_must_be_create
validate :attestation_must_be_valid
def initialize(attestation_object:, **attributes)
super(**attributes)
@attestation_object = attestation_object
end
def validate!(**args)
super(**args)
unless client_data["type"] == "webauthn.create"
raise InvalidResponseError, "Client data type is not webauthn.create"
end
verifier = ActionPack::WebAuthn.attestation_verifiers[attestation.format]
unless verifier
raise InvalidResponseError, "Unsupported attestation format: #{attestation.format}"
end
verifier.verify!(attestation, client_data_json: client_data_json)
end
# Returns the decoded Attestation object, lazily parsed from the raw
# attestation object bytes.
def attestation
@attestation ||= ActionPack::WebAuthn::Authenticator::Attestation.decode(attestation_object)
@attestation ||= ActionPack::WebAuthn::Authenticator::Attestation.wrap(attestation_object)
end
# Returns the authenticator data extracted from the attestation object.
def authenticator_data
attestation.authenticator_data
end
private
def client_data_type_must_be_create
unless client_data["type"] == "webauthn.create"
errors.add(:base, "Client data type is not webauthn.create")
end
end
def attestation_must_be_valid
verifier = ActionPack::WebAuthn.attestation_verifiers[attestation.format]
if verifier
verifier.verify!(attestation, client_data_json: client_data_json)
else
errors.add(:base, "Unsupported attestation format: #{attestation.format}")
end
end
end
@@ -17,7 +17,7 @@
class ActionPack::WebAuthn::Authenticator::AttestationVerifiers::None
def verify!(attestation, client_data_json:)
if attestation.attestation_statement.present?
raise ActionPack::WebAuthn::Authenticator::Response::InvalidResponseError,
raise ActionPack::WebAuthn::InvalidResponseError,
"Attestation statement must be empty for 'none' format"
end
end
@@ -60,16 +60,29 @@ class ActionPack::WebAuthn::Authenticator::Data
attr_reader :bytes, :relying_party_id_hash, :flags, :sign_count, :aaguid, :credential_id, :public_key_bytes
class << self
# Wraps raw authenticator data into a Data instance. Accepts an existing
# Data object (returned as-is), a Base64URL-encoded string, or raw binary.
def wrap(data)
if data.is_a?(self)
data
else
data = Base64.urlsafe_decode64(data) unless data.encoding == Encoding::BINARY
decode(data)
end
rescue ArgumentError
raise ActionPack::WebAuthn::InvalidResponseError, "Invalid base64 encoding in authenticator data"
end
# Decodes raw authenticator data bytes into a Data instance, parsing the
# RP ID hash, flags, sign count, and (if present) attested credential data.
def decode(bytes)
bytes = bytes.bytes if bytes.is_a?(String)
minimum_length = RELYING_PARTY_ID_HASH_LENGTH + FLAGS_LENGTH + SIGN_COUNT_LENGTH
if bytes.length < minimum_length
raise ActionPack::WebAuthn::InvalidResponseError, "Authenticator data is too short"
end
position = 0
relying_party_id_hash = bytes[position, RELYING_PARTY_ID_HASH_LENGTH].pack("C*")
@@ -86,6 +99,10 @@ class ActionPack::WebAuthn::Authenticator::Data
public_key_bytes = nil
if flags & ATTESTED_CREDENTIAL_DATA_FLAG != 0
if bytes.length < position + AAGUID_LENGTH + CREDENTIAL_ID_LENGTH_BYTES
raise ActionPack::WebAuthn::InvalidResponseError, "Authenticator data is too short for attested credential data"
end
aaguid_bytes = bytes[position, AAGUID_LENGTH].pack("C*")
aaguid = aaguid_bytes.unpack("H8H4H4H4H12").join("-")
position += AAGUID_LENGTH
@@ -93,6 +110,10 @@ class ActionPack::WebAuthn::Authenticator::Data
credential_id_length = bytes[position, CREDENTIAL_ID_LENGTH_BYTES].pack("C*").unpack1("n")
position += CREDENTIAL_ID_LENGTH_BYTES
if bytes.length < position + credential_id_length + 1
raise ActionPack::WebAuthn::InvalidResponseError, "Authenticator data is too short for credential ID and public key"
end
credential_id = Base64.urlsafe_encode64(bytes[position, credential_id_length].pack("C*"), padding: false)
position += credential_id_length
@@ -121,10 +142,13 @@ class ActionPack::WebAuthn::Authenticator::Data
@public_key_bytes = public_key_bytes
end
# Returns true if the user performed a test of presence (e.g., touched the
# authenticator).
def user_present?
flags & USER_PRESENT_FLAG != 0
end
# Returns true if the user was verified via biometrics, PIN, or similar.
def user_verified?
flags & USER_VERIFIED_FLAG != 0
end
@@ -141,6 +165,9 @@ class ActionPack::WebAuthn::Authenticator::Data
flags & BACKUP_STATE_FLAG != 0
end
# Decodes the COSE public key bytes into an OpenSSL key object.
# Returns +nil+ when no attested credential data is present (authentication
# responses).
def public_key
@public_key ||= ActionPack::WebAuthn::CoseKey.decode(public_key_bytes).to_openssl_key if public_key_bytes
end
@@ -18,65 +18,57 @@
#
# == Example
#
# response.validate!(
# challenge: session[:webauthn_challenge],
# response = ActionPack::WebAuthn::Authenticator::AssertionResponse.new(
# client_data_json: client_data_json,
# authenticator_data: authenticator_data,
# signature: signature,
# credential: credential,
# challenge: ActionPack::WebAuthn::Current.challenge,
# origin: "https://example.com",
# user_verification: :required
# )
#
# response.validate!
#
class ActionPack::WebAuthn::Authenticator::Response
# Raised when response validation fails.
class InvalidResponseError < StandardError; end
include ActiveModel::Validations
attr_reader :client_data_json
attr_accessor :challenge, :origin, :user_verification
def initialize(client_data_json:)
validate :challenge_must_match
validate :challenge_must_not_be_expired
validate :origin_must_match
validate :must_not_be_cross_origin
validate :must_not_have_token_binding
validate :relying_party_id_must_match
validate :user_must_be_present
validate :user_must_be_verified_when_required
def initialize(client_data_json:, challenge: nil, origin: nil, user_verification: :preferred)
@client_data_json = client_data_json
@challenge = challenge
@origin = origin
@user_verification = user_verification.to_sym
end
def valid?(**args)
validate!(**args)
true
rescue InvalidResponseError
false
end
def validate!(challenge:, origin:, user_verification: :preferred)
unless challenge_matches?(challenge)
raise InvalidResponseError, "Challenge does not match"
end
unless origin_matches?(origin)
raise InvalidResponseError, "Origin does not match"
end
if cross_origin?
raise InvalidResponseError, "Cross-origin requests are not supported"
end
if token_binding_present?
raise InvalidResponseError, "Token binding is not supported"
end
unless relying_party_id_matches?
raise InvalidResponseError, "Relying party ID does not match"
end
unless user_present?
raise InvalidResponseError, "User presence is required"
end
if user_verification == :required && !user_verified?
raise InvalidResponseError, "User verification is required"
end
def validate!
super
rescue ActiveModel::ValidationError
raise ActionPack::WebAuthn::InvalidResponseError, errors.full_messages.join(", ")
end
# Returns the RelyingParty used for RP ID validation.
def relying_party
ActionPack::WebAuthn.relying_party
end
# Parses the client data JSON string into a Hash. Raises
# +InvalidResponseError+ if the JSON is malformed.
def client_data
@client_data ||= JSON.parse(client_data_json)
rescue JSON::ParserError
raise ActionPack::WebAuthn::InvalidResponseError, "Client data is not valid JSON"
end
def authenticator_data
@@ -84,34 +76,68 @@ class ActionPack::WebAuthn::Authenticator::Response
end
private
def challenge_matches?(expected_challenge)
ActiveSupport::SecurityUtils.secure_compare(expected_challenge, client_data["challenge"])
def challenge_must_match
if challenge.blank?
errors.add(:base, "Challenge missing")
elsif client_data["challenge"].blank?
errors.add(:base, "Challenge missing in client data")
elsif !ActiveSupport::SecurityUtils.secure_compare(challenge.to_s, client_data["challenge"].to_s)
errors.add(:base, "Challenge does not match")
end
end
def origin_matches?(expected_origin)
ActiveSupport::SecurityUtils.secure_compare(expected_origin, client_data["origin"])
def challenge_must_not_be_expired
return if errors.any? || challenge.blank?
signed_message = Base64.urlsafe_decode64(challenge)
unless ActionPack::WebAuthn.challenge_verifier.verified(signed_message)
errors.add(:base, "Challenge has expired")
end
rescue ArgumentError
errors.add(:base, "Challenge is invalid")
end
def relying_party_id_matches?
ActiveSupport::SecurityUtils.secure_compare(
def origin_must_match
if origin.blank?
errors.add(:base, "Origin missing")
elsif client_data["origin"].blank?
errors.add(:base, "Origin missing in client data")
elsif !ActiveSupport::SecurityUtils.secure_compare(origin.to_s, client_data["origin"].to_s)
errors.add(:base, "Origin does not match")
end
end
def must_not_be_cross_origin
if client_data["crossOrigin"] == true
errors.add(:base, "Cross-origin requests are not supported")
end
end
def must_not_have_token_binding
if client_data.dig("tokenBinding", "status") == "present"
errors.add(:base, "Token binding is not supported")
end
end
def relying_party_id_must_match
unless ActiveSupport::SecurityUtils.secure_compare(
Digest::SHA256.digest(relying_party.id),
authenticator_data&.relying_party_id_hash || ""
)
errors.add(:base, "Relying party ID does not match")
end
end
def cross_origin?
client_data["crossOrigin"] == true
def user_must_be_present
unless authenticator_data&.user_present?
errors.add(:base, "User presence is required")
end
end
def token_binding_present?
client_data.dig("tokenBinding", "status") == "present"
end
def user_present?
authenticator_data&.user_present?
end
def user_verified?
authenticator_data&.user_verified?
def user_must_be_verified_when_required
if user_verification == :required && !authenticator_data&.user_verified?
errors.add(:base, "User verification is required")
end
end
end
+21 -14
View File
@@ -49,11 +49,8 @@
#
# == Errors
#
# Raises +DecodeError+ when encountering malformed or unsupported CBOR data.
# Raises +InvalidCborError+ when encountering malformed or unsupported CBOR data.
class ActionPack::WebAuthn::CborDecoder
# Raised when the decoder encounters invalid or unsupported CBOR data.
class DecodeError < StandardError; end
# Major types
UNSIGNED_INTEGER_TYPE = 0
NEGATIVE_INTEGER_TYPE = 1
@@ -86,6 +83,10 @@ class ActionPack::WebAuthn::CborDecoder
MAX_DEPTH = 16
MAX_SIZE = 10.megabytes
# Tags
POSITIVE_BIGNUM_TAG = 2
NEGATIVE_BIGNUM_TAG = 3
class << self
# Decodes a CBOR-encoded byte sequence into a Ruby object.
#
@@ -98,7 +99,7 @@ class ActionPack::WebAuthn::CborDecoder
end
def initialize(bytes, max_depth: MAX_DEPTH, max_size: MAX_SIZE) # :nodoc:
raise DecodeError, "Input exceeds maximum size" if bytes.length > max_size
raise ActionPack::WebAuthn::InvalidCborError, "Input exceeds maximum size" if bytes.length > max_size
@bytes = bytes
@max_depth = max_depth
@@ -108,8 +109,8 @@ class ActionPack::WebAuthn::CborDecoder
# Decodes the next CBOR data item from the byte sequence.
def decode
raise DecodeError, "Unexpected end of input" if @position >= @bytes.length
raise DecodeError, "Maximum nesting depth exceeded" if @depth > @max_depth
raise ActionPack::WebAuthn::InvalidCborError, "Unexpected end of input" if @position >= @bytes.length
raise ActionPack::WebAuthn::InvalidCborError, "Maximum nesting depth exceeded" if @depth >= @max_depth
@depth += 1
@@ -190,13 +191,19 @@ class ActionPack::WebAuthn::CborDecoder
when FOUR_BYTE_VALUE_FOLLOWS then read_bytes(4).pack("C*").unpack1("g")
when EIGHT_BYTE_VALUE_FOLLOWS then read_bytes(8).pack("C*").unpack1("G")
else
raise DecodeError, "Invalid simple value: #{info}"
raise ActionPack::WebAuthn::InvalidCborError, "Invalid simple value: #{info}"
end
end
def decode_tag
read_argument
decode
tag = read_argument
value = decode
case tag
when POSITIVE_BIGNUM_TAG then value.bytes.inject(0) { |n, b| (n << 8) | b }
when NEGATIVE_BIGNUM_TAG then -1 - value.bytes.inject(0) { |n, b| (n << 8) | b }
else value
end
end
def decode_half_float
@@ -225,9 +232,9 @@ class ActionPack::WebAuthn::CborDecoder
when FOUR_BYTE_VALUE_FOLLOWS then read_bytes(4).pack("C*").unpack1("N")
when EIGHT_BYTE_VALUE_FOLLOWS then read_bytes(8).pack("C*").unpack1("Q>")
when RESERVED_VALUE_RANGE
raise DecodeError, "Reserved additional info: #{info}"
raise ActionPack::WebAuthn::InvalidCborError, "Reserved additional info: #{info}"
else
raise DecodeError, "Invalid additional info: #{info}"
raise ActionPack::WebAuthn::InvalidCborError, "Invalid additional info: #{info}"
end
end
@@ -245,7 +252,7 @@ class ActionPack::WebAuthn::CborDecoder
end
def read_bytes(length)
raise DecodeError, "Unexpected end of input" if @position + length > @bytes.length
raise ActionPack::WebAuthn::InvalidCborError, "Unexpected end of input" if @position + length > @bytes.length
bytes = @bytes[@position, length]
@position += length
@@ -253,7 +260,7 @@ class ActionPack::WebAuthn::CborDecoder
end
def read_byte
raise DecodeError, "Unexpected end of input" if @position >= @bytes.length
raise ActionPack::WebAuthn::InvalidCborError, "Unexpected end of input" if @position >= @bytes.length
byte = @bytes[@position]
@position += 1
+52 -10
View File
@@ -19,22 +19,25 @@
# [ES256]
# ECDSA with P-256 curve and SHA-256. The most common algorithm for WebAuthn.
#
# [EdDSA]
# EdDSA with Ed25519 curve. Increasingly supported by modern authenticators.
#
# [RS256]
# RSASSA-PKCS1-v1_5 with SHA-256. Used by some security keys and platforms.
#
# == Attributes
#
# [+key_type+]
# The COSE key type (2 for EC2, 3 for RSA).
# The COSE key type (1 for OKP, 2 for EC2, 3 for RSA).
#
# [+algorithm+]
# The COSE algorithm identifier (-7 for ES256, -257 for RS256).
# The COSE algorithm identifier (-7 for ES256, -8 for EdDSA, -257 for RS256).
#
# [+parameters+]
# The full COSE key parameters map, including curve and coordinate data.
class ActionPack::WebAuthn::CoseKey
# Raised when the key type, algorithm, or curve is not supported.
class UnsupportedKeyTypeError < StandardError; end
P256_COORDINATE_LENGTH = 32
MINIMUM_RSA_KEY_BITS = 2048
# COSE key labels
KEY_TYPE_LABEL = 1
@@ -44,18 +47,25 @@ class ActionPack::WebAuthn::CoseKey
EC2_Y_LABEL = -3
RSA_N_LABEL = -1
RSA_E_LABEL = -2
OKP_CURVE_LABEL = -1
OKP_X_LABEL = -2
# COSE key types
OKP = 1
EC2 = 2
RSA = 3
# COSE algorithms
ES256 = -7
EDDSA = -8
RS256 = -257
# COSE EC2 curves
P256 = 1
# COSE OKP curves
ED25519 = 6
# OpenSSL types
UNCOMPRESSED_POINT_MARKER = 0x04
@@ -84,26 +94,30 @@ class ActionPack::WebAuthn::CoseKey
# Converts the COSE key to an OpenSSL public key object.
#
# Returns an +OpenSSL::PKey::EC+ for EC2 keys or +OpenSSL::PKey::RSA+ for
# RSA keys, suitable for use with +OpenSSL::PKey#verify+.
# Returns an +OpenSSL::PKey::EC+ for EC2 keys, +OpenSSL::PKey::RSA+ for
# RSA keys, or an Ed25519 key for OKP keys, suitable for use with
# +OpenSSL::PKey#verify+.
#
# Raises +UnsupportedKeyTypeError+ if the key type, algorithm, or curve
# is not supported.
def to_openssl_key
case [ key_type, algorithm ]
when [ EC2, ES256 ] then build_ec2_es256_key
when [ OKP, EDDSA ] then build_okp_eddsa_key
when [ RSA, RS256 ] then build_rsa_rs256_key
else raise UnsupportedKeyTypeError, "Unsupported COSE key type/algorithm: #{key_type}/#{algorithm}"
else raise ActionPack::WebAuthn::UnsupportedKeyTypeError, "Unsupported COSE key type/algorithm: #{key_type}/#{algorithm}"
end
end
private
def build_ec2_es256_key
curve = parameters[EC2_CURVE_LABEL]
raise UnsupportedKeyTypeError, "Unsupported EC curve: #{curve}" unless curve == P256
raise ActionPack::WebAuthn::UnsupportedKeyTypeError, "Unsupported EC curve: #{curve}" unless curve == P256
x = parameters[EC2_X_LABEL]
y = parameters[EC2_Y_LABEL]
raise ActionPack::WebAuthn::InvalidKeyError, "Missing EC2 key coordinates" if x.nil? || y.nil?
raise ActionPack::WebAuthn::InvalidKeyError, "Invalid EC2 coordinate length" unless x.bytesize == P256_COORDINATE_LENGTH && y.bytesize == P256_COORDINATE_LENGTH
# Uncompressed point format: 0x04 || x || y
public_key_bytes = [ UNCOMPRESSED_POINT_MARKER, *x.bytes, *y.bytes ].pack("C*")
@@ -117,11 +131,37 @@ class ActionPack::WebAuthn::CoseKey
])
OpenSSL::PKey::EC.new(asn1.to_der)
rescue OpenSSL::PKey::PKeyError => error
raise ActionPack::WebAuthn::InvalidKeyError, "Invalid EC2 key: #{error.message}"
end
def build_okp_eddsa_key
curve = parameters[OKP_CURVE_LABEL]
raise ActionPack::WebAuthn::UnsupportedKeyTypeError, "Unsupported OKP curve: #{curve}" unless curve == ED25519
x = parameters[OKP_X_LABEL]
raise ActionPack::WebAuthn::InvalidKeyError, "Missing OKP key coordinate" if x.nil?
asn1 = OpenSSL::ASN1::Sequence([
OpenSSL::ASN1::Sequence([
OpenSSL::ASN1::ObjectId("ED25519")
]),
OpenSSL::ASN1::BitString(x)
])
OpenSSL::PKey.read(asn1.to_der)
rescue OpenSSL::PKey::PKeyError => error
raise ActionPack::WebAuthn::InvalidKeyError, "Invalid OKP key: #{error.message}"
end
def build_rsa_rs256_key
n = OpenSSL::BN.new(parameters[RSA_N_LABEL], 2)
e = OpenSSL::BN.new(parameters[RSA_E_LABEL], 2)
n_bytes = parameters[RSA_N_LABEL]
e_bytes = parameters[RSA_E_LABEL]
raise ActionPack::WebAuthn::InvalidKeyError, "Missing RSA key parameters" if n_bytes.nil? || e_bytes.nil?
raise ActionPack::WebAuthn::InvalidKeyError, "RSA key must be at least #{MINIMUM_RSA_KEY_BITS} bits" if n_bytes.bytesize * 8 < MINIMUM_RSA_KEY_BITS
n = OpenSSL::BN.new(n_bytes, 2)
e = OpenSSL::BN.new(e_bytes, 2)
asn1 = OpenSSL::ASN1::Sequence([
OpenSSL::ASN1::Sequence([
@@ -137,5 +177,7 @@ class ActionPack::WebAuthn::CoseKey
])
OpenSSL::PKey::RSA.new(asn1.to_der)
rescue OpenSSL::PKey::PKeyError => error
raise ActionPack::WebAuthn::InvalidKeyError, "Invalid RSA key: #{error.message}"
end
end
+21 -1
View File
@@ -1,3 +1,23 @@
# = Action Pack WebAuthn Current Attributes
#
# Thread-isolated request-scoped attributes for WebAuthn ceremonies. These are
# set automatically by ActionPack::Passkey::Request at the start of each
# request and consumed by the registration/authentication flows.
#
# == Attributes
#
# [+host+]
# The relying party identifier (typically +request.host+). Used as the
# default RelyingParty ID.
#
# [+origin+]
# The expected origin (typically +request.base_url+). Validated against the
# +origin+ field in the authenticator's client data.
#
# [+challenge+]
# The Base64URL-encoded challenge read from the encrypted cookie. Validated
# against the +challenge+ field in the authenticator's client data.
#
class ActionPack::WebAuthn::Current < ActiveSupport::CurrentAttributes
attribute :host, :origin
attribute :host, :origin, :challenge
end
@@ -1,14 +1,94 @@
# = Action Pack WebAuthn Public Key Credential
#
# Represents a WebAuthn public key credential and orchestrates the registration
# and authentication ceremonies. During registration (+.register+), it verifies
# the attestation response and returns a new credential. During authentication
# (+#authenticate+), it verifies the assertion response against the stored
# public key.
#
# == Registration
#
# credential = ActionPack::WebAuthn::PublicKeyCredential.register(
# params[:passkey],
# challenge: ActionPack::WebAuthn::Current.challenge,
# origin: ActionPack::WebAuthn::Current.origin
# )
#
# credential.id # => Base64URL-encoded credential ID
# credential.public_key # => OpenSSL::PKey::EC
# credential.sign_count # => 0
#
# == Authentication
#
# credential = ActionPack::WebAuthn::PublicKeyCredential.new(
# id: stored_credential_id,
# public_key: stored_public_key,
# sign_count: stored_sign_count
# )
#
# credential.authenticate(params[:passkey])
#
# == Ceremony Options
#
# Use +.creation_options+ and +.request_options+ to generate the JSON options
# passed to the browser's +navigator.credentials.create()+ and
# +navigator.credentials.get()+ calls.
#
# == Attributes
#
# [+id+]
# The Base64URL-encoded credential identifier.
#
# [+public_key+]
# The OpenSSL public key for signature verification.
#
# [+sign_count+]
# The signature counter, used for replay detection.
#
# [+aaguid+]
# The authenticator attestation GUID (set during registration).
#
# [+backed_up+]
# Whether the credential is backed up to cloud storage (synced passkey).
#
# [+transports+]
# Transport hints (e.g., "internal", "usb", "ble", "nfc").
#
class ActionPack::WebAuthn::PublicKeyCredential
attr_reader :id, :public_key, :sign_count, :aaguid, :backed_up, :transports, :owner
attr_reader :id, :public_key, :sign_count, :aaguid, :backed_up, :transports
class << self
def create(client_data_json:, attestation_object:, challenge:, origin:, transports: [], owner: nil)
# Returns a RequestOptions object for the authentication ceremony.
# Credentials responding to +to_public_key_credential+ are automatically
# transformed.
def request_options(**attributes)
attributes[:credentials] = transform_credentials(attributes[:credentials]) if attributes[:credentials]
ActionPack::WebAuthn::PublicKeyCredential::RequestOptions.new(**attributes)
end
# Returns a CreationOptions object for the registration ceremony.
# Credentials in +exclude_credentials+ responding to
# +to_public_key_credential+ are automatically transformed.
def creation_options(**attributes)
attributes[:exclude_credentials] = transform_credentials(attributes[:exclude_credentials]) if attributes[:exclude_credentials]
ActionPack::WebAuthn::PublicKeyCredential::CreationOptions.new(**attributes)
end
# Verifies an attestation response from the browser and returns a new
# PublicKeyCredential with the registered credential data.
#
# Raises +InvalidResponseError+ if the attestation is invalid.
def register(params, challenge: ActionPack::WebAuthn::Current.challenge, origin: ActionPack::WebAuthn::Current.origin)
response = ActionPack::WebAuthn::Authenticator::AttestationResponse.new(
client_data_json: client_data_json,
attestation_object: attestation_object
client_data_json: params[:client_data_json],
attestation_object: params[:attestation_object],
challenge: challenge,
origin: origin
)
response.validate!(challenge: challenge, origin: origin)
response.validate!
new(
id: response.attestation.credential_id,
@@ -16,33 +96,61 @@ class ActionPack::WebAuthn::PublicKeyCredential
sign_count: response.attestation.sign_count,
aaguid: response.attestation.aaguid,
backed_up: response.attestation.backed_up?,
transports: transports,
owner: owner
transports: Array(params[:transports])
)
end
private
def transform_credentials(credentials)
Array(credentials).map do |credential|
if credential.respond_to?(:to_public_key_credential)
credential.to_public_key_credential
else
credential
end
end
end
end
def initialize(id:, public_key:, sign_count:, aaguid: nil, backed_up: nil, transports: [], owner: nil)
def initialize(id:, public_key:, sign_count:, aaguid: nil, backed_up: nil, transports: [])
@id = id
@public_key = public_key
@public_key = OpenSSL::PKey.read(public_key) unless public_key.is_a?(OpenSSL::PKey::PKey)
@sign_count = sign_count
@aaguid = aaguid
@backed_up = backed_up
@transports = transports
@owner = owner
end
def authenticate(client_data_json:, authenticator_data:, signature:, challenge:, origin:)
# Verifies an assertion response against this credential's public key.
# Updates +sign_count+ and +backed_up+ on success.
#
# Raises +InvalidResponseError+ if the assertion is invalid.
def authenticate(params, challenge: ActionPack::WebAuthn::Current.challenge, origin: ActionPack::WebAuthn::Current.origin)
response = ActionPack::WebAuthn::Authenticator::AssertionResponse.new(
client_data_json: client_data_json,
authenticator_data: authenticator_data,
signature: signature,
credential: self
client_data_json: params[:client_data_json],
authenticator_data: params[:authenticator_data],
signature: params[:signature],
credential: self,
challenge: challenge,
origin: origin
)
response.validate!(challenge: challenge, origin: origin)
response.validate!
@sign_count = response.authenticator_data.sign_count
@backed_up = response.authenticator_data.backed_up?
end
# Returns a Hash of the credential data suitable for persisting.
def to_h
{
credential_id: id,
public_key: public_key.to_der,
sign_count: sign_count,
aaguid: aaguid,
backed_up: backed_up,
transports: transports
}
end
end
@@ -36,51 +36,33 @@
#
# == Supported Algorithms
#
# By default, supports ES256 (ECDSA with P-256 and SHA-256) and RS256
# (RSASSA-PKCS1-v1_5 with SHA-256), which cover the vast majority of
# authenticators.
# By default, supports ES256 (ECDSA with P-256 and SHA-256), EdDSA
# (Ed25519), and RS256 (RSASSA-PKCS1-v1_5 with SHA-256), which cover
# the vast majority of authenticators.
class ActionPack::WebAuthn::PublicKeyCredential::CreationOptions < ActionPack::WebAuthn::PublicKeyCredential::Options
ES256 = { type: "public-key", alg: -7 }.freeze
EDDSA = { type: "public-key", alg: -8 }.freeze
RS256 = { type: "public-key", alg: -257 }.freeze
RESIDENT_KEY_OPTIONS = %i[ preferred required discouraged ].freeze
ATTESTATION_PREFERENCES = %i[ none indirect direct enterprise ].freeze
attr_reader :id, :name, :display_name
attribute :id
attribute :name
attribute :display_name
attribute :resident_key, default: :preferred
attribute :exclude_credentials, default: -> { [] }
attribute :attestation, default: :none
attribute :challenge_expiration, default: -> { Rails.configuration.action_pack.web_authn.creation_challenge_expiration }
# Creates a new set of credential creation options.
#
# ==== Options
#
# [+:id+]
# Required. The user's unique identifier.
#
# [+:name+]
# Required. The user's account name (e.g., email).
#
# [+:display_name+]
# Required. The user's display name.
#
# [+:relying_party+]
# Optional. The relying party configuration.
#
# [+:resident_key+]
# Optional. Resident key requirement. One of +:preferred+, +:required+,
# or +:discouraged+. Defaults to +:preferred+.
#
# [+:exclude_credentials+]
# Optional. Existing credentials to exclude from registration. Each must
# respond to +id+ and +transports+.
#
# [+:attestation+]
# Optional. The attestation conveyance preference. One of +:none+,
# +:indirect+, +:direct+, or +:enterprise+. Defaults to +:none+.
def initialize(id:, name:, display_name:, resident_key: :preferred, exclude_credentials: [], attestation: :none, **attrs)
super(**attrs)
validates :id, :name, :display_name, presence: true
validates :resident_key, inclusion: { in: RESIDENT_KEY_OPTIONS }
validates :attestation, inclusion: { in: ATTESTATION_PREFERENCES }
@id = id
@name = name
@display_name = display_name
@resident_key = resident_key
@exclude_credentials = exclude_credentials
@attestation = validate_attestation(attestation)
def initialize(attributes = {})
super
self.resident_key = resident_key.to_sym
self.attestation = attestation.to_sym
validate!
end
# Returns a Hash suitable for JSON serialization and passing to the
@@ -96,37 +78,28 @@ class ActionPack::WebAuthn::PublicKeyCredential::CreationOptions < ActionPack::W
},
pubKeyCredParams: [
ES256,
EDDSA,
RS256
],
authenticatorSelection: {
residentKey: @resident_key.to_s,
requireResidentKey: @resident_key == :required,
residentKey: resident_key.to_s,
requireResidentKey: resident_key == :required,
userVerification: user_verification.to_s
}
}
if @exclude_credentials.any?
json[:excludeCredentials] = @exclude_credentials.map { |credential| exclude_credential_json(credential) }
if exclude_credentials.any?
json[:excludeCredentials] = exclude_credentials.map { |credential| exclude_credential_json(credential) }
end
if @attestation != :none
json[:attestation] = @attestation.to_s
if attestation != :none
json[:attestation] = attestation.to_s
end
json
end
private
ATTESTATION_PREFERENCES = %i[ none indirect direct enterprise ].freeze
def validate_attestation(value)
if ATTESTATION_PREFERENCES.include?(value)
value
else
raise ArgumentError, "Invalid attestation preference: #{value.inspect}. Must be one of: #{ATTESTATION_PREFERENCES.map(&:inspect).join(", ")}"
end
end
def exclude_credential_json(credential)
hash = { type: "public-key", id: credential.id }
hash[:transports] = credential.transports if credential.transports.any?
@@ -1,26 +1,77 @@
# = Action Pack WebAuthn Public Key Credential Options
#
# Abstract base class for WebAuthn ceremony options. Provides shared
# attributes and challenge generation for both CreationOptions (registration)
# and RequestOptions (authentication).
#
# This class should not be instantiated directly. Use CreationOptions or
# RequestOptions instead.
#
# == Challenge Generation
#
# Each options object generates a signed, expiring challenge via
# +ActionPack::WebAuthn.challenge_verifier+. The challenge is Base64URL-encoded
# and includes an embedded timestamp so the server can reject stale challenges.
#
# == Attributes
#
# [+user_verification+]
# Controls whether user verification (biometrics/PIN) is required. One of
# +:required+, +:preferred+, or +:discouraged+. Defaults to +:preferred+.
#
# [+relying_party+]
# The RelyingParty configuration. Defaults to +ActionPack::WebAuthn.relying_party+.
#
# [+challenge_expiration+]
# How long the challenge remains valid. Defaults vary by ceremony type
# (configured in the Railtie).
#
class ActionPack::WebAuthn::PublicKeyCredential::Options
include ActiveModel::API
include ActiveModel::Attributes
CHALLENGE_LENGTH = 32
USER_VERIFICATION_OPTIONS = [ :required, :preferred, :discouraged ].freeze
USER_VERIFICATION_OPTIONS = %i[ required preferred discouraged ].freeze
attr_reader :user_verification, :relying_party
attribute :user_verification, default: :preferred
attribute :relying_party, default: -> { ActionPack::WebAuthn.relying_party }
attribute :challenge_expiration
def initialize(user_verification: :preferred, relying_party: ActionPack::WebAuthn.relying_party)
@user_verification = user_verification.to_sym
@relying_party = relying_party
validates :user_verification, inclusion: { in: USER_VERIFICATION_OPTIONS }
unless USER_VERIFICATION_OPTIONS.include?(user_verification)
raise ArgumentError, "Invalid user verification option: #{user_verification.inspect}"
end
def initialize(attributes = {})
super
self.user_verification = user_verification.to_sym
end
# Returns a Base64URL-encoded random challenge. The challenge is generated
# once and memoized for the lifetime of this object.
# Validates the options, raising +InvalidOptionsError+ if any are invalid.
def validate!
super
rescue ActiveModel::ValidationError
raise ActionPack::WebAuthn::InvalidOptionsError, errors.full_messages.to_sentence
end
# Returns a human-readable representation of the options.
def inspect
attributes_string = attributes.map { |name, value| "#{name}: #{value.inspect}" }.join(", ")
"#<#{self.class.name} #{attributes_string}>"
end
# Returns a Base64URL-encoded signed challenge containing a random nonce and
# an embedded timestamp. The challenge is generated once and memoized for the
# lifetime of this object.
#
# The challenge must be stored server-side and verified when the client
# responds, to prevent replay attacks.
# The timestamp allows the server to reject stale challenges. The expiration
# window is configurable per-ceremony via
# +config.action_pack.web_authn.creation_challenge_expiration+ and
# +config.action_pack.web_authn.request_challenge_expiration+, or per-instance
# via the +challenge_expiration+ attribute.
def challenge
@challenge ||= Base64.urlsafe_encode64(
SecureRandom.random_bytes(CHALLENGE_LENGTH),
ActionPack::WebAuthn.challenge_verifier.generate(
Base64.strict_encode64(SecureRandom.random_bytes(CHALLENGE_LENGTH)),
expires_in: challenge_expiration
),
padding: false
)
end
@@ -24,21 +24,12 @@
# The relying party (your application) configuration. Defaults to
# +ActionPack::WebAuthn.relying_party+.
class ActionPack::WebAuthn::PublicKeyCredential::RequestOptions < ActionPack::WebAuthn::PublicKeyCredential::Options
attr_reader :credentials
attribute :credentials, default: -> { [] }
attribute :challenge_expiration, default: -> { Rails.configuration.action_pack.web_authn.request_challenge_expiration }
# Creates a new set of credential request options.
#
# ==== Options
#
# [+:credentials+]
# Required. The user's registered WebAuthn credentials.
#
# [+:relying_party+]
# Optional. The relying party configuration.
def initialize(credentials:, **attrs)
super(**attrs)
@credentials = credentials
def initialize(attributes = {})
super
validate!
end
# Returns a Hash suitable for JSON serialization and passing to the
@@ -0,0 +1,15 @@
module ActionPackPasskeyInferNameFromAaguid
extend ActiveSupport::Concern
class_methods do
def register(...)
super(...).tap do |credential|
credential.update!(name: credential.authenticator.name) if credential.authenticator && credential.name.blank?
end
end
end
def authenticator
Passkey::Authenticator.find_by_aaguid(aaguid)
end
end
@@ -0,0 +1,33 @@
require "test_helper"
class My::PasskeyChallengesControllerTest < ActionDispatch::IntegrationTest
test "returns a fresh challenge" do
untenanted do
post my_passkey_challenge_url
assert_response :success
assert_not_nil response.parsed_body["challenge"]
end
end
test "stores challenge in cookie" do
untenanted do
post my_passkey_challenge_url
jar = ActionDispatch::Cookies::CookieJar.build(request, cookies.to_hash)
assert_equal response.parsed_body["challenge"], jar.encrypted[ActionPack::Passkey::ChallengesController::COOKIE_NAME]
end
end
test "returns a different challenge each time" do
untenanted do
post my_passkey_challenge_url
first_challenge = response.parsed_body["challenge"]
post my_passkey_challenge_url
second_challenge = response.parsed_body["challenge"]
assert_not_equal first_challenge, second_challenge
end
end
end
@@ -0,0 +1,26 @@
require "test_helper"
class My::PasskeysControllerTest < ActionDispatch::IntegrationTest
include WebauthnTestHelper
setup do
sign_in_as :kevin
end
test "index" do
get my_passkeys_path
assert_response :success
end
test "register a passkey" do
challenge = request_webauthn_challenge
assert_difference -> { identities(:kevin).passkeys.count }, 1 do
post my_passkeys_path, params: build_attestation_params(challenge: challenge)
end
passkey = identities(:kevin).passkeys.order(created_at: :desc).first
assert_redirected_to edit_my_passkey_path(passkey, created: true)
assert_equal [ "internal" ], passkey.transports
end
end
@@ -1,14 +1,15 @@
require "test_helper"
class Sessions::PasskeysControllerTest < ActionDispatch::IntegrationTest
include WebauthnTestHelper
setup do
@identity = identities(:kevin)
@private_key = OpenSSL::PKey::EC.generate("prime256v1")
@credential = @identity.credentials.create!(
@credential = @identity.passkeys.create!(
name: "Test Passkey",
credential_id: Base64.urlsafe_encode64(SecureRandom.random_bytes(32), padding: false),
public_key: @private_key.public_to_der,
public_key: webauthn_private_key.public_to_der,
sign_count: 0,
transports: [ "internal" ]
)
@@ -16,10 +17,9 @@ class Sessions::PasskeysControllerTest < ActionDispatch::IntegrationTest
test "successful authentication" do
untenanted do
get new_session_url
challenge = session[:webauthn_challenge]
challenge = request_webauthn_challenge
post session_passkey_url, params: assertion_params(challenge: challenge)
post session_passkey_url, params: build_assertion_params(challenge: challenge, credential: @credential)
assert_response :redirect
assert cookies[:session_token].present?
@@ -29,10 +29,9 @@ class Sessions::PasskeysControllerTest < ActionDispatch::IntegrationTest
test "updates sign count" do
untenanted do
get new_session_url
challenge = session[:webauthn_challenge]
challenge = request_webauthn_challenge
post session_passkey_url, params: assertion_params(challenge: challenge, sign_count: 1)
post session_passkey_url, params: build_assertion_params(challenge: challenge, credential: @credential, sign_count: 1)
assert_equal 1, @credential.reload.sign_count
end
@@ -40,10 +39,9 @@ class Sessions::PasskeysControllerTest < ActionDispatch::IntegrationTest
test "rejects invalid signature" do
untenanted do
get new_session_url
challenge = session[:webauthn_challenge]
challenge = request_webauthn_challenge
params = assertion_params(challenge: challenge)
params = build_assertion_params(challenge: challenge, credential: @credential)
params[:passkey][:signature] = Base64.urlsafe_encode64("invalid", padding: false)
post session_passkey_url, params: params
@@ -56,7 +54,7 @@ class Sessions::PasskeysControllerTest < ActionDispatch::IntegrationTest
test "rejects unknown credential" do
untenanted do
get new_session_url
request_webauthn_challenge
post session_passkey_url, params: {
passkey: {
@@ -74,10 +72,9 @@ class Sessions::PasskeysControllerTest < ActionDispatch::IntegrationTest
test "successful authentication via JSON" do
untenanted do
get new_session_url
challenge = session[:webauthn_challenge]
challenge = request_webauthn_challenge
post session_passkey_url(format: :json), params: assertion_params(challenge: challenge)
post session_passkey_url(format: :json), params: build_assertion_params(challenge: challenge, credential: @credential)
assert_response :success
assert @response.parsed_body["session_token"].present?
@@ -86,7 +83,7 @@ class Sessions::PasskeysControllerTest < ActionDispatch::IntegrationTest
test "failed authentication via JSON" do
untenanted do
get new_session_url
request_webauthn_challenge
post session_passkey_url(format: :json), params: {
passkey: {
@@ -101,44 +98,4 @@ class Sessions::PasskeysControllerTest < ActionDispatch::IntegrationTest
assert_equal "That passkey didn't work. Try again.", @response.parsed_body["message"]
end
end
private
def assertion_params(challenge:, sign_count: 1)
origin = "http://www.example.com"
client_data_json = {
challenge: challenge,
origin: origin,
type: "webauthn.get"
}.to_json
authenticator_data = build_authenticator_data(sign_count: sign_count)
signature = sign(authenticator_data, client_data_json)
{
passkey: {
id: @credential.credential_id,
client_data_json: client_data_json,
authenticator_data: Base64.urlsafe_encode64(authenticator_data, padding: false),
signature: Base64.urlsafe_encode64(signature, padding: false)
}
}
end
def build_authenticator_data(sign_count:)
rp_id_hash = Digest::SHA256.digest("www.example.com")
flags = 0x01 | 0x04 # user present + user verified
bytes = []
bytes.concat(rp_id_hash.bytes)
bytes << flags
bytes.concat([ sign_count ].pack("N").bytes)
bytes.pack("C*")
end
def sign(authenticator_data, client_data_json)
client_data_hash = Digest::SHA256.digest(client_data_json)
signed_data = authenticator_data + client_data_hash
@private_key.sign("SHA256", signed_data)
end
end
+92
View File
@@ -0,0 +1,92 @@
require "test_helper"
class ActionPack::PasskeyTest < ActiveSupport::TestCase
setup do
@identity = identities(:kevin)
@private_key = OpenSSL::PKey::EC.generate("prime256v1")
ActionPack::WebAuthn::Current.host = "www.example.com"
ActionPack::WebAuthn::Current.origin = "http://www.example.com"
@passkey = @identity.passkeys.create!(
credential_id: Base64.urlsafe_encode64(SecureRandom.random_bytes(32), padding: false),
public_key: @private_key.public_to_der,
sign_count: 0,
transports: [ "internal" ]
)
end
test "authenticate with valid assertion" do
challenge = ActionPack::Passkey.request_options(credentials: [ @passkey ]).challenge
assertion = build_assertion(challenge: challenge)
result = @passkey.authenticate(assertion, challenge: challenge)
assert_equal @passkey, result
end
test "authenticate returns nil with invalid signature" do
challenge = ActionPack::Passkey.request_options(credentials: [ @passkey ]).challenge
assertion = build_assertion(challenge: challenge)
assertion[:signature] = Base64.urlsafe_encode64("invalid", padding: false)
assert_nil @passkey.authenticate(assertion, challenge: challenge)
end
test "authenticate updates sign count and backed_up" do
challenge = ActionPack::Passkey.request_options(credentials: [ @passkey ]).challenge
assertion = build_assertion(challenge: challenge, sign_count: 5, backed_up: true)
@passkey.authenticate(assertion, challenge: challenge)
assert_equal 5, @passkey.reload.sign_count
assert @passkey.backed_up?
end
test "to_public_key_credential" do
credential = @passkey.to_public_key_credential
assert_equal @passkey.credential_id, credential.id
assert_equal @passkey.sign_count, credential.sign_count
assert_equal @passkey.transports, credential.transports
end
private
def build_assertion(challenge:, sign_count: 1, backed_up: false)
origin = ActionPack::WebAuthn::Current.origin
client_data_json = {
challenge: challenge,
origin: origin,
type: "webauthn.get"
}.to_json
authenticator_data = build_authenticator_data(sign_count: sign_count, backed_up: backed_up)
signature = sign(authenticator_data, client_data_json)
{
id: @passkey.credential_id,
client_data_json: client_data_json,
authenticator_data: Base64.urlsafe_encode64(authenticator_data, padding: false),
signature: Base64.urlsafe_encode64(signature, padding: false)
}
end
def build_authenticator_data(sign_count:, backed_up: false)
rp_id_hash = Digest::SHA256.digest(ActionPack::WebAuthn::Current.host)
flags = 0x01 | 0x04 # user present + user verified
flags |= 0x08 | 0x10 if backed_up # backup eligible + backup state
bytes = []
bytes.concat(rp_id_hash.bytes)
bytes << flags
bytes.concat([ sign_count ].pack("N").bytes)
bytes.pack("C*")
end
def sign(authenticator_data, client_data_json)
client_data_hash = Digest::SHA256.digest(client_data_json)
signed_data = authenticator_data + client_data_hash
@private_key.sign("SHA256", signed_data)
end
end
@@ -1,10 +1,12 @@
require "test_helper"
class ActionPack::WebAuthn::Authenticator::AssertionResponseTest < ActiveSupport::TestCase
include WebauthnTestHelper
setup do
ActionPack::WebAuthn::Current.host = "example.com"
@challenge = "test-challenge-123"
@challenge = webauthn_challenge
@origin = "https://example.com"
@client_data_json = {
challenge: @challenge,
@@ -24,7 +26,9 @@ class ActionPack::WebAuthn::Authenticator::AssertionResponseTest < ActiveSupport
client_data_json: @client_data_json,
authenticator_data: @authenticator_data,
signature: @signature,
credential: @credential
credential: @credential,
challenge: @challenge,
origin: @origin
)
end
@@ -36,7 +40,7 @@ class ActionPack::WebAuthn::Authenticator::AssertionResponseTest < ActiveSupport
test "validate! succeeds with valid challenge, origin, type, and signature" do
assert_nothing_raised do
@response.validate!(challenge: @challenge, origin: @origin)
@response.validate!
end
end
@@ -51,11 +55,13 @@ class ActionPack::WebAuthn::Authenticator::AssertionResponseTest < ActiveSupport
client_data_json: client_data_json,
authenticator_data: @authenticator_data,
signature: sign(@authenticator_data, client_data_json),
credential: @credential
credential: @credential,
challenge: @challenge,
origin: @origin
)
error = assert_raises(ActionPack::WebAuthn::Authenticator::Response::InvalidResponseError) do
response.validate!(challenge: @challenge, origin: @origin)
error = assert_raises(ActionPack::WebAuthn::InvalidResponseError) do
response.validate!
end
assert_equal "Client data type is not webauthn.get", error.message
@@ -65,28 +71,34 @@ class ActionPack::WebAuthn::Authenticator::AssertionResponseTest < ActiveSupport
response = ActionPack::WebAuthn::Authenticator::AssertionResponse.new(
client_data_json: @client_data_json,
authenticator_data: @authenticator_data,
signature: "invalid-signature",
credential: @credential
signature: Base64.urlsafe_encode64("invalid-signature", padding: false),
credential: @credential,
challenge: @challenge,
origin: @origin
)
error = assert_raises(ActionPack::WebAuthn::Authenticator::Response::InvalidResponseError) do
response.validate!(challenge: @challenge, origin: @origin)
error = assert_raises(ActionPack::WebAuthn::InvalidResponseError) do
response.validate!
end
assert_equal "Invalid signature", error.message
end
test "validate! raises when challenge does not match" do
error = assert_raises(ActionPack::WebAuthn::Authenticator::Response::InvalidResponseError) do
@response.validate!(challenge: "wrong-challenge", origin: @origin)
@response.challenge = "wrong-challenge"
error = assert_raises(ActionPack::WebAuthn::InvalidResponseError) do
@response.validate!
end
assert_equal "Challenge does not match", error.message
end
test "validate! raises when origin does not match" do
error = assert_raises(ActionPack::WebAuthn::Authenticator::Response::InvalidResponseError) do
@response.validate!(challenge: @challenge, origin: "https://evil.com")
@response.origin = "https://evil.com"
error = assert_raises(ActionPack::WebAuthn::InvalidResponseError) do
@response.validate!
end
assert_equal "Origin does not match", error.message
@@ -98,11 +110,14 @@ class ActionPack::WebAuthn::Authenticator::AssertionResponseTest < ActiveSupport
client_data_json: @client_data_json,
authenticator_data: authenticator_data,
signature: sign(authenticator_data, @client_data_json),
credential: @credential
credential: @credential,
challenge: @challenge,
origin: @origin,
user_verification: :preferred
)
assert_nothing_raised do
response.validate!(challenge: @challenge, origin: @origin, user_verification: :preferred)
response.validate!
end
end
@@ -112,11 +127,14 @@ class ActionPack::WebAuthn::Authenticator::AssertionResponseTest < ActiveSupport
client_data_json: @client_data_json,
authenticator_data: authenticator_data,
signature: sign(authenticator_data, @client_data_json),
credential: @credential
credential: @credential,
challenge: @challenge,
origin: @origin,
user_verification: :required
)
assert_nothing_raised do
response.validate!(challenge: @challenge, origin: @origin, user_verification: :required)
response.validate!
end
end
@@ -126,11 +144,14 @@ class ActionPack::WebAuthn::Authenticator::AssertionResponseTest < ActiveSupport
client_data_json: @client_data_json,
authenticator_data: authenticator_data,
signature: sign(authenticator_data, @client_data_json),
credential: @credential
credential: @credential,
challenge: @challenge,
origin: @origin,
user_verification: :required
)
error = assert_raises(ActionPack::WebAuthn::Authenticator::Response::InvalidResponseError) do
response.validate!(challenge: @challenge, origin: @origin, user_verification: :required)
error = assert_raises(ActionPack::WebAuthn::InvalidResponseError) do
response.validate!
end
assert_equal "User verification is required", error.message
@@ -146,7 +167,7 @@ class ActionPack::WebAuthn::Authenticator::AssertionResponseTest < ActiveSupport
bytes = []
bytes.concat(rp_id_hash.bytes)
bytes << flags
bytes.concat([sign_count].pack("N").bytes)
bytes.concat([ sign_count ].pack("N").bytes)
bytes.pack("C*")
end
@@ -1,10 +1,43 @@
require "test_helper"
class ActionPack::WebAuthn::Authenticator::AttestationResponseTest < ActiveSupport::TestCase
# Auth data in all objects contains:
# rp_id_hash: SHA-256("example.com") (32 bytes)
# sign_count: 0
# aaguid: 00010203-0405-0607-0809-0a0b0c0d0e0f (16 bytes)
# credential_id: 32 sequential bytes 0x00..0x1f
# cose_key: EC2/ES256 P-256 {1: 2, 3: -7, -1: 1, -2: <x>, -3: <y>}
# {"fmt": "none", "attStmt": {}, "authData": <flags: 0x45 (UP+UV+AT)>}
ATTESTATION_NONE_VERIFIED = [ "a363666d74646e6f6e656761747453746d74a068617574684461" \
"746158a4a379a6f6eeafb9a55e378c118034e2751e682fab9f2d30ab13d2125586ce1947" \
"4500000000000102030405060708090a0b0c0d0e0f0020000102030405060708090a0b0c" \
"0d0e0f101112131415161718191a1b1c1d1e1fa50102032620012158202ba472104c686f" \
"39d4b623cc9324953e7053b47cae818e8cf774203a4f51af7122582069cb8ac519bdd929" \
"e2bdbe79e9f9b8d14c2d89a7cbd324647a1ccd68b8de3ca0" ].pack("H*")
# {"fmt": "none", "attStmt": {}, "authData": <flags: 0x41 (UP+AT)>}
ATTESTATION_NONE_NOT_VERIFIED = [ "a363666d74646e6f6e656761747453746d74a06861757468" \
"4461746158a4a379a6f6eeafb9a55e378c118034e2751e682fab9f2d30ab13d2125586ce" \
"19474100000000000102030405060708090a0b0c0d0e0f0020000102030405060708090a" \
"0b0c0d0e0f101112131415161718191a1b1c1d1e1fa50102032620012158202ba472104c" \
"686f39d4b623cc9324953e7053b47cae818e8cf774203a4f51af7122582069cb8ac519bd" \
"d929e2bdbe79e9f9b8d14c2d89a7cbd324647a1ccd68b8de3ca0" ].pack("H*")
# {"fmt": "packed", "attStmt": {}, "authData": <flags: 0x45 (UP+UV+AT)>}
ATTESTATION_PACKED_VERIFIED = [ "a363666d74667061636b65646761747453746d74a068617574" \
"684461746158a4a379a6f6eeafb9a55e378c118034e2751e682fab9f2d30ab13d2125586" \
"ce19474500000000000102030405060708090a0b0c0d0e0f0020000102030405060708090" \
"a0b0c0d0e0f101112131415161718191a1b1c1d1e1fa50102032620012158202ba472104" \
"c686f39d4b623cc9324953e7053b47cae818e8cf774203a4f51af7122582069cb8ac519b" \
"dd929e2bdbe79e9f9b8d14c2d89a7cbd324647a1ccd68b8de3ca0" ].pack("H*")
include WebauthnTestHelper
setup do
ActionPack::WebAuthn::Current.host = "example.com"
@challenge = "test-challenge-123"
@challenge = webauthn_challenge
@origin = "https://example.com"
@client_data_json = {
challenge: @challenge,
@@ -14,7 +47,9 @@ class ActionPack::WebAuthn::Authenticator::AttestationResponseTest < ActiveSuppo
@response = ActionPack::WebAuthn::Authenticator::AttestationResponse.new(
client_data_json: @client_data_json,
attestation_object: build_attestation_object(user_verified: true)
attestation_object: ATTESTATION_NONE_VERIFIED,
challenge: @challenge,
origin: @origin
)
end
@@ -24,40 +59,49 @@ class ActionPack::WebAuthn::Authenticator::AttestationResponseTest < ActiveSuppo
test "validate! succeeds with valid challenge, origin, and type" do
assert_nothing_raised do
@response.validate!(challenge: @challenge, origin: @origin)
@response.validate!
end
end
test "validate! succeeds with user_verification preferred when not verified" do
response = ActionPack::WebAuthn::Authenticator::AttestationResponse.new(
client_data_json: @client_data_json,
attestation_object: build_attestation_object(user_verified: false)
attestation_object: ATTESTATION_NONE_NOT_VERIFIED,
challenge: @challenge,
origin: @origin,
user_verification: :preferred
)
assert_nothing_raised do
response.validate!(challenge: @challenge, origin: @origin, user_verification: :preferred)
response.validate!
end
end
test "validate! succeeds with user_verification required when verified" do
response = ActionPack::WebAuthn::Authenticator::AttestationResponse.new(
client_data_json: @client_data_json,
attestation_object: build_attestation_object(user_verified: true)
attestation_object: ATTESTATION_NONE_VERIFIED,
challenge: @challenge,
origin: @origin,
user_verification: :required
)
assert_nothing_raised do
response.validate!(challenge: @challenge, origin: @origin, user_verification: :required)
response.validate!
end
end
test "validate! raises with user_verification required when not verified" do
response = ActionPack::WebAuthn::Authenticator::AttestationResponse.new(
client_data_json: @client_data_json,
attestation_object: build_attestation_object(user_verified: false)
attestation_object: ATTESTATION_NONE_NOT_VERIFIED,
challenge: @challenge,
origin: @origin,
user_verification: :required
)
error = assert_raises(ActionPack::WebAuthn::Authenticator::Response::InvalidResponseError) do
response.validate!(challenge: @challenge, origin: @origin, user_verification: :required)
error = assert_raises(ActionPack::WebAuthn::InvalidResponseError) do
response.validate!
end
assert_equal "User verification is required", error.message
@@ -72,27 +116,33 @@ class ActionPack::WebAuthn::Authenticator::AttestationResponseTest < ActiveSuppo
response = ActionPack::WebAuthn::Authenticator::AttestationResponse.new(
client_data_json: client_data_json,
attestation_object: build_attestation_object(user_verified: true)
attestation_object: ATTESTATION_NONE_VERIFIED,
challenge: @challenge,
origin: @origin
)
error = assert_raises(ActionPack::WebAuthn::Authenticator::Response::InvalidResponseError) do
response.validate!(challenge: @challenge, origin: @origin)
error = assert_raises(ActionPack::WebAuthn::InvalidResponseError) do
response.validate!
end
assert_equal "Client data type is not webauthn.create", error.message
end
test "validate! raises when challenge does not match" do
error = assert_raises(ActionPack::WebAuthn::Authenticator::Response::InvalidResponseError) do
@response.validate!(challenge: "wrong-challenge", origin: @origin)
@response.challenge = "wrong-challenge"
error = assert_raises(ActionPack::WebAuthn::InvalidResponseError) do
@response.validate!
end
assert_equal "Challenge does not match", error.message
end
test "validate! raises when origin does not match" do
error = assert_raises(ActionPack::WebAuthn::Authenticator::Response::InvalidResponseError) do
@response.validate!(challenge: @challenge, origin: "https://evil.com")
@response.origin = "https://evil.com"
error = assert_raises(ActionPack::WebAuthn::InvalidResponseError) do
@response.validate!
end
assert_equal "Origin does not match", error.message
@@ -101,11 +151,13 @@ class ActionPack::WebAuthn::Authenticator::AttestationResponseTest < ActiveSuppo
test "validate! raises when attestation format is not registered" do
response = ActionPack::WebAuthn::Authenticator::AttestationResponse.new(
client_data_json: @client_data_json,
attestation_object: build_attestation_object(user_verified: true, format: "packed")
attestation_object: ATTESTATION_PACKED_VERIFIED,
challenge: @challenge,
origin: @origin
)
error = assert_raises(ActionPack::WebAuthn::Authenticator::Response::InvalidResponseError) do
response.validate!(challenge: @challenge, origin: @origin)
error = assert_raises(ActionPack::WebAuthn::InvalidResponseError) do
response.validate!
end
assert_equal "Unsupported attestation format: packed", error.message
@@ -120,94 +172,14 @@ class ActionPack::WebAuthn::Authenticator::AttestationResponseTest < ActiveSuppo
response = ActionPack::WebAuthn::Authenticator::AttestationResponse.new(
client_data_json: @client_data_json,
attestation_object: build_attestation_object(user_verified: true, format: "packed")
attestation_object: ATTESTATION_PACKED_VERIFIED,
challenge: @challenge,
origin: @origin
)
response.validate!(challenge: @challenge, origin: @origin)
response.validate!
assert verified
ensure
ActionPack::WebAuthn.attestation_verifiers.delete("packed")
end
private
def build_attestation_object(user_verified:, format: "none")
auth_data = build_authenticator_data(user_verified: user_verified)
encode_cbor_attestation_object(auth_data, format: format)
end
def build_authenticator_data(user_verified:)
rp_id_hash = Digest::SHA256.digest("example.com")
flags = 0x41 # user present + attested credential
flags |= 0x04 if user_verified
sign_count = 0
aaguid = SecureRandom.random_bytes(16)
credential_id = SecureRandom.random_bytes(32)
cose_key = build_cose_key
bytes = []
bytes.concat(rp_id_hash.bytes)
bytes << flags
bytes.concat([sign_count].pack("N").bytes)
bytes.concat(aaguid.bytes)
bytes.concat([credential_id.bytesize].pack("n").bytes)
bytes.concat(credential_id.bytes)
bytes.concat(cose_key.bytes)
bytes.pack("C*")
end
def build_cose_key
ec_key = OpenSSL::PKey::EC.generate("prime256v1")
public_key_bn = ec_key.public_key.to_bn
public_key_point = public_key_bn.to_s(2)
x = public_key_point[1, 32]
y = public_key_point[33, 32]
params = { 1 => 2, 3 => -7, -1 => 1, -2 => x, -3 => y }
encode_cbor_map(params)
end
def encode_cbor_attestation_object(auth_data, format:)
bytes = [0xa3]
bytes.concat([0x63, *"fmt".bytes])
bytes.concat([0x40 + format.bytesize, *format.bytes])
bytes.concat([0x67, *"attStmt".bytes])
bytes << 0xa0
bytes.concat([0x68, *"authData".bytes])
if auth_data.bytesize <= 255
bytes.concat([0x58, auth_data.bytesize])
else
bytes.concat([0x59, (auth_data.bytesize >> 8) & 0xff, auth_data.bytesize & 0xff])
end
bytes.concat(auth_data.bytes)
bytes.pack("C*")
end
def encode_cbor_map(hash)
bytes = [0xa0 + hash.size]
hash.each do |key, value|
bytes.concat(encode_cbor_integer(key))
bytes.concat(encode_cbor_value(value))
end
bytes.pack("C*")
end
def encode_cbor_integer(int)
if int >= 0 && int <= 23
[int]
elsif int >= -24 && int < 0
[0x20 - int - 1]
else
raise "Integer encoding not implemented for #{int}"
end
end
def encode_cbor_value(value)
case value
when Integer
encode_cbor_integer(value)
when String
length = value.bytesize
length <= 23 ? [0x40 + length, *value.bytes] : [0x58, length, *value.bytes]
end
end
end
@@ -1,26 +1,26 @@
require "test_helper"
class ActionPack::WebAuthn::Authenticator::AttestationTest < ActiveSupport::TestCase
setup do
@rp_id_hash = Digest::SHA256.digest("example.com")
@sign_count = 42
@aaguid = SecureRandom.random_bytes(16)
@credential_id = SecureRandom.random_bytes(32)
# Attestation object: {"fmt": "none", "attStmt": {}, "authData": <164 bytes>}
# Auth data contains:
# rp_id_hash: SHA-256("example.com") (32 bytes)
# flags: 0x41 (user present + attested credential)
# sign_count: 42
# aaguid: 00010203-0405-0607-0809-0a0b0c0d0e0f (16 bytes)
# credential_id: 32 sequential bytes 0x00..0x1f
# cose_key: EC2/ES256 P-256 {1: 2, 3: -7, -1: 1, -2: <x>, -3: <y>}
ATTESTATION_CBOR = [ "a363666d74646e6f6e656761747453746d74a068617574684461746158a4a3" \
"79a6f6eeafb9a55e378c118034e2751e682fab9f2d30ab13d2125586ce1947410000002a" \
"000102030405060708090a0b0c0d0e0f0020000102030405060708090a0b0c0d0e0f1011" \
"12131415161718191a1b1c1d1e1fa50102032620012158202ba472104c686f39d4b623cc" \
"9324953e7053b47cae818e8cf774203a4f51af7122582069cb8ac519bdd929e2bdbe79e9" \
"f9b8d14c2d89a7cbd324647a1ccd68b8de3ca0" ].pack("H*")
# Generate a real EC key
ec_key = OpenSSL::PKey::EC.generate("prime256v1")
public_key_bn = ec_key.public_key.to_bn
public_key_point = public_key_bn.to_s(2)
x_coord = public_key_point[1, 32]
y_coord = public_key_point[33, 32]
@cose_key = build_cose_key(x_coord, y_coord)
@auth_data = build_authenticator_data
@attestation_object = build_attestation_object
end
CREDENTIAL_ID_BASE64 = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8"
SIGN_COUNT = 42
test "decodes attestation object" do
attestation = ActionPack::WebAuthn::Authenticator::Attestation.decode(@attestation_object)
attestation = ActionPack::WebAuthn::Authenticator::Attestation.decode(ATTESTATION_CBOR)
assert_equal "none", attestation.format
assert_equal({}, attestation.attestation_statement)
@@ -28,108 +28,20 @@ class ActionPack::WebAuthn::Authenticator::AttestationTest < ActiveSupport::Test
end
test "delegates credential_id to authenticator_data" do
attestation = ActionPack::WebAuthn::Authenticator::Attestation.decode(@attestation_object)
attestation = ActionPack::WebAuthn::Authenticator::Attestation.decode(ATTESTATION_CBOR)
assert_equal Base64.urlsafe_encode64(@credential_id, padding: false), attestation.credential_id
assert_equal CREDENTIAL_ID_BASE64, attestation.credential_id
end
test "delegates sign_count to authenticator_data" do
attestation = ActionPack::WebAuthn::Authenticator::Attestation.decode(@attestation_object)
attestation = ActionPack::WebAuthn::Authenticator::Attestation.decode(ATTESTATION_CBOR)
assert_equal @sign_count, attestation.sign_count
assert_equal SIGN_COUNT, attestation.sign_count
end
test "delegates public_key to authenticator_data" do
attestation = ActionPack::WebAuthn::Authenticator::Attestation.decode(@attestation_object)
attestation = ActionPack::WebAuthn::Authenticator::Attestation.decode(ATTESTATION_CBOR)
assert_instance_of OpenSSL::PKey::EC, attestation.public_key
end
private
def build_authenticator_data
bytes = []
bytes.concat(@rp_id_hash.bytes)
bytes << 0x41 # flags: user present + attested credential
bytes.concat([@sign_count].pack("N").bytes)
bytes.concat(@aaguid.bytes)
bytes.concat([@credential_id.bytesize].pack("n").bytes)
bytes.concat(@credential_id.bytes)
bytes.concat(@cose_key.bytes)
bytes.pack("C*")
end
def build_attestation_object
# CBOR map: { "fmt": "none", "attStmt": {}, "authData": <bytes> }
encode_cbor_attestation_object
end
def encode_cbor_attestation_object
bytes = [0xa3] # map with 3 items
# "fmt" => "none"
bytes.concat([0x63, *"fmt".bytes]) # text string "fmt"
bytes.concat([0x64, *"none".bytes]) # text string "none"
# "attStmt" => {}
bytes.concat([0x67, *"attStmt".bytes]) # text string "attStmt"
bytes << 0xa0 # empty map
# "authData" => <bytes>
bytes.concat([0x68, *"authData".bytes]) # text string "authData"
auth_data_length = @auth_data.bytesize
if auth_data_length <= 23
bytes << (0x40 + auth_data_length)
elsif auth_data_length <= 255
bytes.concat([0x58, auth_data_length])
else
bytes.concat([0x59, (auth_data_length >> 8) & 0xff, auth_data_length & 0xff])
end
bytes.concat(@auth_data.bytes)
bytes.pack("C*")
end
def build_cose_key(x, y)
params = {
1 => 2, # kty: EC2
3 => -7, # alg: ES256
-1 => 1, # crv: P-256
-2 => x,
-3 => y
}
encode_cbor_map(params)
end
def encode_cbor_map(hash)
bytes = [0xa0 + hash.size]
hash.each do |key, value|
bytes.concat(encode_cbor_integer(key))
bytes.concat(encode_cbor_value(value))
end
bytes.pack("C*")
end
def encode_cbor_integer(int)
if int >= 0 && int <= 23
[int]
elsif int >= -24 && int < 0
[0x20 - int - 1]
else
raise "Integer encoding not implemented for #{int}"
end
end
def encode_cbor_value(value)
case value
when Integer
encode_cbor_integer(value)
when String
length = value.bytesize
if length <= 23
[0x40 + length, *value.bytes]
else
[0x58, length, *value.bytes]
end
end
end
end
@@ -24,7 +24,7 @@ class ActionPack::WebAuthn::Authenticator::AttestationVerifiers::NoneTest < Acti
test "verify! raises with non-empty attestation statement" do
attestation = stub(attestation_statement: { "sig" => "abc" })
error = assert_raises(ActionPack::WebAuthn::Authenticator::Response::InvalidResponseError) do
error = assert_raises(ActionPack::WebAuthn::InvalidResponseError) do
@verifier.verify!(attestation, client_data_json: "")
end
@@ -1,46 +1,64 @@
require "test_helper"
class ActionPack::WebAuthn::Authenticator::DataTest < ActiveSupport::TestCase
setup do
@rp_id_hash = Digest::SHA256.digest("example.com")
@sign_count = 42
@aaguid = SecureRandom.random_bytes(16)
@credential_id = SecureRandom.random_bytes(32)
# Common values:
# rp_id_hash: SHA-256("example.com") (32 bytes)
# sign_count: 42
# aaguid: 00010203-0405-0607-0809-0a0b0c0d0e0f (16 bytes)
# credential_id: 32 sequential bytes 0x00..0x1f
# cose_key: EC2/ES256 P-256 {1: 2, 3: -7, -1: 1, -2: <x>, -3: <y>}
# Generate a real EC key for COSE encoding
ec_key = OpenSSL::PKey::EC.generate("prime256v1")
public_key_bn = ec_key.public_key.to_bn
public_key_point = public_key_bn.to_s(2)
x_coord = public_key_point[1, 32]
y_coord = public_key_point[33, 32]
RP_ID_HASH = [ "a379a6f6eeafb9a55e378c118034e2751e682fab9f2d30ab13d2125586ce1947" ].pack("H*")
SIGN_COUNT = 42
CREDENTIAL_ID_BASE64 = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8"
@cose_key = build_cose_key(x_coord, y_coord)
end
COSE_KEY_CBOR = [ "a50102032620012158202ba472104c686f39d4b623cc9324953e7053b47cae81" \
"8e8cf774203a4f51af7122582069cb8ac519bdd929e2bdbe79e9f9b8d14c2d89a7cbd324" \
"647a1ccd68b8de3ca0" ].pack("H*")
# rp_id_hash(32) + flags 0x01 (UP) + sign_count 42
AUTH_DATA_NO_CREDENTIAL = [ "a379a6f6eeafb9a55e378c118034e2751e682fab9f2d30ab13d2" \
"125586ce1947010000002a" ].pack("H*")
# rp_id_hash(32) + flags 0x41 (UP+AT) + sign_count 42 + aaguid(16) +
# credential_id_len 32 (2 bytes) + credential_id(32) + cose_key CBOR
AUTH_DATA_WITH_CREDENTIAL = [ "a379a6f6eeafb9a55e378c118034e2751e682fab9f2d30ab13" \
"d2125586ce1947410000002a000102030405060708090a0b0c0d0e0f0020000102030405" \
"060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1fa5010203262001215820" \
"2ba472104c686f39d4b623cc9324953e7053b47cae818e8cf774203a4f51af7122582069" \
"cb8ac519bdd929e2bdbe79e9f9b8d14c2d89a7cbd324647a1ccd68b8de3ca0" ].pack("H*")
# Error test data: flags 0x41 (AT set) but no attested credential data after header
# rp_id_hash(32) + flags 0x41 + sign_count 42
AUTH_DATA_AT_FLAG_NO_CREDENTIAL = [ "a379a6f6eeafb9a55e378c118034e2751e682fab9f2d30" \
"ab13d2125586ce1947410000002a" ].pack("H*")
# rp_id_hash(32) + flags 0x41 + sign_count 0 + aaguid(16), missing credential_id_len
AUTH_DATA_TRUNCATED_BEFORE_CRED_LEN = [ "a379a6f6eeafb9a55e378c118034e2751e682fab9f" \
"2d30ab13d2125586ce19474100000000000102030405060708090a0b0c0d0e0f" ].pack("H*")
# rp_id_hash(32) + flags 0x41 + sign_count 0 + aaguid(16) + credential_id_len 9999
AUTH_DATA_HUGE_CRED_LEN = [ "a379a6f6eeafb9a55e378c118034e2751e682fab9f2d30ab13d2" \
"125586ce19474100000000000102030405060708090a0b0c0d0e0f270f" ].pack("H*")
test "decodes authenticator data without attested credential" do
flags = 0x01 # user present only
bytes = build_authenticator_data(flags: flags, include_credential: false)
data = ActionPack::WebAuthn::Authenticator::Data.decode(AUTH_DATA_NO_CREDENTIAL)
data = ActionPack::WebAuthn::Authenticator::Data.decode(bytes)
assert_equal @rp_id_hash, data.relying_party_id_hash
assert_equal flags, data.flags
assert_equal @sign_count, data.sign_count
assert_equal RP_ID_HASH, data.relying_party_id_hash
assert_equal 0x01, data.flags
assert_equal SIGN_COUNT, data.sign_count
assert_nil data.credential_id
assert_nil data.public_key_bytes
end
test "decodes authenticator data with attested credential" do
flags = 0x41 # user present + attested credential data
bytes = build_authenticator_data(flags: flags, include_credential: true)
data = ActionPack::WebAuthn::Authenticator::Data.decode(AUTH_DATA_WITH_CREDENTIAL)
data = ActionPack::WebAuthn::Authenticator::Data.decode(bytes)
assert_equal @rp_id_hash, data.relying_party_id_hash
assert_equal flags, data.flags
assert_equal @sign_count, data.sign_count
assert_equal Base64.urlsafe_encode64(@credential_id, padding: false), data.credential_id
assert_equal @cose_key, data.public_key_bytes
assert_equal RP_ID_HASH, data.relying_party_id_hash
assert_equal 0x41, data.flags
assert_equal SIGN_COUNT, data.sign_count
assert_equal CREDENTIAL_ID_BASE64, data.credential_id
assert_equal COSE_KEY_CBOR, data.public_key_bytes
end
test "user_present? returns true when flag is set" do
@@ -84,91 +102,44 @@ class ActionPack::WebAuthn::Authenticator::DataTest < ActiveSupport::TestCase
end
test "public_key returns OpenSSL key when public_key_bytes present" do
flags = 0x41
bytes = build_authenticator_data(flags: flags, include_credential: true)
data = ActionPack::WebAuthn::Authenticator::Data.decode(bytes)
data = ActionPack::WebAuthn::Authenticator::Data.decode(AUTH_DATA_WITH_CREDENTIAL)
assert_instance_of OpenSSL::PKey::EC, data.public_key
end
test "public_key returns nil when public_key_bytes not present" do
flags = 0x01
bytes = build_authenticator_data(flags: flags, include_credential: false)
data = ActionPack::WebAuthn::Authenticator::Data.decode(bytes)
data = ActionPack::WebAuthn::Authenticator::Data.decode(AUTH_DATA_NO_CREDENTIAL)
assert_nil data.public_key
end
private
def build_authenticator_data(flags:, include_credential:)
bytes = []
bytes.concat(@rp_id_hash.bytes)
bytes << flags
bytes.concat([ @sign_count ].pack("N").bytes)
if include_credential
bytes.concat(@aaguid.bytes)
bytes.concat([ @credential_id.bytesize ].pack("n").bytes)
bytes.concat(@credential_id.bytes)
bytes.concat(@cose_key.bytes)
end
bytes.pack("C*")
test "raises when attested credential flag set but data truncated before AAGUID" do
assert_raises(ActionPack::WebAuthn::InvalidResponseError) do
ActionPack::WebAuthn::Authenticator::Data.decode(AUTH_DATA_AT_FLAG_NO_CREDENTIAL)
end
end
test "raises when attested credential flag set but data truncated before credential ID" do
assert_raises(ActionPack::WebAuthn::InvalidResponseError) do
ActionPack::WebAuthn::Authenticator::Data.decode(AUTH_DATA_TRUNCATED_BEFORE_CRED_LEN)
end
end
test "raises when credential ID length exceeds remaining bytes" do
assert_raises(ActionPack::WebAuthn::InvalidResponseError) do
ActionPack::WebAuthn::Authenticator::Data.decode(AUTH_DATA_HUGE_CRED_LEN)
end
end
private
def build_data_with_flags(flags)
ActionPack::WebAuthn::Authenticator::Data.new(
bytes: [],
relying_party_id_hash: @rp_id_hash,
relying_party_id_hash: RP_ID_HASH,
flags: flags,
sign_count: 0,
credential_id: nil,
public_key_bytes: nil
)
end
def build_cose_key(x, y)
# Simple CBOR map for EC2 key
params = {
1 => 2, # kty: EC2
3 => -7, # alg: ES256
-1 => 1, # crv: P-256
-2 => x,
-3 => y
}
encode_cbor_map(params)
end
def encode_cbor_map(hash)
bytes = [ 0xa0 + hash.size ]
hash.each do |key, value|
bytes.concat(encode_cbor_integer(key))
bytes.concat(encode_cbor_value(value))
end
bytes.pack("C*")
end
def encode_cbor_integer(int)
if int >= 0 && int <= 23
[ int ]
elsif int >= -24 && int < 0
[ 0x20 - int - 1 ]
else
raise "Integer encoding not implemented for #{int}"
end
end
def encode_cbor_value(value)
case value
when Integer
encode_cbor_integer(value)
when String
length = value.bytesize
if length <= 23
[ 0x40 + length, *value.bytes ]
else
[ 0x58, length, *value.bytes ]
end
end
end
end
@@ -1,10 +1,12 @@
require "test_helper"
class ActionPack::WebAuthn::Authenticator::ResponseTest < ActiveSupport::TestCase
include WebauthnTestHelper
setup do
ActionPack::WebAuthn::Current.host = "example.com"
@challenge = "test-challenge-123"
@challenge = webauthn_challenge
@origin = "https://example.com"
@client_data_json = {
challenge: @challenge,
@@ -15,7 +17,9 @@ class ActionPack::WebAuthn::Authenticator::ResponseTest < ActiveSupport::TestCas
@authenticator_data = build_authenticator_data
@response = TestableResponse.new(
client_data_json: @client_data_json,
authenticator_data: @authenticator_data
authenticator_data: @authenticator_data,
challenge: @challenge,
origin: @origin
)
end
@@ -34,28 +38,34 @@ class ActionPack::WebAuthn::Authenticator::ResponseTest < ActiveSupport::TestCas
end
test "valid? returns true when challenge and origin match" do
assert @response.valid?(challenge: @challenge, origin: @origin)
assert @response.valid?
end
test "valid? returns false when challenge does not match" do
assert_not @response.valid?(challenge: "wrong-challenge", origin: @origin)
@response.challenge = "wrong-challenge"
assert_not @response.valid?
end
test "valid? returns false when origin does not match" do
assert_not @response.valid?(challenge: @challenge, origin: "https://evil.com")
@response.origin = "https://evil.com"
assert_not @response.valid?
end
test "validate! raises when challenge does not match" do
error = assert_raises(ActionPack::WebAuthn::Authenticator::Response::InvalidResponseError) do
@response.validate!(challenge: "wrong-challenge", origin: @origin)
@response.challenge = "wrong-challenge"
error = assert_raises(ActionPack::WebAuthn::InvalidResponseError) do
@response.validate!
end
assert_equal "Challenge does not match", error.message
end
test "validate! raises when origin does not match" do
error = assert_raises(ActionPack::WebAuthn::Authenticator::Response::InvalidResponseError) do
@response.validate!(challenge: @challenge, origin: "https://evil.com")
@response.origin = "https://evil.com"
error = assert_raises(ActionPack::WebAuthn::InvalidResponseError) do
@response.validate!
end
assert_equal "Origin does not match", error.message
@@ -71,11 +81,13 @@ class ActionPack::WebAuthn::Authenticator::ResponseTest < ActiveSupport::TestCas
response = TestableResponse.new(
client_data_json: client_data_json,
authenticator_data: @authenticator_data
authenticator_data: @authenticator_data,
challenge: @challenge,
origin: @origin
)
error = assert_raises(ActionPack::WebAuthn::Authenticator::Response::InvalidResponseError) do
response.validate!(challenge: @challenge, origin: @origin)
error = assert_raises(ActionPack::WebAuthn::InvalidResponseError) do
response.validate!
end
assert_equal "Cross-origin requests are not supported", error.message
@@ -89,17 +101,19 @@ class ActionPack::WebAuthn::Authenticator::ResponseTest < ActiveSupport::TestCas
bytes = []
bytes.concat(rp_id_hash.bytes)
bytes << flags
bytes.concat([sign_count].pack("N").bytes)
bytes.concat([ sign_count ].pack("N").bytes)
wrong_rp_data = ActionPack::WebAuthn::Authenticator::Data.decode(bytes.pack("C*"))
response = TestableResponse.new(
client_data_json: @client_data_json,
authenticator_data: wrong_rp_data
authenticator_data: wrong_rp_data,
challenge: @challenge,
origin: @origin
)
error = assert_raises(ActionPack::WebAuthn::Authenticator::Response::InvalidResponseError) do
response.validate!(challenge: @challenge, origin: @origin)
error = assert_raises(ActionPack::WebAuthn::InvalidResponseError) do
response.validate!
end
assert_equal "Relying party ID does not match", error.message
@@ -115,11 +129,13 @@ class ActionPack::WebAuthn::Authenticator::ResponseTest < ActiveSupport::TestCas
response = TestableResponse.new(
client_data_json: client_data_json,
authenticator_data: @authenticator_data
authenticator_data: @authenticator_data,
challenge: @challenge,
origin: @origin
)
error = assert_raises(ActionPack::WebAuthn::Authenticator::Response::InvalidResponseError) do
response.validate!(challenge: @challenge, origin: @origin)
error = assert_raises(ActionPack::WebAuthn::InvalidResponseError) do
response.validate!
end
assert_equal "Token binding is not supported", error.message
@@ -134,7 +150,7 @@ class ActionPack::WebAuthn::Authenticator::ResponseTest < ActiveSupport::TestCas
bytes = []
bytes.concat(rp_id_hash.bytes)
bytes << flags
bytes.concat([sign_count].pack("N").bytes)
bytes.concat([ sign_count ].pack("N").bytes)
ActionPack::WebAuthn::Authenticator::Data.decode(bytes.pack("C*"))
end
@@ -152,7 +152,7 @@ class ActionPack::WebAuthn::CborDecoderTest < ActiveSupport::TestCase
end
test "raises error for reserved additional info values" do
assert_raises(ActionPack::WebAuthn::CborDecoder::DecodeError) do
assert_raises(ActionPack::WebAuthn::InvalidCborError) do
decode("1c")
end
end
@@ -165,6 +165,10 @@ class ActionPack::WebAuthn::CborDecoderTest < ActiveSupport::TestCase
assert_equal [], decode("9fff")
end
test "decodes empty indefinite length map" do
assert_equal({}, decode("bfff"))
end
test "decodes indefinite length map" do
assert_equal({ "a" => 1, "b" => 2 }, decode("bf616101616202ff"))
end
@@ -228,7 +232,7 @@ class ActionPack::WebAuthn::CborDecoderTest < ActiveSupport::TestCase
end
test "raises error for unsupported simple value" do
assert_raises(ActionPack::WebAuthn::CborDecoder::DecodeError) do
assert_raises(ActionPack::WebAuthn::InvalidCborError) do
decode("e0")
end
end
@@ -243,28 +247,28 @@ class ActionPack::WebAuthn::CborDecoderTest < ActiveSupport::TestCase
end
test "raises error for empty input" do
assert_raises(ActionPack::WebAuthn::CborDecoder::DecodeError) do
assert_raises(ActionPack::WebAuthn::InvalidCborError) do
ActionPack::WebAuthn::CborDecoder.decode([])
end
end
test "raises error for truncated byte string" do
# 0x44 = byte string of length 4, but only 2 bytes follow
assert_raises(ActionPack::WebAuthn::CborDecoder::DecodeError) do
assert_raises(ActionPack::WebAuthn::InvalidCborError) do
decode("440102")
end
end
test "raises error for truncated integer" do
# 0x19 = 2-byte integer follows, but only 1 byte provided
assert_raises(ActionPack::WebAuthn::CborDecoder::DecodeError) do
assert_raises(ActionPack::WebAuthn::InvalidCborError) do
decode("19ff")
end
end
test "raises error for truncated array" do
# 0x82 = array of 2 items, but only 1 provided
assert_raises(ActionPack::WebAuthn::CborDecoder::DecodeError) do
assert_raises(ActionPack::WebAuthn::InvalidCborError) do
decode("8201")
end
end
@@ -274,7 +278,7 @@ class ActionPack::WebAuthn::CborDecoderTest < ActiveSupport::TestCase
# 0x81 = array of 1 item
deeply_nested = "81" * 20 + "01"
error = assert_raises(ActionPack::WebAuthn::CborDecoder::DecodeError) do
error = assert_raises(ActionPack::WebAuthn::InvalidCborError) do
decode(deeply_nested)
end
@@ -282,7 +286,7 @@ class ActionPack::WebAuthn::CborDecoderTest < ActiveSupport::TestCase
end
test "raises error for input exceeding max size" do
error = assert_raises(ActionPack::WebAuthn::CborDecoder::DecodeError) do
error = assert_raises(ActionPack::WebAuthn::InvalidCborError) do
ActionPack::WebAuthn::CborDecoder.decode([ 0x01 ], max_size: 0)
end
+155 -61
View File
@@ -1,33 +1,64 @@
require "test_helper"
class ActionPack::WebAuthn::CoseKeyTest < ActiveSupport::TestCase
setup do
# Generate a real EC key for valid test data
ec_key = OpenSSL::PKey::EC.generate("prime256v1")
public_key_bn = ec_key.public_key.to_bn
public_key_bytes = public_key_bn.to_s(2)
# Skip the 0x04 uncompressed point prefix
@ec2_x = public_key_bytes[1, 32]
@ec2_y = public_key_bytes[33, 32]
# EC2/ES256 P-256 public key (32-byte x and y coordinates)
EC2_X = [ "2ba472104c686f39d4b623cc9324953e7053b47cae818e8cf774203a4f51af71" ].pack("H*")
EC2_Y = [ "69cb8ac519bdd929e2bdbe79e9f9b8d14c2d89a7cbd324647a1ccd68b8de3ca0" ].pack("H*")
# CBOR: {1: 2, 3: -7, -1: 1, -2: <x 32 bytes>, -3: <y 32 bytes>}
EC2_CBOR = [ "a50102032620012158202ba472104c686f39d4b623cc9324953e7053b47cae81" \
"8e8cf774203a4f51af7122582069cb8ac519bdd929e2bdbe79e9f9b8d14c2d89a7cbd324" \
"647a1ccd68b8de3ca0" ].pack("H*")
# Ed25519 public key (32 bytes)
ED25519_X = [ "a95ee02872a2c5224b394832767bea746620e50776e845872228716065f16005" ].pack("H*")
# CBOR: {1: 1, 3: -8, -1: 6, -2: <x 32 bytes>}
OKP_CBOR = [ "a4010103272006215820a95ee02872a2c5224b394832767bea746620e50776e8" \
"45872228716065f16005" ].pack("H*")
# RSA 2048-bit public key (256-byte modulus, 3-byte exponent 65537)
RSA_N = [ "d388adb3aa7812402281c57ce870821b17558f0a247a771834892d85399ecd4f" \
"830dd35f65e7afe5030d9ee10f4873567039976486202cce8ac499114194d32fe615026e" \
"7eeee5b2ff564041d68b9b33c35a2ac17210c69c9e85fa74249b06e4ffa6b38ff5ef54e" \
"1860aa59a6fb043e2b65ecf0ce8d0ff90d25683ca2da016618308f3fa7f74efc178ec46" \
"e0224f10cf0eed7d46cc6167210f088cc6b77fc08a7fcd14536aa9c726519806a96ea00" \
"517ce1ed1336ae6962338a6c4cc4754d953ebbffb5d6b1bc76368b552b628adb788b0bc" \
"9f895dff6b1c74d79ce210b5941995beb1f498a1e9123666bdc92bc6b0f2a04fdb40cf1" \
"d253ba1582673ec293113" ].pack("H*")
RSA_E = [ "010001" ].pack("H*")
# CBOR: {1: 3, 3: -257, -1: <n 256 bytes>, -2: <e 3 bytes>}
RSA_CBOR = [ "a401030339010020590100d388adb3aa7812402281c57ce870821b17558f0a24" \
"7a771834892d85399ecd4f830dd35f65e7afe5030d9ee10f4873567039976486202cce8a" \
"c499114194d32fe615026e7eeee5b2ff564041d68b9b33c35a2ac17210c69c9e85fa742" \
"49b06e4ffa6b38ff5ef54e1860aa59a6fb043e2b65ecf0ce8d0ff90d25683ca2da01661" \
"8308f3fa7f74efc178ec46e0224f10cf0eed7d46cc6167210f088cc6b77fc08a7fcd145" \
"36aa9c726519806a96ea00517ce1ed1336ae6962338a6c4cc4754d953ebbffb5d6b1bc7" \
"6368b552b628adb788b0bc9f895dff6b1c74d79ce210b5941995beb1f498a1e9123666b" \
"dc92bc6b0f2a04fdb40cf1d253ba1582673ec2931132143010001" ].pack("H*")
setup do
@ec2_parameters = {
1 => 2, # kty: EC2
3 => -7, # alg: ES256
-1 => 1, # crv: P-256
-2 => @ec2_x,
-3 => @ec2_y
-2 => EC2_X,
-3 => EC2_Y
}
# Generate a real RSA key for valid test data
rsa_key = OpenSSL::PKey::RSA.new(2048)
@rsa_n = rsa_key.n.to_s(2)
@rsa_e = rsa_key.e.to_s(2)
@rsa_parameters = {
1 => 3, # kty: RSA
3 => -257, # alg: RS256
-1 => @rsa_n,
-2 => @rsa_e
-1 => RSA_N,
-2 => RSA_E
}
@okp_parameters = {
1 => 1, # kty: OKP
3 => -8, # alg: EdDSA
-1 => 6, # crv: Ed25519
-2 => ED25519_X
}
end
@@ -44,16 +75,21 @@ class ActionPack::WebAuthn::CoseKeyTest < ActiveSupport::TestCase
end
test "decodes EC2/ES256 key from CBOR" do
cbor = encode_cbor(@ec2_parameters)
key = ActionPack::WebAuthn::CoseKey.decode(cbor)
key = ActionPack::WebAuthn::CoseKey.decode(EC2_CBOR)
assert_equal 2, key.key_type
assert_equal(-7, key.algorithm)
end
test "decodes OKP/EdDSA key from CBOR" do
key = ActionPack::WebAuthn::CoseKey.decode(OKP_CBOR)
assert_equal 1, key.key_type
assert_equal(-8, key.algorithm)
end
test "decodes RSA/RS256 key from CBOR" do
cbor = encode_cbor(@rsa_parameters)
key = ActionPack::WebAuthn::CoseKey.decode(cbor)
key = ActionPack::WebAuthn::CoseKey.decode(RSA_CBOR)
assert_equal 3, key.key_type
assert_equal(-257, key.algorithm)
@@ -72,6 +108,18 @@ class ActionPack::WebAuthn::CoseKeyTest < ActiveSupport::TestCase
assert_equal "prime256v1", openssl_key.group.curve_name
end
test "converts OKP/EdDSA key to OpenSSL Ed25519 key" do
key = ActionPack::WebAuthn::CoseKey.new(
key_type: 1,
algorithm: -8,
parameters: @okp_parameters
)
openssl_key = key.to_openssl_key
assert_equal "ED25519", openssl_key.oid
end
test "converts RSA/RS256 key to OpenSSL RSA key" do
key = ActionPack::WebAuthn::CoseKey.new(
key_type: 3,
@@ -92,13 +140,28 @@ class ActionPack::WebAuthn::CoseKeyTest < ActiveSupport::TestCase
parameters: {}
)
error = assert_raises(ActionPack::WebAuthn::CoseKey::UnsupportedKeyTypeError) do
error = assert_raises(ActionPack::WebAuthn::UnsupportedKeyTypeError) do
key.to_openssl_key
end
assert_match(/99\/-7/, error.message)
end
test "raises error for unsupported OKP curve" do
parameters = @okp_parameters.merge(-1 => 5) # Ed448 instead of Ed25519
key = ActionPack::WebAuthn::CoseKey.new(
key_type: 1,
algorithm: -8,
parameters: parameters
)
error = assert_raises(ActionPack::WebAuthn::UnsupportedKeyTypeError) do
key.to_openssl_key
end
assert_match(/curve/, error.message.downcase)
end
test "raises error for unsupported EC curve" do
parameters = @ec2_parameters.merge(-1 => 2) # P-384 instead of P-256
key = ActionPack::WebAuthn::CoseKey.new(
@@ -107,55 +170,86 @@ class ActionPack::WebAuthn::CoseKeyTest < ActiveSupport::TestCase
parameters: parameters
)
error = assert_raises(ActionPack::WebAuthn::CoseKey::UnsupportedKeyTypeError) do
error = assert_raises(ActionPack::WebAuthn::UnsupportedKeyTypeError) do
key.to_openssl_key
end
assert_match(/curve/, error.message.downcase)
end
private
def encode_cbor(hash)
# CBOR map encoding
bytes = [ 0xa0 + hash.size ] # map with n items
test "raises error for EC2 key with missing coordinates" do
parameters = @ec2_parameters.except(-3) # missing y coordinate
key = ActionPack::WebAuthn::CoseKey.new(
key_type: 2,
algorithm: -7,
parameters: parameters
)
hash.each do |key, value|
bytes.concat(encode_cbor_integer(key))
bytes.concat(encode_cbor_value(value))
end
bytes.pack("C*")
error = assert_raises(ActionPack::WebAuthn::InvalidKeyError) do
key.to_openssl_key
end
def encode_cbor_integer(int)
if int >= 0 && int <= 23
[ int ]
elsif int >= 0 && int <= 255
[ 0x18, int ]
elsif int >= -24 && int < 0
[ 0x20 - int - 1 ]
elsif int >= -256 && int < -24
[ 0x38, -int - 1 ]
else
# 16-bit negative integer
val = -int - 1
[ 0x39, (val >> 8) & 0xff, val & 0xff ]
end
assert_match(/missing ec2 key coordinates/i, error.message)
end
test "raises error for EC2 key with wrong coordinate length" do
parameters = @ec2_parameters.merge(-2 => "\x00" * 16) # 16 bytes instead of 32
key = ActionPack::WebAuthn::CoseKey.new(
key_type: 2,
algorithm: -7,
parameters: parameters
)
error = assert_raises(ActionPack::WebAuthn::InvalidKeyError) do
key.to_openssl_key
end
def encode_cbor_value(value)
case value
when Integer
encode_cbor_integer(value)
when String
length = value.bytesize
if length <= 23
[ 0x40 + length, *value.bytes ]
elsif length <= 255
[ 0x58, length, *value.bytes ]
else
[ 0x59, (length >> 8) & 0xff, length & 0xff, *value.bytes ]
end
end
assert_match(/invalid ec2 coordinate length/i, error.message)
end
test "raises error for OKP key with missing coordinate" do
parameters = @okp_parameters.except(-2) # missing x coordinate
key = ActionPack::WebAuthn::CoseKey.new(
key_type: 1,
algorithm: -8,
parameters: parameters
)
error = assert_raises(ActionPack::WebAuthn::InvalidKeyError) do
key.to_openssl_key
end
assert_match(/missing okp key coordinate/i, error.message)
end
test "raises error for RSA key with missing parameters" do
parameters = @rsa_parameters.except(-1) # missing n
key = ActionPack::WebAuthn::CoseKey.new(
key_type: 3,
algorithm: -257,
parameters: parameters
)
error = assert_raises(ActionPack::WebAuthn::InvalidKeyError) do
key.to_openssl_key
end
assert_match(/missing rsa key parameters/i, error.message)
end
test "raises error for RSA key smaller than 2048 bits" do
small_n = "\x01" + ("\x00" * 127) # 1024-bit modulus
parameters = @rsa_parameters.merge(-1 => small_n)
key = ActionPack::WebAuthn::CoseKey.new(
key_type: 3,
algorithm: -257,
parameters: parameters
)
error = assert_raises(ActionPack::WebAuthn::InvalidKeyError) do
key.to_openssl_key
end
assert_match(/at least 2048 bits/i, error.message)
end
end
@@ -22,9 +22,12 @@ class ActionPack::WebAuthn::PublicKeyCredential::CreationOptionsTest < ActiveSup
assert_match(/\A[A-Za-z0-9_-]+\z/, @options.challenge)
end
test "generates challenge of correct length" do
decoded = Base64.urlsafe_decode64(@options.challenge)
assert_equal 32, decoded.bytesize
test "generates signed challenge containing nonce" do
signed_message = Base64.urlsafe_decode64(@options.challenge)
nonce = ActionPack::WebAuthn.challenge_verifier.verified(signed_message)
assert_not_nil nonce
assert_equal 32, Base64.strict_decode64(nonce).bytesize
end
test "as_json" do
@@ -39,6 +42,7 @@ class ActionPack::WebAuthn::PublicKeyCredential::CreationOptionsTest < ActiveSup
assert_equal [
{ type: "public-key", alg: -7 },
{ type: "public-key", alg: -8 },
{ type: "public-key", alg: -257 }
], @options.as_json[:pubKeyCredParams]
@@ -101,7 +105,7 @@ class ActionPack::WebAuthn::PublicKeyCredential::CreationOptionsTest < ActiveSup
end
test "raises with invalid attestation preference" do
assert_raises(ArgumentError) do
assert_raises(ActionPack::WebAuthn::InvalidOptionsError) do
ActionPack::WebAuthn::PublicKeyCredential::CreationOptions.new(
id: "user-123",
name: "user@example.com",
@@ -22,9 +22,12 @@ class ActionPack::WebAuthn::PublicKeyCredential::RequestOptionsTest < ActiveSupp
assert_match(/\A[A-Za-z0-9_-]+\z/, @options.challenge)
end
test "generates challenge of correct length" do
decoded = Base64.urlsafe_decode64(@options.challenge)
assert_equal 32, decoded.bytesize
test "generates signed challenge containing nonce" do
signed_message = Base64.urlsafe_decode64(@options.challenge)
nonce = ActionPack::WebAuthn.challenge_verifier.verified(signed_message)
assert_not_nil nonce
assert_equal 32, Base64.strict_decode64(nonce).bytesize
end
test "as_json" do
@@ -0,0 +1,32 @@
require "test_helper"
class ActionPackPasskeyInferNameFromAaguidTest < ActiveSupport::TestCase
setup do
@identity = identities(:kevin)
@private_key = OpenSSL::PKey::EC.generate("prime256v1")
ActionPack::WebAuthn::Current.host = "www.example.com"
ActionPack::WebAuthn::Current.origin = "http://www.example.com"
end
test "authenticator lookup by known aaguid" do
authenticator = Passkey::Authenticator.find_by_aaguid("dd4ec289-e01d-41c9-bb89-70fa845d4bf2")
assert_equal "Apple Passwords", authenticator.name
end
test "authenticator lookup returns nil for unknown aaguid" do
assert_nil Passkey::Authenticator.find_by_aaguid("00000000-0000-0000-0000-000000000000")
end
test "authenticator lookup by aaguid on passkey" do
passkey = @identity.passkeys.create!(
credential_id: Base64.urlsafe_encode64(SecureRandom.random_bytes(32), padding: false),
public_key: @private_key.public_to_der,
sign_count: 0,
aaguid: "dd4ec289-e01d-41c9-bb89-70fa845d4bf2"
)
assert_equal "Apple Passwords", passkey.authenticator.name
end
end
@@ -1,6 +1,6 @@
class MagicLinkMailerPreview < ActionMailer::Preview
def magic_link
identity = Identity.new email_address: "test@example.com"
identity = Identity.all.sample
magic_link = MagicLink.new(identity: identity)
magic_link.valid?
+94
View File
@@ -0,0 +1,94 @@
module WebauthnTestHelper
# Fixed EC P-256 key pair for WebAuthn tests.
WEBAUTHN_PRIVATE_KEY = OpenSSL::PKey::EC.new(
[ "307702010104201dd589de7210b3318620f32150e3012cce021519df1d6e9e01" \
"0471146d395cdca00a06082a8648ce3d030107a14403420004116847fe19e1ad" \
"4471ab9980d7ff9cc1e4c7cb7a3af00e082b64fcd84f5ae70114c2495eef16f" \
"542b5e57dd1b263661624e3cf28f581b57a441edbd756a41d0e" ].pack("H*")
)
# Pre-encoded COSE EC2/ES256 public key (CBOR) for the key above.
# {1: 2, 3: -7, -1: 1, -2: <x 32 bytes>, -3: <y 32 bytes>}
COSE_PUBLIC_KEY = [ "a5010203262001215820116847fe19e1ad4471ab9980d7ff9cc1" \
"e4c7cb7a3af00e082b64fcd84f5ae70122582014c2495eef16f542b5e57dd1b2" \
"63661624e3cf28f581b57a441edbd756a41d0e" ].pack("H*")
# CBOR prefix for {"fmt": "none", "attStmt": {}, "authData": bytes(164)}.
# Auth data is always 164 bytes: rp_id_hash(32) + flags(1) + sign_count(4)
# + aaguid(16) + credential_id_length(2) + credential_id(32) + cose_key(77).
ATTESTATION_OBJECT_CBOR_PREFIX =
[ "a363666d74646e6f6e656761747453746d74a068617574684461746158a4" ].pack("H*")
private
def request_webauthn_challenge
untenanted { post my_passkey_challenge_url }
response.parsed_body["challenge"]
end
def webauthn_challenge
ActionPack::WebAuthn::PublicKeyCredential::Options.new.challenge
end
def webauthn_private_key
WEBAUTHN_PRIVATE_KEY
end
def build_attestation_params(challenge:)
credential_id = SecureRandom.random_bytes(32)
auth_data = build_attestation_auth_data(credential_id: credential_id)
{
passkey: {
client_data_json: webauthn_client_data_json(challenge: challenge, type: "webauthn.create"),
attestation_object: Base64.urlsafe_encode64(ATTESTATION_OBJECT_CBOR_PREFIX + auth_data, padding: false),
transports: [ "internal" ]
}
}
end
def build_assertion_params(challenge:, credential:, sign_count: 1)
client_data_json = webauthn_client_data_json(challenge: challenge, type: "webauthn.get")
authenticator_data = build_assertion_auth_data(sign_count: sign_count)
signature = webauthn_sign(authenticator_data, client_data_json)
{
passkey: {
id: credential.credential_id,
client_data_json: client_data_json,
authenticator_data: Base64.urlsafe_encode64(authenticator_data, padding: false),
signature: Base64.urlsafe_encode64(signature, padding: false)
}
}
end
def webauthn_client_data_json(challenge:, type:)
{ challenge: challenge, origin: "http://www.example.com", type: type }.to_json
end
# Attestation auth data includes the attested credential (public key + credential ID).
def build_attestation_auth_data(credential_id:)
[
Digest::SHA256.digest("www.example.com"), # rp_id_hash
[ 0x45 ].pack("C"), # flags: UP + UV + AT
[ 0 ].pack("N"), # sign_count
"\x00" * 16, # aaguid
[ credential_id.bytesize ].pack("n"), # credential_id_length
credential_id,
COSE_PUBLIC_KEY
].join.b
end
# Assertion auth data is simpler — no attested credential.
def build_assertion_auth_data(sign_count:)
[
Digest::SHA256.digest("www.example.com"),
[ 0x05 ].pack("C"), # flags: UP + UV
[ sign_count ].pack("N")
].join.b
end
def webauthn_sign(authenticator_data, client_data_json)
signed_data = authenticator_data + Digest::SHA256.digest(client_data_json)
WEBAUTHN_PRIVATE_KEY.sign("SHA256", signed_data)
end
end