import * as crypto from 'node:crypto'; import config from '@/config/index.js'; 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; 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}) { 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; 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; 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; 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, 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, }; }, }, };