ox
Version:
821 lines (763 loc) • 26.1 kB
text/typescript
import * as Base64 from '../core/Base64.js'
import * as Bytes from '../core/Bytes.js'
import * as Cbor from '../core/Cbor.js'
import * as CoseKey from '../core/CoseKey.js'
import * as Errors from '../core/Errors.js'
import * as Hash from '../core/Hash.js'
import * as Hex from '../core/Hex.js'
import type { OneOf } from '../core/internal/types.js'
import * as internal from '../core/internal/webauthn.js'
import * as P256 from '../core/P256.js'
import * as PublicKey from '../core/PublicKey.js'
import * as Signature from '../core/Signature.js'
import type * as Credential_ from './Credential.js'
import {
base64UrlOptions,
bufferSourceToBytes,
bytesToArrayBuffer,
deserializeExtensions,
responseKeys,
serializeExtensions,
} from './internal/utils.js'
import type * as Types from './Types.js'
export const createChallenge = Uint8Array.from([
105, 171, 180, 181, 160, 222, 75, 198, 42, 42, 32, 31, 141, 37, 186, 233,
])
/** Response from a WebAuthn registration ceremony. */
export type Response<serialized extends boolean = false> = {
credential: Credential_.Credential<serialized>
counter: number
userVerified?: true | undefined
backedUp?: boolean | undefined
deviceType?: 'multiDevice' | 'singleDevice' | undefined
}
/**
* Creates a new WebAuthn P256 Credential, which can be stored and later used for signing.
*
* @example
* ```ts twoslash
* import { Registration } from 'ox/webauthn'
*
* const credential = await Registration.create({ name: 'Example' }) // [!code focus]
* // @log: {
* // @log: id: 'oZ48...',
* // @log: publicKey: { x: 51421...5123n, y: 12345...6789n },
* // @log: raw: PublicKeyCredential {},
* // @log: }
* ```
*
* @param options - Credential creation options.
* @returns A WebAuthn P256 credential.
*/
export async function create(
options: create.Options,
): Promise<Credential_.Credential> {
const {
createFn = (opts: Types.CredentialCreationOptions | undefined) =>
window.navigator.credentials.create(opts as never),
...rest
} = options
const creationOptions =
'publicKey' in rest
? (rest as Types.CredentialCreationOptions)
: getOptions(rest as never)
try {
const credential = (await createFn(
creationOptions as never,
)) as Types.PublicKeyCredential
if (!credential) throw new CreateFailedError()
const response =
credential.response as Types.AuthenticatorAttestationResponse
// Eagerly copy ArrayBuffer data to avoid cross-origin access errors
// when browser extensions (e.g. 1Password on Firefox) replace
// `navigator.credentials` with a cross-compartment proxy.
const attestationObject = response.attestationObject
const clientDataJSON = response.clientDataJSON
const id = credential.id
const publicKey = await internal.parseCredentialPublicKey(
response,
attestationObject,
)
return {
attestationObject,
clientDataJSON,
id,
publicKey,
raw: credential,
}
} catch (error) {
throw new CreateFailedError({
cause: error as Error,
})
}
}
export declare namespace create {
type Options = OneOf<
| (getOptions.Options & {
/**
* Credential creation function. Useful for environments that do not support
* the WebAuthn API natively (i.e. React Native or testing environments).
*
* @default window.navigator.credentials.create
*/
createFn?:
| ((
options?: Types.CredentialCreationOptions | undefined,
) => Promise<Types.Credential | null>)
| undefined
})
| Types.CredentialCreationOptions
>
type ErrorType =
| getOptions.ErrorType
| internal.parseCredentialPublicKey.ErrorType
| Errors.GlobalErrorType
}
/**
* Returns the creation options for a P256 WebAuthn Credential to be used with
* the Web Authentication API.
*
* @example
* ```ts twoslash
* import { Registration } from 'ox/webauthn'
*
* const options = Registration.getOptions({ name: 'Example' })
*
* const credential = await window.navigator.credentials.create(options)
* ```
*
* @param options - Options.
* @returns The credential creation options.
*/
export function getOptions(
options: getOptions.Options,
): Types.CredentialCreationOptions {
const {
attestation = 'none',
authenticatorSelection = {
residentKey: 'preferred',
requireResidentKey: false,
userVerification: 'required',
},
challenge = createChallenge,
excludeCredentialIds,
extensions,
name: name_,
rp = {
id: window.location.hostname,
name: window.document.title,
},
user,
} = options
const name = (user?.name ?? name_)!
return {
publicKey: {
attestation,
authenticatorSelection,
challenge:
typeof challenge === 'string' ? Bytes.fromHex(challenge) : challenge,
...(excludeCredentialIds
? {
excludeCredentials: excludeCredentialIds?.map((id) => ({
id: Base64.toBytes(id),
type: 'public-key',
})),
}
: {}),
pubKeyCredParams: [
{
type: 'public-key',
alg: -7, // p256
},
],
rp,
user: {
id: user?.id ?? Hash.keccak256(Bytes.fromString(name), { as: 'Bytes' }),
name,
displayName: user?.displayName ?? name,
},
...(extensions && { extensions }),
},
}
}
export declare namespace getOptions {
type Options = {
/**
* A string specifying the relying party's preference for how the attestation statement
* (i.e., provision of verifiable evidence of the authenticity of the authenticator and its data)
* is conveyed during credential creation.
*/
attestation?:
| Types.PublicKeyCredentialCreationOptions['attestation']
| undefined
/**
* An object whose properties are criteria used to filter out the potential authenticators
* for the credential creation operation.
*/
authenticatorSelection?:
| Types.PublicKeyCredentialCreationOptions['authenticatorSelection']
| undefined
/**
* An `ArrayBuffer`, `TypedArray`, or `DataView` used as a cryptographic challenge.
*/
challenge?:
| Hex.Hex
| Types.PublicKeyCredentialCreationOptions['challenge']
| undefined
/**
* List of credential IDs to exclude from the creation. This property can be used
* to prevent creation of a credential if it already exists.
*/
excludeCredentialIds?: readonly string[] | undefined
/**
* List of Web Authentication API credentials to use during creation or authentication.
*/
extensions?:
| Types.PublicKeyCredentialCreationOptions['extensions']
| undefined
/**
* An object describing the relying party that requested the credential creation
*/
rp?:
| {
id: string
name: string
}
| undefined
/**
* A numerical hint, in milliseconds, which indicates the time the calling web app is willing to wait for the creation operation to complete.
*/
timeout?: Types.PublicKeyCredentialCreationOptions['timeout'] | undefined
} & OneOf<
| {
/** Name for the credential (user.name). */
name: string
user?:
| {
displayName?: string
id?: Types.BufferSource
name: string
}
| undefined
}
| {
name?: string | undefined
/**
* An object describing the user account for which the credential is generated.
*/
user: {
displayName?: string
id?: Types.BufferSource
name: string
}
}
>
type ErrorType =
| Base64.toBytes.ErrorType
| Hash.keccak256.ErrorType
| Bytes.fromString.ErrorType
| Errors.GlobalErrorType
}
/**
* Serializes a registration response into a JSON-serializable
* format, converting `ArrayBuffer` fields to base64url strings
* and the public key to a hex string.
*
* @example
* ```ts twoslash
* import { Registration } from 'ox/webauthn'
*
* const credential = await Registration.create({ name: 'Example' })
* const response = Registration.verify({
* credential,
* challenge: '0x...',
* origin: 'https://example.com',
* rpId: 'example.com',
* })
*
* const serialized = Registration.serializeResponse(response) // [!code focus]
*
* // `serialized` is JSON-serializable — send it to a server, store it, etc.
* const json = JSON.stringify(serialized)
* ```
*
* @param response - The registration response to serialize.
* @returns The serialized registration response.
*/
export function serializeResponse(response: Response): Response<true> {
const { credential, ...rest } = response
const rawResponse = {} as Record<string, string>
for (const key of responseKeys) {
const r = credential.raw.response as unknown as Record<string, unknown>
let value = r[key]
if (!(value instanceof ArrayBuffer)) {
const getter =
`get${key[0]!.toUpperCase()}${key.slice(1)}` as keyof typeof r
const fn = r[getter]
if (typeof fn === 'function') value = fn.call(r)
}
if (value instanceof ArrayBuffer)
rawResponse[key] = Base64.fromBytes(
new Uint8Array(value),
base64UrlOptions,
)
}
return {
...rest,
credential: {
attestationObject: Base64.fromBytes(
new Uint8Array(credential.attestationObject),
base64UrlOptions,
),
clientDataJSON: Base64.fromBytes(
new Uint8Array(credential.clientDataJSON),
base64UrlOptions,
),
id: credential.id,
publicKey: PublicKey.toHex(credential.publicKey),
raw: {
id: credential.raw.id,
type: credential.raw.type,
authenticatorAttachment: credential.raw.authenticatorAttachment,
rawId: Base64.fromBytes(
bufferSourceToBytes(credential.raw.rawId),
base64UrlOptions,
),
response: rawResponse as unknown as Types.AuthenticatorResponse<true>,
},
},
}
}
export declare namespace serializeResponse {
type ErrorType =
| Base64.fromBytes.ErrorType
| PublicKey.toHex.ErrorType
| Errors.GlobalErrorType
}
/**
* Serializes credential creation options into a JSON-serializable
* format, converting `BufferSource` fields to base64url strings.
*
* @example
* ```ts twoslash
* import { Registration } from 'ox/webauthn'
*
* const options = Registration.getOptions({ name: 'Example' })
*
* const serialized = Registration.serializeOptions(options) // [!code focus]
*
* // `serialized` is JSON-serializable — send it to a server, store it, etc.
* const json = JSON.stringify(serialized)
* ```
*
* @param options - The credential creation options to serialize.
* @returns The serialized credential creation options.
*/
export function serializeOptions(
options: Types.CredentialCreationOptions,
): Types.CredentialCreationOptions<true> {
const publicKey = options.publicKey
if (!publicKey) return {}
const { challenge, excludeCredentials, extensions, user, ...rest } = publicKey
return {
publicKey: {
...rest,
challenge: Hex.fromBytes(bufferSourceToBytes(challenge)),
...(excludeCredentials && {
excludeCredentials: excludeCredentials.map(({ id, ...rest }) => ({
...rest,
id: Base64.fromBytes(bufferSourceToBytes(id), base64UrlOptions),
})),
}),
...(extensions && {
extensions: serializeExtensions(extensions),
}),
user: {
...user,
id: Base64.fromBytes(bufferSourceToBytes(user.id), base64UrlOptions),
},
},
}
}
export declare namespace serializeOptions {
type ErrorType = Base64.fromBytes.ErrorType | Errors.GlobalErrorType
}
/**
* Deserializes credential creation options that can be passed to
* `navigator.credentials.create()`.
*
* @example
* ```ts twoslash
* import { Registration } from 'ox/webauthn'
*
* const options = Registration.getOptions({ name: 'Example' })
* const serialized = Registration.serializeOptions(options)
*
* // ... send to server and back ...
*
* const deserialized = Registration.deserializeOptions(serialized) // [!code focus]
* const credential = await window.navigator.credentials.create(deserialized)
* ```
*
* @param options - The serialized credential creation options.
* @returns The deserialized credential creation options.
*/
export function deserializeOptions(
options: Types.CredentialCreationOptions<true>,
): Types.CredentialCreationOptions {
const publicKey = options.publicKey
if (!publicKey) return {}
const { challenge, excludeCredentials, extensions, user, ...rest } = publicKey
return {
publicKey: {
...rest,
challenge: Bytes.fromHex(challenge),
...(excludeCredentials && {
excludeCredentials: excludeCredentials.map(({ id, ...rest }) => ({
...rest,
id: Base64.toBytes(id),
})),
}),
...(extensions && {
extensions: deserializeExtensions(extensions),
}),
user: {
...user,
id: Base64.toBytes(user.id),
},
},
}
}
export declare namespace deserializeOptions {
type ErrorType = Base64.toBytes.ErrorType | Errors.GlobalErrorType
}
/**
* Deserializes a serialized registration response.
*
* @example
* ```ts twoslash
* import { Registration } from 'ox/webauthn'
*
* const response = Registration.deserializeResponse({ // [!code focus]
* credential: { // [!code focus]
* attestationObject: 'o2NmbXRkbm9uZQ...', // [!code focus]
* clientDataJSON: 'eyJ0eXBlIjoid2Vi...', // [!code focus]
* id: 'm1-bMPuAqpWhCxHZQZTT6e-lSPntQbh3opIoGe7g4Qs', // [!code focus]
* publicKey: '0x04ab891400...', // [!code focus]
* raw: { id: '...', type: 'public-key', authenticatorAttachment: 'platform', rawId: '...', response: { clientDataJSON: 'eyJ0eXBlIjoid2Vi...' } }, // [!code focus]
* }, // [!code focus]
* counter: 0, // [!code focus]
* }) // [!code focus]
* ```
*
* @param response - The serialized registration response.
* @returns The deserialized registration response.
*/
export function deserializeResponse(response: Response<true>): Response {
const { credential, ...rest } = response
const rawResponse: Record<string, ArrayBuffer> = {}
for (const [key, value] of Object.entries(credential.raw.response))
rawResponse[key] = bytesToArrayBuffer(Base64.toBytes(value))
return {
...rest,
credential: {
attestationObject: bytesToArrayBuffer(
Base64.toBytes(credential.attestationObject),
),
clientDataJSON: bytesToArrayBuffer(
Base64.toBytes(credential.clientDataJSON),
),
id: credential.id,
publicKey: PublicKey.from(credential.publicKey),
raw: {
id: credential.raw.id,
type: credential.raw.type,
authenticatorAttachment: credential.raw.authenticatorAttachment,
rawId: bytesToArrayBuffer(Base64.toBytes(credential.raw.rawId)),
response: rawResponse as unknown as Types.AuthenticatorResponse,
getClientExtensionResults: () => ({}),
},
},
}
}
export declare namespace deserializeResponse {
type ErrorType =
| Base64.toBytes.ErrorType
| PublicKey.from.ErrorType
| Errors.GlobalErrorType
}
/**
* Verifies a WebAuthn registration (credential creation) response. Validates the
* `clientDataJSON`, `attestationObject`, authenticator flags, challenge, origin, and
* relying party ID, then extracts the credential ID and public key.
*
* @example
* ```ts twoslash
* import { Registration } from 'ox/webauthn'
*
* const credential = await Registration.create({ name: 'Example' })
*
* const result = Registration.verify({ // [!code focus]
* credential, // [!code focus]
* challenge: '0x69abb4b5a0de4bc62a2a201f8d25bae9', // [!code focus]
* origin: 'https://example.com', // [!code focus]
* rpId: 'example.com', // [!code focus]
* }) // [!code focus]
* // @log: {
* // @log: credential: {
* // @log: id: 'oZ48...',
* // @log: publicKey: { prefix: 4, x: 51421...5123n, y: 12345...6789n },
* // @log: },
* // @log: counter: 0,
* // @log: userVerified: true,
* // @log: }
* ```
*
* @param options - Verification options.
* @returns The verified registration result.
*/
export function verify(options: verify.Options): verify.ReturnType {
const {
attestation = 'none',
credential,
origin,
rpId,
userVerification = 'required',
} = options
// 1. Decode and validate clientDataJSON
const clientDataJSONBytes = new Uint8Array(credential.clientDataJSON)
const clientDataJSON = Bytes.toString(clientDataJSONBytes)
const clientData = JSON.parse(clientDataJSON)
if (clientData.type !== 'webauthn.create')
throw new VerifyError(
`Expected clientData.type "webauthn.create", got "${clientData.type}"`,
)
// Validate challenge
const challengeResult = (() => {
if (typeof options.challenge === 'function')
return options.challenge(clientData.challenge)
const challengeBytes =
typeof options.challenge === 'string'
? Bytes.fromHex(options.challenge)
: options.challenge
const challenge = Base64.fromBytes(challengeBytes, base64UrlOptions)
return clientData.challenge === challenge
})()
if (!challengeResult) throw new VerifyError('Challenge mismatch')
// Validate origin
const origins = Array.isArray(origin) ? origin : [origin]
if (!origins.includes(clientData.origin))
throw new VerifyError(
`Origin mismatch: expected ${JSON.stringify(origin)}, got "${clientData.origin}"`,
)
// 2. Decode attestationObject via CBOR
const attestationObjectBytes = new Uint8Array(credential.attestationObject)
const attestation_ = Cbor.decode<{
authData: Uint8Array
attStmt: Record<string, unknown>
fmt: string
}>(attestationObjectBytes)
// 3. Parse authenticatorData
const authData = attestation_.authData
const rpIdHash = authData.slice(0, 32)
const expectedRpIdHash = Hash.sha256(Hex.fromString(rpId), { as: 'Bytes' })
if (!Bytes.isEqual(rpIdHash, expectedRpIdHash))
throw new VerifyError('rpId hash mismatch')
const flags = authData[32]!
const up = (flags & 0x01) !== 0
const uv = (flags & 0x04) !== 0
const at = (flags & 0x40) !== 0
const be = (flags & 0x08) !== 0
const bs = (flags & 0x10) !== 0
if (!up) throw new VerifyError('User presence flag not set')
if (!at) throw new VerifyError('Attested credential data flag not set')
if (userVerification === 'required' && !uv)
throw new VerifyError('User verification flag not set')
// If the BE bit is not set, the BS bit must not be set.
if (!be && bs)
throw new VerifyError(
'Backup state (BS) flag is set but backup eligibility (BE) flag is not',
)
// Minimum authData length: 37 (rpIdHash + flags + counter) + 16 (AAGUID) + 2 (credIdLen)
if (authData.length < 55)
throw new VerifyError('authData too short for attested credential data')
// Counter (4 bytes, big-endian, starting at offset 33)
const counter =
((authData[33]! << 24) |
(authData[34]! << 16) |
(authData[35]! << 8) |
authData[36]!) >>>
0
// Credential ID length (2 bytes at offset 53, big-endian)
const credIdLen = (authData[53]! << 8) | authData[54]!
if (55 + credIdLen > authData.length)
throw new VerifyError('credIdLen exceeds authData bounds')
// Credential ID (variable length starting at offset 55)
const credentialId = authData.slice(55, 55 + credIdLen)
// Verify credential ID consistency if caller-supplied id is present
if (credential.id !== undefined) {
const expectedId = Base64.fromBytes(credentialId, base64UrlOptions)
if (credential.id !== expectedId)
throw new VerifyError(
`Credential ID mismatch: supplied "${credential.id}" does not match authData "${expectedId}"`,
)
}
// 4. Parse and validate COSE public key
const ed = (flags & 0x80) !== 0
const coseKeyBytes = authData.slice(55 + credIdLen)
const coseKeyHex = Hex.fromBytes(coseKeyBytes)
const coseKeyData = Cbor.decode<Record<string, unknown>>(coseKeyHex)
// Validate key type is EC2 (2), algorithm is ES256 (-7), and curve is P-256 (1)
if (
coseKeyData['1'] !== 2 ||
coseKeyData['3'] !== -7 ||
coseKeyData['-1'] !== 1
)
throw new VerifyError(
'COSE key must be EC2 (kty=2) with ES256 algorithm (alg=-7) on P-256 curve (crv=1)',
)
const publicKey = CoseKey.toPublicKey(coseKeyHex)
// Verify no unexpected trailing bytes after the COSE key.
// Re-encode the extracted public key as a COSE key to determine its expected length.
const expectedCoseKeyLen = Bytes.fromHex(
CoseKey.fromPublicKey(publicKey),
).length
const trailingBytes = coseKeyBytes.length - expectedCoseKeyLen
if (trailingBytes > 0 && !ed)
throw new VerifyError(
`authData contains ${trailingBytes} unexpected trailing byte(s) after COSE key`,
)
// 5. Verify attestation statement (cryptographic binding of authData + clientDataJSON)
const clientDataHash = Hash.sha256(Bytes.fromString(clientDataJSON), {
as: 'Bytes',
})
const verificationData = Bytes.concat(authData, clientDataHash)
const { fmt, attStmt } = attestation_
if (fmt === 'none') {
// "none" format has no attestation signature; only accept if caller opts in
if (attestation === 'required')
throw new VerifyError(
'Attestation format is "none" but attestation verification is required. ' +
'Set `attestation: "none"` to accept unattested credentials.',
)
} else if (fmt === 'packed') {
// Packed attestation: verify signature over authData || clientDataHash
const sig = attStmt.sig
const alg = attStmt.alg
if (!(sig instanceof Uint8Array) || typeof alg !== 'number')
throw new VerifyError(
'Invalid packed attestation statement: missing sig or alg',
)
if (alg !== -7)
throw new VerifyError(
`Unsupported attestation algorithm: ${alg} (expected -7 / ES256)`,
)
if (attStmt.x5c) {
// Full attestation with certificate chain is not supported
throw new VerifyError(
'Packed attestation with x5c certificate chain is not supported. ' +
'Use self attestation (no x5c) or set `attestation: "none"`.',
)
}
// Self attestation: verify using the credential public key
const attSignature = Signature.fromDerBytes(sig)
const verified = P256.verify({
hash: true,
payload: verificationData,
publicKey,
signature: attSignature,
})
if (!verified)
throw new VerifyError('Attestation signature verification failed')
} else {
throw new VerifyError(`Unsupported attestation format: "${fmt}"`)
}
// 6. Build credential ID string
const id = credential.id ?? Base64.fromBytes(credentialId, base64UrlOptions)
// 7. Build raw credential
const raw = credential.raw ?? {
authenticatorAttachment: null,
getClientExtensionResults: () => ({}),
id,
rawId: bytesToArrayBuffer(credentialId),
response: {
attestationObject: credential.attestationObject,
clientDataJSON: credential.clientDataJSON,
} as never,
type: 'public-key',
}
return {
credential: {
attestationObject: credential.attestationObject,
clientDataJSON: credential.clientDataJSON,
id,
publicKey,
raw,
},
counter,
...(uv ? { userVerified: true as const } : {}),
...(be ? { backedUp: bs } : {}),
...(be
? {
deviceType: bs ? ('multiDevice' as const) : ('singleDevice' as const),
}
: {}),
}
}
export declare namespace verify {
type Options = {
/**
* Attestation verification mode.
* - `'required'` (default): attestation signature must be present and valid (`packed` self-attestation).
* - `'none'`: accept `fmt: "none"` attestation (no cryptographic binding of authData to clientDataJSON).
*
* @default 'required'
*/
attestation?: 'required' | 'none' | undefined
/** The credential response from `Registration.create()`. */
credential: {
attestationObject: Credential_.Credential['attestationObject']
clientDataJSON: Credential_.Credential['clientDataJSON']
id?: Credential_.Credential['id'] | undefined
raw?: Credential_.Credential['raw'] | undefined
}
/**
* Challenge to verify. Either the raw hex/bytes originally generated, or a
* function that receives the base64url challenge string and returns whether
* it is valid (for async/DB lookups).
*/
challenge: Hex.Hex | Uint8Array | ((challenge: string) => boolean)
/** Expected origin(s) (e.g. `"https://example.com"`). */
origin: string | string[]
/** Relying party ID (e.g. `"example.com"`). */
rpId: string
/** The user verification requirement. @default 'required' */
userVerification?: Types.UserVerificationRequirement | undefined
}
type ReturnType = Response
type ErrorType =
| Base64.toBytes.ErrorType
| Base64.fromBytes.ErrorType
| Bytes.fromHex.ErrorType
| Bytes.isEqual.ErrorType
| Cbor.decode.ErrorType
| CoseKey.toPublicKey.ErrorType
| Hash.sha256.ErrorType
| P256.verify.ErrorType
| Signature.fromDerBytes.ErrorType
| VerifyError
| Errors.GlobalErrorType
}
/** Thrown when WebAuthn registration verification fails. */
export class VerifyError extends Errors.BaseError {
override readonly name = 'Registration.VerifyError'
}
/** Thrown when a WebAuthn P256 credential creation fails. */
export class CreateFailedError extends Errors.BaseError<Error> {
override readonly name = 'Registration.CreateFailedError'
constructor({ cause }: { cause?: Error | undefined } = {}) {
super('Failed to create credential.', {
cause,
})
}
}