fido2-lib
Version:
A library for performing FIDO 2.0 / WebAuthn functionality
394 lines (332 loc) • 9 kB
JavaScript
// External dependencies
import { parse as tldtsParse } from "tldts";
import punycode from "punycode.js";
import { decodeProtectedHeader, importJWK, jwtVerify } from "jose";
import {
Certificate as PkijsCertificate,
CertificateChainValidationEngine,
CertificateRevocationList,
CryptoEngine,
PublicKeyInfo,
setEngine
} from "pkijs";
import { fromBER } from "asn1js";
import * as cbor from "cbor-x";
import base64 from "@hexagon/base64";
// Internal dependencies
import { Certificate } from "./certUtils.js";
import { PublicKey } from "./keyUtils.js";
// Import webcrypto
import * as platformCrypto from "crypto";
import * as peculiarCrypto from "@peculiar/webcrypto";
let webcrypto;
if (typeof self !== "undefined" && "crypto" in self) {
// Always use crypto if available natively (browser / Deno)
webcrypto = self.crypto;
} else {
// Always use node webcrypto if available ( >= 16.0 )
if (platformCrypto && platformCrypto.webcrypto) {
webcrypto = platformCrypto.webcrypto;
} else {
// Fallback to @peculiar/webcrypto
webcrypto = new peculiarCrypto.Crypto();
}
}
// Set up pkijs
const pkijs = {
setEngine,
CryptoEngine,
Certificate: PkijsCertificate,
CertificateRevocationList,
CertificateChainValidationEngine,
PublicKeyInfo,
};
pkijs.setEngine(
"newEngine",
webcrypto,
new pkijs.CryptoEngine({
name: "",
crypto: webcrypto,
subtle: webcrypto.subtle,
})
);
function extractBigNum(fullArray, start, end, expectedLength) {
let num = fullArray.slice(start, end);
if (num.length !== expectedLength) {
num = Array(expectedLength)
.fill(0)
.concat(...num)
.slice(num.length);
}
return num;
}
/*
Convert signature from DER to raw
Expects Uint8Array
*/
function derToRaw(signature) {
const rStart = 4;
const rEnd = rStart + signature[3];
const sStart = rEnd + 2;
return new Uint8Array([
...extractBigNum(signature, rStart, rEnd, 32),
...extractBigNum(signature, sStart, signature.length, 32),
]);
}
function isAndroidFacetId(str) {
return str.startsWith("android:apk-key-hash:");
}
function isIOSFacetId(str) {
return str.startsWith("ios:bundle-id:");
}
function checkOrigin(str) {
if (!str) throw new Error("Empty Origin");
if (isAndroidFacetId(str) || isIOSFacetId(str)) {
return str;
}
const originUrl = new URL(str);
const origin = originUrl.origin;
if (origin !== str) {
throw new Error("origin was malformatted");
}
const isLocalhost =
originUrl.hostname == "localhost" ||
originUrl.hostname.endsWith(".localhost");
if (originUrl.protocol !== "https:" && !isLocalhost) {
throw new Error("origin should be https");
}
if (
(!validDomainName(originUrl.hostname) ||
!validEtldPlusOne(originUrl.hostname)) &&
!isLocalhost
) {
throw new Error("origin is not a valid eTLD+1");
}
return origin;
}
function checkUrl(value, name, rules = {}) {
if (!name) {
throw new TypeError("name not specified in checkUrl");
}
if (typeof value !== "string") {
throw new Error(`${name} must be a string`);
}
let urlValue = null;
try {
urlValue = new URL(value);
} catch (_err) {
throw new Error(`${name} is not a valid eTLD+1/url`);
}
if (!value.startsWith("http")) {
throw new Error(`${name} must be http protocol`);
}
if (!rules.allowHttp && urlValue.protocol !== "https:") {
throw new Error(`${name} should be https`);
}
// origin: base url without path including /
if (!rules.allowPath && (value.endsWith("/") || urlValue.pathname !== "/")) {
// urlValue adds / in path always
throw new Error(`${name} should not include path in url`);
}
if (!rules.allowHash && urlValue.hash) {
throw new Error(`${name} should not include hash in url`);
}
if (!rules.allowCred && (urlValue.username || urlValue.password)) {
throw new Error(`${name} should not include credentials in url`);
}
if (!rules.allowQuery && urlValue.search) {
throw new Error(`${name} should not include query string in url`);
}
return value;
}
function validEtldPlusOne(value) {
// Parse domain name
const result = tldtsParse(value, { allowPrivateDomains: true });
// Require valid public suffix
if (result.publicSuffix === null) {
return false;
}
// Require valid hostname
if (result.domainWithoutSuffix === null) {
return false;
}
return true;
}
function validDomainName(value) {
// Before we can validate we need to take care of IDNs with unicode chars.
const ascii = punycode.toASCII(value);
if (ascii.length < 1) {
// return 'DOMAIN_TOO_SHORT';
return false;
}
if (ascii.length > 255) {
// return 'DOMAIN_TOO_LONG';
return false;
}
// Check each part's length and allowed chars.
const labels = ascii.split(".");
let label;
for (let i = 0; i < labels.length; ++i) {
label = labels[i];
if (!label.length) {
// LABEL_TOO_SHORT
return false;
}
if (label.length > 63) {
// LABEL_TOO_LONG
return false;
}
if (label.charAt(0) === "-") {
// LABEL_STARTS_WITH_DASH
return false;
}
/* if (label.charAt(label.length - 1) === '-') {
// LABEL_ENDS_WITH_DASH
return false;
} */
if (!/^[a-z0-9-]+$/.test(label)) {
// LABEL_INVALID_CHARS
return false;
}
}
return true;
}
function checkDomainOrUrl(value, name, rules = {}) {
if (!name) {
throw new TypeError("name not specified in checkDomainOrUrl");
}
if (typeof value !== "string") {
throw new Error(`${name} must be a string`);
}
if (validEtldPlusOne(value, name) && validDomainName(value, name)) {
return value; // if valid domain no need for futher checks
}
return checkUrl(value, name, rules);
}
function checkRpId(rpId) {
if (typeof rpId !== "string") {
throw new Error("rpId must be a string");
}
const isLocalhost = rpId === "localhost" || rpId.endsWith(".localhost");
if (isLocalhost) return rpId;
return checkDomainOrUrl(rpId, "rpId");
}
async function verifySignature(publicKey, expectedSignature, data, hashName) {
let publicKeyInst;
if (publicKey instanceof PublicKey) {
publicKeyInst = publicKey;
// Check for Public CryptoKey
} else if (publicKey && publicKey.type === "public") {
publicKeyInst = new PublicKey();
publicKeyInst.fromCryptoKey(publicKey);
// Try importing from PEM
} else {
publicKeyInst = new PublicKey();
await publicKeyInst.fromPem(publicKey);
}
// Check for valid algorithm
const alg = publicKeyInst.getAlgorithm();
if (typeof alg === "undefined") {
throw new Error("verifySignature: Algoritm missing.");
}
// Use supplied hashName
if (hashName) {
alg.hash = {
name: hashName,
};
}
if (!alg.hash) {
throw new Error("verifySignature: Hash name missing.");
}
// Sync (possible updated) algorithm back to key
publicKeyInst.setAlgorithm(alg);
try {
let uSignature = new Uint8Array(expectedSignature);
if (alg.name === "ECDSA") {
uSignature = await derToRaw(uSignature);
}
const result = await webcrypto.subtle.verify(
publicKeyInst.getAlgorithm(),
publicKeyInst.getKey(),
uSignature,
new Uint8Array(data)
);
// If verification fails with SHA-1, try Node.js native crypto as fallback
// Node.js 24+ disables SHA-1 in WebCrypto, so we use the native crypto module
if (
!result &&
hashName === "SHA-1" &&
platformCrypto &&
platformCrypto.createVerify
) {
try {
const pem = await publicKeyInst.toPem();
const verify = platformCrypto.createVerify("RSA-SHA1");
verify.update(Buffer.from(data));
verify.end();
return verify.verify(pem, Buffer.from(expectedSignature));
} catch (fallbackError) {
console.error("SHA-1 fallback failed:", fallbackError);
return result;
}
}
return result;
} catch (e) {
console.error(e);
throw e;
}
}
async function hashDigest(o, alg) {
if (typeof o === "string") {
o = new TextEncoder().encode(o);
}
const result = await webcrypto.subtle.digest(alg || "SHA-256", o);
return result;
}
function randomValues(n) {
const byteArray = new Uint8Array(n);
webcrypto.getRandomValues(byteArray);
return byteArray;
}
function getHostname(urlIn) {
return new URL(urlIn).hostname;
}
async function getEmbeddedJwk(jwsHeader, alg) {
let publicKeyJwk;
// Use JWK from header
if (jwsHeader.jwk) {
publicKeyJwk = jwsHeader.jwk;
// Extract JWK from first x509 certificate in header
} else if (jwsHeader.x5c) {
const x5c0 = jwsHeader.x5c[0];
const cert = new Certificate(x5c0);
publicKeyJwk = await cert.getPublicKeyJwk();
// Use common name as kid if missing
publicKeyJwk.kid = publicKeyJwk.kid || cert.getCommonName();
}
if (!publicKeyJwk) {
throw new Error("getEmbeddedJwk: JWK not found in JWS.");
}
// Use alg from header if not present, use passed alg as default
publicKeyJwk.alg = publicKeyJwk.alg || jwsHeader.alg || alg;
return publicKeyJwk;
}
export {
base64,
cbor,
checkDomainOrUrl,
checkOrigin,
checkRpId,
checkUrl,
decodeProtectedHeader,
fromBER,
getEmbeddedJwk,
getHostname,
hashDigest,
importJWK,
jwtVerify,
pkijs,
randomValues,
verifySignature,
webcrypto
};