UNPKG

fido2-lib

Version:

A library for performing FIDO 2.0 / WebAuthn functionality

2,005 lines (1,717 loc) 188 kB
'use strict'; var tldts = require('tldts'); var punycode = require('punycode.js'); var jose = require('jose'); var pkijs$1 = require('pkijs'); var asn1js = require('asn1js'); var cborX = require('cbor-x'); var base64 = require('@hexagon/base64'); var platformCrypto = require('crypto'); var peculiarCrypto = require('@peculiar/webcrypto'); function _interopNamespaceDefault(e) { var n = Object.create(null); if (e) { Object.keys(e).forEach(function (k) { if (k !== 'default') { var d = Object.getOwnPropertyDescriptor(e, k); Object.defineProperty(n, k, d.get ? d : { enumerable: true, get: function () { return e[k]; } }); } }); } n.default = e; return Object.freeze(n); } var cborX__namespace = /*#__PURE__*/_interopNamespaceDefault(cborX); var platformCrypto__namespace = /*#__PURE__*/_interopNamespaceDefault(platformCrypto); var peculiarCrypto__namespace = /*#__PURE__*/_interopNamespaceDefault(peculiarCrypto); class Certificate { constructor(cert) { let decoded; // Clean up base64 string if (typeof cert === "string" || cert instanceof String) { cert = cert.replace(/\r/g, "").trim(); decoded = ab2str(coerceToArrayBuffer$1(cert, "certificate")); } if (isPem(cert)) { cert = pemToBase64(cert); } else if (decoded && isPem(decoded)) { cert = pemToBase64(decoded); } // Clean up certificate if (typeof cert === "string" || cert instanceof String) { cert = cert.replace(/\n/g, ""); } cert = coerceToArrayBuffer$1(cert, "certificate"); if (cert.byteLength === 0) { throw new Error("cert was empty (0 bytes)"); } const asn1 = asn1js.fromBER(cert); if (asn1.offset === -1) { throw new Error("error parsing ASN.1"); } this._cert = new pkijs.Certificate({ schema: asn1.result }); this.warning = new Map(); this.info = new Map(); } getCommonName() { return this.searchForCommonName(this._cert.subject.typesAndValues); } searchForCommonName(attributes) { const X509_COMMON_NAME_KEY = "2.5.4.3"; // Search the attributes for the common name of the certificate for (const attr of attributes) { if (attr.type === X509_COMMON_NAME_KEY) { return attr.value.valueBlock.value; } } // Return empty string if not found return ""; } verify() { const issuerCommonName = this.getIssuer(); const issuerCert = CertManager.getCertByCommonName(issuerCommonName); const _issuerCert = issuerCert ? issuerCert._cert : undefined; return this._cert.verify(_issuerCert) .catch((err) => { // who the hell throws a string? if (typeof err === "string") { err = new Error(err); } return Promise.reject(err); }); } async getPublicKey() { const k = await this._cert.getPublicKey(); return k; } async getPublicKeyJwk() { const publicKey = await this.getPublicKey(); // Covert CryptoKey to JWK const publicKeyJwk = await webcrypto.subtle.exportKey("jwk", publicKey); return publicKeyJwk; } getIssuer() { return this.searchForCommonName(this._cert.issuer.typesAndValues); } getSerial(compatibility) { if (compatibility === undefined) { console.warn("[DEPRECATION WARNING] Please use getSerial(\"v2\")."); } else if (compatibility === "v1") { console.warn("[DEPRECATION WARNING] Please migrate to getSerial(\"v2\") which will return just the serial number."); } return (compatibility === "v2") ? this._cert.serialNumber.valueBlock.toString() : this.getCommonName(); } getVersion() { // x.509 versions: // 0 = v1 // 1 = v2 // 2 = v3 return (this._cert.version + 1); } getSubject() { const ret = new Map(); const subjectItems = this._cert.subject.typesAndValues; for (const subject of subjectItems) { const kv = resolveOid(subject.type,decodeValue(subject.value.valueBlock)); ret.set(kv.id, kv.value); } return ret; } getExtensions() { const ret = new Map(); if (this._cert.extensions === undefined) return ret; for (const ext of this._cert.extensions) { let kv; let v = ext.parsedValue || ext.extnValue; try { if (v.valueBlock) { v = decodeValue(v.valueBlock); } kv = resolveOid(ext.extnID, v); } catch (err) { if (ext.critical === false) { this.warning.set("x509-extension-error", ext.extnID + ": " + err.message); continue; } else { throw err; } } ret.set(kv.id, kv.value); } return ret; } } function resolveOid(id, value) { /* eslint complexity: ["off"] */ const ret = { id, value, }; if (value && value.valueHex) value = value.valueHex; let retMap; switch (id) { // FIDO case "1.3.6.1.4.1.45724.2.1.1": ret.id = "fido-u2f-transports"; ret.value = decodeU2FTransportType(value); return ret; case "1.3.6.1.4.1.45724.1.1.4": ret.id = "fido-aaguid"; return ret; // Subject case "2.5.4.6": ret.id = "country-name"; return ret; case "2.5.4.10": ret.id = "organization-name"; return ret; case "2.5.4.11": ret.id = "organizational-unit-name"; return ret; case "2.5.4.3": ret.id = "common-name"; return ret; // cert attributes case "2.5.29.14": ret.id = "subject-key-identifier"; return ret; case "2.5.29.15": ret.id = "key-usage"; ret.value = decodeKeyUsage(value); return ret; case "2.5.29.19": ret.id = "basic-constraints"; return ret; case "2.5.29.35": retMap = new Map(); ret.id = "authority-key-identifier"; retMap.set("key-identifier", decodeValue(value.keyIdentifier)); // TODO: other values ret.value = retMap; return ret; case "2.5.29.32": ret.id = "certificate-policies"; ret.value = decodeCertificatePolicies(value); return ret; case "1.3.6.1.4.1.311.21.31": ret.id = "policy-qualifiers"; ret.value = decodePolicyQualifiers(value); return ret; case "2.5.29.37": ret.id = "ext-key-usage"; ret.value = decodeExtKeyUsage(value); return ret; case "2.5.29.17": ret.id = "subject-alt-name"; ret.value = decodeAltNames(value); return ret; case "1.3.6.1.5.5.7.1.1": ret.id = "authority-info-access"; ret.value = decodeAuthorityInfoAccess(value); return ret; case "1.3.6.1.5.5.7.48.2": ret.id = "cert-authority-issuers"; if (typeof value !== "object") { throw new Error("expect cert-authority-issues to have Object as value"); } ret.value = decodeGeneralName(value.type, value.value); return ret; case "1.3.6.1.5.5.7.2.2": ret.id = "policy-qualifier"; ret.value = decodeValue(value.valueBlock); return ret; // TPM case "2.23.133.8.3": ret.id = "tcg-kp-aik-certificate"; return ret; case "2.23.133.2.1": ret.id = "tcg-at-tpm-manufacturer"; return ret; case "2.23.133.2.2": ret.id = "tcg-at-tpm-model"; return ret; case "2.23.133.2.3": ret.id = "tcg-at-tpm-version"; return ret; // Yubico case "1.3.6.1.4.1.41482.2": ret.id = "yubico-device-id"; ret.value = resolveOid(ab2str(value)).id; return ret; case "1.3.6.1.4.1.41482.1.1": ret.id = "Security Key by Yubico"; return ret; case "1.3.6.1.4.1.41482.1.2": ret.id = "YubiKey NEO/NEO-n"; return ret; case "1.3.6.1.4.1.41482.1.3": ret.id = "YubiKey Plus"; return ret; case "1.3.6.1.4.1.41482.1.4": ret.id = "YubiKey Edge"; return ret; case "1.3.6.1.4.1.41482.1.5": ret.id = "YubiKey 4/YubiKey 4 Nano"; return ret; // TODO // 1.3.6.1.4.1.45724.1.1.4 FIDO AAGUID // basic-constraints Yubico FIDO2, ST Micro // 2.5.29.35 ST Micro // subject-key-identifier ST Micro // 1.3.6.1.4.1.41482.3.3 Yubico Firmware version, encoded as 3 bytes, like: 040300 for 4.3.0 // 1.3.6.1.4.1.41482.3.7 Yubico serial number of the YubiKey, encoded as an integer // 1.3.6.1.4.1.41482.3.8 Yubico two bytes, the first encoding pin policy and the second touch policy // Pin policy: 01 - never, 02 - once per session, 03 - always // Touch policy: 01 - never, 02 - always, 03 - cached for 15s default: return ret; } } function decodeValue(valueBlock) { // Use property-based detection instead of constructor.name to avoid issues // with bundlers that rename classes (e.g., Deno v2 bundler) // Check if it's a wrapper object with valueBlock property (OctetString, BmpString, Utf8String, Constructed) // These are ASN.1 objects that wrap a valueBlock if (valueBlock && typeof valueBlock === "object" && "valueBlock" in valueBlock && valueBlock.valueBlock) { const innerBlock = valueBlock.valueBlock; // Constructed wrapper: only decode the first element of the array // (matches original case "Constructed") if (Array.isArray(innerBlock.value) && innerBlock.value.length > 0) { return decodeValue(innerBlock.value[0]); } // String wrapper types (BmpString, Utf8String): return the string value // This must come before valueHex check because string types also have valueHex // (matches original case "BmpString" and "Utf8String") if ("value" in innerBlock && typeof innerBlock.value === "string") { return innerBlock.value; } // OctetString wrapper: return the hex value // (matches original case "OctetString") if ("valueHex" in innerBlock && innerBlock.valueHex instanceof ArrayBuffer) { return innerBlock.valueHex; } // Fallback to recursively decode the inner block return decodeValue(innerBlock); } // Now handle value blocks (objects without valueBlock property) // LocalIntegerValueBlock: has valueDec getter // (matches original case "LocalIntegerValueBlock") if ("valueDec" in valueBlock) { return valueBlock.valueDec; } // LocalBitStringValueBlock: has unusedBits and valueHex // (matches original case "LocalBitStringValueBlock") if ("unusedBits" in valueBlock && "valueHex" in valueBlock && valueBlock.valueHex instanceof ArrayBuffer) { return new Uint8Array(valueBlock.valueHex)[0]; } // LocalOctetStringValueBlock: has isConstructed and valueHex but no unusedBits // (matches original case "LocalOctetStringValueBlock") if ("isConstructed" in valueBlock && "valueHex" in valueBlock && !("unusedBits" in valueBlock) && valueBlock.valueHex instanceof ArrayBuffer) { return valueBlock.valueHex; } // LocalConstructedValueBlock: has value array, map over ALL elements // (matches original case "LocalConstructedValueBlock") // Must check for array specifically and NOT be a wrapper (no valueBlock property) if (typeof valueBlock === "object" && !("valueBlock" in valueBlock) && "value" in valueBlock && Array.isArray(valueBlock.value)) { return valueBlock.value.map((v) => decodeValue(v)); } // String value blocks (LocalSimpleStringValueBlock, LocalUtf8StringValueBlock, LocalBmpStringValueBlock) // These are value blocks (not wrappers) with a string value property // (matches original case "LocalUtf8StringValueBlock", "LocalSimpleStringValueBlock", "LocalBmpStringValueBlock") if ("value" in valueBlock && typeof valueBlock.value === "string") { return valueBlock.value; } // If we can't determine the type, throw an error with helpful debug info const blockType = Object.getPrototypeOf(valueBlock).constructor.name; const availableProps = Object.keys(valueBlock).join(", "); throw new TypeError(`unknown value type when decoding certificate: ${blockType} (properties: ${availableProps})`); } function decodeU2FTransportType(u2fRawTransports) { const bitLen = 3; const bitCount = 8 - bitLen - 1; let type = (u2fRawTransports >> bitLen); const ret = new Set(); for (let i = bitCount; i >= 0; i--) { // https://fidoalliance.org/specs/fido-u2f-v1.2-ps-20170411/fido-u2f-authenticator-transports-extension-v1.2-ps-20170411.html if (type & 0x1) switch (i) { case 0: ret.add("bluetooth-classic"); break; case 1: ret.add("bluetooth-low-energy"); break; case 2: ret.add("usb"); break; case 3: ret.add("nfc"); break; case 4: ret.add("usb-internal"); break; default: throw new Error("unknown U2F transport type: " + type); } type >>= 1; } return ret; } function decodeKeyUsage(value) { if (typeof value !== "number") { throw new Error("certificate: expected 'keyUsage' value to be number"); } const retSet = new Set(); if (value & 0x80) retSet.add("digitalSignature"); if (value & 0x40) retSet.add("contentCommitment"); if (value & 0x20) retSet.add("keyEncipherment"); if (value & 0x10) retSet.add("dataEncipherment"); if (value & 0x08) retSet.add("keyAgreement"); if (value & 0x04) retSet.add("keyCertSign"); if (value & 0x02) retSet.add("cRLSign"); if (value & 0x01) retSet.add("encipherOnly"); if (value & 0x01) retSet.add("decipherOnly"); return retSet; } function decodeExtKeyUsage(value) { let keyPurposes = value.keyPurposes; if (typeof value !== "object" || !Array.isArray(keyPurposes)) { throw new Error("expected extended key purposes to be an Array"); } keyPurposes = keyPurposes.map((oid) => resolveOid(oid).id); return keyPurposes; } function decodeCertificatePolicies(value) { if (value && Array.isArray(value.certificatePolicies)) { value = value.certificatePolicies.map((_policy) => resolveOid(value.certificatePolicies[0].policyIdentifier, value.certificatePolicies[0].policyQualifiers)); } return value; } function decodePolicyQualifiers(value) { if (value && Array.isArray(value)) { value = value.map((qual) => resolveOid(qual.policyQualifierId, qual.qualifier)); } return value; } function decodeAltNames(value) { if (typeof value !== "object" || !Array.isArray(value.altNames)) { throw new Error("expected alternate names to be an Array"); } let altNames = value.altNames; altNames = altNames.map((name) => { if (typeof name !== "object") { throw new Error("expected alternate name to be an object"); } if (name.type !== 4) { throw new Error("expected all alternate names to be of general type"); } if (typeof name.value !== "object" || !Array.isArray(name.value.typesAndValues)) { throw new Error("malformatted alternate name"); } return decodeGeneralName(name.type, name.value.typesAndValues); }); return altNames; } function decodeAuthorityInfoAccess(v) { if (typeof v !== "object" || !Array.isArray(v.accessDescriptions)) { throw new Error("expected authority info access descriptions to be Array"); } const retMap = new Map(); v.accessDescriptions.forEach((desc) => { const { id, value } = resolveOid(desc.accessMethod, desc.accessLocation); retMap.set(id, value); }); return retMap; } function decodeGeneralName(type, v) { if (typeof type !== "number") { throw new Error("malformed general name in x509 certificate"); } let nameList; switch (type) { case 0: // other name throw new Error("general name 'other name' not supported"); case 1: // rfc822Name throw new Error("general name 'rfc822Name' not supported"); case 2: // dNSName throw new Error("general name 'dNSName' not supported"); case 3: // x400Address throw new Error("general name 'x400Address' not supported"); case 4: // directoryName if (!Array.isArray(v)) { throw new Error("expected general name 'directory name' to be Array"); } nameList = new Map(); v.forEach((val) => { const { id, value } = resolveOid(val.type, decodeValue(val.value)); nameList.set(id, value); }); return { directoryName: nameList }; case 5: // ediPartyName throw new Error("general name 'ediPartyName' not supported"); case 6: // uniformResourceIdentifier return { uniformResourceIdentifier: v }; case 7: // iPAddress throw new Error("general name 'iPAddress' not supported"); case 8: // registeredID throw new Error("general name 'registeredID' not supported"); default: throw new Error("unknown general name type: " + type); } } class CRL { constructor(crl) { // Clean up base64 string if (typeof crl === "string" || crl instanceof String) { crl = crl.replace(/\r/g, ""); } if (isPem(crl)) { crl = pemToBase64(crl); } crl = coerceToArrayBuffer$1(crl, "crl"); const asn1 = asn1js.fromBER(crl); this._crl = new pkijs.CertificateRevocationList({ schema: asn1.result, }); } } const certMap = new Map(); class CertManager { static addCert(certBuf) { const cert = new Certificate(certBuf); const commonName = cert.getCommonName(); certMap.set(commonName, cert); return true; } static getCerts() { return new Map([...certMap]); } static getCertBySerial(serial) { console.warn("[DEPRECATION WARNING] Please use CertManager.getCertByCommonName(commonName)."); return certMap.get(serial); } static getCertByCommonName(commonName) { return certMap.get(commonName); } static removeAll() { certMap.clear(); } static async verifyCertChain(certs, roots, crls) { if (!Array.isArray(certs) || certs.length < 1) { throw new Error("expected 'certs' to be non-empty Array, got: " + certs); } certs = certs.map((cert) => { if (!(cert instanceof Certificate)) { // throw new Error("expected 'cert' to be an instance of Certificate"); cert = new Certificate(cert); } return cert._cert; }); if (!Array.isArray(roots) || roots.length < 1) { throw new Error("expected 'roots' to be non-empty Array, got: " + roots); } roots = roots.map((r) => { if (!(r instanceof Certificate)) { // throw new Error("expected 'root' to be an instance of Certificate"); r = new Certificate(r); } return r._cert; }); crls = crls || []; if (!Array.isArray(crls)) { throw new Error("expected 'crls' to be undefined or Array, got: " + crls); } crls = crls.map((crl) => { if (!(crl instanceof CRL)) { // throw new Error("expected 'crl' to be an instance of Certificate"); crl = new CRL(crl); } return crl._crl; }); const chain = new pkijs.CertificateChainValidationEngine({ trustedCerts: roots, certs, crls, }); const res = await chain.verify(); if (!res.result) { throw new Error(res.resultMessage); } else { return res; } } } const helpers = { resolveOid, }; /** * Main COSE labels * defined here: https://tools.ietf.org/html/rfc8152#section-7.1 * used by {@link fromCose} * * @private */ const coseLabels = { 1: { name: "kty", values: { 1: "OKP", 2: "EC", 3: "RSA", }, }, 2: { name: "kid", values: {}, }, 3: { name: "alg", values: { "-7": "ECDSA_w_SHA256", /* "-8": "EdDSA", */ "-35": "ECDSA_w_SHA384", "-36": "ECDSA_w_SHA512", /*"-37": "RSASSA-PSS_w_SHA-256", "-38": "RSASSA-PSS_w_SHA-384", "-39": "RSASSA-PSS_w_SHA-512",*/ "-257": "RSASSA-PKCS1-v1_5_w_SHA256", "-258": "RSASSA-PKCS1-v1_5_w_SHA384", "-259": "RSASSA-PKCS1-v1_5_w_SHA512", "-65535": "RSASSA-PKCS1-v1_5_w_SHA1", }, }, 4: { name: "key_ops", values: {}, }, 5: { name: "base_iv", values: {}, }, }; /** * Key specific COSE parameters * used by {@link fromCose} * * @private */ const coseKeyParamList = { // ECDSA key parameters // defined here: https://tools.ietf.org/html/rfc8152#section-13.1.1 EC: { "-1": { name: "crv", values: { 1: "P-256", 2: "P-384", 3: "P-521", }, }, // value = Buffer "-2": { name: "x" }, "-3": { name: "y" }, "-4": { name: "d" }, }, // Octet Key Pair key parameters // defined here: https://datatracker.ietf.org/doc/html/rfc8152#section-13.2 OKP: { "-1": { name: "crv", values: { 4: "X25519", 5: "X448", 6: "Ed25519", 7: "Ed448", }, }, // value = Buffer "-2": { name: "x" }, "-4": { name: "d" }, }, // RSA key parameters // defined here: https://tools.ietf.org/html/rfc8230#section-4 RSA: { // value = Buffer "-1": { name: "n" }, "-2": { name: "e" }, "-3": { name: "d" }, "-4": { name: "p" }, "-5": { name: "q" }, "-6": { name: "dP" }, "-7": { name: "dQ" }, "-8": { name: "qInv" }, "-9": { name: "other" }, "-10": { name: "r_i" }, "-11": { name: "d_i" }, "-12": { name: "t_i" }, }, }; /** * Maps COSE algorithm identifier to JWK alg * used by {@link fromCose} * * @private */ const algToJWKAlg = { "RSASSA-PKCS1-v1_5_w_SHA256": "RS256", "RSASSA-PKCS1-v1_5_w_SHA384": "RS384", "RSASSA-PKCS1-v1_5_w_SHA512": "RS512", "RSASSA-PKCS1-v1_5_w_SHA1": "RS256", /* PS256-512 is untested "RSASSA-PSS_w_SHA-256": "PS256", "RSASSA-PSS_w_SHA-384": "PS384", "RSASSA-PSS_w_SHA-512": "PS512",*/ "ECDSA_w_SHA256": "ES256", "ECDSA_w_SHA384": "ES384", "ECDSA_w_SHA512": "ES512", /* EdDSA is untested and unfinished "EdDSA": "EdDSA" */ }; /** * Maps Cose algorithm identifier or JWK.alg to webcrypto algorithm identifier * used by {@link setAlgorithm} * * @private */ const algorithmInputMap = { /* Cose Algorithm identifier to Webcrypto algorithm name */ "RSASSA-PKCS1-v1_5_w_SHA256": "RSASSA-PKCS1-v1_5", "RSASSA-PKCS1-v1_5_w_SHA384": "RSASSA-PKCS1-v1_5", "RSASSA-PKCS1-v1_5_w_SHA512": "RSASSA-PKCS1-v1_5", "RSASSA-PKCS1-v1_5_w_SHA1": "RSASSA-PKCS1-v1_5", /*"RSASSA-PSS_w_SHA-256": "RSASSA-PSS", "RSASSA-PSS_w_SHA-384": "RSASSA-PSS", "RSASSA-PSS_w_SHA-512": "RSASSA-PSS",*/ "ECDSA_w_SHA256": "ECDSA", "ECDSA_w_SHA384": "ECDSA", "ECDSA_w_SHA512": "ECDSA", /*"EdDSA": "EdDSA",*/ /* JWK alg to Webcrypto algorithm name */ "RS256": "RSASSA-PKCS1-v1_5", "RS384": "RSASSA-PKCS1-v1_5", "RS512": "RSASSA-PKCS1-v1_5", /*"PS256": "RSASSA-PSS", "PS384": "RSASSA-PSS", "PS512": "RSASSA-PSS",*/ "ES384": "ECDSA", "ES256": "ECDSA", "ES512": "ECDSA", /*"EdDSA": "EdDSA",*/ }; /** * Maps Cose algorithm identifier webcrypto hash name * used by {@link setAlgorithm} * * @private */ const inputHashMap = { /* Cose Algorithm identifier to Webcrypto hash name */ "RSASSA-PKCS1-v1_5_w_SHA256": "SHA-256", "RSASSA-PKCS1-v1_5_w_SHA384": "SHA-384", "RSASSA-PKCS1-v1_5_w_SHA512": "SHA-512", "RSASSA-PKCS1-v1_5_w_SHA1": "SHA-1", /*"RSASSA-PSS_w_SHA256": "SHA-256", "RSASSA-PSS_w_SHA384": "SHA-384", "RSASSA-PSS_w_SHA512": "SHA-512",*/ "ECDSA_w_SHA256": "SHA-256", "ECDSA_w_SHA384": "SHA-384", "ECDSA_w_SHA512": "SHA-512", /* "EdDSA": "EdDSA", */ }; /** * Class representing a generic public key, * with utility functions to convert between different formats * using Webcrypto * * @package * */ class PublicKey { /** * Create a empty public key * * @returns {CryptoKey} */ constructor() { /** * Internal reference to imported PEM string * @type {string} * @private */ this._original_pem = undefined; /** * Internal reference to imported JWK object * @type {object} * @private */ this._original_jwk = undefined; /** * Internal reference to imported Cose data * @type {object} * @private */ this._original_cose = undefined; /** * Internal reference to algorithm, should be of RsaHashedImportParams or EcKeyImportParams format * @type {object} * @private */ this._alg = undefined; /** * Internal reference to a CryptoKey object * @type {object} * @private */ this._key = undefined; } /** * Import a CryptoKey, makes basic checks and throws on failure * * @public * @param {CryptoKey} key - CryptoKey to import * @param {object} [alg] - Algorithm override * * @returns {CryptoKey} - Returns this for chaining */ fromCryptoKey(key, alg) { // Throw on missing key if (!key) { throw new TypeError("No key passed"); } // Allow a CryptoKey to be passed through the constructor if (key && (!key.type || key.type !== "public")) { throw new TypeError("Invalid argument passed to fromCryptoKey, should be instance of CryptoKey with type public"); } // Store key this._key = key; // Store internal representation of algorithm this.setAlgorithm(key.algorithm); // Update algorithm if passed if (alg) { this.setAlgorithm(alg); } return this; } /** * Import public key from SPKI PEM. Throws on any type of failure. * * @async * @public * @param {string} pem - PEM formatted string * @return {Promise<PublicKey>} - Returns itself for chaining */ async fromPem(pem, hashName) { // Convert PEM to Base64 let base64ber, ber; // Clean up base64 string if (typeof pem === "string" || pem instanceof String) { pem = pem.replace(/\r/g, ""); } if (isPem(pem)) { base64ber = pemToBase64(pem); ber = coerceToArrayBuffer$1(base64ber, "base64ber"); } else { throw new Error("Supplied key is not in PEM format"); } if (ber.byteLength === 0) { throw new Error("Supplied key ber was empty (0 bytes)"); } // Extract x509 information const asn1 = asn1js.fromBER(ber); if (asn1.offset === -1) { throw new Error("error parsing ASN.1"); } let keyInfo = new pkijs.PublicKeyInfo({ schema: asn1.result }); const algorithm = {}; // Extract algorithm from key info if (keyInfo.algorithm.algorithmId === "1.2.840.10045.2.1") { algorithm.name = "ECDSA"; // Use parsedKey to extract namedCurve if present, else default to P-256 const parsedKey = keyInfo.parsedKey; if (parsedKey && parsedKey.namedCurve === "1.2.840.10045.3.1.7") { // NIST P-256, secp256r1 algorithm.namedCurve = "P-256"; } else if (parsedKey && parsedKey.namedCurve === "1.3.132.0.34") { // NIST P-384, secp384r1 algorithm.namedCurve = "P-384"; } else if (parsedKey && parsedKey.namedCurve === "1.3.132.0.35") { // NIST P-512, secp521r1 algorithm.namedCurve = "P-512"; } else { algorithm.namedCurve = "P-256"; } // Handle RSA } else if (keyInfo.algorithm.algorithmId === "1.2.840.113549.1.1.1") { algorithm.name = "RSASSA-PKCS1-v1_5"; // Default hash to SHA-256 algorithm.hash = hashName || "SHA-256"; } this.setAlgorithm(algorithm); // Import key using webcrypto let importSPKIResult; try { importSPKIResult = await webcrypto.subtle.importKey("spki", ber, algorithm, true, ["verify"]); } catch (_e1) { throw new Error("Unsupported key format", _e1); } // Store references this._original_pem = pem; this._key = importSPKIResult; return this; } /** * Import public key from JWK. Throws on any type of failure. * * @async * @public * @param {object} jwk - JWK object * @return {Promise<PublicKey>} - Returns itself for chaining */ async fromJWK(jwk, extractable) { // Copy JWK const jwkCopy = JSON.parse(JSON.stringify(jwk)); // Force extractable flag if specified if ( typeof extractable !== "undefined" && typeof extractable === "boolean" ) { jwkCopy.ext = extractable; } // Store alg this.setAlgorithm(jwkCopy); // Import jwk with Jose this._original_jwk = jwk; const generatedKey = await webcrypto.subtle.importKey( "jwk", jwkCopy, this.getAlgorithm(), true, ["verify"] ); this._key = generatedKey; return this; } /** * Import public key from COSE data. Throws on any type of failure. * * Internally this function converts COSE to a JWK, then calls .fromJwk() to import key to CryptoKey * * @async * @public * @param {object} cose - COSE data * @return {Promise<PublicKey>} - Returns itself for chaining */ async fromCose(cose) { if (typeof cose !== "object") { throw new TypeError( "'cose' argument must be an object, probably an Buffer conatining valid COSE", ); } this._cose = coerceToArrayBuffer$1(cose, "coseToJwk"); let parsedCose; try { // In the current state, the "cose" parameter can contain not only the actual cose (= public key) but also extensions. // Both are CBOR encoded entries, so you can treat and evaluate the "cose" parameter accordingly. // "fromCose" is called from a context that contains an active AT flag (attestation), so the first CBOR entry is the actual cose. // "tools.cbor.decode" will fail when multiple entries are provided (e.g. cose + at least one extension), so "decodeMultiple" is the sollution. cborX__namespace.decodeMultiple( new Uint8Array(cose), cborObject => { parsedCose = cborObject; return false; } ); } catch (err) { throw new Error( "couldn't parse authenticator.authData.attestationData CBOR: " + err, ); } if (typeof parsedCose !== "object") { throw new Error( "invalid parsing of authenticator.authData.attestationData CBOR", ); } const coseMap = new Map(Object.entries(parsedCose)); const extraMap = new Map(); const retKey = {}; // parse main COSE labels for (const kv of coseMap) { const key = kv[0].toString(); let value = kv[1].toString(); if (!coseLabels[key]) { extraMap.set(kv[0], kv[1]); continue; } const name = coseLabels[key].name; if (coseLabels[key].values[value]) { value = coseLabels[key].values[value]; } retKey[name] = value; } const keyParams = coseKeyParamList[retKey.kty]; // parse key-specific parameters for (const kv of extraMap) { const key = kv[0].toString(); let value = kv[1]; if (!keyParams[key]) { throw new Error( "unknown COSE key label: " + retKey.kty + " " + key, ); } const name = keyParams[key].name; if (keyParams[key].values) { value = keyParams[key].values[value.toString()]; } value = coerceToBase64Url(value, "coseToJwk"); retKey[name] = value; } // Store reference to original cose object this._original_cose = cose; // Set algorithm from cose JWK-like this.setAlgorithm(retKey); // Convert cose algorithm identifier to jwk algorithm name retKey.alg = algToJWKAlg[retKey.alg]; await this.fromJWK(retKey, true); return this; } /** * Exports public key to PEM. * - Reuses original PEM string if present. * - Possible to force regeneration of PEM string by setting 'forcedExport' parameter to true * - Throws on any kind of failure * * @async * @public * @param {boolean} [forcedExport] - Force regeneration of PEM string even if original PEM-string is available * @return {Promise<string>} - Returns PEM string */ async toPem(forcedExport) { if (this._original_pem && !forcedExport) { return this._original_pem; } else if (this.getKey()) { let pemResult = abToPem("PUBLIC KEY",await webcrypto.subtle.exportKey("spki", this.getKey())); return pemResult; } else { throw new Error("No key information available"); } } /** * Exports public key to JWK. * - Only works if original jwk from 'fromJwk()' is available * - Throws on any kind of failure * * @public * @return {object} - Returns JWK object */ toJwk() { if (this._original_jwk) { return this._original_jwk; } else { throw new Error("No usable key information available"); } } /** * Exports public key to COSE data * - Only works if original cose data from 'fromCose()' is available * - Throws on any kind of failure * * @public * @return {object} - Returns COSE data object */ toCose() { if (this._original_cose) { return this._original_cose; } else { throw new Error("No usable key information available"); } } /** * Returns internal key in CryptoKey format * - Mainly intended for internal use * - Throws if internal CryptoKey does not exist * * @public * @return {CryptoKey} - Internal CryptoKey instance, or undefined */ getKey() { if (this._key) { return this._key; } else { throw new Error("Key data not available"); } } /** * Returns internal algorithm, which should be of one of the following formats * - RsaHashedImportParams * - EcKeyImportParams * - undefined * * @public * @return {object|undefined} - Internal algorithm representation, or undefined */ getAlgorithm() { return this._alg; } /** * Sets internal algorithm identifier in format used by webcrypto, should be one of * - Allows adding missing properties * - Makes sure `alg.hash` is is `{ hash: { name: 'foo'} }` format * - Syncs back updated algorithm to this._key * * @public * @param {object} - RsaHashedImportParams, EcKeyImportParams, JWK or JWK-like * @return {object|undefined} - Internal algorithm representation, or undefined */ setAlgorithm(algorithmInput) { let algorithmOutput = this._alg || {}; // Check for name if not already present // From Algorithm object if (algorithmInput.name) { algorithmOutput.name = algorithmInput.name; // JWK or JWK-like } else if (algorithmInput.alg) { const algMapResult = algorithmInputMap[algorithmInput.alg]; if (algMapResult) { algorithmOutput.name = algMapResult; } } // Check for hash if not already present // From Algorithm object if (algorithmInput.hash) { if (algorithmInput.hash.name) { algorithmOutput.hash = algorithmInput.hash; } else { algorithmOutput.hash = { name: algorithmInput.hash }; } // Try to extract hash from JWK-like .alg } else if (algorithmInput.alg) { let hashMapResult = inputHashMap[algorithmInput.alg]; if (hashMapResult) { algorithmOutput.hash = { name: hashMapResult }; } } // Try to extract namedCurve if not already present if (algorithmInput.namedCurve) { algorithmOutput.namedCurve = algorithmInput.namedCurve; } else if (algorithmInput.crv) { algorithmOutput.namedCurve = algorithmInput.crv; } // Set this._alg if any algorithm properties existed, or were added if (Object.keys(algorithmOutput).length > 0) { this._alg = algorithmOutput; // Sync algorithm hash to CryptoKey if (this._alg.hash && this._key) { this._key.algorithm.hash = this._alg.hash; } } } } /** * Utility function to convert a cose algorithm to string * * @package * * @param {string|number} - Cose algorithm */ function coseAlgToStr(alg) { if (typeof alg !== "number") { throw new TypeError("expected 'alg' to be a number, got: " + alg); } const algValues = coseLabels["3"].values; const mapResult = algValues[alg]; if (!mapResult) { throw new Error("'alg' is not a valid COSE algorithm number"); } return algValues[alg]; } /** * Utility function to convert a cose hashing algorithm to string * * @package * * @param {string|number} - Cose algorithm */ function coseAlgToHashStr(alg) { if (typeof alg === "number") alg = coseAlgToStr(alg); if (typeof alg !== "string") { throw new Error("'alg' is not a string or a valid COSE algorithm number"); } const mapResult = inputHashMap[alg]; if (!mapResult) { throw new Error("'alg' is not a valid COSE algorithm"); } return inputHashMap[alg]; } // External dependencies 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__namespace && platformCrypto__namespace.webcrypto) { webcrypto = platformCrypto__namespace.webcrypto; } else { // Fallback to @peculiar/webcrypto webcrypto = new peculiarCrypto__namespace.Crypto(); } } // Set up pkijs const pkijs = { setEngine: pkijs$1.setEngine, CryptoEngine: pkijs$1.CryptoEngine, Certificate: pkijs$1.Certificate, CertificateRevocationList: pkijs$1.CertificateRevocationList, CertificateChainValidationEngine: pkijs$1.CertificateChainValidationEngine, PublicKeyInfo: pkijs$1.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 = tldts.parse(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) && validDomainName(value)) { 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__namespace && platformCrypto__namespace.createVerify ) { try { const pem = await publicKeyInst.toPem(); const verify = platformCrypto__namespace.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; } var toolbox = /*#__PURE__*/Object.freeze({ __proto__: null, base64: base64, cbor: cborX__namespace, checkDomainOrUrl: checkDomainOrUrl, checkOrigin: checkOrigin, checkRpId: checkRpId, checkUrl: checkUrl, decodeProtectedHeader: jose.decodeProtectedHeader, fromBER: asn1js.fromBER, getEmbeddedJwk: getEmbeddedJwk, getHostname: getHostname, hashDigest: hashDigest, importJWK: jose.importJWK, jwtVerify: jose.jwtVerify, pkijs: pkijs, randomValues: randomValues, verifySignature: verifySignature, get webcrypto () { return webcrypto; } }); function ab2str(buf) { let str = ""; new Uint8Array(buf).forEach((ch) => { str += String.fromCharCode(ch); }); return str; } function isBase64Url(str) { return !!str.match(/^[A-Za-z0-9\-_]+={0,2}$/); } function isPem(pem) { if (typeof pem !== "string") { return false; } const pemRegex = /^-----BEGIN .+-----$\n([A-Za-z0-9+/=]|\n)*^-----END .+-----$/m; return !!pem.match(pemRegex); } function isPositiveInteger(n) { return n >>> 0 === parseFloat(n); } function abToBuf$1(ab) { return new Uint8Array(ab).buffer; } function abToInt(ab) { if (!(ab instanceof ArrayBuffer)) { throw new Error("abToInt: expected ArrayBuffer"); } const buf = new Uint8Array(ab); let cnt = ab.byteLength - 1; let ret = 0; buf.forEach((byte) => { ret |= byte << (cnt * 8); cnt--; }); return ret; } function abToPem(type, ab) { if (typeof type !== "string") { throw new Error( "abToPem expected 'type' to be string like 'CERTIFICATE', got: " + type, ); } const str = coerceToBase64(ab, "pem buffer"); return [ `-----BEGIN ${type}-----\n`, ...str.match(/.{1,64}/g).map((s) => s + "\n"), `-----END ${type}-----\n`, ].join(""); } /** * Creates a new Uint8Array based on two different ArrayBuffers * * @private * @param {ArrayBuffers} buffer1 The first buffer. * @param {ArrayBuffers} buffer2 The second buffer. * @return {ArrayBuffers} The new ArrayBuffer created out of the two. */ const appendBuffer$1 = function(buffer1, buffer2) { const tmp = new Uint8Array(buffer1.byteLength + buffer2.byteLength); tmp.set(new Uint8Array(buffer1), 0); tmp.set(new Uint8Array(buffer2), buffer1.byteLength); return tmp.buffer; }; function coerceToArrayBuffer$1(buf, name) { if (!name) { throw new TypeError("name not specified in coerceToArrayBuffer"); } // Handle empty strings if (typeof buf === "string" && buf === "") { buf = new Uint8Array(0); // Handle base64url and base64 strings } else if (typeof buf === "string") { // base64 to base64url buf = buf.replace(/\+/g, "-").replace(/\//g, "_").replace("=", ""); // base64 to Buffer buf = base64.toArrayBuffer(buf, true); } // Extract typed array from Array if (Array.isArray(buf)) { buf = new Uint8Array(buf); } // Extract ArrayBuffer from Node buffer if (typeof Buffer !== "undefined" && buf instanceof Buffer) { buf = new Uint8Array(buf); buf = buf.buffer; } // Extract arraybuffer from TypedArray if (buf instanceof Uint8Array) { buf = buf.slice(0, buf.byteLength, buf.buffer.byteOffset).buffer; } // error if none of the above worked if (!(buf instanceof ArrayBuffer)) { throw new TypeError(`could not coerce '${name}' to ArrayBuffer`); } return buf; } function coerceToBase64(thing, name) { if (!name) { throw new TypeError("name not specified in coerceToBase64"); } if (typeof thing !== "string") { try { thing = base64.fromArrayBuffer( coerceToArrayBuffer$1(thing, name), ); } catch (_err) { throw new Error(`could not coerce '${name}' to string`); } } return thing; } function str2ab(str) { const buf = new ArrayBuffer(str.length); const bufView = new Uint8Array(buf); for (let i = 0, strLen = str.length; i < strLen; i++) { bufView[i] = str.charCodeAt(i); } return buf; } function coerceToBase64Url(thing, name) { if (!name) { throw new TypeError("name not specified in coerceToBase64"); } if (typeof thing === "string") { // Convert from base64 to base64url thing = thing.replace(/\+/g, "-").replace(/\//g, "_").replace(/={0,2}$/g, ""); } if (typeof thing !== "string") { try { thing = base64.fromArrayBuffer( coerceToArrayBuffer$1(thing, name), true, ); } catch (_err) { throw new Error(`could not coerce '${name}' to string`); } } return thing; } // Merged with previous arrayBufferEquals function arrayBufferEquals(b1, b2) { if ( !(b1 instanceof ArrayBuffer) || !(b2 instanceof ArrayBuffer) ) { return false; } if (b1.byteLength !== b2.byteLength) { return false; } b1 = new Uint8Array(b1); b2 = new Uint8Array(b2); for (let i = 0; i < b1.byteLength; i++) { if (b1[i] !== b2[i]) return false; } return true; } function abToHex(ab) { if (!(ab instanceof ArrayBuffer)) { throw new TypeError("Invalid argument passed to abToHex"); } const result = Array.prototype.map.call( new Uint8Array(ab), (x) => ("00" + x.toString(16)).slice(-2), ).join(""); return result; } function b64ToJsObject(b64, desc) { return JSON.parse(ab2str(coerceToArrayBuffer$1(b64, desc))); } function jsObjectToB64(obj) { return base64.fromString( JSON.stringify(obj).replace(/[\u{0080}-\u{FFFF}]/gu, ""), ); } function pemToBase64(pem) { // Clean up base64 string if (typeof pem === "string" || pem instanceof String) { pem = pem.replace(/\r/g, ""); } if (!isPem(pem)) { throw new Error("expected PEM string as input"); } // Remove trailing \n pem = pem.replace(/\n$/, ""); // Split on \n let pemArr = pem.split("\n"); // remove first and last lines pemArr = pemArr.slice(1, pemArr.length - 1); return pemArr.join(""); } var utils = /*#__PURE__*/Object.freeze({ __proto__: null, ab2str: ab2str, abToBuf: abToBuf$1, abToHex: abToHex, abToInt: abToInt, abToPem: abToPem, appendBuffer: appendBuffer$1, arrayBufferEquals: arrayBufferEquals, b64ToJsObject: b64ToJsObject, coerceToArrayBuffer: coerceToArrayBuffer$1, coerceToBase64: coerceToBase64, coerceToBase64Url: coerceToBase64Url, isBase64Url: isBase64Url, isPem: isPem, isPositiveInteger: isPositiveInteger, jsObjectToB64: jsObjectToB64, pemToBase64: pemToBase64, str2ab: str2ab, tools: toolbox }); // deno-lint-ignore-file async function validateExpectations() { /* eslint complexity: ["off"] */ let req = this.requiredExpectations; let opt = this.optionalExpectations; let exp = this.expectations; if (!(exp instanceof Map)) { throw new Error("expectations should be of type Map"); } if (Array.isArray(req)) { req = new Set([req]); } if (!(req instanceof Set)) { throw new Error("requiredExpectaions should be of type Set"); } if (Array.isArray(opt)) { opt = new Set([opt]); } if (!(opt instanceof Set)) { throw new Error("optionalExpectations should be of type Set"); } for (let field of req) { if (!exp.has(field)) { throw new Error(`expectation did not contain value for '${field}'`); } } let optCount = 0; for (const [field] of exp) { if (opt.has(field)) { optCount++; } } if (req.size !== exp.size - optCount) { throw new Error( `wrong number of expectations: should have ${req.size} but got ${exp.size - optCount}`, ); } // origin - isValid if (req.has("origin")) { let expectedOrigin = exp.get("origin"); checkOr