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:
422
src/server/api/2fa.ts
Normal file
422
src/server/api/2fa.ts
Normal 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
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
67
src/server/api/endpoints/i/2fa/getkeys.ts
Normal file
67
src/server/api/endpoints/i/2fa/getkeys.ts
Normal 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
|
||||
}))
|
||||
};
|
||||
});
|
151
src/server/api/endpoints/i/2fa/key-done.ts
Normal file
151
src/server/api/endpoints/i/2fa/key-done.ts
Normal 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
|
||||
};
|
||||
});
|
60
src/server/api/endpoints/i/2fa/register-key.ts
Normal file
60
src/server/api/endpoints/i/2fa/register-key.ts
Normal 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
|
||||
};
|
||||
});
|
46
src/server/api/endpoints/i/2fa/remove-key.ts
Normal file
46
src/server/api/endpoints/i/2fa/remove-key.ts
Normal 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 {};
|
||||
});
|
@ -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();
|
||||
};
|
||||
|
Reference in New Issue
Block a user