Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for Android SafetyNet #26

Open
wants to merge 3 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down
3 changes: 1 addition & 2 deletions packages/client/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down
3 changes: 1 addition & 2 deletions packages/server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down
5 changes: 5 additions & 0 deletions packages/server/src/authenticatorKey/index.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
const { decodeAllSync } = require('cbor');
const { parseAndroidSafetyNetKey } = require('./parseAndroidSafetyNetKey');
const { parseFidoU2FKey } = require('./parseFidoU2FKey');
const { parseFidoPackedKey } = require('./parseFidoPackedKey');

Expand All @@ -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);
}
Expand Down
197 changes: 197 additions & 0 deletions packages/server/src/authenticatorKey/parseAndroidSafetyNetKey.js
Original file line number Diff line number Diff line change
@@ -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,
};
};
10 changes: 10 additions & 0 deletions packages/server/src/login.js
Original file line number Diff line number Diff line change
@@ -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');
Expand Down Expand Up @@ -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,
Expand Down
8 changes: 8 additions & 0 deletions packages/server/src/registration.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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,
Expand Down
17 changes: 16 additions & 1 deletion packages/server/src/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ exports.convertASN1toPEM = pkBuffer => {
* @return {Buffer} - RAW PKCS encoded public key
*/
exports.convertCOSEPublicKeyToRawPKCSECDHAKey = cosePublicKey => {
/*
/*
+------+-------+-------+---------+----------------------------------+
| name | key | label | type | description |
| | type | | | |
Expand Down Expand Up @@ -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, '');
};
2 changes: 1 addition & 1 deletion yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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==
Expand Down