diff options
author | Jarrett Helton <jaydhelton@gmail.com> | 2021-08-24 18:40:09 -0400 |
---|---|---|
committer | Jarrett Helton <jaydhelton@gmail.com> | 2021-08-24 18:40:09 -0400 |
commit | 22260e63c0b2a91d8f5db4000304e73b2bff9277 (patch) | |
tree | 87c3ecded4690c29cb474d0d6cd69d5ac7ef5fe6 /packages/server/src | |
parent | 2bb27c6febdbacbd7bbe4356318a6b3fa6fd84db (diff) | |
parent | 30ecc73b9856747337523f1e367b10d9d96a4a95 (diff) |
Merge remote-tracking branch 'origin/master' into v4/rename-methods-and-types
Diffstat (limited to 'packages/server/src')
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; |