hippofish/packages/backend/src/server/api/2fa.ts
ThatOneCalculator abf47e54f3
clean up w/ rome
2023-01-16 11:19:20 -08:00

417 lines
11 KiB
TypeScript

import * as crypto from "node:crypto";
import * as jsrsasign from "jsrsasign";
import config from "@/config/index.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`;
}
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: 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,
};
},
},
};