UNPKG

@passwordless-id/webauthn

Version:

A small wrapper around the webauthn protocol to make one's life easier.

151 lines (145 loc) 7.39 kB
import { parsers } from "./index.js"; import { parseAuthenticator, parseClient, toAuthenticationInfo } from "./parsers.js"; import * as utils from './utils.js'; export function randomChallenge() { const buffer = crypto.getRandomValues(new Uint8Array(18)); // > 128 bits, a multiple of 3 bytes to have base64 encoding without padding return utils.toBase64url(buffer); } async function isValid(validator, value) { if (typeof validator === 'function') { const res = validator(value); if (res instanceof Promise) return await res; else return res; } // the validator can be a single value too return validator === value; } async function isNotValid(validator, value) { return !(await isValid(validator, value)); } export async function verifyRegistration(registrationJson, expected) { const client = parseClient(registrationJson.response.clientDataJSON); const authenticator = parseAuthenticator(registrationJson.response.authenticatorData); const aaguid = authenticator.aaguid; if (!aaguid) // should never happen, worst case should be a fallback to "zeroed" aaguid throw new Error("Unexpected error, no AAGUID."); if (client.type !== "webauthn.create") throw new Error(`Unexpected ClientData type: ${client.type}`); if (await isNotValid(expected.origin, client.origin)) throw new Error(`Unexpected ClientData origin: ${client.origin}`); if (await isNotValid(expected.challenge, client.challenge)) throw new Error(`Unexpected ClientData challenge: ${client.challenge}`); return parsers.toRegistrationInfo(registrationJson, authenticator); } export async function verifyAuthentication(authenticationJson, credential, expected) { if (authenticationJson.id !== credential.id) throw new Error(`Credential ID mismatch: ${authenticationJson.id} vs ${credential.id}`); const isValidSignature = await verifySignature({ algorithm: credential.algorithm, publicKey: credential.publicKey, authenticatorData: authenticationJson.response.authenticatorData, clientData: authenticationJson.response.clientDataJSON, signature: authenticationJson.response.signature, verbose: expected.verbose }); if (!isValidSignature) throw new Error(`Invalid signature: ${authenticationJson.response.signature}`); const client = parseClient(authenticationJson.response.clientDataJSON); const authenticator = parseAuthenticator(authenticationJson.response.authenticatorData); if (expected.verbose) { console.debug(client); console.debug(authenticator); } if (client.type !== "webauthn.get") throw new Error(`Unexpected clientData type: ${client.type}`); if (await isNotValid(expected.origin, client.origin)) throw new Error(`Unexpected ClientData origin: ${client.origin}`); if (await isNotValid(expected.challenge, client.challenge)) throw new Error(`Unexpected ClientData challenge: ${client.challenge}`); // this only works because we consider `rp.origin` and `rp.id` to be the same during authentication/registration const rpId = expected.domain ?? new URL(client.origin).hostname; const expectedRpIdHash = utils.toBase64url(await utils.sha256(utils.toBuffer(rpId))); if (authenticator.rpIdHash !== expectedRpIdHash) throw new Error(`Unexpected RpIdHash: ${authenticator.rpIdHash} vs ${expectedRpIdHash}`); if (!authenticator.flags.userPresent) throw new Error(`Unexpected authenticator flags: missing userPresent`); if (!authenticator.flags.userVerified && expected.userVerified) throw new Error(`Unexpected authenticator flags: missing userVerified`); if (expected.counter && authenticator.signCount <= expected.counter) throw new Error(`Unexpected authenticator counter: ${authenticator.signCount} (should be > ${expected.counter})`); return toAuthenticationInfo(authenticationJson, authenticator); } // https://w3c.github.io/webauthn/#sctn-public-key-easy // https://www.iana.org/assignments/cose/cose.xhtml#algorithms /* User agents MUST be able to return a non-null value for getPublicKey() when the credential public key has a COSEAlgorithmIdentifier value of: -7 (ES256), where kty is 2 (with uncompressed points) and crv is 1 (P-256). -257 (RS256). -8 (EdDSA), where crv is 6 (Ed25519). */ function getAlgoParams(algorithm) { switch (algorithm) { case 'RS256': return { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' }; case 'ES256': return { name: 'ECDSA', namedCurve: 'P-256', hash: 'SHA-256', }; // case 'EdDSA': Not supported by browsers default: throw new Error(`Unknown or unsupported crypto algorithm: ${algorithm}. Only 'RS256' and 'ES256' are supported.`); } } export async function parseCryptoKey(algorithm, publicKey) { const algoParams = getAlgoParams(algorithm); const buffer = utils.parseBase64url(publicKey); return crypto.subtle.importKey('spki', buffer, algoParams, false, ['verify']); } // https://w3c.github.io/webauthn/#sctn-verifying-assertion // https://w3c.github.io/webauthn/#sctn-signature-attestation-types /* Emphasis mine: 6.5.6. Signature Formats for Packed Attestation, FIDO U2F Attestation, and **Assertion Signatures** [...] For COSEAlgorithmIdentifier -7 (ES256) [...] the sig value MUST be encoded as an ASN.1 [...] [...] For COSEAlgorithmIdentifier -257 (RS256) [...] The signature is not ASN.1 wrapped. [...] For COSEAlgorithmIdentifier -37 (PS256) [...] The signature is not ASN.1 wrapped. */ // see also https://gist.github.com/philholden/50120652bfe0498958fd5926694ba354 export async function verifySignature({ algorithm, publicKey, authenticatorData, clientData, signature, verbose }) { let cryptoKey = await parseCryptoKey(algorithm, publicKey); if (verbose) { console.debug(cryptoKey); } let clientHash = await utils.sha256(utils.parseBase64url(clientData)); // during "login", the authenticatorData is exactly 37 bytes let comboBuffer = utils.concatenateBuffers(utils.parseBase64url(authenticatorData), clientHash); if (verbose) { console.debug('Algorithm: ' + algorithm); console.debug('Public key: ' + publicKey); console.debug('Data: ' + utils.toBase64url(comboBuffer)); console.debug('Signature: ' + signature); } // https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/verify let signatureBuffer = utils.parseBase64url(signature); if (algorithm == 'ES256') signatureBuffer = convertASN1toRaw(signatureBuffer); const algoParams = getAlgoParams(algorithm); const isValid = await crypto.subtle.verify(algoParams, cryptoKey, signatureBuffer, comboBuffer); return isValid; } function convertASN1toRaw(signatureBuffer) { // Convert signature from ASN.1 sequence to "raw" format const signature = new Uint8Array(signatureBuffer); const rStart = signature[4] === 0 ? 5 : 4; const rEnd = rStart + 32; const sStart = signature[rEnd + 2] === 0 ? rEnd + 3 : rEnd + 2; const r = signature.slice(rStart, rEnd); const s = signature.slice(sStart); return new Uint8Array([...r, ...s]); }