// Copyright 2014 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. /** * @fileoverview Handles web page requests for gnubby enrollment. */ 'use strict'; /** * webSafeBase64ToNormal reencodes a base64-encoded string. * * @param {string} s A string encoded as web-safe base64. * @return {string} A string encoded in normal base64. */ function webSafeBase64ToNormal(s) { return s.replace(/-/g, '+').replace(/_/g, '/'); } /** * decodeWebSafeBase64ToArray decodes a base64-encoded string. * * @param {string} s A base64-encoded string. * @return {!Uint8Array} */ function decodeWebSafeBase64ToArray(s) { var bytes = atob(webSafeBase64ToNormal(s)); var buffer = new ArrayBuffer(bytes.length); var ret = new Uint8Array(buffer); for (var i = 0; i < bytes.length; i++) { ret[i] = bytes.charCodeAt(i); } return ret; } // See "FIDO U2F Authenticator Transports Extension", §3.2.1. const transportTypeOID = [1, 3, 6, 1, 4, 1, 45724, 2, 1, 1]; /** * Returns the value of the transport-type X.509 extension from the supplied * attestation certificate, or 0. * * @param {!Uint8Array} der The DER bytes of an attestation certificate. * @returns {Uint8Array} the bytes of the transport-type extension, if present, * or null. * @throws {Error} */ function transportType(der) { var topLevel = new ByteString(der); const tbsCert = topLevel.getASN1(Tag.SEQUENCE).getASN1(Tag.SEQUENCE); tbsCert.getOptionalASN1( Tag.CONSTRUCTED | Tag.CONTEXT_SPECIFIC | 0); // version tbsCert.getASN1(Tag.INTEGER); // serialNumber tbsCert.getASN1(Tag.SEQUENCE); // signature algorithm tbsCert.getASN1(Tag.SEQUENCE); // issuer tbsCert.getASN1(Tag.SEQUENCE); // validity tbsCert.getASN1(Tag.SEQUENCE); // subject tbsCert.getASN1(Tag.SEQUENCE); // SPKI tbsCert.getOptionalASN1( // issuerUniqueID Tag.CONSTRUCTED | Tag.CONTEXT_SPECIFIC | 1); tbsCert.getOptionalASN1( // subjectUniqueID Tag.CONSTRUCTED | Tag.CONTEXT_SPECIFIC | 2); const outerExtensions = tbsCert.getOptionalASN1(Tag.CONSTRUCTED | Tag.CONTEXT_SPECIFIC | 3); if (outerExtensions == null) { return null; } const extensions = outerExtensions.getASN1(Tag.SEQUENCE); if (extensions.empty) { return null; } while (!extensions.empty) { const extension = extensions.getASN1(Tag.SEQUENCE); const oid = extension.getASN1ObjectIdentifier(); if (oid.length != transportTypeOID.length) { continue; } var matches = true; for (var i = 0; i < oid.length; i++) { if (oid[i] != transportTypeOID[i]) { matches = false; break; } } if (!matches) { continue; } extension.getOptionalASN1(Tag.BOOLEAN); // 'critical' flag const contents = extension.getASN1(Tag.OCTETSTRING); if (!extension.empty) { throw Error('trailing garbage after extension'); } return contents.getASN1(Tag.BITSTRING).data; } return null; } /** * makeCertAndKey creates a new ECDSA keypair and returns the private key * and a cert containing the public key. * * @param {!Uint8Array=} opt_original The certificate being replaced, as DER * bytes. * @return {Promise<{privateKey: !webCrypto.CryptoKey, certDER: !Uint8Array}>} */ async function makeCertAndKey(opt_original) { var transport = null; if (opt_original) { transport = transportType(opt_original); } const keyalg = {name: 'ECDSA', namedCurve: 'P-256'}; const keypair = await crypto.subtle.generateKey(keyalg, true, ['sign', 'verify']); const publicKey = await crypto.subtle.exportKey('raw', keypair.publicKey); var serialBuffer = new ArrayBuffer(10); var serial = new Uint8Array(serialBuffer); crypto.getRandomValues(serial); const ecdsaWithSHA256 = [1, 2, 840, 10045, 4, 3, 2]; const ansiX962 = [1, 2, 840, 10045, 2, 1]; const secp256R1 = [1, 2, 840, 10045, 3, 1, 7]; const commonName = [2, 5, 4, 3]; const x509V3 = 2; const certBuilder = new ByteBuilder(); certBuilder.addASN1(Tag.SEQUENCE, (b) => { b.addASN1(Tag.SEQUENCE, (b) => { // TBSCertificate b.addASN1(Tag.CONTEXT_SPECIFIC | Tag.CONSTRUCTED | 0, (b) => { b.addASN1Int(x509V3); // Version }); b.addASN1BigInt(serial); // Serial number b.addASN1(Tag.SEQUENCE, (b) => { // Signature algorithm b.addASN1ObjectIdentifier(ecdsaWithSHA256); }); b.addASN1(Tag.SEQUENCE, (b) => { // Issuer b.addASN1(Tag.SET, (b) => { b.addASN1(Tag.SEQUENCE, (b) => { b.addASN1ObjectIdentifier(commonName); b.addASN1PrintableString('U2F Issuer'); }); }); }); b.addASN1(Tag.SEQUENCE, (b) => { // Validity b.addASN1(Tag.UTCTime, (b) => { b.addBytesFromString('0001010000Z'); }); b.addASN1(Tag.UTCTime, (b) => { b.addBytesFromString('0001010000Z'); }); }); b.addASN1(Tag.SEQUENCE, (b) => { // Subject b.addASN1(Tag.SET, (b) => { b.addASN1(Tag.SEQUENCE, (b) => { b.addASN1ObjectIdentifier(commonName); b.addASN1PrintableString('U2F Device'); }); }); }); b.addASN1(Tag.SEQUENCE, (b) => { // Public key b.addASN1(Tag.SEQUENCE, (b) => { // Algorithm identifier b.addASN1ObjectIdentifier(ansiX962); b.addASN1ObjectIdentifier(secp256R1); }); b.addASN1BitString(new Uint8Array(publicKey)); }); if (transport !== null) { var t = transport; // This causes the compiler to see t cannot be null. // Extensions b.addASN1(Tag.CONTEXT_SPECIFIC | Tag.CONSTRUCTED | 3, (b) => { b.addASN1(Tag.SEQUENCE, (b) => { b.addASN1(Tag.SEQUENCE, (b) => { // Transport-type extension. b.addASN1ObjectIdentifier(transportTypeOID); b.addASN1(Tag.OCTETSTRING, (b) => { b.addASN1(Tag.BITSTRING, (b) => { b.addBytes(t); }); }); }); }); }); } }); b.addASN1(Tag.SEQUENCE, (b) => { // Algorithm identifier b.addASN1ObjectIdentifier(ecdsaWithSHA256); }); b.addASN1(Tag.BITSTRING, (b) => { // Signature // This signature is obviously not correct since it's constant and the // rest of the certificate is not. However, since the issuer certificate // doesn't exist, there's no way for anyone to check the signature on this // certificate and thus this sufficies. However, at least fastmail.com // expects to be able to parse out a valid ECDSA signature and so one is // provided. b.addBytes(new Uint8Array([ 0x00, 0x30, 0x45, 0x02, 0x21, 0x00, 0xc1, 0xa3, 0xa6, 0x8e, 0x2f, 0x16, 0xa7, 0x21, 0x46, 0x27, 0x05, 0x7f, 0x62, 0xbb, 0x72, 0x8c, 0x9e, 0x03, 0xe7, 0xa1, 0xba, 0x62, 0xd0, 0x46, 0x52, 0x4e, 0x45, 0x6d, 0x2c, 0x2f, 0x3f, 0x73, 0x02, 0x20, 0x0b, 0x5f, 0x78, 0xe5, 0x11, 0xaa, 0x18, 0x12, 0x9f, 0x6f, 0x23, 0x6d, 0x92, 0x13, 0x22, 0x7d, 0x92, 0xb4, 0xe6, 0x7e, 0xdf, 0x53, 0xe8, 0x16, 0xdf, 0xb0, 0x5d, 0x9d, 0xc8, 0xb9, 0x0f, 0xde ])); }); }); return {privateKey: keypair.privateKey, certDER: certBuilder.data}; } /** * Registration encodes a registration response success message. See "FIDO U2F * Raw Message Formats" (§4.3). */ const Registration = class { /** * @param {string} registrationData the registration response message, * base64-encoded. * @param {string} appId the application identifier. * @param {string} challenge the server-generated challenge parameter. This * is only used if opt_clientData is null and, in that case, is expected * to be a webSafeBase64-encoded, 32-byte value. * @param {string=} opt_clientData the client data, base64-encoded. * @throws {Error} */ constructor(registrationData, appId, challenge, opt_clientData) { var data = new ByteString(decodeWebSafeBase64ToArray(registrationData)); var magic = data.getBytes(1); if (magic[0] != 5) { throw Error('bad magic number'); } /** @private {!Uint8Array} */ this.publicKey_ = data.getBytes(65); /** @private {!Uint8Array} */ this.keyHandleLen_ = data.getBytes(1); /** @private {!Uint8Array} */ this.keyHandle_ = data.getBytes(this.keyHandleLen_[0]); /** @private {!Uint8Array} */ this.certificate_ = data.getASN1Element(Tag.SEQUENCE).data; /** @private {!Uint8Array} */ this.signature_ = data.getASN1Element(Tag.SEQUENCE).data; if (!data.empty) { throw Error('extra trailing bytes'); } var challengeHash; if (!opt_clientData) { // U2F_V1 - deprecated challengeHash = decodeWebSafeBase64ToArray(challenge); if (challengeHash.length != 32) { throw Error('bad challenge length for U2F_V1'); } } else { // U2F_V2 challengeHash = sha256HashOfString(atob(webSafeBase64ToNormal(opt_clientData))); } /** @private {string} */ this.challengeHash_ = challengeHash; /** @private {string} */ this.appId_ = appId; } /** @return {!Uint8Array} the attestation certificate, DER-encoded. */ get certificate() { return this.certificate_; } /** @return {!Uint8Array} the attestation signature, DER-encoded. */ get signature() { return this.signature_; } /** * toBeSigned marshals the parts of a registration that are signed by the * attestation key, however obtained. * * @return {!Uint8Array} data to be signed. */ toBeSigned() { var tbs = new ByteBuilder(); tbs.addBytesFromString('\0'); tbs.addBytes(sha256HashOfString(this.appId_)); tbs.addBytes(this.challengeHash_); tbs.addBytes(this.keyHandle_); tbs.addBytes(this.publicKey_); return tbs.data; } /** * sign signs data from the registration (see toBeSigned()) using the supplied * private key. This is used in |RANDOMIZE| mode. * * @param {!webCrypto.CryptoKey} key ECDSA P-256 signing key in WebCrypto * format * @return {Promise} ASN.1 DER encoded ECDSA signature. */ async sign(key) { const algo = {name: 'ECDSA', hash: {name: 'SHA-256'}}; var signatureBuf = await crypto.subtle.sign(algo, key, this.toBeSigned()); var signatureRaw = new ByteString(new Uint8Array(signatureBuf)); var signatureASN1 = new ByteBuilder(); signatureASN1.addASN1(Tag.SEQUENCE, (b) => { // The P-256 signature from WebCrypto is a pair of 32-byte, big-endian // values concatenated. b.addASN1BigInt(signatureRaw.getBytes(32)); b.addASN1BigInt(signatureRaw.getBytes(32)); }); return signatureASN1.data; } /** * withReplacement marshals the registration (to base64) with the certificate * and signature replaced. * * @param {!Uint8Array} certificate new certificate, as DER. * @param {!Uint8Array} signature new signature, as DER. * @return {string} The supplied registration data with certificate and * signature replaced, base64. */ withReplacement(certificate, signature) { var result = new ByteBuilder(); result.addBytesFromString('\x05'); result.addBytes(this.publicKey_); result.addBytes(this.keyHandleLen_); result.addBytes(this.keyHandle_); result.addBytes(certificate); result.addBytes(signature); return B64_encode(result.data); } }; /** * ConveyancePreference describes how to alter (if at all) the attestation * certificate in a registration response. * @enum */ var ConveyancePreference = { /** * NONE means that the token's attestation certificate should be replaced with * a randomly generated one, and that response should be re-signed using a * corresponding key. */ NONE: 1, /** * DIRECT means that the token's attestation cert should be returned unchanged * to the relying party. */ DIRECT: 0, }; /** * WebAuthnAttestationConveyancePreference is the * AttestationConveyancePreference enum from WebAuthn. * @enum{string} */ const WebAuthnAttestationConveyancePreference = { NONE: 'none', INDIRECT: 'indirect', DIRECT: 'direct', ENTERPRISE: 'enterprise', }; /** * conveyancePreference returns the attestation certificate replacement mode. * * @param {EnrollChallenge} enrollChallenge * @return {ConveyancePreference} */ function conveyancePreference(enrollChallenge) { if (enrollChallenge.hasOwnProperty('attestation') && (enrollChallenge['attestation'] == 'direct' || enrollChallenge['attestation'] == 'indirect')) { return ConveyancePreference.DIRECT; } return ConveyancePreference.NONE; } /** * Handles a U2F enroll request. * @param {MessageSender} messageSender The message sender. * @param {Object} request The web page's enroll request. * @param {Function} sendResponse Called back with the result of the enroll. * @return {Closeable} A handler object to be closed when the browser channel * closes. */ function handleU2fEnrollRequest(messageSender, request, sendResponse) { var sentResponse = false; var closeable = null; function sendErrorResponse(error) { var response = makeU2fErrorResponse(request, error.errorCode, error.errorMessage); sendResponseOnce(sentResponse, closeable, response, sendResponse); } var sender = createSenderFromMessageSender(messageSender); if (!sender) { sendErrorResponse({errorCode: ErrorCodes.BAD_REQUEST}); return null; } async function getRegistrationData( appId, enrollChallenge, registrationData, opt_clientData) { var isDirect = true; if (conveyancePreference(enrollChallenge) == ConveyancePreference.NONE) { isDirect = false; } else if (chrome.cryptotokenPrivate != null) { isDirect = await (new Promise((resolve, reject) => { chrome.cryptotokenPrivate.canAppIdGetAttestation( { 'appId': appId, 'tabId': messageSender.tab.id, 'origin': sender.origin, }, resolve); })); } var decodedRegistrationData = new ByteString(decodeWebSafeBase64ToArray(registrationData)); var magicValue = decodedRegistrationData.getBytes(1); if (magicValue[0] == 4) { // This is a gNubby with obsolete firmware. We can't parse the reply from // this device and users need to be guided to reflashing them. Therefore // let attestation data pass directly so that can happen on // accounts.google.com. isDirect = true; } if (isDirect) { return registrationData; } const reg = new Registration( registrationData, appId, enrollChallenge['challenge'], opt_clientData); const keypair = await makeCertAndKey(reg.certificate); const signature = await reg.sign(keypair.privateKey); return reg.withReplacement(keypair.certDER, signature); } /** * @param {string} u2fVersion * @param {string} registrationData Registration data, base64 * @param {string=} opt_clientData Base64. */ function sendSuccessResponse(u2fVersion, registrationData, opt_clientData) { var enrollChallenges = request['registerRequests']; var enrollChallengeOrNull = findEnrollChallengeOfVersion(enrollChallenges, u2fVersion); if (!enrollChallengeOrNull) { sendErrorResponse({errorCode: ErrorCodes.OTHER_ERROR}); return; } var enrollChallenge = enrollChallengeOrNull; // Avoids compiler warning. var appId = request['appId']; if (enrollChallenge.hasOwnProperty('appId')) { appId = enrollChallenge['appId']; } getRegistrationData( appId, enrollChallenge, registrationData, opt_clientData) .then( (registrationData) => { var responseData = makeEnrollResponseData( enrollChallenge, u2fVersion, registrationData, opt_clientData); var response = makeU2fSuccessResponse(request, responseData); sendResponseOnce(sentResponse, closeable, response, sendResponse); }, (err) => { console.warn( 'attestation certificate replacement failed: ' + err); sendErrorResponse({errorCode: ErrorCodes.OTHER_ERROR}); }); } function timeout() { sendErrorResponse({errorCode: ErrorCodes.TIMEOUT}); } if (sender.origin.indexOf('http://') == 0 && !HTTP_ORIGINS_ALLOWED) { sendErrorResponse({errorCode: ErrorCodes.BAD_REQUEST}); return null; } if (!isValidEnrollRequest(request)) { sendErrorResponse({errorCode: ErrorCodes.BAD_REQUEST}); return null; } chrome.cryptotokenPrivate.recordRegisterRequest(sender.tabId, sender.frameId); var timeoutValueSeconds = getTimeoutValueFromRequest(request); // Attenuate watchdog timeout value less than the enroller's timeout, so the // watchdog only fires after the enroller could reasonably have called back, // not before. var watchdogTimeoutValueSeconds = attenuateTimeoutInSeconds( timeoutValueSeconds, MINIMUM_TIMEOUT_ATTENUATION_SECONDS / 2); var watchdog = new WatchdogRequestHandler(watchdogTimeoutValueSeconds, timeout); var wrappedErrorCb = watchdog.wrapCallback(sendErrorResponse); var wrappedSuccessCb = watchdog.wrapCallback(sendSuccessResponse); // TODO: Fix unused; intended to pass wrapped callbacks to Enroller? var timer = createAttenuatedTimer( FACTORY_REGISTRY.getCountdownFactory(), timeoutValueSeconds); var logMsgUrl = request['logMsgUrl']; var enroller = new Enroller( timer, sender, sendErrorResponse, sendSuccessResponse, logMsgUrl); watchdog.setCloseable(/** @type {!Closeable} */ (enroller)); closeable = watchdog; var registerRequests = request['registerRequests']; var signRequests = getSignRequestsFromEnrollRequest(request); enroller.doEnroll(registerRequests, signRequests, request['appId']); return closeable; } /** * Returns whether the request appears to be a valid enroll request. * @param {Object} request The request. * @return {boolean} Whether the request appears valid. */ function isValidEnrollRequest(request) { if (!request.hasOwnProperty('registerRequests')) { return false; } var enrollChallenges = request['registerRequests']; if (!enrollChallenges.length) { return false; } var hasAppId = request.hasOwnProperty('appId'); if (!isValidEnrollChallengeArray(enrollChallenges, !hasAppId)) { return false; } var signChallenges = getSignChallenges(request); // A missing sign challenge array is ok, in the case the user is not already // enrolled. // A challenge value need not necessarily be supplied with every challenge. var challengeRequired = false; if (signChallenges && !isValidSignChallengeArray( signChallenges, challengeRequired, !hasAppId)) { return false; } return true; } /** * @typedef {{ * version: (string|undefined), * challenge: string, * appId: string * }} */ var EnrollChallenge; /** * @param {Array} enrollChallenges The enroll challenges to * validate. * @param {boolean} appIdRequired Whether the appId property is required on * each challenge. * @return {boolean} Whether the given array of challenges is a valid enroll * challenges array. */ function isValidEnrollChallengeArray(enrollChallenges, appIdRequired) { var seenVersions = {}; for (var i = 0; i < enrollChallenges.length; i++) { var enrollChallenge = enrollChallenges[i]; var version = enrollChallenge['version']; if (!version) { // Version is implicitly V1 if not specified. version = 'U2F_V1'; } if (version != 'U2F_V1' && version != 'U2F_V2') { return false; } if (seenVersions[version]) { // Each version can appear at most once. return false; } seenVersions[version] = version; if (appIdRequired && !enrollChallenge['appId']) { return false; } if (!enrollChallenge['challenge']) { // The challenge is required. return false; } } return true; } /** * Finds the enroll challenge of the given version in the enroll challenge * array. * @param {Array} enrollChallenges The enroll challenges to * search. * @param {string} version Version to search for. * @return {?EnrollChallenge} The enroll challenge with the given versions, or * null if it isn't found. */ function findEnrollChallengeOfVersion(enrollChallenges, version) { for (var i = 0; i < enrollChallenges.length; i++) { if (enrollChallenges[i]['version'] == version) { return enrollChallenges[i]; } } return null; } /** * Makes a responseData object for the enroll request with the given parameters. * @param {EnrollChallenge} enrollChallenge The enroll challenge used to * register. * @param {string} u2fVersion Version of gnubby that enrolled. * @param {string} registrationData The registration data. * @param {string=} opt_clientData The client data, if available. * @return {Object} The responseData object. */ function makeEnrollResponseData( enrollChallenge, u2fVersion, registrationData, opt_clientData) { var responseData = {}; responseData['registrationData'] = registrationData; // Echo the used challenge back in the reply. for (var k in enrollChallenge) { responseData[k] = enrollChallenge[k]; } if (u2fVersion == 'U2F_V2') { // For U2F_V2, the challenge sent to the gnubby is modified to be the // hash of the client data. Include the client data. responseData['clientData'] = opt_clientData; } return responseData; } /** * Gets the expanded sign challenges from an enroll request, potentially by * modifying the request to contain a challenge value where one was omitted. * (For enrolling, the server isn't interested in the value of a signature, * only whether the presented key handle is already enrolled.) * @param {Object} request The request. * @return {Array} */ function getSignRequestsFromEnrollRequest(request) { var signChallenges; if (request.hasOwnProperty('registeredKeys')) { signChallenges = request['registeredKeys']; } else { signChallenges = request['signRequests']; } if (signChallenges) { for (var i = 0; i < signChallenges.length; i++) { // Make sure each sign challenge has a challenge value. // The actual value doesn't matter, as long as it's a string. if (!signChallenges[i].hasOwnProperty('challenge')) { signChallenges[i]['challenge'] = ''; } } } return signChallenges; } /** * Creates a new object to track enrolling with a gnubby. * @param {!Countdown} timer Timer for enroll request. * @param {!WebRequestSender} sender The sender of the request. * @param {function(U2fError)} errorCb Called upon enroll failure. * @param {function(string, string, (string|undefined))} successCb Called upon * enroll success with the version of the succeeding gnubby, the enroll * data, and optionally the browser data associated with the enrollment. * @param {string=} opt_logMsgUrl The url to post log messages to. * @constructor */ function Enroller(timer, sender, errorCb, successCb, opt_logMsgUrl) { /** @private {Countdown} */ this.timer_ = timer; /** @private {WebRequestSender} */ this.sender_ = sender; /** @private {function(U2fError)} */ this.errorCb_ = errorCb; /** @private {function(string, string, (string|undefined))} */ this.successCb_ = successCb; /** @private {string|undefined} */ this.logMsgUrl_ = opt_logMsgUrl; /** @private {boolean} */ this.done_ = false; /** @private {Object} */ this.browserData_ = {}; /** @private {Array} */ this.encodedEnrollChallenges_ = []; /** @private {Array} */ this.encodedSignChallenges_ = []; // Allow http appIds for http origins. (Broken, but the caller deserves // what they get.) /** @private {boolean} */ this.allowHttp_ = this.sender_.origin ? this.sender_.origin.indexOf('http://') == 0 : false; /** @private {RequestHandler} */ this.handler_ = null; } /** * Default timeout value in case the caller never provides a valid timeout. */ Enroller.DEFAULT_TIMEOUT_MILLIS = 30 * 1000; /** * Performs an enroll request with the given enroll and sign challenges. * @param {Array} enrollChallenges A set of enroll challenges. * @param {Array} signChallenges A set of sign challenges for * existing enrollments for this user and appId. * @param {string=} opt_appId The app id for the entire request. */ Enroller.prototype.doEnroll = function( enrollChallenges, signChallenges, opt_appId) { /** @private {Array} */ this.enrollChallenges_ = enrollChallenges; /** @private {Array} */ this.signChallenges_ = signChallenges; /** @private {(string|undefined)} */ this.appId_ = opt_appId; var self = this; getTabIdWhenPossible(this.sender_) .then( function() { if (self.done_) { return; } self.approveOrigin_(); }, function() { self.close(); self.notifyError_({errorCode: ErrorCodes.BAD_REQUEST}); }); }; /** * Ensures the user has approved this origin to use security keys, sending * to the request to the handler if/when the user has done so. * @private */ Enroller.prototype.approveOrigin_ = function() { var self = this; FACTORY_REGISTRY.getApprovedOrigins() .isApprovedOrigin(this.sender_.origin, this.sender_.tabId) .then(function(result) { if (self.done_) { return; } if (!result) { // Origin not approved: rather than give an explicit indication to // the web page, let a timeout occur. // NOTE: if you are looking at this in a debugger, this line will // always be false since the origin of the debugger is different // than origin of requesting page if (self.timer_.expired()) { self.notifyTimeout_(); return; } var newTimer = self.timer_.clone(self.notifyTimeout_.bind(self)); self.timer_.clearTimeout(); self.timer_ = newTimer; return; } self.sendEnrollRequestToHelper_(); }); }; /** * Notifies the caller of a timeout error. * @private */ Enroller.prototype.notifyTimeout_ = function() { this.notifyError_({errorCode: ErrorCodes.TIMEOUT}); }; /** * Performs an enroll request with this instance's enroll and sign challenges, * by encoding them into a helper request and passing the resulting request to * the factory registry's helper. * @private */ Enroller.prototype.sendEnrollRequestToHelper_ = function() { var encodedEnrollChallenges = this.encodeEnrollChallenges_(this.enrollChallenges_, this.appId_); // If the request didn't contain a sign challenge, provide one. The value // doesn't matter. var defaultSignChallenge = ''; var encodedSignChallenges = encodeSignChallenges( this.signChallenges_, defaultSignChallenge, this.appId_); var request = { type: 'enroll_helper_request', enrollChallenges: encodedEnrollChallenges, signData: encodedSignChallenges, logMsgUrl: this.logMsgUrl_ }; if (!this.timer_.expired()) { request.timeout = this.timer_.millisecondsUntilExpired() / 1000.0; request.timeoutSeconds = this.timer_.millisecondsUntilExpired() / 1000.0; } // Begin fetching/checking the app ids. var enrollAppIds = []; if (this.appId_) { enrollAppIds.push(this.appId_); } for (var i = 0; i < this.enrollChallenges_.length; i++) { if (this.enrollChallenges_[i].hasOwnProperty('appId')) { enrollAppIds.push(this.enrollChallenges_[i]['appId']); } } // Sanity check if (!enrollAppIds.length) { console.warn(UTIL_fmt('empty enroll app ids?')); this.notifyError_({errorCode: ErrorCodes.BAD_REQUEST}); return; } var self = this; this.checkAppIds_(enrollAppIds, async (result) => { if (self.done_) { return; } if (!result) { self.notifyError_({errorCode: ErrorCodes.BAD_REQUEST}); return; } let v2Challenge; for (let index = 0; index < self.enrollChallenges_.length; index++) { if (self.enrollChallenges_[index]['version'] === 'U2F_V2') { v2Challenge = self.enrollChallenges_[index]; } } if (v2Challenge['challenge'] === undefined) { console.warn('Did not find U2F_V2 challenge'); this.notifyError_({errorCode: ErrorCodes.BAD_REQUEST}); return; } console.log('Proxying registration request to WebAuthn'); this.doRegisterWebAuthn_(enrollAppIds[0], v2Challenge, request); }); }; const googleCorpAppId = 'https://www.gstatic.com/securitykey/a/google.com/origins.json'; /** * Proxies the registration request over the WebAuthn API. * @private */ Enroller.prototype.doRegisterWebAuthn_ = function(appId, challenge, request) { const encodedChallenge = challenge['challenge']; if (appId == googleCorpAppId) { this.doRegisterWebAuthnContinue_( appId, encodedChallenge, request, WebAuthnAttestationConveyancePreference.ENTERPRISE); return; } if (!chrome.cryptotokenPrivate) { this.doRegisterWebAuthnContinue_( appId, encodedChallenge, request, WebAuthnAttestationConveyancePreference.DIRECT); return; } chrome.cryptotokenPrivate.isAppIdHashInEnterpriseContext( decodeWebSafeBase64ToArray(B64_encode(sha256HashOfString(appId))), (enterprise_context) => { this.doRegisterWebAuthnContinue_( appId, encodedChallenge, request, enterprise_context ? WebAuthnAttestationConveyancePreference.ENTERPRISE : WebAuthnAttestationConveyancePreference.DIRECT); }); }; Enroller.prototype.doRegisterWebAuthnContinue_ = function( appId, challenge, request, attestationMode) { // Set a random ID. const randomId = new Uint8Array(new ArrayBuffer(16)); crypto.getRandomValues(randomId); const decodedChallenge = B64_decode(challenge); if (decodedChallenge.length == 0) { this.notifyError_({ errorCode: ErrorCodes.BAD_REQUEST, errorMessage: 'challenge must be base64url encoded', }); return; } const excludeList = []; for (let index = 0; index < request['signData'].length; index++) { const element = request['signData'][index]; const decodedKeyHandle = B64_decode(element['keyHandle']); if (decodedKeyHandle.length == 0) { this.notifyError_({ errorCode: ErrorCodes.BAD_REQUEST, errorMessage: 'keyHandle must be base64url encoded', }); return; } excludeList.push({ type: 'public-key', id: new Uint8Array(decodedKeyHandle).buffer, transports: ['usb'], }); } // Request enterprise attestation for the gstatic corp App ID and domains // whitelisted via enterprise policy. Otherwise request 'direct' attestation // (which might later get stripped). const options = { publicKey: { rp: { id: appId, name: this.sender_.origin, }, user: { id: randomId.buffer, displayName: this.sender_.origin, name: this.sender_.origin, }, challenge: new Uint8Array(decodedChallenge).buffer, pubKeyCredParams: [{ type: 'public-key', alg: -7, // ES-256 }], timeout: this.timer_.millisecondsUntilExpired(), excludeCredentials: excludeList, authenticatorSelection: { authenticatorAttachment: 'cross-platform', requireResidentKey: false, userVerification: 'discouraged', }, attestation: attestationMode, }, }; navigator.credentials.create(options) .then(response => { this.onWebAuthnSuccess_(response, appId); }) .catch(exception => { this.onWebAuthnError_(exception); }); }; /** * Handles a successful credential response from WebAuthn's make credential * request. * @private */ Enroller.prototype.onWebAuthnSuccess_ = async function(publicKeyCredential, appId) { const clientData = new Uint8Array(publicKeyCredential['response']['clientDataJSON']); const browserData = B64_encode(Array.from(clientData)); const u2fResponseData = await this.parseU2fResponseFromAttestationObject_( publicKeyCredential['response']['attestationObject'], appId, browserData); this.notifySuccess_('U2F_V2', u2fResponseData, browserData); }; /** * Parses the attestation object received from a WebAuthn make credential call * and converts it into a U2F response message formatted into Base64. * @private */ Enroller.prototype.parseU2fResponseFromAttestationObject_ = async function(attestationObject, appId, clientData) { // The first byte of the registration response is always 0x5. let u2fResponse = [0x5]; // Parse the attestation object from CBOR into a JavaScript object. const attestationObjectCbor = new Cbor(attestationObject).getCBOR(); // Authenticator data must be at least 120 bytes in length. // https://www.w3.org/TR/webauthn/#fig-attStructs if (!attestationObjectCbor['authData'] || attestationObjectCbor['authData'].length < 120) { console.warn('Received invalid authenticator response'); this.notifyError_({ errorCode: ErrorCodes.OTHER_ERROR, errorMessage: 'Invalid response message', }); return; } const authData = attestationObjectCbor['authData']; // Attested credential data starts after a 32 byte RP ID hash, a 1 byte flag, // and a 4 byte counter value. // https://www.w3.org/TR/webauthn/#sctn-attestation const attestedCredentialData = authData.slice(37, authData.length); let index = 16; let credentialIdLength = (attestedCredentialData[index++] & 0xFF) << 8; credentialIdLength |= (attestedCredentialData[index++] & 0xFF); const credentialId = attestedCredentialData.slice(index, index + credentialIdLength); index += credentialIdLength; const encodedPublicKey = attestedCredentialData.slice(index, attestedCredentialData.length); // Parse public key and format it in X509 format [0x4, 32-byte X, 32-byte Y]. const coseKey = new Cbor(encodedPublicKey).getCBOR(); const publicKeyArray = ([0x4].concat(Array.from(coseKey['-2']))) .concat(Array.from(coseKey['-3'])); // Concatenate U2F registration response from the public key, key handle // length, key handle, attestatation certificate, and signature. u2fResponse = u2fResponse.concat(publicKeyArray); u2fResponse.push(credentialIdLength); u2fResponse = u2fResponse.concat(Array.from(credentialId)); const fmt = attestationObjectCbor['fmt']; const attStatement = attestationObjectCbor['attStmt']; let x5c; let signature; switch (new TextDecoder('utf-8').decode(fmt)) { case 'fido-u2f': x5c = attStatement['x5c'][0]; signature = attStatement['sig']; break; case 'none': // Append empty x509 cert and signature to the registration message. const emptySequence = new Uint8Array([0x30, 0]); // empty ASN.1 SEQUENCE. const registrationData = B64_encode(u2fResponse.concat(Array.from(emptySequence)) .concat(Array.from(emptySequence))); const reg = new Registration(registrationData, appId, null, clientData); const keypair = await makeCertAndKey(); signature = await reg.sign(keypair.privateKey); x5c = keypair.certDER; break; default: console.warn('Received unsupported non-U2F attestation'); this.notifyError_({ errorCode: ErrorCodes.OTHER_ERROR, errorMessage: 'Invalid response message', }); return; } u2fResponse = u2fResponse.concat(Array.from(x5c)); u2fResponse = u2fResponse.concat(Array.from(signature)); return B64_encode(u2fResponse); }; /** * Handles DOMExceptions returned as errors from the WebAuthn make credential * call. Converts exceptions into U2F compatible exceptions. * @param {*} exception Exception returned from the WebAuthn request. * @private */ Enroller.prototype.onWebAuthnError_ = function(exception) { const domError = /** @type {!DOMException} */ (exception); let errorCode = ErrorCodes.OTHER_ERROR; let errorDetails; if (domError && domError.name) { switch (domError.name) { case 'NotAllowedError': errorCode = ErrorCodes.TIMEOUT; break; case 'InvalidStateError': errorCode = ErrorCodes.DEVICE_INELIGIBLE; break; default: // Fall through break; } } this.notifyError_({ errorCode: errorCode, errorMessage: domError.toString(), }); }; /** * Encodes the enroll challenge as an enroll helper challenge. * @param {EnrollChallenge} enrollChallenge The enroll challenge to encode. * @param {string=} opt_appId The app id for the entire request. * @return {EnrollHelperChallenge} The encoded challenge. * @private */ Enroller.encodeEnrollChallenge_ = function(enrollChallenge, opt_appId) { var encodedChallenge = {}; var version; if (enrollChallenge['version']) { version = enrollChallenge['version']; } else { // Version is implicitly V1 if not specified. version = 'U2F_V1'; } encodedChallenge['version'] = version; encodedChallenge['challengeHash'] = enrollChallenge['challenge']; var appId; if (enrollChallenge['appId']) { appId = enrollChallenge['appId']; } else { appId = opt_appId; } if (!appId) { // Sanity check. (Other code should fail if it's not set.) console.warn(UTIL_fmt('No appId?')); } encodedChallenge['appIdHash'] = B64_encode(sha256HashOfString(appId)); return /** @type {EnrollHelperChallenge} */ (encodedChallenge); }; /** * Encodes the given enroll challenges using this enroller's state. * @param {Array} enrollChallenges The enroll challenges. * @param {string=} opt_appId The app id for the entire request. * @return {!Array} The encoded enroll challenges. * @private */ Enroller.prototype.encodeEnrollChallenges_ = function( enrollChallenges, opt_appId) { var challenges = []; for (var i = 0; i < enrollChallenges.length; i++) { var enrollChallenge = enrollChallenges[i]; var version = enrollChallenge.version; if (!version) { // Version is implicitly V1 if not specified. version = 'U2F_V1'; } if (version == 'U2F_V2') { var modifiedChallenge = {}; for (var k in enrollChallenge) { modifiedChallenge[k] = enrollChallenge[k]; } // V2 enroll responses contain signatures over a browser data object, // which we're constructing here. The browser data object contains, among // other things, the server challenge. var serverChallenge = enrollChallenge['challenge']; var browserData = makeEnrollBrowserData(serverChallenge, this.sender_.origin); // Replace the challenge with the hash of the browser data. modifiedChallenge['challenge'] = B64_encode(sha256HashOfString(browserData)); this.browserData_[version] = B64_encode(UTIL_StringToBytes(browserData)); challenges.push(Enroller.encodeEnrollChallenge_( /** @type {EnrollChallenge} */ (modifiedChallenge), opt_appId)); } else { challenges.push( Enroller.encodeEnrollChallenge_(enrollChallenge, opt_appId)); } } return challenges; }; /** * Checks the app ids associated with this enroll request, and calls a callback * with the result of the check. * @param {!Array} enrollAppIds The app ids in the enroll challenge * portion of the enroll request. * @param {function(boolean)} cb Called with the result of the check. * @private */ Enroller.prototype.checkAppIds_ = function(enrollAppIds, cb) { var appIds = UTIL_unionArrays(enrollAppIds, getDistinctAppIds(this.signChallenges_)); FACTORY_REGISTRY.getOriginChecker() .canClaimAppIds(this.sender_.origin, appIds) .then(this.originChecked_.bind(this, appIds, cb)); }; /** * Called with the result of checking the origin. When the origin is allowed * to claim the app ids, begins checking whether the app ids also list the * origin. * @param {!Array} appIds The app ids. * @param {function(boolean)} cb Called with the result of the check. * @param {boolean} result Whether the origin could claim the app ids. * @private */ Enroller.prototype.originChecked_ = function(appIds, cb, result) { if (!result) { this.notifyError_({errorCode: ErrorCodes.BAD_REQUEST}); return; } var appIdChecker = FACTORY_REGISTRY.getAppIdCheckerFactory().create(); appIdChecker .checkAppIds( this.timer_.clone(), this.sender_.origin, appIds, this.allowHttp_, this.logMsgUrl_) .then(cb); }; /** Closes this enroller. */ Enroller.prototype.close = function() { if (this.handler_) { this.handler_.close(); this.handler_ = null; } this.done_ = true; }; /** * Notifies the caller with the error. * @param {U2fError} error Error. * @private */ Enroller.prototype.notifyError_ = function(error) { if (this.done_) { return; } this.close(); this.done_ = true; this.errorCb_(error); }; /** * Notifies the caller of success with the provided response data. * @param {string} u2fVersion Protocol version * @param {string} info Response data * @param {string=} opt_browserData Browser data used * @private */ Enroller.prototype.notifySuccess_ = function( u2fVersion, info, opt_browserData) { if (this.done_) { return; } this.close(); this.done_ = true; this.successCb_(u2fVersion, info, opt_browserData); }; /** * Called by the helper upon completion. * @param {EnrollHelperReply} reply The result of the enroll request. * @private */ Enroller.prototype.helperComplete_ = function(reply) { if (reply.code) { var reportedError = mapDeviceStatusCodeToU2fError(reply.code); console.log(UTIL_fmt( 'helper reported ' + reply.code.toString(16) + ', returning ' + reportedError.errorCode)); // Log non-expected reply codes if we have url to send them. if (reportedError.errorCode == ErrorCodes.OTHER_ERROR) { var logMsg = 'log=u2fenroll&rc=' + reply.code.toString(16); if (this.logMsgUrl_) { logMessage(logMsg, this.logMsgUrl_); } } this.notifyError_(reportedError); } else { console.log(UTIL_fmt('Gnubby enrollment succeeded!!!!!')); var browserData; if (reply.version == 'U2F_V2') { // For U2F_V2, the challenge sent to the gnubby is modified to be the hash // of the browser data. Include the browser data. browserData = this.browserData_[reply.version]; } this.notifySuccess_( /** @type {string} */ (reply.version), /** @type {string} */ (reply.enrollData), browserData); } };