summaryrefslogtreecommitdiffhomepage
path: root/packages/server/src
diff options
context:
space:
mode:
authorMatthew Miller <matthew@millerti.me>2020-06-07 15:09:57 -0700
committerGitHub <noreply@github.com>2020-06-07 15:09:57 -0700
commit992a56a10fab7f651a936bcc65642664b9cd86bc (patch)
tree8a0bf34253858057e35a3aa996b911a97751e3af /packages/server/src
parentb4c1bae58a11f7651dd44b7cfc7ba210ef09a605 (diff)
parentc172c6afd507d8a690c8716bb37d551b9e99379a (diff)
Merge pull request #24 from MasterKale/feature/improved-verification
feature/improved-verification
Diffstat (limited to 'packages/server/src')
-rw-r--r--packages/server/src/assertion/generateAssertionOptions.ts12
-rw-r--r--packages/server/src/assertion/verifyAssertionResponse.test.ts177
-rw-r--r--packages/server/src/assertion/verifyAssertionResponse.ts81
-rw-r--r--packages/server/src/attestation/generateAttestationOptions.test.ts24
-rw-r--r--packages/server/src/attestation/generateAttestationOptions.ts40
-rw-r--r--packages/server/src/attestation/verifications/verifyAndroidSafetyNet.ts53
-rw-r--r--packages/server/src/attestation/verifications/verifyFIDOU2F.ts56
-rw-r--r--packages/server/src/attestation/verifications/verifyNone.ts48
-rw-r--r--packages/server/src/attestation/verifications/verifyPacked.ts80
-rw-r--r--packages/server/src/attestation/verifyAttestationResponse.test.ts295
-rw-r--r--packages/server/src/attestation/verifyAttestationResponse.ts143
-rw-r--r--packages/server/src/helpers/asciiToBinary.ts8
-rw-r--r--packages/server/src/helpers/decodeAttestationObject.test.ts4
-rw-r--r--packages/server/src/helpers/decodeAttestationObject.ts12
-rw-r--r--packages/server/src/helpers/decodeClientDataJSON.test.ts2
-rw-r--r--packages/server/src/helpers/decodeClientDataJSON.ts12
-rw-r--r--packages/server/src/helpers/decodeCredentialPublicKey.ts7
-rw-r--r--packages/server/src/helpers/parseAuthenticatorData.ts10
18 files changed, 658 insertions, 406 deletions
diff --git a/packages/server/src/assertion/generateAssertionOptions.ts b/packages/server/src/assertion/generateAssertionOptions.ts
index 6645e2e..d11677a 100644
--- a/packages/server/src/assertion/generateAssertionOptions.ts
+++ b/packages/server/src/assertion/generateAssertionOptions.ts
@@ -4,12 +4,12 @@ import type {
} from '@simplewebauthn/typescript-types';
type Options = {
- challenge: string,
- allowedCredentialIDs: Base64URLString[],
- suggestedTransports?: AuthenticatorTransport[],
- timeout?: number,
- userVerification?: UserVerificationRequirement,
- extensions?: AuthenticationExtensionsClientInputs,
+ challenge: string;
+ allowedCredentialIDs: Base64URLString[];
+ suggestedTransports?: AuthenticatorTransport[];
+ timeout?: number;
+ userVerification?: UserVerificationRequirement;
+ extensions?: AuthenticationExtensionsClientInputs;
};
/**
diff --git a/packages/server/src/assertion/verifyAssertionResponse.test.ts b/packages/server/src/assertion/verifyAssertionResponse.test.ts
index 9da06ce..20b6e0e 100644
--- a/packages/server/src/assertion/verifyAssertionResponse.test.ts
+++ b/packages/server/src/assertion/verifyAssertionResponse.test.ts
@@ -1,7 +1,9 @@
+import base64url from 'base64url';
import verifyAssertionResponse from './verifyAssertionResponse';
import * as decodeClientDataJSON from '../helpers/decodeClientDataJSON';
import * as parseAuthenticatorData from '../helpers/parseAuthenticatorData';
+import toHash from '../helpers/toHash';
let mockDecodeClientData: jest.SpyInstance;
let mockParseAuthData: jest.SpyInstance;
@@ -17,60 +19,51 @@ afterEach(() => {
});
test('should verify an assertion response', () => {
- const verification = verifyAssertionResponse(
- assertionResponse,
- assertionChallenge,
- assertionOrigin,
- authenticator,
- );
-
- expect(verification.verified).toEqual(true);
-});
-
-test('should verify an assertion response if origin does not start with https', () => {
- const verification = verifyAssertionResponse(
- assertionResponse,
- assertionChallenge,
- 'dev.dontneeda.pw',
- authenticator,
- );
+ const verification = verifyAssertionResponse({
+ credential: assertionResponse,
+ expectedChallenge: assertionChallenge,
+ expectedOrigin: assertionOrigin,
+ expectedRPID: 'dev.dontneeda.pw',
+ authenticator: authenticator,
+ });
expect(verification.verified).toEqual(true);
});
test('should return authenticator info after verification', () => {
- const verification = verifyAssertionResponse(
- assertionResponse,
- assertionChallenge,
- assertionOrigin,
- authenticator,
- );
+ const verification = verifyAssertionResponse({
+ credential: assertionResponse,
+ expectedChallenge: assertionChallenge,
+ expectedOrigin: assertionOrigin,
+ expectedRPID: 'dev.dontneeda.pw',
+ authenticator: authenticator,
+ });
expect(verification.authenticatorInfo.counter).toEqual(144);
- expect(verification.authenticatorInfo.base64CredentialID).toEqual(
- authenticator.credentialID,
- );
+ expect(verification.authenticatorInfo.base64CredentialID).toEqual(authenticator.credentialID);
});
test('should throw when response challenge is not expected value', () => {
expect(() => {
- verifyAssertionResponse(
- assertionResponse,
- 'shouldhavebeenthisvalue',
- 'https://different.address',
- authenticator,
- );
+ verifyAssertionResponse({
+ credential: assertionResponse,
+ expectedChallenge: 'shouldhavebeenthisvalue',
+ expectedOrigin: 'https://different.address',
+ expectedRPID: 'dev.dontneeda.pw',
+ authenticator: authenticator,
+ });
}).toThrow(/assertion challenge/i);
});
test('should throw when response origin is not expected value', () => {
expect(() => {
- verifyAssertionResponse(
- assertionResponse,
- assertionChallenge,
- 'https://different.address',
- authenticator,
- );
+ verifyAssertionResponse({
+ credential: assertionResponse,
+ expectedChallenge: assertionChallenge,
+ expectedOrigin: 'https://different.address',
+ expectedRPID: 'dev.dontneeda.pw',
+ authenticator: authenticator,
+ });
}).toThrow(/assertion origin/i);
});
@@ -83,17 +76,30 @@ test('should throw when assertion type is not webauthn.create', () => {
});
expect(() => {
- verifyAssertionResponse(assertionResponse, assertionChallenge, assertionOrigin, authenticator);
+ verifyAssertionResponse({
+ credential: assertionResponse,
+ expectedChallenge: assertionChallenge,
+ expectedOrigin: assertionOrigin,
+ expectedRPID: 'dev.dontneeda.pw',
+ authenticator: authenticator,
+ });
}).toThrow(/assertion type/i);
});
test('should throw error if user was not present', () => {
mockParseAuthData.mockReturnValue({
+ rpIdHash: toHash(Buffer.from('dev.dontneeda.pw', 'ascii')),
flags: 0,
});
expect(() => {
- verifyAssertionResponse(assertionResponse, assertionChallenge, assertionOrigin, authenticator);
+ verifyAssertionResponse({
+ credential: assertionResponse,
+ expectedChallenge: assertionChallenge,
+ expectedOrigin: assertionOrigin,
+ expectedRPID: 'dev.dontneeda.pw',
+ authenticator: authenticator,
+ });
}).toThrow(/not present/i);
});
@@ -106,10 +112,74 @@ test('should throw error if previous counter value is not less than in response'
};
expect(() => {
- verifyAssertionResponse(assertionResponse, assertionChallenge, assertionOrigin, badDevice);
+ verifyAssertionResponse({
+ credential: assertionResponse,
+ expectedChallenge: assertionChallenge,
+ expectedOrigin: assertionOrigin,
+ expectedRPID: 'dev.dontneeda.pw',
+ authenticator: badDevice,
+ });
}).toThrow(/counter value/i);
});
+test('should throw error if assertion RP ID is unexpected value', () => {
+ mockParseAuthData.mockReturnValue({
+ rpIdHash: toHash(Buffer.from('bad.url', 'ascii')),
+ flags: 0,
+ });
+
+ expect(() => {
+ verifyAssertionResponse({
+ credential: assertionResponse,
+ expectedChallenge: assertionChallenge,
+ expectedOrigin: assertionOrigin,
+ expectedRPID: 'dev.dontneeda.pw',
+ authenticator: authenticator,
+ });
+ }).toThrow(/rp id/i);
+});
+
+test('should not compare counters if both are 0', () => {
+ const verification = verifyAssertionResponse({
+ credential: assertionFirstTimeUsedResponse,
+ expectedChallenge: assertionFirstTimeUsedChallenge,
+ expectedOrigin: assertionFirstTimeUsedOrigin,
+ expectedRPID: 'dev.dontneeda.pw',
+ authenticator: authenticatorFirstTimeUsed,
+ });
+
+ expect(verification.verified).toEqual(true);
+});
+
+test('should throw an error if user verification is required but user was not verified', () => {
+ const actualData = parseAuthenticatorData.default(
+ base64url.toBuffer(assertionResponse.response.authenticatorData),
+ );
+
+ mockParseAuthData.mockReturnValue({
+ ...actualData,
+ flags: {
+ up: true,
+ uv: false,
+ },
+ });
+
+ expect(() => {
+ verifyAssertionResponse({
+ credential: assertionResponse,
+ expectedChallenge: assertionChallenge,
+ expectedOrigin: assertionOrigin,
+ expectedRPID: 'dev.dontneeda.pw',
+ authenticator: authenticator,
+ requireUserVerification: true,
+ });
+ }).toThrow(/user could not be verified/i);
+});
+
+/**
+ * Assertion examples below
+ */
+
const assertionResponse = {
id: 'KEbWNCc7NgaYnUyrNeFGX9_3Y-8oJ3KwzjnaiD1d1LVTxR7v3CaKfCz2Vy_g_MHSh7yJ8yL0Pxg6jo_o0hYiew',
rawId: '',
@@ -134,5 +204,30 @@ const authenticator = {
'BIheFp-u6GvFT2LNGovf3ZrT0iFVBsA_76rRysxRG9A18WGeA6hPmnab0HAViUYVRkwTNcN77QBf_' + 'RR0dv3lIvQ',
credentialID:
'KEbWNCc7NgaYnUyrNeFGX9_3Y-8oJ3KwzjnaiD1d1LVTxR7v3CaKfCz2Vy_g_MHSh7yJ8yL0Px' + 'g6jo_o0hYiew',
+ counter: 143,
+};
+
+/**
+ * Represented a device that's being used on the website for the first time
+ */
+const assertionFirstTimeUsedResponse = {
+ id: 'wSisR0_4hlzw3Y1tj4uNwwifIhRa-ZxWJwWbnfror0pVK9qPdBPO5pW3gasPqn6wXHb0LNhXB_IrA1nFoSQJ9A',
+ rawId: 'wSisR0_4hlzw3Y1tj4uNwwifIhRa-ZxWJwWbnfror0pVK9qPdBPO5pW3gasPqn6wXHb0LNhXB_IrA1nFoSQJ9A',
+ response: {
+ authenticatorData: 'PdxHEOnAiLIp26idVjIguzn3Ipr_RlsKZWsa-5qK-KABAAAAAA',
+ clientDataJSON:
+ 'eyJjaGFsbGVuZ2UiOiJkRzkwWVd4c2VWVnVhWEYxWlZaaGJIVmxSWFpsY25sQmMzTmxjblJwYjI0IiwiY2xpZW50RXh0ZW5zaW9ucyI6e30sImhhc2hBbGdvcml0aG0iOiJTSEEtMjU2Iiwib3JpZ2luIjoiaHR0cHM6Ly9kZXYuZG9udG5lZWRhLnB3IiwidHlwZSI6IndlYmF1dGhuLmdldCJ9',
+ signature:
+ 'MEQCIBu6M-DGzu1O8iocGHEj0UaAZm0HmxTeRIE6-nS3_CPjAiBDsmIzy5sacYwwzgpXqfwRt_2vl5yiQZ_OAqWJQBGVsQ',
+ },
+ type: 'public-key',
+};
+const assertionFirstTimeUsedChallenge = 'totallyUniqueValueEveryAssertion';
+const assertionFirstTimeUsedOrigin = 'https://dev.dontneeda.pw';
+const authenticatorFirstTimeUsed = {
+ publicKey:
+ 'BGmaxR4mBbukc2QhtW2ldhAAd555r-ljlGQN8MbcTnPP9CyUlE-0AB2fbzZbNgBvJuRa7r6o2jPphOmtyNPR_kY',
+ credentialID:
+ 'wSisR0_4hlzw3Y1tj4uNwwifIhRa-ZxWJwWbnfror0pVK9qPdBPO5pW3gasPqn6wXHb0LNhXB_IrA1nFoSQJ9A',
counter: 0,
};
diff --git a/packages/server/src/assertion/verifyAssertionResponse.ts b/packages/server/src/assertion/verifyAssertionResponse.ts
index 7d13271..0029796 100644
--- a/packages/server/src/assertion/verifyAssertionResponse.ts
+++ b/packages/server/src/assertion/verifyAssertionResponse.ts
@@ -1,8 +1,5 @@
import base64url from 'base64url';
-import {
- AssertionCredentialJSON,
- AuthenticatorDevice,
-} from '@simplewebauthn/typescript-types';
+import { AssertionCredentialJSON, AuthenticatorDevice } from '@simplewebauthn/typescript-types';
import decodeClientDataJSON from '../helpers/decodeClientDataJSON';
import toHash from '../helpers/toHash';
@@ -10,27 +7,46 @@ import convertASN1toPEM from '../helpers/convertASN1toPEM';
import verifySignature from '../helpers/verifySignature';
import parseAuthenticatorData from '../helpers/parseAuthenticatorData';
+type Options = {
+ credential: AssertionCredentialJSON;
+ expectedChallenge: string;
+ expectedOrigin: string;
+ expectedRPID: string;
+ authenticator: AuthenticatorDevice;
+ requireUserVerification?: boolean;
+};
+
/**
* Verify that the user has legitimately completed the login process
*
- * @param response Authenticator assertion response with base64url-encoded values
+ * **Options:**
+ *
+ * @param credential Authenticator credential returned by browser's `startAssertion()`
* @param expectedChallenge The random value provided to generateAssertionOptions for the
* authenticator to sign
- * @param expectedOrigin Expected URL of website assertion should have occurred on
+ * @param expectedOrigin Website URL that the attestation should have occurred on
+ * @param expectedRPID RP ID that was specified in the attestation options
+ * @param authenticator An internal {@link AuthenticatorDevice} matching the credential's ID
+ * @param requireUserVerification (Optional) Enforce user verification by the authenticator
+ * (via PIN, fingerprint, etc...)
*/
-export default function verifyAssertionResponse(
- credential: AssertionCredentialJSON,
- expectedChallenge: string,
- expectedOrigin: string,
- authenticator: AuthenticatorDevice,
-): VerifiedAssertion {
+export default function verifyAssertionResponse(options: Options): VerifiedAssertion {
+ const {
+ credential,
+ expectedChallenge,
+ expectedOrigin,
+ expectedRPID,
+ authenticator,
+ requireUserVerification = false,
+ } = options;
const { response } = credential;
const clientDataJSON = decodeClientDataJSON(response.clientDataJSON);
const { type, origin, challenge } = clientDataJSON;
- if (!expectedOrigin.startsWith('https://')) {
- expectedOrigin = `https://${expectedOrigin}`;
+ // Make sure we're handling an assertion
+ if (type !== 'webauthn.get') {
+ throw new Error(`Unexpected assertion type: ${type}`);
}
if (challenge !== expectedChallenge) {
@@ -44,20 +60,33 @@ export default function verifyAssertionResponse(
throw new Error(`Unexpected assertion origin "${origin}", expected "${expectedOrigin}"`);
}
- // Make sure we're handling an assertion
- if (type !== 'webauthn.get') {
- throw new Error(`Unexpected assertion type: ${type}`);
- }
-
const authDataBuffer = base64url.toBuffer(response.authenticatorData);
- const authDataStruct = parseAuthenticatorData(authDataBuffer);
- const { flags, counter } = authDataStruct;
+ const parsedAuthData = parseAuthenticatorData(authDataBuffer);
+ const { rpIdHash, flags, counter } = parsedAuthData;
+
+ // Make sure the response's RP ID is ours
+ const expectedRPIDHash = toHash(Buffer.from(expectedRPID, 'ascii'));
+ if (!rpIdHash.equals(expectedRPIDHash)) {
+ throw new Error(`Unexpected RP ID hash`);
+ }
+ // Make sure someone was physically present
if (!flags.up) {
throw new Error('User not present during assertion');
}
- if (counter <= authenticator.counter) {
+ // Enforce user verification if specified
+ if (requireUserVerification && !flags.uv) {
+ throw new Error('User verification required, but user could not be verified');
+ }
+
+ const clientDataHash = toHash(base64url.toBuffer(response.clientDataJSON));
+ const signatureBase = Buffer.concat([authDataBuffer, clientDataHash]);
+
+ const publicKey = convertASN1toPEM(base64url.toBuffer(authenticator.publicKey));
+ const signature = base64url.toBuffer(response.signature);
+
+ if ((counter > 0 || authenticator.counter > 0) && 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
@@ -67,14 +96,6 @@ export default function verifyAssertionResponse(
);
}
- const { rpIdHash, flagsBuf, counterBuf } = authDataStruct;
-
- const clientDataHash = toHash(base64url.toBuffer(response.clientDataJSON));
- const signatureBase = Buffer.concat([rpIdHash, flagsBuf, counterBuf, clientDataHash]);
-
- const publicKey = convertASN1toPEM(base64url.toBuffer(authenticator.publicKey));
- const signature = base64url.toBuffer(response.signature);
-
const toReturn = {
verified: verifySignature(signature, signatureBase, publicKey),
authenticatorInfo: {
diff --git a/packages/server/src/attestation/generateAttestationOptions.test.ts b/packages/server/src/attestation/generateAttestationOptions.test.ts
index 2b83fa9..112586a 100644
--- a/packages/server/src/attestation/generateAttestationOptions.test.ts
+++ b/packages/server/src/attestation/generateAttestationOptions.test.ts
@@ -35,6 +35,18 @@ test('should generate credential request options suitable for sending via JSON',
alg: -7,
type: 'public-key',
},
+ {
+ alg: -35,
+ type: 'public-key',
+ },
+ {
+ alg: -36,
+ type: 'public-key',
+ },
+ {
+ alg: -8,
+ type: 'public-key',
+ },
],
timeout,
attestation: attestationType,
@@ -52,11 +64,13 @@ test('should map excluded credential IDs if specified', () => {
excludedCredentialIDs: ['someIDhere'],
});
- expect(options.excludeCredentials).toEqual([{
- id: 'someIDhere',
- type: 'public-key',
- transports: ['usb', 'ble', 'nfc', 'internal'],
- }]);
+ expect(options.excludeCredentials).toEqual([
+ {
+ id: 'someIDhere',
+ type: 'public-key',
+ transports: ['usb', 'ble', 'nfc', 'internal'],
+ },
+ ]);
});
test('defaults to 60 seconds if no timeout is specified', () => {
diff --git a/packages/server/src/attestation/generateAttestationOptions.ts b/packages/server/src/attestation/generateAttestationOptions.ts
index d142740..39d6378 100644
--- a/packages/server/src/attestation/generateAttestationOptions.ts
+++ b/packages/server/src/attestation/generateAttestationOptions.ts
@@ -4,20 +4,24 @@ import type {
} from '@simplewebauthn/typescript-types';
type Options = {
- serviceName: string,
- rpID: string,
- challenge: string,
- userID: string,
- userName: string,
- userDisplayName?: string,
- timeout?: number,
- attestationType?: AttestationConveyancePreference,
- excludedCredentialIDs?: Base64URLString[],
- suggestedTransports?: AuthenticatorTransport[],
- authenticatorSelection?: AuthenticatorSelectionCriteria,
- extensions?: AuthenticationExtensionsClientInputs,
+ serviceName: string;
+ rpID: string;
+ challenge: string;
+ userID: string;
+ userName: string;
+ userDisplayName?: string;
+ timeout?: number;
+ attestationType?: AttestationConveyancePreference;
+ excludedCredentialIDs?: Base64URLString[];
+ suggestedTransports?: AuthenticatorTransport[];
+ authenticatorSelection?: AuthenticatorSelectionCriteria;
+ extensions?: AuthenticationExtensionsClientInputs;
};
+// Supported crypto algo identifiers
+// See https://w3c.github.io/webauthn/#sctn-alg-identifier
+export const supportedCOSEAlgorithIdentifiers: COSEAlgorithmIdentifier[] = [-7, -35, -36, -8];
+
/**
* Prepare a value to pass into navigator.credentials.create(...) for authenticator "registration"
*
@@ -67,15 +71,13 @@ export default function generateAttestationOptions(
name: userName,
displayName: userDisplayName,
},
- pubKeyCredParams: [
- {
- alg: -7,
- type: 'public-key',
- },
- ],
+ pubKeyCredParams: supportedCOSEAlgorithIdentifiers.map(id => ({
+ alg: id,
+ type: 'public-key',
+ })),
timeout,
attestation: attestationType,
- excludeCredentials: excludedCredentialIDs.map((id) => ({
+ excludeCredentials: excludedCredentialIDs.map(id => ({
id,
type: 'public-key',
transports: suggestedTransports,
diff --git a/packages/server/src/attestation/verifications/verifyAndroidSafetyNet.ts b/packages/server/src/attestation/verifications/verifyAndroidSafetyNet.ts
index a5dc89a..9e0c080 100644
--- a/packages/server/src/attestation/verifications/verifyAndroidSafetyNet.ts
+++ b/packages/server/src/attestation/verifications/verifyAndroidSafetyNet.ts
@@ -1,36 +1,22 @@
import base64url from 'base64url';
-import type { AttestationObject } from '../../helpers/decodeAttestationObject';
-import type { VerifiedAttestation } from '../verifyAttestationResponse';
+import type { AttestationStatement } from '../../helpers/decodeAttestationObject';
import toHash from '../../helpers/toHash';
import verifySignature from '../../helpers/verifySignature';
-import convertCOSEtoPKCS from '../../helpers/convertCOSEtoPKCS';
import getCertificateInfo from '../../helpers/getCertificateInfo';
-import parseAuthenticatorData from '../../helpers/parseAuthenticatorData';
+
+type Options = {
+ attStmt: AttestationStatement;
+ clientDataHash: Buffer;
+ authData: Buffer;
+};
/**
* Verify an attestation response with fmt 'android-safetynet'
*/
-export default function verifyAttestationAndroidSafetyNet(
- attestationObject: AttestationObject,
- base64ClientDataJSON: string,
-): VerifiedAttestation {
- const { attStmt, authData, fmt } = attestationObject;
- const authDataStruct = parseAuthenticatorData(authData);
- const { counter, credentialID, COSEPublicKey, flags } = authDataStruct;
-
- if (!flags.up) {
- throw new Error('User was not present for attestation (None)');
- }
-
- 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)');
- }
+export default function verifyAttestationAndroidSafetyNet(options: Options): boolean {
+ const { attStmt, clientDataHash, authData } = options;
if (!attStmt.response) {
throw new Error('No response was included in attStmt by authenticator (SafetyNet)');
@@ -48,7 +34,6 @@ export default function verifyAttestationAndroidSafetyNet(
* START Verify PAYLOAD
*/
const { nonce, ctsProfileMatch } = PAYLOAD;
- const clientDataHash = toHash(base64url.toBuffer(base64ClientDataJSON));
const nonceBase = Buffer.concat([authData, clientDataHash]);
const nonceBuffer = toHash(nonceBase);
@@ -102,28 +87,12 @@ export default function verifyAttestationAndroidSafetyNet(
const signatureBaseBuffer = Buffer.from(`${jwtParts[0]}.${jwtParts[1]}`);
const signatureBuffer = base64url.toBuffer(SIGNATURE);
- const toReturn: VerifiedAttestation = {
- verified: verifySignature(signatureBuffer, signatureBaseBuffer, certificate),
- userVerified: false,
- };
+ const verified = verifySignature(signatureBuffer, signatureBaseBuffer, certificate);
/**
* END Verify Signature
*/
- if (toReturn.verified) {
- toReturn.userVerified = flags.uv;
-
- const publicKey = convertCOSEtoPKCS(COSEPublicKey);
-
- toReturn.authenticatorInfo = {
- fmt,
- counter,
- base64PublicKey: base64url.encode(publicKey),
- base64CredentialID: base64url.encode(credentialID),
- };
- }
-
- return toReturn;
+ return verified;
}
/**
diff --git a/packages/server/src/attestation/verifications/verifyFIDOU2F.ts b/packages/server/src/attestation/verifications/verifyFIDOU2F.ts
index 5842a3c..0fd2e74 100644
--- a/packages/server/src/attestation/verifications/verifyFIDOU2F.ts
+++ b/packages/server/src/attestation/verifications/verifyFIDOU2F.ts
@@ -1,41 +1,25 @@
-import base64url from 'base64url';
+import type { AttestationStatement } from '../../helpers/decodeAttestationObject';
-import type { AttestationObject } from '../../helpers/decodeAttestationObject';
-import type { VerifiedAttestation } from '../verifyAttestationResponse';
-
-import toHash from '../../helpers/toHash';
import convertCOSEtoPKCS from '../../helpers/convertCOSEtoPKCS';
import convertASN1toPEM from '../../helpers/convertASN1toPEM';
import verifySignature from '../../helpers/verifySignature';
-import parseAuthenticatorData from '../../helpers/parseAuthenticatorData';
+
+type Options = {
+ attStmt: AttestationStatement;
+ clientDataHash: Buffer;
+ rpIdHash: Buffer;
+ credentialID: Buffer;
+ credentialPublicKey: Buffer;
+};
/**
* Verify an attestation response with fmt 'fido-u2f'
*/
-export default function verifyAttestationFIDOU2F(
- attestationObject: AttestationObject,
- base64ClientDataJSON: string,
-): VerifiedAttestation {
- const { fmt, authData, attStmt } = attestationObject;
-
- const authDataStruct = parseAuthenticatorData(authData);
- const { flags, COSEPublicKey, rpIdHash, credentialID, counter } = authDataStruct;
-
- if (!flags.up) {
- 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)');
- }
+export default function verifyAttestationFIDOU2F(options: Options): boolean {
+ const { attStmt, clientDataHash, rpIdHash, credentialID, credentialPublicKey } = options;
- const clientDataHash = toHash(base64url.toBuffer(base64ClientDataJSON));
const reservedByte = Buffer.from([0x00]);
- const publicKey = convertCOSEtoPKCS(COSEPublicKey);
+ const publicKey = convertCOSEtoPKCS(credentialPublicKey);
const signatureBase = Buffer.concat([
reservedByte,
@@ -57,19 +41,5 @@ export default function verifyAttestationFIDOU2F(
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;
+ return verifySignature(sig, signatureBase, publicKeyCertPEM);
}
diff --git a/packages/server/src/attestation/verifications/verifyNone.ts b/packages/server/src/attestation/verifications/verifyNone.ts
deleted file mode 100644
index 66fd7da..0000000
--- a/packages/server/src/attestation/verifications/verifyNone.ts
+++ /dev/null
@@ -1,48 +0,0 @@
-import base64url from 'base64url';
-
-import type { AttestationObject } from '../../helpers/decodeAttestationObject';
-import type { VerifiedAttestation } from '../verifyAttestationResponse';
-
-import convertCOSEtoPKCS from '../../helpers/convertCOSEtoPKCS';
-import parseAuthenticatorData from '../../helpers/parseAuthenticatorData';
-
-/**
- * Verify an attestation response with fmt 'none'
- *
- * This is the weaker of the attestations, so there are only so many checks we can perform
- */
-export default function verifyAttestationNone(
- attestationObject: AttestationObject,
-): VerifiedAttestation {
- const { fmt, authData } = attestationObject;
- const authDataStruct = parseAuthenticatorData(authData);
-
- const { credentialID, COSEPublicKey, counter, flags } = authDataStruct;
-
- if (!flags.up) {
- throw new Error('User was not present for attestation (None)');
- }
-
- 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)');
- }
-
- 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
index 16acdfd..45bd57e 100644
--- a/packages/server/src/attestation/verifications/verifyPacked.ts
+++ b/packages/server/src/attestation/verifications/verifyPacked.ts
@@ -1,60 +1,38 @@
-import base64url from 'base64url';
-import cbor from 'cbor';
import elliptic from 'elliptic';
import NodeRSA, { SigningSchemeHash } from 'node-rsa';
-import type { AttestationObject } from '../../helpers/decodeAttestationObject';
-import type { VerifiedAttestation } from '../verifyAttestationResponse';
+import type { AttestationStatement } from '../../helpers/decodeAttestationObject';
-import convertCOSEtoPKCS, {
- COSEKEYS,
- COSEPublicKey as COSEPublicKeyType
-} from '../../helpers/convertCOSEtoPKCS';
+import convertCOSEtoPKCS, { COSEKEYS } from '../../helpers/convertCOSEtoPKCS';
import toHash from '../../helpers/toHash';
import convertASN1toPEM from '../../helpers/convertASN1toPEM';
import getCertificateInfo from '../../helpers/getCertificateInfo';
import verifySignature from '../../helpers/verifySignature';
-import parseAuthenticatorData from '../../helpers/parseAuthenticatorData';
+import decodeCredentialPublicKey from '../../helpers/decodeCredentialPublicKey';
+
+type Options = {
+ attStmt: AttestationStatement;
+ clientDataHash: Buffer;
+ authData: Buffer;
+ credentialPublicKey: Buffer;
+};
/**
* Verify an attestation response with fmt 'packed'
*/
-export default function verifyAttestationPacked(
- attestationObject: AttestationObject,
- base64ClientDataJSON: string,
-): VerifiedAttestation {
- const { fmt, authData, attStmt } = attestationObject;
- const { sig, x5c } = attStmt;
-
- const authDataStruct = parseAuthenticatorData(authData);
-
- const { COSEPublicKey, counter, credentialID, flags } = authDataStruct;
-
- if (!flags.up) {
- throw new Error('User was not present for attestation (Packed)');
- }
+export default function verifyAttestationPacked(options: Options): boolean {
+ const { attStmt, clientDataHash, authData, credentialPublicKey } = options;
- 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)');
- }
+ const { sig, x5c } = attStmt;
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);
+ let verified = false;
+ const pkcsPublicKey = convertCOSEtoPKCS(credentialPublicKey);
if (x5c) {
const leafCert = convertASN1toPEM(x5c[0]);
@@ -87,9 +65,9 @@ export default function verifyAttestationPacked(
throw new Error('Batch certificate version was not `3` (ASN.1 value of 2) (Packed|Full');
}
- toReturn.verified = verifySignature(sig, signatureBase, leafCert);
+ verified = verifySignature(sig, signatureBase, leafCert);
} else {
- const cosePublicKey: COSEPublicKeyType = cbor.decodeAllSync(COSEPublicKey)[0];
+ const cosePublicKey = decodeCredentialPublicKey(credentialPublicKey);
const kty = cosePublicKey.get(COSEKEYS.kty);
const alg = cosePublicKey.get(COSEKEYS.alg);
@@ -111,7 +89,6 @@ export default function verifyAttestationPacked(
throw new Error('COSE public key was missing kty crv (Packed|EC2)');
}
- const pkcsPublicKey = convertCOSEtoPKCS(COSEPublicKey);
const signatureBaseHash = toHash(signatureBase, hashAlg);
/**
@@ -126,7 +103,7 @@ export default function verifyAttestationPacked(
const ec = new elliptic.ec(COSECRV[crv as number]);
const key = ec.keyFromPublic(pkcsPublicKey);
- toReturn.verified = key.verify(signatureBaseHash, sig);
+ verified = key.verify(signatureBaseHash, sig);
} else if (kty === COSEKTY.RSA) {
const n = cosePublicKey.get(COSEKEYS.n);
@@ -147,7 +124,7 @@ export default function verifyAttestationPacked(
'components-public',
);
- toReturn.verified = key.verify(signatureBase, sig);
+ verified = key.verify(signatureBase, sig);
} else if (kty === COSEKTY.OKP) {
const x = cosePublicKey.get(COSEKEYS.x);
@@ -161,20 +138,11 @@ export default function verifyAttestationPacked(
key.keyFromPublic(x as Buffer);
// TODO: is `publicKey` right here?
- toReturn.verified = key.verify(signatureBaseHash, sig, publicKey);
+ verified = key.verify(signatureBaseHash, sig, pkcsPublicKey);
}
}
- if (toReturn.verified) {
- toReturn.authenticatorInfo = {
- fmt,
- counter,
- base64PublicKey: base64url.encode(publicKey),
- base64CredentialID: base64url.encode(credentialID),
- };
- }
-
- return toReturn;
+ return verified;
}
enum COSEKTY {
@@ -193,10 +161,16 @@ const COSERSASCHEME: { [key: string]: SigningSchemeHash } = {
'-259': 'pkcs1-sha512',
};
+// See https://w3c.github.io/webauthn/#sctn-alg-identifier
const COSECRV: { [key: number]: string } = {
+ // alg: -7
1: 'p256',
+ // alg: -35
2: 'p384',
+ // alg: -36
3: 'p521',
+ // alg: -8
+ 6: 'ed25519',
};
const COSEALGHASH: { [key: string]: string } = {
diff --git a/packages/server/src/attestation/verifyAttestationResponse.test.ts b/packages/server/src/attestation/verifyAttestationResponse.test.ts
index 1e4cc0d..b2ff37c 100644
--- a/packages/server/src/attestation/verifyAttestationResponse.test.ts
+++ b/packages/server/src/attestation/verifyAttestationResponse.test.ts
@@ -1,45 +1,64 @@
+import base64url from 'base64url';
+
import verifyAttestationResponse from './verifyAttestationResponse';
import * as decodeAttestationObject from '../helpers/decodeAttestationObject';
import * as decodeClientDataJSON from '../helpers/decodeClientDataJSON';
+import * as parseAuthenticatorData from '../helpers/parseAuthenticatorData';
+import * as decodeCredentialPublicKey from '../helpers/decodeCredentialPublicKey';
+
+import * as verifyFIDOU2F from './verifications/verifyFIDOU2F';
+
+import toHash from '../helpers/toHash';
let mockDecodeAttestation: jest.SpyInstance;
let mockDecodeClientData: jest.SpyInstance;
+let mockParseAuthData: jest.SpyInstance;
+let mockDecodePubKey: jest.SpyInstance;
+let mockVerifyFIDOU2F: jest.SpyInstance;
beforeEach(() => {
mockDecodeAttestation = jest.spyOn(decodeAttestationObject, 'default');
mockDecodeClientData = jest.spyOn(decodeClientDataJSON, 'default');
+ mockParseAuthData = jest.spyOn(parseAuthenticatorData, 'default');
+ mockDecodePubKey = jest.spyOn(decodeCredentialPublicKey, 'default');
+ mockVerifyFIDOU2F = jest.spyOn(verifyFIDOU2F, 'default');
});
afterEach(() => {
mockDecodeAttestation.mockRestore();
mockDecodeClientData.mockRestore();
+ mockParseAuthData.mockRestore();
+ mockDecodePubKey.mockRestore();
+ mockVerifyFIDOU2F.mockRestore();
});
test('should verify FIDO U2F attestation', () => {
- const verification = verifyAttestationResponse(
- attestationFIDOU2F,
- attestationFIDOU2FChallenge,
- 'https://clover.millertime.dev:3000',
- );
+ const verification = verifyAttestationResponse({
+ credential: attestationFIDOU2F,
+ expectedChallenge: attestationFIDOU2FChallenge,
+ expectedOrigin: 'https://dev.dontneeda.pw',
+ expectedRPID: 'dev.dontneeda.pw',
+ });
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',
+ 'BMiRyw5pUoMhBjCrcQND6lJPaRHA0f-XWcKBb5ZwWk1eFJu6aan4o7epl6qa9n9T-6KsIMvZE2PcTnLj8rN58is',
);
expect(verification.authenticatorInfo?.base64CredentialID).toEqual(
- 'YVh69pHvWm1Tli1c5KdXM9BOwaAr6AuIEqeo9YGZlc1G-MhKqUvGLACnOWt-RNzeUQxgxq2N4AIKeyKM6Q0QYw',
+ 'VHzbxaYaJu2P8m1Y2iHn2gRNHrgK0iYbn9E978L3Qi7Q-chFeicIHwYCRophz5lth2nCgEVKcgWirxlgidgbUQ',
);
});
test('should verify Packed (EC2) attestation', () => {
- const verification = verifyAttestationResponse(
- attestationPacked,
- attestationPackedChallenge,
- 'https://dev.dontneeda.pw',
- );
+ const verification = verifyAttestationResponse({
+ credential: attestationPacked,
+ expectedChallenge: attestationPackedChallenge,
+ expectedOrigin: 'https://dev.dontneeda.pw',
+ expectedRPID: 'dev.dontneeda.pw',
+ });
expect(verification.verified).toEqual(true);
expect(verification.authenticatorInfo?.fmt).toEqual('packed');
@@ -54,11 +73,12 @@ test('should verify Packed (EC2) attestation', () => {
});
test('should verify Packed (X5C) attestation', () => {
- const verification = verifyAttestationResponse(
- attestationPackedX5C,
- attestationPackedX5CChallenge,
- 'https://dev.dontneeda.pw',
- );
+ const verification = verifyAttestationResponse({
+ credential: attestationPackedX5C,
+ expectedChallenge: attestationPackedX5CChallenge,
+ expectedOrigin: 'https://dev.dontneeda.pw',
+ expectedRPID: 'dev.dontneeda.pw',
+ });
expect(verification.verified).toEqual(true);
expect(verification.authenticatorInfo?.fmt).toEqual('packed');
@@ -72,11 +92,12 @@ test('should verify Packed (X5C) attestation', () => {
});
test('should verify None attestation', () => {
- const verification = verifyAttestationResponse(
- attestationNone,
- attestationNoneChallenge,
- 'https://dev.dontneeda.pw',
- );
+ const verification = verifyAttestationResponse({
+ credential: attestationNone,
+ expectedChallenge: attestationNoneChallenge,
+ expectedOrigin: 'https://dev.dontneeda.pw',
+ expectedRPID: 'dev.dontneeda.pw',
+ });
expect(verification.verified).toEqual(true);
expect(verification.authenticatorInfo?.fmt).toEqual('none');
@@ -90,11 +111,12 @@ test('should verify None attestation', () => {
});
test('should verify Android SafetyNet attestation', () => {
- const verification = verifyAttestationResponse(
- attestationAndroidSafetyNet,
- attestationAndroidSafetyNetChallenge,
- 'https://dev.dontneeda.pw',
- );
+ const verification = verifyAttestationResponse({
+ credential: attestationAndroidSafetyNet,
+ expectedChallenge: attestationAndroidSafetyNetChallenge,
+ expectedOrigin: 'https://dev.dontneeda.pw',
+ expectedRPID: 'dev.dontneeda.pw',
+ });
expect(verification.verified).toEqual(true);
expect(verification.authenticatorInfo?.fmt).toEqual('android-safetynet');
@@ -109,21 +131,23 @@ test('should verify Android SafetyNet attestation', () => {
test('should throw when response challenge is not expected value', () => {
expect(() => {
- verifyAttestationResponse(
- attestationNone,
- 'shouldhavebeenthisvalue',
- 'https://dev.dontneeda.pw',
- );
+ verifyAttestationResponse({
+ credential: attestationNone,
+ expectedChallenge: 'shouldhavebeenthisvalue',
+ expectedOrigin: 'https://dev.dontneeda.pw',
+ expectedRPID: 'dev.dontneeda.pw',
+ });
}).toThrow(/attestation challenge/i);
});
test('should throw when response origin is not expected value', () => {
expect(() => {
- verifyAttestationResponse(
- attestationNone,
- attestationNoneChallenge,
- 'https://different.address',
- );
+ verifyAttestationResponse({
+ credential: attestationNone,
+ expectedChallenge: attestationNoneChallenge,
+ expectedOrigin: 'https://different.address',
+ expectedRPID: 'dev.dontneeda.pw',
+ });
}).toThrow(/attestation origin/i);
});
@@ -139,57 +163,194 @@ test('should throw when attestation type is not webauthn.create', () => {
});
expect(() => {
- verifyAttestationResponse(attestationNone, challenge, origin);
+ verifyAttestationResponse({
+ credential: attestationNone,
+ expectedChallenge: challenge,
+ expectedOrigin: origin,
+ expectedRPID: 'dev.dontneeda.pw',
+ });
}).toThrow(/attestation type/i);
});
test('should throw if an unexpected attestation format is specified', () => {
const fmt = 'fizzbuzz';
+ const realAtteObj = decodeAttestationObject.default(attestationNone.response.attestationObject);
+
mockDecodeAttestation.mockReturnValue({
+ ...realAtteObj,
// @ts-ignore 2322
fmt,
});
expect(() => {
- verifyAttestationResponse(
- attestationNone,
- attestationNoneChallenge,
- 'https://dev.dontneeda.pw',
- );
- }).toThrow();
+ verifyAttestationResponse({
+ credential: attestationNone,
+ expectedChallenge: attestationNoneChallenge,
+ expectedOrigin: 'https://dev.dontneeda.pw',
+ expectedRPID: 'dev.dontneeda.pw',
+ });
+ }).toThrow(/unsupported attestation format/i);
+});
+
+test('should throw error if assertion RP ID is unexpected value', () => {
+ mockParseAuthData.mockReturnValue({
+ rpIdHash: toHash(Buffer.from('bad.url', 'ascii')),
+ flags: 0,
+ });
+
+ expect(() => {
+ verifyAttestationResponse({
+ credential: attestationNone,
+ expectedChallenge: attestationNoneChallenge,
+ expectedOrigin: 'https://dev.dontneeda.pw',
+ expectedRPID: '',
+ });
+ }).toThrow(/rp id/i);
+});
+
+test('should throw error if user was not present', () => {
+ mockParseAuthData.mockReturnValue({
+ rpIdHash: toHash(Buffer.from('dev.dontneeda.pw', 'ascii')),
+ flags: {
+ up: false,
+ },
+ });
+
+ expect(() => {
+ verifyAttestationResponse({
+ credential: attestationNone,
+ expectedChallenge: attestationNoneChallenge,
+ expectedOrigin: 'https://dev.dontneeda.pw',
+ expectedRPID: 'dev.dontneeda.pw',
+ });
+ }).toThrow(/not present/i);
+});
+
+test('should throw if the authenticator does not give back credential ID', () => {
+ mockParseAuthData.mockReturnValue({
+ rpIdHash: toHash(Buffer.from('dev.dontneeda.pw', 'ascii')),
+ flags: {
+ up: true,
+ },
+ credentialID: undefined,
+ });
+
+ expect(() => {
+ verifyAttestationResponse({
+ credential: attestationNone,
+ expectedChallenge: attestationNoneChallenge,
+ expectedOrigin: 'https://dev.dontneeda.pw',
+ expectedRPID: 'dev.dontneeda.pw',
+ });
+ }).toThrow(/credential id/i);
+});
+
+test('should throw if the authenticator does not give back credential public key', () => {
+ mockParseAuthData.mockReturnValue({
+ rpIdHash: toHash(Buffer.from('dev.dontneeda.pw', 'ascii')),
+ flags: {
+ up: true,
+ },
+ credentialID: 'aaa',
+ credentialPublicKey: undefined,
+ });
+
+ expect(() => {
+ verifyAttestationResponse({
+ credential: attestationNone,
+ expectedChallenge: attestationNoneChallenge,
+ expectedOrigin: 'https://dev.dontneeda.pw',
+ expectedRPID: 'dev.dontneeda.pw',
+ });
+ }).toThrow(/public key/i);
+});
+
+test('should throw error if no alg is specified in public key', () => {
+ mockDecodePubKey.mockReturnValue({
+ get: () => undefined,
+ credentialID: '',
+ credentialPublicKey: '',
+ });
+
+ expect(() => {
+ verifyAttestationResponse({
+ credential: attestationNone,
+ expectedChallenge: attestationNoneChallenge,
+ expectedOrigin: 'https://dev.dontneeda.pw',
+ expectedRPID: 'dev.dontneeda.pw',
+ });
+ }).toThrow(/missing alg/i);
+});
+
+test('should throw error if unsupported alg is used', () => {
+ mockDecodePubKey.mockReturnValue({
+ get: () => -999,
+ credentialID: '',
+ credentialPublicKey: '',
+ });
+
+ expect(() => {
+ verifyAttestationResponse({
+ credential: attestationNone,
+ expectedChallenge: attestationNoneChallenge,
+ expectedOrigin: 'https://dev.dontneeda.pw',
+ expectedRPID: 'dev.dontneeda.pw',
+ });
+ }).toThrow(/unexpected public key/i);
+});
+
+test('should not include authenticator info if not verified', () => {
+ mockVerifyFIDOU2F.mockReturnValue(false);
+
+ const verification = verifyAttestationResponse({
+ credential: attestationFIDOU2F,
+ expectedChallenge: attestationFIDOU2FChallenge,
+ expectedOrigin: 'https://dev.dontneeda.pw',
+ expectedRPID: 'dev.dontneeda.pw',
+ });
+
+ expect(verification.verified).toBe(false);
+ expect(verification.authenticatorInfo).toBeUndefined();
+});
+
+test('should throw an error if user verification is required but user was not verified', () => {
+ mockParseAuthData.mockReturnValue({
+ rpIdHash: toHash(Buffer.from('dev.dontneeda.pw', 'ascii')),
+ flags: {
+ up: true,
+ uv: false,
+ },
+ });
+
+ expect(() => {
+ const verification = verifyAttestationResponse({
+ credential: attestationFIDOU2F,
+ expectedChallenge: attestationFIDOU2FChallenge,
+ expectedOrigin: 'https://dev.dontneeda.pw',
+ expectedRPID: 'dev.dontneeda.pw',
+ requireUserVerification: true,
+ });
+ }).toThrow(/user could not be verified/i);
});
+/**
+ * Various Attestations Below
+ */
+
const attestationFIDOU2F = {
- id: 'YVh69pHvWm1Tli1c5KdXM9BOwaAr6AuIEqeo9YGZlc1G-MhKqUvGLACnOWt-RNzeUQxgxq2N4AIKeyKM6Q0QYw',
- rawId: 'YVh69pHvWm1Tli1c5KdXM9BOwaAr6AuIEqeo9YGZlc1G+MhKqUvGLACnOWt+RNzeUQxgxq2N4AIKeyKM6Q0QYw==',
+ id: 'VHzbxaYaJu2P8m1Y2iHn2gRNHrgK0iYbn9E978L3Qi7Q-chFeicIHwYCRophz5lth2nCgEVKcgWirxlgidgbUQ',
+ rawId: 'VHzbxaYaJu2P8m1Y2iHn2gRNHrgK0iYbn9E978L3Qi7Q-chFeicIHwYCRophz5lth2nCgEVKcgWirxlgidgbUQ',
response: {
attestationObject:
- '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==',
+ 'o2NmbXRoZmlkby11MmZnYXR0U3RtdKJjc2lnWEcwRQIgRYUftNUmhT0VWTZmIgDmrOoP26Pcre-kL3DLnCrXbegCIQCOu_x5gqp-Rej76zeBuXlk8e7J-9WM_i-wZmCIbIgCGmN4NWOBWQLBMIICvTCCAaWgAwIBAgIEKudiYzANBgkqhkiG9w0BAQsFADAuMSwwKgYDVQQDEyNZdWJpY28gVTJGIFJvb3QgQ0EgU2VyaWFsIDQ1NzIwMDYzMTAgFw0xNDA4MDEwMDAwMDBaGA8yMDUwMDkwNDAwMDAwMFowbjELMAkGA1UEBhMCU0UxEjAQBgNVBAoMCVl1YmljbyBBQjEiMCAGA1UECwwZQXV0aGVudGljYXRvciBBdHRlc3RhdGlvbjEnMCUGA1UEAwweWXViaWNvIFUyRiBFRSBTZXJpYWwgNzE5ODA3MDc1MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEKgOGXmBD2Z4R_xCqJVRXhL8Jr45rHjsyFykhb1USGozZENOZ3cdovf5Ke8fj2rxi5tJGn_VnW4_6iQzKdIaeP6NsMGowIgYJKwYBBAGCxAoCBBUxLjMuNi4xLjQuMS40MTQ4Mi4xLjEwEwYLKwYBBAGC5RwCAQEEBAMCBDAwIQYLKwYBBAGC5RwBAQQEEgQQbUS6m_bsLkm5MAyP6SDLczAMBgNVHRMBAf8EAjAAMA0GCSqGSIb3DQEBCwUAA4IBAQByV9A83MPhFWmEkNb4DvlbUwcjc9nmRzJjKxHc3HeK7GvVkm0H4XucVDB4jeMvTke0WHb_jFUiApvpOHh5VyMx5ydwFoKKcRs5x0_WwSWL0eTZ5WbVcHkDR9pSNcA_D_5AsUKOBcbpF5nkdVRxaQHuuIuwV4k1iK2IqtMNcU8vL6w21U261xCcWwJ6sMq4zzVO8QCKCQhsoIaWrwz828GDmPzfAjFsJiLJXuYivdHACkeJ5KHMt0mjVLpfJ2BCML7_rgbmvwL7wBW80VHfNdcKmKjkLcpEiPzwcQQhiN_qHV90t-p4iyr5xRSpurlP5zic2hlRkLKxMH2_kRjhqSn4aGF1dGhEYXRhWMQ93EcQ6cCIsinbqJ1WMiC7Ofcimv9GWwplaxr7mor4oEEAAAAAAAAAAAAAAAAAAAAAAAAAAABAVHzbxaYaJu2P8m1Y2iHn2gRNHrgK0iYbn9E978L3Qi7Q-chFeicIHwYCRophz5lth2nCgEVKcgWirxlgidgbUaUBAgMmIAEhWCDIkcsOaVKDIQYwq3EDQ-pST2kRwNH_l1nCgW-WcFpNXiJYIBSbummp-KO3qZeqmvZ_U_uirCDL2RNj3E5y4_KzefIr',
clientDataJSON:
- 'eyJjaGFsbGVuZ2UiOiJVMmQ0TjNZME0wOU1jbGRQYjFSNVpFeG5UbG95IiwiY2xpZW50' +
- 'RXh0ZW5zaW9ucyI6e30sImhhc2hBbGdvcml0aG0iOiJTSEEtMjU2Iiwib3JpZ2luIjoiaHR0cHM6Ly9jbG92ZXIu' +
- 'bWlsbGVydGltZS5kZXY6MzAwMCIsInR5cGUiOiJ3ZWJhdXRobi5jcmVhdGUifQ==',
+ 'eyJjaGFsbGVuZ2UiOiJkRzkwWVd4c2VWVnVhWEYxWlZaaGJIVmxSWFpsY25sQmRIUmxjM1JoZEdsdmJnIiwiY2xpZW50RXh0ZW5zaW9ucyI6e30sImhhc2hBbGdvcml0aG0iOiJTSEEtMjU2Iiwib3JpZ2luIjoiaHR0cHM6Ly9kZXYuZG9udG5lZWRhLnB3IiwidHlwZSI6IndlYmF1dGhuLmNyZWF0ZSJ9',
},
getClientExtensionResults: () => ({}),
- type: 'webauthn.create',
+ type: 'public-key',
};
-const attestationFIDOU2FChallenge = 'Sgx7v43OLrWOoTydLgNZ2';
+const attestationFIDOU2FChallenge = 'totallyUniqueValueEveryAttestation';
const attestationPacked = {
id: '',
diff --git a/packages/server/src/attestation/verifyAttestationResponse.ts b/packages/server/src/attestation/verifyAttestationResponse.ts
index ed4ac5c..6779244 100644
--- a/packages/server/src/attestation/verifyAttestationResponse.ts
+++ b/packages/server/src/attestation/verifyAttestationResponse.ts
@@ -1,34 +1,58 @@
-import {
- AttestationCredentialJSON,
-} from '@simplewebauthn/typescript-types';
+import base64url from 'base64url';
+import { AttestationCredentialJSON } from '@simplewebauthn/typescript-types';
import decodeAttestationObject, { ATTESTATION_FORMATS } from '../helpers/decodeAttestationObject';
import decodeClientDataJSON from '../helpers/decodeClientDataJSON';
+import parseAuthenticatorData from '../helpers/parseAuthenticatorData';
+import toHash from '../helpers/toHash';
+import decodeCredentialPublicKey from '../helpers/decodeCredentialPublicKey';
+import convertCOSEtoPKCS, { COSEKEYS } from '../helpers/convertCOSEtoPKCS';
+import { supportedCOSEAlgorithIdentifiers } from './generateAttestationOptions';
import verifyFIDOU2F from './verifications/verifyFIDOU2F';
import verifyPacked from './verifications/verifyPacked';
-import verifyNone from './verifications/verifyNone';
import verifyAndroidSafetynet from './verifications/verifyAndroidSafetyNet';
+type Options = {
+ credential: AttestationCredentialJSON;
+ expectedChallenge: string;
+ expectedOrigin: string;
+ expectedRPID: string;
+ requireUserVerification?: boolean;
+};
+
/**
* Verify that the user has legitimately completed the registration process
*
+ * **Options:**
+ *
* @param response Authenticator attestation response with base64url-encoded values
* @param expectedChallenge The random value provided to generateAttestationOptions for the
* authenticator to sign
- * @param expectedOrigin Expected URL of website attestation should have occurred on
+ * @param expectedOrigin Website URL that the attestation should have occurred on
+ * @param expectedRPID RP ID that was specified in the attestation options
+ * @param requireUserVerification (Optional) Enforce user verification by the authenticator
+ * (via PIN, fingerprint, etc...)
*/
-export default function verifyAttestationResponse(
- credential: AttestationCredentialJSON,
- expectedChallenge: string,
- expectedOrigin: string,
-): VerifiedAttestation {
+export default function verifyAttestationResponse(options: Options): VerifiedAttestation {
+ const {
+ credential,
+ expectedChallenge,
+ expectedOrigin,
+ expectedRPID,
+ requireUserVerification = false,
+ } = options;
const { response } = credential;
- const attestationObject = decodeAttestationObject(response.attestationObject);
const clientDataJSON = decodeClientDataJSON(response.clientDataJSON);
const { type, origin, challenge } = clientDataJSON;
+ // Make sure we're handling an attestation
+ if (type !== 'webauthn.create') {
+ throw new Error(`Unexpected attestation type: ${type}`);
+ }
+
+ // Ensure the device provided the challenge we gave it
if (challenge !== expectedChallenge) {
throw new Error(
`Unexpected attestation challenge "${challenge}", expected "${expectedChallenge}"`,
@@ -40,33 +64,102 @@ export default function verifyAttestationResponse(
throw new Error(`Unexpected attestation origin "${origin}", expected "${expectedOrigin}"`);
}
- // Make sure we're handling an attestation
- if (type !== 'webauthn.create') {
- throw new Error(`Unexpected attestation type: ${type}`);
+ const attestationObject = decodeAttestationObject(response.attestationObject);
+ const { fmt, authData, attStmt } = attestationObject;
+
+ const parsedAuthData = parseAuthenticatorData(authData);
+ const { rpIdHash, flags, credentialID, counter, credentialPublicKey } = parsedAuthData;
+
+ // Make sure the response's RP ID is ours
+ const expectedRPIDHash = toHash(Buffer.from(expectedRPID, 'ascii'));
+ if (!rpIdHash.equals(expectedRPIDHash)) {
+ throw new Error(`Unexpected RP ID hash`);
+ }
+
+ // Make sure someone was physically present
+ if (!flags.up) {
+ throw new Error('User not present during assertion');
+ }
+
+ // Enforce user verification if specified
+ if (requireUserVerification && !flags.uv) {
+ throw new Error('User verification required, but user could not be verified');
+ }
+
+ if (!credentialID) {
+ throw new Error('No credential ID was provided by authenticator');
+ }
+
+ if (!credentialPublicKey) {
+ throw new Error('No public key was provided by authenticator');
+ }
+
+ const decodedPublicKey = decodeCredentialPublicKey(credentialPublicKey);
+ const alg = decodedPublicKey.get(COSEKEYS.alg);
+
+ if (!alg) {
+ throw new Error('Credential public key was missing alg');
+ }
+
+ // Make sure the key algorithm is one we specified within the attestation options
+ if (!supportedCOSEAlgorithIdentifiers.includes(alg as number)) {
+ const supported = supportedCOSEAlgorithIdentifiers.join(', ');
+ throw new Error(`Unexpected public key alg "${alg}", expected one of "${supported}"`);
}
- const { fmt } = attestationObject;
+ const clientDataHash = toHash(base64url.toBuffer(response.clientDataJSON));
/**
* Verification can only be performed when attestation = 'direct'
*/
+ let verified = false;
if (fmt === ATTESTATION_FORMATS.FIDO_U2F) {
- return verifyFIDOU2F(attestationObject, response.clientDataJSON);
+ verified = verifyFIDOU2F({
+ attStmt,
+ clientDataHash,
+ credentialID,
+ credentialPublicKey,
+ rpIdHash,
+ });
+ } else if (fmt === ATTESTATION_FORMATS.PACKED) {
+ verified = verifyPacked({
+ attStmt,
+ authData,
+ clientDataHash,
+ credentialPublicKey,
+ });
+ } else if (fmt === ATTESTATION_FORMATS.ANDROID_SAFETYNET) {
+ verified = verifyAndroidSafetynet({
+ attStmt,
+ authData,
+ clientDataHash,
+ });
+ } else if (fmt === ATTESTATION_FORMATS.NONE) {
+ // This is the weaker of the attestations, so there's nothing else to really check
+ verified = true;
+ } else {
+ throw new Error(`Unsupported Attestation Format: ${fmt}`);
}
- if (fmt === ATTESTATION_FORMATS.PACKED) {
- return verifyPacked(attestationObject, response.clientDataJSON);
- }
+ const toReturn: VerifiedAttestation = {
+ verified,
+ userVerified: flags.uv,
+ };
- if (fmt === ATTESTATION_FORMATS.ANDROID_SAFETYNET) {
- return verifyAndroidSafetynet(attestationObject, response.clientDataJSON);
- }
+ if (toReturn.verified) {
+ toReturn.userVerified = flags.uv;
+
+ const publicKey = convertCOSEtoPKCS(credentialPublicKey);
- if (fmt === ATTESTATION_FORMATS.NONE) {
- return verifyNone(attestationObject);
+ toReturn.authenticatorInfo = {
+ fmt,
+ counter,
+ base64PublicKey: base64url.encode(publicKey),
+ base64CredentialID: base64url.encode(credentialID),
+ };
}
- throw new Error(`Unsupported Attestation Format: ${fmt}`);
+ return toReturn;
}
/**
diff --git a/packages/server/src/helpers/asciiToBinary.ts b/packages/server/src/helpers/asciiToBinary.ts
deleted file mode 100644
index beb6f1d..0000000
--- a/packages/server/src/helpers/asciiToBinary.ts
+++ /dev/null
@@ -1,8 +0,0 @@
-/**
- * Decode a base64-encoded string to a binary string
- *
- * @param input Base64-encoded string
- */
-export default function asciiToBinary(input: string): string {
- return Buffer.from(input, 'base64').toString('binary');
-}
diff --git a/packages/server/src/helpers/decodeAttestationObject.test.ts b/packages/server/src/helpers/decodeAttestationObject.test.ts
index e8eb364..2f88f2a 100644
--- a/packages/server/src/helpers/decodeAttestationObject.test.ts
+++ b/packages/server/src/helpers/decodeAttestationObject.test.ts
@@ -1,6 +1,6 @@
import decodeAttestationObject from './decodeAttestationObject';
-test('should decode base64-encoded indirect attestationObject', () => {
+test('should decode base64url-encoded indirect attestationObject', () => {
const decoded = decodeAttestationObject(
'o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YVjEAbElFazplpnc037DORGDZNjDq86cN9vm6' +
'+APoAM20wtBAAAAAAAAAAAAAAAAAAAAAAAAAAAAQKmPuEwByQJ3e89TccUSrCGDkNWquhevjLLn/' +
@@ -13,7 +13,7 @@ test('should decode base64-encoded indirect attestationObject', () => {
expect(decoded.authData).toBeDefined();
});
-test('should decode base64-encoded direct attestationObject', () => {
+test('should decode base64url-encoded direct attestationObject', () => {
const decoded = decodeAttestationObject(
'o2NmbXRoZmlkby11MmZnYXR0U3RtdKJjc2lnWEgwRgIhAK40WxA0t7py7AjEXvwGwTlmqlvrOk' +
's5g9lf+9zXzRiVAiEA3bv60xyXveKDOusYzniD7CDSostCet9PYK7FLdnTdZNjeDVjgVkCwTCCAr0wggGloAMCAQICBCrn' +
diff --git a/packages/server/src/helpers/decodeAttestationObject.ts b/packages/server/src/helpers/decodeAttestationObject.ts
index 2eb9997..e5accdd 100644
--- a/packages/server/src/helpers/decodeAttestationObject.ts
+++ b/packages/server/src/helpers/decodeAttestationObject.ts
@@ -23,10 +23,12 @@ export enum ATTESTATION_FORMATS {
export type AttestationObject = {
fmt: ATTESTATION_FORMATS;
- attStmt: {
- sig?: Buffer;
- x5c?: Buffer[];
- response?: Buffer;
- };
+ attStmt: AttestationStatement;
authData: Buffer;
};
+
+export type AttestationStatement = {
+ sig?: Buffer;
+ x5c?: Buffer[];
+ response?: Buffer;
+};
diff --git a/packages/server/src/helpers/decodeClientDataJSON.test.ts b/packages/server/src/helpers/decodeClientDataJSON.test.ts
index 7674ec5..b1a7940 100644
--- a/packages/server/src/helpers/decodeClientDataJSON.test.ts
+++ b/packages/server/src/helpers/decodeClientDataJSON.test.ts
@@ -1,6 +1,6 @@
import decodeClientDataJSON from './decodeClientDataJSON';
-test('should convert base64-encoded attestation clientDataJSON to JSON', () => {
+test('should convert base64url-encoded attestation clientDataJSON to JSON', () => {
expect(
decodeClientDataJSON(
'eyJjaGFsbGVuZ2UiOiJVMmQ0TjNZME0wOU1jbGRQYjFSNVpFeG5UbG95IiwiY2xpZW50RXh0ZW5zaW9ucyI6e30' +
diff --git a/packages/server/src/helpers/decodeClientDataJSON.ts b/packages/server/src/helpers/decodeClientDataJSON.ts
index c0ebb2b..52bbf4c 100644
--- a/packages/server/src/helpers/decodeClientDataJSON.ts
+++ b/packages/server/src/helpers/decodeClientDataJSON.ts
@@ -1,15 +1,15 @@
-import asciiToBinary from './asciiToBinary';
+import base64url from 'base64url';
/**
- * Decode an authenticator's base64-encoded clientDataJSON to JSON
+ * Decode an authenticator's base64url-encoded clientDataJSON to JSON
*/
export default function decodeClientDataJSON(data: string): ClientDataJSON {
- const toString = asciiToBinary(data);
+ const toString = base64url.decode(data);
const clientData: ClientDataJSON = JSON.parse(toString);
- // `challenge` will be Base64-encoded here. Decode it for easier comparisons with what is provided
- // as the expected value
- clientData.challenge = Buffer.from(clientData.challenge, 'base64').toString('ascii');
+ // `challenge` will be Base64URL-encoded here. Decode it for easier comparisons with what is
+ // provided as the expected value
+ clientData.challenge = base64url.decode(clientData.challenge);
return clientData;
}
diff --git a/packages/server/src/helpers/decodeCredentialPublicKey.ts b/packages/server/src/helpers/decodeCredentialPublicKey.ts
new file mode 100644
index 0000000..a856a72
--- /dev/null
+++ b/packages/server/src/helpers/decodeCredentialPublicKey.ts
@@ -0,0 +1,7 @@
+import cbor from 'cbor';
+
+import { COSEPublicKey } from './convertCOSEtoPKCS';
+
+export default function decodeCredentialPublicKey(publicKey: Buffer): COSEPublicKey {
+ return cbor.decodeFirstSync(publicKey);
+}
diff --git a/packages/server/src/helpers/parseAuthenticatorData.ts b/packages/server/src/helpers/parseAuthenticatorData.ts
index 3177dd5..e177002 100644
--- a/packages/server/src/helpers/parseAuthenticatorData.ts
+++ b/packages/server/src/helpers/parseAuthenticatorData.ts
@@ -27,7 +27,7 @@ export default function parseAuthenticatorData(authData: Buffer): ParsedAuthenti
let aaguid: Buffer | undefined = undefined;
let credentialID: Buffer | undefined = undefined;
- let COSEPublicKey: Buffer | undefined = undefined;
+ let credentialPublicKey: Buffer | undefined = undefined;
if (flags.at) {
aaguid = intBuffer.slice(0, 16);
@@ -41,7 +41,7 @@ export default function parseAuthenticatorData(authData: Buffer): ParsedAuthenti
credentialID = intBuffer.slice(0, credIDLen);
intBuffer = intBuffer.slice(credIDLen);
- COSEPublicKey = intBuffer;
+ credentialPublicKey = intBuffer;
}
return {
@@ -52,11 +52,11 @@ export default function parseAuthenticatorData(authData: Buffer): ParsedAuthenti
counterBuf,
aaguid,
credentialID,
- COSEPublicKey,
+ credentialPublicKey,
};
}
-type ParsedAuthenticatorData = {
+export type ParsedAuthenticatorData = {
rpIdHash: Buffer;
flagsBuf: Buffer;
flags: {
@@ -70,5 +70,5 @@ type ParsedAuthenticatorData = {
counterBuf: Buffer;
aaguid?: Buffer;
credentialID?: Buffer;
- COSEPublicKey?: Buffer;
+ credentialPublicKey?: Buffer;
};