@forwardemail/passport-fido2-webauthn
Version:
WebAuthn authentication strategy for Passport.
256 lines (217 loc) • 9.5 kB
JavaScript
// Module dependencies.
var passport = require('passport-strategy')
, url = require('url')
, crypto = require('crypto')
, cose2jwk = require('cose-to-jwk')
, jwk2pem = require('jwk-to-pem')
, base64url = require('base64url')
, util = require('util')
, utils = require('./utils')
, Attestation = require('./fido2/attestation')
, AuthenticatorData = require('./fido2/authenticatordata')
, SessionStore = require('./store/session');
// Constants for authenticator data flags.
var USER_PRESENT = 0x01;
var USER_VERIFIED = 0x04;
/**
* Create a new `Strategy` object.
*
*/
function Strategy(options, verify, verifySignCount, register) {
if (typeof options == 'function') {
register = verifySignCount;
verifySignCount = verify;
verify = options;
options = {};
}
if (typeof register == 'undefined') {
register = verifySignCount;
verifySignCount = undefined;
}
passport.Strategy.call(this);
this.name = 'webauthn';
this._attestationFormats = options.attestationFormats || require('./fido2/formats');
this._verify = verify;
this._verifySignCount = verifySignCount;
this._register = register;
this._store = options.store || new SessionStore();
}
// Inherit from `passport.Strategy`.
util.inherits(Strategy, passport.Strategy);
Strategy.prototype.authenticate = function(req, options) {
var response = req.body.response;
var clientDataJSON = base64url.decode(response.clientDataJSON);
var clientData = JSON.parse(clientDataJSON);
var self = this;
function validated(err, ok, ctx) {
if (err) { return self.error(err); }
if (!ok) {
return self.fail(ctx, 403);
}
ctx = ctx || {};
// Verify that the origin contained in client data matches the origin of this
// app (which is the relying party).
var origin = utils.originalOrigin(req);
if (origin !== clientData.origin) {
return self.fail({ message: 'Origin mismatch' }, 403);
}
// TODO: Verify the state of Token Binding for the TLS connection over which
// the attestation was obtained.
var rpID = url.parse(origin).hostname;
var rpIdHash = crypto.createHash('sha256').update(rpID).digest();
if (clientData.type === 'webauthn.get') {
// https://www.w3.org/TR/webauthn-2/#sctn-verifying-assertion
// TODO: Verify that credentials was in allowedCredentials, if set
var userHandle = null;
if (response.userHandle) {
userHandle = base64url.toBuffer(response.userHandle);
}
if (!ctx.user) {
// If the user was not identified before the authentication ceremony was
// initiated, response.userHandle must be present.
//
// NOTE: User handle being set should imply resident keys (???)
if (!userHandle) { return self.fail({ message: 'User handle not set' }, 403); };
} else {
if (userHandle && (Buffer.compare(ctx.user.id, userHandle) != 0)) {
// If the user was identified before the authentication ceremony was
// initiated, if response.userHandle is present, it must map to the
// same user.
return self.fail({ message: 'User handle does not map to user' }, 403);
}
}
var b_authenticatorData = base64url.toBuffer(response.authenticatorData);
var authenticatorData = AuthenticatorData.parse(b_authenticatorData);
// TODO: Support appID extension for rpIdHash
// Verify that the RP ID hash contained in authenticator data matches the
// hash of this app's (which is the relying party) RP ID.
if (!rpIdHash.equals(authenticatorData.rpIdHash)) {
return self.fail({ message: 'RP ID hash mismatch' }, 403);
}
// Verify that the user present bit is set in authenticator data flags.
if (!(authenticatorData.flags & USER_PRESENT)) {
return self.fail({ message: 'User not present' }, 403);
}
// TODO: Verify that extensions are as expected.
var id = req.body.id;
var flags = {
userPresent: !!(authenticatorData.flags & USER_PRESENT),
userVerified: !!(authenticatorData.flags & USER_VERIFIED)
};
function verified(err, user, publicKey, info) {
if (err) { return self.error(err); }
if (!user) { return self.fail(publicKey); }
var hash = crypto.createHash('sha256').update(clientDataJSON).digest();
var data = Buffer.concat([b_authenticatorData, hash]);
var signature = base64url.toBuffer(response.signature);
// Verify that the signature is valid.
var ok = crypto.createVerify('sha256').update(data).verify(publicKey, signature);
if (!ok) {
return self.fail({ message: 'Invalid signature' }, 403);
}
// If the application desires, allow it to process the signature counter
// in order to detect cloned authenticators and incorporate this
// information into risk scoring.
if (authenticatorData.signCount && (info && info.signCount)) {
self._verifySignCount(id, authenticatorData.signCount, info.signCount, function(err, ok) {
if (err) { return self.error(err); }
if (!ok) { return self.fail({ message: 'Cloned authenticator detected' }, 403); }
self.success(user, info);
});
} else {
self.success(user, info);
}
}
try {
if (self._passReqToCallback) {
var arity = self._verify.length;
switch (arity) {
case 5:
return self._verify(req, id, userHandle, flags, verified);
default:
return self._verify(req, id, userHandle, verified);
}
} else {
var arity = self._verify.length;
switch (arity) {
case 4:
return self._verify(id, userHandle, flags, verified);
default:
return self._verify(id, userHandle, verified);
}
}
} catch (ex) {
return self.error(ex);
}
} else if (clientData.type === 'webauthn.create') {
// https://www.w3.org/TR/webauthn-2/#sctn-registering-a-new-credential
var b_attestation = base64url.toBuffer(response.attestationObject);
var attestation = Attestation.parse(b_attestation);
var authenticatorData = AuthenticatorData.parse(attestation.authData, true, false);
// Verify that the RP ID hash contained in authenticator data matches the
// hash of this app's (which is the relying party) RP ID.
if (!rpIdHash.equals(authenticatorData.rpIdHash)) {
return self.fail({ message: 'RP ID hash mismatch' }, 403);
}
// Verify that the user present bit is set in authenticator data flags.
if (!(authenticatorData.flags & USER_PRESENT)) {
return self.fail({ message: 'User not present' }, 403);
}
// TODO: Verify alg is allowed
// TODO: Verify that extensions are as expected.
var format = self._attestationFormats[attestation.fmt];
if (!format) {
return self.fail({ message: 'Unsupported attestation format: ' + attestation.fmt }, 400);
}
// Verify that the attestation statement conveys a valid attestation signature.
var hash = crypto.createHash('sha256').update(clientDataJSON).digest();
var vAttestation;
try {
vAttestation = format.verify(attestation.attStmt, attestation.authData, hash);
} catch (ex) {
return self.fail({ message: ex.message }, 400);
}
var credentialId = base64url.encode(authenticatorData.attestedCredentialData.credentialId);
var jwk = cose2jwk(authenticatorData.attestedCredentialData.credentialPublicKey);
var pem = jwk2pem(jwk);
var flags = {
userPresent: !!(authenticatorData.flags & USER_PRESENT),
userVerified: !!(authenticatorData.flags & USER_VERIFIED)
};
function registered(err, user, info) {
if (err) { return self.error(err); }
if (!user) { return self.fail(info); }
self.success(user, info);
}
try {
if (self._passReqToCallback) {
// TODO
//self._verify(req, username, password, verified);
} else {
var arity = self._register.length;
switch (arity) {
case 8:
return self._register(ctx.user, credentialId, pem, flags, authenticatorData.signCount, response.transports, vAttestation, registered);
case 7:
return self._register(ctx.user, credentialId, pem, flags, authenticatorData.signCount, response.transports, registered);
case 6:
return self._register(ctx.user, credentialId, pem, flags, authenticatorData.signCount, registered);
case 5:
return self._register(ctx.user, credentialId, pem, flags, registered);
default:
return self._register(ctx.user, credentialId, pem, registered);
}
}
} catch (ex) {
return self.error(ex);
}
} else {
return self.fail({ message: 'Unsupported response type: ' + clientData.type }, 400);
}
}
// Verify that the challenge (aka nonce) received from the client equals the
// challenge sent.
var challenge = base64url.toBuffer(clientData.challenge);
this._store.verify(req, challenge, validated);
};
module.exports = Strategy;