diff options
Diffstat (limited to 'packages/server/src')
28 files changed, 1587 insertions, 0 deletions
diff --git a/packages/server/src/assertion/generateAssertionOptions.test.ts b/packages/server/src/assertion/generateAssertionOptions.test.ts new file mode 100644 index 0000000..54d10f9 --- /dev/null +++ b/packages/server/src/assertion/generateAssertionOptions.test.ts @@ -0,0 +1,45 @@ +import generateAssertionOptions from './generateAssertionOptions'; + +test('should generate credential request options suitable for sending via JSON', () => { + const challenge = 'totallyrandomvalue'; + + const options = generateAssertionOptions( + challenge, + [ + Buffer.from('1234', 'ascii').toString('base64'), + Buffer.from('5678', 'ascii').toString('base64'), + ], + 1, + ); + + expect(options).toEqual({ + publicKey: { + challenge, + allowCredentials: [ + { + id: 'MTIzNA==', + type: 'public-key', + transports: ['usb', 'ble', 'nfc'], + }, + { + id: 'NTY3OA==', + type: 'public-key', + transports: ['usb', 'ble', 'nfc'], + }, + ], + timeout: 1, + }, + }); +}); + +test('defaults to 60 seconds if no timeout is specified', () => { + const options = generateAssertionOptions( + 'totallyrandomvalue', + [ + Buffer.from('1234', 'ascii').toString('base64'), + Buffer.from('5678', 'ascii').toString('base64'), + ], + ); + + expect(options.publicKey.timeout).toEqual(60000); +}); diff --git a/packages/server/src/assertion/generateAssertionOptions.ts b/packages/server/src/assertion/generateAssertionOptions.ts new file mode 100644 index 0000000..e9693fa --- /dev/null +++ b/packages/server/src/assertion/generateAssertionOptions.ts @@ -0,0 +1,28 @@ +import { PublicKeyCredentialRequestOptionsJSON } from '@webauthntine/typescript-types'; + + +/** + * Prepare a value to pass into navigator.credentials.get(...) for authenticator "login" + * + * @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 generateAssertionOptions( + challenge: string, + base64CredentialIDs: string[], + timeout: number = 60000, +): PublicKeyCredentialRequestOptionsJSON { + return { + publicKey: { + challenge, + allowCredentials: base64CredentialIDs.map(id => ({ + 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..bdd636a --- /dev/null +++ b/packages/server/src/assertion/parseAssertionAuthData.ts @@ -0,0 +1,28 @@ +import { ParsedAssertionAuthData } from "@webauthntine/typescript-types"; + +/** + * 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..9e5b083 --- /dev/null +++ b/packages/server/src/assertion/verifyAssertionResponse.test.ts @@ -0,0 +1,111 @@ +import verifyAssertionResponse from './verifyAssertionResponse'; + +import * as decodeClientDataJSON from '../helpers/decodeClientDataJSON'; +import * as parseAssertionAuthData from './parseAssertionAuthData'; + +let mockDecodeClientData: jest.SpyInstance; +let mockParseAuthData: jest.SpyInstance; + +beforeEach(() => { + mockDecodeClientData = jest.spyOn(decodeClientDataJSON, 'default'); + mockParseAuthData = jest.spyOn(parseAssertionAuthData, 'default'); +}); + +afterEach(() => { + mockDecodeClientData.mockRestore(); + mockParseAuthData.mockRestore(); +}); + +test('should verify an assertion response', () => { + const verification = verifyAssertionResponse( + assertionResponse, + 'https://dev.dontneeda.pw', + authenticator, + ); + + expect(verification.verified).toEqual(true); +}); + +test('should throw when response origin is not expected value', () => { + expect(() => { + verifyAssertionResponse( + assertionResponse, + 'https://different.address', + authenticator, + ); + }).toThrow(); +}); + +test('should throw when assertion type is not webauthn.create', () => { + // @ts-ignore 2345 + mockDecodeClientData.mockReturnValue({ + origin: assertionOrigin, + type: 'webauthn.badtype', + }); + + expect(() => { + verifyAssertionResponse( + assertionResponse, + assertionOrigin, + authenticator, + ); + }).toThrow(); +}); + +test('should throw error if user was not present', () => { + mockParseAuthData.mockReturnValue({ + flags: 0, + }); + + expect(() => { + verifyAssertionResponse( + assertionResponse, + assertionOrigin, + authenticator, + ); + }).toThrow(); +}); + +test('should throw error if previous counter value is not less than in response', () => { + // This'll match the `counter` value in `assertionResponse`, simulating a potential replay attack + const badCounter = 135; + const badDevice = { + ...authenticator, + counter: badCounter, + }; + + expect(() => { + verifyAssertionResponse( + assertionResponse, + assertionOrigin, + badDevice, + ); + }).toThrow(); +}); + +/** + * parsed authData: { + * rpIdHash: <Buffer>, + * flagsBuf: <Buffer>, + * flags: 1, + * counter: 135, + * counterBuf: <Buffer> + * } + */ +const assertionResponse = { + base64AuthenticatorData: 'PdxHEOnAiLIp26idVjIguzn3Ipr_RlsKZWsa-5qK-KABAAAAhw', + base64ClientDataJSON: 'eyJjaGFsbGVuZ2UiOiJXRzVRU21RM1oyOTROR2gyTVROUk56WnViVmhMTlZZMWMwOHRP' + + 'V3BLVG5JIiwiY2xpZW50RXh0ZW5zaW9ucyI6e30sImhhc2hBbGdvcml0aG0iOiJTSEEtMjU2Iiwib3JpZ2luIjoi' + + 'aHR0cHM6Ly9kZXYuZG9udG5lZWRhLnB3IiwidHlwZSI6IndlYmF1dGhuLmdldCJ9', + base64Signature: 'MEQCIHZYFY3LsKzI0T9XRwEACl7YsYZysZ2HUw3q9f7tlq3wAiBNbyBbQMNM56P6Z00tBEZ6v' + + 'II4f9Al-p4pZw7OBpSaog', +}; +const assertionOrigin = 'https://dev.dontneeda.pw'; + +const authenticator = { + base64PublicKey: 'BBMQEnZRfg4ASys9kfGUj99Xlsa028wqYJZw8xuGahPQJWN3K9D9DajLxzKlY7uf_ulA5D6gh' + + 'UJ9hrouDX84S_I', + base64CredentialID: 'wJZRtQbYjKlpiRnzet7yyVizdsj_oUhi11kFbKyO0hc5gIg-4xeaTC9YC9y9sfow6gO3jE' + + 'MoONBKNX4SmSclmQ', + counter: 134, +}; diff --git a/packages/server/src/assertion/verifyAssertionResponse.ts b/packages/server/src/assertion/verifyAssertionResponse.ts new file mode 100644 index 0000000..fb668f4 --- /dev/null +++ b/packages/server/src/assertion/verifyAssertionResponse.ts @@ -0,0 +1,82 @@ +import base64url from 'base64url'; +import { + AuthenticatorAssertionResponseJSON, + U2F_USER_PRESENTED, + AuthenticatorDevice, + VerifiedAssertion, +} from "@webauthntine/typescript-types"; + +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: AuthenticatorAssertionResponseJSON, + expectedOrigin: string, + authenticator: AuthenticatorDevice, +): VerifiedAssertion { + const { base64AuthenticatorData, base64ClientDataJSON, base64Signature } = response; + const clientDataJSON = decodeClientDataJSON(base64ClientDataJSON); + + const { type, origin } = clientDataJSON; + + // Check that the origin is our site + if (origin !== expectedOrigin) { + throw new Error(`Unexpected assertion origin: ${origin}`); + } + + // Make sure we're handling an assertion + if (type !== 'webauthn.get') { + throw new Error(`Unexpected assertion type: ${type}`); + } + + const authDataBuffer = base64url.toBuffer(base64AuthenticatorData); + const authData = parseAssertionAuthData(authDataBuffer); + + if (!(authData.flags & U2F_USER_PRESENTED)) { + throw new Error('User was NOT present during assertion!'); + } + + const { + rpIdHash, + flagsBuf, + counterBuf, + counter, + } = authData; + + 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( + `Response counter value ${counter} was lower than expected ${authenticator.counter}`, + ); + } + + 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), + }; + + return toReturn; +} diff --git a/packages/server/src/attestation/generateAttestationOptions.test.ts b/packages/server/src/attestation/generateAttestationOptions.test.ts new file mode 100644 index 0000000..4c6e605 --- /dev/null +++ b/packages/server/src/attestation/generateAttestationOptions.test.ts @@ -0,0 +1,66 @@ +import generateAttestationOptions from './generateAttestationOptions'; + +test('should generate credential request options suitable for sending via JSON', () => { + const serviceName = 'WebAuthntine'; + const rpID = 'not.real'; + const challenge = 'totallyrandomvalue'; + const userID = '1234'; + const username = 'usernameHere'; + const timeout = 1; + const attestationType = 'indirect'; + + const options = generateAttestationOptions( + serviceName, + rpID, + challenge, + userID, + username, + timeout, + attestationType, + ); + + expect(options).toEqual({ + publicKey: { + challenge, + rp: { + name: serviceName, + id: rpID, + }, + user: { + id: userID, + name: username, + displayName: username, + }, + pubKeyCredParams: [{ + alg: -7, + type: 'public-key', + }], + timeout, + attestation: attestationType, + }, + }); +}); + +test('defaults to 60 seconds if no timeout is specified', () => { + const options = generateAttestationOptions( + 'WebAuthntine', + 'not.real', + 'totallyrandomvalue', + '1234', + 'usernameHere', + ); + + expect(options.publicKey.timeout).toEqual(60000); +}); + +test('defaults to direct attestation if no attestation type is specified', () => { + const options = generateAttestationOptions( + 'WebAuthntine', + 'not.real', + 'totallyrandomvalue', + '1234', + 'usernameHere', + ); + + expect(options.publicKey.attestation).toEqual('direct'); +}); diff --git a/packages/server/src/attestation/generateAttestationOptions.ts b/packages/server/src/attestation/generateAttestationOptions.ts new file mode 100644 index 0000000..68cac94 --- /dev/null +++ b/packages/server/src/attestation/generateAttestationOptions.ts @@ -0,0 +1,46 @@ +import { PublicKeyCredentialCreationOptionsJSON } from '@webauthntine/typescript-types'; + + +/** + * Prepare a value to pass into navigator.credentials.create(...) for authenticator "registration" + * + * @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 generateAttestationOptions( + serviceName: string, + rpID: string, + challenge: string, + userID: string, + username: string, + timeout: number = 60000, + attestationType: 'direct' | 'indirect' = 'direct', +): PublicKeyCredentialCreationOptionsJSON { + return { + publicKey: { + // Cryptographically random bytes to prevent replay attacks + challenge, + // The organization registering and authenticating the user + rp: { + name: serviceName, + id: rpID, + }, + user: { + id: userID, + 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..b51af5f --- /dev/null +++ b/packages/server/src/attestation/parseAttestationAuthData.ts @@ -0,0 +1,59 @@ +import { ParsedAttestationAuthData } from "@webauthntine/typescript-types"; + +/** + * Make sense of the authData buffer contained in an Attestation + */ +export default function parseAttestationAuthData(authData: Buffer): ParsedAttestationAuthData { + 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, + }; + + 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..6f5365a --- /dev/null +++ b/packages/server/src/attestation/verifications/verifyAndroidSafetyNet.ts @@ -0,0 +1,155 @@ +import base64url from 'base64url'; +import { + AttestationObject, + VerifiedAttestation, + SafetyNetJWTHeader, + SafetyNetJWTPayload, + SafetyNetJWTSignature, +} from "@webauthntine/typescript-types"; + +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 (SafetyNet)'); + } + + // 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]; + + /** + * 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) { + throw new Error('Could not verify payload nonce (SafetyNet)'); + } + + if (!ctsProfileMatch) { + throw new Error('Could not verify device integrity (SafetyNet)'); + } + /** + * 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-----`; + }); + + const certificate = fullpathCert[0]; + + const commonCertInfo = getCertificateInfo(certificate); + + const { subject } = commonCertInfo; + + // TODO: Find out where this CN string is specified and if it might change + if (subject.CN !== 'attest.android.com') { + throw new Error('Certificate common name was not "attest.android.com" (SafetyNet)'); + } + + // 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), + userVerified: false, + }; + /** + * END Verify Signature + */ + + + if (toReturn.verified) { + const authDataStruct = parseAttestationAuthData(authData); + const { counter, credentialID, COSEPublicKey, flags } = authDataStruct; + + toReturn.userVerified = flags.uv; + + if (!COSEPublicKey) { + throw new Error('No public key was provided by authenticator (SafetyNet)'); + } + + if (!credentialID) { + throw new Error('No credential ID was provided by authenticator (SafetyNet)'); + } + + 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..75e664f --- /dev/null +++ b/packages/server/src/attestation/verifications/verifyFIDOU2F.ts @@ -0,0 +1,81 @@ +import base64url from 'base64url'; +import { AttestationObject, VerifiedAttestation, U2F_USER_PRESENTED } from '@webauthntine/typescript-types'; + +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 (FIDOU2F)'); + } + + if (!COSEPublicKey) { + throw new Error('No public key was provided by authenticator (FIDOU2F)'); + } + + if (!credentialID) { + throw new Error('No credential ID was provided by authenticator (FIDOU2F)'); + } + + 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 (FIDOU2F)'); + } + + if (!sig) { + throw new Error('No attestation signature provided in attestation statement (FIDOU2F)'); + } + + const publicKeyCertPEM = convertASN1toPEM(x5c[0]); + + const toReturn: VerifiedAttestation = { + verified: verifySignature(sig, signatureBase, publicKeyCertPEM), + userVerified: flags.uv, + }; + + 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..4f967d1 --- /dev/null +++ b/packages/server/src/attestation/verifications/verifyNone.ts @@ -0,0 +1,54 @@ +import base64url from 'base64url'; +import { AttestationObject, VerifiedAttestation } from "@webauthntine/typescript-types"; + +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); + + const { + credentialID, + COSEPublicKey, + counter, + flags, + } = authDataStruct; + + if (!COSEPublicKey) { + throw new Error('No public key was provided by authenticator (None)'); + } + + if (!credentialID) { + throw new Error('No credential ID was provided by authenticator (None)'); + } + + // Make sure the (U)ser (P)resent for the attestation + if (!flags.up) { + throw new Error('User was not present for attestation (None)'); + } + + const publicKey = convertCOSEtoPKCS(COSEPublicKey); + + const toReturn: VerifiedAttestation = { + verified: true, + userVerified: flags.uv, + 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..98b4e66 --- /dev/null +++ b/packages/server/src/attestation/verifications/verifyPacked.ts @@ -0,0 +1,212 @@ +import base64url from 'base64url'; +import cbor from 'cbor'; +import elliptic from 'elliptic'; +import NodeRSA, { SigningSchemeHash } from 'node-rsa'; +import { AttestationObject, VerifiedAttestation, COSEKEYS, COSEPublicKey } from "@webauthntine/typescript-types"; + +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, flags } = authDataStruct; + + if (!COSEPublicKey) { + throw new Error('No public key was provided by authenticator (Packed)'); + } + + if (!credentialID) { + throw new Error('No credential ID was provided by authenticator (Packed)'); + } + + if (!sig) { + throw new Error('No attestation signature provided in attestation statement (Packed)'); + } + + const clientDataHash = toHash(base64url.toBuffer(base64ClientDataJSON)); + + const signatureBase = Buffer.concat([ + authData, + clientDataHash, + ]); + + const toReturn: VerifiedAttestation = { + verified: false, + userVerified: flags.uv, + }; + const publicKey = convertCOSEtoPKCS(COSEPublicKey); + + if (x5c) { + 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"! (Packed|Full'); + } + + if (!CN) { + throw new Error('Batch certificate CN MUST no be empty! (Packed|Full'); + } + + if (!O) { + throw new Error('Batch certificate CN MUST no be empty! (Packed|Full'); + } + + if (!C || C.length !== 2) { + throw new Error('Batch certificate C MUST be set to two character ISO 3166 code! (Packed|Full'); + } + + if (basicConstraintsCA) { + throw new Error('Batch certificate basic constraints CA MUST be false! (Packed|Full'); + } + + if (version !== 3) { + throw new Error('Batch certificate version MUST be 3(ASN1 2)! (Packed|Full'); + } + + toReturn.verified = verifySignature(sig, signatureBase, leafCert); + } else if (ecdaaKeyId) { + throw new Error('ECDAA not supported yet (Packed|ECDAA)'); + } else { + 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 (Packed|Self)'); + } + + if (!kty) { + throw new Error('COSE public key was missing kty (Packed|Self)'); + } + + const hashAlg: string = COSEALGHASH[(alg as number)]; + + if (kty === COSEKTY.EC2) { + const crv = cosePublicKey.get(COSEKEYS.crv); + + if (!crv) { + throw new Error('COSE public key was missing kty crv (Packed|EC2)'); + } + + const pkcsPublicKey = convertCOSEtoPKCS(COSEPublicKey); + const signatureBaseHash = toHash(signatureBase, hashAlg); + + /** + * Instantiating the curve here is _very_ computationally heavy - a bit of profiling + * (in compiled JS, not TS) reported an average of ~125ms to execute this line. The elliptic + * README states, "better do it once and reuse it", so maybe there's a better way to handle + * this in a server context, when we can re-use an existing instance. + * + * For now, it's worth noting that this line is probably the reason why it can take + * 5-6 seconds to run tests. + */ + 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) { + const n = cosePublicKey.get(COSEKEYS.n); + + if (!n) { + throw new Error('COSE public key was missing n (Packed|RSA)'); + } + + 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) { + const x = cosePublicKey.get(COSEKEYS.x); + + if (!x) { + throw new Error('COSE public key was missing x (Packed|OKP)'); + } + + 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..2b314cf --- /dev/null +++ b/packages/server/src/attestation/verifyAttestationResponse.test.ts @@ -0,0 +1,260 @@ +import verifyAttestationResponse from './verifyAttestationResponse'; + +import * as decodeAttestationObject from '../helpers/decodeAttestationObject'; +import * as decodeClientDataJSON from '../helpers/decodeClientDataJSON'; + +let mockDecodeAttestation: jest.SpyInstance; +let mockDecodeClientData: jest.SpyInstance; + +beforeEach(() => { + mockDecodeAttestation = jest.spyOn(decodeAttestationObject, 'default'); + mockDecodeClientData = jest.spyOn(decodeClientDataJSON, 'default'); +}); + +afterEach(() => { + mockDecodeAttestation.mockRestore(); + mockDecodeClientData.mockRestore(); +}); + +test('should verify FIDO U2F attestation', () => { + const verification = verifyAttestationResponse( + attestationFIDOU2F, + '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( + attestationPacked, + '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( + attestationNone, + '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( + attestationAndroidSafetyNet, + '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', + ); +}); + +test('should throw when response origin is not expected value', () => { + expect(() => { + verifyAttestationResponse( + attestationNone, + 'https://different.address' + ); + }).toThrow(); +}); + +test('should throw when attestation type is not webauthn.create', () => { + const origin = 'https://dev.dontneeda.pw'; + + // @ts-ignore 2345 + mockDecodeClientData.mockReturnValue({ origin, type: 'webauthn.badtype' }); + + expect(() => { + verifyAttestationResponse( + attestationNone, + origin, + ); + }).toThrow(); +}); + +test('should throw if an unexpected attestation format is specified', () => { + const fmt = 'fizzbuzz'; + + mockDecodeAttestation.mockReturnValue({ + // @ts-ignore 2322 + fmt, + }); + + expect(() => { + verifyAttestationResponse( + attestationNone, + 'https://dev.dontneeda.pw', + ); + }).toThrow(); +}); + +const attestationFIDOU2F = { + 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==', +}; + +const attestationPacked = { + base64AttestationObject: 'o2NmbXRmcGFja2VkZ2F0dFN0bXSiY2FsZyZjc2lnWEcwRQIhANvrPZMUFrl_rvlgR' + + 'qz6lCPlF6B4y885FYUCCrhrzAYXAiAb4dQKXbP3IimsTTadkwXQlrRVdxzlbmPXt847-Oh6r2hhdXRoRGF0YVjhP' + + 'dxHEOnAiLIp26idVjIguzn3Ipr_RlsKZWsa-5qK-KBFXsOO-a3OAAI1vMYKZIsLJfHwVQMAXQGE4WNXLCDWOCa2x' + + '8hpqk5dZy_xdc4wBd4UgCJ4M_JAHI7oJgDDVb8WUcKqRB_mzRxwCL9vdTl-ZKPXg3_-Zrt1Adgb7EnK9ivqaTOKM' + + 'DqRrKsIObWYJaqpsSJtUKUBAgMmIAEhWCBKMVVaivqCBpqqAxMjuCo5jMeUdh3jDOC0EF4fLBNNTyJYILc7rqDDe' + + 'X1pwCLrl3ZX7IThrtZNwKQVLQyfHiorqP-n', + base64ClientDataJSON: 'eyJjaGFsbGVuZ2UiOiJjelpRU1dKQ2JsQlFibkpIVGxOQ2VFNWtkRVJ5VkRkVmNsWlpT' + + 'a3M1U0UwIiwib3JpZ2luIjoiaHR0cHM6Ly9kZXYuZG9udG5lZWRhLnB3IiwidHlwZSI6IndlYmF1dGhuLmNyZWF0' + + 'ZSJ9', +}; + +const attestationNone = { + base64AttestationObject: 'o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YVjFPdxHEOnAiLIp26idVjIguzn3I' + + 'pr_RlsKZWsa-5qK-KBFAAAAAAAAAAAAAAAAAAAAAAAAAAAAQQHSlyRHIdWleVqO24-6ix7JFWODqDWo_arvEz3Se' + + '5EgIFHkcVjZ4F5XDSBreIHsWRilRnKmaaqlqK3V2_4XtYs2pQECAyYgASFYID5PQTZQQg6haZFQWFzqfAOyQ_ENs' + + 'MH8xxQ4GRiNPsqrIlggU8IVUOV8qpgk_Jh-OTaLuZL52KdX1fTht07X4DiQPow', + base64ClientDataJSON: 'eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmdlIjoiYUVWalkxQlhkWHBw' + + 'VURBd1NEQndOV2Q0YURKZmRUVmZVRU0wVG1WWloyUSIsIm9yaWdpbiI6Imh0dHBzOlwvXC9kZXYuZG9udG5lZWRh' + + 'LnB3IiwiYW5kcm9pZFBhY2thZ2VOYW1lIjoib3JnLm1vemlsbGEuZmlyZWZveCJ9' +}; + +const attestationAndroidSafetyNet = { + 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' +}; diff --git a/packages/server/src/attestation/verifyAttestationResponse.ts b/packages/server/src/attestation/verifyAttestationResponse.ts new file mode 100644 index 0000000..f302771 --- /dev/null +++ b/packages/server/src/attestation/verifyAttestationResponse.ts @@ -0,0 +1,58 @@ +import decodeAttestationObject from '@helpers/decodeAttestationObject'; +import decodeClientDataJSON from '@helpers/decodeClientDataJSON'; +import { ATTESTATION_FORMATS, AuthenticatorAttestationResponseJSON, VerifiedAttestation } from '@webauthntine/typescript-types'; + +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: AuthenticatorAttestationResponseJSON, + expectedOrigin: string, +): VerifiedAttestation { + const { base64AttestationObject, base64ClientDataJSON } = response; + const attestationObject = decodeAttestationObject(base64AttestationObject); + const clientDataJSON = decodeClientDataJSON(base64ClientDataJSON); + + const { type, origin } = clientDataJSON; + + // Check that the origin is our site + if (origin !== expectedOrigin) { + throw new Error(`Unexpected attestation origin: ${origin}`); + } + + // Make sure we're handling an attestation + if (type !== 'webauthn.create') { + throw new Error(`Unexpected attestation type: ${type}`); + } + + const { fmt } = attestationObject; + + /** + * Verification can only be performed when attestation = 'direct' + */ + if (fmt === ATTESTATION_FORMATS.FIDO_U2F) { + return verifyFIDOU2F(attestationObject, base64ClientDataJSON); + } + + if (fmt === ATTESTATION_FORMATS.PACKED) { + return verifyPacked(attestationObject, base64ClientDataJSON); + } + + if (fmt === ATTESTATION_FORMATS.ANDROID_SAFETYNET) { + return verifyAndroidSafetynet(attestationObject, base64ClientDataJSON); + } + + if (fmt === ATTESTATION_FORMATS.NONE) { + return verifyNone(attestationObject); + } + + throw new Error(`Unsupported Attestation Format: ${fmt}`); +} 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.test.ts b/packages/server/src/helpers/convertCOSEtoPKCS.test.ts new file mode 100644 index 0000000..1d0ad6e --- /dev/null +++ b/packages/server/src/helpers/convertCOSEtoPKCS.test.ts @@ -0,0 +1,29 @@ +import cbor from 'cbor'; +import { COSEKEYS } from '@webauthntine/typescript-types'; + +import convertCOSEtoPKCS from './convertCOSEtoPKCS'; + + +test('should throw an error curve if, somehow, curve coordinate x is missing', () => { + const mockCOSEKey = new Map<number, number | Buffer>(); + + mockCOSEKey.set(COSEKEYS.y, 1); + + jest.spyOn(cbor, 'decodeFirstSync').mockReturnValue(mockCOSEKey); + + expect(() => { + convertCOSEtoPKCS(Buffer.from('123', 'ascii')); + }).toThrow(); +}); + +test('should throw an error curve if, somehow, curve coordinate y is missing', () => { + const mockCOSEKey = new Map<number, number | Buffer>(); + + mockCOSEKey.set(COSEKEYS.x, 1); + + jest.spyOn(cbor, 'decodeFirstSync').mockReturnValue(mockCOSEKey); + + expect(() => { + convertCOSEtoPKCS(Buffer.from('123', 'ascii')); + }).toThrow(); +}); diff --git a/packages/server/src/helpers/convertCOSEtoPKCS.ts b/packages/server/src/helpers/convertCOSEtoPKCS.ts new file mode 100644 index 0000000..fbafd59 --- /dev/null +++ b/packages/server/src/helpers/convertCOSEtoPKCS.ts @@ -0,0 +1,43 @@ +import cbor from 'cbor'; +import { COSEKEYS, COSEPublicKey } from '@webauthntine/typescript-types'; + + +/** + * 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) { + /* + +------+-------+-------+---------+----------------------------------+ + | 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 | + +------+-------+-------+---------+----------------------------------+ + */ + const struct: COSEPublicKey = cbor.decodeFirstSync(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..fa39454 --- /dev/null +++ b/packages/server/src/helpers/decodeAttestationObject.ts @@ -0,0 +1,17 @@ +import base64url from 'base64url'; +import cbor from 'cbor'; +import { AttestationObject } from '@webauthntine/typescript-types'; + + +/** + * 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..1aeb9c9 --- /dev/null +++ b/packages/server/src/helpers/decodeClientDataJSON.ts @@ -0,0 +1,11 @@ +import { ClientDataJSON } from '@webauthntine/typescript-types'; + +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..4238bc2 --- /dev/null +++ b/packages/server/src/helpers/getCertificateInfo.ts @@ -0,0 +1,31 @@ +import jsrsasign from 'jsrsasign'; +import { CertificateInfo } from '@webauthntine/typescript-types'; + + +/** + * 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/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.test.ts b/packages/server/src/index.test.ts new file mode 100644 index 0000000..ea02a04 --- /dev/null +++ b/packages/server/src/index.test.ts @@ -0,0 +1,17 @@ +import * as index from './index'; + +test('should export method `generateAttestationOptions`', () => { + expect(index.generateAttestationOptions).toBeDefined(); +}); + +test('should export method `verifyAttestationResponse`', () => { + expect(index.verifyAttestationResponse).toBeDefined(); +}); + +test('should export method `generateAssertionOptions`', () => { + expect(index.generateAssertionOptions).toBeDefined(); +}); + +test('should export method `verifyAssertionResponse`', () => { + expect(index.verifyAssertionResponse).toBeDefined(); +}); diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts new file mode 100644 index 0000000..14a7f3d --- /dev/null +++ b/packages/server/src/index.ts @@ -0,0 +1,11 @@ +import generateAttestationOptions from './attestation/generateAttestationOptions'; +import verifyAttestationResponse from './attestation/verifyAttestationResponse'; +import generateAssertionOptions from './assertion/generateAssertionOptions'; +import verifyAssertionResponse from './assertion/verifyAssertionResponse'; + +export { + generateAttestationOptions, + verifyAttestationResponse, + generateAssertionOptions, + verifyAssertionResponse, +}; diff --git a/packages/server/src/setupTests.ts b/packages/server/src/setupTests.ts new file mode 100644 index 0000000..103e5fa --- /dev/null +++ b/packages/server/src/setupTests.ts @@ -0,0 +1,4 @@ +// Silence some console output +// jest.spyOn(console, 'log').mockImplementation(); +// jest.spyOn(console, 'debug').mockImplementation(); +// jest.spyOn(console, 'error').mockImplementation(); |