diff options
Diffstat (limited to 'packages/server/src')
26 files changed, 1545 insertions, 0 deletions
diff --git a/packages/server/src/assertion/generateAssertionCredentials.ts b/packages/server/src/assertion/generateAssertionCredentials.ts new file mode 100644 index 0000000..71f9e44 --- /dev/null +++ b/packages/server/src/assertion/generateAssertionCredentials.ts @@ -0,0 +1,29 @@ +import base64url from 'base64url'; + +import { AssertionCredentials } from '@libTypes'; + +/** + * Prepare credentials for user registration via navigator.credentials.get(...) + * + * @param challenge Random string the authenticator needs to sign and pass back + * @param base64CredentialIDs Array of base64-encoded authenticator IDs registered by the user for + * assertion + * @param timeout How long (in ms) the user can take to complete attestation + */ +export default function generateAssertionCredentials( + challenge: string, + base64CredentialIDs: string[], + timeout: number = 60000, +): AssertionCredentials { + return { + publicKey: { + challenge: Uint8Array.from(challenge, c => c.charCodeAt(0)), + allowCredentials: base64CredentialIDs.map(id => ({ + id: base64url.toBuffer(id), + type: 'public-key', + transports: ['usb', 'ble', 'nfc'], + })), + timeout, + }, + }; +} diff --git a/packages/server/src/assertion/parseAssertionAuthData.ts b/packages/server/src/assertion/parseAssertionAuthData.ts new file mode 100644 index 0000000..e6aa011 --- /dev/null +++ b/packages/server/src/assertion/parseAssertionAuthData.ts @@ -0,0 +1,28 @@ +import { ParsedAssertionAuthData } from "@libTypes"; + +/** + * Make sense of the authData buffer contained in an Assertion + */ +export default function parseAssertionAuthData(authData: Buffer): ParsedAssertionAuthData { + let intBuffer = authData; + + const rpIdHash = intBuffer.slice(0, 32); + intBuffer = intBuffer.slice(32); + + const flagsBuf = intBuffer.slice(0, 1); + intBuffer = intBuffer.slice(1); + + const flags = flagsBuf[0]; + const counterBuf = intBuffer.slice(0, 4); + intBuffer = intBuffer.slice(4); + + const counter = counterBuf.readUInt32BE(0); + + return { + rpIdHash, + flagsBuf, + flags, + counter, + counterBuf, + }; +} diff --git a/packages/server/src/assertion/verifyAssertionResponse.test.ts b/packages/server/src/assertion/verifyAssertionResponse.test.ts new file mode 100644 index 0000000..ba76943 --- /dev/null +++ b/packages/server/src/assertion/verifyAssertionResponse.test.ts @@ -0,0 +1,25 @@ +import verifyAssertionResponse from './verifyAssertionResponse'; + +test('', () => { + const verification = verifyAssertionResponse( + { + base64AuthenticatorData: 'PdxHEOnAiLIp26idVjIguzn3Ipr_RlsKZWsa-5qK-KABAAAAhw', + base64ClientDataJSON: 'eyJjaGFsbGVuZ2UiOiJXRzVRU21RM1oyOTROR2gyTVROUk56WnViVmhMTlZZMWMwOHRP' + + 'V3BLVG5JIiwiY2xpZW50RXh0ZW5zaW9ucyI6e30sImhhc2hBbGdvcml0aG0iOiJTSEEtMjU2Iiwib3JpZ2luIjoi' + + 'aHR0cHM6Ly9kZXYuZG9udG5lZWRhLnB3IiwidHlwZSI6IndlYmF1dGhuLmdldCJ9', + base64Signature: 'MEQCIHZYFY3LsKzI0T9XRwEACl7YsYZysZ2HUw3q9f7tlq3wAiBNbyBbQMNM56P6Z00tBEZ6v' + + 'II4f9Al-p4pZw7OBpSaog', + userHandle: null, + }, + 'https://dev.dontneeda.pw', + { + base64PublicKey: 'BBMQEnZRfg4ASys9kfGUj99Xlsa028wqYJZw8xuGahPQJWN3K9D9DajLxzKlY7uf_ulA5D6gh' + + 'UJ9hrouDX84S_I', + base64CredentialID: 'wJZRtQbYjKlpiRnzet7yyVizdsj_oUhi11kFbKyO0hc5gIg-4xeaTC9YC9y9sfow6gO3jE' + + 'MoONBKNX4SmSclmQ', + counter: 134, + }, + ); + + expect(verification.verified).toEqual(true); +}); diff --git a/packages/server/src/assertion/verifyAssertionResponse.ts b/packages/server/src/assertion/verifyAssertionResponse.ts new file mode 100644 index 0000000..49cc905 --- /dev/null +++ b/packages/server/src/assertion/verifyAssertionResponse.ts @@ -0,0 +1,90 @@ +import base64url from 'base64url'; + +import { + EncodedAuthenticatorAssertionResponse, + U2F_USER_PRESENTED, + AuthenticatorDevice, + VerifiedAssertion, +} from "@libTypes"; +import decodeClientDataJSON from "@helpers/decodeClientDataJSON"; + +import parseAssertionAuthData from './parseAssertionAuthData'; +import toHash from '@helpers/toHash'; +import convertASN1toPEM from '@helpers/convertASN1toPEM'; +import verifySignature from '@helpers/verifySignature'; + +/** + * Verify that the user has legitimately completed the login process + * + * @param response Authenticator attestation response with base64-encoded values + * @param expectedOrigin Expected URL of website attestation should have occurred on + */ +export default function verifyAssertionResponse( + response: EncodedAuthenticatorAssertionResponse, + expectedOrigin: string, + authenticator: AuthenticatorDevice, +): VerifiedAssertion { + const { base64AuthenticatorData, base64ClientDataJSON, base64Signature } = response; + const clientDataJSON = decodeClientDataJSON(base64ClientDataJSON); + + console.debug('decodedClientDataJSON:', clientDataJSON); + + const { type, origin } = clientDataJSON; + + // Check that the origin is our site + if (origin !== expectedOrigin) { + console.error('client origin did not equal our origin'); + console.debug('expectedOrigin:', expectedOrigin); + console.debug('assertion\'s origin:', origin); + throw new Error('Assertion origin was an unexpected value'); + } + + // Make sure we're handling an assertion + if (type !== 'webauthn.get') { + console.error('type did not equal "webauthn.get"'); + console.debug('attestation\'s type:', type); + throw new Error('Attestation type was an unexpected value'); + } + + const authDataBuffer = base64url.toBuffer(base64AuthenticatorData); + const authData = parseAssertionAuthData(authDataBuffer); + console.log('parsed authData:', authData); + + if (!(authData.flags & U2F_USER_PRESENTED)) { + throw new Error('User was NOT present during authentication!'); + } + + const { + rpIdHash, + flagsBuf, + counterBuf, + counter, + } = authData; + + const clientDataHash = toHash(base64url.toBuffer(base64ClientDataJSON)); + const signatureBase = Buffer.concat([ + rpIdHash, + flagsBuf, + counterBuf, + clientDataHash, + ]); + + const publicKey = convertASN1toPEM(base64url.toBuffer(authenticator.base64PublicKey)); + const signature = base64url.toBuffer(base64Signature); + + const toReturn = { + verified: verifySignature(signature, signatureBase, publicKey), + }; + + if (toReturn.verified) { + if (counter <= authenticator.counter) { + // Error out when the counter in the DB is greater than or equal to the counter in the + // dataStruct. It's related to how the authenticator maintains the number of times its been + // used for this client. If this happens, then someone's somehow increased the counter + // on the device without going through this site + throw new Error(`Device's counter ${counter} isn't greater than ${authenticator.counter}!`); + } + } + + return toReturn; +} diff --git a/packages/server/src/attestation/generateAttestationCredentials.ts b/packages/server/src/attestation/generateAttestationCredentials.ts new file mode 100644 index 0000000..a45c21d --- /dev/null +++ b/packages/server/src/attestation/generateAttestationCredentials.ts @@ -0,0 +1,45 @@ +import { AttestationCredentials } from '@libTypes'; + +/** + * Prepare credentials for user registration via navigator.credentials.create(...) + * + * @param serviceName Friendly user-visible website name + * @param rpID Valid domain name (after `https://`) + * @param challenge Random string the authenticator needs to sign and pass back + * @param userID User's website-specific unique ID + * @param username User's website-specific username + * @param timeout How long (in ms) the user can take to complete attestation + * @param attestationType Request a full ("direct") or anonymized ("indirect") attestation statement + */ +export default function generateAttestationCredentials( + serviceName: string, + rpID: string, + challenge: string, + userID: string, + username: string, + timeout: number = 60000, + attestationType: 'direct' | 'indirect' = 'direct', +): AttestationCredentials { + return { + publicKey: { + // Cryptographically random bytes to prevent replay attacks + challenge: Uint8Array.from(challenge, c => c.charCodeAt(0)), + // The organization registering and authenticating the user + rp: { + name: serviceName, + id: rpID, + }, + user: { + id: Uint8Array.from(userID, c => c.charCodeAt(0)), + name: username, + displayName: username, + }, + pubKeyCredParams: [{ + alg: -7, + type: 'public-key', + }], + timeout, + attestation: attestationType, + }, + }; +} diff --git a/packages/server/src/attestation/parseAttestationAuthData.ts b/packages/server/src/attestation/parseAttestationAuthData.ts new file mode 100644 index 0000000..996967d --- /dev/null +++ b/packages/server/src/attestation/parseAttestationAuthData.ts @@ -0,0 +1,63 @@ +import { ParsedAttestationAuthData } from "@libTypes"; + +/** + * Make sense of the authData buffer contained in an Attestation + */ +export default function parseAttestationAuthData(authData: Buffer): ParsedAttestationAuthData { + console.log('parsing attestation auth data'); + + let intBuffer = authData; + + const rpIdHash = intBuffer.slice(0, 32); + intBuffer = intBuffer.slice(32); + + const flagsBuf = intBuffer.slice(0, 1); + intBuffer = intBuffer.slice(1); + + const flagsInt = flagsBuf[0]; + + const flags = { + up: !!(flagsInt & 0x01), + uv: !!(flagsInt & 0x04), + at: !!(flagsInt & 0x40), + ed: !!(flagsInt & 0x80), + flagsInt, + }; + + console.debug('flags:', flags); + + const counterBuf = intBuffer.slice(0, 4); + intBuffer = intBuffer.slice(4); + + const counter = counterBuf.readUInt32BE(0); + + let aaguid: Buffer | undefined = undefined; + let credentialID: Buffer | undefined = undefined; + let COSEPublicKey: Buffer | undefined = undefined; + + if (flags.at) { + aaguid = intBuffer.slice(0, 16); + intBuffer = intBuffer.slice(16); + + const credIDLenBuf = intBuffer.slice(0, 2); + intBuffer = intBuffer.slice(2); + + const credIDLen = credIDLenBuf.readUInt16BE(0); + + credentialID = intBuffer.slice(0, credIDLen); + intBuffer = intBuffer.slice(credIDLen); + + COSEPublicKey = intBuffer; + } + + return { + rpIdHash, + flagsBuf, + flags, + counter, + counterBuf, + aaguid, + credentialID, + COSEPublicKey, + }; +} diff --git a/packages/server/src/attestation/verifications/verifyAndroidSafetyNet.ts b/packages/server/src/attestation/verifications/verifyAndroidSafetyNet.ts new file mode 100644 index 0000000..874c388 --- /dev/null +++ b/packages/server/src/attestation/verifications/verifyAndroidSafetyNet.ts @@ -0,0 +1,166 @@ +import base64url from 'base64url'; + +import { + AttestationObject, + VerifiedAttestation, + SafetyNetJWTHeader, + SafetyNetJWTPayload, + SafetyNetJWTSignature, +} from "@libTypes"; +import toHash from "@helpers/toHash"; +import verifySignature from '@helpers/verifySignature'; +import convertCOSEtoPKCS from '@helpers/convertCOSEtoPKCS'; +import getCertificateInfo from '@helpers/getCertificateInfo'; + +import parseAttestationAuthData from '../parseAttestationAuthData'; + + +/** + * Verify an attestation response with fmt 'android-safetynet' + */ +export default function verifyAttestationAndroidSafetyNet( + attestationObject: AttestationObject, + base64ClientDataJSON: string, +): VerifiedAttestation { + const { attStmt, authData, fmt } = attestationObject; + + if (!attStmt.response) { + throw new Error('No response was included in attStmt by authenticator'); + } + + // Prepare to verify a JWT + const jwt = attStmt.response.toString('utf8'); + const jwtParts = jwt.split('.'); + + const HEADER: SafetyNetJWTHeader = JSON.parse(base64url.decode(jwtParts[0])); + const PAYLOAD: SafetyNetJWTPayload = JSON.parse(base64url.decode(jwtParts[1])); + const SIGNATURE: SafetyNetJWTSignature = jwtParts[2]; + + console.debug('HEADER:', HEADER); + console.debug('PAYLOAD:', PAYLOAD); + console.debug('SIGNATURE:', SIGNATURE); + + /** + * START Verify PAYLOAD + */ + const { nonce, ctsProfileMatch } = PAYLOAD; + const clientDataHash = toHash(base64url.toBuffer(base64ClientDataJSON)); + + const nonceBase = Buffer.concat([ + authData, + clientDataHash, + ]); + const nonceBuffer = toHash(nonceBase); + const expectedNonce = nonceBuffer.toString('base64'); + + if (nonce !== expectedNonce) { + console.error('Payload nonce was not the expected value!'); + console.debug('payload nonce:', PAYLOAD.nonce); + console.debug('expected nonce:', expectedNonce); + throw new Error('Could not verify response payload nonce'); + } + + if (!ctsProfileMatch) { + console.error('ctsProfileMatch was false!'); + console.debug('ctsProfileMatch:', ctsProfileMatch); + throw new Error('Could not verify response payload profile'); + } + /** + * END Verify PAYLOAD + */ + + /** + * START Verify Header + */ + // Generate an array of certs constituting a full certificate chain + const fullpathCert = HEADER.x5c.concat([GlobalSignRootCAR2]).map((cert) => { + let pem = ''; + // Take a string of characters and chop them up into 64-char lines (just like a PEM cert) + for (let i = 0; i < cert.length; i += 64) { + pem += `${cert.slice(i, i + 64)}\n`; + } + + return `-----BEGIN CERTIFICATE-----\n${pem}-----END CERTIFICATE-----`; + }); + + console.debug('fullpathCert:', fullpathCert); + + const certificate = fullpathCert[0]; + + const commonCertInfo = getCertificateInfo(certificate); + console.debug('commonCertInfo:', commonCertInfo); + + const { subject } = commonCertInfo; + + // TODO: Find out where this CN string is specified and if it might change + if (subject.CN !== 'attest.android.com') { + console.error('common name was not "attest.android.com"'); + throw new Error('Could not verify certificate common name'); + } + + // TODO: Re-investigate this if we decide to "use MDS or Metadata Statements" + // validateCertificatePath(fullpathCert); + /** + * END Verify Header + */ + + /** + * START Verify Signature + */ + const signatureBaseBuffer = Buffer.from(`${jwtParts[0]}.${jwtParts[1]}`); + const signatureBuffer = base64url.toBuffer(SIGNATURE); + + const toReturn: VerifiedAttestation = { + verified: verifySignature(signatureBuffer, signatureBaseBuffer, certificate), + }; + /** + * END Verify Signature + */ + + + if (toReturn.verified) { + const authDataStruct = parseAttestationAuthData(authData); + console.debug('authDataStruct:', authDataStruct); + const { counter, credentialID, COSEPublicKey } = authDataStruct; + + if (!COSEPublicKey) { + throw new Error('No public key was provided by authenticator'); + } + + if (!credentialID) { + throw new Error('No credential ID was provided by authenticator'); + } + + const publicKey = convertCOSEtoPKCS(COSEPublicKey); + + toReturn.authenticatorInfo = { + fmt, + counter, + base64PublicKey: base64url.encode(publicKey), + base64CredentialID: base64url.encode(credentialID), + }; + } + + return toReturn; +} + +/** + * This "GS Root R2" root certificate was downloaded from https://pki.goog/gsr2/GSR2.crt + * on 08/10/2019 and then run through `base64url.encode()` to get this representation. + * + * The certificate is valid until Dec 15, 2021 + */ +const GlobalSignRootCAR2 = 'MIIDujCCAqKgAwIBAgILBAAAAAABD4Ym5g0wDQYJKoZIhvcNAQEFBQAwTDEgMB4GA1UEC' + + 'xMXR2xvYmFsU2lnbiBSb290IENBIC0gUjIxEzARBgNVBAoTCkdsb2JhbFNpZ24xEzARBgNVBAMTCkdsb2JhbFNpZ24wHhc' + + 'NMDYxMjE1MDgwMDAwWhcNMjExMjE1MDgwMDAwWjBMMSAwHgYDVQQLExdHbG9iYWxTaWduIFJvb3QgQ0EgLSBSMjETMBEGA' + + '1UEChMKR2xvYmFsU2lnbjETMBEGA1UEAxMKR2xvYmFsU2lnbjCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAKb' + + 'PJA6-Lm8omUVCxKs-IVSbC9N_hHD6ErPLv4dfxn-G07IwXNb9rfF73OX4YJYJkhD10FPe-3t-c4isUoh7SqbKSaZeqKeMW' + + 'hG8eoLrvozps6yWJQeXSpkqBy-0Hne_ig-1AnwblrjFuTosvNYSuetZfeLQBoZfXklqtTleiDTsvHgMCJiEbKjNS7SgfQx' + + '5TfC4LcshytVsW33hoCmEofnTlEnLJGKRILzdC9XZzPnqJworc5HGnRusyMvo4KD0L5CLTfuwNhv2GXqF4G3yYROIXJ_gk' + + 'wpRl4pazq-r1feqCapgvdzZX99yqWATXgAByUr6P6TqBwMhAo6CygPCm48CAwEAAaOBnDCBmTAOBgNVHQ8BAf8EBAMCAQY' + + 'wDwYDVR0TAQH_BAUwAwEB_zAdBgNVHQ4EFgQUm-IHV2ccHsBqBt5ZtJot39wZhi4wNgYDVR0fBC8wLTAroCmgJ4YlaHR0c' + + 'DovL2NybC5nbG9iYWxzaWduLm5ldC9yb290LXIyLmNybDAfBgNVHSMEGDAWgBSb4gdXZxwewGoG3lm0mi3f3BmGLjANBgk' + + 'qhkiG9w0BAQUFAAOCAQEAmYFThxxol4aR7OBKuEQLq4GsJ0_WwbgcQ3izDJr86iw8bmEbTUsp9Z8FHSbBuOmDAGJFtqkIk' + + '7mpM0sYmsL4h4hO291xNBrBVNpGP-DTKqttVCL1OmLNIG-6KYnX3ZHu01yiPqFbQfXf5WRDLenVOavSot-3i9DAgBkcRcA' + + 'tjOj4LaR0VknFBbVPFd5uRHg5h6h-u_N5GJG79G-dwfCMNYxdAfvDbbnvRG15RjF-Cv6pgsH_76tuIMRQyV-dTZsXjAzlA' + + 'cmgQWpzU_qlULRuJQ_7TBj0_VLZjmmx6BEP3ojY-x1J96relc8geMJgEtslQIxq_H5COEBkEveegeGTLg'; diff --git a/packages/server/src/attestation/verifications/verifyFIDOU2F.ts b/packages/server/src/attestation/verifications/verifyFIDOU2F.ts new file mode 100644 index 0000000..9464053 --- /dev/null +++ b/packages/server/src/attestation/verifications/verifyFIDOU2F.ts @@ -0,0 +1,80 @@ +import base64url from 'base64url'; + +import { AttestationObject, VerifiedAttestation, U2F_USER_PRESENTED } from '@libTypes'; +import toHash from '@helpers/toHash'; +import convertCOSEtoPKCS from '@helpers/convertCOSEtoPKCS'; +import convertASN1toPEM from '@helpers/convertASN1toPEM'; +import verifySignature from '@helpers/verifySignature'; + +import parseAttestationAuthData from '../parseAttestationAuthData'; + + +/** + * Verify an attestation response with fmt 'fido-u2f' + */ +export default function verifyAttestationFIDOU2F( + attestationObject: AttestationObject, + base64ClientDataJSON: string, +): VerifiedAttestation { + const { fmt, authData, attStmt } = attestationObject; + + const authDataStruct = parseAttestationAuthData(authData); + const { + flags, + COSEPublicKey, + rpIdHash, + credentialID, + counter, + } = authDataStruct; + + if (!(flags.flagsInt & U2F_USER_PRESENTED)) { + throw new Error('User was NOT present during authentication'); + } + + if (!COSEPublicKey) { + throw new Error('No public key was provided by authenticator'); + } + + if (!credentialID) { + throw new Error('No credential ID was provided by authenticator'); + } + + const clientDataHash = toHash(base64url.toBuffer(base64ClientDataJSON)); + const reservedByte = Buffer.from([0x00]); + const publicKey = convertCOSEtoPKCS(COSEPublicKey); + + const signatureBase = Buffer.concat([ + reservedByte, + rpIdHash, + clientDataHash, + credentialID, + publicKey, + ]); + + const { sig, x5c } = attStmt; + + if (!x5c) { + throw new Error('No attestation certificate provided in attestation statement'); + } + + if (!sig) { + throw new Error('No attestation signature provided in attestation statement'); + } + + const publicKeyCertPEM = convertASN1toPEM(x5c[0]); + + const toReturn: VerifiedAttestation = { + verified: verifySignature(sig, signatureBase, publicKeyCertPEM), + }; + + if (toReturn.verified) { + toReturn.authenticatorInfo = { + fmt, + counter, + base64PublicKey: base64url.encode(publicKey), + base64CredentialID: base64url.encode(credentialID), + }; + } + + return toReturn; +} diff --git a/packages/server/src/attestation/verifications/verifyNone.ts b/packages/server/src/attestation/verifications/verifyNone.ts new file mode 100644 index 0000000..18e9417 --- /dev/null +++ b/packages/server/src/attestation/verifications/verifyNone.ts @@ -0,0 +1,61 @@ +import base64url from 'base64url'; + +import { AttestationObject, VerifiedAttestation } from "@libTypes"; +import convertCOSEtoPKCS from "@helpers/convertCOSEtoPKCS"; + +import parseAttestationAuthData from '../parseAttestationAuthData'; + + +/** + * Verify an attestation response with fmt 'none' + * + * This is the weaker of the assertions, so there are only so many checks we can perform + */ +export default function verifyAttestationNone( + attestationObject: AttestationObject, +): VerifiedAttestation { + const { fmt, authData } = attestationObject; + const authDataStruct = parseAttestationAuthData(authData); + + console.log('authDataStruct:', authDataStruct); + + const { + credentialID, + COSEPublicKey, + counter, + flags, + } = authDataStruct; + + if (!COSEPublicKey) { + throw new Error('No public key was provided by authenticator'); + } + + if (!credentialID) { + throw new Error('No credential ID was provided by authenticator'); + } + + // Make sure the (U)ser (P)resent for the attestation + if (!flags.up) { + console.error('User was not Present for attestation'); + console.debug('attestation\'s flags:', flags); + throw new Error('User presence could not be verified'); + } + + if (!flags.uv) { + console.warn('The authenticator could not uniquely Verify the user'); + } + + const publicKey = convertCOSEtoPKCS(COSEPublicKey); + + const toReturn: VerifiedAttestation = { + verified: true, + authenticatorInfo: { + fmt, + counter, + base64PublicKey: base64url.encode(publicKey), + base64CredentialID: base64url.encode(credentialID), + }, + }; + + return toReturn; +} diff --git a/packages/server/src/attestation/verifications/verifyPacked.ts b/packages/server/src/attestation/verifications/verifyPacked.ts new file mode 100644 index 0000000..b63fef0 --- /dev/null +++ b/packages/server/src/attestation/verifications/verifyPacked.ts @@ -0,0 +1,210 @@ +import base64url from 'base64url'; +import cbor from 'cbor'; +import elliptic from 'elliptic'; +import NodeRSA, { SigningSchemeHash } from 'node-rsa'; + +import { AttestationObject, VerifiedAttestation, COSEKEYS, COSEPublicKey } from "@libTypes"; +import convertCOSEtoPKCS from "@helpers/convertCOSEtoPKCS"; +import toHash from "@helpers/toHash"; +import convertASN1toPEM from '@helpers/convertASN1toPEM'; +import getCertificateInfo from '@helpers/getCertificateInfo'; +import verifySignature from '@helpers/verifySignature'; + +import parseAttestationAuthData from '../parseAttestationAuthData'; + + +/** + * Verify an attestation response with fmt 'packed' + */ +export default function verifyAttestationPacked(attestationObject: AttestationObject, + base64ClientDataJSON: string, +): VerifiedAttestation { + const { fmt, authData, attStmt } = attestationObject; + const { sig, x5c, ecdaaKeyId } = attStmt; + + const authDataStruct = parseAttestationAuthData(authData); + + const { COSEPublicKey, counter, credentialID } = authDataStruct; + + if (!COSEPublicKey) { + throw new Error('No public key was provided by authenticator'); + } + + if (!credentialID) { + throw new Error('No credential ID was provided by authenticator'); + } + + if (!sig) { + throw new Error('No attestation signature provided in attestation statement'); + } + + const clientDataHash = toHash(base64url.toBuffer(base64ClientDataJSON)); + + const signatureBase = Buffer.concat([ + authData, + clientDataHash, + ]); + + const toReturn: VerifiedAttestation = { verified: false }; + const publicKey = convertCOSEtoPKCS(COSEPublicKey); + + if (x5c) { + console.log('FULL Attestation'); + + const leafCert = convertASN1toPEM(x5c[0]); + const leafCertInfo = getCertificateInfo(leafCert); + + const { subject, basicConstraintsCA, version } = leafCertInfo; + const { + OU, + CN, + O, + C, + } = subject; + + if (OU !== 'Authenticator Attestation') { + throw new Error('Batch certificate OU MUST be set strictly to "Authenticator Attestation"!'); + } + + if (!CN) { + throw new Error('Batch certificate CN MUST no be empty!'); + } + + if (!O) { + throw new Error('Batch certificate CN MUST no be empty!'); + } + + if (!C || C.length !== 2) { + throw new Error('Batch certificate C MUST be set to two character ISO 3166 code!'); + } + + if (basicConstraintsCA) { + throw new Error('Batch certificate basic constraints CA MUST be false!'); + } + + if (version !== 3) { + throw new Error('Batch certificate version MUST be 3(ASN1 2)!'); + } + + toReturn.verified = verifySignature(sig, signatureBase, leafCert); + } else if (ecdaaKeyId) { + throw new Error('ECDAA not supported yet'); + } else { + console.log('SELF Attestation'); + + const cosePublicKey: COSEPublicKey = cbor.decodeAllSync(COSEPublicKey)[0]; + + const kty = cosePublicKey.get(COSEKEYS.kty); + const alg = cosePublicKey.get(COSEKEYS.alg); + + if (!alg) { + throw new Error('COSE public key was missing alg'); + } + + if (!kty) { + throw new Error('COSE public key was missing kty'); + } + + const hashAlg: string = COSEALGHASH[(alg as number)]; + + if (kty === COSEKTY.EC2) { + console.log('EC2'); + + const crv = cosePublicKey.get(COSEKEYS.crv); + + if (!crv) { + throw new Error('COSE public key was missing kty crv'); + } + + const pkcsPublicKey = convertCOSEtoPKCS(cosePublicKey); + const signatureBaseHash = toHash(signatureBase, hashAlg); + + const ec = new elliptic.ec(COSECRV[(crv as number)]); + const key = ec.keyFromPublic(pkcsPublicKey); + + toReturn.verified = key.verify(signatureBaseHash, sig); + } else if (kty === COSEKTY.RSA) { + console.log('RSA'); + + const n = cosePublicKey.get(COSEKEYS.n); + + if (!n) { + throw new Error('COSE public key was missing n'); + } + + const signingScheme = COSERSASCHEME[alg as number]; + + // TODO: Verify this works + const key = new NodeRSA(); + key.setOptions({ signingScheme }); + key.importKey({ + n: (n as Buffer), + e: 65537, + }, 'components-public'); + + toReturn.verified = key.verify(signatureBase, sig); + } else if (kty === COSEKTY.OKP) { + console.log('OKP'); + + const x = cosePublicKey.get(COSEKEYS.x); + + if (!x) { + throw new Error('COSE public key was missing x'); + } + + const signatureBaseHash = toHash(signatureBase, hashAlg); + + const key = new elliptic.eddsa('ed25519'); + key.keyFromPublic((x as Buffer)); + + // TODO: is `publicKey` right here? + toReturn.verified = key.verify(signatureBaseHash, sig, publicKey); + } + } + + if (toReturn.verified) { + toReturn.authenticatorInfo = { + fmt, + counter, + base64PublicKey: base64url.encode(publicKey), + base64CredentialID: base64url.encode(credentialID), + }; + } + + return toReturn; +} + +enum COSEKTY { + OKP = 1, + EC2 = 2, + RSA = 3, +} + +const COSERSASCHEME: { [key: string]: SigningSchemeHash } = { + '-3': 'pss-sha256', + '-39': 'pss-sha512', + '-38': 'pss-sha384', + '-65535': 'pkcs1-sha1', + '-257': 'pkcs1-sha256', + '-258': 'pkcs1-sha384', + '-259': 'pkcs1-sha512' +} + +const COSECRV: { [key: number]: string } = { + 1: 'p256', + 2: 'p384', + 3: 'p521', +}; + +const COSEALGHASH: { [key: string]: string } = { + '-257': 'sha256', + '-258': 'sha384', + '-259': 'sha512', + '-65535': 'sha1', + '-39': 'sha512', + '-38': 'sha384', + '-37': 'sha256', + '-7': 'sha256', + '-8': 'sha512', + '-36': 'sha512' +} diff --git a/packages/server/src/attestation/verifyAttestationResponse.test.ts b/packages/server/src/attestation/verifyAttestationResponse.test.ts new file mode 100644 index 0000000..7b31dd0 --- /dev/null +++ b/packages/server/src/attestation/verifyAttestationResponse.test.ts @@ -0,0 +1,197 @@ +import verifyAttestationResponse from './verifyAttestationResponse'; + +test('should verify FIDO U2F attestation', () => { + const verification = verifyAttestationResponse( + { + base64AttestationObject: 'o2NmbXRoZmlkby11MmZnYXR0U3RtdKJjc2lnWEgwRgIhAK40WxA0t7py7AjEXvwGw' + + 'TlmqlvrOks5g9lf+9zXzRiVAiEA3bv60xyXveKDOusYzniD7CDSostCet9PYK7FLdnTdZNjeDVjgVkCwTCCAr0wg' + + 'gGloAMCAQICBCrnYmMwDQYJKoZIhvcNAQELBQAwLjEsMCoGA1UEAxMjWXViaWNvIFUyRiBSb290IENBIFNlcmlhb' + + 'CA0NTcyMDA2MzEwIBcNMTQwODAxMDAwMDAwWhgPMjA1MDA5MDQwMDAwMDBaMG4xCzAJBgNVBAYTAlNFMRIwEAYDV' + + 'QQKDAlZdWJpY28gQUIxIjAgBgNVBAsMGUF1dGhlbnRpY2F0b3IgQXR0ZXN0YXRpb24xJzAlBgNVBAMMHll1Ymljb' + + 'yBVMkYgRUUgU2VyaWFsIDcxOTgwNzA3NTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABCoDhl5gQ9meEf8QqiVUV' + + '4S/Ca+Oax47MhcpIW9VEhqM2RDTmd3HaL3+SnvH49q8YubSRp/1Z1uP+okMynSGnj+jbDBqMCIGCSsGAQQBgsQKA' + + 'gQVMS4zLjYuMS40LjEuNDE0ODIuMS4xMBMGCysGAQQBguUcAgEBBAQDAgQwMCEGCysGAQQBguUcAQEEBBIEEG1Eu' + + 'pv27C5JuTAMj+kgy3MwDAYDVR0TAQH/BAIwADANBgkqhkiG9w0BAQsFAAOCAQEAclfQPNzD4RVphJDW+A75W1MHI' + + '3PZ5kcyYysR3Nx3iuxr1ZJtB+F7nFQweI3jL05HtFh2/4xVIgKb6Th4eVcjMecncBaCinEbOcdP1sEli9Hk2eVm1' + + 'XB5A0faUjXAPw/+QLFCjgXG6ReZ5HVUcWkB7riLsFeJNYitiKrTDXFPLy+sNtVNutcQnFsCerDKuM81TvEAigkIb' + + 'KCGlq8M/NvBg5j83wIxbCYiyV7mIr3RwApHieShzLdJo1S6XydgQjC+/64G5r8C+8AVvNFR3zXXCpio5C3KRIj88' + + 'HEEIYjf6h1fdLfqeIsq+cUUqbq5T+c4nNoZUZCysTB9v5EY4akp+GhhdXRoRGF0YVjEAbElFazplpnc037DORGDZ' + + 'NjDq86cN9vm6+APoAM20wtBAAAAAAAAAAAAAAAAAAAAAAAAAAAAQGFYevaR71ptU5YtXOSnVzPQTsGgK+gLiBKnq' + + 'PWBmZXNRvjISqlLxiwApzlrfkTc3lEMYMatjeACCnsijOkNEGOlAQIDJiABIVggdWLG6UvGyHFw/k/bv6/k6z/LL' + + 'gSO5KXzXw2EcUxkEX8iWCBeaVLz/cbyoKvRIg/q+q7tan0VN+i3WR0BOBCcuNP7yw==', + base64ClientDataJSON: 'eyJjaGFsbGVuZ2UiOiJVMmQ0TjNZME0wOU1jbGRQYjFSNVpFeG5UbG95IiwiY2xpZW50' + + 'RXh0ZW5zaW9ucyI6e30sImhhc2hBbGdvcml0aG0iOiJTSEEtMjU2Iiwib3JpZ2luIjoiaHR0cHM6Ly9jbG92ZXIu' + + 'bWlsbGVydGltZS5kZXY6MzAwMCIsInR5cGUiOiJ3ZWJhdXRobi5jcmVhdGUifQ==', + }, + 'https://clover.millertime.dev:3000', + ); + + expect(verification.verified).toEqual(true); + expect(verification.authenticatorInfo?.fmt).toEqual('fido-u2f'); + expect(verification.authenticatorInfo?.counter).toEqual(0); + expect(verification.authenticatorInfo?.base64PublicKey).toEqual( + 'BHVixulLxshxcP5P27-v5Os_yy4EjuSl818NhHFMZBF_XmlS8_3G8qCr0SIP6vqu7Wp9FTfot1kdATgQnLjT-8s', + ); + expect(verification.authenticatorInfo?.base64CredentialID).toEqual( + 'YVh69pHvWm1Tli1c5KdXM9BOwaAr6AuIEqeo9YGZlc1G-MhKqUvGLACnOWt-RNzeUQxgxq2N4AIKeyKM6Q0QYw', + ); +}); + +test('should verify Packed (EC2) attestation', () => { + const verification = verifyAttestationResponse( + { + base64AttestationObject: 'o2NmbXRmcGFja2VkZ2F0dFN0bXSiY2FsZyZjc2lnWEcwRQIhANvrPZMUFrl_rvlgR' + + 'qz6lCPlF6B4y885FYUCCrhrzAYXAiAb4dQKXbP3IimsTTadkwXQlrRVdxzlbmPXt847-Oh6r2hhdXRoRGF0YVjhP' + + 'dxHEOnAiLIp26idVjIguzn3Ipr_RlsKZWsa-5qK-KBFXsOO-a3OAAI1vMYKZIsLJfHwVQMAXQGE4WNXLCDWOCa2x' + + '8hpqk5dZy_xdc4wBd4UgCJ4M_JAHI7oJgDDVb8WUcKqRB_mzRxwCL9vdTl-ZKPXg3_-Zrt1Adgb7EnK9ivqaTOKM' + + 'DqRrKsIObWYJaqpsSJtUKUBAgMmIAEhWCBKMVVaivqCBpqqAxMjuCo5jMeUdh3jDOC0EF4fLBNNTyJYILc7rqDDe' + + 'X1pwCLrl3ZX7IThrtZNwKQVLQyfHiorqP-n', + base64ClientDataJSON: 'eyJjaGFsbGVuZ2UiOiJjelpRU1dKQ2JsQlFibkpIVGxOQ2VFNWtkRVJ5VkRkVmNsWlpT' + + 'a3M1U0UwIiwib3JpZ2luIjoiaHR0cHM6Ly9kZXYuZG9udG5lZWRhLnB3IiwidHlwZSI6IndlYmF1dGhuLmNyZWF0' + + 'ZSJ9', + }, + 'https://dev.dontneeda.pw' + ) + + expect(verification.verified).toEqual(true); + expect(verification.authenticatorInfo?.fmt).toEqual('packed'); + expect(verification.authenticatorInfo?.counter).toEqual(1589874425); + expect(verification.authenticatorInfo?.base64PublicKey).toEqual( + 'BEoxVVqK-oIGmqoDEyO4KjmMx5R2HeMM4LQQXh8sE01PtzuuoMN5fWnAIuuXdlfshOGu1k3ApBUtDJ8eKiuo_6c', + ); + expect(verification.authenticatorInfo?.base64CredentialID).toEqual( + 'AYThY1csINY4JrbHyGmqTl1nL_F1zjAF3hSAIngz8kAcjugmAMNVvxZRwqpEH-bNHHAIv291OX5ko9eDf_5mu3U' + + 'B2BvsScr2K-ppM4owOpGsqwg5tZglqqmxIm1Q', + ); +}); + +test('should verify None attestation', () => { + const verification = verifyAttestationResponse( + { + base64AttestationObject: 'o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YVjFPdxHEOnAiLIp26idVjIguzn3I' + + 'pr_RlsKZWsa-5qK-KBFAAAAAAAAAAAAAAAAAAAAAAAAAAAAQQHSlyRHIdWleVqO24-6ix7JFWODqDWo_arvEz3Se5E' + + 'gIFHkcVjZ4F5XDSBreIHsWRilRnKmaaqlqK3V2_4XtYs2pQECAyYgASFYID5PQTZQQg6haZFQWFzqfAOyQ_ENsMH8x' + + 'xQ4GRiNPsqrIlggU8IVUOV8qpgk_Jh-OTaLuZL52KdX1fTht07X4DiQPow', + base64ClientDataJSON: 'eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmdlIjoiYUVWalkxQlhkWHBw' + + 'VURBd1NEQndOV2Q0YURKZmRUVmZVRU0wVG1WWloyUSIsIm9yaWdpbiI6Imh0dHBzOlwvXC9kZXYuZG9udG5lZWRhLn' + + 'B3IiwiYW5kcm9pZFBhY2thZ2VOYW1lIjoib3JnLm1vemlsbGEuZmlyZWZveCJ9' + }, + 'https://dev.dontneeda.pw' + ) + + expect(verification.verified).toEqual(true); + expect(verification.authenticatorInfo?.fmt).toEqual('none'); + expect(verification.authenticatorInfo?.counter).toEqual(0); + expect(verification.authenticatorInfo?.base64PublicKey).toEqual( + 'BD5PQTZQQg6haZFQWFzqfAOyQ_ENsMH8xxQ4GRiNPsqrU8IVUOV8qpgk_Jh-OTaLuZL52KdX1fTht07X4DiQPow', + ); + expect(verification.authenticatorInfo?.base64CredentialID).toEqual( + 'AdKXJEch1aV5Wo7bj7qLHskVY4OoNaj9qu8TPdJ7kSAgUeRxWNngXlcNIGt4gexZGKVGcqZpqqWordXb_he1izY', + ); +}); + +test('should verify Android SafetyNet attestation', () => { + const verification = verifyAttestationResponse( + { + base64AttestationObject: 'o2NmbXRxYW5kcm9pZC1zYWZldHluZXRnYXR0U3RtdKJjdmVyaDE3MTIyMDM3aHJlc' + + '3BvbnNlWRS9ZXlKaGJHY2lPaUpTVXpJMU5pSXNJbmcxWXlJNld5Sk5TVWxHYTJwRFEwSkljV2RCZDBsQ1FXZEpVV' + + 'kpZY205T01GcFBaRkpyUWtGQlFVRkJRVkIxYm5wQlRrSm5hM0ZvYTJsSE9YY3dRa0ZSYzBaQlJFSkRUVkZ6ZDBOU' + + 'ldVUldVVkZIUlhkS1ZsVjZSV1ZOUW5kSFFURlZSVU5vVFZaU01qbDJXako0YkVsR1VubGtXRTR3U1VaT2JHTnVXb' + + 'kJaTWxaNlRWSk5kMFZSV1VSV1VWRkVSWGR3U0ZaR1RXZFJNRVZuVFZVNGVFMUNORmhFVkVVMFRWUkJlRTFFUVROT' + + 'lZHc3dUbFp2V0VSVVJUVk5WRUYzVDFSQk0wMVVhekJPVm05M1lrUkZURTFCYTBkQk1WVkZRbWhOUTFaV1RYaEZla' + + '0ZTUW1kT1ZrSkJaMVJEYTA1b1lrZHNiV0l6U25WaFYwVjRSbXBCVlVKblRsWkNRV05VUkZVeGRtUlhOVEJaVjJ4M' + + 'VNVWmFjRnBZWTNoRmVrRlNRbWRPVmtKQmIxUkRhMlIyWWpKa2MxcFRRazFVUlUxNFIzcEJXa0puVGxaQ1FVMVVSV' + + 'zFHTUdSSFZucGtRelZvWW0xU2VXSXliR3RNYlU1MllsUkRRMEZUU1hkRVVWbEtTMjlhU1doMlkwNUJVVVZDUWxGQ' + + 'lJHZG5SVkJCUkVORFFWRnZRMmRuUlVKQlRtcFlhM293WlVzeFUwVTBiU3N2UnpWM1QyOHJXRWRUUlVOeWNXUnVPR' + + 'Gh6UTNCU04yWnpNVFJtU3pCU2FETmFRMWxhVEVaSWNVSnJOa0Z0V2xaM01rczVSa2N3VHpseVVsQmxVVVJKVmxKN' + + 'VJUTXdVWFZ1VXpsMVowaEROR1ZuT1c5MmRrOXRLMUZrV2pKd09UTllhSHAxYmxGRmFGVlhXRU40UVVSSlJVZEtTe' + + 'k5UTW1GQlpucGxPVGxRVEZNeU9XaE1ZMUYxV1ZoSVJHRkROMDlhY1U1dWIzTnBUMGRwWm5NNGRqRnFhVFpJTDNob' + + '2JIUkRXbVV5YkVvck4wZDFkSHBsZUV0d2VIWndSUzkwV2xObVlsazVNRFZ4VTJ4Q2FEbG1jR293TVRWamFtNVJSb' + + 'XRWYzBGVmQyMUxWa0ZWZFdWVmVqUjBTMk5HU3pSd1pYWk9UR0Y0UlVGc0swOXJhV3hOZEVsWlJHRmpSRFZ1Wld3M' + + 'GVFcHBlWE0wTVROb1lXZHhWekJYYUdnMVJsQXpPV2hIYXpsRkwwSjNVVlJxWVhwVGVFZGtkbGd3YlRaNFJsbG9hQ' + + 'zh5VmsxNVdtcFVORXQ2VUVwRlEwRjNSVUZCWVU5RFFXeG5kMmRuU2xWTlFUUkhRVEZWWkVSM1JVSXZkMUZGUVhkS' + + 'lJtOUVRVlJDWjA1V1NGTlZSVVJFUVV0Q1oyZHlRbWRGUmtKUlkwUkJWRUZOUW1kT1ZraFNUVUpCWmpoRlFXcEJRV' + + 'TFDTUVkQk1WVmtSR2RSVjBKQ1VYRkNVWGRIVjI5S1FtRXhiMVJMY1hWd2J6UlhObmhVTm1veVJFRm1RbWRPVmtoV' + + 'FRVVkhSRUZYWjBKVFdUQm1hSFZGVDNaUWJTdDRaMjU0YVZGSE5rUnlabEZ1T1V0NlFtdENaMmR5UW1kRlJrSlJZM' + + 'EpCVVZKWlRVWlpkMHAzV1VsTGQxbENRbEZWU0UxQlIwZEhNbWd3WkVoQk5reDVPWFpaTTA1M1RHNUNjbUZUTlc1a' + + 'U1qbHVUREprTUdONlJuWk5WRUZ5UW1kbmNrSm5SVVpDVVdOM1FXOVpabUZJVWpCalJHOTJURE5DY21GVE5XNWlNa' + + 'mx1VERKa2VtTnFTWFpTTVZKVVRWVTRlRXh0VG5sa1JFRmtRbWRPVmtoU1JVVkdha0ZWWjJoS2FHUklVbXhqTTFGM' + + 'VdWYzFhMk50T1hCYVF6VnFZakl3ZDBsUldVUldVakJuUWtKdmQwZEVRVWxDWjFwdVoxRjNRa0ZuU1hkRVFWbExTM' + + '2RaUWtKQlNGZGxVVWxHUVhwQmRrSm5UbFpJVWpoRlMwUkJiVTFEVTJkSmNVRm5hR2cxYjJSSVVuZFBhVGgyV1ROS' + + '2MweHVRbkpoVXpWdVlqSTVia3d3WkZWVmVrWlFUVk0xYW1OdGQzZG5aMFZGUW1kdmNrSm5SVVZCWkZvMVFXZFJRM' + + 'EpKU0RGQ1NVaDVRVkJCUVdSM1EydDFVVzFSZEVKb1dVWkpaVGRGTmt4TldqTkJTMUJFVjFsQ1VHdGlNemRxYW1RN' + + 'E1FOTVRVE5qUlVGQlFVRlhXbVJFTTFCTVFVRkJSVUYzUWtsTlJWbERTVkZEVTFwRFYyVk1Tblp6YVZaWE5rTm5LM' + + 'mRxTHpsM1dWUktVbnAxTkVocGNXVTBaVmswWXk5dGVYcHFaMGxvUVV4VFlta3ZWR2g2WTNweGRHbHFNMlJyTTNaa' + + 'VRHTkpWek5NYkRKQ01HODNOVWRSWkdoTmFXZGlRbWRCU0ZWQlZtaFJSMjFwTDFoM2RYcFVPV1ZIT1ZKTVNTdDRNR' + + 'm95ZFdKNVdrVldla0UzTlZOWlZtUmhTakJPTUVGQlFVWnRXRkU1ZWpWQlFVRkNRVTFCVW1wQ1JVRnBRbU5EZDBFN' + + 'WFqZE9WRWRZVURJM09IbzBhSEl2ZFVOSWFVRkdUSGx2UTNFeVN6QXJlVXhTZDBwVlltZEpaMlk0WjBocWRuQjNNb' + + 'TFDTVVWVGFuRXlUMll6UVRCQlJVRjNRMnR1UTJGRlMwWlZlVm8zWmk5UmRFbDNSRkZaU2t0dldrbG9kbU5PUVZGR' + + 'lRFSlJRVVJuWjBWQ1FVazVibFJtVWt0SlYyZDBiRmRzTTNkQ1REVTFSVlJXTm10aGVuTndhRmN4ZVVGak5VUjFiV' + + 'FpZVHpReGExcDZkMG8yTVhkS2JXUlNVbFF2VlhORFNYa3hTMFYwTW1Nd1JXcG5iRzVLUTBZeVpXRjNZMFZYYkV4U' + + 'ldUSllVRXg1Um1wclYxRk9ZbE5vUWpGcE5GY3lUbEpIZWxCb2RETnRNV0kwT1doaWMzUjFXRTAyZEZnMVEzbEZTR' + + 'zVVYURoQ2IyMDBMMWRzUm1sb2VtaG5iamd4Ukd4a2IyZDZMMHN5VlhkTk5sTTJRMEl2VTBWNGEybFdabllyZW1KS' + + '01ISnFkbWM1TkVGc1pHcFZabFYzYTBrNVZrNU5ha1ZRTldVNGVXUkNNMjlNYkRabmJIQkRaVVkxWkdkbVUxZzBWV' + + 'Gw0TXpWdmFpOUpTV1F6VlVVdlpGQndZaTl4WjBkMmMydG1aR1Y2ZEcxVmRHVXZTMU50Y21sM1kyZFZWMWRsV0daV' + + 'Vlra3plbk5wYTNkYVltdHdiVkpaUzIxcVVHMW9kalJ5YkdsNlIwTkhkRGhRYmpod2NUaE5Na3RFWmk5UU0ydFdiM' + + '1F6WlRFNFVUMGlMQ0pOU1VsRlUycERRMEY2UzJkQmQwbENRV2RKVGtGbFR6QnRjVWRPYVhGdFFrcFhiRkYxUkVGT' + + '1FtZHJjV2hyYVVjNWR6QkNRVkZ6UmtGRVFrMU5VMEYzU0dkWlJGWlJVVXhGZUdSSVlrYzVhVmxYZUZSaFYyUjFTV' + + 'VpLZG1JelVXZFJNRVZuVEZOQ1UwMXFSVlJOUWtWSFFURlZSVU5vVFV0U01uaDJXVzFHYzFVeWJHNWlha1ZVVFVKR' + + 'lIwRXhWVVZCZUUxTFVqSjRkbGx0Um5OVk1teHVZbXBCWlVaM01IaE9la0V5VFZSVmQwMUVRWGRPUkVwaFJuY3dlV' + + 'TFVUlhsTlZGVjNUVVJCZDA1RVNtRk5SVWw0UTNwQlNrSm5UbFpDUVZsVVFXeFdWRTFTTkhkSVFWbEVWbEZSUzBWN' + + 'FZraGlNamx1WWtkVloxWklTakZqTTFGblZUSldlV1J0YkdwYVdFMTRSWHBCVWtKblRsWkNRVTFVUTJ0a1ZWVjVRa' + + '1JSVTBGNFZIcEZkMmRuUldsTlFUQkhRMU54UjFOSllqTkVVVVZDUVZGVlFVRTBTVUpFZDBGM1oyZEZTMEZ2U1VKQ' + + 'lVVUlJSMDA1UmpGSmRrNHdOWHByVVU4NUszUk9NWEJKVW5aS2VucDVUMVJJVnpWRWVrVmFhRVF5WlZCRGJuWlZRV' + + 'EJSYXpJNFJtZEpRMlpMY1VNNVJXdHpRelJVTW1aWFFsbHJMMnBEWmtNelVqTldXazFrVXk5a1RqUmFTME5GVUZwU' + + '2NrRjZSSE5wUzFWRWVsSnliVUpDU2pWM2RXUm5lbTVrU1UxWlkweGxMMUpIUjBac05YbFBSRWxMWjJwRmRpOVRTa' + + '2d2VlV3clpFVmhiSFJPTVRGQ2JYTkxLMlZSYlUxR0t5dEJZM2hIVG1oeU5UbHhUUzg1YVd3M01Va3laRTQ0Umtkb' + + 'VkyUmtkM1ZoWldvMFlsaG9jREJNWTFGQ1ltcDRUV05KTjBwUU1HRk5NMVEwU1N0RWMyRjRiVXRHYzJKcWVtRlVUa' + + '001ZFhwd1JteG5UMGxuTjNKU01qVjRiM2x1VlhoMk9IWk9iV3R4TjNwa1VFZElXR3Q0VjFrM2IwYzVhaXRLYTFKN' + + 'VFrRkNhemRZY2twbWIzVmpRbHBGY1VaS1NsTlFhemRZUVRCTVMxY3dXVE42Tlc5Nk1rUXdZekYwU2t0M1NFRm5UV' + + 'UpCUVVkcVoyZEZlazFKU1VKTWVrRlBRbWRPVmtoUk9FSkJaamhGUWtGTlEwRlpXWGRJVVZsRVZsSXdiRUpDV1hkR' + + '1FWbEpTM2RaUWtKUlZVaEJkMFZIUTBOelIwRlJWVVpDZDAxRFRVSkpSMEV4VldSRmQwVkNMM2RSU1UxQldVSkJaa' + + 'mhEUVZGQmQwaFJXVVJXVWpCUFFrSlpSVVpLYWxJclJ6UlJOamdyWWpkSFEyWkhTa0ZpYjA5ME9VTm1NSEpOUWpoS' + + 'FFURlZaRWwzVVZsTlFtRkJSa3AyYVVJeFpHNUlRamRCWVdkaVpWZGlVMkZNWkM5alIxbFpkVTFFVlVkRFEzTkhRV' + + 'kZWUmtKM1JVSkNRMnQzU25wQmJFSm5aM0pDWjBWR1FsRmpkMEZaV1ZwaFNGSXdZMFJ2ZGt3eU9XcGpNMEYxWTBkM' + + 'GNFeHRaSFppTW1OMldqTk9lVTFxUVhsQ1owNVdTRkk0UlV0NlFYQk5RMlZuU21GQmFtaHBSbTlrU0ZKM1QyazRkb' + + 'Gt6U25OTWJrSnlZVk0xYm1JeU9XNU1NbVI2WTJwSmRsb3pUbmxOYVRWcVkyMTNkMUIzV1VSV1VqQm5Ra1JuZDA1c' + + 'VFUQkNaMXB1WjFGM1FrRm5TWGRMYWtGdlFtZG5ja0puUlVaQ1VXTkRRVkpaWTJGSVVqQmpTRTAyVEhrNWQyRXlhM' + + '1ZhTWpsMlduazVlVnBZUW5aak1td3dZak5LTlV4NlFVNUNaMnR4YUd0cFJ6bDNNRUpCVVhOR1FVRlBRMEZSUlVGS' + + 'GIwRXJUbTV1TnpoNU5uQlNhbVE1V0d4UlYwNWhOMGhVWjJsYUwzSXpVazVIYTIxVmJWbElVRkZ4TmxOamRHazVVR' + + 'VZoYW5aM1VsUXlhVmRVU0ZGeU1ESm1aWE54VDNGQ1dUSkZWRlYzWjFwUksyeHNkRzlPUm5ab2MwODVkSFpDUTA5S' + + 'llYcHdjM2RYUXpsaFNqbDRhblUwZEZkRVVVZzRUbFpWTmxsYVdpOVlkR1ZFVTBkVk9WbDZTbkZRYWxrNGNUTk5SS' + + 'Gh5ZW0xeFpYQkNRMlkxYnpodGR5OTNTalJoTWtjMmVIcFZjalpHWWpaVU9FMWpSRTh5TWxCTVVrdzJkVE5OTkZSN' + + 'mN6TkJNazB4YWpaaWVXdEtXV2s0ZDFkSlVtUkJka3RNVjFwMUwyRjRRbFppZWxsdGNXMTNhMjAxZWt4VFJGYzFia' + + '2xCU21KRlRFTlJRMXAzVFVnMU5uUXlSSFp4YjJaNGN6WkNRbU5EUmtsYVZWTndlSFUyZURaMFpEQldOMU4yU2tOR' + + 'GIzTnBjbE50U1dGMGFpODVaRk5UVmtSUmFXSmxkRGh4THpkVlN6UjJORnBWVGpnd1lYUnVXbm94ZVdjOVBTSmRmU' + + 'S5leUp1YjI1alpTSTZJbkZyYjB4dE9XSnJUeXNyYzJoMFZITnZheXRqUW1GRmJFcEJXa1pXTUcxRlFqQTVVbWcxV' + + 'TNKWVpGVTlJaXdpZEdsdFpYTjBZVzF3VFhNaU9qRTFOalUwTWpReU5qSTNOek1zSW1Gd2ExQmhZMnRoWjJWT1lXM' + + 'WxJam9pWTI5dExtZHZiMmRzWlM1aGJtUnliMmxrTG1kdGN5SXNJbUZ3YTBScFoyVnpkRk5vWVRJMU5pSTZJaXR0Y' + + '0ZKQ016RjRRemRTYUdsaWN5OWxWbUVyTDNWQ05XNTFaMVVyV0UxRFFXa3plSFZKZGpaMGIwMDlJaXdpWTNSelVIS' + + 'nZabWxzWlUxaGRHTm9JanAwY25WbExDSmhjR3REWlhKMGFXWnBZMkYwWlVScFoyVnpkRk5vWVRJMU5pSTZXeUk0V' + + 'URGelZ6QkZVRXBqYzJ4M04xVjZVbk5wV0V3Mk5IY3JUelV3UldRclVrSkpRM1JoZVRGbk1qUk5QU0pkTENKaVlYT' + + 'nBZMGx1ZEdWbmNtbDBlU0k2ZEhKMVpYMC5yUW5Ib2FZVGgxTEU2VVZwaU1lZWFidDdUeWJ3dzdXZk42RzJ5R01tZ' + + 'kVjbTFabjRWalZkenpoY1BqTS1WR052aWl1RGxyZ2VuWEViZ082V05YNlYzc0hHVjN1VGxGMlBuOUZsY3YxWmItS' + + '2NGVHZUd29iYnY3LUp5VUZzTlhTSnhHZFRTOWxwNU5EdDFnWGJ6OVpORWhzVXI3ajBqbWNyaU9rR29PRzM4MXRSa' + + '0Vqdk5aa0hpMkF1UDF2MWM4RXg3cEpZc09ISzJxaDlmSHFuSlAzcGowUFc3WThpcDBSTVZaNF9xZzFqc0dMMnZ0O' + + 'G12cEJFMjg5dE1fcnROdm94TWU2aEx0Q1ZkdE9ZRjIzMWMtWVFJd2FEbnZWdDcwYW5XLUZYdUx3R1J5dWhfRlpNM' + + '3FCSlhhcXdCNjNITk5uMmh5MFRDdHQ4RDdIMmI4MGltWkZRX1FoYXV0aERhdGFYxT3cRxDpwIiyKduonVYyILs59' + + 'yKa_0ZbCmVrGvuaivigRQAAAAC5P9lh8uZGL7EiggAiR954AEEBDL2BKZVhBca7N3j3asDaoSrA3tJgT_E4KN25T' + + 'hBVqBHCdffSZt9bvku7hPBcd76BzU7Y-ckXslUkD13Imbzde6UBAgMmIAEhWCCT4hId3ByJ_agRyznv1xIazx2nl' + + 'VEGyvN7intoZr7C2CJYIKo3XB-cca9aUOLC-xhp3GfhyfTS0hjws5zL_bT_N1AL', + base64ClientDataJSON: 'eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmdlIjoiWDNaV1VHOUZOREpF' + + 'YUMxM2F6Tmlka2h0WVd0MGFWWjJSVmxETFV4M1FsZyIsIm9yaWdpbiI6Imh0dHBzOlwvXC9kZXYuZG9udG5lZWRh' + + 'LnB3IiwiYW5kcm9pZFBhY2thZ2VOYW1lIjoiY29tLmFuZHJvaWQuY2hyb21lIn0' + }, + 'https://dev.dontneeda.pw' + ) + + expect(verification.verified).toEqual(true); + expect(verification.authenticatorInfo?.fmt).toEqual('android-safetynet'); + expect(verification.authenticatorInfo?.counter).toEqual(0); + expect(verification.authenticatorInfo?.base64PublicKey).toEqual( + 'BJPiEh3cHIn9qBHLOe_XEhrPHaeVUQbK83uKe2hmvsLYqjdcH5xxr1pQ4sL7GGncZ-HJ9NLSGPCznMv9tP83UAs', + ); + expect(verification.authenticatorInfo?.base64CredentialID).toEqual( + 'AQy9gSmVYQXGuzd492rA2qEqwN7SYE_xOCjduU4QVagRwnX30mbfW75Lu4TwXHe-gc1O2PnJF7JVJA9dyJm83Xs', + ); +}); diff --git a/packages/server/src/attestation/verifyAttestationResponse.ts b/packages/server/src/attestation/verifyAttestationResponse.ts new file mode 100644 index 0000000..775a150 --- /dev/null +++ b/packages/server/src/attestation/verifyAttestationResponse.ts @@ -0,0 +1,72 @@ +import decodeAttestationObject from '@helpers/decodeAttestationObject'; +import decodeClientDataJSON from '@helpers/decodeClientDataJSON'; +import { ATTESTATION_FORMATS, EncodedAuthenticatorAttestationResponse, VerifiedAttestation } from '@libTypes'; + +import verifyFIDOU2F from './verifications/verifyFIDOU2F'; +import verifyPacked from './verifications/verifyPacked'; +import verifyNone from './verifications/verifyNone'; +import verifyAndroidSafetynet from './verifications/verifyAndroidSafetyNet'; + +/** + * Verify that the user has legitimately completed the registration process + * + * @param response Authenticator attestation response with base64-encoded values + * @param expectedOrigin Expected URL of website attestation should have occurred on + */ +export default function verifyAttestationResponse( + response: EncodedAuthenticatorAttestationResponse, + expectedOrigin: string, +): VerifiedAttestation { + const { base64AttestationObject, base64ClientDataJSON } = response; + const attestationObject = decodeAttestationObject(base64AttestationObject); + const clientDataJSON = decodeClientDataJSON(base64ClientDataJSON); + + console.debug('decoded attestationObject:', attestationObject); + console.debug('decoded clientDataJSON:', clientDataJSON); + + const { type, origin } = clientDataJSON; + + // Check that the origin is our site + if (origin !== expectedOrigin) { + console.error('client origin did not equal our origin'); + console.debug('Expected Origin:', expectedOrigin); + console.debug('attestation\'s origin:', origin); + throw new Error('Attestation origin was an unexpected value'); + } + + // Make sure we're handling an attestation + if (type !== 'webauthn.create') { + console.error('type did not equal "webauthn.create"'); + console.debug('attestation\'s type:', type); + throw new Error('Attestation type was an unexpected value'); + } + + const { fmt } = attestationObject; + + /** + * Verification can only be performed when attestation = 'direct' + */ + if (fmt === ATTESTATION_FORMATS.FIDO_U2F) { + console.log('Decoding FIDO-U2F attestation'); + return verifyFIDOU2F(attestationObject, base64ClientDataJSON); + } + + if (fmt === ATTESTATION_FORMATS.PACKED) { + console.log('Decoding Packed attestation'); + return verifyPacked(attestationObject, base64ClientDataJSON); + } + + if (fmt === ATTESTATION_FORMATS.ANDROID_SAFETYNET) { + console.log('Decoding Android Safetynet attestation'); + return verifyAndroidSafetynet(attestationObject, base64ClientDataJSON); + } + + if (fmt === ATTESTATION_FORMATS.NONE) { + console.log('Decoding None attestation'); + return verifyNone(attestationObject); + } + + const reason = `Unsupported Attestation Format: ${fmt}`; + console.error(reason); + throw new Error(reason); +} diff --git a/packages/server/src/helpers/asciiToBinary.ts b/packages/server/src/helpers/asciiToBinary.ts new file mode 100644 index 0000000..b006edd --- /dev/null +++ b/packages/server/src/helpers/asciiToBinary.ts @@ -0,0 +1,8 @@ +/** + * Decode a base64-encoded string to a binary string + * + * @param input Base64-encoded string + */ +export default function asciiToBinary(input: string) { + return Buffer.from(input, 'base64').toString('binary'); +} diff --git a/packages/server/src/helpers/convertASN1toPEM.ts b/packages/server/src/helpers/convertASN1toPEM.ts new file mode 100644 index 0000000..c282e15 --- /dev/null +++ b/packages/server/src/helpers/convertASN1toPEM.ts @@ -0,0 +1,48 @@ +/** + * Convert binary certificate or public key to an OpenSSL-compatible PEM text format. + * + * @param buffer - Cert or PubKey buffer + * @return PEM + */ +export default function convertASN1toPEM(pkBuffer: Buffer) { + let buffer = pkBuffer; + + let type; + if (buffer.length === 65 && buffer[0] === 0x04) { + /** + * If needed, we encode rawpublic key to ASN structure, adding metadata: + * + * SEQUENCE { + * SEQUENCE { + * OBJECTIDENTIFIER 1.2.840.10045.2.1 (ecPublicKey) + * OBJECTIDENTIFIER 1.2.840.10045.3.1.7 (P-256) + * } + * BITSTRING <raw public key> + * } + * + * Luckily, to do that, we just need to prefix it with constant 26 bytes (metadata is + * constant). + */ + buffer = Buffer.concat([ + Buffer.from('3059301306072a8648ce3d020106082a8648ce3d030107034200', 'hex'), + buffer, + ]); + + type = 'PUBLIC KEY'; + } else { + type = 'CERTIFICATE'; + } + + const b64cert = buffer.toString('base64'); + + let PEMKey = ''; + for (let i = 0; i < Math.ceil(b64cert.length / 64); i += 1) { + const start = 64 * i; + + PEMKey += `${b64cert.substr(start, 64)}\n`; + } + + PEMKey = `-----BEGIN ${type}-----\n${PEMKey}-----END ${type}-----\n`; + + return PEMKey; +} diff --git a/packages/server/src/helpers/convertCOSEtoPKCS.ts b/packages/server/src/helpers/convertCOSEtoPKCS.ts new file mode 100644 index 0000000..b7784d4 --- /dev/null +++ b/packages/server/src/helpers/convertCOSEtoPKCS.ts @@ -0,0 +1,49 @@ +import cbor from 'cbor'; + +import { COSEKEYS, COSEPublicKey } from '@libTypes'; + +/** + * Takes COSE-encoded public key and converts it to PKCS key + * + * @param cosePublicKey COSE-encoded public key + * @return RAW PKCS encoded public key + */ +export default function convertCOSEtoPKCS(cosePublicKey: Buffer | COSEPublicKey) { + /* + +------+-------+-------+---------+----------------------------------+ + | name | key | label | type | description | + | | type | | | | + +------+-------+-------+---------+----------------------------------+ + | crv | 2 | -1 | int / | EC Curve identifier - Taken from | + | | | | tstr | the COSE Curves registry | + | | | | | | + | x | 2 | -2 | bstr | X Coordinate | + | | | | | | + | y | 2 | -3 | bstr / | Y Coordinate | + | | | | bool | | + | | | | | | + | d | 2 | -4 | bstr | Private key | + +------+-------+-------+---------+----------------------------------+ + */ + let struct: COSEPublicKey; + if (cosePublicKey instanceof Buffer) { + struct = cbor.decodeFirstSync(cosePublicKey); + } else { + struct = cosePublicKey; + } + + const tag = Buffer.from([0x04]); + const x = struct.get(COSEKEYS.x); + const y = struct.get(COSEKEYS.y); + + + if (!x) { + throw new Error('COSE public key was missing x'); + } + + if (!y) { + throw new Error('COSE public key was missing y'); + } + + return Buffer.concat([tag, (x as Buffer), (y as Buffer)]); +} diff --git a/packages/server/src/helpers/decodeAttestationObject.test.ts b/packages/server/src/helpers/decodeAttestationObject.test.ts new file mode 100644 index 0000000..d36201e --- /dev/null +++ b/packages/server/src/helpers/decodeAttestationObject.test.ts @@ -0,0 +1,39 @@ +import decodeAttestationObject from './decodeAttestationObject'; + +test('should decode base64-encoded indirect attestationObject', () => { + const decoded = decodeAttestationObject( + 'o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YVjEAbElFazplpnc037DORGDZNjDq86cN9vm6' + + '+APoAM20wtBAAAAAAAAAAAAAAAAAAAAAAAAAAAAQKmPuEwByQJ3e89TccUSrCGDkNWquhevjLLn/' + + 'KNZZaxQQ0steueoG2g12dvnUNbiso8kVJDyLa+6UiA34eniujWlAQIDJiABIVggiUk8wN2j' + + '+3fkKI7KSiLBkKzs3FfhPZxHgHPnGLvOY/YiWCBv7+XyTqArnMVtQ947/8Xk8fnVCdLMRWJGM1VbNevVcQ==' + ); + + expect(decoded.fmt).toEqual('none'); + expect(decoded.attStmt).toEqual({}); + expect(decoded.authData).toBeDefined(); +}); + +test('should decode base64-encoded direct attestationObject', () => { + const decoded = decodeAttestationObject( + 'o2NmbXRoZmlkby11MmZnYXR0U3RtdKJjc2lnWEgwRgIhAK40WxA0t7py7AjEXvwGwTlmqlvrOk' + + 's5g9lf+9zXzRiVAiEA3bv60xyXveKDOusYzniD7CDSostCet9PYK7FLdnTdZNjeDVjgVkCwTCCAr0wggGloAMCAQICBCrn' + + 'YmMwDQYJKoZIhvcNAQELBQAwLjEsMCoGA1UEAxMjWXViaWNvIFUyRiBSb290IENBIFNlcmlhbCA0NTcyMDA2MzEwIBcNMT' + + 'QwODAxMDAwMDAwWhgPMjA1MDA5MDQwMDAwMDBaMG4xCzAJBgNVBAYTAlNFMRIwEAYDVQQKDAlZdWJpY28gQUIxIjAgBgNV' + + 'BAsMGUF1dGhlbnRpY2F0b3IgQXR0ZXN0YXRpb24xJzAlBgNVBAMMHll1YmljbyBVMkYgRUUgU2VyaWFsIDcxOTgwNzA3NT' + + 'BZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABCoDhl5gQ9meEf8QqiVUV4S/Ca+Oax47MhcpIW9VEhqM2RDTmd3HaL3+SnvH' + + '49q8YubSRp/1Z1uP+okMynSGnj+jbDBqMCIGCSsGAQQBgsQKAgQVMS4zLjYuMS40LjEuNDE0ODIuMS4xMBMGCysGAQQBgu' + + 'UcAgEBBAQDAgQwMCEGCysGAQQBguUcAQEEBBIEEG1Eupv27C5JuTAMj+kgy3MwDAYDVR0TAQH/BAIwADANBgkqhkiG9w0B' + + 'AQsFAAOCAQEAclfQPNzD4RVphJDW+A75W1MHI3PZ5kcyYysR3Nx3iuxr1ZJtB+F7nFQweI3jL05HtFh2/4xVIgKb6Th4eV' + + 'cjMecncBaCinEbOcdP1sEli9Hk2eVm1XB5A0faUjXAPw/+QLFCjgXG6ReZ5HVUcWkB7riLsFeJNYitiKrTDXFPLy+sNtVN' + + 'utcQnFsCerDKuM81TvEAigkIbKCGlq8M/NvBg5j83wIxbCYiyV7mIr3RwApHieShzLdJo1S6XydgQjC+/64G5r8C+8AVvN' + + 'FR3zXXCpio5C3KRIj88HEEIYjf6h1fdLfqeIsq+cUUqbq5T+c4nNoZUZCysTB9v5EY4akp+GhhdXRoRGF0YVjEAbElFazp' + + 'lpnc037DORGDZNjDq86cN9vm6+APoAM20wtBAAAAAAAAAAAAAAAAAAAAAAAAAAAAQGFYevaR71ptU5YtXOSnVzPQTsGgK+' + + 'gLiBKnqPWBmZXNRvjISqlLxiwApzlrfkTc3lEMYMatjeACCnsijOkNEGOlAQIDJiABIVggdWLG6UvGyHFw/k/bv6/k6z/L' + + 'LgSO5KXzXw2EcUxkEX8iWCBeaVLz/cbyoKvRIg/q+q7tan0VN+i3WR0BOBCcuNP7yw==' + ); + + expect(decoded.fmt).toEqual('fido-u2f'); + expect(decoded.attStmt.sig).toBeDefined(); + expect(decoded.attStmt.x5c).toBeDefined(); + expect(decoded.authData).toBeDefined(); +}); diff --git a/packages/server/src/helpers/decodeAttestationObject.ts b/packages/server/src/helpers/decodeAttestationObject.ts new file mode 100644 index 0000000..224734e --- /dev/null +++ b/packages/server/src/helpers/decodeAttestationObject.ts @@ -0,0 +1,17 @@ +import base64url from 'base64url'; +import cbor from 'cbor'; + +import { AttestationObject } from '@libTypes'; + +/** + * Convert an AttestationObject from base64 string to a proper object + * + * @param base64AttestationObject Base64-encoded Attestation Object + */ +export default function decodeAttestationObject( + base64AttestationObject: string, +): AttestationObject { + const toBuffer = base64url.toBuffer(base64AttestationObject); + const toCBOR: AttestationObject = cbor.decodeAllSync(toBuffer)[0]; + return toCBOR; +} diff --git a/packages/server/src/helpers/decodeClientDataJSON.test.ts b/packages/server/src/helpers/decodeClientDataJSON.test.ts new file mode 100644 index 0000000..b9868f8 --- /dev/null +++ b/packages/server/src/helpers/decodeClientDataJSON.test.ts @@ -0,0 +1,16 @@ +import decodeClientDataJSON from './decodeClientDataJSON'; + +test('should convert base64-encoded attestation clientDataJSON to JSON', () => { + expect( + decodeClientDataJSON( + 'eyJjaGFsbGVuZ2UiOiJVMmQ0TjNZME0wOU1jbGRQYjFSNVpFeG5UbG95IiwiY2xpZW50RXh0ZW5zaW9ucyI6e30' + + 'sImhhc2hBbGdvcml0aG0iOiJTSEEtMjU2Iiwib3JpZ2luIjoiaHR0cHM6Ly9jbG92ZXIubWlsbGVydGltZS5kZX' + + 'Y6MzAwMCIsInR5cGUiOiJ3ZWJhdXRobi5jcmVhdGUifQ==' + )).toEqual({ + challenge: 'U2d4N3Y0M09McldPb1R5ZExnTloy', + clientExtensions: {}, + hashAlgorithm: 'SHA-256', + origin: 'https://clover.millertime.dev:3000', + type: 'webauthn.create' + }); +}); diff --git a/packages/server/src/helpers/decodeClientDataJSON.ts b/packages/server/src/helpers/decodeClientDataJSON.ts new file mode 100644 index 0000000..7aae023 --- /dev/null +++ b/packages/server/src/helpers/decodeClientDataJSON.ts @@ -0,0 +1,11 @@ +import { ClientDataJSON } from '@libTypes'; + +import asciiToBinary from './asciiToBinary'; + +/** + * Decode an authenticator's base64-encoded clientDataJSON to JSON + */ +export default function decodeClientDataJSON(data: string): ClientDataJSON { + const toString = asciiToBinary(data); + return JSON.parse(toString); +} diff --git a/packages/server/src/helpers/getCertificateInfo.ts b/packages/server/src/helpers/getCertificateInfo.ts new file mode 100644 index 0000000..1779bb3 --- /dev/null +++ b/packages/server/src/helpers/getCertificateInfo.ts @@ -0,0 +1,31 @@ +import jsrsasign from 'jsrsasign'; + +import { CertificateInfo } from '@libTypes'; + +/** + * Extract PEM certificate info + * + * @param pemCertificate Result from call to `convertASN1toPEM(x5c[0])` + */ +export default function getCertificateInfo(pemCertificate: string): CertificateInfo { + const subjectCert = new jsrsasign.X509(); + subjectCert.readCertPEM(pemCertificate); + + const subjectString = subjectCert.getSubjectString(); + const subjectParts = subjectString.slice(1).split('/'); + + const subject: { [key: string]: string } = {}; + subjectParts.forEach((field) => { + const [key, val] = field.split('='); + subject[key] = val; + }); + + const { getVersion } = subjectCert; + const basicConstraintsCA = !!subjectCert.getExtBasicConstraints().cA; + + return { + subject, + version: getVersion(), + basicConstraintsCA, + }; +} diff --git a/packages/server/src/helpers/toHash.ts b/packages/server/src/helpers/toHash.ts new file mode 100644 index 0000000..6e8db1d --- /dev/null +++ b/packages/server/src/helpers/toHash.ts @@ -0,0 +1,10 @@ +import crypto from 'crypto'; + +/** + * Returns hash digest of the given data using the given algorithm. + * @param data Data to hash + * @return The hash + */ +export default function toHash(data: Buffer, algo: string = 'SHA256'): Buffer { + return crypto.createHash(algo).update(data).digest(); +} diff --git a/packages/server/src/helpers/validateCertificatePath.ts b/packages/server/src/helpers/validateCertificatePath.ts new file mode 100644 index 0000000..685ddd8 --- /dev/null +++ b/packages/server/src/helpers/validateCertificatePath.ts @@ -0,0 +1,55 @@ +export default function validateCertificatePath(certificates: any[]) { + console.log('certificates', certificates); + return false; + // TODO: Re-investigate this if we decide to "use MDS or Metadata Statements" + // console.debug('validating certificate path'); + + // const uniqueCerts = new Set(certificates); + + // if (uniqueCerts.size !== certificates.length) { + // throw new Error('Certificate path could not be verified due to duplicate certificates'); + // } + + // certificates.forEach((subjectPEM, index) => { + // const subjectCert = new jsrsasign.X509(); + // subjectCert.readCertPEM(subjectPEM); + + // let issuerPEM; + // if (index + 1 >= certificates.length) { + // console.debug('using subjectPEM as issuerPEM'); + // issuerPEM = subjectPEM; + // } else { + // console.debug('using next cert as issuerPEM'); + // issuerPEM = certificates[index + 1]; + // } + + // const issuerCert = new jsrsasign.X509(); + // issuerCert.readCertPEM(issuerPEM); + + // const subjectCertString = subjectCert.getSubjectString(); + // const issuerCertString = issuerCert.getSubjectString(); + // if (subjectCertString !== issuerCertString) { + // console.error('subject strings didn\'t match'); + // console.debug('subjectCertString:', subjectCertString); + // console.debug('issuerCertString:', issuerCertString); + // throw new Error('Certificate issuers didn\'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); + + // const sigVerified = Signature.verify(signatureHex); + // if (!sigVerified) { + // console.error('failed to validate certificate path'); + // console.debug('sigVerified:', sigVerified); + // throw new Error('Certificate path could not be validated'); + // } + // }); + + // return true; +} diff --git a/packages/server/src/helpers/verifySignature.ts b/packages/server/src/helpers/verifySignature.ts new file mode 100644 index 0000000..c938a23 --- /dev/null +++ b/packages/server/src/helpers/verifySignature.ts @@ -0,0 +1,18 @@ +import crypto from 'crypto'; + +/** + * Verify an authenticator's signature + * + * @param signature attStmt.sig + * @param signatureBase Output from Buffer.concat() + * @param publicKey Authenticator's public key as a PEM certificate + */ +export default function verifySignature( + signature: Buffer, + signatureBase: Buffer, + publicKey: string, +): boolean { + return crypto.createVerify('SHA256') + .update(signatureBase) + .verify(publicKey, signature); +} diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts new file mode 100644 index 0000000..356ec2e --- /dev/null +++ b/packages/server/src/index.ts @@ -0,0 +1,19 @@ +import generateAttestationCredentials from './attestation/generateAttestationCredentials'; +import verifyAttestationResponse from './attestation/verifyAttestationResponse'; +import generateAssertionCredentials from './assertion/generateAssertionCredentials'; +import verifyAssertionResponse from './assertion/verifyAssertionResponse'; + +export { + generateAssertionCredentials, + verifyAttestationResponse, + generateAttestationCredentials, + verifyAssertionResponse, +}; + +export { + EncodedAuthenticatorAssertionResponse, + EncodedAuthenticatorAttestationResponse, + VerifiedAttestation, + VerifiedAssertion, + AuthenticatorDevice, +} from './libTypes'; diff --git a/packages/server/src/libTypes.ts b/packages/server/src/libTypes.ts new file mode 100644 index 0000000..58cc90c --- /dev/null +++ b/packages/server/src/libTypes.ts @@ -0,0 +1,155 @@ +/** + * An object that can be passed into navigator.credentials.create(...) in the browser + */ +export type AttestationCredentials = { + publicKey: PublicKeyCredentialCreationOptions, +}; + +/** + * An object that can be passed into navigator.credentials.get(...) in the browser + */ +export type AssertionCredentials = { + publicKey: PublicKeyCredentialRequestOptions, +}; + +/** + * A slightly-modified AuthenticatorAttestationResponse to simplify working with ArrayBuffers that + * are base64-encoded in the browser so that they can be sent as JSON to the server. + */ +export interface EncodedAuthenticatorAttestationResponse extends Omit< +AuthenticatorAttestationResponse, 'clientDataJSON' | 'attestationObject' +> { + base64ClientDataJSON: string, + base64AttestationObject: string; +} + +/** + * A slightly-modified AuthenticatorAttestationResponse to simplify working with ArrayBuffers that + * are base64-encoded in the browser so that they can be sent as JSON to the server. + */ +export interface EncodedAuthenticatorAssertionResponse extends Omit< +AuthenticatorAssertionResponse, 'clientDataJSON' | 'authenticatorData' | 'signature' +> { + base64AuthenticatorData: string; + base64ClientDataJSON: string; + base64Signature: string; +} + +export enum ATTESTATION_FORMATS { + FIDO_U2F = 'fido-u2f', + PACKED = 'packed', + ANDROID_SAFETYNET = 'android-safetynet', + NONE = 'none', +} + +export type AttestationObject = { + fmt: ATTESTATION_FORMATS, + attStmt: { + sig?: Buffer, + x5c?: Buffer[], + ecdaaKeyId?: Buffer, + response?: Buffer, + }, + authData: Buffer, +}; + +export type ParsedAttestationAuthData = { + rpIdHash: Buffer, + flagsBuf: Buffer, + flags: { + up: boolean, + uv: boolean, + at: boolean, + ed: boolean, + flagsInt: number, + }, + counter: number, + counterBuf: Buffer, + aaguid?: Buffer, + credentialID?: Buffer, + COSEPublicKey?: Buffer, +}; + +export type ClientDataJSON = { + type: string, + challenge: string, + origin: string, +}; + +/** + * Result of attestation verification + */ +export type VerifiedAttestation = { + verified: boolean, + authenticatorInfo?: { + fmt: ATTESTATION_FORMATS, + counter: number, + base64PublicKey: string, + base64CredentialID: string, + }, +}; + +/** + * Result of assertion verification + */ +export type VerifiedAssertion = { + verified: boolean; +}; + +export type CertificateInfo = { + subject: { [key: string]: string }, + version: number, + basicConstraintsCA: boolean, +}; + +export enum COSEKEYS { + kty = 1, + alg = 3, + crv = -1, + x = -2, + y = -3, + n = -1, + e = -2, +} + +export type COSEPublicKey = Map<COSEAlgorithmIdentifier, number | Buffer>; + +export type SafetyNetJWTHeader = { + alg: 'string', + x5c: string[], +}; + +export type SafetyNetJWTPayload = { + nonce: string, + timestampMs: number, + apkPackageName: string, + apkDigestSha256: string, + ctsProfileMatch: boolean, + apkCertificateDigestSha256: string[], + basicIntegrity: boolean, +}; + +export type SafetyNetJWTSignature = string; + +export type ParsedAssertionAuthData = { + rpIdHash: Buffer, + flagsBuf: Buffer, + flags: number, + counter: number, + counterBuf: Buffer, +}; + +/** + * U2F Presence constant + */ +export const U2F_USER_PRESENTED = 0x01; + +/** + * A WebAuthn-compatible device and the information needed to verify assertions by it + */ +export type AuthenticatorDevice = { + base64PublicKey: string, + base64CredentialID: string, + // Number of times this device is expected to have been used + counter: number, +}; diff --git a/packages/server/src/setupTests.ts b/packages/server/src/setupTests.ts new file mode 100644 index 0000000..4cf23af --- /dev/null +++ b/packages/server/src/setupTests.ts @@ -0,0 +1,3 @@ +// Silence some console output +jest.spyOn(console, 'log').mockImplementation(); +jest.spyOn(console, 'debug').mockImplementation(); |