Implement Webauthn 🎉 (#5088)

* Implement Webauthn 🎉

* Share hexifyAB

* Move hr inside template and add AttestationChallenges janitor daemon

* Apply suggestions from code review

Co-Authored-By: Acid Chicken (硫酸鶏) <root@acid-chicken.com>

* Add newline at the end of file

* Fix stray newline in promise chain

* Ignore var in try{}catch(){} block

Co-Authored-By: Acid Chicken (硫酸鶏) <root@acid-chicken.com>

* Add missing comma

* Add missing semicolon

* Support more attestation formats

* add support for more key types and linter pass

* Refactor

* Refactor

* credentialId --> id

* Fix

* Improve readability

* Add indexes

* fixes for credentialId->id

* Avoid changing store state

* Fix syntax error and code style

* Remove unused import

* Refactor of getkey API

* Create 1561706992953-webauthn.ts

* Update ja-JP.yml

* Add type annotations

* Fix code style

* Specify depedency version

* Fix code style

* Fix janitor daemon and login requesting 2FA regardless of status
This commit is contained in:
Mary
2019-07-03 07:18:07 -04:00
committed by syuilo
parent f17e229c1e
commit fd94b817ab
21 changed files with 1376 additions and 64 deletions

422
src/server/api/2fa.ts Normal file
View File

@ -0,0 +1,422 @@
import * as crypto from 'crypto';
import config from '../../config';
import * as jsrsasign from 'jsrsasign';
const ECC_PRELUDE = Buffer.from([0x04]);
const NULL_BYTE = Buffer.from([0]);
const PEM_PRELUDE = Buffer.from(
'3059301306072a8648ce3d020106082a8648ce3d030107034200',
'hex'
);
// Android Safetynet attestations are signed with this cert:
const GSR2 = `-----BEGIN CERTIFICATE-----
MIIDujCCAqKgAwIBAgILBAAAAAABD4Ym5g0wDQYJKoZIhvcNAQEFBQAwTDEgMB4G
A1UECxMXR2xvYmFsU2lnbiBSb290IENBIC0gUjIxEzARBgNVBAoTCkdsb2JhbFNp
Z24xEzARBgNVBAMTCkdsb2JhbFNpZ24wHhcNMDYxMjE1MDgwMDAwWhcNMjExMjE1
MDgwMDAwWjBMMSAwHgYDVQQLExdHbG9iYWxTaWduIFJvb3QgQ0EgLSBSMjETMBEG
A1UEChMKR2xvYmFsU2lnbjETMBEGA1UEAxMKR2xvYmFsU2lnbjCCASIwDQYJKoZI
hvcNAQEBBQADggEPADCCAQoCggEBAKbPJA6+Lm8omUVCxKs+IVSbC9N/hHD6ErPL
v4dfxn+G07IwXNb9rfF73OX4YJYJkhD10FPe+3t+c4isUoh7SqbKSaZeqKeMWhG8
eoLrvozps6yWJQeXSpkqBy+0Hne/ig+1AnwblrjFuTosvNYSuetZfeLQBoZfXklq
tTleiDTsvHgMCJiEbKjNS7SgfQx5TfC4LcshytVsW33hoCmEofnTlEnLJGKRILzd
C9XZzPnqJworc5HGnRusyMvo4KD0L5CLTfuwNhv2GXqF4G3yYROIXJ/gkwpRl4pa
zq+r1feqCapgvdzZX99yqWATXgAByUr6P6TqBwMhAo6CygPCm48CAwEAAaOBnDCB
mTAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUm+IH
V2ccHsBqBt5ZtJot39wZhi4wNgYDVR0fBC8wLTAroCmgJ4YlaHR0cDovL2NybC5n
bG9iYWxzaWduLm5ldC9yb290LXIyLmNybDAfBgNVHSMEGDAWgBSb4gdXZxwewGoG
3lm0mi3f3BmGLjANBgkqhkiG9w0BAQUFAAOCAQEAmYFThxxol4aR7OBKuEQLq4Gs
J0/WwbgcQ3izDJr86iw8bmEbTUsp9Z8FHSbBuOmDAGJFtqkIk7mpM0sYmsL4h4hO
291xNBrBVNpGP+DTKqttVCL1OmLNIG+6KYnX3ZHu01yiPqFbQfXf5WRDLenVOavS
ot+3i9DAgBkcRcAtjOj4LaR0VknFBbVPFd5uRHg5h6h+u/N5GJG79G+dwfCMNYxd
AfvDbbnvRG15RjF+Cv6pgsH/76tuIMRQyV+dTZsXjAzlAcmgQWpzU/qlULRuJQ/7
TBj0/VLZjmmx6BEP3ojY+x1J96relc8geMJgEtslQIxq/H5COEBkEveegeGTLg==
-----END CERTIFICATE-----\n`;
function base64URLDecode(source: string) {
return Buffer.from(source.replace(/\-/g, '+').replace(/_/g, '/'), 'base64');
}
function getCertSubject(certificate: string) {
const subjectCert = new jsrsasign.X509();
subjectCert.readCertPEM(certificate);
const subjectString = subjectCert.getSubjectString();
const subjectFields = subjectString.slice(1).split('/');
const fields = {} as Record<string, string>;
for (const field of subjectFields) {
const eqIndex = field.indexOf('=');
fields[field.substring(0, eqIndex)] = field.substring(eqIndex + 1);
}
return fields;
}
function verifyCertificateChain(certificates: string[]) {
let valid = true;
for (let i = 0; i < certificates.length; i++) {
const Cert = certificates[i];
const certificate = new jsrsasign.X509();
certificate.readCertPEM(Cert);
const CACert = i + 1 >= certificates.length ? Cert : certificates[i + 1];
const certStruct = jsrsasign.ASN1HEX.getTLVbyList(certificate.hex, 0, [0]);
const algorithm = certificate.getSignatureAlgorithmField();
const signatureHex = certificate.getSignatureValueHex();
// Verify against CA
const Signature = new jsrsasign.crypto.Signature({alg: algorithm});
Signature.init(CACert);
Signature.updateHex(certStruct);
valid = valid && Signature.verify(signatureHex); // true if CA signed the certificate
}
return valid;
}
function PEMString(pemBuffer: Buffer, type = 'CERTIFICATE') {
if (pemBuffer.length == 65 && pemBuffer[0] == 0x04) {
pemBuffer = Buffer.concat([PEM_PRELUDE, pemBuffer], 91);
type = 'PUBLIC KEY';
}
const cert = pemBuffer.toString('base64');
const keyParts = [];
const max = Math.ceil(cert.length / 64);
let start = 0;
for (let i = 0; i < max; i++) {
keyParts.push(cert.substring(start, start + 64));
start += 64;
}
return (
`-----BEGIN ${type}-----\n` +
keyParts.join('\n') +
`\n-----END ${type}-----\n`
);
}
export function hash(data: Buffer) {
return crypto
.createHash('sha256')
.update(data)
.digest();
}
export function verifyLogin({
publicKey,
authenticatorData,
clientDataJSON,
clientData,
signature,
challenge
}: {
publicKey: Buffer,
authenticatorData: Buffer,
clientDataJSON: Buffer,
clientData: any,
signature: Buffer,
challenge: string
}) {
if (clientData.type != 'webauthn.get') {
throw new Error('type is not webauthn.get');
}
if (hash(clientData.challenge).toString('hex') != challenge) {
throw new Error('challenge mismatch');
}
if (clientData.origin != config.scheme + '://' + config.host) {
throw new Error('origin mismatch');
}
const verificationData = Buffer.concat(
[authenticatorData, hash(clientDataJSON)],
32 + authenticatorData.length
);
return crypto
.createVerify('SHA256')
.update(verificationData)
.verify(PEMString(publicKey), signature);
}
export const procedures = {
none: {
verify({publicKey}: {publicKey: Map<number, Buffer>}) {
const negTwo = publicKey.get(-2);
if (!negTwo || negTwo.length != 32) {
throw new Error('invalid or no -2 key given');
}
const negThree = publicKey.get(-3);
if (!negThree || negThree.length != 32) {
throw new Error('invalid or no -3 key given');
}
const publicKeyU2F = Buffer.concat(
[ECC_PRELUDE, negTwo, negThree],
1 + 32 + 32
);
return {
publicKey: publicKeyU2F,
valid: true
};
}
},
'android-key': {
verify({
attStmt,
authenticatorData,
clientDataHash,
publicKey,
rpIdHash,
credentialId
}: {
attStmt: any,
authenticatorData: Buffer,
clientDataHash: Buffer,
publicKey: Map<number, any>;
rpIdHash: Buffer,
credentialId: Buffer,
}) {
if (attStmt.alg != -7) {
throw new Error('alg mismatch');
}
const verificationData = Buffer.concat([
authenticatorData,
clientDataHash
]);
const attCert: Buffer = attStmt.x5c[0];
const negTwo = publicKey.get(-2);
if (!negTwo || negTwo.length != 32) {
throw new Error('invalid or no -2 key given');
}
const negThree = publicKey.get(-3);
if (!negThree || negThree.length != 32) {
throw new Error('invalid or no -3 key given');
}
const publicKeyData = Buffer.concat(
[ECC_PRELUDE, negTwo, negThree],
1 + 32 + 32
);
if (!attCert.equals(publicKeyData)) {
throw new Error('public key mismatch');
}
const isValid = crypto
.createVerify('SHA256')
.update(verificationData)
.verify(PEMString(attCert), attStmt.sig);
// TODO: Check 'attestationChallenge' field in extension of cert matches hash(clientDataJSON)
return {
valid: isValid,
publicKey: publicKeyData
};
}
},
// what a stupid attestation
'android-safetynet': {
verify({
attStmt,
authenticatorData,
clientDataHash,
publicKey,
rpIdHash,
credentialId
}: {
attStmt: any,
authenticatorData: Buffer,
clientDataHash: Buffer,
publicKey: Map<number, any>;
rpIdHash: Buffer,
credentialId: Buffer,
}) {
const verificationData = hash(
Buffer.concat([authenticatorData, clientDataHash])
);
const jwsParts = attStmt.response.toString('utf-8').split('.');
const header = JSON.parse(base64URLDecode(jwsParts[0]).toString('utf-8'));
const response = JSON.parse(
base64URLDecode(jwsParts[1]).toString('utf-8')
);
const signature = jwsParts[2];
if (!verificationData.equals(Buffer.from(response.nonce, 'base64'))) {
throw new Error('invalid nonce');
}
const certificateChain = header.x5c
.map(key => PEMString(key))
.concat([GSR2]);
if (getCertSubject(certificateChain[0]).CN != 'attest.android.com') {
throw new Error('invalid common name');
}
if (!verifyCertificateChain(certificateChain)) {
throw new Error('Invalid certificate chain!');
}
const signatureBase = Buffer.from(
jwsParts[0] + '.' + jwsParts[1],
'utf-8'
);
const valid = crypto
.createVerify('sha256')
.update(signatureBase)
.verify(certificateChain[0], base64URLDecode(signature));
const negTwo = publicKey.get(-2);
if (!negTwo || negTwo.length != 32) {
throw new Error('invalid or no -2 key given');
}
const negThree = publicKey.get(-3);
if (!negThree || negThree.length != 32) {
throw new Error('invalid or no -3 key given');
}
const publicKeyData = Buffer.concat(
[ECC_PRELUDE, negTwo, negThree],
1 + 32 + 32
);
return {
valid,
publicKey: publicKeyData
};
}
},
packed: {
verify({
attStmt,
authenticatorData,
clientDataHash,
publicKey,
rpIdHash,
credentialId
}: {
attStmt: any,
authenticatorData: Buffer,
clientDataHash: Buffer,
publicKey: Map<number, any>;
rpIdHash: Buffer,
credentialId: Buffer,
}) {
const verificationData = Buffer.concat([
authenticatorData,
clientDataHash
]);
if (attStmt.x5c) {
const attCert = attStmt.x5c[0];
const validSignature = crypto
.createVerify('SHA256')
.update(verificationData)
.verify(PEMString(attCert), attStmt.sig);
const negTwo = publicKey.get(-2);
if (!negTwo || negTwo.length != 32) {
throw new Error('invalid or no -2 key given');
}
const negThree = publicKey.get(-3);
if (!negThree || negThree.length != 32) {
throw new Error('invalid or no -3 key given');
}
const publicKeyData = Buffer.concat(
[ECC_PRELUDE, negTwo, negThree],
1 + 32 + 32
);
return {
valid: validSignature,
publicKey: publicKeyData
};
} else if (attStmt.ecdaaKeyId) {
// https://fidoalliance.org/specs/fido-v2.0-id-20180227/fido-ecdaa-algorithm-v2.0-id-20180227.html#ecdaa-verify-operation
throw new Error('ECDAA-Verify is not supported');
} else {
if (attStmt.alg != -7) throw new Error('alg mismatch');
throw new Error('self attestation is not supported');
}
}
},
'fido-u2f': {
verify({
attStmt,
authenticatorData,
clientDataHash,
publicKey,
rpIdHash,
credentialId
}: {
attStmt: any,
authenticatorData: Buffer,
clientDataHash: Buffer,
publicKey: Map<number, any>,
rpIdHash: Buffer,
credentialId: Buffer
}) {
const x5c: Buffer[] = attStmt.x5c;
if (x5c.length != 1) {
throw new Error('x5c length does not match expectation');
}
const attCert = x5c[0];
// TODO: make sure attCert is an Elliptic Curve (EC) public key over the P-256 curve
const negTwo: Buffer = publicKey.get(-2);
if (!negTwo || negTwo.length != 32) {
throw new Error('invalid or no -2 key given');
}
const negThree: Buffer = publicKey.get(-3);
if (!negThree || negThree.length != 32) {
throw new Error('invalid or no -3 key given');
}
const publicKeyU2F = Buffer.concat(
[ECC_PRELUDE, negTwo, negThree],
1 + 32 + 32
);
const verificationData = Buffer.concat([
NULL_BYTE,
rpIdHash,
clientDataHash,
credentialId,
publicKeyU2F
]);
const validSignature = crypto
.createVerify('SHA256')
.update(verificationData)
.verify(PEMString(attCert), attStmt.sig);
return {
valid: validSignature,
publicKey: publicKeyU2F
};
}
}
};

View File

@ -0,0 +1,67 @@
import $ from 'cafy';
import * as bcrypt from 'bcryptjs';
import * as crypto from 'crypto';
import define from '../../../define';
import { UserProfiles, UserSecurityKeys, AttestationChallenges } from '../../../../../models';
import { ensure } from '../../../../../prelude/ensure';
import { promisify } from 'util';
import { hash } from '../../../2fa';
import { genId } from '../../../../../misc/gen-id';
export const meta = {
requireCredential: true,
secure: true,
params: {
password: {
validator: $.str
}
}
};
const randomBytes = promisify(crypto.randomBytes);
export default define(meta, async (ps, user) => {
const profile = await UserProfiles.findOne(user.id).then(ensure);
// Compare password
const same = await bcrypt.compare(ps.password, profile.password!);
if (!same) {
throw new Error('incorrect password');
}
const keys = await UserSecurityKeys.find({
userId: user.id
});
if (keys.length === 0) {
throw new Error('no keys found');
}
// 32 byte challenge
const entropy = await randomBytes(32);
const challenge = entropy.toString('base64')
.replace(/=/g, '')
.replace(/\+/g, '-')
.replace(/\//g, '_');
const challengeId = genId();
await AttestationChallenges.save({
userId: user.id,
id: challengeId,
challenge: hash(Buffer.from(challenge, 'utf-8')).toString('hex'),
createdAt: new Date(),
registrationChallenge: false
});
return {
challenge,
challengeId,
securityKeys: keys.map(key => ({
id: key.id
}))
};
});

View File

@ -0,0 +1,151 @@
import $ from 'cafy';
import * as bcrypt from 'bcryptjs';
import { promisify } from 'util';
import * as cbor from 'cbor';
import define from '../../../define';
import {
UserProfiles,
UserSecurityKeys,
AttestationChallenges,
Users
} from '../../../../../models';
import { ensure } from '../../../../../prelude/ensure';
import config from '../../../../../config';
import { procedures, hash } from '../../../2fa';
import { publishMainStream } from '../../../../../services/stream';
const cborDecodeFirst = promisify(cbor.decodeFirst);
export const meta = {
requireCredential: true,
secure: true,
params: {
clientDataJSON: {
validator: $.str
},
attestationObject: {
validator: $.str
},
password: {
validator: $.str
},
challengeId: {
validator: $.str
},
name: {
validator: $.str
}
}
};
const rpIdHashReal = hash(Buffer.from(config.hostname, 'utf-8'));
export default define(meta, async (ps, user) => {
const profile = await UserProfiles.findOne(user.id).then(ensure);
// Compare password
const same = await bcrypt.compare(ps.password, profile.password!);
if (!same) {
throw new Error('incorrect password');
}
if (!profile.twoFactorEnabled) {
throw new Error('2fa not enabled');
}
const clientData = JSON.parse(ps.clientDataJSON);
if (clientData.type != 'webauthn.create') {
throw new Error('not a creation attestation');
}
if (clientData.origin != config.scheme + '://' + config.host) {
throw new Error('origin mismatch');
}
const clientDataJSONHash = hash(Buffer.from(ps.clientDataJSON, 'utf-8'));
const attestation = await cborDecodeFirst(ps.attestationObject);
const rpIdHash = attestation.authData.slice(0, 32);
if (!rpIdHashReal.equals(rpIdHash)) {
throw new Error('rpIdHash mismatch');
}
const flags = attestation.authData[32];
// tslint:disable-next-line:no-bitwise
if (!(flags & 1)) {
throw new Error('user not present');
}
const authData = Buffer.from(attestation.authData);
const credentialIdLength = authData.readUInt16BE(53);
const credentialId = authData.slice(55, 55 + credentialIdLength);
const publicKeyData = authData.slice(55 + credentialIdLength);
const publicKey: Map<number, any> = await cborDecodeFirst(publicKeyData);
if (publicKey.get(3) != -7) {
throw new Error('alg mismatch');
}
if (!procedures[attestation.fmt]) {
throw new Error('unsupported fmt');
}
const verificationData = procedures[attestation.fmt].verify({
attStmt: attestation.attStmt,
authenticatorData: authData,
clientDataHash: clientDataJSONHash,
credentialId,
publicKey,
rpIdHash
});
if (!verificationData.valid) throw new Error('signature invalid');
const attestationChallenge = await AttestationChallenges.findOne({
userId: user.id,
id: ps.challengeId,
registrationChallenge: true,
challenge: hash(clientData.challenge).toString('hex')
});
if (!attestationChallenge) {
throw new Error('non-existent challenge');
}
await AttestationChallenges.delete({
userId: user.id,
id: ps.challengeId
});
// Expired challenge (> 5min old)
if (
new Date().getTime() - attestationChallenge.createdAt.getTime() >=
5 * 60 * 1000
) {
throw new Error('expired challenge');
}
const credentialIdString = credentialId.toString('hex');
await UserSecurityKeys.save({
userId: user.id,
id: credentialIdString,
lastUsed: new Date(),
name: ps.name,
publicKey: verificationData.publicKey.toString('hex')
});
// Publish meUpdated event
publishMainStream(user.id, 'meUpdated', await Users.pack(user.id, user, {
detail: true,
includeSecrets: true
}));
return {
id: credentialIdString,
name: ps.name
};
});

View File

@ -0,0 +1,60 @@
import $ from 'cafy';
import * as bcrypt from 'bcryptjs';
import define from '../../../define';
import { UserProfiles, AttestationChallenges } from '../../../../../models';
import { ensure } from '../../../../../prelude/ensure';
import { promisify } from 'util';
import * as crypto from 'crypto';
import { genId } from '../../../../../misc/gen-id';
import { hash } from '../../../2fa';
const randomBytes = promisify(crypto.randomBytes);
export const meta = {
requireCredential: true,
secure: true,
params: {
password: {
validator: $.str
}
}
};
export default define(meta, async (ps, user) => {
const profile = await UserProfiles.findOne(user.id).then(ensure);
// Compare password
const same = await bcrypt.compare(ps.password, profile.password!);
if (!same) {
throw new Error('incorrect password');
}
if (!profile.twoFactorEnabled) {
throw new Error('2fa not enabled');
}
// 32 byte challenge
const entropy = await randomBytes(32);
const challenge = entropy.toString('base64')
.replace(/=/g, '')
.replace(/\+/g, '-')
.replace(/\//g, '_');
const challengeId = genId();
await AttestationChallenges.save({
userId: user.id,
id: challengeId,
challenge: hash(Buffer.from(challenge, 'utf-8')).toString('hex'),
createdAt: new Date(),
registrationChallenge: true
});
return {
challengeId,
challenge
};
});

View File

@ -0,0 +1,46 @@
import $ from 'cafy';
import * as bcrypt from 'bcryptjs';
import define from '../../../define';
import { UserProfiles, UserSecurityKeys, Users } from '../../../../../models';
import { ensure } from '../../../../../prelude/ensure';
import { publishMainStream } from '../../../../../services/stream';
export const meta = {
requireCredential: true,
secure: true,
params: {
password: {
validator: $.str
},
credentialId: {
validator: $.str
},
}
};
export default define(meta, async (ps, user) => {
const profile = await UserProfiles.findOne(user.id).then(ensure);
// Compare password
const same = await bcrypt.compare(ps.password, profile.password!);
if (!same) {
throw new Error('incorrect password');
}
// Make sure we only delete the user's own creds
await UserSecurityKeys.delete({
userId: user.id,
id: ps.credentialId
});
// Publish meUpdated event
publishMainStream(user.id, 'meUpdated', await Users.pack(user.id, user, {
detail: true,
includeSecrets: true
}));
return {};
});

View File

@ -4,10 +4,11 @@ import * as speakeasy from 'speakeasy';
import { publishMainStream } from '../../../services/stream';
import signin from '../common/signin';
import config from '../../../config';
import { Users, Signins, UserProfiles } from '../../../models';
import { Users, Signins, UserProfiles, UserSecurityKeys, AttestationChallenges } from '../../../models';
import { ILocalUser } from '../../../models/entities/user';
import { genId } from '../../../misc/gen-id';
import { ensure } from '../../../prelude/ensure';
import { verifyLogin, hash } from '../2fa';
export default async (ctx: Koa.BaseContext) => {
ctx.set('Access-Control-Allow-Origin', config.url);
@ -51,40 +52,116 @@ export default async (ctx: Koa.BaseContext) => {
// Compare password
const same = await bcrypt.compare(password, profile.password!);
if (same) {
if (profile.twoFactorEnabled) {
const verified = (speakeasy as any).totp.verify({
secret: profile.twoFactorSecret,
encoding: 'base32',
token: token
});
if (verified) {
signin(ctx, user);
} else {
ctx.throw(403, {
error: 'invalid token'
});
}
} else {
signin(ctx, user);
}
} else {
ctx.throw(403, {
error: 'incorrect password'
async function fail(status?: number, failure?: {error: string}) {
// Append signin history
const record = await Signins.save({
id: genId(),
createdAt: new Date(),
userId: user.id,
ip: ctx.ip,
headers: ctx.headers,
success: !!(status || failure)
});
// Publish signin event
publishMainStream(user.id, 'signin', await Signins.pack(record));
if (status && failure) {
ctx.throw(status, failure);
}
}
// Append signin history
const record = await Signins.save({
id: genId(),
createdAt: new Date(),
userId: user.id,
ip: ctx.ip,
headers: ctx.headers,
success: same
});
if (!same) {
await fail(403, {
error: 'incorrect password'
});
return;
}
// Publish signin event
publishMainStream(user.id, 'signin', await Signins.pack(record));
if (!profile.twoFactorEnabled) {
signin(ctx, user);
return;
}
if (token) {
const verified = (speakeasy as any).totp.verify({
secret: profile.twoFactorSecret,
encoding: 'base32',
token: token
});
if (verified) {
signin(ctx, user);
return;
} else {
await fail(403, {
error: 'invalid token'
});
return;
}
} else {
const clientDataJSON = Buffer.from(body.clientDataJSON, 'hex');
const clientData = JSON.parse(clientDataJSON.toString('utf-8'));
const challenge = await AttestationChallenges.findOne({
userId: user.id,
id: body.challengeId,
registrationChallenge: false,
challenge: hash(clientData.challenge).toString('hex')
});
if (!challenge) {
await fail(403, {
error: 'non-existent challenge'
});
return;
}
await AttestationChallenges.delete({
userId: user.id,
id: body.challengeId
});
if (new Date().getTime() - challenge.createdAt.getTime() >= 5 * 60 * 1000) {
await fail(403, {
error: 'non-existent challenge'
});
return;
}
const securityKey = await UserSecurityKeys.findOne({
id: Buffer.from(
body.credentialId
.replace(/\-/g, '+')
.replace(/_/g, '/'),
'base64'
).toString('hex')
});
if (!securityKey) {
await fail(403, {
error: 'invalid credentialId'
});
return;
}
const isValid = verifyLogin({
publicKey: Buffer.from(securityKey.publicKey, 'hex'),
authenticatorData: Buffer.from(body.authenticatorData, 'hex'),
clientDataJSON,
clientData,
signature: Buffer.from(body.signature, 'hex'),
challenge: challenge.challenge
});
if (isValid) {
signin(ctx, user);
} else {
await fail(403, {
error: 'invalid challenge data'
});
return;
}
}
await fail();
};