diff --git a/README.md b/README.md index 8fd33da..41cd455 100644 --- a/README.md +++ b/README.md @@ -63,10 +63,9 @@ See an example in example/server ## Roadmap -For now only fido-u2f and packed format are implemented +For now only android-safetynet, fido-u2f, and packed formats are implemented - Implement android-key format -- Implement android-safetynet format - Implement tpm format diff --git a/packages/client/README.md b/packages/client/README.md index 8fd33da..41cd455 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -63,10 +63,9 @@ See an example in example/server ## Roadmap -For now only fido-u2f and packed format are implemented +For now only android-safetynet, fido-u2f, and packed formats are implemented - Implement android-key format -- Implement android-safetynet format - Implement tpm format diff --git a/packages/server/README.md b/packages/server/README.md index 8fd33da..41cd455 100644 --- a/packages/server/README.md +++ b/packages/server/README.md @@ -63,10 +63,9 @@ See an example in example/server ## Roadmap -For now only fido-u2f and packed format are implemented +For now only android-safetynet, fido-u2f, and packed formats are implemented - Implement android-key format -- Implement android-safetynet format - Implement tpm format diff --git a/packages/server/src/authenticatorKey/index.js b/packages/server/src/authenticatorKey/index.js index faf3a5d..c3f0921 100644 --- a/packages/server/src/authenticatorKey/index.js +++ b/packages/server/src/authenticatorKey/index.js @@ -1,4 +1,5 @@ const { decodeAllSync } = require('cbor'); +const { parseAndroidSafetyNetKey } = require('./parseAndroidSafetyNetKey'); const { parseFidoU2FKey } = require('./parseFidoU2FKey'); const { parseFidoPackedKey } = require('./parseFidoPackedKey'); @@ -12,6 +13,10 @@ exports.parseAuthenticatorKey = credentials => { const authenticatorKey = decodeAllSync(authenticatorKeyBuffer)[0]; + if (authenticatorKey.fmt === 'android-safetynet') { + return parseAndroidSafetyNetKey(authenticatorKey, credentials.clientDataJSON); + } + if (authenticatorKey.fmt === 'fido-u2f') { return parseFidoU2FKey(authenticatorKey, credentials.clientDataJSON); } diff --git a/packages/server/src/authenticatorKey/parseAndroidSafetyNetKey.js b/packages/server/src/authenticatorKey/parseAndroidSafetyNetKey.js new file mode 100644 index 0000000..f2c478d --- /dev/null +++ b/packages/server/src/authenticatorKey/parseAndroidSafetyNetKey.js @@ -0,0 +1,197 @@ +const { + base64ToPem, + hash, + convertASN1toPEM, + convertCOSEPublicKeyToRawPKCSECDHAKey +} = require('../utils'); +const { createVerify } = require('crypto'); +const jsrsasign = require('jsrsasign'); + +const gsr2 = 'MIIDujCCAqKgAwIBAgILBAAAAAABD4Ym5g0wDQYJKoZIhvcNAQEFBQAwTDEgMB4GA1UECxMXR2xvYmFsU2lnbiBSb290IENBIC0gUjIxEzARBgNVBAoTCkdsb2JhbFNpZ24xEzARBgNVBAMTCkdsb2JhbFNpZ24wHhcNMDYxMjE1MDgwMDAwWhcNMjExMjE1MDgwMDAwWjBMMSAwHgYDVQQLExdHbG9iYWxTaWduIFJvb3QgQ0EgLSBSMjETMBEGA1UEChMKR2xvYmFsU2lnbjETMBEGA1UEAxMKR2xvYmFsU2lnbjCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAKbPJA6+Lm8omUVCxKs+IVSbC9N/hHD6ErPLv4dfxn+G07IwXNb9rfF73OX4YJYJkhD10FPe+3t+c4isUoh7SqbKSaZeqKeMWhG8eoLrvozps6yWJQeXSpkqBy+0Hne/ig+1AnwblrjFuTosvNYSuetZfeLQBoZfXklqtTleiDTsvHgMCJiEbKjNS7SgfQx5TfC4LcshytVsW33hoCmEofnTlEnLJGKRILzdC9XZzPnqJworc5HGnRusyMvo4KD0L5CLTfuwNhv2GXqF4G3yYROIXJ/gkwpRl4pazq+r1feqCapgvdzZX99yqWATXgAByUr6P6TqBwMhAo6CygPCm48CAwEAAaOBnDCBmTAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUm+IHV2ccHsBqBt5ZtJot39wZhi4wNgYDVR0fBC8wLTAroCmgJ4YlaHR0cDovL2NybC5nbG9iYWxzaWduLm5ldC9yb290LXIyLmNybDAfBgNVHSMEGDAWgBSb4gdXZxwewGoG3lm0mi3f3BmGLjANBgkqhkiG9w0BAQUFAAOCAQEAmYFThxxol4aR7OBKuEQLq4GsJ0/WwbgcQ3izDJr86iw8bmEbTUsp9Z8FHSbBuOmDAGJFtqkIk7mpM0sYmsL4h4hO291xNBrBVNpGP+DTKqttVCL1OmLNIG+6KYnX3ZHu01yiPqFbQfXf5WRDLenVOavSot+3i9DAgBkcRcAtjOj4LaR0VknFBbVPFd5uRHg5h6h+u/N5GJG79G+dwfCMNYxdAfvDbbnvRG15RjF+Cv6pgsH/76tuIMRQyV+dTZsXjAzlAcmgQWpzU/qlULRuJQ/7TBj0/VLZjmmx6BEP3ojY+x1J96relc8geMJgEtslQIxq/H5COEBkEveegeGTLg=='; + + +const getCertificateSubject = (certificate) => { + const subjectCert = new jsrsasign.X509(); + subjectCert.readCertPEM(certificate); + + const subjectString = subjectCert.getSubjectString(); + const subjectFields = subjectString.slice(1).split('/'); + + let fields = {}; + for (let field of subjectFields) { + const [ key, val ] = field.split('='); + fields[key] = val; + } + + return fields; +} + +const verifySigningChain = (certificates) => { + if ((new Set(certificates)).size !== certificates.length) { + throw new Error('Failed to validate certificates path! Duplicate certificates detected!'); + } + + certificates.forEach((subjectPem, i) => { + const subjectCert = new jsrsasign.X509(); + subjectCert.readCertPEM(subjectPem); + + let issuerPem = ''; + if (i + 1 >= certificates.length) + issuerPem = subjectPem; + else + issuerPem = certificates[i + 1]; + + const issuerCert = new jsrsasign.X509(); + issuerCert.readCertPEM(issuerPem); + + if (subjectCert.getIssuerString() !== issuerCert.getSubjectString()) { + throw new Error(`Failed to validate certificate path! Issuers don't match!`); + } + + const subjectCertStruct = jsrsasign.ASN1HEX.getTLVbyList(subjectCert.hex, 0, [0]); + const algorithm = subjectCert.getSignatureAlgorithmField(); + const signatureHex = subjectCert.getSignatureValueHex(); + + const signature = new jsrsasign.crypto.Signature({ alg: algorithm }); + signature.init(issuerPem); + signature.updateHex(subjectCertStruct); + + if (!signature.verify(signatureHex)) { + throw new Error('Failed to validate certificate path! Signature is not valid!'); + } + }) +} + +exports.parseAndroidSafetyNetKey = (authenticatorKey, clientDataJSON) => { + const encodedJws = authenticatorKey.attStmt.response.toString(); + const jwsParts = encodedJws.split('.'); + const jws = { + header: JSON.parse(Buffer.from(jwsParts[0], 'base64').toString()), + payload: JSON.parse(Buffer.from(jwsParts[1], 'base64').toString()) + } + + // Check device integrity. + if (!jws.payload.ctsProfileMatch && !jws.payload.basicIntegrity) { + return undefined; + } + + // Verify that the nonce is identical to the hash of authenticatorData + clientDataHash. + const clientDataHash = hash( + 'SHA256', + Buffer.from(clientDataJSON, 'base64') + ); + const authAndClientData = Buffer.concat([authenticatorKey.authData, clientDataHash]); + const expectedNonce = hash('SHA256', authAndClientData).toString('base64'); + if (expectedNonce !== jws.payload.nonce) { + return undefined; + } + + // Verify that the SafetyNet response actually came from the SafetyNet service. + const formattedCerts = jws.header.x5c.concat([gsr2]).map(cert => base64ToPem(cert, 'CERTIFICATE')); + const leafCert = formattedCerts[0]; + const subject = getCertificateSubject(leafCert); + if (subject.CN !== 'attest.android.com') { + return undefined; + } + try { + verifySigningChain(formattedCerts); + } catch (err) { + return undefined; + } + + // Verify the signature of the JWS message. + const leafCertX509 = new jsrsasign.X509(); + leafCertX509.readCertPEM(leafCert); + const leafPublicKey = Buffer.from(leafCertX509.getPublicKeyHex(), 'hex').toString('base64'); + const leafPublicKeyPem = base64ToPem(leafPublicKey, 'PUBLIC KEY'); + if (!jsrsasign.jws.JWS.verify(encodedJws, leafPublicKeyPem)) { + return undefined; + } + + const authenticatorData = parseAttestationData(authenticatorKey.authData); + + const publicKey = convertCOSEPublicKeyToRawPKCSECDHAKey( + authenticatorData.COSEPublicKey + ); + + return { + fmt: 'android-safetynet', + publicKey: publicKey.toString('base64'), + counter: authenticatorData.counter, + credID: authenticatorData.credID.toString('base64'), + }; +}; + +exports.validateAndroidSafetyNetKey = ( + authenticatorDataBuffer, + key, + clientDataJSON, + base64Signature +) => { + const authenticatorData = parseAttestationData(authenticatorDataBuffer); + + if (!(authenticatorData.flags.up)) { + throw new Error('User was NOT presented durring authentication!'); + } + + const clientDataHash = hash( + 'SHA256', + Buffer.from(clientDataJSON, 'base64') + ); + const signatureBaseBuffer = Buffer.concat([ + authenticatorDataBuffer, + clientDataHash, + ]); + + const publicKey = convertASN1toPEM(Buffer.from(key.publicKey, 'base64')); + const signatureBuffer = Buffer.from(base64Signature, 'base64'); + + return createVerify('sha256') + .update(signatureBaseBuffer) + .verify(publicKey, signatureBuffer); +}; + +const parseAttestationData = buffer => { + const rpIdHash = buffer.slice(0, 32); + buffer = buffer.slice(32); + const flagsBuf = buffer.slice(0, 1); + buffer = buffer.slice(1); + const flagsInt = flagsBuf[0]; + const flags = { + up: !!(flagsInt & 0x01), + uv: !!(flagsInt & 0x04), + at: !!(flagsInt & 0x40), + ed: !!(flagsInt & 0x80), + flagsInt, + }; + + const counterBuf = buffer.slice(0, 4); + buffer = buffer.slice(4); + const counter = counterBuf.readUInt32BE(0); + + let aaguid; + let credID; + let COSEPublicKey; + + if (flags.at) { + aaguid = buffer.slice(0, 16); + buffer = buffer.slice(16); + const credIDLenBuf = buffer.slice(0, 2); + buffer = buffer.slice(2); + const credIDLen = credIDLenBuf.readUInt16BE(0); + credID = buffer.slice(0, credIDLen); + buffer = buffer.slice(credIDLen); + COSEPublicKey = buffer; + } + + return { + rpIdHash, + flagsBuf, + flags, + counter, + counterBuf, + aaguid, + credID, + COSEPublicKey, + }; +}; diff --git a/packages/server/src/login.js b/packages/server/src/login.js index fe57776..9c3254a 100644 --- a/packages/server/src/login.js +++ b/packages/server/src/login.js @@ -1,5 +1,6 @@ const { randomBase64Buffer, parseBrowserBufferString } = require('./utils'); const { getChallengeFromClientData } = require('./getChallengeFromClientData'); +const { validateAndroidSafetyNetKey } = require('./authenticatorKey/parseAndroidSafetyNetKey'); const { validateFidoPackedKey } = require('./authenticatorKey/parseFidoPackedKey'); const { validateFidoU2FKey } = require('./authenticatorKey/parseFidoU2FKey'); const { validateLoginCredentials } = require('./validation'); @@ -37,6 +38,15 @@ exports.verifyAuthenticatorAssertion = (data, key) => { 'base64' ); + if (key.fmt === 'android-safetynet') { + return validateAndroidSafetyNetKey( + authenticatorDataBuffer, + key, + data.response.clientDataJSON, + data.response.signature + ); + } + if (key.fmt === 'fido-u2f') { return validateFidoU2FKey( authenticatorDataBuffer, diff --git a/packages/server/src/registration.js b/packages/server/src/registration.js index 696c19c..fe5e64f 100644 --- a/packages/server/src/registration.js +++ b/packages/server/src/registration.js @@ -2,6 +2,7 @@ const { decodeAllSync } = require('cbor'); const { randomBase64Buffer } = require('./utils'); const { getChallengeFromClientData } = require('./getChallengeFromClientData'); +const { parseAndroidSafetyNetKey } = require('./authenticatorKey/parseAndroidSafetyNetKey'); const { parseFidoU2FKey } = require('./authenticatorKey/parseFidoU2FKey'); const { parseFidoPackedKey } = require('./authenticatorKey/parseFidoPackedKey'); const { validateRegistrationCredentials } = require('./validation'); @@ -13,6 +14,13 @@ const parseAuthenticatorKey = (webAuthnResponse) => { ); const authenticatorKey = decodeAllSync(authenticatorKeyBuffer)[0]; + if (authenticatorKey.fmt === 'android-safetynet') { + return parseAndroidSafetyNetKey( + authenticatorKey, + webAuthnResponse.clientDataJSON + ); + } + if (authenticatorKey.fmt === 'fido-u2f') { return parseFidoU2FKey( authenticatorKey, diff --git a/packages/server/src/utils.js b/packages/server/src/utils.js index 5cd831d..c910015 100644 --- a/packages/server/src/utils.js +++ b/packages/server/src/utils.js @@ -68,7 +68,7 @@ exports.convertASN1toPEM = pkBuffer => { * @return {Buffer} - RAW PKCS encoded public key */ exports.convertCOSEPublicKeyToRawPKCSECDHAKey = cosePublicKey => { - /* + /* +------+-------+-------+---------+----------------------------------+ | name | key | label | type | description | | | type | | | | @@ -117,3 +117,18 @@ exports.parseBrowserBufferString = (key_id) => { const buffer = Buffer.from(key_id, 'base64'); return buffer.toString('base64'); }; + +/** + * Convert raw base64-encoded string to multiline PEM. + */ +exports.base64ToPem = (str, type = 'CERTIFICATE') => { + const split = str.match(/.{1,65}/g).join('\n'); // split into 65-character lines + return `-----BEGIN ${type}-----\n${split}\n-----END ${type}-----\n`; +}; + +/** + * Convert multiline PEM to raw base64-encoded string. + */ +exports.pemToBase64 = (pem) => { + return pem.replace(/-----(BEGIN|END) [A-Z ]+-----/g, '').replace(/\n/g, ''); +}; diff --git a/yarn.lock b/yarn.lock index 9a18f89..7ac4d87 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4013,7 +4013,7 @@ https-browserify@^1.0.0: resolved "https://registry.yarnpkg.com/https-browserify/-/https-browserify-1.0.0.tgz#ec06c10e0a34c0f2faf199f7fd7fc78fffd03c73" integrity sha1-7AbBDgo0wPL68Zn3/X/Hj//QPHM= -https-proxy-agent@^2.2.1, https-proxy-agent@^2.2.3: +https-proxy-agent@^2.2.1: version "2.2.4" resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-2.2.4.tgz#4ee7a737abd92678a293d9b34a1af4d0d08c787b" integrity sha512-OmvfoQ53WLjtA9HeYP9RNrWMJzzAz1JGaSFr1nijg0PVR1JaD/xbJq1mdEIIlxGpXp9eSe/O2LgU9DJmTPd0Eg==