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