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:
@@ -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
|
||||
@@ -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."
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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(/=+$/, "")
|
||||
}
|
||||
@@ -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(/=+$/, "")
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
Rails.application.config.to_prepare do
|
||||
ActionPack::Passkey.prepend ActionPackPasskeyInferNameFromAaguid
|
||||
end
|
||||
+2
-2
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 @@
|
||||
class ActionPack::WebAuthn::PublicKeyCredential::Options
|
||||
CHALLENGE_LENGTH = 32
|
||||
USER_VERIFICATION_OPTIONS = [ :required, :preferred, :discouraged ].freeze
|
||||
|
||||
attr_reader :user_verification, :relying_party
|
||||
|
||||
def initialize(user_verification: :preferred, relying_party: ActionPack::WebAuthn.relying_party)
|
||||
@user_verification = user_verification.to_sym
|
||||
@relying_party = relying_party
|
||||
|
||||
unless USER_VERIFICATION_OPTIONS.include?(user_verification)
|
||||
raise ArgumentError, "Invalid user verification option: #{user_verification.inspect}"
|
||||
end
|
||||
end
|
||||
|
||||
# Returns a Base64URL-encoded random challenge. The challenge is generated
|
||||
# once and memoized for the lifetime of this object.
|
||||
# = Action Pack WebAuthn Public Key Credential Options
|
||||
#
|
||||
# The challenge must be stored server-side and verified when the client
|
||||
# responds, to prevent replay attacks.
|
||||
# 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 = %i[ required preferred discouraged ].freeze
|
||||
|
||||
attribute :user_verification, default: :preferred
|
||||
attribute :relying_party, default: -> { ActionPack::WebAuthn.relying_party }
|
||||
attribute :challenge_expiration
|
||||
|
||||
validates :user_verification, inclusion: { in: USER_VERIFICATION_OPTIONS }
|
||||
|
||||
def initialize(attributes = {})
|
||||
super
|
||||
self.user_verification = user_verification.to_sym
|
||||
end
|
||||
|
||||
# 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 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
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_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*")
|
||||
end
|
||||
|
||||
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
|
||||
@@ -95,11 +107,13 @@ class ActionPack::WebAuthn::Authenticator::ResponseTest < ActiveSupport::TestCas
|
||||
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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))
|
||||
error = assert_raises(ActionPack::WebAuthn::InvalidKeyError) do
|
||||
key.to_openssl_key
|
||||
end
|
||||
|
||||
bytes.pack("C*")
|
||||
assert_match(/missing ec2 key coordinates/i, error.message)
|
||||
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
|
||||
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?
|
||||
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user