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;
|
margin-inline-start: auto;
|
||||||
opacity: 0;
|
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.user_agent = request.user_agent
|
||||||
Current.ip_address = request.ip
|
Current.ip_address = request.ip
|
||||||
Current.referrer = request.referrer
|
Current.referrer = request.referrer
|
||||||
|
|
||||||
ActionPack::WebAuthn::Current.host = request.host
|
|
||||||
ActionPack::WebAuthn::Current.origin = request.base_url
|
|
||||||
end
|
end
|
||||||
end
|
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
|
class Sessions::PasskeysController < ApplicationController
|
||||||
|
include ActionPack::Passkey::Request
|
||||||
|
|
||||||
disallow_account_scope
|
disallow_account_scope
|
||||||
require_unauthenticated_access
|
require_unauthenticated_access
|
||||||
rate_limit to: 10, within: 3.minutes, only: :create, with: :rate_limit_exceeded
|
rate_limit to: 10, within: 3.minutes, only: :create, with: :rate_limit_exceeded
|
||||||
|
|
||||||
def create
|
def create
|
||||||
credential = Identity::Credential.authenticate(
|
if credential = ActionPack::Passkey.authenticate(passkey_request_params)
|
||||||
passkey: passkey_params,
|
start_new_session_for credential.holder
|
||||||
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
|
|
||||||
|
|
||||||
respond_to do |format|
|
respond_to do |format|
|
||||||
format.html { redirect_to after_authentication_url }
|
format.html { redirect_to after_authentication_url }
|
||||||
format.json { render json: { session_token: session_token } }
|
format.json { render json: { session_token: session_token } }
|
||||||
end
|
end
|
||||||
end
|
else
|
||||||
|
|
||||||
def authentication_failed
|
|
||||||
alert_message = "That passkey didn't work. Try again."
|
|
||||||
|
|
||||||
respond_to do |format|
|
respond_to do |format|
|
||||||
format.html { redirect_to new_session_path, alert: alert_message }
|
format.html { redirect_to new_session_path, alert: "That passkey didn't work. Try again." }
|
||||||
format.json { render json: { message: alert_message }, status: :unauthorized }
|
format.json { render json: { message: "That passkey didn't work. Try again." }, status: :unauthorized }
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
private
|
||||||
def rate_limit_exceeded
|
def rate_limit_exceeded
|
||||||
rate_limit_exceeded_message = "Try again later."
|
rate_limit_exceeded_message = "Try again later."
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
class SessionsController < ApplicationController
|
class SessionsController < ApplicationController
|
||||||
|
include ActionPack::Passkey::Request
|
||||||
|
|
||||||
disallow_account_scope
|
disallow_account_scope
|
||||||
require_unauthenticated_access except: :destroy
|
require_unauthenticated_access except: :destroy
|
||||||
rate_limit to: 10, within: 3.minutes, only: :create, with: :rate_limit_exceeded
|
rate_limit to: 10, within: 3.minutes, only: :create, with: :rate_limit_exceeded
|
||||||
@@ -6,8 +8,7 @@ class SessionsController < ApplicationController
|
|||||||
layout "public"
|
layout "public"
|
||||||
|
|
||||||
def new
|
def new
|
||||||
@request_options = Identity::Credential.request_options
|
@request_options = passkey_request_options
|
||||||
session[:webauthn_challenge] = @request_options.challenge
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def create
|
def create
|
||||||
@@ -69,5 +70,4 @@ class SessionsController < ApplicationController
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
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 "lexxy"
|
||||||
import "@rails/actiontext"
|
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
|
class Identity < ApplicationRecord
|
||||||
include Joinable, Transferable
|
include Joinable, Transferable
|
||||||
|
|
||||||
|
has_passkeys name: :email_address, display_name: -> { Current.user&.name || email_address }
|
||||||
|
|
||||||
has_many :access_tokens, dependent: :destroy
|
has_many :access_tokens, dependent: :destroy
|
||||||
has_many :credentials, dependent: :destroy
|
|
||||||
has_many :magic_links, dependent: :destroy
|
has_many :magic_links, dependent: :destroy
|
||||||
has_many :sessions, dependent: :destroy
|
has_many :sessions, dependent: :destroy
|
||||||
has_many :users, dependent: :nullify
|
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>
|
<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 class="footer">Need help? <%= mail_to "support@fizzy.do", "Send us an email"%>.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|||||||
@@ -7,3 +7,7 @@ Please enter this 6-character verification code to finish creating your account:
|
|||||||
<%= @magic_link.code %>
|
<%= @magic_link.code %>
|
||||||
|
|
||||||
This code will work for <%= distance_of_time_in_words(MagicLink::EXPIRATION_TIME) %>.
|
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">
|
<section class="panel panel--wide shadow center webhooks">
|
||||||
<% if @access_tokens.any? %>
|
<% if @access_tokens.any? %>
|
||||||
<p class="margin-none-block-start">Tokens you have generated that can be used to access the Fizzy API.</p>
|
<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>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Description</th>
|
<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 %>
|
<% content_for :header do %>
|
||||||
<div class="header__actions header__actions--start">
|
<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>
|
</div>
|
||||||
|
|
||||||
<h1 class="header__title" data-bridge--title-target="header"><%= @page_title %></h1>
|
<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>
|
<p class="margin-none">Your passkey has been registered. Give it a name so you can identify it later.</p>
|
||||||
<% end %>
|
<% 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">
|
<div class="flex flex-column gap-half">
|
||||||
<strong><%= form.label "Name your passkey" %></strong>
|
<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" %>
|
<%= form.text_field :name, autofocus: true, class: "input", placeholder: "e.g. MacBook Pro, iPhone", data: { "1p-ignore": "" }, autocomplete: "off" %>
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<div class="txt-align-center">
|
<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",
|
class: "btn txt-negative borderless txt-small",
|
||||||
data: { turbo_confirm: "Are you sure you want to remove this passkey?" } do %>
|
data: { turbo_confirm: "Are you sure you want to remove this passkey?" } do %>
|
||||||
<%= icon_tag "trash" %>
|
<%= icon_tag "trash" %>
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
<% @page_title = "Passkeys" %>
|
<% @page_title = "Passkeys" %>
|
||||||
|
|
||||||
|
<% content_for :head do %>
|
||||||
|
<%= passkey_creation_options_meta_tag(@creation_options) %>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
<% content_for :header do %>
|
<% content_for :header do %>
|
||||||
<div class="header__actions header__actions--start">
|
<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" %>
|
<%= 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>
|
<h1 class="header__title" data-bridge--title-target="header"><%= @page_title %></h1>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<section class="panel panel--wide shadow center"
|
<section class="panel panel--wide shadow center" data-passkey-errors>
|
||||||
data-controller="credential"
|
|
||||||
data-credential-public-key-value="<%= @creation_options.as_json.to_json %>"
|
|
||||||
data-credential-register-url-value="<%= user_credentials_path(Current.user) %>">
|
|
||||||
|
|
||||||
<p class="font-weight-medium margin-none-block-start">Passkeys let you sign in securely without a password or email code.</p>
|
<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">
|
<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>
|
</ul>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<footer>
|
<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" %>
|
<%= icon_tag "add" %>
|
||||||
<span>Register a passkey</span>
|
<span>Register a passkey</span>
|
||||||
</button>
|
<% end %>
|
||||||
|
|
||||||
<p class="txt-small txt-subtle txt-balance margin-none-block-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
|
Your browser will prompt you to create a passkey using your device's biometrics, PIN, or security key
|
||||||
</p>
|
</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.
|
Something went wrong while registering your passkey.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p data-credential-target="cancelled" class="txt-subtle" hidden>
|
<p data-passkey-error="cancelled" class="txt-subtle">
|
||||||
Passkey registration was cancelled.
|
Passkey registration was cancelled.
|
||||||
Try again when you are ready.
|
Try again when you are ready.
|
||||||
</p>
|
</p>
|
||||||
@@ -1,15 +1,16 @@
|
|||||||
<% @page_title = "Enter your email" %>
|
<% @page_title = "Enter your email" %>
|
||||||
|
|
||||||
<div class="panel panel--centered flex flex-column gap-half" data-controller="passkey"
|
<% content_for :head do %>
|
||||||
data-passkey-public-key-value="<%= @request_options.as_json.to_json %>"
|
<%= passkey_request_options_meta_tag(@request_options) %>
|
||||||
data-passkey-url-value="<%= session_passkey_path %>"
|
<% end %>
|
||||||
data-passkey-csrf-token-value="<%= form_authenticity_token %>">
|
|
||||||
|
<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>
|
<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| %>
|
<%= form_with url: session_path, class: "flex flex-column gap-half txt-medium" do |form| %>
|
||||||
<div class="flex align-center gap">
|
<div class="flex align-center gap">
|
||||||
<label class="flex align-center gap input input--actor">
|
<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>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -25,6 +26,8 @@
|
|||||||
</button>
|
</button>
|
||||||
<% end %>
|
<% 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>
|
</div>
|
||||||
|
|
||||||
<% content_for :footer do %>
|
<% 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 %>
|
<% 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">
|
<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" %>
|
<%= icon_tag "authentication" %>
|
||||||
<span>Manage passkeys</span>
|
<span>Manage passkeys</span>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
require_relative "boot"
|
require_relative "boot"
|
||||||
require "rails/all"
|
require "rails/all"
|
||||||
require_relative "../lib/fizzy"
|
require_relative "../lib/fizzy"
|
||||||
|
require_relative "../lib/action_pack/railtie"
|
||||||
|
|
||||||
Bundler.require(*Rails.groups)
|
Bundler.require(*Rails.groups)
|
||||||
|
|
||||||
@@ -25,6 +26,9 @@ module Fizzy
|
|||||||
g.orm :active_record, primary_key_type: :uuid
|
g.orm :active_record, primary_key_type: :uuid
|
||||||
end
|
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
|
config.mission_control.jobs.http_basic_auth_enabled = false
|
||||||
end
|
end
|
||||||
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/controllers", under: "controllers"
|
||||||
pin_all_from "app/javascript/helpers", under: "helpers"
|
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/initializers", under: "initializers"
|
||||||
pin_all_from "app/javascript/bridge/initializers", under: "bridge/initializers"
|
pin_all_from "app/javascript/bridge/initializers", under: "bridge/initializers"
|
||||||
pin_all_from "app/javascript/bridge/helpers", under: "bridge/helpers"
|
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 :avatar
|
||||||
resource :role
|
resource :role
|
||||||
resource :events
|
resource :events
|
||||||
resources :credentials, except: %i[ show new ]
|
|
||||||
|
|
||||||
resources :push_subscriptions
|
resources :push_subscriptions
|
||||||
|
|
||||||
resources :email_addresses, param: :token do
|
resources :email_addresses, param: :token do
|
||||||
@@ -174,8 +172,10 @@ Rails.application.routes.draw do
|
|||||||
resource :landing
|
resource :landing
|
||||||
|
|
||||||
namespace :my do
|
namespace :my do
|
||||||
|
resource :passkey_challenge, only: :create
|
||||||
resource :identity, only: :show
|
resource :identity, only: :show
|
||||||
resources :access_tokens
|
resources :access_tokens
|
||||||
|
resources :passkeys, except: %i[ show new ]
|
||||||
resources :pins
|
resources :pins
|
||||||
resource :timezone
|
resource :timezone
|
||||||
resource :menu
|
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.
|
# 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|
|
create_table "accesses", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||||
t.datetime "accessed_at"
|
t.datetime "accessed_at"
|
||||||
t.uuid "account_id", null: false
|
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
|
t.index ["external_account_id"], name: "index_accounts_on_external_account_id", unique: true
|
||||||
end
|
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|
|
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.uuid "account_id", null: false
|
||||||
t.text "body", size: :long
|
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"
|
t.index ["identity_id"], name: "index_access_token_on_identity_id"
|
||||||
end
|
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|
|
create_table "magic_links", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||||
t.string "code", null: false
|
t.string "code", null: false
|
||||||
t.datetime "created_at", 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
|
t.index ["external_account_id"], name: "index_accounts_on_external_account_id", unique: true
|
||||||
end
|
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|
|
create_table "action_text_rich_texts", id: :uuid, force: :cascade do |t|
|
||||||
t.uuid "account_id", null: false
|
t.uuid "account_id", null: false
|
||||||
t.text "body", limit: 4294967295
|
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
|
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
|
class << self
|
||||||
|
# Returns a new RelyingParty configured from the current request context.
|
||||||
def relying_party
|
def relying_party
|
||||||
RelyingParty.new
|
RelyingParty.new
|
||||||
end
|
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
|
def attestation_verifiers
|
||||||
@attestation_verifiers ||= {
|
@attestation_verifiers ||= {
|
||||||
"none" => Authenticator::AttestationVerifiers::None.new
|
"none" => Authenticator::AttestationVerifiers::None.new
|
||||||
}
|
}
|
||||||
end
|
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)
|
def register_attestation_verifier(format, verifier)
|
||||||
attestation_verifiers[format.to_s] = verifier
|
attestation_verifiers[format.to_s] = verifier
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -9,21 +9,20 @@
|
|||||||
#
|
#
|
||||||
# # Look up the credential by ID
|
# # Look up the credential by ID
|
||||||
# credential = user.credentials.find_by!(
|
# credential = user.credentials.find_by!(
|
||||||
# credentail_id: params[:id]
|
# credential_id: params[:id]
|
||||||
# )
|
# )
|
||||||
#
|
#
|
||||||
# response = ActionPack::WebAuthn::Authenticator::AssertionResponse.new(
|
# response = ActionPack::WebAuthn::Authenticator::AssertionResponse.new(
|
||||||
# client_data_json: params[:response][:clientDataJSON],
|
# client_data_json: params[:response][:clientDataJSON],
|
||||||
# authenticator_data: params[:response][:authenticatorData],
|
# authenticator_data: params[:response][:authenticatorData],
|
||||||
# signature: params[:response][:signature],
|
# signature: params[:response][:signature],
|
||||||
# credential: credential.to_public_key_credential
|
# credential: credential.to_public_key_credential,
|
||||||
# )
|
# challenge: ActionPack::WebAuthn::Current.challenge,
|
||||||
#
|
|
||||||
# response.validate!(
|
|
||||||
# challenge: session[:authentication_challenge],
|
|
||||||
# origin: "https://example.com"
|
# origin: "https://example.com"
|
||||||
# )
|
# )
|
||||||
#
|
#
|
||||||
|
# response.validate!
|
||||||
|
#
|
||||||
# == Validation
|
# == Validation
|
||||||
#
|
#
|
||||||
# In addition to the base Response validations, this class verifies:
|
# In addition to the base Response validations, this class verifies:
|
||||||
@@ -34,37 +33,43 @@
|
|||||||
class ActionPack::WebAuthn::Authenticator::AssertionResponse < ActionPack::WebAuthn::Authenticator::Response
|
class ActionPack::WebAuthn::Authenticator::AssertionResponse < ActionPack::WebAuthn::Authenticator::Response
|
||||||
attr_reader :credential, :authenticator_data, :signature
|
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)
|
def initialize(credential:, authenticator_data:, signature:, **attributes)
|
||||||
super(**attributes)
|
super(**attributes)
|
||||||
@credential = credential
|
@credential = credential
|
||||||
@signature = signature
|
@signature = signature
|
||||||
|
@signature = Base64.urlsafe_decode64(@signature) unless @signature.encoding == Encoding::BINARY
|
||||||
@authenticator_data = ActionPack::WebAuthn::Authenticator::Data.wrap(authenticator_data)
|
@authenticator_data = ActionPack::WebAuthn::Authenticator::Data.wrap(authenticator_data)
|
||||||
end
|
rescue ArgumentError
|
||||||
|
raise ActionPack::WebAuthn::InvalidResponseError, "Invalid base64 encoding in signature"
|
||||||
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
|
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
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)
|
client_data_hash = Digest::SHA256.digest(client_data_json)
|
||||||
signed_data = authenticator_data.bytes.pack("C*") + client_data_hash
|
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
|
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
|
end
|
||||||
|
|
||||||
def sign_count_increased?
|
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
|
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)
|
def self.decode(bytes)
|
||||||
cbor = ActionPack::WebAuthn::CborDecoder.decode(bytes)
|
cbor = ActionPack::WebAuthn::CborDecoder.decode(bytes)
|
||||||
|
|
||||||
|
|||||||
@@ -8,14 +8,13 @@
|
|||||||
#
|
#
|
||||||
# response = ActionPack::WebAuthn::Authenticator::AttestationResponse.new(
|
# response = ActionPack::WebAuthn::Authenticator::AttestationResponse.new(
|
||||||
# client_data_json: params[:response][:clientDataJSON],
|
# client_data_json: params[:response][:clientDataJSON],
|
||||||
# attestation_object: params[:response][:attestationObject]
|
# attestation_object: params[:response][:attestationObject],
|
||||||
# )
|
# challenge: ActionPack::WebAuthn::Current.challenge,
|
||||||
#
|
|
||||||
# response.validate!(
|
|
||||||
# challenge: session[:registration_challenge],
|
|
||||||
# origin: "https://example.com"
|
# origin: "https://example.com"
|
||||||
# )
|
# )
|
||||||
#
|
#
|
||||||
|
# response.validate!
|
||||||
|
#
|
||||||
# # Store the credential
|
# # Store the credential
|
||||||
# credential_id = response.attestation.credential_id
|
# credential_id = response.attestation.credential_id
|
||||||
# public_key = response.attestation.public_key
|
# public_key = response.attestation.public_key
|
||||||
@@ -31,32 +30,39 @@
|
|||||||
class ActionPack::WebAuthn::Authenticator::AttestationResponse < ActionPack::WebAuthn::Authenticator::Response
|
class ActionPack::WebAuthn::Authenticator::AttestationResponse < ActionPack::WebAuthn::Authenticator::Response
|
||||||
attr_reader :attestation_object
|
attr_reader :attestation_object
|
||||||
|
|
||||||
|
validate :client_data_type_must_be_create
|
||||||
|
validate :attestation_must_be_valid
|
||||||
|
|
||||||
def initialize(attestation_object:, **attributes)
|
def initialize(attestation_object:, **attributes)
|
||||||
super(**attributes)
|
super(**attributes)
|
||||||
@attestation_object = attestation_object
|
@attestation_object = attestation_object
|
||||||
end
|
end
|
||||||
|
|
||||||
def validate!(**args)
|
# Returns the decoded Attestation object, lazily parsed from the raw
|
||||||
super(**args)
|
# attestation object bytes.
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
def attestation
|
def attestation
|
||||||
@attestation ||= ActionPack::WebAuthn::Authenticator::Attestation.decode(attestation_object)
|
@attestation ||= ActionPack::WebAuthn::Authenticator::Attestation.wrap(attestation_object)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Returns the authenticator data extracted from the attestation object.
|
||||||
def authenticator_data
|
def authenticator_data
|
||||||
attestation.authenticator_data
|
attestation.authenticator_data
|
||||||
end
|
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
|
end
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
class ActionPack::WebAuthn::Authenticator::AttestationVerifiers::None
|
class ActionPack::WebAuthn::Authenticator::AttestationVerifiers::None
|
||||||
def verify!(attestation, client_data_json:)
|
def verify!(attestation, client_data_json:)
|
||||||
if attestation.attestation_statement.present?
|
if attestation.attestation_statement.present?
|
||||||
raise ActionPack::WebAuthn::Authenticator::Response::InvalidResponseError,
|
raise ActionPack::WebAuthn::InvalidResponseError,
|
||||||
"Attestation statement must be empty for 'none' format"
|
"Attestation statement must be empty for 'none' format"
|
||||||
end
|
end
|
||||||
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
|
attr_reader :bytes, :relying_party_id_hash, :flags, :sign_count, :aaguid, :credential_id, :public_key_bytes
|
||||||
|
|
||||||
class << self
|
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)
|
def wrap(data)
|
||||||
if data.is_a?(self)
|
if data.is_a?(self)
|
||||||
data
|
data
|
||||||
else
|
else
|
||||||
|
data = Base64.urlsafe_decode64(data) unless data.encoding == Encoding::BINARY
|
||||||
decode(data)
|
decode(data)
|
||||||
end
|
end
|
||||||
|
rescue ArgumentError
|
||||||
|
raise ActionPack::WebAuthn::InvalidResponseError, "Invalid base64 encoding in authenticator data"
|
||||||
end
|
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)
|
def decode(bytes)
|
||||||
bytes = bytes.bytes if bytes.is_a?(String)
|
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
|
position = 0
|
||||||
|
|
||||||
relying_party_id_hash = bytes[position, RELYING_PARTY_ID_HASH_LENGTH].pack("C*")
|
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
|
public_key_bytes = nil
|
||||||
|
|
||||||
if flags & ATTESTED_CREDENTIAL_DATA_FLAG != 0
|
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_bytes = bytes[position, AAGUID_LENGTH].pack("C*")
|
||||||
aaguid = aaguid_bytes.unpack("H8H4H4H4H12").join("-")
|
aaguid = aaguid_bytes.unpack("H8H4H4H4H12").join("-")
|
||||||
position += AAGUID_LENGTH
|
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")
|
credential_id_length = bytes[position, CREDENTIAL_ID_LENGTH_BYTES].pack("C*").unpack1("n")
|
||||||
position += CREDENTIAL_ID_LENGTH_BYTES
|
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)
|
credential_id = Base64.urlsafe_encode64(bytes[position, credential_id_length].pack("C*"), padding: false)
|
||||||
position += credential_id_length
|
position += credential_id_length
|
||||||
|
|
||||||
@@ -121,10 +142,13 @@ class ActionPack::WebAuthn::Authenticator::Data
|
|||||||
@public_key_bytes = public_key_bytes
|
@public_key_bytes = public_key_bytes
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Returns true if the user performed a test of presence (e.g., touched the
|
||||||
|
# authenticator).
|
||||||
def user_present?
|
def user_present?
|
||||||
flags & USER_PRESENT_FLAG != 0
|
flags & USER_PRESENT_FLAG != 0
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Returns true if the user was verified via biometrics, PIN, or similar.
|
||||||
def user_verified?
|
def user_verified?
|
||||||
flags & USER_VERIFIED_FLAG != 0
|
flags & USER_VERIFIED_FLAG != 0
|
||||||
end
|
end
|
||||||
@@ -141,6 +165,9 @@ class ActionPack::WebAuthn::Authenticator::Data
|
|||||||
flags & BACKUP_STATE_FLAG != 0
|
flags & BACKUP_STATE_FLAG != 0
|
||||||
end
|
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
|
def public_key
|
||||||
@public_key ||= ActionPack::WebAuthn::CoseKey.decode(public_key_bytes).to_openssl_key if public_key_bytes
|
@public_key ||= ActionPack::WebAuthn::CoseKey.decode(public_key_bytes).to_openssl_key if public_key_bytes
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -18,65 +18,57 @@
|
|||||||
#
|
#
|
||||||
# == Example
|
# == Example
|
||||||
#
|
#
|
||||||
# response.validate!(
|
# response = ActionPack::WebAuthn::Authenticator::AssertionResponse.new(
|
||||||
# challenge: session[:webauthn_challenge],
|
# client_data_json: client_data_json,
|
||||||
|
# authenticator_data: authenticator_data,
|
||||||
|
# signature: signature,
|
||||||
|
# credential: credential,
|
||||||
|
# challenge: ActionPack::WebAuthn::Current.challenge,
|
||||||
# origin: "https://example.com",
|
# origin: "https://example.com",
|
||||||
# user_verification: :required
|
# user_verification: :required
|
||||||
# )
|
# )
|
||||||
#
|
#
|
||||||
|
# response.validate!
|
||||||
|
#
|
||||||
class ActionPack::WebAuthn::Authenticator::Response
|
class ActionPack::WebAuthn::Authenticator::Response
|
||||||
# Raised when response validation fails.
|
include ActiveModel::Validations
|
||||||
class InvalidResponseError < StandardError; end
|
|
||||||
|
|
||||||
attr_reader :client_data_json
|
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
|
@client_data_json = client_data_json
|
||||||
|
@challenge = challenge
|
||||||
|
@origin = origin
|
||||||
|
@user_verification = user_verification.to_sym
|
||||||
end
|
end
|
||||||
|
|
||||||
def valid?(**args)
|
def validate!
|
||||||
validate!(**args)
|
super
|
||||||
true
|
rescue ActiveModel::ValidationError
|
||||||
rescue InvalidResponseError
|
raise ActionPack::WebAuthn::InvalidResponseError, errors.full_messages.join(", ")
|
||||||
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
|
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Returns the RelyingParty used for RP ID validation.
|
||||||
def relying_party
|
def relying_party
|
||||||
ActionPack::WebAuthn.relying_party
|
ActionPack::WebAuthn.relying_party
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Parses the client data JSON string into a Hash. Raises
|
||||||
|
# +InvalidResponseError+ if the JSON is malformed.
|
||||||
def client_data
|
def client_data
|
||||||
@client_data ||= JSON.parse(client_data_json)
|
@client_data ||= JSON.parse(client_data_json)
|
||||||
|
rescue JSON::ParserError
|
||||||
|
raise ActionPack::WebAuthn::InvalidResponseError, "Client data is not valid JSON"
|
||||||
end
|
end
|
||||||
|
|
||||||
def authenticator_data
|
def authenticator_data
|
||||||
@@ -84,34 +76,68 @@ class ActionPack::WebAuthn::Authenticator::Response
|
|||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
def challenge_matches?(expected_challenge)
|
def challenge_must_match
|
||||||
ActiveSupport::SecurityUtils.secure_compare(expected_challenge, client_data["challenge"])
|
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
|
end
|
||||||
|
|
||||||
def origin_matches?(expected_origin)
|
def challenge_must_not_be_expired
|
||||||
ActiveSupport::SecurityUtils.secure_compare(expected_origin, client_data["origin"])
|
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
|
end
|
||||||
|
|
||||||
def relying_party_id_matches?
|
def origin_must_match
|
||||||
ActiveSupport::SecurityUtils.secure_compare(
|
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),
|
Digest::SHA256.digest(relying_party.id),
|
||||||
authenticator_data&.relying_party_id_hash || ""
|
authenticator_data&.relying_party_id_hash || ""
|
||||||
)
|
)
|
||||||
|
errors.add(:base, "Relying party ID does not match")
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def cross_origin?
|
def user_must_be_present
|
||||||
client_data["crossOrigin"] == true
|
unless authenticator_data&.user_present?
|
||||||
|
errors.add(:base, "User presence is required")
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def token_binding_present?
|
def user_must_be_verified_when_required
|
||||||
client_data.dig("tokenBinding", "status") == "present"
|
if user_verification == :required && !authenticator_data&.user_verified?
|
||||||
|
errors.add(:base, "User verification is required")
|
||||||
end
|
end
|
||||||
|
|
||||||
def user_present?
|
|
||||||
authenticator_data&.user_present?
|
|
||||||
end
|
|
||||||
|
|
||||||
def user_verified?
|
|
||||||
authenticator_data&.user_verified?
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -49,11 +49,8 @@
|
|||||||
#
|
#
|
||||||
# == Errors
|
# == Errors
|
||||||
#
|
#
|
||||||
# Raises +DecodeError+ when encountering malformed or unsupported CBOR data.
|
# Raises +InvalidCborError+ when encountering malformed or unsupported CBOR data.
|
||||||
class ActionPack::WebAuthn::CborDecoder
|
class ActionPack::WebAuthn::CborDecoder
|
||||||
# Raised when the decoder encounters invalid or unsupported CBOR data.
|
|
||||||
class DecodeError < StandardError; end
|
|
||||||
|
|
||||||
# Major types
|
# Major types
|
||||||
UNSIGNED_INTEGER_TYPE = 0
|
UNSIGNED_INTEGER_TYPE = 0
|
||||||
NEGATIVE_INTEGER_TYPE = 1
|
NEGATIVE_INTEGER_TYPE = 1
|
||||||
@@ -86,6 +83,10 @@ class ActionPack::WebAuthn::CborDecoder
|
|||||||
MAX_DEPTH = 16
|
MAX_DEPTH = 16
|
||||||
MAX_SIZE = 10.megabytes
|
MAX_SIZE = 10.megabytes
|
||||||
|
|
||||||
|
# Tags
|
||||||
|
POSITIVE_BIGNUM_TAG = 2
|
||||||
|
NEGATIVE_BIGNUM_TAG = 3
|
||||||
|
|
||||||
class << self
|
class << self
|
||||||
# Decodes a CBOR-encoded byte sequence into a Ruby object.
|
# Decodes a CBOR-encoded byte sequence into a Ruby object.
|
||||||
#
|
#
|
||||||
@@ -98,7 +99,7 @@ class ActionPack::WebAuthn::CborDecoder
|
|||||||
end
|
end
|
||||||
|
|
||||||
def initialize(bytes, max_depth: MAX_DEPTH, max_size: MAX_SIZE) # :nodoc:
|
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
|
@bytes = bytes
|
||||||
@max_depth = max_depth
|
@max_depth = max_depth
|
||||||
@@ -108,8 +109,8 @@ class ActionPack::WebAuthn::CborDecoder
|
|||||||
|
|
||||||
# Decodes the next CBOR data item from the byte sequence.
|
# Decodes the next CBOR data item from the byte sequence.
|
||||||
def decode
|
def decode
|
||||||
raise DecodeError, "Unexpected end of input" if @position >= @bytes.length
|
raise ActionPack::WebAuthn::InvalidCborError, "Unexpected end of input" if @position >= @bytes.length
|
||||||
raise DecodeError, "Maximum nesting depth exceeded" if @depth > @max_depth
|
raise ActionPack::WebAuthn::InvalidCborError, "Maximum nesting depth exceeded" if @depth >= @max_depth
|
||||||
|
|
||||||
@depth += 1
|
@depth += 1
|
||||||
|
|
||||||
@@ -190,13 +191,19 @@ class ActionPack::WebAuthn::CborDecoder
|
|||||||
when FOUR_BYTE_VALUE_FOLLOWS then read_bytes(4).pack("C*").unpack1("g")
|
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")
|
when EIGHT_BYTE_VALUE_FOLLOWS then read_bytes(8).pack("C*").unpack1("G")
|
||||||
else
|
else
|
||||||
raise DecodeError, "Invalid simple value: #{info}"
|
raise ActionPack::WebAuthn::InvalidCborError, "Invalid simple value: #{info}"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def decode_tag
|
def decode_tag
|
||||||
read_argument
|
tag = read_argument
|
||||||
decode
|
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
|
end
|
||||||
|
|
||||||
def decode_half_float
|
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 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 EIGHT_BYTE_VALUE_FOLLOWS then read_bytes(8).pack("C*").unpack1("Q>")
|
||||||
when RESERVED_VALUE_RANGE
|
when RESERVED_VALUE_RANGE
|
||||||
raise DecodeError, "Reserved additional info: #{info}"
|
raise ActionPack::WebAuthn::InvalidCborError, "Reserved additional info: #{info}"
|
||||||
else
|
else
|
||||||
raise DecodeError, "Invalid additional info: #{info}"
|
raise ActionPack::WebAuthn::InvalidCborError, "Invalid additional info: #{info}"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -245,7 +252,7 @@ class ActionPack::WebAuthn::CborDecoder
|
|||||||
end
|
end
|
||||||
|
|
||||||
def read_bytes(length)
|
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]
|
bytes = @bytes[@position, length]
|
||||||
@position += length
|
@position += length
|
||||||
@@ -253,7 +260,7 @@ class ActionPack::WebAuthn::CborDecoder
|
|||||||
end
|
end
|
||||||
|
|
||||||
def read_byte
|
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]
|
byte = @bytes[@position]
|
||||||
@position += 1
|
@position += 1
|
||||||
|
|||||||
@@ -19,22 +19,25 @@
|
|||||||
# [ES256]
|
# [ES256]
|
||||||
# ECDSA with P-256 curve and SHA-256. The most common algorithm for WebAuthn.
|
# 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]
|
# [RS256]
|
||||||
# RSASSA-PKCS1-v1_5 with SHA-256. Used by some security keys and platforms.
|
# RSASSA-PKCS1-v1_5 with SHA-256. Used by some security keys and platforms.
|
||||||
#
|
#
|
||||||
# == Attributes
|
# == Attributes
|
||||||
#
|
#
|
||||||
# [+key_type+]
|
# [+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+]
|
# [+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+]
|
# [+parameters+]
|
||||||
# The full COSE key parameters map, including curve and coordinate data.
|
# The full COSE key parameters map, including curve and coordinate data.
|
||||||
class ActionPack::WebAuthn::CoseKey
|
class ActionPack::WebAuthn::CoseKey
|
||||||
# Raised when the key type, algorithm, or curve is not supported.
|
P256_COORDINATE_LENGTH = 32
|
||||||
class UnsupportedKeyTypeError < StandardError; end
|
MINIMUM_RSA_KEY_BITS = 2048
|
||||||
|
|
||||||
# COSE key labels
|
# COSE key labels
|
||||||
KEY_TYPE_LABEL = 1
|
KEY_TYPE_LABEL = 1
|
||||||
@@ -44,18 +47,25 @@ class ActionPack::WebAuthn::CoseKey
|
|||||||
EC2_Y_LABEL = -3
|
EC2_Y_LABEL = -3
|
||||||
RSA_N_LABEL = -1
|
RSA_N_LABEL = -1
|
||||||
RSA_E_LABEL = -2
|
RSA_E_LABEL = -2
|
||||||
|
OKP_CURVE_LABEL = -1
|
||||||
|
OKP_X_LABEL = -2
|
||||||
|
|
||||||
# COSE key types
|
# COSE key types
|
||||||
|
OKP = 1
|
||||||
EC2 = 2
|
EC2 = 2
|
||||||
RSA = 3
|
RSA = 3
|
||||||
|
|
||||||
# COSE algorithms
|
# COSE algorithms
|
||||||
ES256 = -7
|
ES256 = -7
|
||||||
|
EDDSA = -8
|
||||||
RS256 = -257
|
RS256 = -257
|
||||||
|
|
||||||
# COSE EC2 curves
|
# COSE EC2 curves
|
||||||
P256 = 1
|
P256 = 1
|
||||||
|
|
||||||
|
# COSE OKP curves
|
||||||
|
ED25519 = 6
|
||||||
|
|
||||||
# OpenSSL types
|
# OpenSSL types
|
||||||
UNCOMPRESSED_POINT_MARKER = 0x04
|
UNCOMPRESSED_POINT_MARKER = 0x04
|
||||||
|
|
||||||
@@ -84,26 +94,30 @@ class ActionPack::WebAuthn::CoseKey
|
|||||||
|
|
||||||
# Converts the COSE key to an OpenSSL public key object.
|
# Converts the COSE key to an OpenSSL public key object.
|
||||||
#
|
#
|
||||||
# Returns an +OpenSSL::PKey::EC+ for EC2 keys or +OpenSSL::PKey::RSA+ for
|
# Returns an +OpenSSL::PKey::EC+ for EC2 keys, +OpenSSL::PKey::RSA+ for
|
||||||
# RSA keys, suitable for use with +OpenSSL::PKey#verify+.
|
# 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
|
# Raises +UnsupportedKeyTypeError+ if the key type, algorithm, or curve
|
||||||
# is not supported.
|
# is not supported.
|
||||||
def to_openssl_key
|
def to_openssl_key
|
||||||
case [ key_type, algorithm ]
|
case [ key_type, algorithm ]
|
||||||
when [ EC2, ES256 ] then build_ec2_es256_key
|
when [ EC2, ES256 ] then build_ec2_es256_key
|
||||||
|
when [ OKP, EDDSA ] then build_okp_eddsa_key
|
||||||
when [ RSA, RS256 ] then build_rsa_rs256_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
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
def build_ec2_es256_key
|
def build_ec2_es256_key
|
||||||
curve = parameters[EC2_CURVE_LABEL]
|
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]
|
x = parameters[EC2_X_LABEL]
|
||||||
y = parameters[EC2_Y_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
|
# Uncompressed point format: 0x04 || x || y
|
||||||
public_key_bytes = [ UNCOMPRESSED_POINT_MARKER, *x.bytes, *y.bytes ].pack("C*")
|
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)
|
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
|
end
|
||||||
|
|
||||||
def build_rsa_rs256_key
|
def build_rsa_rs256_key
|
||||||
n = OpenSSL::BN.new(parameters[RSA_N_LABEL], 2)
|
n_bytes = parameters[RSA_N_LABEL]
|
||||||
e = OpenSSL::BN.new(parameters[RSA_E_LABEL], 2)
|
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([
|
asn1 = OpenSSL::ASN1::Sequence([
|
||||||
OpenSSL::ASN1::Sequence([
|
OpenSSL::ASN1::Sequence([
|
||||||
@@ -137,5 +177,7 @@ class ActionPack::WebAuthn::CoseKey
|
|||||||
])
|
])
|
||||||
|
|
||||||
OpenSSL::PKey::RSA.new(asn1.to_der)
|
OpenSSL::PKey::RSA.new(asn1.to_der)
|
||||||
|
rescue OpenSSL::PKey::PKeyError => error
|
||||||
|
raise ActionPack::WebAuthn::InvalidKeyError, "Invalid RSA key: #{error.message}"
|
||||||
end
|
end
|
||||||
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
|
class ActionPack::WebAuthn::Current < ActiveSupport::CurrentAttributes
|
||||||
attribute :host, :origin
|
attribute :host, :origin, :challenge
|
||||||
end
|
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
|
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
|
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(
|
response = ActionPack::WebAuthn::Authenticator::AttestationResponse.new(
|
||||||
client_data_json: client_data_json,
|
client_data_json: params[:client_data_json],
|
||||||
attestation_object: attestation_object
|
attestation_object: params[:attestation_object],
|
||||||
|
challenge: challenge,
|
||||||
|
origin: origin
|
||||||
)
|
)
|
||||||
|
|
||||||
response.validate!(challenge: challenge, origin: origin)
|
response.validate!
|
||||||
|
|
||||||
new(
|
new(
|
||||||
id: response.attestation.credential_id,
|
id: response.attestation.credential_id,
|
||||||
@@ -16,33 +96,61 @@ class ActionPack::WebAuthn::PublicKeyCredential
|
|||||||
sign_count: response.attestation.sign_count,
|
sign_count: response.attestation.sign_count,
|
||||||
aaguid: response.attestation.aaguid,
|
aaguid: response.attestation.aaguid,
|
||||||
backed_up: response.attestation.backed_up?,
|
backed_up: response.attestation.backed_up?,
|
||||||
transports: transports,
|
transports: Array(params[:transports])
|
||||||
owner: owner
|
|
||||||
)
|
)
|
||||||
end
|
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
|
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
|
@id = id
|
||||||
@public_key = public_key
|
@public_key = public_key
|
||||||
|
@public_key = OpenSSL::PKey.read(public_key) unless public_key.is_a?(OpenSSL::PKey::PKey)
|
||||||
@sign_count = sign_count
|
@sign_count = sign_count
|
||||||
@aaguid = aaguid
|
@aaguid = aaguid
|
||||||
@backed_up = backed_up
|
@backed_up = backed_up
|
||||||
@transports = transports
|
@transports = transports
|
||||||
@owner = owner
|
|
||||||
end
|
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(
|
response = ActionPack::WebAuthn::Authenticator::AssertionResponse.new(
|
||||||
client_data_json: client_data_json,
|
client_data_json: params[:client_data_json],
|
||||||
authenticator_data: authenticator_data,
|
authenticator_data: params[:authenticator_data],
|
||||||
signature: signature,
|
signature: params[:signature],
|
||||||
credential: self
|
credential: self,
|
||||||
|
challenge: challenge,
|
||||||
|
origin: origin
|
||||||
)
|
)
|
||||||
|
|
||||||
response.validate!(challenge: challenge, origin: origin)
|
response.validate!
|
||||||
|
|
||||||
@sign_count = response.authenticator_data.sign_count
|
@sign_count = response.authenticator_data.sign_count
|
||||||
@backed_up = response.authenticator_data.backed_up?
|
@backed_up = response.authenticator_data.backed_up?
|
||||||
end
|
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
|
end
|
||||||
|
|||||||
@@ -36,51 +36,33 @@
|
|||||||
#
|
#
|
||||||
# == Supported Algorithms
|
# == Supported Algorithms
|
||||||
#
|
#
|
||||||
# By default, supports ES256 (ECDSA with P-256 and SHA-256) and RS256
|
# By default, supports ES256 (ECDSA with P-256 and SHA-256), EdDSA
|
||||||
# (RSASSA-PKCS1-v1_5 with SHA-256), which cover the vast majority of
|
# (Ed25519), and RS256 (RSASSA-PKCS1-v1_5 with SHA-256), which cover
|
||||||
# authenticators.
|
# the vast majority of authenticators.
|
||||||
class ActionPack::WebAuthn::PublicKeyCredential::CreationOptions < ActionPack::WebAuthn::PublicKeyCredential::Options
|
class ActionPack::WebAuthn::PublicKeyCredential::CreationOptions < ActionPack::WebAuthn::PublicKeyCredential::Options
|
||||||
ES256 = { type: "public-key", alg: -7 }.freeze
|
ES256 = { type: "public-key", alg: -7 }.freeze
|
||||||
|
EDDSA = { type: "public-key", alg: -8 }.freeze
|
||||||
RS256 = { type: "public-key", alg: -257 }.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.
|
validates :id, :name, :display_name, presence: true
|
||||||
#
|
validates :resident_key, inclusion: { in: RESIDENT_KEY_OPTIONS }
|
||||||
# ==== Options
|
validates :attestation, inclusion: { in: ATTESTATION_PREFERENCES }
|
||||||
#
|
|
||||||
# [+: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)
|
|
||||||
|
|
||||||
@id = id
|
def initialize(attributes = {})
|
||||||
@name = name
|
super
|
||||||
@display_name = display_name
|
self.resident_key = resident_key.to_sym
|
||||||
@resident_key = resident_key
|
self.attestation = attestation.to_sym
|
||||||
@exclude_credentials = exclude_credentials
|
validate!
|
||||||
@attestation = validate_attestation(attestation)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
# Returns a Hash suitable for JSON serialization and passing to the
|
# Returns a Hash suitable for JSON serialization and passing to the
|
||||||
@@ -96,37 +78,28 @@ class ActionPack::WebAuthn::PublicKeyCredential::CreationOptions < ActionPack::W
|
|||||||
},
|
},
|
||||||
pubKeyCredParams: [
|
pubKeyCredParams: [
|
||||||
ES256,
|
ES256,
|
||||||
|
EDDSA,
|
||||||
RS256
|
RS256
|
||||||
],
|
],
|
||||||
authenticatorSelection: {
|
authenticatorSelection: {
|
||||||
residentKey: @resident_key.to_s,
|
residentKey: resident_key.to_s,
|
||||||
requireResidentKey: @resident_key == :required,
|
requireResidentKey: resident_key == :required,
|
||||||
userVerification: user_verification.to_s
|
userVerification: user_verification.to_s
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if @exclude_credentials.any?
|
if exclude_credentials.any?
|
||||||
json[:excludeCredentials] = @exclude_credentials.map { |credential| exclude_credential_json(credential) }
|
json[:excludeCredentials] = exclude_credentials.map { |credential| exclude_credential_json(credential) }
|
||||||
end
|
end
|
||||||
|
|
||||||
if @attestation != :none
|
if attestation != :none
|
||||||
json[:attestation] = @attestation.to_s
|
json[:attestation] = attestation.to_s
|
||||||
end
|
end
|
||||||
|
|
||||||
json
|
json
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
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)
|
def exclude_credential_json(credential)
|
||||||
hash = { type: "public-key", id: credential.id }
|
hash = { type: "public-key", id: credential.id }
|
||||||
hash[:transports] = credential.transports if credential.transports.any?
|
hash[:transports] = credential.transports if credential.transports.any?
|
||||||
|
|||||||
@@ -1,26 +1,77 @@
|
|||||||
|
# = Action Pack WebAuthn Public Key Credential Options
|
||||||
|
#
|
||||||
|
# Abstract base class for WebAuthn ceremony options. Provides shared
|
||||||
|
# attributes and challenge generation for both CreationOptions (registration)
|
||||||
|
# and RequestOptions (authentication).
|
||||||
|
#
|
||||||
|
# This class should not be instantiated directly. Use CreationOptions or
|
||||||
|
# RequestOptions instead.
|
||||||
|
#
|
||||||
|
# == Challenge Generation
|
||||||
|
#
|
||||||
|
# Each options object generates a signed, expiring challenge via
|
||||||
|
# +ActionPack::WebAuthn.challenge_verifier+. The challenge is Base64URL-encoded
|
||||||
|
# and includes an embedded timestamp so the server can reject stale challenges.
|
||||||
|
#
|
||||||
|
# == Attributes
|
||||||
|
#
|
||||||
|
# [+user_verification+]
|
||||||
|
# Controls whether user verification (biometrics/PIN) is required. One of
|
||||||
|
# +:required+, +:preferred+, or +:discouraged+. Defaults to +:preferred+.
|
||||||
|
#
|
||||||
|
# [+relying_party+]
|
||||||
|
# The RelyingParty configuration. Defaults to +ActionPack::WebAuthn.relying_party+.
|
||||||
|
#
|
||||||
|
# [+challenge_expiration+]
|
||||||
|
# How long the challenge remains valid. Defaults vary by ceremony type
|
||||||
|
# (configured in the Railtie).
|
||||||
|
#
|
||||||
class ActionPack::WebAuthn::PublicKeyCredential::Options
|
class ActionPack::WebAuthn::PublicKeyCredential::Options
|
||||||
|
include ActiveModel::API
|
||||||
|
include ActiveModel::Attributes
|
||||||
|
|
||||||
CHALLENGE_LENGTH = 32
|
CHALLENGE_LENGTH = 32
|
||||||
USER_VERIFICATION_OPTIONS = [ :required, :preferred, :discouraged ].freeze
|
USER_VERIFICATION_OPTIONS = %i[ required preferred discouraged ].freeze
|
||||||
|
|
||||||
attr_reader :user_verification, :relying_party
|
attribute :user_verification, default: :preferred
|
||||||
|
attribute :relying_party, default: -> { ActionPack::WebAuthn.relying_party }
|
||||||
|
attribute :challenge_expiration
|
||||||
|
|
||||||
def initialize(user_verification: :preferred, relying_party: ActionPack::WebAuthn.relying_party)
|
validates :user_verification, inclusion: { in: USER_VERIFICATION_OPTIONS }
|
||||||
@user_verification = user_verification.to_sym
|
|
||||||
@relying_party = relying_party
|
|
||||||
|
|
||||||
unless USER_VERIFICATION_OPTIONS.include?(user_verification)
|
def initialize(attributes = {})
|
||||||
raise ArgumentError, "Invalid user verification option: #{user_verification.inspect}"
|
super
|
||||||
end
|
self.user_verification = user_verification.to_sym
|
||||||
end
|
end
|
||||||
|
|
||||||
# Returns a Base64URL-encoded random challenge. The challenge is generated
|
# Validates the options, raising +InvalidOptionsError+ if any are invalid.
|
||||||
# once and memoized for the lifetime of this object.
|
def validate!
|
||||||
|
super
|
||||||
|
rescue ActiveModel::ValidationError
|
||||||
|
raise ActionPack::WebAuthn::InvalidOptionsError, errors.full_messages.to_sentence
|
||||||
|
end
|
||||||
|
|
||||||
|
# Returns a human-readable representation of the options.
|
||||||
|
def inspect
|
||||||
|
attributes_string = attributes.map { |name, value| "#{name}: #{value.inspect}" }.join(", ")
|
||||||
|
"#<#{self.class.name} #{attributes_string}>"
|
||||||
|
end
|
||||||
|
|
||||||
|
# Returns a Base64URL-encoded signed challenge containing a random nonce and
|
||||||
|
# an embedded timestamp. The challenge is generated once and memoized for the
|
||||||
|
# lifetime of this object.
|
||||||
#
|
#
|
||||||
# The challenge must be stored server-side and verified when the client
|
# The timestamp allows the server to reject stale challenges. The expiration
|
||||||
# responds, to prevent replay attacks.
|
# 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
|
def challenge
|
||||||
@challenge ||= Base64.urlsafe_encode64(
|
@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
|
padding: false
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -24,21 +24,12 @@
|
|||||||
# The relying party (your application) configuration. Defaults to
|
# The relying party (your application) configuration. Defaults to
|
||||||
# +ActionPack::WebAuthn.relying_party+.
|
# +ActionPack::WebAuthn.relying_party+.
|
||||||
class ActionPack::WebAuthn::PublicKeyCredential::RequestOptions < ActionPack::WebAuthn::PublicKeyCredential::Options
|
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.
|
def initialize(attributes = {})
|
||||||
#
|
super
|
||||||
# ==== Options
|
validate!
|
||||||
#
|
|
||||||
# [+:credentials+]
|
|
||||||
# Required. The user's registered WebAuthn credentials.
|
|
||||||
#
|
|
||||||
# [+:relying_party+]
|
|
||||||
# Optional. The relying party configuration.
|
|
||||||
def initialize(credentials:, **attrs)
|
|
||||||
super(**attrs)
|
|
||||||
|
|
||||||
@credentials = credentials
|
|
||||||
end
|
end
|
||||||
|
|
||||||
# Returns a Hash suitable for JSON serialization and passing to the
|
# 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"
|
require "test_helper"
|
||||||
|
|
||||||
class Sessions::PasskeysControllerTest < ActionDispatch::IntegrationTest
|
class Sessions::PasskeysControllerTest < ActionDispatch::IntegrationTest
|
||||||
|
include WebauthnTestHelper
|
||||||
|
|
||||||
setup do
|
setup do
|
||||||
@identity = identities(:kevin)
|
@identity = identities(:kevin)
|
||||||
@private_key = OpenSSL::PKey::EC.generate("prime256v1")
|
|
||||||
|
|
||||||
@credential = @identity.credentials.create!(
|
@credential = @identity.passkeys.create!(
|
||||||
name: "Test Passkey",
|
name: "Test Passkey",
|
||||||
credential_id: Base64.urlsafe_encode64(SecureRandom.random_bytes(32), padding: false),
|
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,
|
sign_count: 0,
|
||||||
transports: [ "internal" ]
|
transports: [ "internal" ]
|
||||||
)
|
)
|
||||||
@@ -16,10 +17,9 @@ class Sessions::PasskeysControllerTest < ActionDispatch::IntegrationTest
|
|||||||
|
|
||||||
test "successful authentication" do
|
test "successful authentication" do
|
||||||
untenanted do
|
untenanted do
|
||||||
get new_session_url
|
challenge = request_webauthn_challenge
|
||||||
challenge = session[: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_response :redirect
|
||||||
assert cookies[:session_token].present?
|
assert cookies[:session_token].present?
|
||||||
@@ -29,10 +29,9 @@ class Sessions::PasskeysControllerTest < ActionDispatch::IntegrationTest
|
|||||||
|
|
||||||
test "updates sign count" do
|
test "updates sign count" do
|
||||||
untenanted do
|
untenanted do
|
||||||
get new_session_url
|
challenge = request_webauthn_challenge
|
||||||
challenge = session[: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
|
assert_equal 1, @credential.reload.sign_count
|
||||||
end
|
end
|
||||||
@@ -40,10 +39,9 @@ class Sessions::PasskeysControllerTest < ActionDispatch::IntegrationTest
|
|||||||
|
|
||||||
test "rejects invalid signature" do
|
test "rejects invalid signature" do
|
||||||
untenanted do
|
untenanted do
|
||||||
get new_session_url
|
challenge = request_webauthn_challenge
|
||||||
challenge = session[:webauthn_challenge]
|
|
||||||
|
|
||||||
params = assertion_params(challenge: challenge)
|
params = build_assertion_params(challenge: challenge, credential: @credential)
|
||||||
params[:passkey][:signature] = Base64.urlsafe_encode64("invalid", padding: false)
|
params[:passkey][:signature] = Base64.urlsafe_encode64("invalid", padding: false)
|
||||||
|
|
||||||
post session_passkey_url, params: params
|
post session_passkey_url, params: params
|
||||||
@@ -56,7 +54,7 @@ class Sessions::PasskeysControllerTest < ActionDispatch::IntegrationTest
|
|||||||
|
|
||||||
test "rejects unknown credential" do
|
test "rejects unknown credential" do
|
||||||
untenanted do
|
untenanted do
|
||||||
get new_session_url
|
request_webauthn_challenge
|
||||||
|
|
||||||
post session_passkey_url, params: {
|
post session_passkey_url, params: {
|
||||||
passkey: {
|
passkey: {
|
||||||
@@ -74,10 +72,9 @@ class Sessions::PasskeysControllerTest < ActionDispatch::IntegrationTest
|
|||||||
|
|
||||||
test "successful authentication via JSON" do
|
test "successful authentication via JSON" do
|
||||||
untenanted do
|
untenanted do
|
||||||
get new_session_url
|
challenge = request_webauthn_challenge
|
||||||
challenge = session[: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 :success
|
||||||
assert @response.parsed_body["session_token"].present?
|
assert @response.parsed_body["session_token"].present?
|
||||||
@@ -86,7 +83,7 @@ class Sessions::PasskeysControllerTest < ActionDispatch::IntegrationTest
|
|||||||
|
|
||||||
test "failed authentication via JSON" do
|
test "failed authentication via JSON" do
|
||||||
untenanted do
|
untenanted do
|
||||||
get new_session_url
|
request_webauthn_challenge
|
||||||
|
|
||||||
post session_passkey_url(format: :json), params: {
|
post session_passkey_url(format: :json), params: {
|
||||||
passkey: {
|
passkey: {
|
||||||
@@ -101,44 +98,4 @@ class Sessions::PasskeysControllerTest < ActionDispatch::IntegrationTest
|
|||||||
assert_equal "That passkey didn't work. Try again.", @response.parsed_body["message"]
|
assert_equal "That passkey didn't work. Try again.", @response.parsed_body["message"]
|
||||||
end
|
end
|
||||||
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
|
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"
|
require "test_helper"
|
||||||
|
|
||||||
class ActionPack::WebAuthn::Authenticator::AssertionResponseTest < ActiveSupport::TestCase
|
class ActionPack::WebAuthn::Authenticator::AssertionResponseTest < ActiveSupport::TestCase
|
||||||
|
include WebauthnTestHelper
|
||||||
|
|
||||||
setup do
|
setup do
|
||||||
ActionPack::WebAuthn::Current.host = "example.com"
|
ActionPack::WebAuthn::Current.host = "example.com"
|
||||||
|
|
||||||
@challenge = "test-challenge-123"
|
@challenge = webauthn_challenge
|
||||||
@origin = "https://example.com"
|
@origin = "https://example.com"
|
||||||
@client_data_json = {
|
@client_data_json = {
|
||||||
challenge: @challenge,
|
challenge: @challenge,
|
||||||
@@ -24,7 +26,9 @@ class ActionPack::WebAuthn::Authenticator::AssertionResponseTest < ActiveSupport
|
|||||||
client_data_json: @client_data_json,
|
client_data_json: @client_data_json,
|
||||||
authenticator_data: @authenticator_data,
|
authenticator_data: @authenticator_data,
|
||||||
signature: @signature,
|
signature: @signature,
|
||||||
credential: @credential
|
credential: @credential,
|
||||||
|
challenge: @challenge,
|
||||||
|
origin: @origin
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -36,7 +40,7 @@ class ActionPack::WebAuthn::Authenticator::AssertionResponseTest < ActiveSupport
|
|||||||
|
|
||||||
test "validate! succeeds with valid challenge, origin, type, and signature" do
|
test "validate! succeeds with valid challenge, origin, type, and signature" do
|
||||||
assert_nothing_raised do
|
assert_nothing_raised do
|
||||||
@response.validate!(challenge: @challenge, origin: @origin)
|
@response.validate!
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -51,11 +55,13 @@ class ActionPack::WebAuthn::Authenticator::AssertionResponseTest < ActiveSupport
|
|||||||
client_data_json: client_data_json,
|
client_data_json: client_data_json,
|
||||||
authenticator_data: @authenticator_data,
|
authenticator_data: @authenticator_data,
|
||||||
signature: sign(@authenticator_data, client_data_json),
|
signature: sign(@authenticator_data, client_data_json),
|
||||||
credential: @credential
|
credential: @credential,
|
||||||
|
challenge: @challenge,
|
||||||
|
origin: @origin
|
||||||
)
|
)
|
||||||
|
|
||||||
error = assert_raises(ActionPack::WebAuthn::Authenticator::Response::InvalidResponseError) do
|
error = assert_raises(ActionPack::WebAuthn::InvalidResponseError) do
|
||||||
response.validate!(challenge: @challenge, origin: @origin)
|
response.validate!
|
||||||
end
|
end
|
||||||
|
|
||||||
assert_equal "Client data type is not webauthn.get", error.message
|
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(
|
response = ActionPack::WebAuthn::Authenticator::AssertionResponse.new(
|
||||||
client_data_json: @client_data_json,
|
client_data_json: @client_data_json,
|
||||||
authenticator_data: @authenticator_data,
|
authenticator_data: @authenticator_data,
|
||||||
signature: "invalid-signature",
|
signature: Base64.urlsafe_encode64("invalid-signature", padding: false),
|
||||||
credential: @credential
|
credential: @credential,
|
||||||
|
challenge: @challenge,
|
||||||
|
origin: @origin
|
||||||
)
|
)
|
||||||
|
|
||||||
error = assert_raises(ActionPack::WebAuthn::Authenticator::Response::InvalidResponseError) do
|
error = assert_raises(ActionPack::WebAuthn::InvalidResponseError) do
|
||||||
response.validate!(challenge: @challenge, origin: @origin)
|
response.validate!
|
||||||
end
|
end
|
||||||
|
|
||||||
assert_equal "Invalid signature", error.message
|
assert_equal "Invalid signature", error.message
|
||||||
end
|
end
|
||||||
|
|
||||||
test "validate! raises when challenge does not match" do
|
test "validate! raises when challenge does not match" do
|
||||||
error = assert_raises(ActionPack::WebAuthn::Authenticator::Response::InvalidResponseError) do
|
@response.challenge = "wrong-challenge"
|
||||||
@response.validate!(challenge: "wrong-challenge", origin: @origin)
|
|
||||||
|
error = assert_raises(ActionPack::WebAuthn::InvalidResponseError) do
|
||||||
|
@response.validate!
|
||||||
end
|
end
|
||||||
|
|
||||||
assert_equal "Challenge does not match", error.message
|
assert_equal "Challenge does not match", error.message
|
||||||
end
|
end
|
||||||
|
|
||||||
test "validate! raises when origin does not match" do
|
test "validate! raises when origin does not match" do
|
||||||
error = assert_raises(ActionPack::WebAuthn::Authenticator::Response::InvalidResponseError) do
|
@response.origin = "https://evil.com"
|
||||||
@response.validate!(challenge: @challenge, origin: "https://evil.com")
|
|
||||||
|
error = assert_raises(ActionPack::WebAuthn::InvalidResponseError) do
|
||||||
|
@response.validate!
|
||||||
end
|
end
|
||||||
|
|
||||||
assert_equal "Origin does not match", error.message
|
assert_equal "Origin does not match", error.message
|
||||||
@@ -98,11 +110,14 @@ class ActionPack::WebAuthn::Authenticator::AssertionResponseTest < ActiveSupport
|
|||||||
client_data_json: @client_data_json,
|
client_data_json: @client_data_json,
|
||||||
authenticator_data: authenticator_data,
|
authenticator_data: authenticator_data,
|
||||||
signature: sign(authenticator_data, @client_data_json),
|
signature: sign(authenticator_data, @client_data_json),
|
||||||
credential: @credential
|
credential: @credential,
|
||||||
|
challenge: @challenge,
|
||||||
|
origin: @origin,
|
||||||
|
user_verification: :preferred
|
||||||
)
|
)
|
||||||
|
|
||||||
assert_nothing_raised do
|
assert_nothing_raised do
|
||||||
response.validate!(challenge: @challenge, origin: @origin, user_verification: :preferred)
|
response.validate!
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -112,11 +127,14 @@ class ActionPack::WebAuthn::Authenticator::AssertionResponseTest < ActiveSupport
|
|||||||
client_data_json: @client_data_json,
|
client_data_json: @client_data_json,
|
||||||
authenticator_data: authenticator_data,
|
authenticator_data: authenticator_data,
|
||||||
signature: sign(authenticator_data, @client_data_json),
|
signature: sign(authenticator_data, @client_data_json),
|
||||||
credential: @credential
|
credential: @credential,
|
||||||
|
challenge: @challenge,
|
||||||
|
origin: @origin,
|
||||||
|
user_verification: :required
|
||||||
)
|
)
|
||||||
|
|
||||||
assert_nothing_raised do
|
assert_nothing_raised do
|
||||||
response.validate!(challenge: @challenge, origin: @origin, user_verification: :required)
|
response.validate!
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -126,11 +144,14 @@ class ActionPack::WebAuthn::Authenticator::AssertionResponseTest < ActiveSupport
|
|||||||
client_data_json: @client_data_json,
|
client_data_json: @client_data_json,
|
||||||
authenticator_data: authenticator_data,
|
authenticator_data: authenticator_data,
|
||||||
signature: sign(authenticator_data, @client_data_json),
|
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
|
error = assert_raises(ActionPack::WebAuthn::InvalidResponseError) do
|
||||||
response.validate!(challenge: @challenge, origin: @origin, user_verification: :required)
|
response.validate!
|
||||||
end
|
end
|
||||||
|
|
||||||
assert_equal "User verification is required", error.message
|
assert_equal "User verification is required", error.message
|
||||||
@@ -146,7 +167,7 @@ class ActionPack::WebAuthn::Authenticator::AssertionResponseTest < ActiveSupport
|
|||||||
bytes = []
|
bytes = []
|
||||||
bytes.concat(rp_id_hash.bytes)
|
bytes.concat(rp_id_hash.bytes)
|
||||||
bytes << flags
|
bytes << flags
|
||||||
bytes.concat([sign_count].pack("N").bytes)
|
bytes.concat([ sign_count ].pack("N").bytes)
|
||||||
bytes.pack("C*")
|
bytes.pack("C*")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,43 @@
|
|||||||
require "test_helper"
|
require "test_helper"
|
||||||
|
|
||||||
class ActionPack::WebAuthn::Authenticator::AttestationResponseTest < ActiveSupport::TestCase
|
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
|
setup do
|
||||||
ActionPack::WebAuthn::Current.host = "example.com"
|
ActionPack::WebAuthn::Current.host = "example.com"
|
||||||
|
|
||||||
@challenge = "test-challenge-123"
|
@challenge = webauthn_challenge
|
||||||
@origin = "https://example.com"
|
@origin = "https://example.com"
|
||||||
@client_data_json = {
|
@client_data_json = {
|
||||||
challenge: @challenge,
|
challenge: @challenge,
|
||||||
@@ -14,7 +47,9 @@ class ActionPack::WebAuthn::Authenticator::AttestationResponseTest < ActiveSuppo
|
|||||||
|
|
||||||
@response = ActionPack::WebAuthn::Authenticator::AttestationResponse.new(
|
@response = ActionPack::WebAuthn::Authenticator::AttestationResponse.new(
|
||||||
client_data_json: @client_data_json,
|
client_data_json: @client_data_json,
|
||||||
attestation_object: build_attestation_object(user_verified: true)
|
attestation_object: ATTESTATION_NONE_VERIFIED,
|
||||||
|
challenge: @challenge,
|
||||||
|
origin: @origin
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -24,40 +59,49 @@ class ActionPack::WebAuthn::Authenticator::AttestationResponseTest < ActiveSuppo
|
|||||||
|
|
||||||
test "validate! succeeds with valid challenge, origin, and type" do
|
test "validate! succeeds with valid challenge, origin, and type" do
|
||||||
assert_nothing_raised do
|
assert_nothing_raised do
|
||||||
@response.validate!(challenge: @challenge, origin: @origin)
|
@response.validate!
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
test "validate! succeeds with user_verification preferred when not verified" do
|
test "validate! succeeds with user_verification preferred when not verified" do
|
||||||
response = ActionPack::WebAuthn::Authenticator::AttestationResponse.new(
|
response = ActionPack::WebAuthn::Authenticator::AttestationResponse.new(
|
||||||
client_data_json: @client_data_json,
|
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
|
assert_nothing_raised do
|
||||||
response.validate!(challenge: @challenge, origin: @origin, user_verification: :preferred)
|
response.validate!
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
test "validate! succeeds with user_verification required when verified" do
|
test "validate! succeeds with user_verification required when verified" do
|
||||||
response = ActionPack::WebAuthn::Authenticator::AttestationResponse.new(
|
response = ActionPack::WebAuthn::Authenticator::AttestationResponse.new(
|
||||||
client_data_json: @client_data_json,
|
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
|
assert_nothing_raised do
|
||||||
response.validate!(challenge: @challenge, origin: @origin, user_verification: :required)
|
response.validate!
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
test "validate! raises with user_verification required when not verified" do
|
test "validate! raises with user_verification required when not verified" do
|
||||||
response = ActionPack::WebAuthn::Authenticator::AttestationResponse.new(
|
response = ActionPack::WebAuthn::Authenticator::AttestationResponse.new(
|
||||||
client_data_json: @client_data_json,
|
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
|
error = assert_raises(ActionPack::WebAuthn::InvalidResponseError) do
|
||||||
response.validate!(challenge: @challenge, origin: @origin, user_verification: :required)
|
response.validate!
|
||||||
end
|
end
|
||||||
|
|
||||||
assert_equal "User verification is required", error.message
|
assert_equal "User verification is required", error.message
|
||||||
@@ -72,27 +116,33 @@ class ActionPack::WebAuthn::Authenticator::AttestationResponseTest < ActiveSuppo
|
|||||||
|
|
||||||
response = ActionPack::WebAuthn::Authenticator::AttestationResponse.new(
|
response = ActionPack::WebAuthn::Authenticator::AttestationResponse.new(
|
||||||
client_data_json: client_data_json,
|
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
|
error = assert_raises(ActionPack::WebAuthn::InvalidResponseError) do
|
||||||
response.validate!(challenge: @challenge, origin: @origin)
|
response.validate!
|
||||||
end
|
end
|
||||||
|
|
||||||
assert_equal "Client data type is not webauthn.create", error.message
|
assert_equal "Client data type is not webauthn.create", error.message
|
||||||
end
|
end
|
||||||
|
|
||||||
test "validate! raises when challenge does not match" do
|
test "validate! raises when challenge does not match" do
|
||||||
error = assert_raises(ActionPack::WebAuthn::Authenticator::Response::InvalidResponseError) do
|
@response.challenge = "wrong-challenge"
|
||||||
@response.validate!(challenge: "wrong-challenge", origin: @origin)
|
|
||||||
|
error = assert_raises(ActionPack::WebAuthn::InvalidResponseError) do
|
||||||
|
@response.validate!
|
||||||
end
|
end
|
||||||
|
|
||||||
assert_equal "Challenge does not match", error.message
|
assert_equal "Challenge does not match", error.message
|
||||||
end
|
end
|
||||||
|
|
||||||
test "validate! raises when origin does not match" do
|
test "validate! raises when origin does not match" do
|
||||||
error = assert_raises(ActionPack::WebAuthn::Authenticator::Response::InvalidResponseError) do
|
@response.origin = "https://evil.com"
|
||||||
@response.validate!(challenge: @challenge, origin: "https://evil.com")
|
|
||||||
|
error = assert_raises(ActionPack::WebAuthn::InvalidResponseError) do
|
||||||
|
@response.validate!
|
||||||
end
|
end
|
||||||
|
|
||||||
assert_equal "Origin does not match", error.message
|
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
|
test "validate! raises when attestation format is not registered" do
|
||||||
response = ActionPack::WebAuthn::Authenticator::AttestationResponse.new(
|
response = ActionPack::WebAuthn::Authenticator::AttestationResponse.new(
|
||||||
client_data_json: @client_data_json,
|
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
|
error = assert_raises(ActionPack::WebAuthn::InvalidResponseError) do
|
||||||
response.validate!(challenge: @challenge, origin: @origin)
|
response.validate!
|
||||||
end
|
end
|
||||||
|
|
||||||
assert_equal "Unsupported attestation format: packed", error.message
|
assert_equal "Unsupported attestation format: packed", error.message
|
||||||
@@ -120,94 +172,14 @@ class ActionPack::WebAuthn::Authenticator::AttestationResponseTest < ActiveSuppo
|
|||||||
|
|
||||||
response = ActionPack::WebAuthn::Authenticator::AttestationResponse.new(
|
response = ActionPack::WebAuthn::Authenticator::AttestationResponse.new(
|
||||||
client_data_json: @client_data_json,
|
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
|
assert verified
|
||||||
ensure
|
ensure
|
||||||
ActionPack::WebAuthn.attestation_verifiers.delete("packed")
|
ActionPack::WebAuthn.attestation_verifiers.delete("packed")
|
||||||
end
|
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
|
end
|
||||||
|
|||||||
@@ -1,26 +1,26 @@
|
|||||||
require "test_helper"
|
require "test_helper"
|
||||||
|
|
||||||
class ActionPack::WebAuthn::Authenticator::AttestationTest < ActiveSupport::TestCase
|
class ActionPack::WebAuthn::Authenticator::AttestationTest < ActiveSupport::TestCase
|
||||||
setup do
|
# Attestation object: {"fmt": "none", "attStmt": {}, "authData": <164 bytes>}
|
||||||
@rp_id_hash = Digest::SHA256.digest("example.com")
|
# Auth data contains:
|
||||||
@sign_count = 42
|
# rp_id_hash: SHA-256("example.com") (32 bytes)
|
||||||
@aaguid = SecureRandom.random_bytes(16)
|
# flags: 0x41 (user present + attested credential)
|
||||||
@credential_id = SecureRandom.random_bytes(32)
|
# 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
|
CREDENTIAL_ID_BASE64 = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8"
|
||||||
ec_key = OpenSSL::PKey::EC.generate("prime256v1")
|
SIGN_COUNT = 42
|
||||||
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
|
|
||||||
|
|
||||||
test "decodes attestation object" do
|
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 "none", attestation.format
|
||||||
assert_equal({}, attestation.attestation_statement)
|
assert_equal({}, attestation.attestation_statement)
|
||||||
@@ -28,108 +28,20 @@ class ActionPack::WebAuthn::Authenticator::AttestationTest < ActiveSupport::Test
|
|||||||
end
|
end
|
||||||
|
|
||||||
test "delegates credential_id to authenticator_data" do
|
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
|
end
|
||||||
|
|
||||||
test "delegates sign_count to authenticator_data" do
|
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
|
end
|
||||||
|
|
||||||
test "delegates public_key to authenticator_data" do
|
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
|
assert_instance_of OpenSSL::PKey::EC, attestation.public_key
|
||||||
end
|
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
|
end
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ class ActionPack::WebAuthn::Authenticator::AttestationVerifiers::NoneTest < Acti
|
|||||||
test "verify! raises with non-empty attestation statement" do
|
test "verify! raises with non-empty attestation statement" do
|
||||||
attestation = stub(attestation_statement: { "sig" => "abc" })
|
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: "")
|
@verifier.verify!(attestation, client_data_json: "")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -1,46 +1,64 @@
|
|||||||
require "test_helper"
|
require "test_helper"
|
||||||
|
|
||||||
class ActionPack::WebAuthn::Authenticator::DataTest < ActiveSupport::TestCase
|
class ActionPack::WebAuthn::Authenticator::DataTest < ActiveSupport::TestCase
|
||||||
setup do
|
# Common values:
|
||||||
@rp_id_hash = Digest::SHA256.digest("example.com")
|
# rp_id_hash: SHA-256("example.com") (32 bytes)
|
||||||
@sign_count = 42
|
# sign_count: 42
|
||||||
@aaguid = SecureRandom.random_bytes(16)
|
# aaguid: 00010203-0405-0607-0809-0a0b0c0d0e0f (16 bytes)
|
||||||
@credential_id = SecureRandom.random_bytes(32)
|
# 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
|
RP_ID_HASH = [ "a379a6f6eeafb9a55e378c118034e2751e682fab9f2d30ab13d2125586ce1947" ].pack("H*")
|
||||||
ec_key = OpenSSL::PKey::EC.generate("prime256v1")
|
SIGN_COUNT = 42
|
||||||
public_key_bn = ec_key.public_key.to_bn
|
CREDENTIAL_ID_BASE64 = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8"
|
||||||
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)
|
COSE_KEY_CBOR = [ "a50102032620012158202ba472104c686f39d4b623cc9324953e7053b47cae81" \
|
||||||
end
|
"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
|
test "decodes authenticator data without attested credential" do
|
||||||
flags = 0x01 # user present only
|
data = ActionPack::WebAuthn::Authenticator::Data.decode(AUTH_DATA_NO_CREDENTIAL)
|
||||||
bytes = build_authenticator_data(flags: flags, include_credential: false)
|
|
||||||
|
|
||||||
data = ActionPack::WebAuthn::Authenticator::Data.decode(bytes)
|
assert_equal RP_ID_HASH, data.relying_party_id_hash
|
||||||
|
assert_equal 0x01, data.flags
|
||||||
assert_equal @rp_id_hash, data.relying_party_id_hash
|
assert_equal SIGN_COUNT, data.sign_count
|
||||||
assert_equal flags, data.flags
|
|
||||||
assert_equal @sign_count, data.sign_count
|
|
||||||
assert_nil data.credential_id
|
assert_nil data.credential_id
|
||||||
assert_nil data.public_key_bytes
|
assert_nil data.public_key_bytes
|
||||||
end
|
end
|
||||||
|
|
||||||
test "decodes authenticator data with attested credential" do
|
test "decodes authenticator data with attested credential" do
|
||||||
flags = 0x41 # user present + attested credential data
|
data = ActionPack::WebAuthn::Authenticator::Data.decode(AUTH_DATA_WITH_CREDENTIAL)
|
||||||
bytes = build_authenticator_data(flags: flags, include_credential: true)
|
|
||||||
|
|
||||||
data = ActionPack::WebAuthn::Authenticator::Data.decode(bytes)
|
assert_equal RP_ID_HASH, data.relying_party_id_hash
|
||||||
|
assert_equal 0x41, data.flags
|
||||||
assert_equal @rp_id_hash, data.relying_party_id_hash
|
assert_equal SIGN_COUNT, data.sign_count
|
||||||
assert_equal flags, data.flags
|
assert_equal CREDENTIAL_ID_BASE64, data.credential_id
|
||||||
assert_equal @sign_count, data.sign_count
|
assert_equal COSE_KEY_CBOR, data.public_key_bytes
|
||||||
assert_equal Base64.urlsafe_encode64(@credential_id, padding: false), data.credential_id
|
|
||||||
assert_equal @cose_key, data.public_key_bytes
|
|
||||||
end
|
end
|
||||||
|
|
||||||
test "user_present? returns true when flag is set" do
|
test "user_present? returns true when flag is set" do
|
||||||
@@ -84,91 +102,44 @@ class ActionPack::WebAuthn::Authenticator::DataTest < ActiveSupport::TestCase
|
|||||||
end
|
end
|
||||||
|
|
||||||
test "public_key returns OpenSSL key when public_key_bytes present" do
|
test "public_key returns OpenSSL key when public_key_bytes present" do
|
||||||
flags = 0x41
|
data = ActionPack::WebAuthn::Authenticator::Data.decode(AUTH_DATA_WITH_CREDENTIAL)
|
||||||
bytes = build_authenticator_data(flags: flags, include_credential: true)
|
|
||||||
data = ActionPack::WebAuthn::Authenticator::Data.decode(bytes)
|
|
||||||
|
|
||||||
assert_instance_of OpenSSL::PKey::EC, data.public_key
|
assert_instance_of OpenSSL::PKey::EC, data.public_key
|
||||||
end
|
end
|
||||||
|
|
||||||
test "public_key returns nil when public_key_bytes not present" do
|
test "public_key returns nil when public_key_bytes not present" do
|
||||||
flags = 0x01
|
data = ActionPack::WebAuthn::Authenticator::Data.decode(AUTH_DATA_NO_CREDENTIAL)
|
||||||
bytes = build_authenticator_data(flags: flags, include_credential: false)
|
|
||||||
data = ActionPack::WebAuthn::Authenticator::Data.decode(bytes)
|
|
||||||
|
|
||||||
assert_nil data.public_key
|
assert_nil data.public_key
|
||||||
end
|
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
|
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)
|
def build_data_with_flags(flags)
|
||||||
ActionPack::WebAuthn::Authenticator::Data.new(
|
ActionPack::WebAuthn::Authenticator::Data.new(
|
||||||
bytes: [],
|
bytes: [],
|
||||||
relying_party_id_hash: @rp_id_hash,
|
relying_party_id_hash: RP_ID_HASH,
|
||||||
flags: flags,
|
flags: flags,
|
||||||
sign_count: 0,
|
sign_count: 0,
|
||||||
credential_id: nil,
|
credential_id: nil,
|
||||||
public_key_bytes: nil
|
public_key_bytes: nil
|
||||||
)
|
)
|
||||||
end
|
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
|
end
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
require "test_helper"
|
require "test_helper"
|
||||||
|
|
||||||
class ActionPack::WebAuthn::Authenticator::ResponseTest < ActiveSupport::TestCase
|
class ActionPack::WebAuthn::Authenticator::ResponseTest < ActiveSupport::TestCase
|
||||||
|
include WebauthnTestHelper
|
||||||
|
|
||||||
setup do
|
setup do
|
||||||
ActionPack::WebAuthn::Current.host = "example.com"
|
ActionPack::WebAuthn::Current.host = "example.com"
|
||||||
|
|
||||||
@challenge = "test-challenge-123"
|
@challenge = webauthn_challenge
|
||||||
@origin = "https://example.com"
|
@origin = "https://example.com"
|
||||||
@client_data_json = {
|
@client_data_json = {
|
||||||
challenge: @challenge,
|
challenge: @challenge,
|
||||||
@@ -15,7 +17,9 @@ class ActionPack::WebAuthn::Authenticator::ResponseTest < ActiveSupport::TestCas
|
|||||||
@authenticator_data = build_authenticator_data
|
@authenticator_data = build_authenticator_data
|
||||||
@response = TestableResponse.new(
|
@response = TestableResponse.new(
|
||||||
client_data_json: @client_data_json,
|
client_data_json: @client_data_json,
|
||||||
authenticator_data: @authenticator_data
|
authenticator_data: @authenticator_data,
|
||||||
|
challenge: @challenge,
|
||||||
|
origin: @origin
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -34,28 +38,34 @@ class ActionPack::WebAuthn::Authenticator::ResponseTest < ActiveSupport::TestCas
|
|||||||
end
|
end
|
||||||
|
|
||||||
test "valid? returns true when challenge and origin match" do
|
test "valid? returns true when challenge and origin match" do
|
||||||
assert @response.valid?(challenge: @challenge, origin: @origin)
|
assert @response.valid?
|
||||||
end
|
end
|
||||||
|
|
||||||
test "valid? returns false when challenge does not match" do
|
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
|
end
|
||||||
|
|
||||||
test "valid? returns false when origin does not match" do
|
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
|
end
|
||||||
|
|
||||||
test "validate! raises when challenge does not match" do
|
test "validate! raises when challenge does not match" do
|
||||||
error = assert_raises(ActionPack::WebAuthn::Authenticator::Response::InvalidResponseError) do
|
@response.challenge = "wrong-challenge"
|
||||||
@response.validate!(challenge: "wrong-challenge", origin: @origin)
|
|
||||||
|
error = assert_raises(ActionPack::WebAuthn::InvalidResponseError) do
|
||||||
|
@response.validate!
|
||||||
end
|
end
|
||||||
|
|
||||||
assert_equal "Challenge does not match", error.message
|
assert_equal "Challenge does not match", error.message
|
||||||
end
|
end
|
||||||
|
|
||||||
test "validate! raises when origin does not match" do
|
test "validate! raises when origin does not match" do
|
||||||
error = assert_raises(ActionPack::WebAuthn::Authenticator::Response::InvalidResponseError) do
|
@response.origin = "https://evil.com"
|
||||||
@response.validate!(challenge: @challenge, origin: "https://evil.com")
|
|
||||||
|
error = assert_raises(ActionPack::WebAuthn::InvalidResponseError) do
|
||||||
|
@response.validate!
|
||||||
end
|
end
|
||||||
|
|
||||||
assert_equal "Origin does not match", error.message
|
assert_equal "Origin does not match", error.message
|
||||||
@@ -71,11 +81,13 @@ class ActionPack::WebAuthn::Authenticator::ResponseTest < ActiveSupport::TestCas
|
|||||||
|
|
||||||
response = TestableResponse.new(
|
response = TestableResponse.new(
|
||||||
client_data_json: client_data_json,
|
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
|
error = assert_raises(ActionPack::WebAuthn::InvalidResponseError) do
|
||||||
response.validate!(challenge: @challenge, origin: @origin)
|
response.validate!
|
||||||
end
|
end
|
||||||
|
|
||||||
assert_equal "Cross-origin requests are not supported", error.message
|
assert_equal "Cross-origin requests are not supported", error.message
|
||||||
@@ -89,17 +101,19 @@ class ActionPack::WebAuthn::Authenticator::ResponseTest < ActiveSupport::TestCas
|
|||||||
bytes = []
|
bytes = []
|
||||||
bytes.concat(rp_id_hash.bytes)
|
bytes.concat(rp_id_hash.bytes)
|
||||||
bytes << flags
|
bytes << flags
|
||||||
bytes.concat([sign_count].pack("N").bytes)
|
bytes.concat([ sign_count ].pack("N").bytes)
|
||||||
|
|
||||||
wrong_rp_data = ActionPack::WebAuthn::Authenticator::Data.decode(bytes.pack("C*"))
|
wrong_rp_data = ActionPack::WebAuthn::Authenticator::Data.decode(bytes.pack("C*"))
|
||||||
|
|
||||||
response = TestableResponse.new(
|
response = TestableResponse.new(
|
||||||
client_data_json: @client_data_json,
|
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
|
error = assert_raises(ActionPack::WebAuthn::InvalidResponseError) do
|
||||||
response.validate!(challenge: @challenge, origin: @origin)
|
response.validate!
|
||||||
end
|
end
|
||||||
|
|
||||||
assert_equal "Relying party ID does not match", error.message
|
assert_equal "Relying party ID does not match", error.message
|
||||||
@@ -115,11 +129,13 @@ class ActionPack::WebAuthn::Authenticator::ResponseTest < ActiveSupport::TestCas
|
|||||||
|
|
||||||
response = TestableResponse.new(
|
response = TestableResponse.new(
|
||||||
client_data_json: client_data_json,
|
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
|
error = assert_raises(ActionPack::WebAuthn::InvalidResponseError) do
|
||||||
response.validate!(challenge: @challenge, origin: @origin)
|
response.validate!
|
||||||
end
|
end
|
||||||
|
|
||||||
assert_equal "Token binding is not supported", error.message
|
assert_equal "Token binding is not supported", error.message
|
||||||
@@ -134,7 +150,7 @@ class ActionPack::WebAuthn::Authenticator::ResponseTest < ActiveSupport::TestCas
|
|||||||
bytes = []
|
bytes = []
|
||||||
bytes.concat(rp_id_hash.bytes)
|
bytes.concat(rp_id_hash.bytes)
|
||||||
bytes << flags
|
bytes << flags
|
||||||
bytes.concat([sign_count].pack("N").bytes)
|
bytes.concat([ sign_count ].pack("N").bytes)
|
||||||
|
|
||||||
ActionPack::WebAuthn::Authenticator::Data.decode(bytes.pack("C*"))
|
ActionPack::WebAuthn::Authenticator::Data.decode(bytes.pack("C*"))
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -152,7 +152,7 @@ class ActionPack::WebAuthn::CborDecoderTest < ActiveSupport::TestCase
|
|||||||
end
|
end
|
||||||
|
|
||||||
test "raises error for reserved additional info values" do
|
test "raises error for reserved additional info values" do
|
||||||
assert_raises(ActionPack::WebAuthn::CborDecoder::DecodeError) do
|
assert_raises(ActionPack::WebAuthn::InvalidCborError) do
|
||||||
decode("1c")
|
decode("1c")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -165,6 +165,10 @@ class ActionPack::WebAuthn::CborDecoderTest < ActiveSupport::TestCase
|
|||||||
assert_equal [], decode("9fff")
|
assert_equal [], decode("9fff")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "decodes empty indefinite length map" do
|
||||||
|
assert_equal({}, decode("bfff"))
|
||||||
|
end
|
||||||
|
|
||||||
test "decodes indefinite length map" do
|
test "decodes indefinite length map" do
|
||||||
assert_equal({ "a" => 1, "b" => 2 }, decode("bf616101616202ff"))
|
assert_equal({ "a" => 1, "b" => 2 }, decode("bf616101616202ff"))
|
||||||
end
|
end
|
||||||
@@ -228,7 +232,7 @@ class ActionPack::WebAuthn::CborDecoderTest < ActiveSupport::TestCase
|
|||||||
end
|
end
|
||||||
|
|
||||||
test "raises error for unsupported simple value" do
|
test "raises error for unsupported simple value" do
|
||||||
assert_raises(ActionPack::WebAuthn::CborDecoder::DecodeError) do
|
assert_raises(ActionPack::WebAuthn::InvalidCborError) do
|
||||||
decode("e0")
|
decode("e0")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -243,28 +247,28 @@ class ActionPack::WebAuthn::CborDecoderTest < ActiveSupport::TestCase
|
|||||||
end
|
end
|
||||||
|
|
||||||
test "raises error for empty input" do
|
test "raises error for empty input" do
|
||||||
assert_raises(ActionPack::WebAuthn::CborDecoder::DecodeError) do
|
assert_raises(ActionPack::WebAuthn::InvalidCborError) do
|
||||||
ActionPack::WebAuthn::CborDecoder.decode([])
|
ActionPack::WebAuthn::CborDecoder.decode([])
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
test "raises error for truncated byte string" do
|
test "raises error for truncated byte string" do
|
||||||
# 0x44 = byte string of length 4, but only 2 bytes follow
|
# 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")
|
decode("440102")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
test "raises error for truncated integer" do
|
test "raises error for truncated integer" do
|
||||||
# 0x19 = 2-byte integer follows, but only 1 byte provided
|
# 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")
|
decode("19ff")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
test "raises error for truncated array" do
|
test "raises error for truncated array" do
|
||||||
# 0x82 = array of 2 items, but only 1 provided
|
# 0x82 = array of 2 items, but only 1 provided
|
||||||
assert_raises(ActionPack::WebAuthn::CborDecoder::DecodeError) do
|
assert_raises(ActionPack::WebAuthn::InvalidCborError) do
|
||||||
decode("8201")
|
decode("8201")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -274,7 +278,7 @@ class ActionPack::WebAuthn::CborDecoderTest < ActiveSupport::TestCase
|
|||||||
# 0x81 = array of 1 item
|
# 0x81 = array of 1 item
|
||||||
deeply_nested = "81" * 20 + "01"
|
deeply_nested = "81" * 20 + "01"
|
||||||
|
|
||||||
error = assert_raises(ActionPack::WebAuthn::CborDecoder::DecodeError) do
|
error = assert_raises(ActionPack::WebAuthn::InvalidCborError) do
|
||||||
decode(deeply_nested)
|
decode(deeply_nested)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -282,7 +286,7 @@ class ActionPack::WebAuthn::CborDecoderTest < ActiveSupport::TestCase
|
|||||||
end
|
end
|
||||||
|
|
||||||
test "raises error for input exceeding max size" do
|
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)
|
ActionPack::WebAuthn::CborDecoder.decode([ 0x01 ], max_size: 0)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -1,33 +1,64 @@
|
|||||||
require "test_helper"
|
require "test_helper"
|
||||||
|
|
||||||
class ActionPack::WebAuthn::CoseKeyTest < ActiveSupport::TestCase
|
class ActionPack::WebAuthn::CoseKeyTest < ActiveSupport::TestCase
|
||||||
setup do
|
# EC2/ES256 P-256 public key (32-byte x and y coordinates)
|
||||||
# Generate a real EC key for valid test data
|
EC2_X = [ "2ba472104c686f39d4b623cc9324953e7053b47cae818e8cf774203a4f51af71" ].pack("H*")
|
||||||
ec_key = OpenSSL::PKey::EC.generate("prime256v1")
|
EC2_Y = [ "69cb8ac519bdd929e2bdbe79e9f9b8d14c2d89a7cbd324647a1ccd68b8de3ca0" ].pack("H*")
|
||||||
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]
|
|
||||||
|
|
||||||
|
# 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 = {
|
@ec2_parameters = {
|
||||||
1 => 2, # kty: EC2
|
1 => 2, # kty: EC2
|
||||||
3 => -7, # alg: ES256
|
3 => -7, # alg: ES256
|
||||||
-1 => 1, # crv: P-256
|
-1 => 1, # crv: P-256
|
||||||
-2 => @ec2_x,
|
-2 => EC2_X,
|
||||||
-3 => @ec2_y
|
-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 = {
|
@rsa_parameters = {
|
||||||
1 => 3, # kty: RSA
|
1 => 3, # kty: RSA
|
||||||
3 => -257, # alg: RS256
|
3 => -257, # alg: RS256
|
||||||
-1 => @rsa_n,
|
-1 => RSA_N,
|
||||||
-2 => @rsa_e
|
-2 => RSA_E
|
||||||
|
}
|
||||||
|
|
||||||
|
@okp_parameters = {
|
||||||
|
1 => 1, # kty: OKP
|
||||||
|
3 => -8, # alg: EdDSA
|
||||||
|
-1 => 6, # crv: Ed25519
|
||||||
|
-2 => ED25519_X
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -44,16 +75,21 @@ class ActionPack::WebAuthn::CoseKeyTest < ActiveSupport::TestCase
|
|||||||
end
|
end
|
||||||
|
|
||||||
test "decodes EC2/ES256 key from CBOR" do
|
test "decodes EC2/ES256 key from CBOR" do
|
||||||
cbor = encode_cbor(@ec2_parameters)
|
key = ActionPack::WebAuthn::CoseKey.decode(EC2_CBOR)
|
||||||
key = ActionPack::WebAuthn::CoseKey.decode(cbor)
|
|
||||||
|
|
||||||
assert_equal 2, key.key_type
|
assert_equal 2, key.key_type
|
||||||
assert_equal(-7, key.algorithm)
|
assert_equal(-7, key.algorithm)
|
||||||
end
|
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
|
test "decodes RSA/RS256 key from CBOR" do
|
||||||
cbor = encode_cbor(@rsa_parameters)
|
key = ActionPack::WebAuthn::CoseKey.decode(RSA_CBOR)
|
||||||
key = ActionPack::WebAuthn::CoseKey.decode(cbor)
|
|
||||||
|
|
||||||
assert_equal 3, key.key_type
|
assert_equal 3, key.key_type
|
||||||
assert_equal(-257, key.algorithm)
|
assert_equal(-257, key.algorithm)
|
||||||
@@ -72,6 +108,18 @@ class ActionPack::WebAuthn::CoseKeyTest < ActiveSupport::TestCase
|
|||||||
assert_equal "prime256v1", openssl_key.group.curve_name
|
assert_equal "prime256v1", openssl_key.group.curve_name
|
||||||
end
|
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
|
test "converts RSA/RS256 key to OpenSSL RSA key" do
|
||||||
key = ActionPack::WebAuthn::CoseKey.new(
|
key = ActionPack::WebAuthn::CoseKey.new(
|
||||||
key_type: 3,
|
key_type: 3,
|
||||||
@@ -92,13 +140,28 @@ 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
|
key.to_openssl_key
|
||||||
end
|
end
|
||||||
|
|
||||||
assert_match(/99\/-7/, error.message)
|
assert_match(/99\/-7/, error.message)
|
||||||
end
|
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
|
test "raises error for unsupported EC curve" do
|
||||||
parameters = @ec2_parameters.merge(-1 => 2) # P-384 instead of P-256
|
parameters = @ec2_parameters.merge(-1 => 2) # P-384 instead of P-256
|
||||||
key = ActionPack::WebAuthn::CoseKey.new(
|
key = ActionPack::WebAuthn::CoseKey.new(
|
||||||
@@ -107,55 +170,86 @@ class ActionPack::WebAuthn::CoseKeyTest < ActiveSupport::TestCase
|
|||||||
parameters: parameters
|
parameters: parameters
|
||||||
)
|
)
|
||||||
|
|
||||||
error = assert_raises(ActionPack::WebAuthn::CoseKey::UnsupportedKeyTypeError) do
|
error = assert_raises(ActionPack::WebAuthn::UnsupportedKeyTypeError) do
|
||||||
key.to_openssl_key
|
key.to_openssl_key
|
||||||
end
|
end
|
||||||
|
|
||||||
assert_match(/curve/, error.message.downcase)
|
assert_match(/curve/, error.message.downcase)
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
test "raises error for EC2 key with missing coordinates" do
|
||||||
def encode_cbor(hash)
|
parameters = @ec2_parameters.except(-3) # missing y coordinate
|
||||||
# CBOR map encoding
|
key = ActionPack::WebAuthn::CoseKey.new(
|
||||||
bytes = [ 0xa0 + hash.size ] # map with n items
|
key_type: 2,
|
||||||
|
algorithm: -7,
|
||||||
|
parameters: parameters
|
||||||
|
)
|
||||||
|
|
||||||
hash.each do |key, value|
|
error = assert_raises(ActionPack::WebAuthn::InvalidKeyError) do
|
||||||
bytes.concat(encode_cbor_integer(key))
|
key.to_openssl_key
|
||||||
bytes.concat(encode_cbor_value(value))
|
|
||||||
end
|
end
|
||||||
|
|
||||||
bytes.pack("C*")
|
assert_match(/missing ec2 key coordinates/i, error.message)
|
||||||
end
|
end
|
||||||
|
|
||||||
def encode_cbor_integer(int)
|
test "raises error for EC2 key with wrong coordinate length" do
|
||||||
if int >= 0 && int <= 23
|
parameters = @ec2_parameters.merge(-2 => "\x00" * 16) # 16 bytes instead of 32
|
||||||
[ int ]
|
key = ActionPack::WebAuthn::CoseKey.new(
|
||||||
elsif int >= 0 && int <= 255
|
key_type: 2,
|
||||||
[ 0x18, int ]
|
algorithm: -7,
|
||||||
elsif int >= -24 && int < 0
|
parameters: parameters
|
||||||
[ 0x20 - int - 1 ]
|
)
|
||||||
elsif int >= -256 && int < -24
|
|
||||||
[ 0x38, -int - 1 ]
|
error = assert_raises(ActionPack::WebAuthn::InvalidKeyError) do
|
||||||
else
|
key.to_openssl_key
|
||||||
# 16-bit negative integer
|
|
||||||
val = -int - 1
|
|
||||||
[ 0x39, (val >> 8) & 0xff, val & 0xff ]
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def encode_cbor_value(value)
|
assert_match(/invalid ec2 coordinate length/i, error.message)
|
||||||
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
|
||||||
|
|
||||||
|
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
|
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
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -22,9 +22,12 @@ class ActionPack::WebAuthn::PublicKeyCredential::CreationOptionsTest < ActiveSup
|
|||||||
assert_match(/\A[A-Za-z0-9_-]+\z/, @options.challenge)
|
assert_match(/\A[A-Za-z0-9_-]+\z/, @options.challenge)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "generates challenge of correct length" do
|
test "generates signed challenge containing nonce" do
|
||||||
decoded = Base64.urlsafe_decode64(@options.challenge)
|
signed_message = Base64.urlsafe_decode64(@options.challenge)
|
||||||
assert_equal 32, decoded.bytesize
|
nonce = ActionPack::WebAuthn.challenge_verifier.verified(signed_message)
|
||||||
|
|
||||||
|
assert_not_nil nonce
|
||||||
|
assert_equal 32, Base64.strict_decode64(nonce).bytesize
|
||||||
end
|
end
|
||||||
|
|
||||||
test "as_json" do
|
test "as_json" do
|
||||||
@@ -39,6 +42,7 @@ class ActionPack::WebAuthn::PublicKeyCredential::CreationOptionsTest < ActiveSup
|
|||||||
|
|
||||||
assert_equal [
|
assert_equal [
|
||||||
{ type: "public-key", alg: -7 },
|
{ type: "public-key", alg: -7 },
|
||||||
|
{ type: "public-key", alg: -8 },
|
||||||
{ type: "public-key", alg: -257 }
|
{ type: "public-key", alg: -257 }
|
||||||
], @options.as_json[:pubKeyCredParams]
|
], @options.as_json[:pubKeyCredParams]
|
||||||
|
|
||||||
@@ -101,7 +105,7 @@ class ActionPack::WebAuthn::PublicKeyCredential::CreationOptionsTest < ActiveSup
|
|||||||
end
|
end
|
||||||
|
|
||||||
test "raises with invalid attestation preference" do
|
test "raises with invalid attestation preference" do
|
||||||
assert_raises(ArgumentError) do
|
assert_raises(ActionPack::WebAuthn::InvalidOptionsError) do
|
||||||
ActionPack::WebAuthn::PublicKeyCredential::CreationOptions.new(
|
ActionPack::WebAuthn::PublicKeyCredential::CreationOptions.new(
|
||||||
id: "user-123",
|
id: "user-123",
|
||||||
name: "user@example.com",
|
name: "user@example.com",
|
||||||
|
|||||||
@@ -22,9 +22,12 @@ class ActionPack::WebAuthn::PublicKeyCredential::RequestOptionsTest < ActiveSupp
|
|||||||
assert_match(/\A[A-Za-z0-9_-]+\z/, @options.challenge)
|
assert_match(/\A[A-Za-z0-9_-]+\z/, @options.challenge)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "generates challenge of correct length" do
|
test "generates signed challenge containing nonce" do
|
||||||
decoded = Base64.urlsafe_decode64(@options.challenge)
|
signed_message = Base64.urlsafe_decode64(@options.challenge)
|
||||||
assert_equal 32, decoded.bytesize
|
nonce = ActionPack::WebAuthn.challenge_verifier.verified(signed_message)
|
||||||
|
|
||||||
|
assert_not_nil nonce
|
||||||
|
assert_equal 32, Base64.strict_decode64(nonce).bytesize
|
||||||
end
|
end
|
||||||
|
|
||||||
test "as_json" do
|
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
|
class MagicLinkMailerPreview < ActionMailer::Preview
|
||||||
def magic_link
|
def magic_link
|
||||||
identity = Identity.new email_address: "test@example.com"
|
identity = Identity.all.sample
|
||||||
magic_link = MagicLink.new(identity: identity)
|
magic_link = MagicLink.new(identity: identity)
|
||||||
magic_link.valid?
|
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