440 lines
12 KiB
TypeScript
440 lines
12 KiB
TypeScript
import * as crypto from 'node:crypto';
|
|
import { Inject, Injectable } from '@nestjs/common';
|
|
import * as jsrsasign from 'jsrsasign';
|
|
import { DI } from '@/di-symbols.js';
|
|
import { UsersRepository } from '@/models/index.js';
|
|
import { Config } from '@/config.js';
|
|
|
|
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.KJUR.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`
|
|
);
|
|
}
|
|
|
|
@Injectable()
|
|
export class TwoFactorAuthenticationService {
|
|
constructor(
|
|
@Inject(DI.config)
|
|
private config: Config,
|
|
|
|
@Inject(DI.usersRepository)
|
|
private usersRepository: UsersRepository,
|
|
) {
|
|
}
|
|
|
|
public hash(data: Buffer) {
|
|
return crypto
|
|
.createHash('sha256')
|
|
.update(data)
|
|
.digest();
|
|
}
|
|
|
|
public verifySignin({
|
|
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 (this.hash(clientData.challenge).toString('hex') !== challenge) {
|
|
throw new Error('challenge mismatch');
|
|
}
|
|
if (clientData.origin !== this.config.scheme + '://' + this.config.host) {
|
|
throw new Error('origin mismatch');
|
|
}
|
|
|
|
const verificationData = Buffer.concat(
|
|
[authenticatorData, this.hash(clientDataJSON)],
|
|
32 + authenticatorData.length,
|
|
);
|
|
|
|
return crypto
|
|
.createVerify('SHA256')
|
|
.update(verificationData)
|
|
.verify(PEMString(publicKey), signature);
|
|
}
|
|
|
|
public getProcedures() {
|
|
return {
|
|
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 = this.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: any) => 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,
|
|
};
|
|
},
|
|
},
|
|
};
|
|
}
|
|
}
|