diff options
Diffstat (limited to 'dom/webauthn/tests/u2futil.js')
-rw-r--r-- | dom/webauthn/tests/u2futil.js | 511 |
1 files changed, 511 insertions, 0 deletions
diff --git a/dom/webauthn/tests/u2futil.js b/dom/webauthn/tests/u2futil.js new file mode 100644 index 0000000000..e8026e8e59 --- /dev/null +++ b/dom/webauthn/tests/u2futil.js @@ -0,0 +1,511 @@ +// Used by local_addTest() / local_completeTest() +var _countCompletions = 0; +var _expectedCompletions = 0; + +const flag_TUP = 0x01; +const flag_UV = 0x04; +const flag_AT = 0x40; + +const cose_kty = 1; +const cose_kty_ec2 = 2; +const cose_alg = 3; +const cose_alg_ECDSA_w_SHA256 = -7; +const cose_alg_ECDSA_w_SHA512 = -36; +const cose_crv = -1; +const cose_crv_P256 = 1; +const cose_crv_x = -2; +const cose_crv_y = -3; + +var { AppConstants } = SpecialPowers.ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" +); + +async function addVirtualAuthenticator( + protocol = "ctap2_1", + transport = "internal", + hasResidentKey = true, + hasUserVerification = true, + isUserConsenting = true, + isUserVerified = true +) { + let id = await SpecialPowers.spawnChrome( + [ + protocol, + transport, + hasResidentKey, + hasUserVerification, + isUserConsenting, + isUserVerified, + ], + ( + protocol, + transport, + hasResidentKey, + hasUserVerification, + isUserConsenting, + isUserVerified + ) => { + let webauthnService = Cc["@mozilla.org/webauthn/service;1"].getService( + Ci.nsIWebAuthnService + ); + let id = webauthnService.addVirtualAuthenticator( + protocol, + transport, + hasResidentKey, + hasUserVerification, + isUserConsenting, + isUserVerified + ); + return id; + } + ); + + SimpleTest.registerCleanupFunction(async () => { + await SpecialPowers.spawnChrome([id], id => { + let webauthnService = Cc["@mozilla.org/webauthn/service;1"].getService( + Ci.nsIWebAuthnService + ); + webauthnService.removeVirtualAuthenticator(id); + }); + }); + + return id; +} + +function handleEventMessage(event) { + if ("test" in event.data) { + let summary = event.data.test + ": " + event.data.msg; + log(event.data.status + ": " + summary); + ok(event.data.status, summary); + } else if ("done" in event.data) { + SimpleTest.finish(); + } else { + ok(false, "Unexpected message in the test harness: " + event.data); + } +} + +function log(msg) { + console.log(msg); + let logBox = document.getElementById("log"); + if (logBox) { + logBox.textContent += "\n" + msg; + } +} + +function local_is(value, expected, message) { + if (value === expected) { + local_ok(true, message); + } else { + local_ok(false, message + " unexpectedly: " + value + " !== " + expected); + } +} + +function local_isnot(value, expected, message) { + if (value !== expected) { + local_ok(true, message); + } else { + local_ok(false, message + " unexpectedly: " + value + " === " + expected); + } +} + +function local_ok(expression, message) { + let body = { test: this.location.pathname, status: expression, msg: message }; + parent.postMessage(body, "http://mochi.test:8888"); +} + +function local_doesThrow(fn, name) { + let gotException = false; + try { + fn(); + } catch (ex) { + gotException = true; + } + local_ok(gotException, name); +} + +function local_expectThisManyTests(count) { + if (_expectedCompletions > 0) { + local_ok( + false, + "Error: local_expectThisManyTests should only be called once." + ); + } + _expectedCompletions = count; +} + +function local_completeTest() { + _countCompletions += 1; + if (_countCompletions == _expectedCompletions) { + log("All tests completed."); + local_finished(); + } + if (_countCompletions > _expectedCompletions) { + local_ok( + false, + "Error: local_completeTest called more than local_addTest." + ); + } +} + +function local_finished() { + parent.postMessage({ done: true }, "http://mochi.test:8888"); +} + +function string2buffer(str) { + return new Uint8Array(str.length).map((x, i) => str.charCodeAt(i)); +} + +function buffer2string(buf) { + let str = ""; + if (!(buf.constructor === Uint8Array)) { + buf = new Uint8Array(buf); + } + buf.map(function (x) { + return (str += String.fromCharCode(x)); + }); + return str; +} + +function bytesToBase64(u8a) { + let CHUNK_SZ = 0x8000; + let c = []; + let array = new Uint8Array(u8a); + for (let i = 0; i < array.length; i += CHUNK_SZ) { + c.push(String.fromCharCode.apply(null, array.subarray(i, i + CHUNK_SZ))); + } + return window.btoa(c.join("")); +} + +function base64ToBytes(b64encoded) { + return new Uint8Array( + window + .atob(b64encoded) + .split("") + .map(function (c) { + return c.charCodeAt(0); + }) + ); +} + +function bytesToBase64UrlSafe(buf) { + return bytesToBase64(buf) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=/g, ""); +} + +function base64ToBytesUrlSafe(str) { + if (str.length % 4 == 1) { + throw "Improper b64 string"; + } + + var b64 = str.replace(/\-/g, "+").replace(/\_/g, "/"); + while (b64.length % 4 != 0) { + b64 += "="; + } + return base64ToBytes(b64); +} + +function hexEncode(buf) { + return Array.from(buf) + .map(x => ("0" + x.toString(16)).substr(-2)) + .join(""); +} + +function hexDecode(str) { + return new Uint8Array(str.match(/../g).map(x => parseInt(x, 16))); +} + +function hasOnlyKeys(obj, ...keys) { + let okeys = new Set(Object.keys(obj)); + return keys.length == okeys.size && keys.every(k => okeys.has(k)); +} + +function webAuthnDecodeCBORAttestation(aCborAttBuf) { + let attObj = CBOR.decode(aCborAttBuf); + console.log(":: Attestation CBOR Object ::"); + if (!hasOnlyKeys(attObj, "authData", "fmt", "attStmt")) { + return Promise.reject("Invalid CBOR Attestation Object"); + } + if (attObj.fmt == "fido-u2f" && !hasOnlyKeys(attObj.attStmt, "sig", "x5c")) { + return Promise.reject("Invalid CBOR Attestation Statement"); + } + if ( + attObj.fmt == "packed" && + !( + hasOnlyKeys(attObj.attStmt, "alg", "sig") || + hasOnlyKeys(attObj.attStmt, "alg", "sig", "x5c") + ) + ) { + return Promise.reject("Invalid CBOR Attestation Statement"); + } + if (attObj.fmt == "none" && Object.keys(attObj.attStmt).length) { + return Promise.reject("Invalid CBOR Attestation Statement"); + } + + return webAuthnDecodeAuthDataArray(new Uint8Array(attObj.authData)).then( + function (aAuthDataObj) { + attObj.authDataObj = aAuthDataObj; + return Promise.resolve(attObj); + } + ); +} + +function webAuthnDecodeAuthDataArray(aAuthData) { + let rpIdHash = aAuthData.slice(0, 32); + let flags = aAuthData.slice(32, 33); + let counter = aAuthData.slice(33, 37); + + console.log(":: Authenticator Data ::"); + console.log("RP ID Hash: " + hexEncode(rpIdHash)); + console.log("Counter: " + hexEncode(counter) + " Flags: " + flags); + + if ((flags & flag_AT) == 0x00) { + // No Attestation Data, so we're done. + return Promise.resolve({ + rpIdHash, + flags, + counter, + }); + } + + if (aAuthData.length < 38) { + return Promise.reject( + "Authenticator Data flag was set, but not enough data passed in!" + ); + } + + let attData = {}; + attData.aaguid = aAuthData.slice(37, 53); + attData.credIdLen = (aAuthData[53] << 8) + aAuthData[54]; + attData.credId = aAuthData.slice(55, 55 + attData.credIdLen); + + console.log(":: Authenticator Data ::"); + console.log("AAGUID: " + hexEncode(attData.aaguid)); + + let cborPubKey = aAuthData.slice(55 + attData.credIdLen); + var pubkeyObj = CBOR.decode(cborPubKey.buffer); + if ( + !( + cose_kty in pubkeyObj && + cose_alg in pubkeyObj && + cose_crv in pubkeyObj && + cose_crv_x in pubkeyObj && + cose_crv_y in pubkeyObj + ) + ) { + throw "Invalid CBOR Public Key Object"; + } + if (pubkeyObj[cose_kty] != cose_kty_ec2) { + throw "Unexpected key type"; + } + if (pubkeyObj[cose_alg] != cose_alg_ECDSA_w_SHA256) { + throw "Unexpected public key algorithm"; + } + if (pubkeyObj[cose_crv] != cose_crv_P256) { + throw "Unexpected curve"; + } + + let pubKeyBytes = assemblePublicKeyBytesData( + pubkeyObj[cose_crv_x], + pubkeyObj[cose_crv_y] + ); + console.log(":: CBOR Public Key Object Data ::"); + console.log("kty: " + pubkeyObj[cose_kty] + " (EC2)"); + console.log("alg: " + pubkeyObj[cose_alg] + " (ES256)"); + console.log("crv: " + pubkeyObj[cose_crv] + " (P256)"); + console.log("X: " + pubkeyObj[cose_crv_x]); + console.log("Y: " + pubkeyObj[cose_crv_y]); + console.log("Uncompressed (hex): " + hexEncode(pubKeyBytes)); + + return importPublicKey(pubKeyBytes).then(function (aKeyHandle) { + return Promise.resolve({ + rpIdHash, + flags, + counter, + attestationAuthData: attData, + publicKeyBytes: pubKeyBytes, + publicKeyHandle: aKeyHandle, + }); + }); +} + +function importPublicKey(keyBytes) { + if (keyBytes[0] != 0x04 || keyBytes.byteLength != 65) { + throw "Bad public key octet string"; + } + var jwk = { + kty: "EC", + crv: "P-256", + x: bytesToBase64UrlSafe(keyBytes.slice(1, 33)), + y: bytesToBase64UrlSafe(keyBytes.slice(33)), + }; + return crypto.subtle.importKey( + "jwk", + jwk, + { name: "ECDSA", namedCurve: "P-256" }, + true, + ["verify"] + ); +} + +function deriveAppAndChallengeParam(appId, clientData, attestation) { + var appIdBuf = string2buffer(appId); + return Promise.all([ + crypto.subtle.digest("SHA-256", appIdBuf), + crypto.subtle.digest("SHA-256", clientData), + ]).then(function (digests) { + return { + appParam: new Uint8Array(digests[0]), + challengeParam: new Uint8Array(digests[1]), + attestation, + }; + }); +} + +function assemblePublicKeyBytesData(xCoord, yCoord) { + // Produce an uncompressed EC key point. These start with 0x04, and then + // two 32-byte numbers denoting X and Y. + if (xCoord.length != 32 || yCoord.length != 32) { + throw "Coordinates must be 32 bytes long"; + } + let keyBytes = new Uint8Array(65); + keyBytes[0] = 0x04; + xCoord.map((x, i) => (keyBytes[1 + i] = x)); + yCoord.map((x, i) => (keyBytes[33 + i] = x)); + return keyBytes; +} + +function assembleSignedData(appParam, flags, counter, challengeParam) { + let signedData = new Uint8Array(32 + 1 + 4 + 32); + new Uint8Array(appParam).map((x, i) => (signedData[0 + i] = x)); + signedData[32] = new Uint8Array(flags)[0]; + new Uint8Array(counter).map((x, i) => (signedData[33 + i] = x)); + new Uint8Array(challengeParam).map((x, i) => (signedData[37 + i] = x)); + return signedData; +} + +function assembleRegistrationSignedData( + appParam, + challengeParam, + keyHandle, + pubKey +) { + let signedData = new Uint8Array(1 + 32 + 32 + keyHandle.length + 65); + signedData[0] = 0x00; + new Uint8Array(appParam).map((x, i) => (signedData[1 + i] = x)); + new Uint8Array(challengeParam).map((x, i) => (signedData[33 + i] = x)); + new Uint8Array(keyHandle).map((x, i) => (signedData[65 + i] = x)); + new Uint8Array(pubKey).map( + (x, i) => (signedData[65 + keyHandle.length + i] = x) + ); + return signedData; +} + +function sanitizeSigArray(arr) { + // ECDSA signature fields into WebCrypto must be exactly 32 bytes long, so + // this method strips leading padding bytes, if added, and also appends + // padding zeros, if needed. + if (arr.length > 32) { + arr = arr.slice(arr.length - 32); + } + let ret = new Uint8Array(32); + ret.set(arr, ret.length - arr.length); + return ret; +} + +function verifySignature(key, data, derSig) { + if (derSig.byteLength < 68) { + return Promise.reject( + "Invalid signature (length=" + + derSig.byteLength + + "): " + + hexEncode(new Uint8Array(derSig)) + ); + } + + // Copy signature data into the current context. + let derSigCopy = new ArrayBuffer(derSig.byteLength); + new Uint8Array(derSigCopy).set(new Uint8Array(derSig)); + + let sigAsn1 = org.pkijs.fromBER(derSigCopy); + + // pkijs.asn1 seems to erroneously set an error code when calling some + // internal function. The test suite doesn't like dangling globals. + delete window.error; + + let sigR = new Uint8Array( + sigAsn1.result.value_block.value[0].value_block.value_hex + ); + let sigS = new Uint8Array( + sigAsn1.result.value_block.value[1].value_block.value_hex + ); + + // The resulting R and S values from the ASN.1 Sequence must be fit into 32 + // bytes. Sometimes they have leading zeros, sometimes they're too short, it + // all depends on what lib generated the signature. + let R = sanitizeSigArray(sigR); + let S = sanitizeSigArray(sigS); + + console.log("Verifying these bytes: " + bytesToBase64UrlSafe(data)); + + let sigData = new Uint8Array(R.length + S.length); + sigData.set(R); + sigData.set(S, R.length); + + let alg = { name: "ECDSA", hash: "SHA-256" }; + return crypto.subtle.verify(alg, key, sigData, data); +} + +async function addCredential(authenticatorId, rpId) { + let keyPair = await crypto.subtle.generateKey( + { + name: "ECDSA", + namedCurve: "P-256", + }, + true, + ["sign"] + ); + + let credId = new Uint8Array(32); + crypto.getRandomValues(credId); + credId = bytesToBase64UrlSafe(credId); + + let privateKey = await crypto.subtle + .exportKey("pkcs8", keyPair.privateKey) + .then(privateKey => bytesToBase64UrlSafe(privateKey)); + + await SpecialPowers.spawnChrome( + [authenticatorId, credId, rpId, privateKey], + (authenticatorId, credId, rpId, privateKey) => { + let webauthnService = Cc["@mozilla.org/webauthn/service;1"].getService( + Ci.nsIWebAuthnService + ); + + webauthnService.addCredential( + authenticatorId, + credId, + true, // resident key + rpId, + privateKey, + "VGVzdCBVc2Vy", // "Test User" + 0 // sign count + ); + } + ); + + return credId; +} + +async function removeCredential(authenticatorId, credId) { + await SpecialPowers.spawnChrome( + [authenticatorId, credId], + (authenticatorId, credId) => { + let webauthnService = Cc["@mozilla.org/webauthn/service;1"].getService( + Ci.nsIWebAuthnService + ); + + webauthnService.removeCredential(authenticatorId, credId); + } + ); +} |