summaryrefslogtreecommitdiffhomepage
path: root/packages/server/src
diff options
context:
space:
mode:
Diffstat (limited to 'packages/server/src')
-rw-r--r--packages/server/src/assertion/generateAssertionOptions.test.ts45
-rw-r--r--packages/server/src/assertion/generateAssertionOptions.ts28
-rw-r--r--packages/server/src/assertion/parseAssertionAuthData.ts28
-rw-r--r--packages/server/src/assertion/verifyAssertionResponse.test.ts111
-rw-r--r--packages/server/src/assertion/verifyAssertionResponse.ts82
-rw-r--r--packages/server/src/attestation/generateAttestationOptions.test.ts66
-rw-r--r--packages/server/src/attestation/generateAttestationOptions.ts46
-rw-r--r--packages/server/src/attestation/parseAttestationAuthData.ts59
-rw-r--r--packages/server/src/attestation/verifications/verifyAndroidSafetyNet.ts155
-rw-r--r--packages/server/src/attestation/verifications/verifyFIDOU2F.ts81
-rw-r--r--packages/server/src/attestation/verifications/verifyNone.ts54
-rw-r--r--packages/server/src/attestation/verifications/verifyPacked.ts212
-rw-r--r--packages/server/src/attestation/verifyAttestationResponse.test.ts260
-rw-r--r--packages/server/src/attestation/verifyAttestationResponse.ts58
-rw-r--r--packages/server/src/helpers/asciiToBinary.ts8
-rw-r--r--packages/server/src/helpers/convertASN1toPEM.ts48
-rw-r--r--packages/server/src/helpers/convertCOSEtoPKCS.test.ts29
-rw-r--r--packages/server/src/helpers/convertCOSEtoPKCS.ts43
-rw-r--r--packages/server/src/helpers/decodeAttestationObject.test.ts39
-rw-r--r--packages/server/src/helpers/decodeAttestationObject.ts17
-rw-r--r--packages/server/src/helpers/decodeClientDataJSON.test.ts16
-rw-r--r--packages/server/src/helpers/decodeClientDataJSON.ts11
-rw-r--r--packages/server/src/helpers/getCertificateInfo.ts31
-rw-r--r--packages/server/src/helpers/toHash.ts10
-rw-r--r--packages/server/src/helpers/verifySignature.ts18
-rw-r--r--packages/server/src/index.test.ts17
-rw-r--r--packages/server/src/index.ts11
-rw-r--r--packages/server/src/setupTests.ts4
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();