blob: 4088338c9e868c6a269811f3b40f2d450ddece1d [file] [log] [blame]
// 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<!Uint8Array>} 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<EnrollChallenge>} 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<EnrollChallenge>} 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<SignChallenge>}
*/
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<string, string>} */
this.browserData_ = {};
/** @private {Array<EnrollHelperChallenge>} */
this.encodedEnrollChallenges_ = [];
/** @private {Array<SignHelperChallenge>} */
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<EnrollChallenge>} enrollChallenges A set of enroll challenges.
* @param {Array<SignChallenge>} 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<EnrollChallenge>} */
this.enrollChallenges_ = enrollChallenges;
/** @private {Array<SignChallenge>} */
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;
}
const attestationPreference =
conveyancePreference(challenge) == ConveyancePreference.DIRECT ?
WebAuthnAttestationConveyancePreference.DIRECT :
WebAuthnAttestationConveyancePreference.NONE;
if (!chrome.cryptotokenPrivate) {
this.doRegisterWebAuthnContinue_(
appId, encodedChallenge, request, attestationPreference);
return;
}
chrome.cryptotokenPrivate.isAppIdHashInEnterpriseContext(
decodeWebSafeBase64ToArray(B64_encode(sha256HashOfString(appId))),
(enterprise_context) => {
this.doRegisterWebAuthnContinue_(
appId, encodedChallenge, request,
enterprise_context ?
WebAuthnAttestationConveyancePreference.ENTERPRISE :
attestationPreference);
});
};
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<EnrollChallenge>} enrollChallenges The enroll challenges.
* @param {string=} opt_appId The app id for the entire request.
* @return {!Array<EnrollHelperChallenge>} 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<string>} 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<string>} 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);
}
};