summaryrefslogtreecommitdiffhomepage
path: root/packages/server/src
diff options
context:
space:
mode:
authorJarrett Helton <jaydhelton@gmail.com>2021-08-24 18:40:09 -0400
committerJarrett Helton <jaydhelton@gmail.com>2021-08-24 18:40:09 -0400
commit22260e63c0b2a91d8f5db4000304e73b2bff9277 (patch)
tree87c3ecded4690c29cb474d0d6cd69d5ac7ef5fe6 /packages/server/src
parent2bb27c6febdbacbd7bbe4356318a6b3fa6fd84db (diff)
parent30ecc73b9856747337523f1e367b10d9d96a4a95 (diff)
Merge remote-tracking branch 'origin/master' into v4/rename-methods-and-types
Diffstat (limited to 'packages/server/src')
-rw-r--r--packages/server/src/helpers/constants.ts70
-rw-r--r--packages/server/src/helpers/decodeClientDataJSON.ts2
-rw-r--r--packages/server/src/helpers/index.ts55
-rw-r--r--packages/server/src/helpers/isCertRevoked.ts7
-rw-r--r--packages/server/src/helpers/parseAuthenticatorData.test.ts53
-rw-r--r--packages/server/src/helpers/parseAuthenticatorData.ts7
-rw-r--r--packages/server/src/index.ts2
-rw-r--r--packages/server/src/metadata/mdsTypes.ts294
-rw-r--r--packages/server/src/metadata/verifyAttestationWithMetadata.ts73
-rw-r--r--packages/server/src/registration/verifications/verifyAndroidKey.test.ts2
-rw-r--r--packages/server/src/registration/verifications/verifyAndroidSafetyNet.test.ts2
-rw-r--r--packages/server/src/registration/verifications/verifyPacked.ts6
-rw-r--r--packages/server/src/registration/verifyRegistrationResponse.test.ts2
-rw-r--r--packages/server/src/registration/verifyRegistrationResponse.ts2
-rw-r--r--packages/server/src/services/defaultRootCerts/mds.ts32
-rw-r--r--packages/server/src/services/metadataService.test.ts133
-rw-r--r--packages/server/src/services/metadataService.ts257
-rw-r--r--packages/server/src/services/settingsService.test.ts10
-rw-r--r--packages/server/src/services/settingsService.ts28
19 files changed, 746 insertions, 291 deletions
diff --git a/packages/server/src/helpers/constants.ts b/packages/server/src/helpers/constants.ts
deleted file mode 100644
index 185f058..0000000
--- a/packages/server/src/helpers/constants.ts
+++ /dev/null
@@ -1,70 +0,0 @@
-type COSEInfo = {
- kty: number;
- alg: number;
- crv?: number;
-};
-
-/**
- * A mapping of ALG_SIGN hex values (as unsigned shorts) to COSE curve values. Keys should appear as
- * values in a metadata statement's `authenticationAlgorithm` property.
- *
- * From https://fidoalliance.org/specs/fido-v2.0-rd-20180702/fido-registry-v2.0-rd-20180702.html
- * FIDO Registry of Predefined Values - 3.6.1 Authentication Algorithms
- */
-export const FIDO_METADATA_AUTH_ALG_TO_COSE: { [algKey: number]: COSEInfo } = {
- // ALG_SIGN_SECP256R1_ECDSA_SHA256_RAW
- 1: { kty: 2, alg: -7, crv: 1 },
- // ALG_SIGN_RSASSA_PSS_SHA256_RAW
- 3: { kty: 3, alg: -37 },
- // ALG_SIGN_SECP256K1_ECDSA_SHA256_RAW
- 5: { kty: 2, alg: -7, crv: 8 },
- // ALG_SIGN_RSASSA_PSS_SHA384_RAW
- 10: { kty: 3, alg: -38 },
- // ALG_SIGN_RSASSA_PSS_SHA512_RAW
- 11: { kty: 3, alg: -39 },
- // ALG_SIGN_RSASSA_PKCSV15_SHA256_RAW
- 12: { kty: 3, alg: -257 },
- // ALG_SIGN_RSASSA_PKCSV15_SHA384_RAW
- 13: { kty: 3, alg: -258 },
- // ALG_SIGN_RSASSA_PKCSV15_SHA512_RAW
- 14: { kty: 3, alg: -259 },
- // ALG_SIGN_RSASSA_PKCSV15_SHA1_RAW
- 15: { kty: 3, alg: -65535 },
- // ALG_SIGN_SECP384R1_ECDSA_SHA384_RAW
- 16: { kty: 2, alg: -35, crv: 2 },
- // ALG_SIGN_SECP521R1_ECDSA_SHA512_RAW
- 17: { kty: 2, alg: -36, crv: 3 },
- // ALG_SIGN_SECP521R1_ECDSA_SHA512_RAW
- 18: { kty: 1, alg: -8, crv: 6 },
-};
-
-/**
- * A map of ATTESTATION hex values (as unsigned shorts). Values should appear in a metadata
- * statement's `attestationTypes` property.
- *
- * From https://fidoalliance.org/specs/fido-v2.0-rd-20180702/fido-registry-v2.0-rd-20180702.html
- * FIDO Registry of Predefined Values - 3.6.3 Authenticator Attestation Types
- */
-export enum FIDO_METADATA_ATTESTATION_TYPES {
- ATTESTATION_BASIC_FULL = 15879,
- // Self attestation
- ATTESTATION_BASIC_SURROGATE = 15880,
- ATTESTATION_ECDAA = 15881,
- ATTESTATION_ATTCA = 15882,
-}
-
-export type FIDO_AUTHENTICATOR_STATUS =
- | 'NOT_FIDO_CERTIFIED'
- | 'FIDO_CERTIFIED'
- | 'USER_VERIFICATION_BYPASS'
- | 'ATTESTATION_KEY_COMPROMISE'
- | 'USER_KEY_REMOTE_COMPROMISE'
- | 'USER_KEY_PHYSICAL_COMPROMISE'
- | 'UPDATE_AVAILABLE'
- | 'REVOKED'
- | 'SELF_ASSERTION_SUBMITTED'
- | 'FIDO_CERTIFIED_L1'
- | 'FIDO_CERTIFIED_L2'
- | 'FIDO_CERTIFIED_L3'
- | 'FIDO_CERTIFIED_L4'
- | 'FIDO_CERTIFIED_L5';
diff --git a/packages/server/src/helpers/decodeClientDataJSON.ts b/packages/server/src/helpers/decodeClientDataJSON.ts
index a3b7881..40ce0c0 100644
--- a/packages/server/src/helpers/decodeClientDataJSON.ts
+++ b/packages/server/src/helpers/decodeClientDataJSON.ts
@@ -10,7 +10,7 @@ export default function decodeClientDataJSON(data: string): ClientDataJSON {
return clientData;
}
-type ClientDataJSON = {
+export type ClientDataJSON = {
type: string;
challenge: string;
origin: string;
diff --git a/packages/server/src/helpers/index.ts b/packages/server/src/helpers/index.ts
new file mode 100644
index 0000000..248a31f
--- /dev/null
+++ b/packages/server/src/helpers/index.ts
@@ -0,0 +1,55 @@
+import convertAAGUIDToString from './convertAAGUIDToString';
+import convertCertBufferToPEM from './convertCertBufferToPEM';
+import convertCOSEtoPKCS from './convertCOSEtoPKCS';
+import convertPublicKeyToPEM from './convertPublicKeyToPEM';
+import decodeAttestationObject from './decodeAttestationObject';
+import { decodeCborFirst } from './decodeCbor';
+import decodeClientDataJSON from './decodeClientDataJSON';
+import decodeCredentialPublicKey from './decodeCredentialPublicKey';
+import generateChallenge from './generateChallenge';
+import getCertificateInfo from './getCertificateInfo';
+import isBase64URLString from './isBase64URLString';
+import isCertRevoked from './isCertRevoked';
+import parseAuthenticatorData from './parseAuthenticatorData';
+import toHash from './toHash';
+import validateCertificatePath from './validateCertificatePath';
+import verifySignature from './verifySignature';
+
+export {
+ convertAAGUIDToString,
+ convertCertBufferToPEM,
+ convertCOSEtoPKCS,
+ convertPublicKeyToPEM,
+ decodeAttestationObject,
+ decodeCborFirst,
+ decodeClientDataJSON,
+ decodeCredentialPublicKey,
+ generateChallenge,
+ getCertificateInfo,
+ isBase64URLString,
+ isCertRevoked,
+ parseAuthenticatorData,
+ toHash,
+ validateCertificatePath,
+ verifySignature,
+};
+
+import type {
+ AttestationFormat,
+ AttestationObject,
+ AttestationStatement,
+} from './decodeAttestationObject';
+import type { CertificateInfo } from './getCertificateInfo';
+import type { ClientDataJSON } from './decodeClientDataJSON';
+import type { COSEPublicKey } from './convertCOSEtoPKCS';
+import type { ParsedAuthenticatorData } from './parseAuthenticatorData';
+
+export type {
+ AttestationFormat,
+ AttestationObject,
+ AttestationStatement,
+ CertificateInfo,
+ ClientDataJSON,
+ COSEPublicKey,
+ ParsedAuthenticatorData,
+};
diff --git a/packages/server/src/helpers/isCertRevoked.ts b/packages/server/src/helpers/isCertRevoked.ts
index e3113b7..0e44017 100644
--- a/packages/server/src/helpers/isCertRevoked.ts
+++ b/packages/server/src/helpers/isCertRevoked.ts
@@ -3,6 +3,8 @@ import fetch from 'node-fetch';
import { AsnParser } from '@peculiar/asn1-schema';
import { CertificateList } from '@peculiar/asn1-x509';
+import convertCertBufferToPEM from './convertCertBufferToPEM';
+
/**
* A cache of revoked cert serial numbers by Authority Key ID
*/
@@ -59,8 +61,9 @@ export default async function isCertRevoked(cert: X509): Promise<boolean> {
const crlCert = new X509();
try {
const respCRL = await fetch(crlURL[0]);
- const dataCRL = await respCRL.text();
- crlCert.readCertPEM(dataCRL);
+ const dataCRL = await respCRL.buffer();
+ const dataPEM = convertCertBufferToPEM(dataCRL);
+ crlCert.readCertPEM(dataPEM);
} catch (err) {
return false;
}
diff --git a/packages/server/src/helpers/parseAuthenticatorData.test.ts b/packages/server/src/helpers/parseAuthenticatorData.test.ts
new file mode 100644
index 0000000..3bba4b6
--- /dev/null
+++ b/packages/server/src/helpers/parseAuthenticatorData.test.ts
@@ -0,0 +1,53 @@
+import cbor from 'cbor';
+
+import parseAuthenticatorData from './parseAuthenticatorData';
+
+// Grabbed this from a Conformance test, contains attestation data
+const authDataWithAT = Buffer.from(
+ 'SZYN5YgOjGh0NBcPZHZgW4/krrmihjLHmVzzuoMdl2NBAAAAJch83ZdWwUm4niTLNjZU81AAIHa7Ksm5br3hAh3UjxP9+4rqu8BEsD+7SZ2xWe1/yHv6pAEDAzkBACBZAQDcxA7Ehs9goWB2Hbl6e9v+aUub9rvy2M7Hkvf+iCzMGE63e3sCEW5Ru33KNy4um46s9jalcBHtZgtEnyeRoQvszis+ws5o4Da0vQfuzlpBmjWT1dV6LuP+vs9wrfObW4jlA5bKEIhv63+jAxOtdXGVzo75PxBlqxrmrr5IR9n8Fw7clwRsDkjgRHaNcQVbwq/qdNwU5H3hZKu9szTwBS5NGRq01EaDF2014YSTFjwtAmZ3PU1tcO/QD2U2zg6eB5grfWDeAJtRE8cbndDWc8aLL0aeC37Q36+TVsGe6AhBgHEw6eO3I3NW5r9v/26CqMPBDwmEundeq1iGyKfMloobIUMBAAE=',
+ 'base64',
+);
+// Grabbed this from a Conformance test, contains extension data
+const authDataWithED = Buffer.from(
+ 'SZYN5YgOjGh0NBcPZHZgW4/krrmihjLHmVzzuoMdl2OBAAAAjaFxZXhhbXBsZS5leHRlbnNpb254dlRoaXMgaXMgYW4gZXhhbXBsZSBleHRlbnNpb24hIElmIHlvdSByZWFkIHRoaXMgbWVzc2FnZSwgeW91IHByb2JhYmx5IHN1Y2Nlc3NmdWxseSBwYXNzaW5nIGNvbmZvcm1hbmNlIHRlc3RzLiBHb29kIGpvYiE=',
+ 'base64',
+);
+
+test('should parse flags', () => {
+ const parsed = parseAuthenticatorData(authDataWithED);
+
+ const { flags } = parsed;
+
+ expect(flags.up).toEqual(true);
+ expect(flags.uv).toEqual(false);
+ expect(flags.at).toEqual(false);
+ expect(flags.ed).toEqual(true);
+});
+
+test('should parse attestation data', () => {
+ const parsed = parseAuthenticatorData(authDataWithAT);
+
+ const { credentialID, credentialPublicKey, aaguid } = parsed;
+
+ expect(credentialID?.toString('base64')).toEqual('drsqybluveECHdSPE/37iuq7wESwP7tJnbFZ7X/Ie/o=');
+ expect(credentialPublicKey?.toString('base64')).toEqual(
+ 'pAEDAzkBACBZAQDcxA7Ehs9goWB2Hbl6e9v+aUub9rvy2M7Hkvf+iCzMGE63e3sCEW5Ru33KNy4um46s9jalcBHtZgtEnyeRoQvszis+ws5o4Da0vQfuzlpBmjWT1dV6LuP+vs9wrfObW4jlA5bKEIhv63+jAxOtdXGVzo75PxBlqxrmrr5IR9n8Fw7clwRsDkjgRHaNcQVbwq/qdNwU5H3hZKu9szTwBS5NGRq01EaDF2014YSTFjwtAmZ3PU1tcO/QD2U2zg6eB5grfWDeAJtRE8cbndDWc8aLL0aeC37Q36+TVsGe6AhBgHEw6eO3I3NW5r9v/26CqMPBDwmEundeq1iGyKfMloobIUMBAAE=',
+ );
+ expect(aaguid?.toString('base64')).toEqual('yHzdl1bBSbieJMs2NlTzUA==');
+});
+
+test('should parse extension data', () => {
+ expect.assertions(1);
+
+ const parsed = parseAuthenticatorData(authDataWithED);
+
+ const { extensionsDataBuffer } = parsed;
+
+ if (extensionsDataBuffer) {
+ const decoded = cbor.decodeFirstSync(extensionsDataBuffer);
+ expect(decoded).toEqual({
+ 'example.extension':
+ 'This is an example extension! If you read this message, you probably successfully passing conformance tests. Good job!',
+ });
+ }
+});
diff --git a/packages/server/src/helpers/parseAuthenticatorData.ts b/packages/server/src/helpers/parseAuthenticatorData.ts
index 9b13195..911c9e0 100644
--- a/packages/server/src/helpers/parseAuthenticatorData.ts
+++ b/packages/server/src/helpers/parseAuthenticatorData.ts
@@ -45,17 +45,18 @@ export default function parseAuthenticatorData(authData: Buffer): ParsedAuthenti
const firstDecoded = decodeCborFirst(authData.slice(pointer));
const firstEncoded = Buffer.from(cbor.encode(firstDecoded) as ArrayBuffer);
credentialPublicKey = firstEncoded;
- authData = authData.slice((pointer += firstEncoded.byteLength));
+ pointer += firstEncoded.byteLength;
}
let extensionsDataBuffer: Buffer | undefined = undefined;
if (flags.ed) {
- const firstDecoded = decodeCborFirst(authData);
+ const firstDecoded = decodeCborFirst(authData.slice(pointer));
const firstEncoded = Buffer.from(cbor.encode(firstDecoded) as ArrayBuffer);
extensionsDataBuffer = firstEncoded;
- authData = authData.slice((pointer += firstEncoded.byteLength));
+ pointer += firstEncoded.byteLength;
}
+ // Pointer should be at the end of the authenticator data, otherwise too much data was sent
if (authData.byteLength > pointer) {
throw new Error('Leftover bytes detected while parsing authenticator data');
}
diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts
index 285c25d..25228b7 100644
--- a/packages/server/src/index.ts
+++ b/packages/server/src/index.ts
@@ -20,7 +20,7 @@ export {
import type { GenerateRegistrationOptionsOpts } from './registration/generateRegistrationOptions';
import type { GenerateAuthenticationOptionsOpts } from './authentication/generateAuthenticationOptions';
-import type { MetadataStatement } from './services/metadataService';
+import type { MetadataStatement } from './metadata/mdsTypes';
import type {
VerifiedRegistrationResponse,
VerifyRegistrationResponseOpts,
diff --git a/packages/server/src/metadata/mdsTypes.ts b/packages/server/src/metadata/mdsTypes.ts
new file mode 100644
index 0000000..22ba564
--- /dev/null
+++ b/packages/server/src/metadata/mdsTypes.ts
@@ -0,0 +1,294 @@
+import { Base64URLString } from '@simplewebauthn/typescript-types';
+
+/**
+ * Metadata Service structures
+ * https://fidoalliance.org/specs/mds/fido-metadata-service-v3.0-ps-20210518.html
+ */
+export type MDSJWTHeader = {
+ alg: string;
+ typ: string;
+ x5c: Base64URLString[];
+};
+
+export type MDSJWTPayload = {
+ legalHeader: string;
+ no: number;
+ nextUpdate: string; // YYYY-MM-DD
+ entries: MetadataBLOBPayloadEntry[];
+};
+
+export type MetadataBLOBPayloadEntry = {
+ aaid?: string;
+ aaguid?: string;
+ attestationCertificateKeyIdentifiers?: string[];
+ metadataStatement?: MetadataStatement;
+ biometricStatusReports?: BiometricStatusReport[];
+ statusReports: StatusReport[];
+ timeOfLastStatusChange: string; // YYYY-MM-DD
+ rogueListURL?: string;
+ rogueListHash?: string;
+};
+
+export type BiometricStatusReport = {
+ certLevel: number;
+ modality: UserVerify;
+ effectiveDate?: string;
+ certificationDescriptor?: string;
+ certificateNumber?: string;
+ certificationPolicyVersion?: string;
+ certificationRequirementsVersion?: string;
+};
+
+export type StatusReport = {
+ status: AuthenticatorStatus;
+ effectiveDate?: string; // YYYY-MM-DD
+ authenticatorVersion?: number;
+ certificate?: string;
+ url?: string;
+ certificationDescriptor?: string;
+ certificateNumber?: string;
+ certificationPolicyVersion?: string;
+ certificationRequirementsVersion?: string;
+};
+
+export type AuthenticatorStatus =
+ | 'NOT_FIDO_CERTIFIED'
+ | 'FIDO_CERTIFIED'
+ | 'USER_VERIFICATION_BYPASS'
+ | 'ATTESTATION_KEY_COMPROMISE'
+ | 'USER_KEY_REMOTE_COMPROMISE'
+ | 'USER_KEY_PHYSICAL_COMPROMISE'
+ | 'UPDATE_AVAILABLE'
+ | 'REVOKED'
+ | 'SELF_ASSERTION_SUBMITTED'
+ | 'FIDO_CERTIFIED_L1'
+ | 'FIDO_CERTIFIED_L1plus'
+ | 'FIDO_CERTIFIED_L2'
+ | 'FIDO_CERTIFIED_L2plus'
+ | 'FIDO_CERTIFIED_L3'
+ | 'FIDO_CERTIFIED_L3plus';
+
+/**
+ * Types defined in the FIDO Metadata Statement spec
+ *
+ * See https://fidoalliance.org/specs/mds/fido-metadata-statement-v3.0-ps-20210518.html
+ */
+export type CodeAccuracyDescriptor = {
+ base: number;
+ minLength: number;
+ maxRetries?: number;
+ blockSlowdown?: number;
+};
+
+export type BiometricAccuracyDescriptor = {
+ selfAttestedFRR?: number;
+ selfAttestedFAR?: number;
+ maxTemplates?: number;
+ maxRetries?: number;
+ blockSlowdown?: number;
+};
+
+export type PatternAccuracyDescriptor = {
+ minComplexity: number;
+ maxRetries?: number;
+ blockSlowdown?: number;
+};
+
+export type VerificationMethodDescriptor = {
+ userVerificationMethod: UserVerify;
+ caDesc?: CodeAccuracyDescriptor;
+ baDesc?: BiometricAccuracyDescriptor;
+ paDesc?: PatternAccuracyDescriptor;
+};
+
+export type VerificationMethodANDCombinations = VerificationMethodDescriptor[];
+
+export type rgbPaletteEntry = {
+ r: number;
+ g: number;
+ b: number;
+};
+
+export type DisplayPNGCharacteristicsDescriptor = {
+ width: number;
+ height: number;
+ bitDepth: number;
+ colorType: number;
+ compression: number;
+ filter: number;
+ interlace: number;
+ plte?: rgbPaletteEntry[];
+};
+
+export type EcdaaTrustAnchor = {
+ X: string;
+ Y: string;
+ c: string;
+ sx: string;
+ sy: string;
+ G1Curve: string;
+};
+
+export type ExtensionDescriptor = {
+ id: string;
+ tag?: number;
+ data?: string;
+ fail_if_unknown: boolean;
+};
+
+// langCode -> "en-US", "ja-JP", etc...
+export type AlternativeDescriptions = { [langCode: string]: string };
+
+export type MetadataStatement = {
+ legalHeader?: string;
+ aaid?: string;
+ aaguid?: string;
+ attestationCertificateKeyIdentifiers?: string[];
+ description: string;
+ alternativeDescriptions?: AlternativeDescriptions;
+ authenticatorVersion: number;
+ protocolFamily: string;
+ schema: number;
+ upv: Version[];
+ authenticationAlgorithms: AlgSign[];
+ publicKeyAlgAndEncodings: AlgKey[];
+ attestationTypes: Attestation[];
+ userVerificationDetails: VerificationMethodANDCombinations[];
+ keyProtection: KeyProtection[];
+ isKeyRestricted?: boolean;
+ isFreshUserVerificationRequired?: boolean;
+ matcherProtection: MatcherProtection[];
+ cryptoStrength?: number;
+ attachmentHint?: AttachmentHint[];
+ tcDisplay: TransactionConfirmationDisplay[];
+ tcDisplayContentType?: string;
+ tcDisplayPNGCharacteristics?: DisplayPNGCharacteristicsDescriptor[];
+ attestationRootCertificates: string[];
+ ecdaaTrustAnchors?: EcdaaTrustAnchor[];
+ icon?: string;
+ supportedExtensions?: ExtensionDescriptor[];
+ authenticatorGetInfo?: AuthenticatorGetInfo;
+};
+
+/**
+ * Types declared in other specs
+ */
+
+/**
+ * USER_VERIFY
+ * https://fidoalliance.org/specs/common-specs/fido-registry-v2.1-ps-20191217.html#user-verification-methods
+ */
+export type UserVerify =
+ | 'presence_internal'
+ | 'fingerprint_internal'
+ | 'passcode_internal'
+ | 'voiceprint_internal'
+ | 'faceprint_internal'
+ | 'location_internal'
+ | 'eyeprint_internal'
+ | 'pattern_internal'
+ | 'handprint_internal'
+ | 'passcode_external'
+ | 'pattern_external'
+ | 'none'
+ | 'all';
+
+/**
+ * ALG_SIGN
+ * https://fidoalliance.org/specs/common-specs/fido-registry-v2.1-ps-20191217.html#authentication-algorithms
+ */
+export type AlgSign =
+ | 'secp256r1_ecdsa_sha256_raw'
+ | 'secp256r1_ecdsa_sha256_der'
+ | 'rsassa_pss_sha256_raw'
+ | 'rsassa_pss_sha256_der'
+ | 'secp256k1_ecdsa_sha256_raw'
+ | 'secp256k1_ecdsa_sha256_der'
+ | 'sm2_sm3_raw'
+ | 'rsa_emsa_pkcs1_sha256_raw'
+ | 'rsa_emsa_pkcs1_sha256_der'
+ | 'rsassa_pss_sha384_raw'
+ | 'rsassa_pss_sha256_raw'
+ | 'rsassa_pkcsv15_sha256_raw'
+ | 'rsassa_pkcsv15_sha384_raw'
+ | 'rsassa_pkcsv15_sha512_raw'
+ | 'rsassa_pkcsv15_sha1_raw'
+ | 'secp384r1_ecdsa_sha384_raw'
+ | 'secp512r1_ecdsa_sha256_raw'
+ | 'ed25519_eddsa_sha512_raw';
+
+/**
+ * ALG_KEY
+ * https://fidoalliance.org/specs/common-specs/fido-registry-v2.1-ps-20191217.html#public-key-representation-formats
+ */
+export type AlgKey = 'ecc_x962_raw' | 'ecc_x962_der' | 'rsa_2048_raw' | 'rsa_2048_der' | 'cose';
+
+/**
+ * ATTESTATION
+ * https://fidoalliance.org/specs/common-specs/fido-registry-v2.1-ps-20191217.html#authenticator-attestation-types
+ */
+export type Attestation = 'basic_full' | 'basic_surrogate' | 'ecdaa' | 'attca';
+
+/**
+ * KEY_PROTECTION
+ * https://fidoalliance.org/specs/common-specs/fido-registry-v2.1-ps-20191217.html#key-protection-types
+ */
+export type KeyProtection = 'software' | 'hardware' | 'tee' | 'secure_element' | 'remote_handle';
+
+/**
+ * MATCHER_PROTECTION
+ * https://fidoalliance.org/specs/common-specs/fido-registry-v2.1-ps-20191217.html#matcher-protection-types
+ */
+export type MatcherProtection = 'software' | 'tee' | 'on_chip';
+
+/**
+ * ATTACHMENT_HINT
+ * https://fidoalliance.org/specs/common-specs/fido-registry-v2.1-ps-20191217.html#authenticator-attachment-hints
+ */
+export type AttachmentHint =
+ | 'internal'
+ | 'external'
+ | 'wired'
+ | 'wireless'
+ | 'nfc'
+ | 'bluetooth'
+ | 'network'
+ | 'ready'
+ | 'wifi_direct';
+
+/**
+ * TRANSACTION_CONFIRMATION_DISPLAY
+ * https://fidoalliance.org/specs/common-specs/fido-registry-v2.1-ps-20191217.html#transaction-confirmation-display-types
+ */
+export type TransactionConfirmationDisplay =
+ | 'any'
+ | 'privileged_software'
+ | 'tee'
+ | 'hardware'
+ | 'remote';
+
+/**
+ * https://fidoalliance.org/specs/fido-uaf-v1.2-ps-20201020/fido-uaf-protocol-v1.2-ps-20201020.html#version-interface
+ */
+export type Version = {
+ major: number;
+ minor: number;
+};
+
+/**
+ * https://fidoalliance.org/specs/fido-v2.0-ps-20190130/fido-client-to-authenticator-protocol-v2.0-ps-20190130.html#authenticatorGetInfoz
+ */
+export type AuthenticatorGetInfo = {
+ versions: ('FIDO_2_0' | 'U2F_V2')[];
+ extensions?: string[];
+ aaguid: string;
+ options?: {
+ plat?: boolean;
+ rk?: boolean;
+ clientPin?: boolean;
+ up?: boolean;
+ uv?: boolean;
+ };
+ maxMsgSize?: number;
+ pinProtocols?: number[];
+};
diff --git a/packages/server/src/metadata/verifyAttestationWithMetadata.ts b/packages/server/src/metadata/verifyAttestationWithMetadata.ts
index 5e0b01c..83c7989 100644
--- a/packages/server/src/metadata/verifyAttestationWithMetadata.ts
+++ b/packages/server/src/metadata/verifyAttestationWithMetadata.ts
@@ -1,19 +1,32 @@
import { Base64URLString } from '@simplewebauthn/typescript-types';
-import { MetadataStatement } from '../services/metadataService';
-import { FIDO_METADATA_AUTH_ALG_TO_COSE } from '../helpers/constants';
+import { MetadataStatement, AlgSign } from '../metadata/mdsTypes';
import convertCertBufferToPEM from '../helpers/convertCertBufferToPEM';
import validateCertificatePath from '../helpers/validateCertificatePath';
+/**
+ * Match properties of the authenticator's attestation statement against expected values as
+ * registered with the FIDO Alliance Metadata Service
+ */
export default async function verifyAttestationWithMetadata(
statement: MetadataStatement,
alg: number,
x5c: Buffer[] | Base64URLString[],
): Promise<boolean> {
- // Make sure the alg in the attestation statement matches the one specified in the metadata
- const metaCOSE = FIDO_METADATA_AUTH_ALG_TO_COSE[statement.authenticationAlgorithm];
- if (metaCOSE.alg !== alg) {
- throw new Error(`Attestation alg "${alg}" did not match metadata auth alg "${metaCOSE.alg}"`);
+ // Make sure the alg in the attestation statement matches one of the ones specified in metadata
+ const statementCOSEAlgs: Set<number> = new Set();
+ statement.authenticationAlgorithms.forEach(algSign => {
+ // Convert algSign string to { kty, alg, crv }
+ const algSignCOSEINFO = algSignToCOSEInfo(algSign);
+
+ if (algSignCOSEINFO) {
+ statementCOSEAlgs.add(algSignCOSEINFO.alg);
+ }
+ });
+
+ if (!statementCOSEAlgs.has(alg)) {
+ const debugAlgs = Array.from(statementCOSEAlgs).join(', ');
+ throw new Error(`Attestation alg "${alg}" did not match metadata auth algs [${debugAlgs}]`);
}
try {
@@ -27,3 +40,51 @@ export default async function verifyAttestationWithMetadata(
return true;
}
+
+type COSEInfo = {
+ kty: number;
+ alg: number;
+ crv?: number;
+};
+
+/**
+ * Convert ALG_SIGN values to COSE info
+ * https://fidoalliance.org/specs/common-specs/fido-registry-v2.1-ps-20191217.html#authentication-algorithms
+ */
+function algSignToCOSEInfo(algSign: AlgSign): COSEInfo | undefined {
+ switch (algSign) {
+ case 'secp256r1_ecdsa_sha256_raw':
+ case 'secp256r1_ecdsa_sha256_der':
+ return { kty: 2, alg: -7, crv: 1 };
+ case 'rsassa_pss_sha256_raw':
+ case 'rsassa_pss_sha256_der':
+ return { kty: 3, alg: -37 };
+ case 'secp256k1_ecdsa_sha256_raw':
+ case 'secp256k1_ecdsa_sha256_der':
+ return { kty: 2, alg: -7, crv: 8 };
+ case 'rsassa_pss_sha384_raw':
+ return { kty: 3, alg: -38 };
+ case 'rsassa_pkcsv15_sha256_raw':
+ return { kty: 3, alg: -257 };
+ case 'rsassa_pkcsv15_sha384_raw':
+ return { kty: 3, alg: -258 };
+ case 'rsassa_pkcsv15_sha512_raw':
+ return { kty: 3, alg: -259 };
+ case 'rsassa_pkcsv15_sha1_raw':
+ return { kty: 3, alg: -65535 };
+ case 'secp384r1_ecdsa_sha384_raw':
+ return { kty: 2, alg: -35, crv: 2 };
+ case 'secp512r1_ecdsa_sha256_raw':
+ return { kty: 2, alg: -36, crv: 3 };
+ case 'ed25519_eddsa_sha512_raw':
+ return { kty: 1, alg: -8, crv: 6 };
+ // TODO: COSE info in FIDO Registry v2.1 isn't readily available for these, these seem rare...
+ // case 'sm2_sm3_raw':
+ // return {};
+ // case 'rsa_emsa_pkcs1_sha256_raw':
+ // case 'rsa_emsa_pkcs1_sha256_der':
+ // return {};
+ default:
+ return undefined;
+ }
+}
diff --git a/packages/server/src/registration/verifications/verifyAndroidKey.test.ts b/packages/server/src/registration/verifications/verifyAndroidKey.test.ts
index f249066..d1f7a4e 100644
--- a/packages/server/src/registration/verifications/verifyAndroidKey.test.ts
+++ b/packages/server/src/registration/verifications/verifyAndroidKey.test.ts
@@ -8,7 +8,7 @@ import verifyAttestationResponse from '../verifyAttestationResponse';
* Clear out root certs for android-key since responses were captured from FIDO Conformance testing
* and have cert paths that can't be validated with known root certs from Google
*/
-SettingsService.setRootCertificates({ attestationFormat: 'android-key', certificates: [] });
+SettingsService.setRootCertificates({ identifier: 'android-key', certificates: [] });
test('should verify Android KeyStore response', async () => {
const expectedChallenge = '4ab7dfd1-a695-4777-985f-ad2993828e99';
diff --git a/packages/server/src/registration/verifications/verifyAndroidSafetyNet.test.ts b/packages/server/src/registration/verifications/verifyAndroidSafetyNet.test.ts
index 6a754d3..5b71663 100644
--- a/packages/server/src/registration/verifications/verifyAndroidSafetyNet.test.ts
+++ b/packages/server/src/registration/verifications/verifyAndroidSafetyNet.test.ts
@@ -10,7 +10,7 @@ import toHash from '../../helpers/toHash';
import settingsService from '../../services/settingsService';
const rootCertificates = settingsService.getRootCertificates({
- attestationFormat: 'android-safetynet',
+ identifier: 'android-safetynet',
});
let authData: Buffer;
diff --git a/packages/server/src/registration/verifications/verifyPacked.ts b/packages/server/src/registration/verifications/verifyPacked.ts
index 1cb74ec..41fddf1 100644
--- a/packages/server/src/registration/verifications/verifyPacked.ts
+++ b/packages/server/src/registration/verifications/verifyPacked.ts
@@ -10,7 +10,6 @@ import convertCOSEtoPKCS, {
COSEKTY,
COSERSASCHEME,
} from '../../helpers/convertCOSEtoPKCS';
-import { FIDO_METADATA_ATTESTATION_TYPES } from '../../helpers/constants';
import toHash from '../../helpers/toHash';
import convertCertBufferToPEM from '../../helpers/convertCertBufferToPEM';
import validateCertificatePath from '../../helpers/validateCertificatePath';
@@ -94,10 +93,7 @@ export default async function verifyAttestationPacked(
if (statement) {
// The presence of x5c means this is a full attestation. Check to see if attestationTypes
// includes packed attestations.
- if (
- statement.attestationTypes.indexOf(FIDO_METADATA_ATTESTATION_TYPES.ATTESTATION_BASIC_FULL) <
- 0
- ) {
+ if (statement.attestationTypes.indexOf('basic_full') < 0) {
throw new Error('Metadata does not indicate support for full attestations (Packed|Full)');
}
diff --git a/packages/server/src/registration/verifyRegistrationResponse.test.ts b/packages/server/src/registration/verifyRegistrationResponse.test.ts
index 7f11c0a..f81392f 100644
--- a/packages/server/src/registration/verifyRegistrationResponse.test.ts
+++ b/packages/server/src/registration/verifyRegistrationResponse.test.ts
@@ -17,7 +17,7 @@ import { RegistrationCredentialJSON } from '@simplewebauthn/typescript-types';
* Clear out root certs for android-key since responses were captured from FIDO Conformance testing
* and have cert paths that can't be validated with known root certs from Google
*/
-SettingsService.setRootCertificates({ attestationFormat: 'android-key', certificates: [] });
+SettingsService.setRootCertificates({ identifier: 'android-key', certificates: [] });
let mockDecodeAttestation: jest.SpyInstance;
let mockDecodeClientData: jest.SpyInstance;
diff --git a/packages/server/src/registration/verifyRegistrationResponse.ts b/packages/server/src/registration/verifyRegistrationResponse.ts
index 3c9150f..34bc991 100644
--- a/packages/server/src/registration/verifyRegistrationResponse.ts
+++ b/packages/server/src/registration/verifyRegistrationResponse.ts
@@ -178,7 +178,7 @@ export default async function verifyRegistrationResponse(
}
const clientDataHash = toHash(base64url.toBuffer(response.clientDataJSON));
- const rootCertificates = settingsService.getRootCertificates({ attestationFormat: fmt });
+ const rootCertificates = settingsService.getRootCertificates({ identifier: fmt });
// Prepare arguments to pass to the relevant verification method
const verifierOpts: AttestationFormatVerifierOpts = {
diff --git a/packages/server/src/services/defaultRootCerts/mds.ts b/packages/server/src/services/defaultRootCerts/mds.ts
new file mode 100644
index 0000000..1a06db1
--- /dev/null
+++ b/packages/server/src/services/defaultRootCerts/mds.ts
@@ -0,0 +1,32 @@
+/**
+ * GlobalSign Root CA - R3
+ *
+ * Downloaded from https://valid.r3.roots.globalsign.com/
+ *
+ * Valid until 2029-03-18 @ 00:00 PST
+ *
+ * SHA256 Fingerprint
+ * CB:B5:22:D7:B7:F1:27:AD:6A:01:13:86:5B:DF:1C:D4:10:2E:7D:07:59:AF:63:5A:7C:F4:72:0D:C9:63:C5:3B
+ */
+export const GlobalSign_Root_CA_R3 = `-----BEGIN CERTIFICATE-----
+ MIIDXzCCAkegAwIBAgILBAAAAAABIVhTCKIwDQYJKoZIhvcNAQELBQAwTDEgMB4G
+ A1UECxMXR2xvYmFsU2lnbiBSb290IENBIC0gUjMxEzARBgNVBAoTCkdsb2JhbFNp
+ Z24xEzARBgNVBAMTCkdsb2JhbFNpZ24wHhcNMDkwMzE4MTAwMDAwWhcNMjkwMzE4
+ MTAwMDAwWjBMMSAwHgYDVQQLExdHbG9iYWxTaWduIFJvb3QgQ0EgLSBSMzETMBEG
+ A1UEChMKR2xvYmFsU2lnbjETMBEGA1UEAxMKR2xvYmFsU2lnbjCCASIwDQYJKoZI
+ hvcNAQEBBQADggEPADCCAQoCggEBAMwldpB5BngiFvXAg7aEyiie/QV2EcWtiHL8
+ RgJDx7KKnQRfJMsuS+FggkbhUqsMgUdwbN1k0ev1LKMPgj0MK66X17YUhhB5uzsT
+ gHeMCOFJ0mpiLx9e+pZo34knlTifBtc+ycsmWQ1z3rDI6SYOgxXG71uL0gRgykmm
+ KPZpO/bLyCiR5Z2KYVc3rHQU3HTgOu5yLy6c+9C7v/U9AOEGM+iCK65TpjoWc4zd
+ QQ4gOsC0p6Hpsk+QLjJg6VfLuQSSaGjlOCZgdbKfd/+RFO+uIEn8rUAVSNECMWEZ
+ XriX7613t2Saer9fwRPvm2L7DWzgVGkWqQPabumDk3F2xmmFghcCAwEAAaNCMEAw
+ DgYDVR0PAQH/BAQDAgEGMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFI/wS3+o
+ LkUkrk1Q+mOai97i3Ru8MA0GCSqGSIb3DQEBCwUAA4IBAQBLQNvAUKr+yAzv95ZU
+ RUm7lgAJQayzE4aGKAczymvmdLm6AC2upArT9fHxD4q/c2dKg8dEe3jgr25sbwMp
+ jjM5RcOO5LlXbKr8EpbsU8Yt5CRsuZRj+9xTaGdWPoO4zzUhw8lo/s7awlOqzJCK
+ 6fBdRoyV3XpYKBovHd7NADdBj+1EbddTKJd+82cEHhXXipa0095MJ6RMG3NzdvQX
+ mcIfeg7jLQitChws/zyrVQ4PkX4268NXSb7hLi18YIvDQVETI53O9zJrlAGomecs
+ Mx86OyXShkDOOyyGeMlhLxS67ttVb9+E7gUJTb0o2HLO02JQZR7rkpeDMdmztcpH
+ WD9f
+ -----END CERTIFICATE-----
+ `;
diff --git a/packages/server/src/services/metadataService.test.ts b/packages/server/src/services/metadataService.test.ts
new file mode 100644
index 0000000..d1984f5
--- /dev/null
+++ b/packages/server/src/services/metadataService.test.ts
@@ -0,0 +1,133 @@
+jest.mock('node-fetch');
+import fetch from 'node-fetch';
+
+import MetadataService, { BaseMetadataService } from './metadataService';
+import type { MetadataStatement } from '../metadata/mdsTypes';
+
+const _fetch = fetch as unknown as jest.Mock;
+
+describe('Method: initialize()', () => {
+ beforeEach(() => {
+ _fetch.mockReset();
+ });
+
+ test('should default to querying MDS v3', async () => {
+ await MetadataService.initialize();
+
+ expect(_fetch).toHaveBeenCalledTimes(1);
+ expect(_fetch).toHaveBeenCalledWith('https://mds.fidoalliance.org/');
+ });
+
+ test('should query provided MDS server URLs', async () => {
+ const mdsServers = ['https://custom-mds1.com', 'https://custom-mds2.com'];
+
+ await MetadataService.initialize({
+ mdsServers,
+ });
+
+ expect(_fetch).toHaveBeenCalledTimes(mdsServers.length);
+ expect(_fetch).toHaveBeenNthCalledWith(1, mdsServers[0]);
+ expect(_fetch).toHaveBeenNthCalledWith(2, mdsServers[1]);
+ });
+
+ test('should not query any servers on empty list of URLs', async () => {
+ await MetadataService.initialize({ mdsServers: [] });
+
+ expect(_fetch).not.toHaveBeenCalled();
+ });
+
+ test('should load local statements', async () => {
+ await MetadataService.initialize({
+ statements: [localStatement],
+ });
+
+ const statement = await MetadataService.getStatement(localStatementAAGUID);
+
+ expect(statement).toEqual(localStatement);
+ });
+});
+
+describe('Method: getStatement()', () => {
+ test('should return undefined if service not initialized', async () => {
+ // For lack of a way to "uninitialize" the singleton, create a new instance
+ const service = new BaseMetadataService();
+ const statement = await service.getStatement('not-a-real-aaguid');
+
+ expect(statement).toBeUndefined();
+ });
+
+ test('should return undefined if aaguid is undefined', async () => {
+ // TypeScript will prevent you from passing `undefined`, but JS won't so test it
+ // @ts-ignore
+ const statement = await MetadataService.getStatement(undefined);
+
+ expect(statement).toBeUndefined();
+ });
+
+ test('should throw after initialization on AAGUID with no statement', async () => {
+ // Require the `catch` to be evaluated
+ expect.assertions(1);
+
+ await MetadataService.initialize({
+ mdsServers: [],
+ statements: [],
+ });
+
+ try {
+ await MetadataService.getStatement('not-a-real-aaguid');
+ } catch (err) {
+ expect(err).not.toBeUndefined();
+ }
+ });
+});
+
+const localStatementAAGUID = '91dfead7-959e-4475-ad26-9b0d482be089';
+const localStatement: MetadataStatement = {
+ legalHeader: 'https://fidoalliance.org/metadata/metadata-statement-legal-header/',
+ description: 'Virtual FIDO2 EdDSA25519 SHA512 Conformance Testing CTAP2 Authenticator',
+ aaguid: localStatementAAGUID,
+ protocolFamily: 'fido2',
+ authenticatorVersion: 2,
+ upv: [
+ {
+ major: 1,
+ minor: 0,
+ },
+ ],
+ authenticationAlgorithms: ['ed25519_eddsa_sha512_raw'],
+ publicKeyAlgAndEncodings: ['cose'],
+ attestationTypes: ['basic_full', 'basic_surrogate'],
+ schema: 3,
+ userVerificationDetails: [
+ [
+ {
+ userVerificationMethod: 'none',
+ },
+ ],
+ ],
+ keyProtection: ['hardware', 'secure_element'],
+ matcherProtection: ['on_chip'],
+ cryptoStrength: 128,
+ attachmentHint: ['external', 'wired', 'wireless', 'nfc'],
+ tcDisplay: [],
+ attestationRootCertificates: [],
+ supportedExtensions: [
+ {
+ id: 'hmac-secret',
+ fail_if_unknown: false,
+ },
+ ],
+ authenticatorGetInfo: {
+ versions: ['U2F_V2', 'FIDO_2_0'],
+ extensions: ['credProtect', 'hmac-secret'],
+ aaguid: '91dfead7959e4475ad269b0d482be089',
+ options: {
+ plat: false,
+ rk: true,
+ clientPin: true,
+ up: true,
+ uv: true,
+ },
+ maxMsgSize: 1200,
+ },
+};
diff --git a/packages/server/src/services/metadataService.ts b/packages/server/src/services/metadataService.ts
index a9baf9e..58bfba5 100644
--- a/packages/server/src/services/metadataService.ts
+++ b/packages/server/src/services/metadataService.ts
@@ -1,40 +1,35 @@
-import { Base64URLString } from '@simplewebauthn/typescript-types';
import fetch from 'node-fetch';
import { KJUR } from 'jsrsasign';
-import base64url from 'base64url';
-import { FIDO_AUTHENTICATOR_STATUS } from '../helpers/constants';
-import toHash from '../helpers/toHash';
import validateCertificatePath from '../helpers/validateCertificatePath';
import convertCertBufferToPEM from '../helpers/convertCertBufferToPEM';
import convertAAGUIDToString from '../helpers/convertAAGUIDToString';
+import type {
+ MDSJWTHeader,
+ MDSJWTPayload,
+ MetadataStatement,
+ MetadataBLOBPayloadEntry,
+} from '../metadata/mdsTypes';
+import SettingsService from '../services/settingsService';
// TODO: Re-enable this once we figure out logging
// import { log } from '../helpers/logging';
import parseJWT from '../metadata/parseJWT';
-// Cached WebAuthn metadata statements
-type CachedAAGUID = {
- url: TOCEntry['url'];
- hash: TOCEntry['hash'];
- statusReports: TOCEntry['statusReports'];
- statement?: MetadataStatement;
- tocURL?: CachedMDS['url'];
-};
-
-// Cached MDS APIs from which TOCs are downloaded
+// Cached MDS APIs from which BLOBs are downloaded
type CachedMDS = {
url: string;
- alg: string;
no: number;
nextUpdate: Date;
- rootCertURL: string;
- // Specify a query param, etc... to be appended to the end of a metadata statement URL
- // TODO: This will need to be extended later, for now support FIDO MDS API that requires an API
- // token passed as a query param
- metadataURLSuffix: string;
};
+type CachedBLOBEntry = {
+ entry: MetadataBLOBPayloadEntry;
+ url: string;
+};
+
+const defaultURLMDS = 'https://mds.fidoalliance.org/'; // v3
+
enum SERVICE_STATE {
DISABLED,
REFRESHING,
@@ -42,28 +37,26 @@ enum SERVICE_STATE {
}
/**
- * A basic service for coordinating interactions with the FIDO Metadata Service. This includes TOC
+ * A basic service for coordinating interactions with the FIDO Metadata Service. This includes BLOB
* download and parsing, and on-demand requesting and caching of individual metadata statements.
*
* https://fidoalliance.org/metadata/
*/
-class MetadataService {
+export class BaseMetadataService {
private mdsCache: { [url: string]: CachedMDS } = {};
- private statementCache: { [aaguid: string]: CachedAAGUID } = {};
+ private statementCache: { [aaguid: string]: CachedBLOBEntry } = {};
private state: SERVICE_STATE = SERVICE_STATE.DISABLED;
/**
* Prepare the service to handle remote MDS servers and/or cache local metadata statements.
*/
- async initialize(opts: {
- mdsServers: Pick<CachedMDS, 'url' | 'rootCertURL' | 'metadataURLSuffix'>[];
- statements?: MetadataStatement[];
- }): Promise<void> {
- if (!opts) {
- throw new Error('MetadataService initialization options are missing');
- }
-
- const { mdsServers, statements } = opts;
+ async initialize(
+ opts: {
+ mdsServers?: string[];
+ statements?: MetadataStatement[];
+ } = {},
+ ): Promise<void> {
+ const { mdsServers = [defaultURLMDS], statements } = opts;
this.setState(SERVICE_STATE.REFRESHING);
@@ -73,45 +66,42 @@ class MetadataService {
// Only cache statements that are for FIDO2-compatible authenticators
if (statement.aaguid) {
this.statementCache[statement.aaguid] = {
+ entry: {
+ metadataStatement: statement,
+ statusReports: [],
+ timeOfLastStatusChange: '1970-01-01',
+ },
url: '',
- hash: '',
- statement,
- statusReports: [],
};
}
});
}
- if (!mdsServers.length) {
- throw new Error('MetadataService must be initialized with at least one MDS server');
- }
-
// If MDS servers are provided, then process them and add their statements to the cache
if (mdsServers?.length) {
// TODO: Re-enable this once we figure out logging
// const currentCacheCount = Object.keys(this.statementCache).length;
+ // let numServers = mdsServers.length;
- for (const server of mdsServers) {
+ for (const url of mdsServers) {
try {
- await this.downloadTOC({
- url: server.url,
- rootCertURL: server.rootCertURL,
- metadataURLSuffix: server.metadataURLSuffix,
- alg: '',
+ await this.downloadBlob({
+ url,
no: 0,
nextUpdate: new Date(0),
});
} catch (err) {
// Notify of the error and move on
// TODO: Re-enable this once we figure out logging
- // log('warning', `Could not download TOC from ${server.url}:`, err);
+ // log('warning', `Could not download BLOB from ${url}:`, err);
+ // numServers -= 1;
}
}
// TODO: Re-enable this once we figure out logging
// const newCacheCount = Object.keys(this.statementCache).length;
// const cacheDiff = newCacheCount - currentCacheCount;
- // log('info', `Downloaded ${cacheDiff} statements from ${mdsServers.length} metadata servers`);
+ // log('info', `Downloaded ${cacheDiff} statements from ${numServers} metadata servers`);
}
this.setState(SERVICE_STATE.READY);
@@ -120,8 +110,8 @@ class MetadataService {
/**
* Get a metadata statement for a given aaguid. Defaults to returning a cached statement.
*
- * This method will coordinate updating the TOC as per the `nextUpdate` property in the initial
- * TOC download.
+ * This method will coordinate updating the cache as per the `nextUpdate` property in the initial
+ * BLOB download.
*/
async getStatement(aaguid: string | Buffer): Promise<MetadataStatement | undefined> {
if (this.state === SERVICE_STATE.DISABLED) {
@@ -136,7 +126,7 @@ class MetadataService {
aaguid = convertAAGUIDToString(aaguid);
}
- // If a TOC refresh is in progress then pause this until the service is ready
+ // If a cache refresh is in progress then pause this until the service is ready
await this.pauseUntilReady();
// Try to grab a cached statement
@@ -145,25 +135,27 @@ class MetadataService {
if (!cachedStatement) {
// TODO: FIDO conformance requires this, but it seems excessive for WebAuthn. Investigate
// later
- throw new Error(`Unlisted aaguid "${aaguid}" in TOC`);
+ throw new Error(`No metadata statement found for aaguid "${aaguid}"`);
}
// If the statement points to an MDS API, check the MDS' nextUpdate to see if we need to refresh
- if (cachedStatement.tocURL) {
- const mds = this.mdsCache[cachedStatement.tocURL];
+ if (cachedStatement.url) {
+ const mds = this.mdsCache[cachedStatement.url];
const now = new Date();
if (now > mds.nextUpdate) {
try {
this.setState(SERVICE_STATE.REFRESHING);
- await this.downloadTOC(mds);
+ await this.downloadBlob(mds);
} finally {
this.setState(SERVICE_STATE.READY);
}
}
}
+ const { entry } = cachedStatement;
+
// Check to see if the this aaguid has a status report with a "compromised" status
- for (const report of cachedStatement.statusReports) {
+ for (const report of entry.statusReports) {
const { status } = report;
if (
status === 'USER_VERIFICATION_BYPASS' ||
@@ -175,74 +167,42 @@ class MetadataService {
}
}
- // If the statement hasn't been cached but came from an MDS TOC, then download it
- if (!cachedStatement.statement && cachedStatement.tocURL) {
- // Download the metadata statement if it's not been cached
- const resp = await fetch(cachedStatement.url);
- const data = await resp.text();
- const statement: MetadataStatement = JSON.parse(
- Buffer.from(data, 'base64').toString('utf-8'),
- );
-
- const mds = this.mdsCache[cachedStatement.tocURL];
-
- const hashAlg = mds?.alg === 'ES256' ? 'SHA256' : undefined;
- const calculatedHash = base64url.encode(toHash(data, hashAlg));
-
- if (calculatedHash === cachedStatement.hash) {
- // Update the cached entry with the latest statement
- cachedStatement.statement = statement;
- } else {
- // From FIDO MDS docs: "Ignore the downloaded metadata statement if the hash value doesn't
- // match."
- cachedStatement.statement = undefined;
- throw new Error(`Downloaded metadata for aaguid "${aaguid}" but hash did not match`);
- }
- }
-
- return cachedStatement.statement;
+ return entry.metadataStatement;
}
/**
- * Download and process the latest TOC from MDS
+ * Download and process the latest BLOB from MDS
*/
- private async downloadTOC(mds: CachedMDS) {
- const { url, no, rootCertURL, metadataURLSuffix } = mds;
-
- // Query MDS for the latest TOC
- const respTOC = await fetch(url);
- const data = await respTOC.text();
-
- // Break apart the JWT we get back
- const parsedJWT = parseJWT<MDSJWTTOCHeader, MDSJWTTOCPayload>(data);
+ private async downloadBlob(mds: CachedMDS) {
+ const { url, no } = mds;
+ // Get latest "BLOB" (FIDO's terminology, not mine)
+ const resp = await fetch(url);
+ const data = await resp.text();
+
+ // Parse the JWT
+ const parsedJWT = parseJWT<MDSJWTHeader, MDSJWTPayload>(data);
const header = parsedJWT[0];
const payload = parsedJWT[1];
if (payload.no <= no) {
// From FIDO MDS docs: "also ignore the file if its number (no) is less or equal to the
- // number of the last Metadata TOC object cached locally."
- throw new Error(`Latest TOC no. "${payload.no}" is not greater than previous ${no}`);
- }
-
- let fullCertPath = header.x5c.map(convertCertBufferToPEM);
- if (rootCertURL.length > 0) {
- // Download FIDO the root certificate and append it to the TOC certs
- const respFIDORootCert = await fetch(rootCertURL);
- const fidoRootCert = await respFIDORootCert.text();
- fullCertPath = fullCertPath.concat(fidoRootCert);
+ // number of the last BLOB cached locally."
+ throw new Error(`Latest BLOB no. "${payload.no}" is not greater than previous ${no}`);
}
+ const headerCertsPEM = header.x5c.map(convertCertBufferToPEM);
try {
// Validate the certificate chain
- await validateCertificatePath(fullCertPath);
+ const rootCerts = SettingsService.getRootCertificates({ identifier: 'mds' });
+ await validateCertificatePath(headerCertsPEM, rootCerts);
} catch (err) {
// From FIDO MDS docs: "ignore the file if the chain cannot be verified or if one of the
// chain certificates is revoked"
- throw new Error(`TOC certificate path could not be validated: ${err.message}`);
+ throw new Error(`BLOB certificate path could not be validated: ${err.message}`);
}
- // Verify the TOC JWT signature
- const leafCert = fullCertPath[0];
+ // Verify the BLOB JWT signature
+ const leafCert = headerCertsPEM[0];
const verified = KJUR.jws.JWS.verifyJWT(data, leafCert, {
alg: [header.alg],
// Empty values to appease TypeScript and this library's subtly mis-typed @types definitions
@@ -253,33 +213,22 @@ class MetadataService {
if (!verified) {
// From FIDO MDS docs: "The FIDO Server SHOULD ignore the file if the signature is invalid."
- throw new Error('TOC signature could not be verified');
+ throw new Error('BLOB signature could not be verified');
}
- // Prepare the in-memory cache of statements.
+ // Cache statements for FIDO2 devices
for (const entry of payload.entries) {
// Only cache entries with an `aaguid`
if (entry.aaguid) {
- const _entry = entry as TOCAAGUIDEntry;
- const cached: CachedAAGUID = {
- url: `${entry.url}${metadataURLSuffix}`,
- hash: entry.hash,
- statusReports: entry.statusReports,
- // An easy way for us to link back to a cached MDS API entry
- tocURL: url,
- };
-
- this.statementCache[_entry.aaguid] = cached;
+ this.statementCache[entry.aaguid] = { entry, url };
}
}
- // Cache this MDS API
+ // Remember info about the server so we can refresh later
const [year, month, day] = payload.nextUpdate.split('-');
this.mdsCache[url] = {
...mds,
- // Store the header `alg` so we know what to use when verifying metadata statement hashes
- alg: header.alg,
- // Store the payload `no` to make sure we're getting the next TOC in the sequence
+ // Store the payload `no` to make sure we're getting the next BLOB in the sequence
no: payload.no,
// Convert the nextUpdate property into a Date so we can determine when to re-download
nextUpdate: new Date(
@@ -341,67 +290,7 @@ class MetadataService {
}
}
-const metadataService = new MetadataService();
-
-export default metadataService;
-
-export type MetadataStatement = {
- aaguid: string;
- assertionScheme: string;
- attachmentHint: number;
- attestationRootCertificates: Base64URLString[];
- attestationTypes: number[];
- authenticationAlgorithm: number;
- authenticatorVersion: number;
- description: string;
- icon: string;
- isSecondFactorOnly: string;
- keyProtection: number;
- legalHeader: string;
- matcherProtection: number;
- protocolFamily: string;
- publicKeyAlgAndEncoding: number;
- tcDisplay: number;
- tcDisplayContentType: string;
- upv: [{ major: number; minor: number }];
- userVerificationDetails: [[{ userVerification: 1 }]];
-};
-
-type MDSJWTTOCHeader = {
- alg: string;
- typ: string;
- x5c: Base64URLString[];
-};
-
-type MDSJWTTOCPayload = {
- // YYYY-MM-DD
- nextUpdate: string;
- entries: TOCEntry[];
- no: number;
- legalHeader: string;
-};
+// Export a service singleton
+const MetadataService = new BaseMetadataService();
-type TOCEntry = {
- url: string;
- // YYYY-MM-DD
- timeOfLastStatusChange: string;
- hash: string;
- aaid?: string;
- aaguid?: string;
- attestationCertificateKeyIdentifiers: string[];
- statusReports: {
- status: FIDO_AUTHENTICATOR_STATUS;
- certificateNumber: string;
- certificate: string;
- certificationDescriptor: string;
- url: string;
- certificationRequirementsVersion: string;
- certificationPolicyVersion: string;
- // YYYY-MM-DD
- effectiveDate: string;
- }[];
-};
-
-type TOCAAGUIDEntry = Omit<TOCEntry, 'aaid'> & {
- aaguid: string;
-};
+export default MetadataService;
diff --git a/packages/server/src/services/settingsService.test.ts b/packages/server/src/services/settingsService.test.ts
index 65b6115..28ff656 100644
--- a/packages/server/src/services/settingsService.test.ts
+++ b/packages/server/src/services/settingsService.test.ts
@@ -18,28 +18,28 @@ describe('setRootCertificate/getRootCertificate', () => {
test('should accept cert as Buffer', () => {
const gsr1Buffer = pemToBuffer(GlobalSign_Root_CA);
settingsService.setRootCertificates({
- attestationFormat: 'android-safetynet',
+ identifier: 'android-safetynet',
certificates: [gsr1Buffer],
});
- const certs = settingsService.getRootCertificates({ attestationFormat: 'android-safetynet' });
+ const certs = settingsService.getRootCertificates({ identifier: 'android-safetynet' });
expect(certs).toEqual([GlobalSign_Root_CA]);
});
test('should accept cert as PEM string', () => {
settingsService.setRootCertificates({
- attestationFormat: 'apple',
+ identifier: 'apple',
certificates: [Apple_WebAuthn_Root_CA],
});
- const certs = settingsService.getRootCertificates({ attestationFormat: 'apple' });
+ const certs = settingsService.getRootCertificates({ identifier: 'apple' });
expect(certs).toEqual([Apple_WebAuthn_Root_CA]);
});
test('should return empty array when certificate is not set', () => {
- const certs = settingsService.getRootCertificates({ attestationFormat: 'none' });
+ const certs = settingsService.getRootCertificates({ identifier: 'none' });
expect(Array.isArray(certs)).toEqual(true);
expect(certs.length).toEqual(0);
diff --git a/packages/server/src/services/settingsService.ts b/packages/server/src/services/settingsService.ts
index 7f74223..a88b628 100644
--- a/packages/server/src/services/settingsService.ts
+++ b/packages/server/src/services/settingsService.ts
@@ -7,10 +7,13 @@ import {
Google_Hardware_Attestation_Root_2,
} from './defaultRootCerts/android-key';
import { Apple_WebAuthn_Root_CA } from './defaultRootCerts/apple';
+import { GlobalSign_Root_CA_R3 } from './defaultRootCerts/mds';
+
+type RootCertIdentifier = AttestationFormat | 'mds';
class SettingsService {
// Certificates are stored as PEM-formatted strings
- private pemCertificates: Map<AttestationFormat, string[]>;
+ private pemCertificates: Map<RootCertIdentifier, string[]>;
constructor() {
this.pemCertificates = new Map();
@@ -24,10 +27,10 @@ class SettingsService {
* `Buffer` is passed in it will be converted to PEM format.
*/
setRootCertificates(opts: {
- attestationFormat: AttestationFormat;
+ identifier: RootCertIdentifier;
certificates: (Buffer | string)[];
}): void {
- const { attestationFormat, certificates } = opts;
+ const { identifier, certificates } = opts;
const newCertificates: string[] = [];
for (const cert of certificates) {
@@ -38,15 +41,15 @@ class SettingsService {
}
}
- this.pemCertificates.set(attestationFormat, newCertificates);
+ this.pemCertificates.set(identifier, newCertificates);
}
/**
* Get any registered root certificates for the specified attestation format
*/
- getRootCertificates(opts: { attestationFormat: AttestationFormat }): string[] {
- const { attestationFormat } = opts;
- return this.pemCertificates.get(attestationFormat) ?? [];
+ getRootCertificates(opts: { identifier: RootCertIdentifier }): string[] {
+ const { identifier } = opts;
+ return this.pemCertificates.get(identifier) ?? [];
}
}
@@ -54,18 +57,23 @@ const settingsService = new SettingsService();
// Initialize default certificates
settingsService.setRootCertificates({
- attestationFormat: 'android-key',
+ identifier: 'android-key',
certificates: [Google_Hardware_Attestation_Root_1, Google_Hardware_Attestation_Root_2],
});
settingsService.setRootCertificates({
- attestationFormat: 'android-safetynet',
+ identifier: 'android-safetynet',
certificates: [GlobalSign_R2, GlobalSign_Root_CA],
});
settingsService.setRootCertificates({
- attestationFormat: 'apple',
+ identifier: 'apple',
certificates: [Apple_WebAuthn_Root_CA],
});
+settingsService.setRootCertificates({
+ identifier: 'mds',
+ certificates: [GlobalSign_Root_CA_R3],
+});
+
export default settingsService;