diff options
Diffstat (limited to 'packages/server/src')
75 files changed, 1827 insertions, 920 deletions
diff --git a/packages/server/src/authentication/generateAuthenticationOptions.test.ts b/packages/server/src/authentication/generateAuthenticationOptions.test.ts index 78c6473..9048cf5 100644 --- a/packages/server/src/authentication/generateAuthenticationOptions.test.ts +++ b/packages/server/src/authentication/generateAuthenticationOptions.test.ts @@ -1,10 +1,13 @@ jest.mock('../helpers/generateChallenge'); +import { isoBase64URL } from '../helpers/iso'; + import { generateAuthenticationOptions } from './generateAuthenticationOptions'; -test('should generate credential request options suitable for sending via JSON', () => { - const challenge = 'totallyrandomvalue'; +const challengeString = 'dG90YWxseXJhbmRvbXZhbHVl'; +const challengeBuffer = isoBase64URL.toBuffer(challengeString); +test('should generate credential request options suitable for sending via JSON', () => { const options = generateAuthenticationOptions({ allowCredentials: [ { @@ -19,12 +22,12 @@ test('should generate credential request options suitable for sending via JSON', }, ], timeout: 1, - challenge, + challenge: challengeBuffer, }); expect(options).toEqual({ // base64url-encoded - challenge: 'dG90YWxseXJhbmRvbXZhbHVl', + challenge: challengeString, allowCredentials: [ { id: 'MTIzNA', @@ -43,7 +46,7 @@ test('should generate credential request options suitable for sending via JSON', test('defaults to 60 seconds if no timeout is specified', () => { const options = generateAuthenticationOptions({ - challenge: 'totallyrandomvalue', + challenge: challengeBuffer, allowCredentials: [ { id: Buffer.from('1234', 'ascii'), type: 'public-key' }, { id: Buffer.from('5678', 'ascii'), type: 'public-key' }, @@ -55,7 +58,7 @@ test('defaults to 60 seconds if no timeout is specified', () => { test('should not set userVerification if not specified', () => { const options = generateAuthenticationOptions({ - challenge: 'totallyrandomvalue', + challenge: challengeBuffer, allowCredentials: [ { id: Buffer.from('1234', 'ascii'), type: 'public-key' }, { id: Buffer.from('5678', 'ascii'), type: 'public-key' }, @@ -86,7 +89,7 @@ test('should generate without params', () => { test('should set userVerification if specified', () => { const options = generateAuthenticationOptions({ - challenge: 'totallyrandomvalue', + challenge: challengeBuffer, allowCredentials: [ { id: Buffer.from('1234', 'ascii'), type: 'public-key' }, { id: Buffer.from('5678', 'ascii'), type: 'public-key' }, @@ -99,7 +102,7 @@ test('should set userVerification if specified', () => { test('should set extensions if specified', () => { const options = generateAuthenticationOptions({ - challenge: 'totallyrandomvalue', + challenge: challengeBuffer, allowCredentials: [ { id: Buffer.from('1234', 'ascii'), type: 'public-key' }, { id: Buffer.from('5678', 'ascii'), type: 'public-key' }, diff --git a/packages/server/src/authentication/generateAuthenticationOptions.ts b/packages/server/src/authentication/generateAuthenticationOptions.ts index b80473e..bd517e3 100644 --- a/packages/server/src/authentication/generateAuthenticationOptions.ts +++ b/packages/server/src/authentication/generateAuthenticationOptions.ts @@ -4,13 +4,13 @@ import type { PublicKeyCredentialDescriptorFuture, UserVerificationRequirement, } from '@simplewebauthn/typescript-types'; -import base64url from 'base64url'; +import { isoBase64URL, isoUint8Array } from '../helpers/iso'; import { generateChallenge } from '../helpers/generateChallenge'; export type GenerateAuthenticationOptionsOpts = { allowCredentials?: PublicKeyCredentialDescriptorFuture[]; - challenge?: string | Buffer; + challenge?: string | Uint8Array; timeout?: number; userVerification?: UserVerificationRequirement; extensions?: AuthenticationExtensionsClientInputs; @@ -42,11 +42,19 @@ export function generateAuthenticationOptions( rpID, } = options; + /** + * Preserve ability to specify `string` values for challenges + */ + let _challenge = challenge; + if (typeof _challenge === 'string') { + _challenge = isoUint8Array.fromUTF8String(_challenge); + } + return { - challenge: base64url.encode(challenge), + challenge: isoBase64URL.fromBuffer(_challenge), allowCredentials: allowCredentials?.map(cred => ({ ...cred, - id: base64url.encode(cred.id as Buffer), + id: isoBase64URL.fromBuffer(cred.id as Uint8Array), })), timeout, userVerification, diff --git a/packages/server/src/authentication/verifyAuthenticationResponse.test.ts b/packages/server/src/authentication/verifyAuthenticationResponse.test.ts index 3b8e7b6..547d953 100644 --- a/packages/server/src/authentication/verifyAuthenticationResponse.test.ts +++ b/packages/server/src/authentication/verifyAuthenticationResponse.test.ts @@ -1,4 +1,3 @@ -import base64url from 'base64url'; import { verifyAuthenticationResponse } from './verifyAuthenticationResponse'; import * as esmDecodeClientDataJSON from '../helpers/decodeClientDataJSON'; @@ -8,6 +7,7 @@ import { AuthenticatorDevice, AuthenticationCredentialJSON, } from '@simplewebauthn/typescript-types'; +import { isoUint8Array, isoBase64URL } from '../helpers/iso'; let mockDecodeClientData: jest.SpyInstance; let mockParseAuthData: jest.SpyInstance; @@ -92,7 +92,7 @@ test('should throw when assertion type is not webauthn.create', async () => { test('should throw error if user was not present', async () => { mockParseAuthData.mockReturnValue({ - rpIdHash: toHash(Buffer.from('dev.dontneeda.pw', 'ascii')), + rpIdHash: await toHash(Buffer.from('dev.dontneeda.pw', 'ascii')), flags: 0, }); @@ -128,7 +128,7 @@ test('should throw error if previous counter value is not less than in response' test('should throw error if assertion RP ID is unexpected value', async () => { mockParseAuthData.mockReturnValue({ - rpIdHash: toHash(Buffer.from('bad.url', 'ascii')), + rpIdHash: await toHash(Buffer.from('bad.url', 'ascii')), flags: 0, }); @@ -157,7 +157,7 @@ test('should not compare counters if both are 0', async () => { test('should throw an error if user verification is required but user was not verified', async () => { const actualData = esmParseAuthenticatorData.parseAuthenticatorData( - base64url.toBuffer(assertionResponse.response.authenticatorData), + isoBase64URL.toBuffer(assertionResponse.response.authenticatorData), ); mockParseAuthData.mockReturnValue({ @@ -183,7 +183,7 @@ test('should throw an error if user verification is required but user was not ve // TODO: Get a real TPM authentication response in here test.skip('should verify TPM assertion', async () => { const expectedChallenge = 'dG90YWxseVVuaXF1ZVZhbHVlRXZlcnlBc3NlcnRpb24'; - jest.spyOn(base64url, 'encode').mockReturnValueOnce(expectedChallenge); + jest.spyOn(isoBase64URL, 'toString').mockReturnValueOnce(expectedChallenge); const verification = await verifyAuthenticationResponse({ credential: { id: 'YJ8FMM-AmcUt73XPX341WXWd7ypBMylGjjhu0g3VzME', @@ -198,13 +198,14 @@ test.skip('should verify TPM assertion', async () => { }, type: 'public-key', clientExtensionResults: {}, + authenticatorAttachment: '', }, expectedChallenge, expectedOrigin: assertionOrigin, expectedRPID: 'dev.dontneeda.pw', authenticator: { - credentialPublicKey: base64url.toBuffer('BAEAAQ'), - credentialID: base64url.toBuffer('YJ8FMM-AmcUt73XPX341WXWd7ypBMylGjjhu0g3VzME'), + credentialPublicKey: isoBase64URL.toBuffer('BAEAAQ'), + credentialID: isoBase64URL.toBuffer('YJ8FMM-AmcUt73XPX341WXWd7ypBMylGjjhu0g3VzME'), counter: 0, }, }); @@ -276,20 +277,21 @@ test('should pass verification if custom challenge verifier returns true', async }, type: 'public-key', clientExtensionResults: {}, + authenticatorAttachment: '', }, expectedChallenge: (challenge: string) => { const parsedChallenge: { actualChallenge: string; arbitraryData: string } = JSON.parse( - base64url.decode(challenge), + isoBase64URL.toString(challenge), ); return parsedChallenge.actualChallenge === 'K3QxOjnVJLiGlnVEp5va5QJeMVWNf_7PYgutgbAtAUA'; }, expectedOrigin: 'http://localhost:8000', expectedRPID: 'localhost', authenticator: { - credentialID: base64url.toBuffer( + credentialID: isoBase64URL.toBuffer( 'AaIBxnYfL2pDWJmIii6CYgHBruhVvFGHheWamphVioG_TnEXxKA9MW4FWnJh21zsbmRpRJso9i2JmAtWOtXfVd4oXTgYVusXwhWWsA', ), - credentialPublicKey: base64url.toBuffer( + credentialPublicKey: isoBase64URL.toBuffer( 'pQECAyYgASFYILTrxTUQv3X4DRM6L_pk65FSMebenhCx3RMsTKoBm-AxIlggEf3qk5552QLNSh1T1oQs7_2C2qysDwN4r4fCp52Hsqs', ), counter: 0, @@ -318,7 +320,7 @@ test('should return authenticator extension output', async () => { clientDataJSON: 'eyJ0eXBlIjoid2ViYXV0aG4uZ2V0IiwiY2hhbGxlbmdlIjoiaVpzVkN6dHJEVzdEMlVfR0hDSWxZS0x3VjJiQ3NCVFJxVlFVbkpYbjlUayIsIm9yaWdpbiI6ImFuZHJvaWQ6YXBrLWtleS1oYXNoOmd4N3NxX3B4aHhocklRZEx5ZkcwcHhLd2lKN2hPazJESlE0eHZLZDQzOFEiLCJhbmRyb2lkUGFja2FnZU5hbWUiOiJjb20uZmlkby5leGFtcGxlLmZpZG8yYXBpZXhhbXBsZSJ9', authenticatorData: - 'DXX8xWP9p3nbLjQ-6kiYiHWLeFSdSTpP2-oc2WqjHMSFAAAAAKFvZGV2aWNlUHVibGljS2V5pWNkcGtYTaUBAgMmIAEhWCCZGqvtneQnGp7erYgG-dyW1tzNDEdiU6VRBInsg3m-WyJYIKCXPP3tu3nif-9O50gWc_szElBN3KVDTP0jQx1q0p7aY3NpZ1hHMEUCIElSbNKK72tOYhp9WTbStQSVL8CuIxOk8DV6r_-uqWR0AiEAnVE6yu-wsyx2Wq5v66jClGhe_2P_HL8R7PIQevT-uPhlbm9uY2VAZXNjb3BlQQBmYWFndWlkULk_2WHy5kYvsSKCACJH3ng=', + 'DXX8xWP9p3nbLjQ-6kiYiHWLeFSdSTpP2-oc2WqjHMSFAAAAAKFsZGV2aWNlUHViS2V5pWNkcGtYTaUBAgMmIAEhWCCZGqvtneQnGp7erYgG-dyW1tzNDEdiU6VRBInsg3m-WyJYIKCXPP3tu3nif-9O50gWc_szElBN3KVDTP0jQx1q0p7aY3NpZ1hHMEUCIElSbNKK72tOYhp9WTbStQSVL8CuIxOk8DV6r_-uqWR0AiEAnVE6yu-wsyx2Wq5v66jClGhe_2P_HL8R7PIQevT-uPhlbm9uY2VAZXNjb3BlQQBmYWFndWlkULk_2WHy5kYvsSKCACJH3ng', signature: 'MEYCIQDlRuxY7cYre0sb3T6TovQdfYIUb72cRZYOQv_zS9wN_wIhAOvN-fwjtyIhWRceqJV4SX74-z6oALERbC7ohk8EdVPO', userHandle: 'b2FPajFxcmM4MWo3QkFFel9RN2lEakh5RVNlU2RLNDF0Sl92eHpQYWV5UQ==', @@ -327,15 +329,16 @@ test('should return authenticator extension output', async () => { rawId: 'E_Pko4wN1BXE23S0ftN3eQ', type: 'public-key', clientExtensionResults: {}, + authenticatorAttachment: '', }, expectedOrigin: 'android:apk-key-hash:gx7sq_pxhxhrIQdLyfG0pxKwiJ7hOk2DJQ4xvKd438Q', expectedRPID: 'try-webauthn.appspot.com', expectedChallenge: 'iZsVCztrDW7D2U_GHCIlYKLwV2bCsBTRqVQUnJXn9Tk', authenticator: { - credentialID: base64url.toBuffer( + credentialID: isoBase64URL.toBuffer( 'AaIBxnYfL2pDWJmIii6CYgHBruhVvFGHheWamphVioG_TnEXxKA9MW4FWnJh21zsbmRpRJso9i2JmAtWOtXfVd4oXTgYVusXwhWWsA', ), - credentialPublicKey: base64url.toBuffer( + credentialPublicKey: isoBase64URL.toBuffer( 'pQECAyYgASFYILTrxTUQv3X4DRM6L_pk65FSMebenhCx3RMsTKoBm-AxIlggEf3qk5552QLNSh1T1oQs7_2C2qysDwN4r4fCp52Hsqs', ), counter: 0, @@ -343,18 +346,16 @@ test('should return authenticator extension output', async () => { }); expect(verification.authenticationInfo?.authenticatorExtensionResults).toMatchObject({ - devicePublicKey: { - dpk: Buffer.from( + devicePubKey: { + dpk: isoUint8Array.fromHex( 'A5010203262001215820991AABED9DE4271A9EDEAD8806F9DC96D6DCCD0C476253A5510489EC8379BE5B225820A0973CFDEDBB79E27FEF4EE7481673FB3312504DDCA5434CFD23431D6AD29EDA', - 'hex', ), - sig: Buffer.from( + sig: isoUint8Array.fromHex( '3045022049526CD28AEF6B4E621A7D5936D2B504952FC0AE2313A4F0357AAFFFAEA964740221009D513ACAEFB0B32C765AAE6FEBA8C294685EFF63FF1CBF11ECF2107AF4FEB8F8', - 'hex', ), - nonce: Buffer.from('', 'hex'), - scope: Buffer.from('00', 'hex'), - aaguid: Buffer.from('B93FD961F2E6462FB12282002247DE78', 'hex'), + nonce: isoUint8Array.fromHex(''), + scope: isoUint8Array.fromHex('00'), + aaguid: isoUint8Array.fromHex('B93FD961F2E6462FB12282002247DE78'), }, }); }); @@ -391,15 +392,16 @@ const assertionResponse: AuthenticationCredentialJSON = { }, clientExtensionResults: {}, type: 'public-key', + authenticatorAttachment: '', }; -const assertionChallenge = base64url.encode('totallyUniqueValueEveryTime'); +const assertionChallenge = isoBase64URL.fromString('totallyUniqueValueEveryTime'); const assertionOrigin = 'https://dev.dontneeda.pw'; const authenticator: AuthenticatorDevice = { - credentialPublicKey: base64url.toBuffer( + credentialPublicKey: isoBase64URL.toBuffer( 'pQECAyYgASFYIIheFp-u6GvFT2LNGovf3ZrT0iFVBsA_76rRysxRG9A1Ilgg8WGeA6hPmnab0HAViUYVRkwTNcN77QBf_RR0dv3lIvQ', ), - credentialID: base64url.toBuffer( + credentialID: isoBase64URL.toBuffer( 'KEbWNCc7NgaYnUyrNeFGX9_3Y-8oJ3KwzjnaiD1d1LVTxR7v3CaKfCz2Vy_g_MHSh7yJ8yL0Pxg6jo_o0hYiew', ), counter: 143, @@ -420,14 +422,15 @@ const assertionFirstTimeUsedResponse: AuthenticationCredentialJSON = { }, type: 'public-key', clientExtensionResults: {}, + authenticatorAttachment: '', }; -const assertionFirstTimeUsedChallenge = base64url.encode('totallyUniqueValueEveryAssertion'); +const assertionFirstTimeUsedChallenge = isoBase64URL.fromString('totallyUniqueValueEveryAssertion'); const assertionFirstTimeUsedOrigin = 'https://dev.dontneeda.pw'; const authenticatorFirstTimeUsed: AuthenticatorDevice = { - credentialPublicKey: base64url.toBuffer( + credentialPublicKey: isoBase64URL.toBuffer( 'pQECAyYgASFYIGmaxR4mBbukc2QhtW2ldhAAd555r-ljlGQN8MbcTnPPIlgg9CyUlE-0AB2fbzZbNgBvJuRa7r6o2jPphOmtyNPR_kY', ), - credentialID: base64url.toBuffer( + credentialID: isoBase64URL.toBuffer( 'wSisR0_4hlzw3Y1tj4uNwwifIhRa-ZxWJwWbnfror0pVK9qPdBPO5pW3gasPqn6wXHb0LNhXB_IrA1nFoSQJ9A', ), counter: 0, diff --git a/packages/server/src/authentication/verifyAuthenticationResponse.ts b/packages/server/src/authentication/verifyAuthenticationResponse.ts index 6bb6e98..c99013e 100644 --- a/packages/server/src/authentication/verifyAuthenticationResponse.ts +++ b/packages/server/src/authentication/verifyAuthenticationResponse.ts @@ -1,4 +1,3 @@ -import base64url from 'base64url'; import { AuthenticationCredentialJSON, AuthenticatorDevice, @@ -10,9 +9,10 @@ import { decodeClientDataJSON } from '../helpers/decodeClientDataJSON'; import { toHash } from '../helpers/toHash'; import { verifySignature } from '../helpers/verifySignature'; import { parseAuthenticatorData } from '../helpers/parseAuthenticatorData'; -import { isBase64URLString } from '../helpers/isBase64URLString'; import { parseBackupFlags } from '../helpers/parseBackupFlags'; import { AuthenticationExtensionsAuthenticatorOutputs } from '../helpers/decodeAuthenticatorExtensions'; +import { matchExpectedRPID } from '../helpers/matchExpectedRPID'; +import { isoUint8Array, isoBase64URL } from '../helpers/iso'; export type VerifyAuthenticationResponseOpts = { credential: AuthenticationCredentialJSON; @@ -120,11 +120,11 @@ export async function verifyAuthenticationResponse( } } - if (!isBase64URLString(response.authenticatorData)) { + if (!isoBase64URL.isBase64url(response.authenticatorData)) { throw new Error('Credential response authenticatorData was not a base64url string'); } - if (!isBase64URLString(response.signature)) { + if (!isoBase64URL.isBase64url(response.signature)) { throw new Error('Credential response signature was not a base64url string'); } @@ -142,28 +142,20 @@ export async function verifyAuthenticationResponse( } } - const authDataBuffer = base64url.toBuffer(response.authenticatorData); + const authDataBuffer = isoBase64URL.toBuffer(response.authenticatorData); const parsedAuthData = parseAuthenticatorData(authDataBuffer); const { rpIdHash, flags, counter, extensionsData } = parsedAuthData; // Make sure the response's RP ID is ours + let expectedRPIDs: string[] = []; if (typeof expectedRPID === 'string') { - const expectedRPIDHash = toHash(Buffer.from(expectedRPID, 'ascii')); - if (!rpIdHash.equals(expectedRPIDHash)) { - throw new Error(`Unexpected RP ID hash`); - } + expectedRPIDs = [expectedRPID]; } else { - // Go through each expected RP ID and try to find one that matches - const foundMatch = expectedRPID.some(expected => { - const expectedRPIDHash = toHash(Buffer.from(expected, 'ascii')); - return rpIdHash.equals(expectedRPIDHash); - }); - - if (!foundMatch) { - throw new Error(`Unexpected RP ID hash`); - } + expectedRPIDs = expectedRPID; } + await matchExpectedRPID(rpIdHash, expectedRPIDs); + if (advancedFIDOConfig !== undefined) { const { userVerification: fidoUserVerification } = advancedFIDOConfig; @@ -193,10 +185,10 @@ export async function verifyAuthenticationResponse( } } - const clientDataHash = toHash(base64url.toBuffer(response.clientDataJSON)); - const signatureBase = Buffer.concat([authDataBuffer, clientDataHash]); + const clientDataHash = await toHash(isoBase64URL.toBuffer(response.clientDataJSON)); + const signatureBase = isoUint8Array.concat([authDataBuffer, clientDataHash]); - const signature = base64url.toBuffer(response.signature); + const signature = isoBase64URL.toBuffer(response.signature); if ((counter > 0 || authenticator.counter > 0) && counter <= authenticator.counter) { // Error out when the counter in the DB is greater than or equal to the counter in the @@ -213,7 +205,7 @@ export async function verifyAuthenticationResponse( const toReturn: VerifiedAuthenticationResponse = { verified: await verifySignature({ signature, - signatureBase, + data: signatureBase, credentialPublicKey: authenticator.credentialPublicKey, }), authenticationInfo: { @@ -250,7 +242,7 @@ export async function verifyAuthenticationResponse( export type VerifiedAuthenticationResponse = { verified: boolean; authenticationInfo: { - credentialID: Buffer; + credentialID: Uint8Array; newCounter: number; userVerified: boolean; credentialDeviceType: CredentialDeviceType; diff --git a/packages/server/src/helpers/__mocks__/generateChallenge.ts b/packages/server/src/helpers/__mocks__/generateChallenge.ts index a339e56..d9d866e 100644 --- a/packages/server/src/helpers/__mocks__/generateChallenge.ts +++ b/packages/server/src/helpers/__mocks__/generateChallenge.ts @@ -1,3 +1,3 @@ -export function generateChallenge(): Buffer { - return Buffer.from([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]); +export function generateChallenge(): Uint8Array { + return Uint8Array.from([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]); } diff --git a/packages/server/src/helpers/convertAAGUIDToString.ts b/packages/server/src/helpers/convertAAGUIDToString.ts index 0fb8356..db9622a 100644 --- a/packages/server/src/helpers/convertAAGUIDToString.ts +++ b/packages/server/src/helpers/convertAAGUIDToString.ts @@ -1,9 +1,11 @@ +import { isoUint8Array } from './iso'; + /** * Convert the aaguid buffer in authData into a UUID string */ -export function convertAAGUIDToString(aaguid: Buffer): string { +export function convertAAGUIDToString(aaguid: Uint8Array): string { // Raw Hex: adce000235bcc60a648b0b25f1f05503 - const hex = aaguid.toString('hex'); + const hex = isoUint8Array.toHex(aaguid); const segments: string[] = [ hex.slice(0, 8), // 8 diff --git a/packages/server/src/helpers/convertCOSEtoPKCS.test.ts b/packages/server/src/helpers/convertCOSEtoPKCS.test.ts index de2d10f..761382f 100644 --- a/packages/server/src/helpers/convertCOSEtoPKCS.test.ts +++ b/packages/server/src/helpers/convertCOSEtoPKCS.test.ts @@ -1,13 +1,14 @@ -import * as esmDecodeCbor from './decodeCbor'; +import { isoCBOR } from './iso'; -import { convertCOSEtoPKCS, COSEKEYS } from './convertCOSEtoPKCS'; +import { convertCOSEtoPKCS } from './convertCOSEtoPKCS'; +import { COSEKEYS } from './cose'; test('should throw an error curve if, somehow, curve coordinate x is missing', () => { const mockCOSEKey = new Map<number, number | Buffer>(); mockCOSEKey.set(COSEKEYS.y, 1); - jest.spyOn(esmDecodeCbor, 'decodeCborFirst').mockReturnValue(mockCOSEKey); + jest.spyOn(isoCBOR, 'decodeFirst').mockReturnValue(mockCOSEKey); expect(() => { convertCOSEtoPKCS(Buffer.from('123', 'ascii')); @@ -19,7 +20,7 @@ test('should throw an error curve if, somehow, curve coordinate y is missing', ( mockCOSEKey.set(COSEKEYS.x, 1); - jest.spyOn(esmDecodeCbor, 'decodeCborFirst').mockReturnValue(mockCOSEKey); + jest.spyOn(isoCBOR, 'decodeFirst').mockReturnValue(mockCOSEKey); expect(() => { convertCOSEtoPKCS(Buffer.from('123', 'ascii')); diff --git a/packages/server/src/helpers/convertCOSEtoPKCS.ts b/packages/server/src/helpers/convertCOSEtoPKCS.ts index 618a0dc..761fae6 100644 --- a/packages/server/src/helpers/convertCOSEtoPKCS.ts +++ b/packages/server/src/helpers/convertCOSEtoPKCS.ts @@ -1,13 +1,16 @@ -import { COSEAlgorithmIdentifier } from '@simplewebauthn/typescript-types'; -import { decodeCborFirst } from './decodeCbor'; +import { isoCBOR, isoUint8Array } from './iso'; +import { COSEPublicKeyEC2, COSEKEYS } from './cose'; /** * Takes COSE-encoded public key and converts it to PKCS key */ -export function convertCOSEtoPKCS(cosePublicKey: Buffer): Buffer { - const struct: COSEPublicKey = decodeCborFirst(cosePublicKey); +export function convertCOSEtoPKCS(cosePublicKey: Uint8Array): Uint8Array { + // This is a little sloppy, I'm using COSEPublicKeyEC2 since it could have both x and y, but when + // there's no y it means it's probably better typed as COSEPublicKeyOKP. I'll leave this for now + // and revisit it later if it ever becomes an actual problem. + const struct = isoCBOR.decodeFirst<COSEPublicKeyEC2>(cosePublicKey); - const tag = Buffer.from([0x04]); + const tag = Uint8Array.from([0x04]); const x = struct.get(COSEKEYS.x); const y = struct.get(COSEKEYS.y); @@ -16,85 +19,8 @@ export function convertCOSEtoPKCS(cosePublicKey: Buffer): Buffer { } if (y) { - return Buffer.concat([tag, x as Buffer, y as Buffer]); + return isoUint8Array.concat([tag, x, y]); } - return Buffer.concat([tag, x as Buffer]); + return isoUint8Array.concat([tag, x]); } - -export type COSEPublicKey = Map<COSEAlgorithmIdentifier, number | Buffer>; - -export enum COSEKEYS { - kty = 1, - alg = 3, - crv = -1, - x = -2, - y = -3, - n = -1, - e = -2, -} - -export enum COSEKTY { - OKP = 1, - EC2 = 2, - RSA = 3, -} - -export const COSERSASCHEME: { [key: string]: SigningSchemeHash } = { - '-3': 'pss-sha256', - '-39': 'pss-sha512', - '-38': 'pss-sha384', - '-65535': 'pkcs1-sha1', - '-257': 'pkcs1-sha256', - '-258': 'pkcs1-sha384', - '-259': 'pkcs1-sha512', -}; - -// See https://w3c.github.io/webauthn/#sctn-alg-identifier -export const COSECRV: { [key: number]: string } = { - // alg: -7 - 1: 'p256', - // alg: -35 - 2: 'p384', - // alg: -36 - 3: 'p521', - // alg: -8 - 6: 'ed25519', -}; - -export const COSEALGHASH: { [key: string]: string } = { - '-65535': 'sha1', - '-259': 'sha512', - '-258': 'sha384', - '-257': 'sha256', - '-39': 'sha512', - '-38': 'sha384', - '-37': 'sha256', - '-36': 'sha512', - '-35': 'sha384', - '-8': 'sha512', - '-7': 'sha256', -}; - -/** - * Imported from node-rsa's types - */ -type SigningSchemeHash = - | 'pkcs1-ripemd160' - | 'pkcs1-md4' - | 'pkcs1-md5' - | 'pkcs1-sha' - | 'pkcs1-sha1' - | 'pkcs1-sha224' - | 'pkcs1-sha256' - | 'pkcs1-sha384' - | 'pkcs1-sha512' - | 'pss-ripemd160' - | 'pss-md4' - | 'pss-md5' - | 'pss-sha' - | 'pss-sha1' - | 'pss-sha224' - | 'pss-sha256' - | 'pss-sha384' - | 'pss-sha512'; diff --git a/packages/server/src/helpers/convertCertBufferToPEM.ts b/packages/server/src/helpers/convertCertBufferToPEM.ts index b6949c4..adf4201 100644 --- a/packages/server/src/helpers/convertCertBufferToPEM.ts +++ b/packages/server/src/helpers/convertCertBufferToPEM.ts @@ -1,19 +1,26 @@ -import base64url from 'base64url'; import type { Base64URLString } from '@simplewebauthn/typescript-types'; +import { isoBase64URL } from './iso'; + /** * Convert buffer to an OpenSSL-compatible PEM text format. */ -export function convertCertBufferToPEM(certBuffer: Buffer | Base64URLString): string { +export function convertCertBufferToPEM(certBuffer: Uint8Array | Base64URLString): string { let b64cert: string; /** * Get certBuffer to a base64 representation */ if (typeof certBuffer === 'string') { - b64cert = base64url.toBase64(certBuffer); + if (isoBase64URL.isBase64url(certBuffer)) { + b64cert = isoBase64URL.toBase64(certBuffer); + } else if (isoBase64URL.isBase64(certBuffer)) { + b64cert = certBuffer; + } else { + throw new Error('Certificate is not a valid base64 or base64url string'); + } } else { - b64cert = certBuffer.toString('base64'); + b64cert = isoBase64URL.fromBuffer(certBuffer, 'base64'); } let PEMKey = ''; diff --git a/packages/server/src/helpers/convertPublicKeyToPEM.test.ts b/packages/server/src/helpers/convertPublicKeyToPEM.test.ts deleted file mode 100644 index 353a9eb..0000000 --- a/packages/server/src/helpers/convertPublicKeyToPEM.test.ts +++ /dev/null @@ -1,83 +0,0 @@ -import base64url from 'base64url'; -import cbor from 'cbor'; -import { COSEKEYS } from './convertCOSEtoPKCS'; -import { convertPublicKeyToPEM } from './convertPublicKeyToPEM'; - -test('should return pem when input is base64URLString', () => { - const mockCOSEKey = new Map<number, number | Buffer>(); - - const x = Buffer.from('gh9MmXjtmcHFesofqWZ6iuxSdAYgoPVvfJqpv1818lo', 'base64'); - const y = Buffer.from('3BDZHsNvKUb5VbyGPqcAFf4FGuPhJ2Xy215oWDw_1jc', 'base64'); - mockCOSEKey.set(COSEKEYS.kty, 2); - mockCOSEKey.set(COSEKEYS.alg, -7); - mockCOSEKey.set(COSEKEYS.crv, 1); - mockCOSEKey.set(COSEKEYS.x, x); - mockCOSEKey.set(COSEKEYS.y, y); - - jest.spyOn(cbor, 'decodeAllSync').mockReturnValueOnce([mockCOSEKey]); - const input = base64url.toBuffer('test'); - const actual = convertPublicKeyToPEM(input); - expect(actual).toEqual(`-----BEGIN PUBLIC KEY----- -MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEgh9MmXjtmcHFesofqWZ6iuxSdAYg\noPVvfJqpv1818lrcENkew28pRvlVvIY+pwAV/gUa4+EnZfLbXmhYPD/WNw== ------END PUBLIC KEY----- -`); -}); - -test('should return pem when input is base64URLString', () => { - const mockCOSEKey = new Map<number, number | Buffer>(); - - const n = Buffer.from( - '0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWhM78LhWx4cbbfAAtVT86zwu1RK7aPFFxuhDR1L6tSoc_BJECPebWKRXjBZCiFV4n3oknjhMstn64tZ_2W-5JsGY4Hc5n9yBXArwl93lqt7_RN5w6Cf0h4QyQ5v-65YGjQR0_FDW2QvzqY368QQMicAtaSqzs8KJZgnYb9c7d0zgdAZHzu6qMQvRL5hajrn1n91CbOpbISD08qNLyrdkt-bFTWhAI4vMQFh6WeZu0fM4lFd2NcRwr3XPksINHaQ-G_xBniIqbw0Ls1jF44-csFCur-kEgU8awapJzKnqDKgw', - 'base64', - ); - const e = Buffer.from('AQAB', 'base64'); - mockCOSEKey.set(COSEKEYS.kty, 3); - mockCOSEKey.set(COSEKEYS.alg, -7); - mockCOSEKey.set(COSEKEYS.crv, 1); - mockCOSEKey.set(COSEKEYS.n, n); - mockCOSEKey.set(COSEKEYS.e, e); - - jest.spyOn(cbor, 'decodeAllSync').mockReturnValueOnce([mockCOSEKey]); - const input = base64url.toBuffer('test'); - const actual = convertPublicKeyToPEM(input); - expect(actual).toEqual(`-----BEGIN PUBLIC KEY----- -MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0vx7agoebGcQSuuPiLJX -ZptN9nndrQmbXEps2aiAFbWhM78LhWx4cbbfAAtVT86zwu1RK7aPFFxuhDR1L6tS -oc/BJECPebWKRXjBZCiFV4n3oknjhMstn64tZ/2W+5JsGY4Hc5n9yBXArwl93lqt -7/RN5w6Cf0h4QyQ5v+65YGjQR0/FDW2QvzqY368QQMicAtaSqzs8KJZgnYb9c7d0 -zgdAZHzu6qMQvRL5hajrn1n91CbOpbISD08qNLyrdkt+bFTWhAI4vMQFh6WeZu0f -M4lFd2NcRwr3XPksINHaQ+G/xBniIqbw0Ls1jF44+csFCur+kEgU8awapJzKnqDK -gwIDAQAB ------END PUBLIC KEY----- -`); -}); - -test('should return pem when input is base64URLString', () => { - const mockCOSEKey = new Map<number, number | Buffer>(); - - mockCOSEKey.set(COSEKEYS.kty, 0); - mockCOSEKey.set(COSEKEYS.alg, -7); - - jest.spyOn(cbor, 'decodeAllSync').mockReturnValueOnce([mockCOSEKey]); - const input = base64url.toBuffer('test'); - try { - convertPublicKeyToPEM(input); - } catch (err) { - expect((err as Error).message).toEqual('Public key was missing kty'); - } -}); - -test('should raise error when kty is OKP (1)', () => { - const mockCOSEKey = new Map<number, number | Buffer>(); - - mockCOSEKey.set(COSEKEYS.kty, 1); - mockCOSEKey.set(COSEKEYS.alg, -7); - - jest.spyOn(cbor, 'decodeAllSync').mockReturnValueOnce([mockCOSEKey]); - const input = base64url.toBuffer('test'); - try { - convertPublicKeyToPEM(input); - } catch (err) { - expect((err as Error).message).toEqual('Could not convert public key type 1 to PEM'); - } -}); diff --git a/packages/server/src/helpers/convertPublicKeyToPEM.ts b/packages/server/src/helpers/convertPublicKeyToPEM.ts deleted file mode 100644 index 5c0e39a..0000000 --- a/packages/server/src/helpers/convertPublicKeyToPEM.ts +++ /dev/null @@ -1,69 +0,0 @@ -import cbor from 'cbor'; -import jwkToPem from 'jwk-to-pem'; - -import { COSEKEYS, COSEKTY, COSECRV } from './convertCOSEtoPKCS'; - -export function convertPublicKeyToPEM(publicKey: Buffer): string { - let struct; - try { - struct = cbor.decodeAllSync(publicKey)[0]; - } catch (err) { - const _err = err as Error; - throw new Error(`Error decoding public key while converting to PEM: ${_err.message}`); - } - - const kty = struct.get(COSEKEYS.kty); - - if (!kty) { - throw new Error('Public key was missing kty'); - } - - if (kty === COSEKTY.EC2) { - const crv = struct.get(COSEKEYS.crv); - const x = struct.get(COSEKEYS.x); - const y = struct.get(COSEKEYS.y); - - if (!crv) { - throw new Error('Public key was missing crv (EC2)'); - } - - if (!x) { - throw new Error('Public key was missing x (EC2)'); - } - - if (!y) { - throw new Error('Public key was missing y (EC2)'); - } - - const ecPEM = jwkToPem({ - kty: 'EC', - // Specify curve as "P-256" from "p256" - crv: COSECRV[crv as number].replace('p', 'P-'), - x: (x as Buffer).toString('base64'), - y: (y as Buffer).toString('base64'), - }); - - return ecPEM; - } else if (kty === COSEKTY.RSA) { - const n = struct.get(COSEKEYS.n); - const e = struct.get(COSEKEYS.e); - - if (!n) { - throw new Error('Public key was missing n (RSA)'); - } - - if (!e) { - throw new Error('Public key was missing e (RSA)'); - } - - const rsaPEM = jwkToPem({ - kty: 'RSA', - n: (n as Buffer).toString('base64'), - e: (e as Buffer).toString('base64'), - }); - - return rsaPEM; - } - - throw new Error(`Could not convert public key type ${kty} to PEM`); -} diff --git a/packages/server/src/helpers/convertX509PublicKeyToCOSE.ts b/packages/server/src/helpers/convertX509PublicKeyToCOSE.ts new file mode 100644 index 0000000..cd76146 --- /dev/null +++ b/packages/server/src/helpers/convertX509PublicKeyToCOSE.ts @@ -0,0 +1,124 @@ +import { AsnParser } from '@peculiar/asn1-schema'; +import { Certificate } from '@peculiar/asn1-x509'; +import { ECParameters, id_ecPublicKey, id_secp256r1 } from '@peculiar/asn1-ecc'; +import { RSAPublicKey } from '@peculiar/asn1-rsa'; + +import { + COSEPublicKey, + COSEKTY, + COSECRV, + COSEKEYS, + COSEPublicKeyEC2, + COSEPublicKeyRSA, + COSEALG, +} from './cose'; + +export function convertX509PublicKeyToCOSE(leafCertificate: Uint8Array): COSEPublicKey { + let cosePublicKey: COSEPublicKey = new Map(); + + /** + * Time to extract the public key from an X.509 leaf certificate + */ + const x509 = AsnParser.parse(leafCertificate, Certificate); + + const { tbsCertificate } = x509; + const { subjectPublicKeyInfo, signature: _tbsSignature } = tbsCertificate; + + const signatureAlgorithm = _tbsSignature.algorithm; + const publicKeyAlgorithmID = subjectPublicKeyInfo.algorithm.algorithm; + + if (publicKeyAlgorithmID === id_ecPublicKey) { + /** + * EC2 Public Key + */ + if (!subjectPublicKeyInfo.algorithm.parameters) { + throw new Error('Leaf cert public key missing parameters (EC2)'); + } + + const ecParameters = AsnParser.parse( + new Uint8Array(subjectPublicKeyInfo.algorithm.parameters), + ECParameters, + ); + + let crv = -999; + if (ecParameters.namedCurve === id_secp256r1) { + crv = COSECRV.P256; + } else { + throw new Error( + `Leaf cert public key contained unexpected namedCurve ${ecParameters.namedCurve} (EC2)`, + ); + } + + const subjectPublicKey = new Uint8Array(subjectPublicKeyInfo.subjectPublicKey); + + let x: Uint8Array; + let y: Uint8Array; + if (subjectPublicKey[0] === 0x04) { + // Public key is in "uncompressed form", so we can split the remaining bytes in half + let pointer = 1; + const halfLength = (subjectPublicKey.length - 1) / 2; + x = subjectPublicKey.slice(pointer, (pointer += halfLength)); + y = subjectPublicKey.slice(pointer); + } else { + throw new Error('TODO: Figure out how to handle public keys in "compressed form"'); + } + + const coseEC2PubKey: COSEPublicKeyEC2 = new Map(); + coseEC2PubKey.set(COSEKEYS.kty, COSEKTY.EC2); + coseEC2PubKey.set(COSEKEYS.alg, signatureAlgorithmToCOSEAlg(signatureAlgorithm)); + coseEC2PubKey.set(COSEKEYS.crv, crv); + coseEC2PubKey.set(COSEKEYS.x, x); + coseEC2PubKey.set(COSEKEYS.y, y); + + cosePublicKey = coseEC2PubKey; + } else if (publicKeyAlgorithmID === '1.2.840.113549.1.1.1') { + /** + * RSA public key + */ + const rsaPublicKey = AsnParser.parse(subjectPublicKeyInfo.subjectPublicKey, RSAPublicKey); + + const coseRSAPubKey: COSEPublicKeyRSA = new Map(); + coseRSAPubKey.set(COSEKEYS.kty, COSEKTY.RSA); + coseRSAPubKey.set(COSEKEYS.alg, signatureAlgorithmToCOSEAlg(signatureAlgorithm)); + coseRSAPubKey.set(COSEKEYS.n, new Uint8Array(rsaPublicKey.modulus)); + coseRSAPubKey.set(COSEKEYS.e, new Uint8Array(rsaPublicKey.publicExponent)); + + cosePublicKey = coseRSAPubKey; + } else { + throw new Error(`Unexpected leaf cert public key algorithm ${publicKeyAlgorithmID}`); + } + + return cosePublicKey; +} + +/** + * Map X.509 signature algorithm OIDs to COSE algorithm IDs + * + * - EC2 OIDs: https://oidref.com/1.2.840.10045.4.3 + * - RSA OIDs: https://oidref.com/1.2.840.113549.1.1 + */ +function signatureAlgorithmToCOSEAlg(signatureAlgorithm: string): COSEALG { + let alg: COSEALG; + + if (signatureAlgorithm === '1.2.840.10045.4.3.2') { + alg = COSEALG.ES256; + } else if (signatureAlgorithm === '1.2.840.10045.4.3.3') { + alg = COSEALG.ES384; + } else if (signatureAlgorithm === '1.2.840.10045.4.3.4') { + alg = COSEALG.ES512; + } else if (signatureAlgorithm === '1.2.840.113549.1.1.11') { + alg = COSEALG.RS256; + } else if (signatureAlgorithm === '1.2.840.113549.1.1.12') { + alg = COSEALG.RS384; + } else if (signatureAlgorithm === '1.2.840.113549.1.1.13') { + alg = COSEALG.RS512; + } else if (signatureAlgorithm === '1.2.840.113549.1.1.5') { + alg = COSEALG.RS1; + } else { + throw new Error( + `Leaf cert contained unexpected signature algorithm ${signatureAlgorithm} (EC2)`, + ); + } + + return alg; +} diff --git a/packages/server/src/helpers/cose.ts b/packages/server/src/helpers/cose.ts new file mode 100644 index 0000000..2f2e446 --- /dev/null +++ b/packages/server/src/helpers/cose.ts @@ -0,0 +1,139 @@ +/** + * Fundamental values that are needed to discern the more specific COSE public key types below. + * + * The use of `Maps` here is due to CBOR encoding being used with public keys, and the CBOR "Map" + * type is being decoded to JavaScript's `Map` type instead of, say, a basic Object as us JS + * developers might prefer. + * + * These types are an unorthodox way of saying "these Maps should involve these discrete lists of + * keys", but it works. + */ +export type COSEPublicKey = { + // Getters + get(key: COSEKEYS.kty): COSEKTY | undefined; + get(key: COSEKEYS.alg): COSEALG | undefined; + // Setters + set(key: COSEKEYS.kty, value: COSEKTY): void; + set(key: COSEKEYS.alg, value: COSEALG): void; +}; + +export type COSEPublicKeyOKP = COSEPublicKey & { + // Getters + get(key: COSEKEYS.crv): number | undefined; + get(key: COSEKEYS.x): Uint8Array | undefined; + // Setters + set(key: COSEKEYS.crv, value: number): void; + set(key: COSEKEYS.x, value: Uint8Array): void; +}; + +export type COSEPublicKeyEC2 = COSEPublicKey & { + // Getters + get(key: COSEKEYS.crv): number | undefined; + get(key: COSEKEYS.x): Uint8Array | undefined; + get(key: COSEKEYS.y): Uint8Array | undefined; + // Setters + set(key: COSEKEYS.crv, value: number): void; + set(key: COSEKEYS.x, value: Uint8Array): void; + set(key: COSEKEYS.y, value: Uint8Array): void; +}; + +export type COSEPublicKeyRSA = COSEPublicKey & { + // Getters + get(key: COSEKEYS.n): Uint8Array | undefined; + get(key: COSEKEYS.e): Uint8Array | undefined; + // Setters + set(key: COSEKEYS.n, value: Uint8Array): void; + set(key: COSEKEYS.e, value: Uint8Array): void; +}; + +export function isCOSEPublicKeyOKP( + cosePublicKey: COSEPublicKey, +): cosePublicKey is COSEPublicKeyOKP { + const kty = cosePublicKey.get(COSEKEYS.kty); + return isCOSEKty(kty) && kty === COSEKTY.OKP; +} + +export function isCOSEPublicKeyEC2( + cosePublicKey: COSEPublicKey, +): cosePublicKey is COSEPublicKeyEC2 { + const kty = cosePublicKey.get(COSEKEYS.kty); + return isCOSEKty(kty) && kty === COSEKTY.EC2; +} + +export function isCOSEPublicKeyRSA( + cosePublicKey: COSEPublicKey, +): cosePublicKey is COSEPublicKeyRSA { + const kty = cosePublicKey.get(COSEKEYS.kty); + return isCOSEKty(kty) && kty === COSEKTY.RSA; +} + +/** + * COSE Keys + * + * https://www.iana.org/assignments/cose/cose.xhtml#key-common-parameters + * https://www.iana.org/assignments/cose/cose.xhtml#key-type-parameters + */ +export enum COSEKEYS { + kty = 1, + alg = 3, + crv = -1, + x = -2, + y = -3, + n = -1, + e = -2, +} + +/** + * COSE Key Types + * + * https://www.iana.org/assignments/cose/cose.xhtml#key-type + */ +export enum COSEKTY { + OKP = 1, + EC2 = 2, + RSA = 3, +} + +export function isCOSEKty(kty: number | undefined): kty is COSEKTY { + return Object.values(COSEKTY).indexOf(kty as COSEKTY) >= 0; +} + +/** + * COSE Curves + * + * https://www.iana.org/assignments/cose/cose.xhtml#elliptic-curves + */ +export enum COSECRV { + P256 = 1, + P384 = 2, + P521 = 3, + ED25519 = 6, +} + +export function isCOSECrv(crv: number | undefined): crv is COSECRV { + return Object.values(COSECRV).indexOf(crv as COSECRV) >= 0; +} + +/** + * COSE Algorithms + * + * https://www.iana.org/assignments/cose/cose.xhtml#algorithms + */ +export enum COSEALG { + ES256 = -7, + EdDSA = -8, + ES384 = -35, + ES512 = -36, + PS256 = -37, + PS384 = -38, + PS512 = -39, + ES256K = -47, + RS256 = -257, + RS384 = -258, + RS512 = -259, + RS1 = -65535, +} + +export function isCOSEAlg(alg: number | undefined): alg is COSEALG { + return Object.values(COSEALG).indexOf(alg as COSEALG) >= 0; +} diff --git a/packages/server/src/helpers/decodeAttestationObject.test.ts b/packages/server/src/helpers/decodeAttestationObject.test.ts index 1ba6bd0..b37d137 100644 --- a/packages/server/src/helpers/decodeAttestationObject.test.ts +++ b/packages/server/src/helpers/decodeAttestationObject.test.ts @@ -11,9 +11,9 @@ test('should decode base64url-encoded indirect attestationObject', () => { ), ); - expect(decoded.fmt).toEqual('none'); - expect(decoded.attStmt).toEqual({}); - expect(decoded.authData).toBeDefined(); + expect(decoded.get('fmt')).toEqual('none'); + expect(decoded.get('attStmt')).toEqual(new Map()); + expect(decoded.get('authData')).toBeDefined(); }); test('should decode base64url-encoded direct attestationObject', () => { @@ -38,8 +38,8 @@ test('should decode base64url-encoded direct attestationObject', () => { ), ); - expect(decoded.fmt).toEqual('fido-u2f'); - expect(decoded.attStmt.sig).toBeDefined(); - expect(decoded.attStmt.x5c).toBeDefined(); - expect(decoded.authData).toBeDefined(); + expect(decoded.get('fmt')).toEqual('fido-u2f'); + expect(decoded.get('attStmt').get('sig')).toBeDefined(); + expect(decoded.get('attStmt').get('x5c')).toBeDefined(); + expect(decoded.get('authData')).toBeDefined(); }); diff --git a/packages/server/src/helpers/decodeAttestationObject.ts b/packages/server/src/helpers/decodeAttestationObject.ts index 5385106..afdd7a4 100644 --- a/packages/server/src/helpers/decodeAttestationObject.ts +++ b/packages/server/src/helpers/decodeAttestationObject.ts @@ -1,13 +1,12 @@ -import cbor from 'cbor'; +import { isoCBOR } from './iso'; /** * Convert an AttestationObject buffer to a proper object * * @param base64AttestationObject Attestation Object buffer */ -export function decodeAttestationObject(attestationObject: Buffer): AttestationObject { - const toCBOR: AttestationObject = cbor.decodeAllSync(attestationObject)[0]; - return toCBOR; +export function decodeAttestationObject(attestationObject: Uint8Array): AttestationObject { + return isoCBOR.decodeFirst<AttestationObject>(attestationObject); } export type AttestationFormat = @@ -20,17 +19,23 @@ export type AttestationFormat = | 'none'; export type AttestationObject = { - fmt: AttestationFormat; - attStmt: AttestationStatement; - authData: Buffer; + get(key: 'fmt'): AttestationFormat; + get(key: 'attStmt'): AttestationStatement; + get(key: 'authData'): Uint8Array; }; +/** + * `AttestationStatement` will be an instance of `Map`, but these keys help make finite the list of + * possible values within it. + */ export type AttestationStatement = { - sig?: Buffer; - x5c?: Buffer[]; - response?: Buffer; - alg?: number; - ver?: string; - certInfo?: Buffer; - pubArea?: Buffer; + get(key: 'sig'): Uint8Array | undefined; + get(key: 'x5c'): Uint8Array[] | undefined; + get(key: 'response'): Uint8Array | undefined; + get(key: 'alg'): number | undefined; + get(key: 'ver'): string | undefined; + get(key: 'certInfo'): Uint8Array | undefined; + get(key: 'pubArea'): Uint8Array | undefined; + // `Map` properties + get size(): number; }; diff --git a/packages/server/src/helpers/decodeAuthenticatorExtensions.test.ts b/packages/server/src/helpers/decodeAuthenticatorExtensions.test.ts index b9e66b5..6cc5e24 100644 --- a/packages/server/src/helpers/decodeAuthenticatorExtensions.test.ts +++ b/packages/server/src/helpers/decodeAuthenticatorExtensions.test.ts @@ -1,30 +1,28 @@ import { decodeAuthenticatorExtensions } from './decodeAuthenticatorExtensions'; +import { isoUint8Array } from './iso'; test('should decode authenticator extensions', () => { const extensions = decodeAuthenticatorExtensions( - Buffer.from( + isoUint8Array.fromHex( 'A16C6465766963655075624B6579A56364706B584DA5010203262001215820991AABED9D' + - 'E4271A9EDEAD8806F9DC96D6DCCD0C476253A5510489EC8379BE5B225820A0973CFDEDBB' + - '79E27FEF4EE7481673FB3312504DDCA5434CFD23431D6AD29EDA63736967584730450221' + - '00EFB38074BD15B8C82CF09F87FBC6FB3C7169EA4F1806B7E90937374302345B7A02202B' + - '7113040731A0E727D338D48542863CE65880AA79E5EA740AC8CCD94347988E656E6F6E63' + - '65406573636F70654100666161677569645000000000000000000000000000000000', - 'hex', + 'E4271A9EDEAD8806F9DC96D6DCCD0C476253A5510489EC8379BE5B225820A0973CFDEDBB' + + '79E27FEF4EE7481673FB3312504DDCA5434CFD23431D6AD29EDA63736967584730450221' + + '00EFB38074BD15B8C82CF09F87FBC6FB3C7169EA4F1806B7E90937374302345B7A02202B' + + '7113040731A0E727D338D48542863CE65880AA79E5EA740AC8CCD94347988E656E6F6E63' + + '65406573636F70654100666161677569645000000000000000000000000000000000', ), ); expect(extensions).toMatchObject({ devicePubKey: { - dpk: Buffer.from( + dpk: isoUint8Array.fromHex( 'A5010203262001215820991AABED9DE4271A9EDEAD8806F9DC96D6DCCD0C476253A5510489EC8379BE5B225820A0973CFDEDBB79E27FEF4EE7481673FB3312504DDCA5434CFD23431D6AD29EDA', - 'hex', ), - sig: Buffer.from( + sig: isoUint8Array.fromHex( '3045022100EFB38074BD15B8C82CF09F87FBC6FB3C7169EA4F1806B7E90937374302345B7A02202B7113040731A0E727D338D48542863CE65880AA79E5EA740AC8CCD94347988E', - 'hex', ), - nonce: Buffer.from('', 'hex'), - scope: Buffer.from('00', 'hex'), - aaguid: Buffer.from('00000000000000000000000000000000', 'hex'), + nonce: isoUint8Array.fromHex(''), + scope: isoUint8Array.fromHex('00'), + aaguid: isoUint8Array.fromHex('00000000000000000000000000000000'), }, }); }); diff --git a/packages/server/src/helpers/decodeAuthenticatorExtensions.ts b/packages/server/src/helpers/decodeAuthenticatorExtensions.ts index a889879..7bd583c 100644 --- a/packages/server/src/helpers/decodeAuthenticatorExtensions.ts +++ b/packages/server/src/helpers/decodeAuthenticatorExtensions.ts @@ -1,4 +1,4 @@ -import cbor from 'cbor'; +import { isoCBOR } from './iso'; /** * Convert authenticator extension data buffer to a proper object @@ -6,16 +6,17 @@ import cbor from 'cbor'; * @param extensionData Authenticator Extension Data buffer */ export function decodeAuthenticatorExtensions( - extensionData: Buffer, + extensionData: Uint8Array, ): AuthenticationExtensionsAuthenticatorOutputs | undefined { - let toCBOR: AuthenticationExtensionsAuthenticatorOutputs | undefined; + let toCBOR: Map<string, unknown>; try { - toCBOR = cbor.decodeAllSync(extensionData)[0]; + toCBOR = isoCBOR.decodeFirst(extensionData); } catch (err) { const _err = err as Error; throw new Error(`Error decoding authenticator extensions: ${_err.message}`); } - return toCBOR; + + return convertMapToObjectDeep(toCBOR); } export type AuthenticationExtensionsAuthenticatorOutputs = { @@ -24,14 +25,34 @@ export type AuthenticationExtensionsAuthenticatorOutputs = { }; export type DevicePublicKeyAuthenticatorOutput = { - dpk?: Buffer; - scp?: Buffer; + dpk?: Uint8Array; sig?: string; - aaguid?: Buffer; + nonce?: Uint8Array; + scope?: Uint8Array; + aaguid?: Uint8Array; }; // TODO: Need to verify this format // https://w3c.github.io/webauthn/#sctn-uvm-extension. export type UVMAuthenticatorOutput = { - uvm?: Buffer[]; + uvm?: Uint8Array[]; }; + +/** + * CBOR-encoded extensions can be deeply-nested Maps, which are too deep for a simple + * `Object.entries()`. This method will recursively make sure that all Maps are converted into + * basic objects. + */ +function convertMapToObjectDeep(input: Map<string, unknown>): { [key: string]: unknown } { + const mapped: { [key: string]: unknown } = {}; + + for (const [key, value] of input) { + if (value instanceof Map) { + mapped[key] = convertMapToObjectDeep(value); + } else { + mapped[key] = value; + } + } + + return mapped; +} diff --git a/packages/server/src/helpers/decodeCbor.ts b/packages/server/src/helpers/decodeCbor.ts deleted file mode 100644 index 37e8ab2..0000000 --- a/packages/server/src/helpers/decodeCbor.ts +++ /dev/null @@ -1,24 +0,0 @@ -import cbor from 'cbor'; - -export function decodeCborFirst(input: string | Buffer | ArrayBufferView): any { - try { - // throws if there are extra bytes - return cbor.decodeFirstSync(input); - } catch (err) { - const _err = err as CborDecoderError; - // if the error was due to extra bytes, return the unpacked value - if (_err.value) { - return _err.value; - } - throw err; - } -} - -/** - * Intuited from a quick scan of `cbor.decodeFirstSync()` here: - * - * https://github.com/hildjj/node-cbor/blob/v5.1.0/lib/decoder.js#L189 - */ -class CborDecoderError extends Error { - value: any; -} diff --git a/packages/server/src/helpers/decodeClientDataJSON.ts b/packages/server/src/helpers/decodeClientDataJSON.ts index b3094db..e0de0a0 100644 --- a/packages/server/src/helpers/decodeClientDataJSON.ts +++ b/packages/server/src/helpers/decodeClientDataJSON.ts @@ -1,10 +1,10 @@ -import base64url from 'base64url'; +import { isoBase64URL } from './iso'; /** * Decode an authenticator's base64url-encoded clientDataJSON to JSON */ export function decodeClientDataJSON(data: string): ClientDataJSON { - const toString = base64url.decode(data); + const toString = isoBase64URL.toString(data); const clientData: ClientDataJSON = JSON.parse(toString); return clientData; diff --git a/packages/server/src/helpers/decodeCredentialPublicKey.ts b/packages/server/src/helpers/decodeCredentialPublicKey.ts index cd7a4a2..32f4199 100644 --- a/packages/server/src/helpers/decodeCredentialPublicKey.ts +++ b/packages/server/src/helpers/decodeCredentialPublicKey.ts @@ -1,6 +1,6 @@ -import { COSEPublicKey } from './convertCOSEtoPKCS'; -import { decodeCborFirst } from './decodeCbor'; +import { COSEPublicKey } from './cose'; +import { isoCBOR } from './iso'; -export function decodeCredentialPublicKey(publicKey: Buffer): COSEPublicKey { - return decodeCborFirst(publicKey); +export function decodeCredentialPublicKey(publicKey: Uint8Array): COSEPublicKey { + return isoCBOR.decodeFirst<COSEPublicKey>(publicKey); } diff --git a/packages/server/src/helpers/generateChallenge.ts b/packages/server/src/helpers/generateChallenge.ts index 4acecf3..8277674 100644 --- a/packages/server/src/helpers/generateChallenge.ts +++ b/packages/server/src/helpers/generateChallenge.ts @@ -1,9 +1,9 @@ -import crypto from 'crypto'; +import { isoCrypto } from './iso'; /** * Generate a suitably random value to be used as an attestation or assertion challenge */ -export function generateChallenge(): Buffer { +export function generateChallenge(): Uint8Array { /** * WebAuthn spec says that 16 bytes is a good minimum: * @@ -12,5 +12,9 @@ export function generateChallenge(): Buffer { * * Just in case, let's double it */ - return crypto.randomBytes(32); + const challenge = new Uint8Array(32); + + isoCrypto.getRandomValues(challenge); + + return challenge; } diff --git a/packages/server/src/helpers/getCertificateInfo.ts b/packages/server/src/helpers/getCertificateInfo.ts index 02183c5..e503f70 100644 --- a/packages/server/src/helpers/getCertificateInfo.ts +++ b/packages/server/src/helpers/getCertificateInfo.ts @@ -36,7 +36,7 @@ const issuerSubjectIDKey: { [key: string]: 'C' | 'O' | 'OU' | 'CN' } = { * * @param pemCertificate Result from call to `convertASN1toPEM(x5c[0])` */ -export function getCertificateInfo(leafCertBuffer: Buffer): CertificateInfo { +export function getCertificateInfo(leafCertBuffer: Uint8Array): CertificateInfo { const asnx509 = AsnParser.parse(leafCertBuffer, Certificate); const parsedCert = asnx509.tbsCertificate; diff --git a/packages/server/src/helpers/index.ts b/packages/server/src/helpers/index.ts index d0c4f42..fec9838 100644 --- a/packages/server/src/helpers/index.ts +++ b/packages/server/src/helpers/index.ts @@ -1,37 +1,38 @@ 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'; +import { isoCBOR, isoBase64URL, isoUint8Array, isoCrypto } from './iso'; +import * as cose from './cose'; export { convertAAGUIDToString, convertCertBufferToPEM, convertCOSEtoPKCS, - convertPublicKeyToPEM, decodeAttestationObject, - decodeCborFirst, decodeClientDataJSON, decodeCredentialPublicKey, generateChallenge, getCertificateInfo, - isBase64URLString, isCertRevoked, parseAuthenticatorData, toHash, validateCertificatePath, verifySignature, + isoCBOR, + isoCrypto, + isoBase64URL, + isoUint8Array, + cose, }; import type { @@ -41,7 +42,7 @@ import type { } from './decodeAttestationObject'; import type { CertificateInfo } from './getCertificateInfo'; import type { ClientDataJSON } from './decodeClientDataJSON'; -import type { COSEPublicKey } from './convertCOSEtoPKCS'; +import type { COSEPublicKey } from './cose'; import type { ParsedAuthenticatorData } from './parseAuthenticatorData'; export type { diff --git a/packages/server/src/helpers/isBase64URLString.test.ts b/packages/server/src/helpers/isBase64URLString.test.ts deleted file mode 100644 index 358c420..0000000 --- a/packages/server/src/helpers/isBase64URLString.test.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { isBase64URLString } from './isBase64URLString'; - -test('should return true when input is base64URLString', () => { - const actual = isBase64URLString('U2ltcGxlV2ViQXV0aG4'); - expect(actual).toEqual(true); -}); - -test('should return false when input is not base64URLString', () => { - const actual = isBase64URLString('U2ltcGxlV2ViQXV0aG4+'); - expect(actual).toEqual(false); -}); - -test('should return false when input is blank', () => { - const actual = isBase64URLString(''); - expect(actual).toEqual(false); -}); diff --git a/packages/server/src/helpers/isBase64URLString.ts b/packages/server/src/helpers/isBase64URLString.ts deleted file mode 100644 index f229bf3..0000000 --- a/packages/server/src/helpers/isBase64URLString.ts +++ /dev/null @@ -1,13 +0,0 @@ -// Base64URL, with optional padding -const base64urlRegEx = /^([0-9a-zA-Z-_]{4})*(([0-9a-zA-Z-_]{2}(==)?)|([0-9a-zA-Z-_]{3}=?))?$/; - -/** - * Check to see if a string only contains valid Base64URL values - */ -export function isBase64URLString(value: string): boolean { - if (!value) { - return false; - } - - return base64urlRegEx.test(value); -} diff --git a/packages/server/src/helpers/isCertRevoked.ts b/packages/server/src/helpers/isCertRevoked.ts index cc8c3f1..1ea3a8a 100644 --- a/packages/server/src/helpers/isCertRevoked.ts +++ b/packages/server/src/helpers/isCertRevoked.ts @@ -1,9 +1,10 @@ import { X509 } from 'jsrsasign'; -import fetch from 'node-fetch'; +import fetch from 'cross-fetch'; import { AsnParser } from '@peculiar/asn1-schema'; import { CertificateList } from '@peculiar/asn1-x509'; import { convertCertBufferToPEM } from './convertCertBufferToPEM'; +import { isoUint8Array } from './iso'; /** * A cache of revoked cert serial numbers by Authority Key ID @@ -61,14 +62,14 @@ export async function isCertRevoked(cert: X509): Promise<boolean> { const crlCert = new X509(); try { const respCRL = await fetch(crlURL[0]); - const dataCRL = await respCRL.buffer(); - const dataPEM = convertCertBufferToPEM(dataCRL); + const dataCRL = await respCRL.arrayBuffer(); + const dataPEM = convertCertBufferToPEM(new Uint8Array(dataCRL)); crlCert.readCertPEM(dataPEM); } catch (err) { return false; } - const data = AsnParser.parse(Buffer.from(crlCert.hex, 'hex'), CertificateList); + const data = AsnParser.parse(isoUint8Array.fromHex(crlCert.hex), CertificateList); const newCached: CAAuthorityInfo = { revokedCerts: [], @@ -85,7 +86,7 @@ export async function isCertRevoked(cert: X509): Promise<boolean> { if (revokedCerts) { for (const cert of revokedCerts) { - const revokedHex = Buffer.from(cert.userCertificate).toString('hex'); + const revokedHex = isoUint8Array.toHex(new Uint8Array(cert.userCertificate)); newCached.revokedCerts.push(revokedHex); } diff --git a/packages/server/src/helpers/iso/index.ts b/packages/server/src/helpers/iso/index.ts new file mode 100644 index 0000000..49f19e4 --- /dev/null +++ b/packages/server/src/helpers/iso/index.ts @@ -0,0 +1,11 @@ +/** + * A collection of methods for isomorphic manipulation of trickier data types + * + * The goal with these is to make it easier to replace dependencies later that might not play well + * with specific server-like runtimes that expose global Web APIs (CloudFlare Workers, Deno, Bun, + * etc...), while also supporting execution in Node. + */ +export * as isoBase64URL from './isoBase64URL'; +export * as isoCBOR from './isoCBOR'; +export * as isoCrypto from './isoCrypto'; +export * as isoUint8Array from './isoUint8Array'; diff --git a/packages/server/src/helpers/iso/isoBase64URL.ts b/packages/server/src/helpers/iso/isoBase64URL.ts new file mode 100644 index 0000000..1dfd522 --- /dev/null +++ b/packages/server/src/helpers/iso/isoBase64URL.ts @@ -0,0 +1,67 @@ +import base64 from '@hexagon/base64'; + +/** + * Decode from a Base64URL-encoded string to an ArrayBuffer. Best used when converting a + * credential ID from a JSON string to an ArrayBuffer, like in allowCredentials or + * excludeCredentials. + * + * @param buffer Value to decode from base64 + * @param to (optional) The decoding to use, in case it's desirable to decode from base64 instead + */ +export function toBuffer( + base64urlString: string, + from: 'base64' | 'base64url' = 'base64url', +): Uint8Array { + const _buffer = base64.toArrayBuffer(base64urlString, from === 'base64url'); + return new Uint8Array(_buffer); +} + +/** + * Encode the given array buffer into a Base64URL-encoded string. Ideal for converting various + * credential response ArrayBuffers to string for sending back to the server as JSON. + * + * @param buffer Value to encode to base64 + * @param to (optional) The encoding to use, in case it's desirable to encode to base64 instead + */ +export function fromBuffer(buffer: Uint8Array, to: 'base64' | 'base64url' = 'base64url'): string { + return base64.fromArrayBuffer(buffer, to === 'base64url'); +} + +/** + * Convert a base64url string into base64 + */ +export function toBase64(base64urlString: string): string { + const fromBase64Url = base64.toArrayBuffer(base64urlString, true); + const toBase64 = base64.fromArrayBuffer(fromBase64Url); + return toBase64; +} + +/** + * Encode a string to base64url + */ +export function fromString(ascii: string): string { + return base64.fromString(ascii, true); +} + +/** + * Decode a base64url string into its original string + */ +export function toString(base64urlString: string): string { + return base64.toString(base64urlString, true); +} + +/** + * Confirm that the string is encoded into base64 + */ +export function isBase64(input: string): boolean { + return base64.validate(input, false); +} + +/** + * Confirm that the string is encoded into base64url, with support for optional padding + */ +export function isBase64url(input: string): boolean { + // Trim padding characters from the string if present + input = input.replace(/=/g, ''); + return base64.validate(input, true); +} diff --git a/packages/server/src/helpers/iso/isoCBOR.ts b/packages/server/src/helpers/iso/isoCBOR.ts new file mode 100644 index 0000000..9f7cbd7 --- /dev/null +++ b/packages/server/src/helpers/iso/isoCBOR.ts @@ -0,0 +1,46 @@ +/* eslint-disable @typescript-eslint/ban-ts-comment */ +import * as cborx from 'cbor-x'; + +/** + * This encoder should keep CBOR data the same length when data is re-encoded + * + * MOST CRITICALLY, this means the following needs to be true of whatever CBOR library we use: + * - CBOR Map type values MUST decode to JavaScript Maps + * - CBOR tag 64 (uint8 Typed Array) MUST NOT be used when encoding Uint8Arrays back to CBOR + * + * So long as these requirements are maintained, then CBOR sequences can be encoded and decoded + * freely while maintaining their lengths for the most accurate pointer movement across them. + */ +const encoder = new cborx.Encoder({ mapsAsObjects: false, tagUint8Array: false }); + +/** + * Decode and return the first item in a sequence of CBOR-encoded values + * + * @param input The CBOR data to decode + * @param asObject (optional) Whether to convert any CBOR Maps into JavaScript Objects. Defaults to + * `false` + */ +export function decodeFirst<Type>(input: Uint8Array): Type { + const decoded = encoder.decodeMultiple(input) as undefined | Type[]; + + if (decoded === undefined) { + throw new Error('CBOR input data was empty'); + } + + /** + * Typing on `decoded` is `void | []` which causes TypeScript to think that it's an empty array, + * and thus you can't destructure it. I'm ignoring that because the code works fine in JS, and + * so this should be a valid operation. + */ + // @ts-ignore 2493 + const [first] = decoded; + + return first; +} + +/** + * Encode data to CBOR + */ +export function encode(input: any): Uint8Array { + return encoder.encode(input); +} diff --git a/packages/server/src/helpers/iso/isoCrypto/digest.ts b/packages/server/src/helpers/iso/isoCrypto/digest.ts new file mode 100644 index 0000000..05260a3 --- /dev/null +++ b/packages/server/src/helpers/iso/isoCrypto/digest.ts @@ -0,0 +1,18 @@ +import WebCrypto from '@simplewebauthn/iso-webcrypto'; + +import { COSEALG } from '../../cose'; +import { mapCoseAlgToWebCryptoAlg } from './mapCoseAlgToWebCryptoAlg'; + +/** + * Generate a digest of the provided data. + * + * @param data The data to generate a digest of + * @param algorithm A COSE algorithm ID that maps to a desired SHA algorithm + */ +export async function digest(data: Uint8Array, algorithm: COSEALG): Promise<Uint8Array> { + const subtleAlgorithm = mapCoseAlgToWebCryptoAlg(algorithm); + + const hashed = await WebCrypto.subtle.digest(subtleAlgorithm, data); + + return new Uint8Array(hashed); +} diff --git a/packages/server/src/helpers/iso/isoCrypto/getRandomValues.ts b/packages/server/src/helpers/iso/isoCrypto/getRandomValues.ts new file mode 100644 index 0000000..ab7454b --- /dev/null +++ b/packages/server/src/helpers/iso/isoCrypto/getRandomValues.ts @@ -0,0 +1,11 @@ +import WebCrypto from '@simplewebauthn/iso-webcrypto'; + +/** + * Fill up the provided bytes array with random bytes equal to its length. + * + * @returns the same bytes array passed into the method + */ +export function getRandomValues(array: Uint8Array): Uint8Array { + WebCrypto.getRandomValues(array); + return array; +} diff --git a/packages/server/src/helpers/iso/isoCrypto/importKey.ts b/packages/server/src/helpers/iso/isoCrypto/importKey.ts new file mode 100644 index 0000000..4d2ef2b --- /dev/null +++ b/packages/server/src/helpers/iso/isoCrypto/importKey.ts @@ -0,0 +1,10 @@ +import WebCrypto from '@simplewebauthn/iso-webcrypto'; + +export async function importKey(opts: { + keyData: JsonWebKey; + algorithm: AlgorithmIdentifier | RsaHashedImportParams | EcKeyImportParams; +}): Promise<CryptoKey> { + const { keyData, algorithm } = opts; + + return WebCrypto.subtle.importKey('jwk', keyData, algorithm, false, ['verify']); +} diff --git a/packages/server/src/helpers/iso/isoCrypto/index.ts b/packages/server/src/helpers/iso/isoCrypto/index.ts new file mode 100644 index 0000000..7850722 --- /dev/null +++ b/packages/server/src/helpers/iso/isoCrypto/index.ts @@ -0,0 +1,3 @@ +export { digest } from './digest'; +export { getRandomValues } from './getRandomValues'; +export { verify } from './verify'; diff --git a/packages/server/src/helpers/iso/isoCrypto/mapCoseAlgToWebCryptoAlg.ts b/packages/server/src/helpers/iso/isoCrypto/mapCoseAlgToWebCryptoAlg.ts new file mode 100644 index 0000000..3394b90 --- /dev/null +++ b/packages/server/src/helpers/iso/isoCrypto/mapCoseAlgToWebCryptoAlg.ts @@ -0,0 +1,19 @@ +import { SubtleCryptoAlg } from './structs'; +import { COSEALG } from '../../cose'; + +/** + * Convert a COSE alg ID into a corresponding string value that WebCrypto APIs expect + */ +export function mapCoseAlgToWebCryptoAlg(alg: COSEALG): SubtleCryptoAlg { + if ([COSEALG.RS1].indexOf(alg) >= 0) { + return 'SHA-1'; + } else if ([COSEALG.ES256, COSEALG.PS256, COSEALG.RS256].indexOf(alg) >= 0) { + return 'SHA-256'; + } else if ([COSEALG.ES384, COSEALG.PS384, COSEALG.RS384].indexOf(alg) >= 0) { + return 'SHA-384'; + } else if ([COSEALG.ES512, COSEALG.PS512, COSEALG.RS512, COSEALG.EdDSA].indexOf(alg) >= 0) { + return 'SHA-512'; + } + + throw new Error(`Unexpected COSE alg value of ${alg}`); +} diff --git a/packages/server/src/helpers/iso/isoCrypto/mapCoseAlgToWebCryptoKeyAlgName.ts b/packages/server/src/helpers/iso/isoCrypto/mapCoseAlgToWebCryptoKeyAlgName.ts new file mode 100644 index 0000000..8be875c --- /dev/null +++ b/packages/server/src/helpers/iso/isoCrypto/mapCoseAlgToWebCryptoKeyAlgName.ts @@ -0,0 +1,19 @@ +import { COSEALG } from '../../cose'; +import { SubtleCryptoKeyAlgName } from './structs'; + +/** + * Convert a COSE alg ID into a corresponding key algorithm string value that WebCrypto APIs expect + */ +export function mapCoseAlgToWebCryptoKeyAlgName(alg: COSEALG): SubtleCryptoKeyAlgName { + if ([COSEALG.EdDSA].indexOf(alg) >= 0) { + return 'Ed25519'; + } else if ([COSEALG.ES256, COSEALG.ES384, COSEALG.ES512, COSEALG.ES256K].indexOf(alg) >= 0) { + return 'ECDSA'; + } else if ([COSEALG.RS256, COSEALG.RS384, COSEALG.RS512].indexOf(alg) >= 0) { + return 'RSASSA-PKCS1-v1_5'; + } else if ([COSEALG.PS256, COSEALG.PS384, COSEALG.PS512].indexOf(alg) >= 0) { + return 'RSA-PSS'; + } + + throw new Error(`Unexpected COSE alg value of ${alg}`); +} diff --git a/packages/server/src/helpers/iso/isoCrypto/structs.ts b/packages/server/src/helpers/iso/isoCrypto/structs.ts new file mode 100644 index 0000000..b6880c4 --- /dev/null +++ b/packages/server/src/helpers/iso/isoCrypto/structs.ts @@ -0,0 +1,3 @@ +export type SubtleCryptoAlg = 'SHA-1' | 'SHA-256' | 'SHA-384' | 'SHA-512'; +export type SubtleCryptoCrv = 'P-256' | 'P-384' | 'P-521' | 'Ed25519'; +export type SubtleCryptoKeyAlgName = 'ECDSA' | 'Ed25519' | 'RSASSA-PKCS1-v1_5' | 'RSA-PSS'; diff --git a/packages/server/src/helpers/iso/isoCrypto/verify.ts b/packages/server/src/helpers/iso/isoCrypto/verify.ts new file mode 100644 index 0000000..b995e7a --- /dev/null +++ b/packages/server/src/helpers/iso/isoCrypto/verify.ts @@ -0,0 +1,36 @@ +import { + COSEALG, + COSEKEYS, + COSEPublicKey, + isCOSEPublicKeyEC2, + isCOSEPublicKeyOKP, + isCOSEPublicKeyRSA, +} from '../../cose'; +import { verifyEC2 } from './verifyEC2'; +import { verifyRSA } from './verifyRSA'; +import { verifyOKP } from './verifyOKP'; + +/** + * Verify signatures with their public key. Supports EC2 and RSA public keys. + */ +export async function verify(opts: { + cosePublicKey: COSEPublicKey; + signature: Uint8Array; + data: Uint8Array; + shaHashOverride?: COSEALG; +}): Promise<boolean> { + const { cosePublicKey, signature, data, shaHashOverride } = opts; + + if (isCOSEPublicKeyEC2(cosePublicKey)) { + return verifyEC2({ cosePublicKey, signature, data, shaHashOverride }); + } else if (isCOSEPublicKeyRSA(cosePublicKey)) { + return verifyRSA({ cosePublicKey, signature, data, shaHashOverride }); + } else if (isCOSEPublicKeyOKP(cosePublicKey)) { + return verifyOKP({ cosePublicKey, signature, data }); + } + + const kty = cosePublicKey.get(COSEKEYS.kty); + throw new Error( + `Signature verification with public key of kty ${kty} is not supported by this method`, + ); +} diff --git a/packages/server/src/helpers/iso/isoCrypto/verifyEC2.ts b/packages/server/src/helpers/iso/isoCrypto/verifyEC2.ts new file mode 100644 index 0000000..b86f57f --- /dev/null +++ b/packages/server/src/helpers/iso/isoCrypto/verifyEC2.ts @@ -0,0 +1,117 @@ +import WebCrypto from '@simplewebauthn/iso-webcrypto'; +import { ECDSASigValue } from '@peculiar/asn1-ecc'; +import { AsnParser } from '@peculiar/asn1-schema'; + +import { COSEALG, COSECRV, COSEKEYS, COSEPublicKeyEC2 } from '../../cose'; +import { mapCoseAlgToWebCryptoAlg } from './mapCoseAlgToWebCryptoAlg'; +import { importKey } from './importKey'; +import { isoBase64URL, isoUint8Array } from '../index'; +import { SubtleCryptoCrv } from './structs'; + +/** + * Verify a signature using an EC2 public key + */ +export async function verifyEC2(opts: { + cosePublicKey: COSEPublicKeyEC2; + signature: Uint8Array; + data: Uint8Array; + shaHashOverride?: COSEALG; +}): Promise<boolean> { + const { cosePublicKey, signature, data, shaHashOverride } = opts; + + // Import the public key + const alg = cosePublicKey.get(COSEKEYS.alg); + const crv = cosePublicKey.get(COSEKEYS.crv); + const x = cosePublicKey.get(COSEKEYS.x); + const y = cosePublicKey.get(COSEKEYS.y); + + if (!alg) { + throw new Error('Public key was missing alg (EC2)'); + } + + if (!crv) { + throw new Error('Public key was missing crv (EC2)'); + } + + if (!x) { + throw new Error('Public key was missing x (EC2)'); + } + + if (!y) { + throw new Error('Public key was missing y (EC2)'); + } + + let _crv: SubtleCryptoCrv; + if (crv === COSECRV.P256) { + _crv = 'P-256'; + } else if (crv === COSECRV.P384) { + _crv = 'P-384'; + } else if (crv === COSECRV.P521) { + _crv = 'P-521'; + } else { + throw new Error(`Unexpected COSE crv value of ${crv} (EC2)`); + } + + const keyData: JsonWebKey = { + kty: 'EC', + crv: _crv, + x: isoBase64URL.fromBuffer(x), + y: isoBase64URL.fromBuffer(y), + ext: false, + }; + + const keyAlgorithm: EcKeyImportParams = { + /** + * Note to future self: you can't use `mapCoseAlgToWebCryptoKeyAlgName()` here because some + * leaf certs from actual devices specified an RSA SHA value for `alg` (e.g. `-257`) which + * would then map here to `'RSASSA-PKCS1-v1_5'`. We always want `'ECDSA'` here so we'll + * hard-code this. + */ + name: 'ECDSA', + namedCurve: _crv, + }; + + const key = await importKey({ + keyData, + algorithm: keyAlgorithm, + }); + + // Determine which SHA algorithm to use for signature verification + let subtleAlg = mapCoseAlgToWebCryptoAlg(alg); + if (shaHashOverride) { + subtleAlg = mapCoseAlgToWebCryptoAlg(shaHashOverride); + } + + const verifyAlgorithm: EcdsaParams = { + name: 'ECDSA', + hash: { name: subtleAlg }, + }; + + // The signature is wrapped in ASN.1 structure, so we need to peel it apart + const parsedSignature = AsnParser.parse(signature, ECDSASigValue); + let rBytes = new Uint8Array(parsedSignature.r); + let sBytes = new Uint8Array(parsedSignature.s); + + if (shouldRemoveLeadingZero(rBytes)) { + rBytes = rBytes.slice(1); + } + + if (shouldRemoveLeadingZero(sBytes)) { + sBytes = sBytes.slice(1); + } + + const finalSignature = isoUint8Array.concat([rBytes, sBytes]); + + return WebCrypto.subtle.verify(verifyAlgorithm, key, finalSignature, data); +} + +/** + * Determine if the DER-specific `00` byte at the start of an ECDSA signature byte sequence + * should be removed based on the following logic: + * + * "If the leading byte is 0x0, and the the high order bit on the second byte is not set to 0, + * then remove the leading 0x0 byte" + */ +function shouldRemoveLeadingZero(bytes: Uint8Array): boolean { + return bytes[0] === 0x0 && (bytes[1] & (1 << 7)) !== 0; +} diff --git a/packages/server/src/helpers/iso/isoCrypto/verifyOKP.test.ts b/packages/server/src/helpers/iso/isoCrypto/verifyOKP.test.ts new file mode 100644 index 0000000..ccdcb00 --- /dev/null +++ b/packages/server/src/helpers/iso/isoCrypto/verifyOKP.test.ts @@ -0,0 +1,41 @@ +import { COSEALG, COSECRV, COSEKEYS, COSEKTY, COSEPublicKeyOKP } from '../../cose'; +import { verifyOKP } from './verifyOKP'; + +test('should verify a signature signed with an Ed25519 public key', async () => { + const cosePublicKey: COSEPublicKeyOKP = new Map(); + cosePublicKey.set(COSEKEYS.kty, COSEKTY.OKP); + cosePublicKey.set(COSEKEYS.alg, COSEALG.EdDSA); + cosePublicKey.set(COSEKEYS.crv, COSECRV.ED25519); + cosePublicKey.set( + COSEKEYS.x, + new Uint8Array([ + 108, 223, 182, 117, 49, 249, 221, 119, 212, 171, 158, 83, 213, 25, 47, 92, 202, 112, 29, 93, + 29, 69, 89, 204, 4, 252, 110, 56, 25, 181, 250, 242, + ]), + ); + + const data = new Uint8Array([ + 73, 150, 13, 229, 136, 14, 140, 104, 116, 52, 23, 15, 100, 118, 96, 91, 143, 228, 174, 185, 162, + 134, 50, 199, 153, 92, 243, 186, 131, 29, 151, 99, 65, 0, 0, 0, 50, 145, 223, 234, 215, 149, + 158, 68, 117, 173, 38, 155, 13, 72, 43, 224, 137, 0, 32, 26, 165, 170, 88, 196, 173, 98, 22, 89, + 49, 152, 159, 162, 234, 142, 198, 252, 167, 119, 99, 175, 187, 21, 101, 110, 214, 98, 129, 2, + 202, 30, 113, 164, 1, 1, 3, 39, 32, 6, 33, 88, 32, 108, 223, 182, 117, 49, 249, 221, 119, 212, + 171, 158, 83, 213, 25, 47, 92, 202, 112, 29, 93, 29, 69, 89, 204, 4, 252, 110, 56, 25, 181, 250, + 242, 180, 65, 206, 26, 160, 29, 17, 43, 138, 105, 200, 52, 116, 140, 10, 89, 241, 15, 241, 83, + 248, 162, 190, 130, 32, 220, 100, 15, 154, 150, 65, 140, + ]); + const signature = new Uint8Array([ + 29, 218, 16, 150, 129, 34, 25, 37, 7, 127, 215, 73, 93, 181, 115, 201, 99, 91, 14, 29, 10, 219, + 155, 105, 53, 4, 41, 143, 152, 107, 146, 16, 156, 117, 252, 244, 164, 32, 79, 182, 160, 161, + 145, 175, 248, 145, 242, 27, 133, 254, 137, 201, 141, 68, 24, 11, 159, 246, 148, 29, 194, 162, + 85, 5, + ]); + + const verified = await verifyOKP({ + cosePublicKey, + data, + signature, + }); + + expect(verified).toBe(true); +}); diff --git a/packages/server/src/helpers/iso/isoCrypto/verifyOKP.ts b/packages/server/src/helpers/iso/isoCrypto/verifyOKP.ts new file mode 100644 index 0000000..84679b3 --- /dev/null +++ b/packages/server/src/helpers/iso/isoCrypto/verifyOKP.ts @@ -0,0 +1,67 @@ +import WebCrypto from '@simplewebauthn/iso-webcrypto'; + +import { COSEPublicKeyOKP, COSEKEYS, isCOSEAlg, COSECRV } from '../../cose'; +import { isoBase64URL } from '../../index'; +import { SubtleCryptoCrv } from './structs'; +import { importKey } from './importKey'; + +export async function verifyOKP(opts: { + cosePublicKey: COSEPublicKeyOKP; + signature: Uint8Array; + data: Uint8Array; +}): Promise<boolean> { + const { cosePublicKey, signature, data } = opts; + + const alg = cosePublicKey.get(COSEKEYS.alg); + const crv = cosePublicKey.get(COSEKEYS.crv); + const x = cosePublicKey.get(COSEKEYS.x); + + if (!alg) { + throw new Error('Public key was missing alg (OKP)'); + } + + if (!isCOSEAlg(alg)) { + throw new Error(`Public key had invalid alg ${alg} (OKP)`); + } + + if (!crv) { + throw new Error('Public key was missing crv (OKP)'); + } + + if (!x) { + throw new Error('Public key was missing x (OKP)'); + } + + // Pulled key import steps from here: + // https://wicg.github.io/webcrypto-secure-curves/#ed25519-operations + let _crv: SubtleCryptoCrv; + if (crv === COSECRV.ED25519) { + _crv = 'Ed25519'; + } else { + throw new Error(`Unexpected COSE crv value of ${crv} (OKP)`); + } + + const keyData: JsonWebKey = { + kty: 'OKP', + crv: _crv, + alg: 'EdDSA', + x: isoBase64URL.fromBuffer(x), + ext: false, + }; + + const keyAlgorithm: EcKeyImportParams = { + name: _crv, + namedCurve: _crv, + }; + + const key = await importKey({ + keyData, + algorithm: keyAlgorithm, + }); + + const verifyAlgorithm: AlgorithmIdentifier = { + name: _crv, + }; + + return WebCrypto.subtle.verify(verifyAlgorithm, key, signature, data); +} diff --git a/packages/server/src/helpers/iso/isoCrypto/verifyRSA.ts b/packages/server/src/helpers/iso/isoCrypto/verifyRSA.ts new file mode 100644 index 0000000..9d07aab --- /dev/null +++ b/packages/server/src/helpers/iso/isoCrypto/verifyRSA.ts @@ -0,0 +1,104 @@ +import WebCrypto from '@simplewebauthn/iso-webcrypto'; + +import { COSEALG, COSEKEYS, COSEPublicKeyRSA, isCOSEAlg } from '../../cose'; +import { mapCoseAlgToWebCryptoAlg } from './mapCoseAlgToWebCryptoAlg'; +import { importKey } from './importKey'; +import { isoBase64URL } from '../index'; +import { mapCoseAlgToWebCryptoKeyAlgName } from './mapCoseAlgToWebCryptoKeyAlgName'; + +/** + * Verify a signature using an RSA public key + */ +export async function verifyRSA(opts: { + cosePublicKey: COSEPublicKeyRSA; + signature: Uint8Array; + data: Uint8Array; + shaHashOverride?: COSEALG; +}): Promise<boolean> { + const { cosePublicKey, signature, data, shaHashOverride } = opts; + + const alg = cosePublicKey.get(COSEKEYS.alg); + const n = cosePublicKey.get(COSEKEYS.n); + const e = cosePublicKey.get(COSEKEYS.e); + + if (!alg) { + throw new Error('Public key was missing alg (RSA)'); + } + + if (!isCOSEAlg(alg)) { + throw new Error(`Public key had invalid alg ${alg} (RSA)`); + } + + if (!n) { + throw new Error('Public key was missing n (RSA)'); + } + + if (!e) { + throw new Error('Public key was missing e (RSA)'); + } + + const keyData: JsonWebKey = { + kty: 'RSA', + alg: '', + n: isoBase64URL.fromBuffer(n), + e: isoBase64URL.fromBuffer(e), + ext: false, + }; + + const keyAlgorithm = { + name: mapCoseAlgToWebCryptoKeyAlgName(alg), + hash: { name: mapCoseAlgToWebCryptoAlg(alg) }, + }; + + const verifyAlgorithm: AlgorithmIdentifier | RsaPssParams = { + name: mapCoseAlgToWebCryptoKeyAlgName(alg), + }; + + if (shaHashOverride) { + keyAlgorithm.hash.name = mapCoseAlgToWebCryptoAlg(shaHashOverride); + } + + if (keyAlgorithm.name === 'RSASSA-PKCS1-v1_5') { + if (keyAlgorithm.hash.name === 'SHA-256') { + keyData.alg = 'RS256'; + } else if (keyAlgorithm.hash.name === 'SHA-384') { + keyData.alg = 'RS384'; + } else if (keyAlgorithm.hash.name === 'SHA-512') { + keyData.alg = 'RS512'; + } else if (keyAlgorithm.hash.name === 'SHA-1') { + keyData.alg = 'RS1'; + } + } else if (keyAlgorithm.name === 'RSA-PSS') { + /** + * salt length. The default value is 20 but the convention is to use hLen, the length of the + * output of the hash function in bytes. A salt length of zero is permitted and will result in + * a deterministic signature value. The actual salt length used can be determined from the + * signature value. + * + * From https://www.cryptosys.net/pki/manpki/pki_rsaschemes.html + */ + let saltLength = 0; + + if (keyAlgorithm.hash.name === 'SHA-256') { + keyData.alg = 'PS256'; + saltLength = 32; // 256 bits => 32 bytes + } else if (keyAlgorithm.hash.name === 'SHA-384') { + keyData.alg = 'PS384'; + saltLength = 48; // 384 bits => 48 bytes + } else if (keyAlgorithm.hash.name === 'SHA-512') { + keyData.alg = 'PS512'; + saltLength = 64; // 512 bits => 64 bytes + } + + (verifyAlgorithm as RsaPssParams).saltLength = saltLength; + } else { + throw new Error(`Unexpected RSA key algorithm ${alg} (${keyAlgorithm.name})`); + } + + const key = await importKey({ + keyData, + algorithm: keyAlgorithm, + }); + + return WebCrypto.subtle.verify(verifyAlgorithm, key, signature, data); +} diff --git a/packages/server/src/helpers/iso/isoUint8Array.ts b/packages/server/src/helpers/iso/isoUint8Array.ts new file mode 100644 index 0000000..7dc163e --- /dev/null +++ b/packages/server/src/helpers/iso/isoUint8Array.ts @@ -0,0 +1,90 @@ +/** + * Make sure two Uint8Arrays are deeply equivalent + */ +export function areEqual(array1: Uint8Array, array2: Uint8Array): boolean { + if (array1.length != array2.length) { + return false; + } + + return array1.every((val, i) => val === array2[i]); +} + +/** + * Convert a Uint8Array to Hexadecimal. + * + * A replacement for `Buffer.toString('hex')` + */ +export function toHex(array: Uint8Array): string { + const hexParts = Array.from(array, i => i.toString(16).padStart(2, '0')); + + // adce000235bcc60a648b0b25f1f05503 + return hexParts.join(''); +} + +/** + * Convert a hexadecimal string to isoUint8Array. + * + * A replacement for `Buffer.from('...', 'hex')` + */ +export function fromHex(hex: string): Uint8Array { + if (!hex) { + return Uint8Array.from([]); + } + + const isValid = hex.length !== 0 && hex.length % 2 === 0 && !/[^a-fA-F0-9]/u.test(hex); + + if (!isValid) { + throw new Error('Invalid hex string'); + } + + const byteStrings = hex.match(/.{1,2}/g) ?? []; + + return Uint8Array.from(byteStrings.map(byte => parseInt(byte, 16))); +} + +/** + * Combine multiple Uint8Arrays into a single Uint8Array + */ +export function concat(arrays: Uint8Array[]): Uint8Array { + let pointer = 0; + const totalLength = arrays.reduce((prev, curr) => prev + curr.length, 0); + + const toReturn = new Uint8Array(totalLength); + + arrays.forEach(arr => { + toReturn.set(arr, pointer); + pointer += arr.length; + }); + + return toReturn; +} + +/** + * Convert bytes into a UTF-8 string + */ +export function toUTF8String(array: Uint8Array): string { + const decoder = new globalThis.TextDecoder('utf-8'); + return decoder.decode(array); +} + +/** + * Convert a UTF-8 string back into bytes + */ +export function fromUTF8String(utf8String: string): Uint8Array { + const encoder = new globalThis.TextEncoder(); + return encoder.encode(utf8String); +} + +/** + * Convert an ASCII string to Uint8Array + */ +export function fromASCIIString(value: string): Uint8Array { + return Uint8Array.from(value.split('').map(x => x.charCodeAt(0))); +} + +/** + * Prepare a DataView we can slice our way around in as we parse the bytes in a Uint8Array + */ +export function toDataView(array: Uint8Array): DataView { + return new DataView(array.buffer, array.byteOffset, array.length); +} diff --git a/packages/server/src/helpers/matchExpectedRPID.ts b/packages/server/src/helpers/matchExpectedRPID.ts new file mode 100644 index 0000000..be49fc2 --- /dev/null +++ b/packages/server/src/helpers/matchExpectedRPID.ts @@ -0,0 +1,44 @@ +import { toHash } from './toHash'; +import { isoUint8Array } from './iso'; + +/** + * Go through each expected RP ID and try to find one that matches. Raises an Error if no + */ +export async function matchExpectedRPID( + rpIDHash: Uint8Array, + expectedRPIDs: string[], +): Promise<void> { + try { + await Promise.any( + expectedRPIDs.map(expected => { + return new Promise((resolve, reject) => { + toHash(isoUint8Array.fromASCIIString(expected)).then(expectedRPIDHash => { + if (isoUint8Array.areEqual(rpIDHash, expectedRPIDHash)) { + resolve(true); + } else { + reject(); + } + }); + }); + }), + ); + } catch (err) { + const _err = err as Error; + + // This means no matches were found + if (_err.name === 'AggregateError') { + throw new UnexpectedRPIDHash(); + } + + // An unexpected error occurred + throw err; + } +} + +class UnexpectedRPIDHash extends Error { + constructor() { + const message = 'Unexpected RP ID hash'; + super(message); + this.name = 'UnexpectedRPIDHash'; + } +} diff --git a/packages/server/src/helpers/parseAuthenticatorData.test.ts b/packages/server/src/helpers/parseAuthenticatorData.test.ts index a706718..1db4bfe 100644 --- a/packages/server/src/helpers/parseAuthenticatorData.test.ts +++ b/packages/server/src/helpers/parseAuthenticatorData.test.ts @@ -1,12 +1,12 @@ -import cbor from 'cbor'; - import { parseAuthenticatorData } from './parseAuthenticatorData'; +import { isoBase64URL } from './iso'; // Grabbed this from a Conformance test, contains attestation data -const authDataWithAT = Buffer.from( +const authDataWithAT = isoBase64URL.toBuffer( '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=', @@ -29,13 +29,16 @@ test('should parse flags', () => { test('should parse attestation data', () => { const parsed = parseAuthenticatorData(authDataWithAT); - const { credentialID, credentialPublicKey, aaguid } = parsed; + const { credentialID, credentialPublicKey, aaguid, counter } = parsed; - expect(credentialID?.toString('base64')).toEqual('drsqybluveECHdSPE/37iuq7wESwP7tJnbFZ7X/Ie/o='); - expect(credentialPublicKey?.toString('base64')).toEqual( + expect(isoBase64URL.fromBuffer(credentialID!)).toEqual( + 'drsqybluveECHdSPE_37iuq7wESwP7tJnbFZ7X_Ie_o', + ); + expect(isoBase64URL.fromBuffer(credentialPublicKey!, 'base64')).toEqual( 'pAEDAzkBACBZAQDcxA7Ehs9goWB2Hbl6e9v+aUub9rvy2M7Hkvf+iCzMGE63e3sCEW5Ru33KNy4um46s9jalcBHtZgtEnyeRoQvszis+ws5o4Da0vQfuzlpBmjWT1dV6LuP+vs9wrfObW4jlA5bKEIhv63+jAxOtdXGVzo75PxBlqxrmrr5IR9n8Fw7clwRsDkjgRHaNcQVbwq/qdNwU5H3hZKu9szTwBS5NGRq01EaDF2014YSTFjwtAmZ3PU1tcO/QD2U2zg6eB5grfWDeAJtRE8cbndDWc8aLL0aeC37Q36+TVsGe6AhBgHEw6eO3I3NW5r9v/26CqMPBDwmEundeq1iGyKfMloobIUMBAAE=', ); - expect(aaguid?.toString('base64')).toEqual('yHzdl1bBSbieJMs2NlTzUA=='); + expect(isoBase64URL.fromBuffer(aaguid!, 'base64')).toEqual('yHzdl1bBSbieJMs2NlTzUA=='); + expect(counter).toEqual(37); }); test('should parse extension data', () => { diff --git a/packages/server/src/helpers/parseAuthenticatorData.ts b/packages/server/src/helpers/parseAuthenticatorData.ts index c2128e0..4e3bb0b 100644 --- a/packages/server/src/helpers/parseAuthenticatorData.ts +++ b/packages/server/src/helpers/parseAuthenticatorData.ts @@ -1,14 +1,14 @@ -import cbor from 'cbor'; -import { decodeCborFirst } from './decodeCbor'; import { decodeAuthenticatorExtensions, AuthenticationExtensionsAuthenticatorOutputs, } from './decodeAuthenticatorExtensions'; +import { isoCBOR, isoUint8Array } from './iso'; +import { COSEPublicKey } from './cose'; /** * Make sense of the authData buffer contained in an Attestation */ -export function parseAuthenticatorData(authData: Buffer): ParsedAuthenticatorData { +export function parseAuthenticatorData(authData: Uint8Array): ParsedAuthenticatorData { if (authData.byteLength < 37) { throw new Error( `Authenticator data was ${authData.byteLength} bytes, expected at least 37 bytes`, @@ -16,6 +16,7 @@ export function parseAuthenticatorData(authData: Buffer): ParsedAuthenticatorDat } let pointer = 0; + const dataView = isoUint8Array.toDataView(authData); const rpIdHash = authData.slice(pointer, (pointer += 32)); @@ -34,37 +35,38 @@ export function parseAuthenticatorData(authData: Buffer): ParsedAuthenticatorDat flagsInt, }; - const counterBuf = authData.slice(pointer, (pointer += 4)); - const counter = counterBuf.readUInt32BE(0); + const counterBuf = authData.slice(pointer, pointer + 4); + const counter = dataView.getUint32(pointer, false); + pointer += 4; - let aaguid: Buffer | undefined = undefined; - let credentialID: Buffer | undefined = undefined; - let credentialPublicKey: Buffer | undefined = undefined; + let aaguid: Uint8Array | undefined = undefined; + let credentialID: Uint8Array | undefined = undefined; + let credentialPublicKey: Uint8Array | undefined = undefined; if (flags.at) { aaguid = authData.slice(pointer, (pointer += 16)); - const credIDLenBuf = authData.slice(pointer, (pointer += 2)); - const credIDLen = credIDLenBuf.readUInt16BE(0); + const credIDLen = dataView.getUint16(pointer); + pointer += 2; credentialID = authData.slice(pointer, (pointer += credIDLen)); // Decode the next CBOR item in the buffer, then re-encode it back to a Buffer - const firstDecoded = decodeCborFirst(authData.slice(pointer)); - const firstEncoded = Buffer.from(cbor.encode(firstDecoded) as ArrayBuffer); + const firstDecoded = isoCBOR.decodeFirst<COSEPublicKey>(authData.slice(pointer)); + const firstEncoded = Uint8Array.from(isoCBOR.encode(firstDecoded)); + credentialPublicKey = firstEncoded; pointer += firstEncoded.byteLength; } let extensionsData: AuthenticationExtensionsAuthenticatorOutputs | undefined = undefined; - let extensionsDataBuffer: Buffer | undefined = undefined; + let extensionsDataBuffer: Uint8Array | undefined = undefined; if (flags.ed) { - const firstDecoded = decodeCborFirst(authData.slice(pointer)); - const firstEncoded = Buffer.from(cbor.encode(firstDecoded) as ArrayBuffer); - extensionsDataBuffer = firstEncoded; + const firstDecoded = isoCBOR.decodeFirst(authData.slice(pointer)); + extensionsDataBuffer = Uint8Array.from(isoCBOR.encode(firstDecoded)); extensionsData = decodeAuthenticatorExtensions(extensionsDataBuffer); - pointer += firstEncoded.byteLength; + pointer += extensionsDataBuffer.byteLength; } // Pointer should be at the end of the authenticator data, otherwise too much data was sent @@ -87,8 +89,8 @@ export function parseAuthenticatorData(authData: Buffer): ParsedAuthenticatorDat } export type ParsedAuthenticatorData = { - rpIdHash: Buffer; - flagsBuf: Buffer; + rpIdHash: Uint8Array; + flagsBuf: Uint8Array; flags: { up: boolean; uv: boolean; @@ -99,10 +101,10 @@ export type ParsedAuthenticatorData = { flagsInt: number; }; counter: number; - counterBuf: Buffer; - aaguid?: Buffer; - credentialID?: Buffer; - credentialPublicKey?: Buffer; + counterBuf: Uint8Array; + aaguid?: Uint8Array; + credentialID?: Uint8Array; + credentialPublicKey?: Uint8Array; extensionsData?: AuthenticationExtensionsAuthenticatorOutputs; - extensionsDataBuffer?: Buffer; + extensionsDataBuffer?: Uint8Array; }; diff --git a/packages/server/src/helpers/toHash.test.ts b/packages/server/src/helpers/toHash.test.ts index df0c50d..8893c51 100644 --- a/packages/server/src/helpers/toHash.test.ts +++ b/packages/server/src/helpers/toHash.test.ts @@ -1,11 +1,11 @@ import { toHash } from './toHash'; -test('should return a buffer of at 32 bytes for input string', () => { - const hash = toHash('string'); +test('should return a buffer of at 32 bytes for input string', async () => { + const hash = await toHash('string'); expect(hash.byteLength).toEqual(32); }); -test('should return a buffer of at 32 bytes for input Buffer', () => { - const hash = toHash(Buffer.alloc(10)); +test('should return a buffer of at 32 bytes for input Buffer', async () => { + const hash = await toHash(Buffer.alloc(10)); expect(hash.byteLength).toEqual(32); }); diff --git a/packages/server/src/helpers/toHash.ts b/packages/server/src/helpers/toHash.ts index 007b1ab..90edd4e 100644 --- a/packages/server/src/helpers/toHash.ts +++ b/packages/server/src/helpers/toHash.ts @@ -1,10 +1,19 @@ -import crypto from 'crypto'; +import { COSEALG } from './cose'; +import { isoUint8Array, isoCrypto } from './iso'; /** - * Returns hash digest of the given data using the given algorithm. - * @param data Data to hash - * @return The hash + * Returns hash digest of the given data, using the given algorithm when provided. Defaults to using + * SHA-256. */ -export function toHash(data: Buffer | string, algo = 'SHA256'): Buffer { - return crypto.createHash(algo).update(data).digest(); +export async function toHash( + data: Uint8Array | string, + algorithm: COSEALG = -7, +): Promise<Uint8Array> { + if (typeof data === 'string') { + data = isoUint8Array.fromUTF8String(data); + } + + const digest = isoCrypto.digest(data, algorithm); + + return digest; } diff --git a/packages/server/src/helpers/validateCertificatePath.ts b/packages/server/src/helpers/validateCertificatePath.ts index d98b16b..ed82eac 100644 --- a/packages/server/src/helpers/validateCertificatePath.ts +++ b/packages/server/src/helpers/validateCertificatePath.ts @@ -117,6 +117,7 @@ async function _validatePath(certificates: string[]): Promise<boolean> { const Signature = new crypto.Signature({ alg }); Signature.init(issuerPem); + // TODO: `updateHex()` takes approximately two seconds per execution, can we improve this? Signature.updateHex(subjectCertStruct); if (!Signature.verify(signatureHex)) { diff --git a/packages/server/src/helpers/verifySignature.ts b/packages/server/src/helpers/verifySignature.ts index de8a56e..ff4e73b 100644 --- a/packages/server/src/helpers/verifySignature.ts +++ b/packages/server/src/helpers/verifySignature.ts @@ -1,102 +1,40 @@ -import crypto from 'crypto'; -import cbor from 'cbor'; -import { verify as ed25519Verify } from '@noble/ed25519'; - -import { COSEKEYS, COSEKTY } from './convertCOSEtoPKCS'; -import { convertCertBufferToPEM } from './convertCertBufferToPEM'; -import { convertPublicKeyToPEM } from './convertPublicKeyToPEM'; - -type VerifySignatureOptsLeafCert = { - signature: Buffer; - signatureBase: Buffer; - leafCert: Buffer; - hashAlgorithm?: string; -}; - -type VerifySignatureOptsCredentialPublicKey = { - signature: Buffer; - signatureBase: Buffer; - credentialPublicKey: Buffer; - hashAlgorithm?: string; -}; +import { COSEALG, COSEPublicKey } from './cose'; +import { isoCrypto } from './iso'; +import { decodeCredentialPublicKey } from './decodeCredentialPublicKey'; +import { convertX509PublicKeyToCOSE } from './convertX509PublicKeyToCOSE'; /** * Verify an authenticator's signature - * - * @param signature attStmt.sig - * @param signatureBase Output from Buffer.concat() - * @param publicKey Authenticator's public key as a PEM certificate - * @param algo Which algorithm to use to verify the signature (default: `'sha256'`) */ -export async function verifySignature( - opts: VerifySignatureOptsLeafCert | VerifySignatureOptsCredentialPublicKey, -): Promise<boolean> { - const { signature, signatureBase, hashAlgorithm = 'sha256' } = opts; - const _isLeafcertOpts = isLeafCertOpts(opts); - const _isCredPubKeyOpts = isCredPubKeyOpts(opts); - - if (!_isLeafcertOpts && !_isCredPubKeyOpts) { +export async function verifySignature(opts: { + signature: Uint8Array; + data: Uint8Array; + credentialPublicKey?: Uint8Array; + leafCertificate?: Uint8Array; + attestationHashAlgorithm?: COSEALG; +}): Promise<boolean> { + const { signature, data, credentialPublicKey, leafCertificate, attestationHashAlgorithm } = opts; + + if (!leafCertificate && !credentialPublicKey) { throw new Error('Must declare either "leafCert" or "credentialPublicKey"'); } - if (_isLeafcertOpts && _isCredPubKeyOpts) { + if (leafCertificate && credentialPublicKey) { throw new Error('Must not declare both "leafCert" and "credentialPublicKey"'); } - let publicKeyPEM = ''; - - if (_isCredPubKeyOpts) { - const { credentialPublicKey } = opts; - - // Decode CBOR to COSE - let struct; - try { - struct = cbor.decodeAllSync(credentialPublicKey)[0]; - } catch (err) { - const _err = err as Error; - throw new Error(`Error decoding public key while converting to PEM: ${_err.message}`); - } - - const kty = struct.get(COSEKEYS.kty); - - if (!kty) { - throw new Error('Public key was missing kty'); - } + let cosePublicKey: COSEPublicKey = new Map(); - // Check key type - if (kty === COSEKTY.OKP) { - // Verify Ed25519 slightly differently - const x = struct.get(COSEKEYS.x); - - if (!x) { - throw new Error('Public key was missing x (OKP)'); - } - - return ed25519Verify(signature, signatureBase, x); - } else { - // Convert pubKey to PEM for ECC and RSA - publicKeyPEM = convertPublicKeyToPEM(credentialPublicKey); - } - } - - if (_isLeafcertOpts) { - const { leafCert } = opts; - publicKeyPEM = convertCertBufferToPEM(leafCert); + if (credentialPublicKey) { + cosePublicKey = decodeCredentialPublicKey(credentialPublicKey); + } else if (leafCertificate) { + cosePublicKey = convertX509PublicKeyToCOSE(leafCertificate); } - return crypto.createVerify(hashAlgorithm).update(signatureBase).verify(publicKeyPEM, signature); -} - -function isLeafCertOpts( - opts: VerifySignatureOptsLeafCert | VerifySignatureOptsCredentialPublicKey, -): opts is VerifySignatureOptsLeafCert { - return Object.keys(opts as VerifySignatureOptsLeafCert).indexOf('leafCert') >= 0; -} - -function isCredPubKeyOpts( - opts: VerifySignatureOptsLeafCert | VerifySignatureOptsCredentialPublicKey, -): opts is VerifySignatureOptsCredentialPublicKey { - return ( - Object.keys(opts as VerifySignatureOptsCredentialPublicKey).indexOf('credentialPublicKey') >= 0 - ); + return isoCrypto.verify({ + cosePublicKey, + signature, + data, + shaHashOverride: attestationHashAlgorithm, + }); } diff --git a/packages/server/src/metadata/mdsTypes.ts b/packages/server/src/metadata/mdsTypes.ts index 1bf9f80..d86f587 100644 --- a/packages/server/src/metadata/mdsTypes.ts +++ b/packages/server/src/metadata/mdsTypes.ts @@ -292,5 +292,5 @@ export type AuthenticatorGetInfo = { }; maxMsgSize?: number; pinProtocols?: number[]; - algorithms?: { type: 'public-key', alg: number }[]; + algorithms?: { type: 'public-key'; alg: number }[]; }; diff --git a/packages/server/src/metadata/parseJWT.ts b/packages/server/src/metadata/parseJWT.ts index 254e14e..beb2501 100644 --- a/packages/server/src/metadata/parseJWT.ts +++ b/packages/server/src/metadata/parseJWT.ts @@ -1,4 +1,4 @@ -import base64url from 'base64url'; +import { isoBase64URL } from '../helpers/iso'; /** * Process a JWT into Javascript-friendly data structures @@ -6,8 +6,8 @@ import base64url from 'base64url'; export function parseJWT<T1, T2>(jwt: string): [T1, T2, string] { const parts = jwt.split('.'); return [ - JSON.parse(base64url.decode(parts[0])) as T1, - JSON.parse(base64url.decode(parts[1])) as T2, + JSON.parse(isoBase64URL.toString(parts[0])) as T1, + JSON.parse(isoBase64URL.toString(parts[1])) as T2, parts[2], ]; } diff --git a/packages/server/src/metadata/verifyAttestationWithMetadata.test.ts b/packages/server/src/metadata/verifyAttestationWithMetadata.test.ts index b48ef2e..c76fb1d 100644 --- a/packages/server/src/metadata/verifyAttestationWithMetadata.test.ts +++ b/packages/server/src/metadata/verifyAttestationWithMetadata.test.ts @@ -1,7 +1,6 @@ -import base64url from 'base64url'; - import { verifyAttestationWithMetadata } from './verifyAttestationWithMetadata'; import { MetadataStatement } from '../metadata/mdsTypes'; +import { isoBase64URL } from '../helpers/iso'; test('should verify attestation with metadata (android-safetynet)', async () => { const metadataStatementJSONSafetyNet: MetadataStatement = { @@ -49,7 +48,7 @@ test('should verify attestation with metadata (android-safetynet)', async () => const verified = await verifyAttestationWithMetadata({ statement: metadataStatementJSONSafetyNet, - credentialPublicKey: base64url.toBuffer(credentialPublicKey), + credentialPublicKey: isoBase64URL.toBuffer(credentialPublicKey), x5c, }); @@ -58,48 +57,49 @@ test('should verify attestation with metadata (android-safetynet)', async () => test('should verify attestation with rsa_emsa_pkcs1_sha256_raw authenticator algorithm in metadata', async () => { const metadataStatement: MetadataStatement = { - 'legalHeader': 'https://fidoalliance.org/metadata/metadata-statement-legal-header/', - 'aaguid': '08987058-cadc-4b81-b6e1-30de50dcbe96', - 'description': 'Windows Hello Hardware Authenticator', - 'authenticatorVersion': 1, - 'protocolFamily': 'fido2', - 'schema': 3, - 'upv': [{ 'major': 1, 'minor': 0 }], - 'authenticationAlgorithms': ['rsassa_pkcsv15_sha256_raw'], - 'publicKeyAlgAndEncodings': ['cose'], - 'attestationTypes': ['attca'], - 'userVerificationDetails': [ - [{ 'userVerificationMethod': 'eyeprint_internal' }], - [{ 'userVerificationMethod': 'passcode_internal' }], - [{ 'userVerificationMethod': 'fingerprint_internal' }], - [{ 'userVerificationMethod': 'faceprint_internal' }] + legalHeader: 'https://fidoalliance.org/metadata/metadata-statement-legal-header/', + aaguid: '08987058-cadc-4b81-b6e1-30de50dcbe96', + description: 'Windows Hello Hardware Authenticator', + authenticatorVersion: 1, + protocolFamily: 'fido2', + schema: 3, + upv: [{ major: 1, minor: 0 }], + authenticationAlgorithms: ['rsassa_pkcsv15_sha256_raw'], + publicKeyAlgAndEncodings: ['cose'], + attestationTypes: ['attca'], + userVerificationDetails: [ + [{ userVerificationMethod: 'eyeprint_internal' }], + [{ userVerificationMethod: 'passcode_internal' }], + [{ userVerificationMethod: 'fingerprint_internal' }], + [{ userVerificationMethod: 'faceprint_internal' }], ], - 'keyProtection': ['hardware'], - 'isKeyRestricted': false, - 'matcherProtection': ['software'], - 'attachmentHint': ['internal'], - 'tcDisplay': [], - 'attestationRootCertificates': [ - 'MIIF9TCCA92gAwIBAgIQXbYwTgy/J79JuMhpUB5dyzANBgkqhkiG9w0BAQsFADCBjDELMAkGA1UEBhMCVVMxEzARBgNVBAgTCldhc2hpbmd0b24xEDAOBgNVBAcTB1JlZG1vbmQxHjAcBgNVBAoTFU1pY3Jvc29mdCBDb3Jwb3JhdGlvbjE2MDQGA1UEAxMtTWljcm9zb2Z0IFRQTSBSb290IENlcnRpZmljYXRlIEF1dGhvcml0eSAyMDE0MB4XDTE0MTIxMDIxMzExOVoXDTM5MTIxMDIxMzkyOFowgYwxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpXYXNoaW5ndG9uMRAwDgYDVQQHEwdSZWRtb25kMR4wHAYDVQQKExVNaWNyb3NvZnQgQ29ycG9yYXRpb24xNjA0BgNVBAMTLU1pY3Jvc29mdCBUUE0gUm9vdCBDZXJ0aWZpY2F0ZSBBdXRob3JpdHkgMjAxNDCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAJ+n+bnKt/JHIRC/oI/xgkgsYdPzP0gpvduDA2GbRtth+L4WUyoZKGBw7uz5bjjP8Aql4YExyjR3EZQ4LqnZChMpoCofbeDR4MjCE1TGwWghGpS0mM3GtWD9XiME4rE2K0VW3pdN0CLzkYbvZbs2wQTFfE62yNQiDjyHFWAZ4BQH4eWa8wrDMUxIAneUCpU6zCwM+l6Qh4ohX063BHzXlTSTc1fDsiPaKuMMjWjK9vp5UHFPa+dMAWr6OljQZPFIg3aZ4cUfzS9y+n77Hs1NXPBn6E4Db679z4DThIXyoKeZTv1aaWOWl/exsDLGt2mTMTyykVV8uD1eRjYriFpmoRDwJKAEMOfaURarzp7hka9TOElGyD2gOV4Fscr2MxAYCywLmOLzA4VDSYLuKAhPSp7yawET30AvY1HRfMwBxetSqWP2+yZRNYJlHpor5QTuRDgzR+Zej+aWx6rWNYx43kLthozeVJ3QCsD5iEI/OZlmWn5WYf7O8LB/1A7scrYv44FD8ck3Z+hxXpkklAsjJMsHZa9mBqh+VR1AicX4uZG8m16x65ZU2uUpBa3rn8CTNmw17ZHOiuSWJtS9+PrZVA8ljgf4QgA1g6NPOEiLG2fn8Gm+r5Ak+9tqv72KDd2FPBJ7Xx4stYj/WjNPtEUhW4rcLK3ktLfcy6ea7Rocw5y5AgMBAAGjUTBPMAsGA1UdDwQEAwIBhjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBR6jArOL0hiF+KU0a5VwVLscXSkVjAQBgkrBgEEAYI3FQEEAwIBADANBgkqhkiG9w0BAQsFAAOCAgEAW4ioo1+J9VWC0UntSBXcXRm1ePTVamtsxVy/GpP4EmJd3Ub53JzNBfYdgfUL51CppS3ZY6BoagB+DqoA2GbSL+7sFGHBl5ka6FNelrwsH6VVw4xV/8klIjmqOyfatPYsz0sUdZev+reeiGpKVoXrK6BDnUU27/mgPtem5YKWvHB/soofUrLKzZV3WfGdx9zBr8V0xW6vO3CKaqkqU9y6EsQw34n7eJCbEVVQ8VdFd9iV1pmXwaBAfBwkviPTKEP9Cm+zbFIOLr3V3CL9hJj+gkTUuXWlJJ6wVXEG5i4rIbLAV59UrW4LonP+seqvWMJYUFxu/niF0R3fSGM+NU11DtBVkhRZt1u0kFhZqjDz1dWyfT/N7Hke3WsDqUFsBi+8SEw90rWx2aUkLvKo83oU4Mx4na+2I3l9F2a2VNGk4K7l3a00g51miPiq0Da0jqw30PaLluTMTGY5+RnZVh50JD6nk+Ea3wRkU8aiYFnpIxfKBZ72whmYYa/egj9IKeqpR0vuLebbU0fJBf880K1jWD3Z5SFyJXo057Mv0OPw5mttytE585ZIy5JsaRXlsOoWGRXE3kUT/MKR1UoAgR54c8Bsh+9Dq2wqIK9mRn15zvBDeyHG6+czurLopziOUeWokxZN1syrEdKlhFoPYavm6t+PzIcpdxZwHA+V3jLJPfI=' + keyProtection: ['hardware'], + isKeyRestricted: false, + matcherProtection: ['software'], + attachmentHint: ['internal'], + tcDisplay: [], + attestationRootCertificates: [ + 'MIIF9TCCA92gAwIBAgIQXbYwTgy/J79JuMhpUB5dyzANBgkqhkiG9w0BAQsFADCBjDELMAkGA1UEBhMCVVMxEzARBgNVBAgTCldhc2hpbmd0b24xEDAOBgNVBAcTB1JlZG1vbmQxHjAcBgNVBAoTFU1pY3Jvc29mdCBDb3Jwb3JhdGlvbjE2MDQGA1UEAxMtTWljcm9zb2Z0IFRQTSBSb290IENlcnRpZmljYXRlIEF1dGhvcml0eSAyMDE0MB4XDTE0MTIxMDIxMzExOVoXDTM5MTIxMDIxMzkyOFowgYwxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpXYXNoaW5ndG9uMRAwDgYDVQQHEwdSZWRtb25kMR4wHAYDVQQKExVNaWNyb3NvZnQgQ29ycG9yYXRpb24xNjA0BgNVBAMTLU1pY3Jvc29mdCBUUE0gUm9vdCBDZXJ0aWZpY2F0ZSBBdXRob3JpdHkgMjAxNDCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAJ+n+bnKt/JHIRC/oI/xgkgsYdPzP0gpvduDA2GbRtth+L4WUyoZKGBw7uz5bjjP8Aql4YExyjR3EZQ4LqnZChMpoCofbeDR4MjCE1TGwWghGpS0mM3GtWD9XiME4rE2K0VW3pdN0CLzkYbvZbs2wQTFfE62yNQiDjyHFWAZ4BQH4eWa8wrDMUxIAneUCpU6zCwM+l6Qh4ohX063BHzXlTSTc1fDsiPaKuMMjWjK9vp5UHFPa+dMAWr6OljQZPFIg3aZ4cUfzS9y+n77Hs1NXPBn6E4Db679z4DThIXyoKeZTv1aaWOWl/exsDLGt2mTMTyykVV8uD1eRjYriFpmoRDwJKAEMOfaURarzp7hka9TOElGyD2gOV4Fscr2MxAYCywLmOLzA4VDSYLuKAhPSp7yawET30AvY1HRfMwBxetSqWP2+yZRNYJlHpor5QTuRDgzR+Zej+aWx6rWNYx43kLthozeVJ3QCsD5iEI/OZlmWn5WYf7O8LB/1A7scrYv44FD8ck3Z+hxXpkklAsjJMsHZa9mBqh+VR1AicX4uZG8m16x65ZU2uUpBa3rn8CTNmw17ZHOiuSWJtS9+PrZVA8ljgf4QgA1g6NPOEiLG2fn8Gm+r5Ak+9tqv72KDd2FPBJ7Xx4stYj/WjNPtEUhW4rcLK3ktLfcy6ea7Rocw5y5AgMBAAGjUTBPMAsGA1UdDwQEAwIBhjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBR6jArOL0hiF+KU0a5VwVLscXSkVjAQBgkrBgEEAYI3FQEEAwIBADANBgkqhkiG9w0BAQsFAAOCAgEAW4ioo1+J9VWC0UntSBXcXRm1ePTVamtsxVy/GpP4EmJd3Ub53JzNBfYdgfUL51CppS3ZY6BoagB+DqoA2GbSL+7sFGHBl5ka6FNelrwsH6VVw4xV/8klIjmqOyfatPYsz0sUdZev+reeiGpKVoXrK6BDnUU27/mgPtem5YKWvHB/soofUrLKzZV3WfGdx9zBr8V0xW6vO3CKaqkqU9y6EsQw34n7eJCbEVVQ8VdFd9iV1pmXwaBAfBwkviPTKEP9Cm+zbFIOLr3V3CL9hJj+gkTUuXWlJJ6wVXEG5i4rIbLAV59UrW4LonP+seqvWMJYUFxu/niF0R3fSGM+NU11DtBVkhRZt1u0kFhZqjDz1dWyfT/N7Hke3WsDqUFsBi+8SEw90rWx2aUkLvKo83oU4Mx4na+2I3l9F2a2VNGk4K7l3a00g51miPiq0Da0jqw30PaLluTMTGY5+RnZVh50JD6nk+Ea3wRkU8aiYFnpIxfKBZ72whmYYa/egj9IKeqpR0vuLebbU0fJBf880K1jWD3Z5SFyJXo057Mv0OPw5mttytE585ZIy5JsaRXlsOoWGRXE3kUT/MKR1UoAgR54c8Bsh+9Dq2wqIK9mRn15zvBDeyHG6+czurLopziOUeWokxZN1syrEdKlhFoPYavm6t+PzIcpdxZwHA+V3jLJPfI=', ], - 'icon': 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEgAAABICAYAAABV7bNHAAACkUlEQVR42uyai3GDMAyGQyegGzACnaCMkBHoBhkhnSAj0A2SDaAT0E6QbEA3cOXW6XEpBtnImMv9utOllxjF/qKHLTdRSm0gdnkAAgACIAACIAACIAACIAgAARAAARAAARAAARBEAFCSJINKkpLuSTtSZbQz76W25zhKkpFWPbtaz6Q75vPuoluuPmqxlZK2yi76s9RznjlpN2K7CrFWaUAHNS0HT0Atw3YpDSjxbdoPuaziG3uk579cvIdeWsbQD7L7NAYoWpKmLy8chueO5reB7KKKrQnQJdDYn9AJZHc5QBT7enINY2hjxrqItsvJWSdxFxKuYlOlWJmE6zPPcsJuN7WFiF7me5DOAws4OyZyG6TOsr/KQziDaJm/mcy2V1V0+T0JeXxqqlrWC9mGGy3O6wwFaI0SdR+EMg9AEAACIAByqViZb+/prgFdN6qb306j3lTWs0BJ76Qjw0ktO+3ad60PQhMrfM9YwqK7lUPe4j+/OR40cDaqJeJ+xo80JsWih1WTBAcb8ysKrb+TfowQKy3v55wbBkk49FJbQusqr4snadL9hEtXC3nO1G1HG6UfxIj5oDnJlHPOVVAerWGmvYQxwc70hiTh7Bidy3/3ZFE6isxf8epNhUCl4n5ftYqWKzMP3IIquaFnquXO0sZ1yn/RWq69SuK6GdPXORfSz4HPnk1bNXO0+UZze5HqKIodNYwnHVVcOUivNcStxj4CGFYhWAWgXgmuF4JzdMhn6wDUm1DpmFyVY7IvQqeTRdod2v2F8lNn/gcpW+rUsOi9mAmFwlSo3Pw9JQ3p+8bhgnAMkPM613BxOBQqc2FEB4SmPQSAAAiAAAiAAAiAAAiAIAAEQAAEQAAEQPco3wIMADOXgFhOTghuAAAAAElFTkSuQmCC', - 'authenticatorGetInfo': { - 'versions': ['FIDO_2_0'], - 'aaguid': '08987058cadc4b81b6e130de50dcbe96', - 'options': { 'plat': true, 'rk': true, 'up': true }, + icon: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEgAAABICAYAAABV7bNHAAACkUlEQVR42uyai3GDMAyGQyegGzACnaCMkBHoBhkhnSAj0A2SDaAT0E6QbEA3cOXW6XEpBtnImMv9utOllxjF/qKHLTdRSm0gdnkAAgACIAACIAACIAACIAgAARAAARAAARAAARBEAFCSJINKkpLuSTtSZbQz76W25zhKkpFWPbtaz6Q75vPuoluuPmqxlZK2yi76s9RznjlpN2K7CrFWaUAHNS0HT0Atw3YpDSjxbdoPuaziG3uk579cvIdeWsbQD7L7NAYoWpKmLy8chueO5reB7KKKrQnQJdDYn9AJZHc5QBT7enINY2hjxrqItsvJWSdxFxKuYlOlWJmE6zPPcsJuN7WFiF7me5DOAws4OyZyG6TOsr/KQziDaJm/mcy2V1V0+T0JeXxqqlrWC9mGGy3O6wwFaI0SdR+EMg9AEAACIAByqViZb+/prgFdN6qb306j3lTWs0BJ76Qjw0ktO+3ad60PQhMrfM9YwqK7lUPe4j+/OR40cDaqJeJ+xo80JsWih1WTBAcb8ysKrb+TfowQKy3v55wbBkk49FJbQusqr4snadL9hEtXC3nO1G1HG6UfxIj5oDnJlHPOVVAerWGmvYQxwc70hiTh7Bidy3/3ZFE6isxf8epNhUCl4n5ftYqWKzMP3IIquaFnquXO0sZ1yn/RWq69SuK6GdPXORfSz4HPnk1bNXO0+UZze5HqKIodNYwnHVVcOUivNcStxj4CGFYhWAWgXgmuF4JzdMhn6wDUm1DpmFyVY7IvQqeTRdod2v2F8lNn/gcpW+rUsOi9mAmFwlSo3Pw9JQ3p+8bhgnAMkPM613BxOBQqc2FEB4SmPQSAAAiAAAiAAAiAAAiAIAAEQAAEQAAEQPco3wIMADOXgFhOTghuAAAAAElFTkSuQmCC', + authenticatorGetInfo: { + versions: ['FIDO_2_0'], + aaguid: '08987058cadc4b81b6e130de50dcbe96', + options: { plat: true, rk: true, up: true }, }, }; // Extracted from an actual TPM|ECC response const x5c = [ 'MIIFuTCCA6GgAwIBAgIQAM86nt2LQk-si1Q75opOtjANBgkqhkiG9w0BAQsFADBCMUAwPgYDVQQDEzdOQ1UtSU5UQy1LRVlJRC0xN0EwMDU3NUQwNUU1OEUzODgxMjEwQkI5OEIxMDQ1QkI0QzMwNjM5MB4XDTIxMTIwMTA3MTMwOFoXDTI3MDYwMzE3NTExOFowADCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAN42zmd-TJwY8b8KKakCP_Jmq46s9qIcae5EObWRtWqw-qXBM9fH15vJ3UrE1mHv9mjCsV384_TJP7snP7MHy93jQOZNvR-T8JGNXR1Zhzg1MOjsZlv69w-shGZBF3lWXKKrdyS4q5KP8WbC6A30LVM_Ic0uAxkOeS-z4CdwWC4au2i8TkCTsUSenc98SFEksNOQONdNLA5qQInYCWppdT2lzEi-BbTV2GyropPgL3PCHGKVNt73XWzWZD_e9zuPNrOG9gfhh1hJaQS82TIul59Qp4C6AbIzH5uvhSh3_mhK2YU7Je6-FE_cvFLiTLt4vVimxd5uNGO4Oth_nfUm_sECAwEAAaOCAeswggHnMA4GA1UdDwEB_wQEAwIHgDAMBgNVHRMBAf8EAjAAMG0GA1UdIAEB_wRjMGEwXwYJKwYBBAGCNxUfMFIwUAYIKwYBBQUHAgIwRB5CAFQAQwBQAEEAIAAgAFQAcgB1AHMAdABlAGQAIAAgAFAAbABhAHQAZgBvAHIAbQAgACAASQBkAGUAbgB0AGkAdAB5MBAGA1UdJQQJMAcGBWeBBQgDMFAGA1UdEQEB_wRGMESkQjBAMRYwFAYFZ4EFAgEMC2lkOjQ5NEU1NDQzMQ4wDAYFZ4EFAgIMA0NOTDEWMBQGBWeBBQIDDAtpZDowMDAyMDAwMDAfBgNVHSMEGDAWgBTg0USwFsuPP50VHiH8i_DHd-1qLjAdBgNVHQ4EFgQU99bEZ0-Oi7GG2f-i68p7Xf1-diQwgbMGCCsGAQUFBwEBBIGmMIGjMIGgBggrBgEFBQcwAoaBk2h0dHA6Ly9hemNzcHJvZG5jdWFpa3B1Ymxpc2guYmxvYi5jb3JlLndpbmRvd3MubmV0L25jdS1pbnRjLWtleWlkLTE3YTAwNTc1ZDA1ZTU4ZTM4ODEyMTBiYjk4YjEwNDViYjRjMzA2MzkvYTdjNjk5MjUtZjM4Yi00ZmQwLWExZWMtMmYzMjI1MjA1YmM4LmNlcjANBgkqhkiG9w0BAQsFAAOCAgEAMwXq91wHH27AiR6rrWH3L7xEJ6o-wnoP808WisQcQ5gCUh4o0E3eeICh1IjPpr-n5CCMwU8GSzX5vQGF3VKa8FoEBNrhT4IuD-3qNv939NW1k4VPVQGTwgXy8YHiAlGnLmAIiqmEAgsn9fKLzBDhT448CJWyWzmtA5TflBX_jeL5V94hTvOMDtdtPQOpdGKlpYyArz3_sU8_XyOZad3DAbQbKOiFfzJoyr4CUDjZy1wHcO5ouwW33syPyrQwlqgnS8whBYXPK2M9Y-qT2--VutBAZIWI2wdiqMhY-RTm9OIbURZWmqVZ2DPn7dEGMow9TgdNYHL9m3CYsvRQejWyBffU0l8aLRzt330FqjHIK1x8kvk25V-mF10bTIejS6F516k3iZ2FbH5UeiZVE9ofVgN_lJ8KwyeOUjyG66VuH6dmnRfn4gg_2Uyj9TrDF0dJpoCKTspShuIaPD2-H-pkDQlDkldXo-bHlrGXJJGRBbhutxbBxozRsvkYhgoR4TbSzyDcFzFnDJd1ib_Z9C9q5KwaUiREX0b1rLCd1BZ-JXYGiQTrfnMZDvbHSXuZ-HXhcF9t5TZ8f4xDZX4gfsyj75uGJ34e4ThWxnNvdY7HkhFSXJzmvT6dIlIW1UorbYYm-UtbW4e8GwEVXquG0bpmWIXmL2k9D_WCSkyzkR7tPvw', - 'MIIG7DCCBNSgAwIBAgITMwAAA-Y6aLPA71ZHOwAAAAAD5jANBgkqhkiG9w0BAQsFADCBjDELMAkGA1UEBhMCVVMxEzARBgNVBAgTCldhc2hpbmd0b24xEDAOBgNVBAcTB1JlZG1vbmQxHjAcBgNVBAoTFU1pY3Jvc29mdCBDb3Jwb3JhdGlvbjE2MDQGA1UEAxMtTWljcm9zb2Z0IFRQTSBSb290IENlcnRpZmljYXRlIEF1dGhvcml0eSAyMDE0MB4XDTIxMDYwMzE3NTExOFoXDTI3MDYwMzE3NTExOFowQjFAMD4GA1UEAxM3TkNVLUlOVEMtS0VZSUQtMTdBMDA1NzVEMDVFNThFMzg4MTIxMEJCOThCMTA0NUJCNEMzMDYzOTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAO26HxYkAnL4SBpcIIDBFYw95P18eBVzl0owJPKtEwqtJhRArv6DQMDGKPw9HGy3Vtmh5VvrGgKh6LPyTbqN2xITi-wgPUIv7Mn-WtvzPO70dnhdRvw1vDY8LeulOh2l9zU2o2jII0HzLTl_LJqKmrN3yZpq1NneSQ49l3sbXvsW0eKSj2iCtgvOk2FhY-Os3cyexx6phX5I26BmoP-Y-W5kYqtNw2o8rxol_I0v51PVzNcLBwseGOpHNYtRF0m0QdoudCKZKk0hWzKPA4BE35wSSWGjgUbp91Pjzva33tYmOlk0UOLoIT2rZ2Y5feG3QpBuacD1ImDEUQ01-kJ1S2bATRR3BoaJtRbOCRoz41MS-2XfbXhcnzZxbT5TY7dlbX4oKYZn2Wqw-TYmfBiPYBX-Mo6wObruVOs6Lk04XzznXvx5lLKLNdvDBJxG3dZIzgepo9fLrp7hTiKw0T1EdYn6-MjUO7utoq7RmKA_AzFI1VLTfVJxPn_RahYPJmt8a8F2X7WlYPg5vayPDyWtmXtuuoxoAclNp3ViC9ko5LVr7M78C2RA1T94yk2eAEm_ueCuqn8mrmqQjFo3fMAfvRB2nL66tQhBZwmWyRIjuycRCJRdtSrwjSXRywA_VHLajhVutGzPrizmFcygT3ozL1NB6s5Ill5o4DpQsE9qNIOHAgMBAAGjggGOMIIBijAOBgNVHQ8BAf8EBAMCAoQwGwYDVR0lBBQwEgYJKwYBBAGCNxUkBgVngQUIAzAWBgNVHSAEDzANMAsGCSsGAQQBgjcVHzASBgNVHRMBAf8ECDAGAQH_AgEAMB0GA1UdDgQWBBTg0USwFsuPP50VHiH8i_DHd-1qLjAfBgNVHSMEGDAWgBR6jArOL0hiF-KU0a5VwVLscXSkVjBwBgNVHR8EaTBnMGWgY6Bhhl9odHRwOi8vd3d3Lm1pY3Jvc29mdC5jb20vcGtpb3BzL2NybC9NaWNyb3NvZnQlMjBUUE0lMjBSb290JTIwQ2VydGlmaWNhdGUlMjBBdXRob3JpdHklMjAyMDE0LmNybDB9BggrBgEFBQcBAQRxMG8wbQYIKwYBBQUHMAKGYWh0dHA6Ly93d3cubWljcm9zb2Z0LmNvbS9wa2lvcHMvY2VydHMvTWljcm9zb2Z0JTIwVFBNJTIwUm9vdCUyMENlcnRpZmljYXRlJTIwQXV0aG9yaXR5JTIwMjAxNC5jcnQwDQYJKoZIhvcNAQELBQADggIBAGW4yKQ4HaO4JdNMVjVO4mCM0lbLMmXQ0YJtyDHCIE6hsywTYv30DeUDm7Nmmhap9nWp26mSihb7qKQuyhdZkfhA10sp4wDbNpcXjQjdEaE2T1rcgKfounCPQRSW1V42DUgX_Bzuh0flbLYGOJIvugR46gBMUuKVQWmMQKyOMwmofFI8xG_z3VaLMcsgQ8Fl0cvJ6XZ2Jly-QRbZ2v44KNItTTuQKYJCL4kx2b50I4CkrRBaq2LAB-npikLN6xxHqsPvulA0t2WRfF9QzzDZhkVVZ5iCP1fAu5dnHvq0ArBlY2W29OIH_zviW2av88wxZ7FSQzIHu6B8GL45s6skvPa7E9lU6hG186LjrJtHJd0Qad3KYzZQyLKT78m1YiZXLFM02vsctM7nXqtndDjbDPVCota3mg8Jgi2s7-Aq59TL9ZBnRMEvJ5m1Rze1ofFwfO21ktBtLB8vXhzkHjtXy5ld0UQXmdbcs32uaqx6Q3_jVzXlXNNjuG6YBW9iBNL2ar3MtFt66LogL1gmOkyrjGK2Cdyzy1lEupr_SKtggthTyubemmf9G6hJtUZuT_gdFxVZm-MOvCtdNsqdi4HaU8VTCPB999upaEc5vv5KeEQ2xQk0wNmffMlGXGHJrQw8WBwCKkm3TW8hjnhZ9e6ePQvdMEzPhefsxjiQirzpf6lB' + 'MIIG7DCCBNSgAwIBAgITMwAAA-Y6aLPA71ZHOwAAAAAD5jANBgkqhkiG9w0BAQsFADCBjDELMAkGA1UEBhMCVVMxEzARBgNVBAgTCldhc2hpbmd0b24xEDAOBgNVBAcTB1JlZG1vbmQxHjAcBgNVBAoTFU1pY3Jvc29mdCBDb3Jwb3JhdGlvbjE2MDQGA1UEAxMtTWljcm9zb2Z0IFRQTSBSb290IENlcnRpZmljYXRlIEF1dGhvcml0eSAyMDE0MB4XDTIxMDYwMzE3NTExOFoXDTI3MDYwMzE3NTExOFowQjFAMD4GA1UEAxM3TkNVLUlOVEMtS0VZSUQtMTdBMDA1NzVEMDVFNThFMzg4MTIxMEJCOThCMTA0NUJCNEMzMDYzOTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAO26HxYkAnL4SBpcIIDBFYw95P18eBVzl0owJPKtEwqtJhRArv6DQMDGKPw9HGy3Vtmh5VvrGgKh6LPyTbqN2xITi-wgPUIv7Mn-WtvzPO70dnhdRvw1vDY8LeulOh2l9zU2o2jII0HzLTl_LJqKmrN3yZpq1NneSQ49l3sbXvsW0eKSj2iCtgvOk2FhY-Os3cyexx6phX5I26BmoP-Y-W5kYqtNw2o8rxol_I0v51PVzNcLBwseGOpHNYtRF0m0QdoudCKZKk0hWzKPA4BE35wSSWGjgUbp91Pjzva33tYmOlk0UOLoIT2rZ2Y5feG3QpBuacD1ImDEUQ01-kJ1S2bATRR3BoaJtRbOCRoz41MS-2XfbXhcnzZxbT5TY7dlbX4oKYZn2Wqw-TYmfBiPYBX-Mo6wObruVOs6Lk04XzznXvx5lLKLNdvDBJxG3dZIzgepo9fLrp7hTiKw0T1EdYn6-MjUO7utoq7RmKA_AzFI1VLTfVJxPn_RahYPJmt8a8F2X7WlYPg5vayPDyWtmXtuuoxoAclNp3ViC9ko5LVr7M78C2RA1T94yk2eAEm_ueCuqn8mrmqQjFo3fMAfvRB2nL66tQhBZwmWyRIjuycRCJRdtSrwjSXRywA_VHLajhVutGzPrizmFcygT3ozL1NB6s5Ill5o4DpQsE9qNIOHAgMBAAGjggGOMIIBijAOBgNVHQ8BAf8EBAMCAoQwGwYDVR0lBBQwEgYJKwYBBAGCNxUkBgVngQUIAzAWBgNVHSAEDzANMAsGCSsGAQQBgjcVHzASBgNVHRMBAf8ECDAGAQH_AgEAMB0GA1UdDgQWBBTg0USwFsuPP50VHiH8i_DHd-1qLjAfBgNVHSMEGDAWgBR6jArOL0hiF-KU0a5VwVLscXSkVjBwBgNVHR8EaTBnMGWgY6Bhhl9odHRwOi8vd3d3Lm1pY3Jvc29mdC5jb20vcGtpb3BzL2NybC9NaWNyb3NvZnQlMjBUUE0lMjBSb290JTIwQ2VydGlmaWNhdGUlMjBBdXRob3JpdHklMjAyMDE0LmNybDB9BggrBgEFBQcBAQRxMG8wbQYIKwYBBQUHMAKGYWh0dHA6Ly93d3cubWljcm9zb2Z0LmNvbS9wa2lvcHMvY2VydHMvTWljcm9zb2Z0JTIwVFBNJTIwUm9vdCUyMENlcnRpZmljYXRlJTIwQXV0aG9yaXR5JTIwMjAxNC5jcnQwDQYJKoZIhvcNAQELBQADggIBAGW4yKQ4HaO4JdNMVjVO4mCM0lbLMmXQ0YJtyDHCIE6hsywTYv30DeUDm7Nmmhap9nWp26mSihb7qKQuyhdZkfhA10sp4wDbNpcXjQjdEaE2T1rcgKfounCPQRSW1V42DUgX_Bzuh0flbLYGOJIvugR46gBMUuKVQWmMQKyOMwmofFI8xG_z3VaLMcsgQ8Fl0cvJ6XZ2Jly-QRbZ2v44KNItTTuQKYJCL4kx2b50I4CkrRBaq2LAB-npikLN6xxHqsPvulA0t2WRfF9QzzDZhkVVZ5iCP1fAu5dnHvq0ArBlY2W29OIH_zviW2av88wxZ7FSQzIHu6B8GL45s6skvPa7E9lU6hG186LjrJtHJd0Qad3KYzZQyLKT78m1YiZXLFM02vsctM7nXqtndDjbDPVCota3mg8Jgi2s7-Aq59TL9ZBnRMEvJ5m1Rze1ofFwfO21ktBtLB8vXhzkHjtXy5ld0UQXmdbcs32uaqx6Q3_jVzXlXNNjuG6YBW9iBNL2ar3MtFt66LogL1gmOkyrjGK2Cdyzy1lEupr_SKtggthTyubemmf9G6hJtUZuT_gdFxVZm-MOvCtdNsqdi4HaU8VTCPB999upaEc5vv5KeEQ2xQk0wNmffMlGXGHJrQw8WBwCKkm3TW8hjnhZ9e6ePQvdMEzPhefsxjiQirzpf6lB', ]; - const credentialPublicKey = 'pAEDAzkBACBZAQC3X5SKwYUkxFxxyvCnz_37Z57eSdsgQuiBLDaBOd1R6VEZReAV3nVr_7jiRgmWfu1C-S3Aro65eSG5shcDCgIvY3KdEI8K5ENEPlmucjnFILBAE_MZtPmZlkEDmVCDcVspHX2iKqiVWYV6IFzVX1QUf0SAlWijV9NEfKDbij34ddV0qfG2nEMA0_xVpN2OK2BVXonFg6tS3T00XlFh4MdzIauIHTDT63eAdHlkFrMqU53T5IqDvL3VurBmBjYRJ3VDT9mA2sm7fSrJNXhSVLPst-ZsiOioVKrpzFE9sJmyCQvq2nGZ2RhDo8FfAKiw0kvJRkCSSe1ddxryk9_VSCprIUMBAAE'; + const credentialPublicKey = + 'pAEDAzkBACBZAQC3X5SKwYUkxFxxyvCnz_37Z57eSdsgQuiBLDaBOd1R6VEZReAV3nVr_7jiRgmWfu1C-S3Aro65eSG5shcDCgIvY3KdEI8K5ENEPlmucjnFILBAE_MZtPmZlkEDmVCDcVspHX2iKqiVWYV6IFzVX1QUf0SAlWijV9NEfKDbij34ddV0qfG2nEMA0_xVpN2OK2BVXonFg6tS3T00XlFh4MdzIauIHTDT63eAdHlkFrMqU53T5IqDvL3VurBmBjYRJ3VDT9mA2sm7fSrJNXhSVLPst-ZsiOioVKrpzFE9sJmyCQvq2nGZ2RhDo8FfAKiw0kvJRkCSSe1ddxryk9_VSCprIUMBAAE'; const verified = await verifyAttestationWithMetadata({ statement: metadataStatement, - credentialPublicKey: base64url.toBuffer(credentialPublicKey), + credentialPublicKey: isoBase64URL.toBuffer(credentialPublicKey), x5c, }); @@ -108,55 +108,60 @@ test('should verify attestation with rsa_emsa_pkcs1_sha256_raw authenticator alg test('should not validate certificate path when authenticator is self-referencing its attestation statement certificates', async () => { const metadataStatement: MetadataStatement = { - "legalHeader": "https://fidoalliance.org/metadata/metadata-statement-legal-header/", - "description": "Virtual Secp256R1 FIDO2 Conformance Testing CTAP2 Authenticator with Self Batch Referencing", - "aaguid": "5b65dac1-7af4-46e6-8a4f-8701fcc4f3b4", - "alternativeDescriptions": { - "ru-RU": "Виртуальный Secp256R1 CTAP2 аутентификатор для тестирование серверов на соответсвие спецификации FIDO2 с одинаковыми сертификатами" + legalHeader: 'https://fidoalliance.org/metadata/metadata-statement-legal-header/', + description: + 'Virtual Secp256R1 FIDO2 Conformance Testing CTAP2 Authenticator with Self Batch Referencing', + aaguid: '5b65dac1-7af4-46e6-8a4f-8701fcc4f3b4', + alternativeDescriptions: { + 'ru-RU': + 'Виртуальный Secp256R1 CTAP2 аутентификатор для тестирование серверов на соответсвие спецификации FIDO2 с одинаковыми сертификатами', }, - "protocolFamily": "fido2", - "authenticatorVersion": 2, - "upv": [{ "major": 1, "minor": 0 }], - "authenticationAlgorithms": ["secp256r1_ecdsa_sha256_raw"], - "publicKeyAlgAndEncodings": ["cose"], - "attestationTypes": ["basic_full"], - "schema": 3, - "userVerificationDetails": [ - [{ "userVerificationMethod": "none" }], - [{ "userVerificationMethod": "presence_internal" }], - [{ "userVerificationMethod": "passcode_external", "caDesc": { "base": 10, "minLength": 4 } }], + protocolFamily: 'fido2', + authenticatorVersion: 2, + upv: [{ major: 1, minor: 0 }], + authenticationAlgorithms: ['secp256r1_ecdsa_sha256_raw'], + publicKeyAlgAndEncodings: ['cose'], + attestationTypes: ['basic_full'], + schema: 3, + userVerificationDetails: [ + [{ userVerificationMethod: 'none' }], + [{ userVerificationMethod: 'presence_internal' }], + [{ userVerificationMethod: 'passcode_external', caDesc: { base: 10, minLength: 4 } }], [ - { "userVerificationMethod": "passcode_external", "caDesc": { "base": 10, "minLength": 4 } }, - { "userVerificationMethod": "presence_internal" } - ] + { userVerificationMethod: 'passcode_external', caDesc: { base: 10, minLength: 4 } }, + { userVerificationMethod: 'presence_internal' }, + ], ], - "keyProtection": ["hardware", "secure_element"], - "matcherProtection": ["on_chip"], - "cryptoStrength": 128, - "attachmentHint": ["external", "wired", "wireless", "nfc"], - "tcDisplay": [], - "attestationRootCertificates": [ - "MIIEQTCCAimgAwIBAgIBATANBgkqhkiG9w0BAQsFADCBoTEYMBYGA1UEAwwPRklETzIgVEVTVCBST09UMTEwLwYJKoZIhvcNAQkBFiJjb25mb3JtYW5jZS10b29sc0BmaWRvYWxsaWFuY2Uub3JnMRYwFAYDVQQKDA1GSURPIEFsbGlhbmNlMQwwCgYDVQQLDANDV0cxCzAJBgNVBAYTAlVTMQswCQYDVQQIDAJNWTESMBAGA1UEBwwJV2FrZWZpZWxkMB4XDTE4MDUyMzE0Mzk0M1oXDTI4MDUyMDE0Mzk0M1owgcIxIzAhBgNVBAMMGkZJRE8yIEJBVENIIEtFWSBwcmltZTI1NnYxMTEwLwYJKoZIhvcNAQkBFiJjb25mb3JtYW5jZS10b29sc0BmaWRvYWxsaWFuY2Uub3JnMRYwFAYDVQQKDA1GSURPIEFsbGlhbmNlMSIwIAYDVQQLDBlBdXRoZW50aWNhdG9yIEF0dGVzdGF0aW9uMQswCQYDVQQGEwJVUzELMAkGA1UECAwCTVkxEjAQBgNVBAcMCVdha2VmaWVsZDBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABE86Xl6rbB+8rpf232RJlnYse+9yAEAqdsbyMPZVbxeqmZtZf8S/UIqvjp7wzQE/Wrm9J5FL8IBDeMvMsRuJtUajLDAqMAkGA1UdEwQCMAAwHQYDVR0OBBYEFFZN98D4xlW2oR9sTRnzv0Hi/QF5MA0GCSqGSIb3DQEBCwUAA4ICAQCH3aCf+CCJBdEtQc4JpOnUelwGGw7DxnBMokHHBgrzJxDn9BFcFwxGLxrFV7EfYehQNOD+74OS8fZRgZiNf9EDGAYiHh0+CspfBWd20zCIjlCdDBcyhwq3PLJ65JC/og3CT9AK4kvks4DI+01RYxNv9S8Jx1haO1lgU55hBIr1P/p21ZKnpcCEhPjB/cIFrHJqL5iJGfed+LXni9Suq24OHnp44Mrv4h7OD2elu5yWfdfFb+RGG2TYURFIGYGijsii093w0ZMBOfBS+3Xq/DrHeZbZrrNkY455gJCZ5eV83Nrt9J9/UF0VZHl/hwnSAUC/b3tN/l0ZlC9kPcNzJD04l4ndFBD2KdfQ2HGTX7pybWLZ7yH2BM3ui2OpiacaOzd7OE91rHYB2uZyQ7jdg25yF9M8QI9NHM/itCjdBvAYt4QCT8dX6gmZiIGR2F/YXZAsybtJ16pnUmODVbW80lPbzy+PUQYX79opeD9u6MBorzr9g08Elpb1F3DgSd8VSLlsR2QPllKl4AcJDMIOfZHOQGOzatMV7ipEVRa0L5FnjAWpHHvSNcsjD4Cul562mO3MlI2pCyo+US+nIzG5XZmOeu4Db/Kw/dEPOo2ztHwlU0qKJ7REBsbt63jdQtlwLuiLHwkpiwnrAOZfwbLLu9Yz4tL1eJlQffuwS/Aolsz7HA==" + keyProtection: ['hardware', 'secure_element'], + matcherProtection: ['on_chip'], + cryptoStrength: 128, + attachmentHint: ['external', 'wired', 'wireless', 'nfc'], + tcDisplay: [], + attestationRootCertificates: [ + 'MIIEQTCCAimgAwIBAgIBATANBgkqhkiG9w0BAQsFADCBoTEYMBYGA1UEAwwPRklETzIgVEVTVCBST09UMTEwLwYJKoZIhvcNAQkBFiJjb25mb3JtYW5jZS10b29sc0BmaWRvYWxsaWFuY2Uub3JnMRYwFAYDVQQKDA1GSURPIEFsbGlhbmNlMQwwCgYDVQQLDANDV0cxCzAJBgNVBAYTAlVTMQswCQYDVQQIDAJNWTESMBAGA1UEBwwJV2FrZWZpZWxkMB4XDTE4MDUyMzE0Mzk0M1oXDTI4MDUyMDE0Mzk0M1owgcIxIzAhBgNVBAMMGkZJRE8yIEJBVENIIEtFWSBwcmltZTI1NnYxMTEwLwYJKoZIhvcNAQkBFiJjb25mb3JtYW5jZS10b29sc0BmaWRvYWxsaWFuY2Uub3JnMRYwFAYDVQQKDA1GSURPIEFsbGlhbmNlMSIwIAYDVQQLDBlBdXRoZW50aWNhdG9yIEF0dGVzdGF0aW9uMQswCQYDVQQGEwJVUzELMAkGA1UECAwCTVkxEjAQBgNVBAcMCVdha2VmaWVsZDBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABE86Xl6rbB+8rpf232RJlnYse+9yAEAqdsbyMPZVbxeqmZtZf8S/UIqvjp7wzQE/Wrm9J5FL8IBDeMvMsRuJtUajLDAqMAkGA1UdEwQCMAAwHQYDVR0OBBYEFFZN98D4xlW2oR9sTRnzv0Hi/QF5MA0GCSqGSIb3DQEBCwUAA4ICAQCH3aCf+CCJBdEtQc4JpOnUelwGGw7DxnBMokHHBgrzJxDn9BFcFwxGLxrFV7EfYehQNOD+74OS8fZRgZiNf9EDGAYiHh0+CspfBWd20zCIjlCdDBcyhwq3PLJ65JC/og3CT9AK4kvks4DI+01RYxNv9S8Jx1haO1lgU55hBIr1P/p21ZKnpcCEhPjB/cIFrHJqL5iJGfed+LXni9Suq24OHnp44Mrv4h7OD2elu5yWfdfFb+RGG2TYURFIGYGijsii093w0ZMBOfBS+3Xq/DrHeZbZrrNkY455gJCZ5eV83Nrt9J9/UF0VZHl/hwnSAUC/b3tN/l0ZlC9kPcNzJD04l4ndFBD2KdfQ2HGTX7pybWLZ7yH2BM3ui2OpiacaOzd7OE91rHYB2uZyQ7jdg25yF9M8QI9NHM/itCjdBvAYt4QCT8dX6gmZiIGR2F/YXZAsybtJ16pnUmODVbW80lPbzy+PUQYX79opeD9u6MBorzr9g08Elpb1F3DgSd8VSLlsR2QPllKl4AcJDMIOfZHOQGOzatMV7ipEVRa0L5FnjAWpHHvSNcsjD4Cul562mO3MlI2pCyo+US+nIzG5XZmOeu4Db/Kw/dEPOo2ztHwlU0qKJ7REBsbt63jdQtlwLuiLHwkpiwnrAOZfwbLLu9Yz4tL1eJlQffuwS/Aolsz7HA==', ], - "supportedExtensions": [{ "id": "hmac-secret", "fail_if_unknown": false }, { "id": "credProtect", "fail_if_unknown": false } + supportedExtensions: [ + { id: 'hmac-secret', fail_if_unknown: false }, + { id: 'credProtect', fail_if_unknown: false }, ], - "authenticatorGetInfo": { - "versions": ["U2F_V2", "FIDO_2_0"], - "extensions": ["credProtect", "hmac-secret"], - "aaguid": "5b65dac17af446e68a4f8701fcc4f3b4", - "options": { "plat": false, "rk": true, "clientPin": true, "up": true, "uv": true }, - "maxMsgSize": 1200, - } + authenticatorGetInfo: { + versions: ['U2F_V2', 'FIDO_2_0'], + extensions: ['credProtect', 'hmac-secret'], + aaguid: '5b65dac17af446e68a4f8701fcc4f3b4', + options: { plat: false, rk: true, clientPin: true, up: true, uv: true }, + maxMsgSize: 1200, + }, }; const x5c = [ - 'MIIEQTCCAimgAwIBAgIBATANBgkqhkiG9w0BAQsFADCBoTEYMBYGA1UEAwwPRklETzIgVEVTVCBST09UMTEwLwYJKoZIhvcNAQkBFiJjb25mb3JtYW5jZS10b29sc0BmaWRvYWxsaWFuY2Uub3JnMRYwFAYDVQQKDA1GSURPIEFsbGlhbmNlMQwwCgYDVQQLDANDV0cxCzAJBgNVBAYTAlVTMQswCQYDVQQIDAJNWTESMBAGA1UEBwwJV2FrZWZpZWxkMB4XDTE4MDUyMzE0Mzk0M1oXDTI4MDUyMDE0Mzk0M1owgcIxIzAhBgNVBAMMGkZJRE8yIEJBVENIIEtFWSBwcmltZTI1NnYxMTEwLwYJKoZIhvcNAQkBFiJjb25mb3JtYW5jZS10b29sc0BmaWRvYWxsaWFuY2Uub3JnMRYwFAYDVQQKDA1GSURPIEFsbGlhbmNlMSIwIAYDVQQLDBlBdXRoZW50aWNhdG9yIEF0dGVzdGF0aW9uMQswCQYDVQQGEwJVUzELMAkGA1UECAwCTVkxEjAQBgNVBAcMCVdha2VmaWVsZDBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABE86Xl6rbB-8rpf232RJlnYse-9yAEAqdsbyMPZVbxeqmZtZf8S_UIqvjp7wzQE_Wrm9J5FL8IBDeMvMsRuJtUajLDAqMAkGA1UdEwQCMAAwHQYDVR0OBBYEFFZN98D4xlW2oR9sTRnzv0Hi_QF5MA0GCSqGSIb3DQEBCwUAA4ICAQCH3aCf-CCJBdEtQc4JpOnUelwGGw7DxnBMokHHBgrzJxDn9BFcFwxGLxrFV7EfYehQNOD-74OS8fZRgZiNf9EDGAYiHh0-CspfBWd20zCIjlCdDBcyhwq3PLJ65JC_og3CT9AK4kvks4DI-01RYxNv9S8Jx1haO1lgU55hBIr1P_p21ZKnpcCEhPjB_cIFrHJqL5iJGfed-LXni9Suq24OHnp44Mrv4h7OD2elu5yWfdfFb-RGG2TYURFIGYGijsii093w0ZMBOfBS-3Xq_DrHeZbZrrNkY455gJCZ5eV83Nrt9J9_UF0VZHl_hwnSAUC_b3tN_l0ZlC9kPcNzJD04l4ndFBD2KdfQ2HGTX7pybWLZ7yH2BM3ui2OpiacaOzd7OE91rHYB2uZyQ7jdg25yF9M8QI9NHM_itCjdBvAYt4QCT8dX6gmZiIGR2F_YXZAsybtJ16pnUmODVbW80lPbzy-PUQYX79opeD9u6MBorzr9g08Elpb1F3DgSd8VSLlsR2QPllKl4AcJDMIOfZHOQGOzatMV7ipEVRa0L5FnjAWpHHvSNcsjD4Cul562mO3MlI2pCyo-US-nIzG5XZmOeu4Db_Kw_dEPOo2ztHwlU0qKJ7REBsbt63jdQtlwLuiLHwkpiwnrAOZfwbLLu9Yz4tL1eJlQffuwS_Aolsz7HA' + 'MIIEQTCCAimgAwIBAgIBATANBgkqhkiG9w0BAQsFADCBoTEYMBYGA1UEAwwPRklETzIgVEVTVCBST09UMTEwLwYJKoZIhvcNAQkBFiJjb25mb3JtYW5jZS10b29sc0BmaWRvYWxsaWFuY2Uub3JnMRYwFAYDVQQKDA1GSURPIEFsbGlhbmNlMQwwCgYDVQQLDANDV0cxCzAJBgNVBAYTAlVTMQswCQYDVQQIDAJNWTESMBAGA1UEBwwJV2FrZWZpZWxkMB4XDTE4MDUyMzE0Mzk0M1oXDTI4MDUyMDE0Mzk0M1owgcIxIzAhBgNVBAMMGkZJRE8yIEJBVENIIEtFWSBwcmltZTI1NnYxMTEwLwYJKoZIhvcNAQkBFiJjb25mb3JtYW5jZS10b29sc0BmaWRvYWxsaWFuY2Uub3JnMRYwFAYDVQQKDA1GSURPIEFsbGlhbmNlMSIwIAYDVQQLDBlBdXRoZW50aWNhdG9yIEF0dGVzdGF0aW9uMQswCQYDVQQGEwJVUzELMAkGA1UECAwCTVkxEjAQBgNVBAcMCVdha2VmaWVsZDBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABE86Xl6rbB-8rpf232RJlnYse-9yAEAqdsbyMPZVbxeqmZtZf8S_UIqvjp7wzQE_Wrm9J5FL8IBDeMvMsRuJtUajLDAqMAkGA1UdEwQCMAAwHQYDVR0OBBYEFFZN98D4xlW2oR9sTRnzv0Hi_QF5MA0GCSqGSIb3DQEBCwUAA4ICAQCH3aCf-CCJBdEtQc4JpOnUelwGGw7DxnBMokHHBgrzJxDn9BFcFwxGLxrFV7EfYehQNOD-74OS8fZRgZiNf9EDGAYiHh0-CspfBWd20zCIjlCdDBcyhwq3PLJ65JC_og3CT9AK4kvks4DI-01RYxNv9S8Jx1haO1lgU55hBIr1P_p21ZKnpcCEhPjB_cIFrHJqL5iJGfed-LXni9Suq24OHnp44Mrv4h7OD2elu5yWfdfFb-RGG2TYURFIGYGijsii093w0ZMBOfBS-3Xq_DrHeZbZrrNkY455gJCZ5eV83Nrt9J9_UF0VZHl_hwnSAUC_b3tN_l0ZlC9kPcNzJD04l4ndFBD2KdfQ2HGTX7pybWLZ7yH2BM3ui2OpiacaOzd7OE91rHYB2uZyQ7jdg25yF9M8QI9NHM_itCjdBvAYt4QCT8dX6gmZiIGR2F_YXZAsybtJ16pnUmODVbW80lPbzy-PUQYX79opeD9u6MBorzr9g08Elpb1F3DgSd8VSLlsR2QPllKl4AcJDMIOfZHOQGOzatMV7ipEVRa0L5FnjAWpHHvSNcsjD4Cul562mO3MlI2pCyo-US-nIzG5XZmOeu4Db_Kw_dEPOo2ztHwlU0qKJ7REBsbt63jdQtlwLuiLHwkpiwnrAOZfwbLLu9Yz4tL1eJlQffuwS_Aolsz7HA', ]; - const credentialPublicKey = 'pQECAyYgASFYIBdmUVOxrn-OOtkVwGP_vAspH3VkgzcGXVlu3-acb7EZIlggKgDTs0fr2d51sLR6uL3KP2cqR3iIUkKMCjyMJhYOkf4'; + const credentialPublicKey = + 'pQECAyYgASFYIBdmUVOxrn-OOtkVwGP_vAspH3VkgzcGXVlu3-acb7EZIlggKgDTs0fr2d51sLR6uL3KP2cqR3iIUkKMCjyMJhYOkf4'; const verified = await verifyAttestationWithMetadata({ statement: metadataStatement, - credentialPublicKey: base64url.toBuffer(credentialPublicKey), + credentialPublicKey: isoBase64URL.toBuffer(credentialPublicKey), x5c, }); diff --git a/packages/server/src/metadata/verifyAttestationWithMetadata.ts b/packages/server/src/metadata/verifyAttestationWithMetadata.ts index 5193135..9b4c471 100644 --- a/packages/server/src/metadata/verifyAttestationWithMetadata.ts +++ b/packages/server/src/metadata/verifyAttestationWithMetadata.ts @@ -4,7 +4,14 @@ import type { MetadataStatement, AlgSign } from '../metadata/mdsTypes'; import { convertCertBufferToPEM } from '../helpers/convertCertBufferToPEM'; import { validateCertificatePath } from '../helpers/validateCertificatePath'; import { decodeCredentialPublicKey } from '../helpers/decodeCredentialPublicKey'; -import { COSEKEYS, COSEKTY } from '../helpers/convertCOSEtoPKCS'; +import { + COSEALG, + COSECRV, + COSEKEYS, + COSEKTY, + COSEPublicKeyEC2, + isCOSEPublicKeyEC2, +} from '../helpers/cose'; /** * Match properties of the authenticator's attestation statement against expected values as @@ -17,15 +24,11 @@ export async function verifyAttestationWithMetadata({ attestationStatementAlg, }: { statement: MetadataStatement; - credentialPublicKey: Buffer; - x5c: Buffer[] | Base64URLString[]; + credentialPublicKey: Uint8Array; + x5c: Uint8Array[] | Base64URLString[]; attestationStatementAlg?: number; }): Promise<boolean> { - const { - authenticationAlgorithms, - authenticatorGetInfo, - attestationRootCertificates, - } = statement; + const { authenticationAlgorithms, authenticatorGetInfo, attestationRootCertificates } = statement; // Make sure the alg in the attestation statement matches one of the ones specified in metadata const keypairCOSEAlgs: Set<COSEInfo> = new Set(); @@ -41,14 +44,28 @@ export async function verifyAttestationWithMetadata({ // Extract the public key's COSE info for comparison const decodedPublicKey = decodeCredentialPublicKey(credentialPublicKey); + + const kty = decodedPublicKey.get(COSEKEYS.kty); + const alg = decodedPublicKey.get(COSEKEYS.alg); + + if (!kty) { + throw new Error('Credential public key was missing kty'); + } + + if (!alg) { + throw new Error('Credential public key was missing alg'); + } + + if (!kty) { + throw new Error('Credential public key was missing kty'); + } + // Assume everything is a number because these values should be - const publicKeyCOSEInfo: COSEInfo = { - kty: decodedPublicKey.get(COSEKEYS.kty) as number, - alg: decodedPublicKey.get(COSEKEYS.alg) as number, - crv: decodedPublicKey.get(COSEKEYS.crv) as number, - }; - if (!publicKeyCOSEInfo.crv) { - delete publicKeyCOSEInfo.crv; + const publicKeyCOSEInfo: COSEInfo = { kty, alg }; + + if (isCOSEPublicKeyEC2(decodedPublicKey)) { + const crv = decodedPublicKey.get(COSEKEYS.crv); + publicKeyCOSEInfo.crv = crv; } /** @@ -90,8 +107,9 @@ export async function verifyAttestationWithMetadata({ * ] * ``` */ - const debugMDSAlgs = authenticationAlgorithms - .map((algSign) => `'${algSign}' (COSE info: ${stringifyCOSEInfo(algSignToCOSEInfoMap[algSign])})`); + const debugMDSAlgs = authenticationAlgorithms.map( + algSign => `'${algSign}' (COSE info: ${stringifyCOSEInfo(algSignToCOSEInfoMap[algSign])})`, + ); const strMDSAlgs = JSON.stringify(debugMDSAlgs, null, 2).replace(/"/g, ''); /** @@ -126,10 +144,7 @@ export async function verifyAttestationWithMetadata({ * certificate chain validation. */ let authenticatorIsSelfReferencing = false; - if ( - authenticatorCerts.length === 1 && - statementRootCerts.indexOf(authenticatorCerts[0]) >= 0 - ) { + if (authenticatorCerts.length === 1 && statementRootCerts.indexOf(authenticatorCerts[0]) >= 0) { authenticatorIsSelfReferencing = true; } @@ -148,9 +163,9 @@ export async function verifyAttestationWithMetadata({ } type COSEInfo = { - kty: number; - alg: number; - crv?: number; + kty: COSEKTY; + alg: COSEALG; + crv?: COSECRV; }; /** diff --git a/packages/server/src/registration/generateRegistrationOptions.test.ts b/packages/server/src/registration/generateRegistrationOptions.test.ts index c67a8b2..25f9d30 100644 --- a/packages/server/src/registration/generateRegistrationOptions.test.ts +++ b/packages/server/src/registration/generateRegistrationOptions.test.ts @@ -175,7 +175,7 @@ test('should require resident key if residentKey option is absent but requireRes userName: 'usernameHere', authenticatorSelection: { requireResidentKey: true, - } + }, }); expect(options.authenticatorSelection?.requireResidentKey).toEqual(true); @@ -190,7 +190,7 @@ test('should discourage resident key if residentKey option is absent but require userName: 'usernameHere', authenticatorSelection: { requireResidentKey: false, - } + }, }); expect(options.authenticatorSelection?.requireResidentKey).toEqual(false); diff --git a/packages/server/src/registration/generateRegistrationOptions.ts b/packages/server/src/registration/generateRegistrationOptions.ts index 20b3283..83bfb3c 100644 --- a/packages/server/src/registration/generateRegistrationOptions.ts +++ b/packages/server/src/registration/generateRegistrationOptions.ts @@ -7,16 +7,16 @@ import type { PublicKeyCredentialDescriptorFuture, PublicKeyCredentialParameters, } from '@simplewebauthn/typescript-types'; -import base64url from 'base64url'; import { generateChallenge } from '../helpers/generateChallenge'; +import { isoBase64URL, isoUint8Array } from '../helpers/iso'; export type GenerateRegistrationOptionsOpts = { rpName: string; rpID: string; userID: string; userName: string; - challenge?: string | Buffer; + challenge?: string | Uint8Array; userDisplayName?: string; timeout?: number; attestationType?: AttestationConveyancePreference; @@ -151,8 +151,16 @@ export function generateRegistrationOptions( authenticatorSelection.requireResidentKey = authenticatorSelection.residentKey === 'required'; } + /** + * Preserve ability to specify `string` values for challenges + */ + let _challenge = challenge; + if (typeof _challenge === 'string') { + _challenge = isoUint8Array.fromASCIIString(_challenge); + } + return { - challenge: base64url.encode(challenge), + challenge: isoBase64URL.fromBuffer(_challenge), rp: { name: rpName, id: rpID, @@ -167,7 +175,7 @@ export function generateRegistrationOptions( attestation: attestationType, excludeCredentials: excludeCredentials.map(cred => ({ ...cred, - id: base64url.encode(cred.id as Buffer), + id: isoBase64URL.fromBuffer(cred.id as Uint8Array), })), authenticatorSelection, extensions, diff --git a/packages/server/src/registration/verifications/tpm/constants.ts b/packages/server/src/registration/verifications/tpm/constants.ts index c470d5b..324f013 100644 --- a/packages/server/src/registration/verifications/tpm/constants.ts +++ b/packages/server/src/registration/verifications/tpm/constants.ts @@ -187,6 +187,6 @@ export const TPM_ECC_CURVE_COSE_CRV_MAP: { [key: string]: number } = { TPM_ECC_NIST_P256: 1, // p256 TPM_ECC_NIST_P384: 2, // p384 TPM_ECC_NIST_P521: 3, // p521 - TPM_ECC_BN_P256: 1, // p256 - TPM_ECC_SM2_P256: 1, // p256 + TPM_ECC_BN_P256: 1, // p256 + TPM_ECC_SM2_P256: 1, // p256 }; diff --git a/packages/server/src/registration/verifications/tpm/parseCertInfo.ts b/packages/server/src/registration/verifications/tpm/parseCertInfo.ts index 6e3d4c3..bf28418 100644 --- a/packages/server/src/registration/verifications/tpm/parseCertInfo.ts +++ b/packages/server/src/registration/verifications/tpm/parseCertInfo.ts @@ -1,48 +1,58 @@ import { TPM_ST, TPM_ALG } from './constants'; +import { isoUint8Array } from '../../../helpers/iso'; /** * Cut up a TPM attestation's certInfo into intelligible chunks */ -export function parseCertInfo(certInfo: Buffer): ParsedCertInfo { +export function parseCertInfo(certInfo: Uint8Array): ParsedCertInfo { let pointer = 0; + const dataView = isoUint8Array.toDataView(certInfo); // Get a magic constant - const magic = certInfo.slice(pointer, (pointer += 4)).readUInt32BE(0); + const magic = dataView.getUint32(pointer); + pointer += 4; // Determine the algorithm used for attestation - const typeBuffer = certInfo.slice(pointer, (pointer += 2)); - const type = TPM_ST[typeBuffer.readUInt16BE(0)]; + const typeBuffer = dataView.getUint16(pointer); + pointer += 2; + const type = TPM_ST[typeBuffer]; // The name of a parent entity, can be ignored - const qualifiedSignerLength = certInfo.slice(pointer, (pointer += 2)).readUInt16BE(0); + const qualifiedSignerLength = dataView.getUint16(pointer); + pointer += 2; const qualifiedSigner = certInfo.slice(pointer, (pointer += qualifiedSignerLength)); // Get the expected hash of `attsToBeSigned` - const extraDataLength = certInfo.slice(pointer, (pointer += 2)).readUInt16BE(0); + const extraDataLength = dataView.getUint16(pointer); + pointer += 2; const extraData = certInfo.slice(pointer, (pointer += extraDataLength)); // Information about the TPM device's internal clock, can be ignored - const clockInfoBuffer = certInfo.slice(pointer, (pointer += 17)); - const clockInfo = { - clock: clockInfoBuffer.slice(0, 8), - resetCount: clockInfoBuffer.slice(8, 12).readUInt32BE(0), - restartCount: clockInfoBuffer.slice(12, 16).readUInt32BE(0), - safe: !!clockInfoBuffer[16], - }; + const clock = certInfo.slice(pointer, (pointer += 8)); + const resetCount = dataView.getUint32(pointer); + pointer += 4; + const restartCount = dataView.getUint32(pointer); + pointer += 4; + const safe = !!certInfo.slice(pointer, (pointer += 1)); + + const clockInfo = { clock, resetCount, restartCount, safe }; // TPM device firmware version const firmwareVersion = certInfo.slice(pointer, (pointer += 8)); // Attested Name - const attestedNameLength = certInfo.slice(pointer, (pointer += 2)).readUInt16BE(0); + const attestedNameLength = dataView.getUint16(pointer); + pointer += 2; const attestedName = certInfo.slice(pointer, (pointer += attestedNameLength)); + const attestedNameDataView = isoUint8Array.toDataView(attestedName); // Attested qualified name, can be ignored - const qualifiedNameLength = certInfo.slice(pointer, (pointer += 2)).readUInt16BE(0); + const qualifiedNameLength = dataView.getUint16(pointer); + pointer += 2; const qualifiedName = certInfo.slice(pointer, (pointer += qualifiedNameLength)); const attested = { - nameAlg: TPM_ALG[attestedName.slice(0, 2).readUInt16BE(0)], + nameAlg: TPM_ALG[attestedNameDataView.getUint16(0)], nameAlgBuffer: attestedName.slice(0, 2), name: attestedName, qualifiedName, @@ -62,19 +72,19 @@ export function parseCertInfo(certInfo: Buffer): ParsedCertInfo { type ParsedCertInfo = { magic: number; type: string; - qualifiedSigner: Buffer; - extraData: Buffer; + qualifiedSigner: Uint8Array; + extraData: Uint8Array; clockInfo: { - clock: Buffer; + clock: Uint8Array; resetCount: number; restartCount: number; safe: boolean; }; - firmwareVersion: Buffer; + firmwareVersion: Uint8Array; attested: { nameAlg: string; - nameAlgBuffer: Buffer; - name: Buffer; - qualifiedName: Buffer; + nameAlgBuffer: Uint8Array; + name: Uint8Array; + qualifiedName: Uint8Array; }; }; diff --git a/packages/server/src/registration/verifications/tpm/parsePubArea.ts b/packages/server/src/registration/verifications/tpm/parsePubArea.ts index ca61ddc..514828c 100644 --- a/packages/server/src/registration/verifications/tpm/parsePubArea.ts +++ b/packages/server/src/registration/verifications/tpm/parsePubArea.ts @@ -1,4 +1,5 @@ import { TPM_ALG, TPM_ECC_CURVE } from './constants'; +import { isoUint8Array } from '../../../helpers/iso'; /** * Break apart a TPM attestation's pubArea buffer @@ -6,17 +7,20 @@ import { TPM_ALG, TPM_ECC_CURVE } from './constants'; * See 12.2.4 TPMT_PUBLIC here: * https://trustedcomputinggroup.org/wp-content/uploads/TPM-Rev-2.0-Part-2-Structures-00.96-130315.pdf */ -export function parsePubArea(pubArea: Buffer): ParsedPubArea { +export function parsePubArea(pubArea: Uint8Array): ParsedPubArea { let pointer = 0; + const dataView = isoUint8Array.toDataView(pubArea); - const typeBuffer = pubArea.slice(pointer, (pointer += 2)); - const type = TPM_ALG[typeBuffer.readUInt16BE(0)]; + const type = TPM_ALG[dataView.getUint16(pointer)]; + pointer += 2; - const nameAlgBuffer = pubArea.slice(pointer, (pointer += 2)); - const nameAlg = TPM_ALG[nameAlgBuffer.readUInt16BE(0)]; + const nameAlg = TPM_ALG[dataView.getUint16(pointer)]; + pointer += 2; // Get some authenticator attributes(?) - const objectAttributesInt = pubArea.slice(pointer, (pointer += 4)).readUInt32BE(0); + // const objectAttributesInt = pubArea.slice(pointer, (pointer += 4)).readUInt32BE(0); + const objectAttributesInt = dataView.getUint32(pointer); + pointer += 4; const objectAttributes = { fixedTPM: !!(objectAttributesInt & 1), stClear: !!(objectAttributesInt & 2), @@ -32,52 +36,70 @@ export function parsePubArea(pubArea: Buffer): ParsedPubArea { }; // Slice out the authPolicy of dynamic length - const authPolicyLength = pubArea.slice(pointer, (pointer += 2)).readUInt16BE(0); + const authPolicyLength = dataView.getUint16(pointer); + pointer += 2; const authPolicy = pubArea.slice(pointer, (pointer += authPolicyLength)); // Extract additional curve params according to type const parameters: { rsa?: RSAParameters; ecc?: ECCParameters } = {}; - let unique = Buffer.from([]); + let unique = Uint8Array.from([]); if (type === 'TPM_ALG_RSA') { - const rsaBuffer = pubArea.slice(pointer, (pointer += 10)); + const symmetric = TPM_ALG[dataView.getUint16(pointer)]; + pointer += 2; - parameters.rsa = { - symmetric: TPM_ALG[rsaBuffer.slice(0, 2).readUInt16BE(0)], - scheme: TPM_ALG[rsaBuffer.slice(2, 4).readUInt16BE(0)], - keyBits: rsaBuffer.slice(4, 6).readUInt16BE(0), - exponent: rsaBuffer.slice(6, 10).readUInt32BE(0), - }; + const scheme = TPM_ALG[dataView.getUint16(pointer)]; + pointer += 2; + + const keyBits = dataView.getUint16(pointer); + pointer += 2; + + const exponent = dataView.getUint32(pointer); + pointer += 4; + + parameters.rsa = { symmetric, scheme, keyBits, exponent }; /** * See 11.2.4.5 TPM2B_PUBLIC_KEY_RSA here: * https://trustedcomputinggroup.org/wp-content/uploads/TPM-Rev-2.0-Part-2-Structures-00.96-130315.pdf */ - const uniqueLength = pubArea.slice(pointer, (pointer += 2)).readUInt16BE(0); + // const uniqueLength = pubArea.slice(pointer, (pointer += 2)).readUInt16BE(0); + const uniqueLength = dataView.getUint16(pointer); + pointer += 2; unique = pubArea.slice(pointer, (pointer += uniqueLength)); } else if (type === 'TPM_ALG_ECC') { - const eccBuffer = pubArea.slice(pointer, (pointer += 8)); + const symmetric = TPM_ALG[dataView.getUint16(pointer)]; + pointer += 2; + + const scheme = TPM_ALG[dataView.getUint16(pointer)]; + pointer += 2; + + const curveID = TPM_ECC_CURVE[dataView.getUint16(pointer)]; + pointer += 2; - parameters.ecc = { - symmetric: TPM_ALG[eccBuffer.slice(0, 2).readUInt16BE(0)], - scheme: TPM_ALG[eccBuffer.slice(2, 4).readUInt16BE(0)], - curveID: TPM_ECC_CURVE[eccBuffer.slice(4, 6).readUInt16BE(0)], - kdf: TPM_ALG[eccBuffer.slice(6, 8).readUInt16BE(0)], - }; + const kdf = TPM_ALG[dataView.getUint16(pointer)]; + pointer += 2; + + parameters.ecc = { symmetric, scheme, curveID, kdf }; /** * See 11.2.5.1 TPM2B_ECC_PARAMETER here: * https://trustedcomputinggroup.org/wp-content/uploads/TPM-Rev-2.0-Part-2-Structures-00.96-130315.pdf */ // Retrieve X - const uniqueXLength = pubArea.slice(pointer, (pointer += 2)).readUInt16BE(0); + const uniqueXLength = dataView.getUint16(pointer); + pointer += 2; + const uniqueX = pubArea.slice(pointer, (pointer += uniqueXLength)); + // Retrieve Y - const uniqueYLength = pubArea.slice(pointer, (pointer += 2)).readUInt16BE(0); + const uniqueYLength = dataView.getUint16(pointer); + pointer += 2; + const uniqueY = pubArea.slice(pointer, (pointer += uniqueYLength)); - unique = Buffer.concat([uniqueX, uniqueY]); + unique = isoUint8Array.concat([uniqueX, uniqueY]); } else { throw new Error(`Unexpected type "${type}" (TPM)`); } @@ -108,12 +130,12 @@ type ParsedPubArea = { decrypt: boolean; signOrEncrypt: boolean; }; - authPolicy: Buffer; + authPolicy: Uint8Array; parameters: { rsa?: RSAParameters; ecc?: ECCParameters; }; - unique: Buffer; + unique: Uint8Array; }; type RSAParameters = { diff --git a/packages/server/src/registration/verifications/tpm/verifyAttestationTPM.test.ts b/packages/server/src/registration/verifications/tpm/verifyAttestationTPM.test.ts index 5652640..0625b9e 100644 --- a/packages/server/src/registration/verifications/tpm/verifyAttestationTPM.test.ts +++ b/packages/server/src/registration/verifications/tpm/verifyAttestationTPM.test.ts @@ -1,9 +1,9 @@ +import { isoBase64URL } from '../../../helpers/iso'; import { verifyRegistrationResponse } from '../../verifyRegistrationResponse'; -import base64url from 'base64url'; test('should verify TPM response', async () => { const expectedChallenge = 'a4de0d36-057d-4e9d-831a-2c578fa89170'; - jest.spyOn(base64url, 'encode').mockReturnValueOnce(expectedChallenge); + jest.spyOn(isoBase64URL, 'fromString').mockReturnValueOnce(expectedChallenge); const verification = await verifyRegistrationResponse({ credential: { id: 'SErwRhxIzjPowcnM3e-D-u89EQXLUe1NYewpshd7Mc0', @@ -16,6 +16,7 @@ test('should verify TPM response', async () => { }, type: 'public-key', clientExtensionResults: {}, + authenticatorAttachment: '', }, expectedChallenge, expectedOrigin: 'https://dev.dontneeda.pw', @@ -33,7 +34,7 @@ test('should verify SHA1 TPM response', async () => { */ const expectedChallenge = '9JyUfJkg8PqoKZuD7FHzOE9dbyculC9urGTpGqBnEwnhKmni4rGRXxm3-ZBHK8x6riJQqIpC8qEa-T0qIFTKTQ'; - jest.spyOn(base64url, 'encode').mockReturnValueOnce(expectedChallenge); + jest.spyOn(isoBase64URL, 'fromString').mockReturnValueOnce(expectedChallenge); const verification = await verifyRegistrationResponse({ credential: { rawId: 'UJDoUJoGiDQF_EEZ3G_z9Lfq16_KFaXtMTjwTUrrRlc', @@ -46,6 +47,7 @@ test('should verify SHA1 TPM response', async () => { }, type: 'public-key', clientExtensionResults: {}, + authenticatorAttachment: '', }, expectedChallenge, expectedOrigin: 'https://localhost:44329', @@ -63,7 +65,7 @@ test('should verify SHA256 TPM response', async () => { */ const expectedChallenge = 'gHrAk4pNe2VlB0HLeKclI2P6QEa83PuGeijTHMtpbhY9KlybyhlwF_VzRe7yhabXagWuY6rkDWfvvhNqgh2o7A'; - jest.spyOn(base64url, 'encode').mockReturnValueOnce(expectedChallenge); + jest.spyOn(isoBase64URL, 'fromString').mockReturnValueOnce(expectedChallenge); const verification = await verifyRegistrationResponse({ credential: { rawId: 'h9XMhkVePN1Prq9Ks_VfwIsVZvt-jmSRTEnevTc-KB8', @@ -76,6 +78,7 @@ test('should verify SHA256 TPM response', async () => { }, type: 'public-key', clientExtensionResults: {}, + authenticatorAttachment: '', }, expectedChallenge, expectedOrigin: 'https://localhost:44329', @@ -100,7 +103,7 @@ test('should verify TPM response with spec-compliant tcgAtTpm SAN structure', as * ] */ const expectedChallenge = 'VfmZXKDxqdoXFMHXO3SE2Q2b8u5Ki64OL_XICELcGKg'; - jest.spyOn(base64url, 'encode').mockReturnValueOnce(expectedChallenge); + jest.spyOn(isoBase64URL, 'fromString').mockReturnValueOnce(expectedChallenge); const verification = await verifyRegistrationResponse({ credential: { id: 'LVwzXx0fStkvsos_jdl9DTd6O3-6be8Ua4tcdXc5XeM', @@ -113,6 +116,7 @@ test('should verify TPM response with spec-compliant tcgAtTpm SAN structure', as }, type: 'public-key', clientExtensionResults: {}, + authenticatorAttachment: '', }, expectedChallenge, expectedOrigin: 'https://dev.netpassport.io', @@ -133,7 +137,7 @@ test('should verify TPM response with non-spec-compliant tcgAtTpm SAN structure' * ] */ const expectedChallenge = '4STWgmXrgJxzigqe6nFuIg'; - jest.spyOn(base64url, 'encode').mockReturnValueOnce(expectedChallenge); + jest.spyOn(isoBase64URL, 'fromString').mockReturnValueOnce(expectedChallenge); const verification = await verifyRegistrationResponse({ credential: { id: 'X7TPi7o8WfiIz1bP0Vciz1xRvSMyiitgOR1sUqY724s', @@ -146,6 +150,7 @@ test('should verify TPM response with non-spec-compliant tcgAtTpm SAN structure' }, type: 'public-key', clientExtensionResults: {}, + authenticatorAttachment: '', }, expectedChallenge, expectedOrigin: 'https://localhost:44329', @@ -157,17 +162,20 @@ test('should verify TPM response with non-spec-compliant tcgAtTpm SAN structure' test('should verify TPM response with ECC public area type', async () => { const expectedChallenge = 'uzn9u0Tx-LBdtGgERsbkHRBjiUt5i2rvm2BBTZrWqEo'; - jest.spyOn(base64url, 'encode').mockReturnValueOnce(expectedChallenge); + jest.spyOn(isoBase64URL, 'fromString').mockReturnValueOnce(expectedChallenge); const verification = await verifyRegistrationResponse({ credential: { - 'id': 'hsS2ywFz_LWf9-lC35vC9uJTVD3ZCVdweZvESUbjXnQ', - 'rawId': 'hsS2ywFz_LWf9-lC35vC9uJTVD3ZCVdweZvESUbjXnQ', - 'type': 'public-key', - 'response': { - 'attestationObject': 'o2NmbXRjdHBtZ2F0dFN0bXSmY2FsZzn__mNzaWdZAQCqAcGoi2IFXCF5xxokjR5yOAwK_11iCOqt8hCkpHE9rW602J3KjhcRQzoFf1UxZvadwmYcHHMxDQDmVuOhH-yW-DfARVT7O3MzlhhzrGTNO_-jhGFsGeEdz0RgNsviDdaVP5lNsV6Pe4bMhgBv1aTkk0zx1T8sxK8B7gKT6x80RIWg89_aYY4gHR4n65SRDp2gOGI2IHDvqTwidyeaAHVPbDrF8iDbQ88O-GH_fheAtFtgjbIq-XQbwVdzQhYdWyL0XVUwGLSSuABuB4seRPkyZCKoOU6VuuQzfWNpH2Nl05ybdXi27HysUexgfPxihB3PbR8LJdi1j04tRg3JvBUvY3ZlcmMyLjBjeDVjglkFuzCCBbcwggOfoAMCAQICEGEZiaSlAkKpqaQOKDYmWPkwDQYJKoZIhvcNAQELBQAwQTE_MD0GA1UEAxM2RVVTLU5UQy1LRVlJRC1FNEE4NjY2RjhGNEM2RDlDMzkzMkE5NDg4NDc3ODBBNjgxMEM0MjEzMB4XDTIyMDExMjIyMTUxOFoXDTI3MDYxMDE4NTQzNlowADCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAKo-7DHdiipZTzfA9fpTaIMVK887zM0nXAVIvU0kmGAsPpTYbf7dn1DAl6BhcDkXs2WrwYP02K8RxXWOF4jf7esMAIkr65zPWqLys8WRNM60d7g9GOADwbN8qrY0hepSsaJwjhswbNJI6L8vJwnnrQ6UWVCm3xHqn8CB2iSWNSUnshgTQTkJ1ZEdToeD51sFXUE0fSxXjyIiSAAD4tCIZkmHFVqchzfqUgiiM_mbbKzUnxEZ6c6r39ccHzbm4Ir-u62repQnVXKTpzFBbJ-Eg15REvw6xuYaGtpItk27AXVcEodfAylf7pgQPfExWkoMZfb8faqbQAj5x29mBJvlzj0CAwEAAaOCAeowggHmMA4GA1UdDwEB_wQEAwIHgDAMBgNVHRMBAf8EAjAAMG0GA1UdIAEB_wRjMGEwXwYJKwYBBAGCNxUfMFIwUAYIKwYBBQUHAgIwRB5CAFQAQwBQAEEAIAAgAFQAcgB1AHMAdABlAGQAIAAgAFAAbABhAHQAZgBvAHIAbQAgACAASQBkAGUAbgB0AGkAdAB5MBAGA1UdJQQJMAcGBWeBBQgDMFAGA1UdEQEB_wRGMESkQjBAMT4wEAYFZ4EFAgIMB05QQ1Q3NXgwFAYFZ4EFAgEMC2lkOjRFNTQ0MzAwMBQGBWeBBQIDDAtpZDowMDA3MDAwMjAfBgNVHSMEGDAWgBQ3yjAtSXrnaSNOtzy1PEXxOO1ZUDAdBgNVHQ4EFgQU1ml3H5Tzrs0Nev69tFNhPZnhaV0wgbIGCCsGAQUFBwEBBIGlMIGiMIGfBggrBgEFBQcwAoaBkmh0dHA6Ly9hemNzcHJvZGV1c2Fpa3B1Ymxpc2guYmxvYi5jb3JlLndpbmRvd3MubmV0L2V1cy1udGMta2V5aWQtZTRhODY2NmY4ZjRjNmQ5YzM5MzJhOTQ4ODQ3NzgwYTY4MTBjNDIxMy9lMDFjMjA2Mi1mYmRjLTQwYTUtYTQwZi1jMzc3YzBmNzY1MWMuY2VyMA0GCSqGSIb3DQEBCwUAA4ICAQAz-YGrj0S841gyMZuit-qsKpKNdxbkaEhyB1baexHGcMzC2y1O1kpTrpaH3I80hrIZFtYoA2xKQ1j67uoC6vm1PhsJB6qhs9T7zmWZ1VtleJTYGNZ_bYY2wo65qJHFB5TXkevJUVe2G39kB_W1TKB6g_GSwb4a5e4D_Sjp7b7RZpyIKHT1_UE1H4RXgR9Qi68K4WVaJXJUS6T4PHrRc4PeGUoJLQFUGxYokWIf456G32GwGgvUSX76K77pVv4Y-kT3v5eEJdYxlS4EVT13a17KWd0DdLje0Ae69q_DQSlrHVLUrADvuZMeM8jxyPQvDb7ETKLsSUeHm73KOCGLStcGQ3pB49nt3d9XdWCcUwUrmbBF2G7HsRgTNbj16G6QUcWroQEqNrBG49aO9mMZ0NwSn5d3oNuXSXjLdGBXM1ukLZ-GNrZDYw5KXU102_5VpHpjIHrZh0dXg3Q9eucKe6EkFbH65-O5VaQWUnR5WJpt6-fl_l0iHqHnKXbgL6tjeerCqZWDvFsOak05R-hosAoQs_Ni0EsgZqHwR_VlG86fsSwCVU3_sDKTNs_Je08ewJ_bbMB5Tq6k1Sxs8Aw8R96EwjQLp3z-Zva1myU-KerYYVDl5BdvgPqbD8Xmst-z6vrP3CJbtr8jgqVS7RWy_cJOA8KCZ6IS_75QT7Gblq6UGFkG7zCCBuswggTToAMCAQICEzMAAAbTtnznKsOrB-gAAAAABtMwDQYJKoZIhvcNAQELBQAwgYwxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpXYXNoaW5ndG9uMRAwDgYDVQQHEwdSZWRtb25kMR4wHAYDVQQKExVNaWNyb3NvZnQgQ29ycG9yYXRpb24xNjA0BgNVBAMTLU1pY3Jvc29mdCBUUE0gUm9vdCBDZXJ0aWZpY2F0ZSBBdXRob3JpdHkgMjAxNDAeFw0yMTA2MTAxODU0MzZaFw0yNzA2MTAxODU0MzZaMEExPzA9BgNVBAMTNkVVUy1OVEMtS0VZSUQtRTRBODY2NkY4RjRDNkQ5QzM5MzJBOTQ4ODQ3NzgwQTY4MTBDNDIxMzCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAJA7GLwHWWbn2H8DRppxQfre4zll1sgE3Wxt9DTYWt5-v-xKwCQb6z_7F1py7LMe58qLqglAgVhS6nEvN2puZ1GzejdsFFxz2gyEfH1y-X3RGp0dxS6UKwEtmksaMEKIRQn2GgKdUkiuvkaxaoznuExoTPyu0aXk6yFsX5KEDu9UZCgt66bRy6m3KIRnn1VK2frZfqGYi8C8x9Q69oGG316tUwAIm3ypDtv3pREXsDLYE1U5Irdv32hzJ4CqqPyau-qJS18b8CsjvgOppwXRSwpOmU7S3xqo-F7h1eeFw2tgHc7PEPt8MSSKeba8Fz6QyiLhgFr8jFUvKRzk4B41HFUMqXYawbhAtfIBiGGsGrrdNKb7MxISnH1E6yLVCQGGhXiN9U7V0h8Gn56eKzopGlubw7yMmgu8Cu2wBX_a_jFmIBHnn8YgwcRm6NvT96KclDHnFqPVm3On12bG31F7EYkIRGLbaTT6avEu9rL6AJn7Xr245Sa6dC_OSMRKqLSufxp6O6f2TH2g4kvT0Go9SeyM2_acBjIiQ0rFeBOm49H4E4VcJepf79FkljovD68imeZ5MXjxepcCzS138374Jeh7k28JePwJnjDxS8n9Dr6xOU3_wxS1gN5cW6cXSoiPGe0JM4CEyAcUtKrvpUWoTajxxnylZuvS8ou2thfH2PQlAgMBAAGjggGOMIIBijAOBgNVHQ8BAf8EBAMCAoQwGwYDVR0lBBQwEgYJKwYBBAGCNxUkBgVngQUIAzAWBgNVHSAEDzANMAsGCSsGAQQBgjcVHzASBgNVHRMBAf8ECDAGAQH_AgEAMB0GA1UdDgQWBBQ3yjAtSXrnaSNOtzy1PEXxOO1ZUDAfBgNVHSMEGDAWgBR6jArOL0hiF-KU0a5VwVLscXSkVjBwBgNVHR8EaTBnMGWgY6Bhhl9odHRwOi8vd3d3Lm1pY3Jvc29mdC5jb20vcGtpb3BzL2NybC9NaWNyb3NvZnQlMjBUUE0lMjBSb290JTIwQ2VydGlmaWNhdGUlMjBBdXRob3JpdHklMjAyMDE0LmNybDB9BggrBgEFBQcBAQRxMG8wbQYIKwYBBQUHMAKGYWh0dHA6Ly93d3cubWljcm9zb2Z0LmNvbS9wa2lvcHMvY2VydHMvTWljcm9zb2Z0JTIwVFBNJTIwUm9vdCUyMENlcnRpZmljYXRlJTIwQXV0aG9yaXR5JTIwMjAxNC5jcnQwDQYJKoZIhvcNAQELBQADggIBAFZTSitCISvll6i6rPUPd8Wt2mogRw6I_c-dWQzdc9-SY9iaIGXqVSPKKOlAYU2ju7nvN6AvrIba6sngHeU0AUTeg1UZ5-bDFOWdSgPaGyH_EN_l-vbV6SJPzOmZHJOHfw2WT8hjlFaTaKYRXxzFH7PUR4nxGRbWtdIGgQhUlWg5oo_FO4bvLKfssPSONn684qkAVierq-ly1WeqJzOYhd4EylgVJ9NL3YUhg8dYcHAieptDzF7OcDqffbuZLZUx6xcyibhWQcntAh7a3xPwqXxENsHhme_bqw_kqa-NVk-Wz4zdoiNNLRvUmCSL1WLc4JPsFJ08Ekn1kW7f9ZKnie5aw-29jEf6KIBt4lGDD3tXTfaOVvWcDbu92jMOO1dhEIj63AwQiDJgZhqnrpjlyWU_X0IVQlaPBg80AE0Y3sw1oMrY0XwdeQUjSpH6e5fTYKrNB6NMT1jXGjKIzVg8XbPWlnebP2wEhq8rYiDR31b9B9Sw_naK7Xb-Cqi-VQdUtknSjeljusrBpxGUx-EIJci0-dzeXRT5_376vyKSuYxA1Xd2jd4EknJLIAVLT3rb10DCuKGLDgafbsfTBxVoEa9hSjYOZUr_m3WV6t6I9WPYjVyhyi7fCEIG4JE7YbM4na4jg5q3DM8ibE8jyufAq0PfJZTJyi7c2Q2N_9NgnCNwZ3B1YkFyZWFYdgAjAAsABAByACCd_8vzbDg65pn7mGjcbcuJ1xU4hL4oA5IsEkFYv60irgAQABAAAwAQACAek7g2C8TeORRoKxuN7HrJ5OinVGuHzEgYODyUsF9D1wAggXPPXn-Pm_4IF0c4XVaJjmHO3EB2KBwdg_L60N0IL9xoY2VydEluZm9Yof9UQ0eAFwAiAAvQNGTLa2wT6u8SKDDdwkgaq5Cmh6jcD_6ULvM9ZmvdbwAUtMInD3WtGSdWHPWijMrW_TfYo-gAAAABPuBems3Sywu4aQsGAe85iOosjtXIACIAC5FPRiZSJzjYMNnAz9zFtM62o57FJwv8F5gNEcioqhHwACIACyVXxq1wZhDsqTqdYr7vQUUJ3vwWVrlN0ZQv5HFnHqWdaGF1dGhEYXRhWKR0puqSE8mcL3SyJJKzIM9AJiqUwalQoDl_KSULYIQe8EUAAAAACJhwWMrcS4G24TDeUNy-lgAghsS2ywFz_LWf9-lC35vC9uJTVD3ZCVdweZvESUbjXnSlAQIDJiABIVggHpO4NgvE3jkUaCsbjex6yeTop1Rrh8xIGDg8lLBfQ9ciWCCBc89ef4-b_ggXRzhdVomOYc7cQHYoHB2D8vrQ3Qgv3A', - 'clientDataJSON': 'eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmdlIjoidXpuOXUwVHgtTEJkdEdnRVJzYmtIUkJqaVV0NWkycnZtMkJCVFpyV3FFbyIsIm9yaWdpbiI6Imh0dHBzOi8vd2ViYXV0aG4uaW8iLCJjcm9zc09yaWdpbiI6ZmFsc2V9' + id: 'hsS2ywFz_LWf9-lC35vC9uJTVD3ZCVdweZvESUbjXnQ', + rawId: 'hsS2ywFz_LWf9-lC35vC9uJTVD3ZCVdweZvESUbjXnQ', + type: 'public-key', + response: { + attestationObject: + 'o2NmbXRjdHBtZ2F0dFN0bXSmY2FsZzn__mNzaWdZAQCqAcGoi2IFXCF5xxokjR5yOAwK_11iCOqt8hCkpHE9rW602J3KjhcRQzoFf1UxZvadwmYcHHMxDQDmVuOhH-yW-DfARVT7O3MzlhhzrGTNO_-jhGFsGeEdz0RgNsviDdaVP5lNsV6Pe4bMhgBv1aTkk0zx1T8sxK8B7gKT6x80RIWg89_aYY4gHR4n65SRDp2gOGI2IHDvqTwidyeaAHVPbDrF8iDbQ88O-GH_fheAtFtgjbIq-XQbwVdzQhYdWyL0XVUwGLSSuABuB4seRPkyZCKoOU6VuuQzfWNpH2Nl05ybdXi27HysUexgfPxihB3PbR8LJdi1j04tRg3JvBUvY3ZlcmMyLjBjeDVjglkFuzCCBbcwggOfoAMCAQICEGEZiaSlAkKpqaQOKDYmWPkwDQYJKoZIhvcNAQELBQAwQTE_MD0GA1UEAxM2RVVTLU5UQy1LRVlJRC1FNEE4NjY2RjhGNEM2RDlDMzkzMkE5NDg4NDc3ODBBNjgxMEM0MjEzMB4XDTIyMDExMjIyMTUxOFoXDTI3MDYxMDE4NTQzNlowADCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAKo-7DHdiipZTzfA9fpTaIMVK887zM0nXAVIvU0kmGAsPpTYbf7dn1DAl6BhcDkXs2WrwYP02K8RxXWOF4jf7esMAIkr65zPWqLys8WRNM60d7g9GOADwbN8qrY0hepSsaJwjhswbNJI6L8vJwnnrQ6UWVCm3xHqn8CB2iSWNSUnshgTQTkJ1ZEdToeD51sFXUE0fSxXjyIiSAAD4tCIZkmHFVqchzfqUgiiM_mbbKzUnxEZ6c6r39ccHzbm4Ir-u62repQnVXKTpzFBbJ-Eg15REvw6xuYaGtpItk27AXVcEodfAylf7pgQPfExWkoMZfb8faqbQAj5x29mBJvlzj0CAwEAAaOCAeowggHmMA4GA1UdDwEB_wQEAwIHgDAMBgNVHRMBAf8EAjAAMG0GA1UdIAEB_wRjMGEwXwYJKwYBBAGCNxUfMFIwUAYIKwYBBQUHAgIwRB5CAFQAQwBQAEEAIAAgAFQAcgB1AHMAdABlAGQAIAAgAFAAbABhAHQAZgBvAHIAbQAgACAASQBkAGUAbgB0AGkAdAB5MBAGA1UdJQQJMAcGBWeBBQgDMFAGA1UdEQEB_wRGMESkQjBAMT4wEAYFZ4EFAgIMB05QQ1Q3NXgwFAYFZ4EFAgEMC2lkOjRFNTQ0MzAwMBQGBWeBBQIDDAtpZDowMDA3MDAwMjAfBgNVHSMEGDAWgBQ3yjAtSXrnaSNOtzy1PEXxOO1ZUDAdBgNVHQ4EFgQU1ml3H5Tzrs0Nev69tFNhPZnhaV0wgbIGCCsGAQUFBwEBBIGlMIGiMIGfBggrBgEFBQcwAoaBkmh0dHA6Ly9hemNzcHJvZGV1c2Fpa3B1Ymxpc2guYmxvYi5jb3JlLndpbmRvd3MubmV0L2V1cy1udGMta2V5aWQtZTRhODY2NmY4ZjRjNmQ5YzM5MzJhOTQ4ODQ3NzgwYTY4MTBjNDIxMy9lMDFjMjA2Mi1mYmRjLTQwYTUtYTQwZi1jMzc3YzBmNzY1MWMuY2VyMA0GCSqGSIb3DQEBCwUAA4ICAQAz-YGrj0S841gyMZuit-qsKpKNdxbkaEhyB1baexHGcMzC2y1O1kpTrpaH3I80hrIZFtYoA2xKQ1j67uoC6vm1PhsJB6qhs9T7zmWZ1VtleJTYGNZ_bYY2wo65qJHFB5TXkevJUVe2G39kB_W1TKB6g_GSwb4a5e4D_Sjp7b7RZpyIKHT1_UE1H4RXgR9Qi68K4WVaJXJUS6T4PHrRc4PeGUoJLQFUGxYokWIf456G32GwGgvUSX76K77pVv4Y-kT3v5eEJdYxlS4EVT13a17KWd0DdLje0Ae69q_DQSlrHVLUrADvuZMeM8jxyPQvDb7ETKLsSUeHm73KOCGLStcGQ3pB49nt3d9XdWCcUwUrmbBF2G7HsRgTNbj16G6QUcWroQEqNrBG49aO9mMZ0NwSn5d3oNuXSXjLdGBXM1ukLZ-GNrZDYw5KXU102_5VpHpjIHrZh0dXg3Q9eucKe6EkFbH65-O5VaQWUnR5WJpt6-fl_l0iHqHnKXbgL6tjeerCqZWDvFsOak05R-hosAoQs_Ni0EsgZqHwR_VlG86fsSwCVU3_sDKTNs_Je08ewJ_bbMB5Tq6k1Sxs8Aw8R96EwjQLp3z-Zva1myU-KerYYVDl5BdvgPqbD8Xmst-z6vrP3CJbtr8jgqVS7RWy_cJOA8KCZ6IS_75QT7Gblq6UGFkG7zCCBuswggTToAMCAQICEzMAAAbTtnznKsOrB-gAAAAABtMwDQYJKoZIhvcNAQELBQAwgYwxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpXYXNoaW5ndG9uMRAwDgYDVQQHEwdSZWRtb25kMR4wHAYDVQQKExVNaWNyb3NvZnQgQ29ycG9yYXRpb24xNjA0BgNVBAMTLU1pY3Jvc29mdCBUUE0gUm9vdCBDZXJ0aWZpY2F0ZSBBdXRob3JpdHkgMjAxNDAeFw0yMTA2MTAxODU0MzZaFw0yNzA2MTAxODU0MzZaMEExPzA9BgNVBAMTNkVVUy1OVEMtS0VZSUQtRTRBODY2NkY4RjRDNkQ5QzM5MzJBOTQ4ODQ3NzgwQTY4MTBDNDIxMzCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAJA7GLwHWWbn2H8DRppxQfre4zll1sgE3Wxt9DTYWt5-v-xKwCQb6z_7F1py7LMe58qLqglAgVhS6nEvN2puZ1GzejdsFFxz2gyEfH1y-X3RGp0dxS6UKwEtmksaMEKIRQn2GgKdUkiuvkaxaoznuExoTPyu0aXk6yFsX5KEDu9UZCgt66bRy6m3KIRnn1VK2frZfqGYi8C8x9Q69oGG316tUwAIm3ypDtv3pREXsDLYE1U5Irdv32hzJ4CqqPyau-qJS18b8CsjvgOppwXRSwpOmU7S3xqo-F7h1eeFw2tgHc7PEPt8MSSKeba8Fz6QyiLhgFr8jFUvKRzk4B41HFUMqXYawbhAtfIBiGGsGrrdNKb7MxISnH1E6yLVCQGGhXiN9U7V0h8Gn56eKzopGlubw7yMmgu8Cu2wBX_a_jFmIBHnn8YgwcRm6NvT96KclDHnFqPVm3On12bG31F7EYkIRGLbaTT6avEu9rL6AJn7Xr245Sa6dC_OSMRKqLSufxp6O6f2TH2g4kvT0Go9SeyM2_acBjIiQ0rFeBOm49H4E4VcJepf79FkljovD68imeZ5MXjxepcCzS138374Jeh7k28JePwJnjDxS8n9Dr6xOU3_wxS1gN5cW6cXSoiPGe0JM4CEyAcUtKrvpUWoTajxxnylZuvS8ou2thfH2PQlAgMBAAGjggGOMIIBijAOBgNVHQ8BAf8EBAMCAoQwGwYDVR0lBBQwEgYJKwYBBAGCNxUkBgVngQUIAzAWBgNVHSAEDzANMAsGCSsGAQQBgjcVHzASBgNVHRMBAf8ECDAGAQH_AgEAMB0GA1UdDgQWBBQ3yjAtSXrnaSNOtzy1PEXxOO1ZUDAfBgNVHSMEGDAWgBR6jArOL0hiF-KU0a5VwVLscXSkVjBwBgNVHR8EaTBnMGWgY6Bhhl9odHRwOi8vd3d3Lm1pY3Jvc29mdC5jb20vcGtpb3BzL2NybC9NaWNyb3NvZnQlMjBUUE0lMjBSb290JTIwQ2VydGlmaWNhdGUlMjBBdXRob3JpdHklMjAyMDE0LmNybDB9BggrBgEFBQcBAQRxMG8wbQYIKwYBBQUHMAKGYWh0dHA6Ly93d3cubWljcm9zb2Z0LmNvbS9wa2lvcHMvY2VydHMvTWljcm9zb2Z0JTIwVFBNJTIwUm9vdCUyMENlcnRpZmljYXRlJTIwQXV0aG9yaXR5JTIwMjAxNC5jcnQwDQYJKoZIhvcNAQELBQADggIBAFZTSitCISvll6i6rPUPd8Wt2mogRw6I_c-dWQzdc9-SY9iaIGXqVSPKKOlAYU2ju7nvN6AvrIba6sngHeU0AUTeg1UZ5-bDFOWdSgPaGyH_EN_l-vbV6SJPzOmZHJOHfw2WT8hjlFaTaKYRXxzFH7PUR4nxGRbWtdIGgQhUlWg5oo_FO4bvLKfssPSONn684qkAVierq-ly1WeqJzOYhd4EylgVJ9NL3YUhg8dYcHAieptDzF7OcDqffbuZLZUx6xcyibhWQcntAh7a3xPwqXxENsHhme_bqw_kqa-NVk-Wz4zdoiNNLRvUmCSL1WLc4JPsFJ08Ekn1kW7f9ZKnie5aw-29jEf6KIBt4lGDD3tXTfaOVvWcDbu92jMOO1dhEIj63AwQiDJgZhqnrpjlyWU_X0IVQlaPBg80AE0Y3sw1oMrY0XwdeQUjSpH6e5fTYKrNB6NMT1jXGjKIzVg8XbPWlnebP2wEhq8rYiDR31b9B9Sw_naK7Xb-Cqi-VQdUtknSjeljusrBpxGUx-EIJci0-dzeXRT5_376vyKSuYxA1Xd2jd4EknJLIAVLT3rb10DCuKGLDgafbsfTBxVoEa9hSjYOZUr_m3WV6t6I9WPYjVyhyi7fCEIG4JE7YbM4na4jg5q3DM8ibE8jyufAq0PfJZTJyi7c2Q2N_9NgnCNwZ3B1YkFyZWFYdgAjAAsABAByACCd_8vzbDg65pn7mGjcbcuJ1xU4hL4oA5IsEkFYv60irgAQABAAAwAQACAek7g2C8TeORRoKxuN7HrJ5OinVGuHzEgYODyUsF9D1wAggXPPXn-Pm_4IF0c4XVaJjmHO3EB2KBwdg_L60N0IL9xoY2VydEluZm9Yof9UQ0eAFwAiAAvQNGTLa2wT6u8SKDDdwkgaq5Cmh6jcD_6ULvM9ZmvdbwAUtMInD3WtGSdWHPWijMrW_TfYo-gAAAABPuBems3Sywu4aQsGAe85iOosjtXIACIAC5FPRiZSJzjYMNnAz9zFtM62o57FJwv8F5gNEcioqhHwACIACyVXxq1wZhDsqTqdYr7vQUUJ3vwWVrlN0ZQv5HFnHqWdaGF1dGhEYXRhWKR0puqSE8mcL3SyJJKzIM9AJiqUwalQoDl_KSULYIQe8EUAAAAACJhwWMrcS4G24TDeUNy-lgAghsS2ywFz_LWf9-lC35vC9uJTVD3ZCVdweZvESUbjXnSlAQIDJiABIVggHpO4NgvE3jkUaCsbjex6yeTop1Rrh8xIGDg8lLBfQ9ciWCCBc89ef4-b_ggXRzhdVomOYc7cQHYoHB2D8vrQ3Qgv3A', + clientDataJSON: + 'eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmdlIjoidXpuOXUwVHgtTEJkdEdnRVJzYmtIUkJqaVV0NWkycnZtMkJCVFpyV3FFbyIsIm9yaWdpbiI6Imh0dHBzOi8vd2ViYXV0aG4uaW8iLCJjcm9zc09yaWdpbiI6ZmFsc2V9', }, - 'clientExtensionResults': {}, + clientExtensionResults: {}, + authenticatorAttachment: '', }, expectedChallenge, expectedOrigin: 'https://webauthn.io', diff --git a/packages/server/src/registration/verifications/tpm/verifyAttestationTPM.ts b/packages/server/src/registration/verifications/tpm/verifyAttestationTPM.ts index fd2375c..c665be3 100644 --- a/packages/server/src/registration/verifications/tpm/verifyAttestationTPM.ts +++ b/packages/server/src/registration/verifications/tpm/verifyAttestationTPM.ts @@ -11,12 +11,20 @@ import { import type { AttestationFormatVerifierOpts } from '../../verifyRegistrationResponse'; import { decodeCredentialPublicKey } from '../../../helpers/decodeCredentialPublicKey'; -import { COSEKEYS, COSEALGHASH } from '../../../helpers/convertCOSEtoPKCS'; +import { + COSEKEYS, + isCOSEAlg, + COSEKTY, + isCOSEPublicKeyRSA, + isCOSEPublicKeyEC2, + COSEALG, +} from '../../../helpers/cose'; import { toHash } from '../../../helpers/toHash'; import { convertCertBufferToPEM } from '../../../helpers/convertCertBufferToPEM'; import { validateCertificatePath } from '../../../helpers/validateCertificatePath'; import { getCertificateInfo } from '../../../helpers/getCertificateInfo'; import { verifySignature } from '../../../helpers/verifySignature'; +import { isoUint8Array } from '../../../helpers/iso'; import { MetadataService } from '../../../services/metadataService'; import { verifyAttestationWithMetadata } from '../../../metadata/verifyAttestationWithMetadata'; @@ -24,10 +32,17 @@ import { TPM_MANUFACTURERS, TPM_ECC_CURVE_COSE_CRV_MAP } from './constants'; import { parseCertInfo } from './parseCertInfo'; import { parsePubArea } from './parsePubArea'; -export async function verifyAttestationTPM(options: AttestationFormatVerifierOpts): Promise<boolean> { +export async function verifyAttestationTPM( + options: AttestationFormatVerifierOpts, +): Promise<boolean> { const { aaguid, attStmt, authData, credentialPublicKey, clientDataHash, rootCertificates } = options; - const { ver, sig, alg, x5c, pubArea, certInfo } = attStmt; + const ver = attStmt.get('ver'); + const sig = attStmt.get('sig'); + const alg = attStmt.get('alg'); + const x5c = attStmt.get('x5c'); + const pubArea = attStmt.get('pubArea'); + const certInfo = attStmt.get('certInfo'); /** * Verify structures @@ -44,6 +59,10 @@ export async function verifyAttestationTPM(options: AttestationFormatVerifierOpt throw new Error(`Attestation statement did not contain alg (TPM)`); } + if (!isCOSEAlg(alg)) { + throw new Error(`Attestation statement contained invalid alg ${alg} (TPM)`); + } + if (!x5c) { throw new Error('No attestation certificate provided in attestation statement (TPM)'); } @@ -64,6 +83,14 @@ export async function verifyAttestationTPM(options: AttestationFormatVerifierOpt const cosePublicKey = decodeCredentialPublicKey(credentialPublicKey); if (pubType === 'TPM_ALG_RSA') { + if (!isCOSEPublicKeyRSA(cosePublicKey)) { + throw new Error( + `Credential public key with kty ${cosePublicKey.get( + COSEKEYS.kty, + )} did not match ${pubType}`, + ); + } + const n = cosePublicKey.get(COSEKEYS.n); const e = cosePublicKey.get(COSEKEYS.e); @@ -74,7 +101,7 @@ export async function verifyAttestationTPM(options: AttestationFormatVerifierOpt throw new Error('COSE public key missing e (TPM|RSA)'); } - if (!unique.equals(n as Buffer)) { + if (!isoUint8Array.areEqual(unique, n)) { throw new Error('PubArea unique is not same as credentialPublicKey (TPM|RSA)'); } @@ -82,7 +109,7 @@ export async function verifyAttestationTPM(options: AttestationFormatVerifierOpt throw new Error(`Parsed pubArea type is RSA, but missing parameters.rsa (TPM|RSA)`); } - const eBuffer = e as Buffer; + const eBuffer = e as Uint8Array; // If `exponent` is equal to 0x00, then exponent is the default RSA exponent of 2^16+1 (65537) const pubAreaExponent = parameters.rsa.exponent || 65537; @@ -93,6 +120,14 @@ export async function verifyAttestationTPM(options: AttestationFormatVerifierOpt throw new Error(`Unexpected public key exp ${eSum}, expected ${pubAreaExponent} (TPM|RSA)`); } } else if (pubType === 'TPM_ALG_ECC') { + if (!isCOSEPublicKeyEC2(cosePublicKey)) { + throw new Error( + `Credential public key with kty ${cosePublicKey.get( + COSEKEYS.kty, + )} did not match ${pubType}`, + ); + } + const crv = cosePublicKey.get(COSEKEYS.crv); const x = cosePublicKey.get(COSEKEYS.x); const y = cosePublicKey.get(COSEKEYS.y); @@ -107,7 +142,7 @@ export async function verifyAttestationTPM(options: AttestationFormatVerifierOpt throw new Error('COSE public key missing y (TPM|ECC)'); } - if (!unique.equals(Buffer.concat([x as Buffer, y as Buffer]))) { + if (!isoUint8Array.areEqual(unique, isoUint8Array.concat([x, y]))) { throw new Error('PubArea unique is not same as public key x and y (TPM|ECC)'); } @@ -116,7 +151,7 @@ export async function verifyAttestationTPM(options: AttestationFormatVerifierOpt } const pubAreaCurveID = parameters.ecc.curveID; - const pubAreaCurveIDMapToCOSECRV = TPM_ECC_CURVE_COSE_CRV_MAP[pubAreaCurveID] + const pubAreaCurveIDMapToCOSECRV = TPM_ECC_CURVE_COSE_CRV_MAP[pubAreaCurveID]; if (pubAreaCurveIDMapToCOSECRV !== crv) { throw new Error( `Public area key curve ID "${pubAreaCurveID}" mapped to "${pubAreaCurveIDMapToCOSECRV}" which did not match public key crv of "${crv}" (TPM|ECC)`, @@ -138,25 +173,24 @@ export async function verifyAttestationTPM(options: AttestationFormatVerifierOpt } // Hash pubArea to create pubAreaHash using the nameAlg in attested - const pubAreaHash = toHash(pubArea, attested.nameAlg.replace('TPM_ALG_', '')); + const pubAreaHash = await toHash(pubArea, attestedNameAlgToCOSEAlg(attested.nameAlg)); // Concatenate attested.nameAlg and pubAreaHash to create attestedName. - const attestedName = Buffer.concat([attested.nameAlgBuffer, pubAreaHash]); + const attestedName = isoUint8Array.concat([attested.nameAlgBuffer, pubAreaHash]); // Check that certInfo.attested.name is equals to attestedName. - if (!attested.name.equals(attestedName)) { + if (!isoUint8Array.areEqual(attested.name, attestedName)) { throw new Error(`Attested name comparison failed (TPM)`); } // Concatenate authData with clientDataHash to create attToBeSigned - const attToBeSigned = Buffer.concat([authData, clientDataHash]); + const attToBeSigned = isoUint8Array.concat([authData, clientDataHash]); // Hash attToBeSigned using the algorithm specified in attStmt.alg to create attToBeSignedHash - const hashAlg: string = COSEALGHASH[alg as number]; - const attToBeSignedHash = toHash(attToBeSigned, hashAlg); + const attToBeSignedHash = await toHash(attToBeSigned, alg); // Check that certInfo.extraData is equals to attToBeSignedHash. - if (!extraData.equals(attToBeSignedHash)) { + if (!isoUint8Array.areEqual(extraData, attToBeSignedHash)) { throw new Error('CertInfo extra data did not equal hashed attestation (TPM)'); } @@ -281,9 +315,9 @@ export async function verifyAttestationTPM(options: AttestationFormatVerifierOpt // In the wise words of Yuriy Ackermann: "Get Martini friend, you are done!" return verifySignature({ signature: sig, - signatureBase: certInfo, - leafCert: x5c[0], - hashAlgorithm: hashAlg + data: certInfo, + leafCertificate: x5c[0], + attestationHashAlgorithm: alg, }); } @@ -349,3 +383,24 @@ function getTcgAtTpmValues(root: Name): { tcgAtTpmVersion, }; } + +/** + * Convert TPM-specific SHA algorithm ID's with COSE-specific equivalents. Note that the choice to + * use ECDSA SHA IDs is arbitrary; any such COSEALG that would map to SHA-256 in + * `mapCoseAlgToWebCryptoAlg()` + * + * SHA IDs referenced from here: + * + * https://trustedcomputinggroup.org/wp-content/uploads/TCG_TPM2_r1p59_Part2_Structures_pub.pdf + */ +function attestedNameAlgToCOSEAlg(alg: string): COSEALG { + if (alg === 'TPM_ALG_SHA256') { + return COSEALG.ES256; + } else if (alg === 'TPM_ALG_SHA384') { + return COSEALG.ES384; + } else if (alg === 'TPM_ALG_SHA512') { + return COSEALG.ES512; + } + + throw new Error(`Unexpected TPM attested name alg ${alg}`); +} diff --git a/packages/server/src/registration/verifications/verifyAttestationAndroidKey.test.ts b/packages/server/src/registration/verifications/verifyAttestationAndroidKey.test.ts index f7cdd4f..dd0a488 100644 --- a/packages/server/src/registration/verifications/verifyAttestationAndroidKey.test.ts +++ b/packages/server/src/registration/verifications/verifyAttestationAndroidKey.test.ts @@ -1,6 +1,5 @@ -import base64url from 'base64url'; - import { SettingsService } from '../../services/settingsService'; +import { isoBase64URL } from '../../helpers/iso'; import { verifyRegistrationResponse } from '../verifyRegistrationResponse'; @@ -12,7 +11,7 @@ SettingsService.setRootCertificates({ identifier: 'android-key', certificates: [ test('should verify Android KeyStore response', async () => { const expectedChallenge = '4ab7dfd1-a695-4777-985f-ad2993828e99'; - jest.spyOn(base64url, 'encode').mockReturnValueOnce(expectedChallenge); + jest.spyOn(isoBase64URL, 'fromString').mockReturnValueOnce(expectedChallenge); const verification = await verifyRegistrationResponse({ credential: { id: 'V51GE29tGbhby7sbg1cZ_qL8V8njqEsXpAnwQBobvgw', @@ -25,6 +24,7 @@ test('should verify Android KeyStore response', async () => { }, type: 'public-key', clientExtensionResults: {}, + authenticatorAttachment: '', }, expectedChallenge, expectedOrigin: 'https://dev.dontneeda.pw', diff --git a/packages/server/src/registration/verifications/verifyAttestationAndroidKey.ts b/packages/server/src/registration/verifications/verifyAttestationAndroidKey.ts index 55a0612..1f3eb83 100644 --- a/packages/server/src/registration/verifications/verifyAttestationAndroidKey.ts +++ b/packages/server/src/registration/verifications/verifyAttestationAndroidKey.ts @@ -7,7 +7,9 @@ import type { AttestationFormatVerifierOpts } from '../verifyRegistrationRespons import { convertCertBufferToPEM } from '../../helpers/convertCertBufferToPEM'; import { validateCertificatePath } from '../../helpers/validateCertificatePath'; import { verifySignature } from '../../helpers/verifySignature'; -import { COSEALGHASH, convertCOSEtoPKCS } from '../../helpers/convertCOSEtoPKCS'; +import { convertCOSEtoPKCS } from '../../helpers/convertCOSEtoPKCS'; +import { isCOSEAlg } from '../../helpers/cose'; +import { isoUint8Array } from '../../helpers/iso'; import { MetadataService } from '../../services/metadataService'; import { verifyAttestationWithMetadata } from '../../metadata/verifyAttestationWithMetadata'; @@ -19,7 +21,9 @@ export async function verifyAttestationAndroidKey( ): Promise<boolean> { const { authData, clientDataHash, attStmt, credentialPublicKey, aaguid, rootCertificates } = options; - const { x5c, sig, alg } = attStmt; + const x5c = attStmt.get('x5c'); + const sig = attStmt.get('sig'); + const alg = attStmt.get('alg'); if (!x5c) { throw new Error('No attestation certificate provided in attestation statement (AndroidKey)'); @@ -33,17 +37,21 @@ export async function verifyAttestationAndroidKey( throw new Error(`Attestation statement did not contain alg (AndroidKey)`); } + if (!isCOSEAlg(alg)) { + throw new Error(`Attestation statement contained invalid alg ${alg} (AndroidKey)`); + } + // Check that credentialPublicKey matches the public key in the attestation certificate // Find the public cert in the certificate as PKCS const parsedCert = AsnParser.parse(x5c[0], Certificate); - const parsedCertPubKey = Buffer.from( + const parsedCertPubKey = new Uint8Array( parsedCert.tbsCertificate.subjectPublicKeyInfo.subjectPublicKey, ); // Convert the credentialPublicKey to PKCS const credPubKeyPKCS = convertCOSEtoPKCS(credentialPublicKey); - if (!credPubKeyPKCS.equals(parsedCertPubKey)) { + if (!isoUint8Array.areEqual(credPubKeyPKCS, parsedCertPubKey)) { throw new Error('Credential public key does not equal leaf cert public key (AndroidKey)'); } @@ -61,7 +69,7 @@ export async function verifyAttestationAndroidKey( // Verify extKeyStore values const { attestationChallenge, teeEnforced, softwareEnforced } = parsedExtKeyStore; - if (!Buffer.from(attestationChallenge.buffer).equals(clientDataHash)) { + if (!isoUint8Array.areEqual(new Uint8Array(attestationChallenge.buffer), clientDataHash)) { throw new Error('Attestation challenge was not equal to client data hash (AndroidKey)'); } @@ -98,13 +106,12 @@ export async function verifyAttestationAndroidKey( } } - const signatureBase = Buffer.concat([authData, clientDataHash]); - const hashAlg = COSEALGHASH[alg as number]; + const signatureBase = isoUint8Array.concat([authData, clientDataHash]); return verifySignature({ signature: sig, - signatureBase, - leafCert: x5c[0], - hashAlgorithm: hashAlg + data: signatureBase, + leafCertificate: x5c[0], + attestationHashAlgorithm: alg, }); } diff --git a/packages/server/src/registration/verifications/verifyAttestationAndroidSafetyNet.test.ts b/packages/server/src/registration/verifications/verifyAttestationAndroidSafetyNet.test.ts index 5df3bee..0e7edb3 100644 --- a/packages/server/src/registration/verifications/verifyAttestationAndroidSafetyNet.test.ts +++ b/packages/server/src/registration/verifications/verifyAttestationAndroidSafetyNet.test.ts @@ -1,5 +1,3 @@ -import base64url from 'base64url'; - import { verifyAttestationAndroidSafetyNet } from './verifyAttestationAndroidSafetyNet'; import { @@ -8,35 +6,38 @@ import { } from '../../helpers/decodeAttestationObject'; import { parseAuthenticatorData } from '../../helpers/parseAuthenticatorData'; import { toHash } from '../../helpers/toHash'; +import { isoBase64URL } from '../../helpers/iso'; import { SettingsService } from '../../services/settingsService'; const rootCertificates = SettingsService.getRootCertificates({ identifier: 'android-safetynet', }); -let authData: Buffer; +let authData: Uint8Array; let attStmt: AttestationStatement; -let clientDataHash: Buffer; -let aaguid: Buffer; -let credentialID: Buffer; -let credentialPublicKey: Buffer; -let rpIdHash: Buffer; +let clientDataHash: Uint8Array; +let aaguid: Uint8Array; +let credentialID: Uint8Array; +let credentialPublicKey: Uint8Array; +let rpIdHash: Uint8Array; let spyDate: jest.SpyInstance; -beforeEach(() => { +beforeEach(async () => { const { attestationObject, clientDataJSON } = attestationAndroidSafetyNet.response; - const decodedAttestationObject = decodeAttestationObject(base64url.toBuffer(attestationObject)); + const decodedAttestationObject = decodeAttestationObject( + isoBase64URL.toBuffer(attestationObject), + ); - authData = decodedAttestationObject.authData; - attStmt = decodedAttestationObject.attStmt; - clientDataHash = toHash(base64url.toBuffer(clientDataJSON)); + authData = decodedAttestationObject.get('authData'); + attStmt = decodedAttestationObject.get('attStmt'); + clientDataHash = await toHash(isoBase64URL.toBuffer(clientDataJSON)); const parsedAuthData = parseAuthenticatorData(authData); aaguid = parsedAuthData.aaguid!; credentialID = parsedAuthData.credentialID!; credentialPublicKey = parsedAuthData.credentialPublicKey!; - spyDate = jest.spyOn(global.Date, 'now'); + spyDate = jest.spyOn(globalThis.Date, 'now'); }); afterEach(() => { @@ -88,11 +89,13 @@ test('should validate response with cert path completed with GlobalSign R1 root spyDate.mockReturnValue(new Date('2021-11-15T00:00:42.000Z')); const { attestationObject, clientDataJSON } = safetyNetUsingGSR1RootCert.response; - const decodedAttestationObject = decodeAttestationObject(base64url.toBuffer(attestationObject)); + const decodedAttestationObject = decodeAttestationObject( + isoBase64URL.toBuffer(attestationObject), + ); - const _authData = decodedAttestationObject.authData; - const _attStmt = decodedAttestationObject.attStmt; - const _clientDataHash = toHash(base64url.toBuffer(clientDataJSON)); + const _authData = decodedAttestationObject.get('authData'); + const _attStmt = decodedAttestationObject.get('attStmt'); + const _clientDataHash = await toHash(isoBase64URL.toBuffer(clientDataJSON)); const parsedAuthData = parseAuthenticatorData(_authData); const _aaguid = parsedAuthData.aaguid!; diff --git a/packages/server/src/registration/verifications/verifyAttestationAndroidSafetyNet.ts b/packages/server/src/registration/verifications/verifyAttestationAndroidSafetyNet.ts index 4c1e685..d47dd70 100644 --- a/packages/server/src/registration/verifications/verifyAttestationAndroidSafetyNet.ts +++ b/packages/server/src/registration/verifications/verifyAttestationAndroidSafetyNet.ts @@ -1,5 +1,3 @@ -import base64url from 'base64url'; - import type { AttestationFormatVerifierOpts } from '../verifyRegistrationResponse'; import { toHash } from '../../helpers/toHash'; @@ -7,6 +5,7 @@ import { verifySignature } from '../../helpers/verifySignature'; import { getCertificateInfo } from '../../helpers/getCertificateInfo'; import { validateCertificatePath } from '../../helpers/validateCertificatePath'; import { convertCertBufferToPEM } from '../../helpers/convertCertBufferToPEM'; +import { isoUint8Array, isoBase64URL } from '../../helpers/iso'; import { MetadataService } from '../../services/metadataService'; import { verifyAttestationWithMetadata } from '../../metadata/verifyAttestationWithMetadata'; @@ -25,7 +24,9 @@ export async function verifyAttestationAndroidSafetyNet( verifyTimestampMS = true, credentialPublicKey, } = options; - const { alg, response, ver } = attStmt; + const alg = attStmt.get('alg'); + const response = attStmt.get('response'); + const ver = attStmt.get('ver'); if (!ver) { throw new Error('No ver value in attestation (SafetyNet)'); @@ -36,11 +37,11 @@ export async function verifyAttestationAndroidSafetyNet( } // Prepare to verify a JWT - const jwt = response.toString('utf8'); + const jwt = isoUint8Array.toUTF8String(response); const jwtParts = jwt.split('.'); - const HEADER: SafetyNetJWTHeader = JSON.parse(base64url.decode(jwtParts[0])); - const PAYLOAD: SafetyNetJWTPayload = JSON.parse(base64url.decode(jwtParts[1])); + const HEADER: SafetyNetJWTHeader = JSON.parse(isoBase64URL.toString(jwtParts[0])); + const PAYLOAD: SafetyNetJWTPayload = JSON.parse(isoBase64URL.toString(jwtParts[1])); const SIGNATURE: SafetyNetJWTSignature = jwtParts[2]; /** @@ -63,9 +64,9 @@ export async function verifyAttestationAndroidSafetyNet( } } - const nonceBase = Buffer.concat([authData, clientDataHash]); - const nonceBuffer = toHash(nonceBase); - const expectedNonce = nonceBuffer.toString('base64'); + const nonceBase = isoUint8Array.concat([authData, clientDataHash]); + const nonceBuffer = await toHash(nonceBase); + const expectedNonce = isoBase64URL.fromBuffer(nonceBuffer, 'base64'); if (nonce !== expectedNonce) { throw new Error('Could not verify payload nonce (SafetyNet)'); @@ -81,7 +82,8 @@ export async function verifyAttestationAndroidSafetyNet( /** * START Verify Header */ - const leafCertBuffer = base64url.toBuffer(HEADER.x5c[0]); + // `HEADER.x5c[0]` is definitely a base64 string + const leafCertBuffer = isoBase64URL.toBuffer(HEADER.x5c[0], 'base64'); const leafCertInfo = getCertificateInfo(leafCertBuffer); const { subject } = leafCertInfo; @@ -121,13 +123,13 @@ export async function verifyAttestationAndroidSafetyNet( /** * START Verify Signature */ - const signatureBaseBuffer = Buffer.from(`${jwtParts[0]}.${jwtParts[1]}`); - const signatureBuffer = base64url.toBuffer(SIGNATURE); + const signatureBaseBuffer = isoUint8Array.fromUTF8String(`${jwtParts[0]}.${jwtParts[1]}`); + const signatureBuffer = isoBase64URL.toBuffer(SIGNATURE); const verified = await verifySignature({ signature: signatureBuffer, - signatureBase: signatureBaseBuffer, - leafCert: leafCertBuffer, + data: signatureBaseBuffer, + leafCertificate: leafCertBuffer, }); /** * END Verify Signature diff --git a/packages/server/src/registration/verifications/verifyAttestationApple.test.ts b/packages/server/src/registration/verifications/verifyAttestationApple.test.ts index c2d4a49..1459df1 100644 --- a/packages/server/src/registration/verifications/verifyAttestationApple.test.ts +++ b/packages/server/src/registration/verifications/verifyAttestationApple.test.ts @@ -1,10 +1,10 @@ -import base64url from 'base64url'; +// TODO: This test can take upwards of 7 seconds to complete locally, more in CI...need to figure +// out why +jest.setTimeout(30000); import { verifyRegistrationResponse } from '../verifyRegistrationResponse'; test('should verify Apple attestation', async () => { - const expectedChallenge = 'h5xSyIRMx2IQPr1mQk6GD98XSQOBHgMHVpJIkMV9Nkc'; - jest.spyOn(base64url, 'encode').mockReturnValueOnce(expectedChallenge); const verification = await verifyRegistrationResponse({ credential: { id: 'J4lAqPXhefDrUD7oh5LQMbBH5TE', @@ -17,8 +17,9 @@ test('should verify Apple attestation', async () => { }, type: 'public-key', clientExtensionResults: {}, + authenticatorAttachment: '', }, - expectedChallenge, + expectedChallenge: 'h5xSyIRMx2IQPr1mQk6GD98XSQOBHgMHVpJIkMV9Nkc', expectedOrigin: 'https://dev.dontneeda.pw', expectedRPID: 'dev.dontneeda.pw', }); diff --git a/packages/server/src/registration/verifications/verifyAttestationApple.ts b/packages/server/src/registration/verifications/verifyAttestationApple.ts index e0c7890..4aae99b 100644 --- a/packages/server/src/registration/verifications/verifyAttestationApple.ts +++ b/packages/server/src/registration/verifications/verifyAttestationApple.ts @@ -7,12 +7,13 @@ import { validateCertificatePath } from '../../helpers/validateCertificatePath'; import { convertCertBufferToPEM } from '../../helpers/convertCertBufferToPEM'; import { toHash } from '../../helpers/toHash'; import { convertCOSEtoPKCS } from '../../helpers/convertCOSEtoPKCS'; +import { isoUint8Array } from '../../helpers/iso'; export async function verifyAttestationApple( options: AttestationFormatVerifierOpts, ): Promise<boolean> { const { attStmt, authData, clientDataHash, credentialPublicKey, rootCertificates } = options; - const { x5c } = attStmt; + const x5c = attStmt.get('x5c'); if (!x5c) { throw new Error('No attestation certificate provided in attestation statement (Apple)'); @@ -44,8 +45,8 @@ export async function verifyAttestationApple( throw new Error('credCert missing "1.2.840.113635.100.8.2" extension (Apple)'); } - const nonceToHash = Buffer.concat([authData, clientDataHash]); - const nonce = toHash(nonceToHash, 'SHA256'); + const nonceToHash = isoUint8Array.concat([authData, clientDataHash]); + const nonce = await toHash(nonceToHash); /** * Ignore the first six ASN.1 structure bytes that define the nonce as an OCTET STRING. Should * trim off <Buffer 30 24 a1 22 04 20> @@ -53,9 +54,9 @@ export async function verifyAttestationApple( * TODO: Try and get @peculiar (GitHub) to add a schema for "1.2.840.113635.100.8.2" when we * find out where it's defined (doesn't seem to be publicly documented at the moment...) */ - const extNonce = Buffer.from(extCertNonce.extnValue.buffer).slice(6); + const extNonce = new Uint8Array(extCertNonce.extnValue.buffer).slice(6); - if (!nonce.equals(extNonce)) { + if (!isoUint8Array.areEqual(nonce, extNonce)) { throw new Error(`credCert nonce was not expected value (Apple)`); } @@ -63,9 +64,9 @@ export async function verifyAttestationApple( * Verify credential public key matches the Subject Public Key of credCert */ const credPubKeyPKCS = convertCOSEtoPKCS(credentialPublicKey); - const credCertSubjectPublicKey = Buffer.from(subjectPublicKeyInfo.subjectPublicKey); + const credCertSubjectPublicKey = new Uint8Array(subjectPublicKeyInfo.subjectPublicKey); - if (!credPubKeyPKCS.equals(credCertSubjectPublicKey)) { + if (!isoUint8Array.areEqual(credPubKeyPKCS, credCertSubjectPublicKey)) { throw new Error('Credential public key does not equal credCert public key (Apple)'); } diff --git a/packages/server/src/registration/verifications/verifyAttestationFIDOU2F.ts b/packages/server/src/registration/verifications/verifyAttestationFIDOU2F.ts index 3c79b9e..e271e48 100644 --- a/packages/server/src/registration/verifications/verifyAttestationFIDOU2F.ts +++ b/packages/server/src/registration/verifications/verifyAttestationFIDOU2F.ts @@ -4,6 +4,8 @@ import { convertCOSEtoPKCS } from '../../helpers/convertCOSEtoPKCS'; import { convertCertBufferToPEM } from '../../helpers/convertCertBufferToPEM'; import { validateCertificatePath } from '../../helpers/validateCertificatePath'; import { verifySignature } from '../../helpers/verifySignature'; +import { isoUint8Array } from '../../helpers/iso'; +import { COSEALG } from '../../helpers/cose'; /** * Verify an attestation response with fmt 'fido-u2f' @@ -17,14 +19,14 @@ export async function verifyAttestationFIDOU2F( rpIdHash, credentialID, credentialPublicKey, - aaguid = '', + aaguid, rootCertificates, } = options; - const reservedByte = Buffer.from([0x00]); + const reservedByte = Uint8Array.from([0x00]); const publicKey = convertCOSEtoPKCS(credentialPublicKey); - const signatureBase = Buffer.concat([ + const signatureBase = isoUint8Array.concat([ reservedByte, rpIdHash, clientDataHash, @@ -32,7 +34,8 @@ export async function verifyAttestationFIDOU2F( publicKey, ]); - const { sig, x5c } = attStmt; + const sig = attStmt.get('sig'); + const x5c = attStmt.get('x5c'); if (!x5c) { throw new Error('No attestation certificate provided in attestation statement (FIDOU2F)'); @@ -43,7 +46,7 @@ export async function verifyAttestationFIDOU2F( } // FIDO spec says that aaguid _must_ equal 0x00 here to be legit - const aaguidToHex = Number.parseInt(aaguid.toString('hex'), 16); + const aaguidToHex = Number.parseInt(isoUint8Array.toHex(aaguid), 16); if (aaguidToHex !== 0x00) { throw new Error(`AAGUID "${aaguidToHex}" was not expected value`); } @@ -58,7 +61,8 @@ export async function verifyAttestationFIDOU2F( return verifySignature({ signature: sig, - signatureBase, - leafCert: x5c[0], + data: signatureBase, + leafCertificate: x5c[0], + attestationHashAlgorithm: COSEALG.ES256, }); } diff --git a/packages/server/src/registration/verifications/verifyAttestationPacked.test.ts b/packages/server/src/registration/verifications/verifyAttestationPacked.test.ts index b38a0e6..f554ae4 100644 --- a/packages/server/src/registration/verifications/verifyAttestationPacked.test.ts +++ b/packages/server/src/registration/verifications/verifyAttestationPacked.test.ts @@ -24,6 +24,7 @@ test('should verify (broken) Packed response from Chrome virtual authenticator', type: 'public-key', clientExtensionResults: {}, transports: ['usb'], + authenticatorAttachment: '', }, expectedChallenge: '9GIs0QQBna16ycw4stSnAqh2Ab6AiH7SS0_Xm4yJ1zk', expectedOrigin: 'https://dev.dontneeda.pw', diff --git a/packages/server/src/registration/verifications/verifyAttestationPacked.ts b/packages/server/src/registration/verifications/verifyAttestationPacked.ts index 02beebc..a57bf13 100644 --- a/packages/server/src/registration/verifications/verifyAttestationPacked.ts +++ b/packages/server/src/registration/verifications/verifyAttestationPacked.ts @@ -1,10 +1,11 @@ import type { AttestationFormatVerifierOpts } from '../verifyRegistrationResponse'; -import { COSEALGHASH } from '../../helpers/convertCOSEtoPKCS'; +import { isCOSEAlg } from '../../helpers/cose'; import { convertCertBufferToPEM } from '../../helpers/convertCertBufferToPEM'; import { validateCertificatePath } from '../../helpers/validateCertificatePath'; import { getCertificateInfo } from '../../helpers/getCertificateInfo'; import { verifySignature } from '../../helpers/verifySignature'; +import { isoUint8Array } from '../../helpers/iso'; import { MetadataService } from '../../services/metadataService'; import { verifyAttestationWithMetadata } from '../../metadata/verifyAttestationWithMetadata'; @@ -17,17 +18,23 @@ export async function verifyAttestationPacked( const { attStmt, clientDataHash, authData, credentialPublicKey, aaguid, rootCertificates } = options; - const { sig, x5c, alg } = attStmt; + const sig = attStmt.get('sig'); + const x5c = attStmt.get('x5c'); + const alg = attStmt.get('alg'); if (!sig) { throw new Error('No attestation signature provided in attestation statement (Packed)'); } - if (typeof alg !== 'number') { - throw new Error(`Attestation Statement alg "${alg}" is not a number (Packed)`); + if (!alg) { + throw new Error('Attestation statement did not contain alg (Packed)'); } - const signatureBase = Buffer.concat([authData, clientDataHash]); + if (!isCOSEAlg(alg)) { + throw new Error(`Attestation statement contained invalid alg ${alg} (Packed)`); + } + + const signatureBase = isoUint8Array.concat([authData, clientDataHash]); let verified = false; @@ -107,17 +114,15 @@ export async function verifyAttestationPacked( verified = await verifySignature({ signature: sig, - signatureBase, - leafCert: x5c[0], + data: signatureBase, + leafCertificate: x5c[0], }); } else { - const hashAlg: string = COSEALGHASH[alg as number]; - verified = await verifySignature({ signature: sig, - signatureBase, + data: signatureBase, credentialPublicKey, - hashAlgorithm: hashAlg + attestationHashAlgorithm: alg, }); } diff --git a/packages/server/src/registration/verifyRegistrationResponse.test.ts b/packages/server/src/registration/verifyRegistrationResponse.test.ts index 21562bf..df4b1dd 100644 --- a/packages/server/src/registration/verifyRegistrationResponse.test.ts +++ b/packages/server/src/registration/verifyRegistrationResponse.test.ts @@ -1,4 +1,4 @@ -import base64url from 'base64url'; +import { RegistrationCredentialJSON } from '@simplewebauthn/typescript-types'; import { verifyRegistrationResponse } from './verifyRegistrationResponse'; @@ -6,23 +6,23 @@ import * as esmDecodeAttestationObject from '../helpers/decodeAttestationObject' import * as esmDecodeClientDataJSON from '../helpers/decodeClientDataJSON'; import * as esmParseAuthenticatorData from '../helpers/parseAuthenticatorData'; import * as esmDecodeCredentialPublicKey from '../helpers/decodeCredentialPublicKey'; +import { toHash } from '../helpers/toHash'; +import { isoBase64URL, isoUint8Array } from '../helpers/iso'; +import { COSEPublicKey, COSEKEYS } from '../helpers/cose'; import { SettingsService } from '../services/settingsService'; import * as esmVerifyAttestationFIDOU2F from './verifications/verifyAttestationFIDOU2F'; -import { toHash } from '../helpers/toHash'; -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({ identifier: 'android-key', certificates: [] }); -let mockDecodeAttestation: jest.SpyInstance; +let mockDecodeAttestation: jest.SpyInstance<esmDecodeAttestationObject.AttestationObject>; let mockDecodeClientData: jest.SpyInstance; let mockParseAuthData: jest.SpyInstance; -let mockDecodePubKey: jest.SpyInstance; +let mockDecodePubKey: jest.SpyInstance<COSEPublicKey>; let mockVerifyFIDOU2F: jest.SpyInstance; beforeEach(() => { @@ -53,12 +53,12 @@ test('should verify FIDO U2F attestation', async () => { expect(verification.registrationInfo?.fmt).toEqual('fido-u2f'); expect(verification.registrationInfo?.counter).toEqual(0); expect(verification.registrationInfo?.credentialPublicKey).toEqual( - base64url.toBuffer( + isoBase64URL.toBuffer( 'pQECAyYgASFYIMiRyw5pUoMhBjCrcQND6lJPaRHA0f-XWcKBb5ZwWk1eIlggFJu6aan4o7epl6qa9n9T-6KsIMvZE2PcTnLj8rN58is', ), ); expect(verification.registrationInfo?.credentialID).toEqual( - base64url.toBuffer( + isoBase64URL.toBuffer( 'VHzbxaYaJu2P8m1Y2iHn2gRNHrgK0iYbn9E978L3Qi7Q-chFeicIHwYCRophz5lth2nCgEVKcgWirxlgidgbUQ', ), ); @@ -66,7 +66,7 @@ test('should verify FIDO U2F attestation', async () => { expect(verification.registrationInfo?.credentialType).toEqual('public-key'); expect(verification.registrationInfo?.userVerified).toEqual(false); expect(verification.registrationInfo?.attestationObject).toEqual( - base64url.toBuffer(attestationFIDOU2F.response.attestationObject), + isoBase64URL.toBuffer(attestationFIDOU2F.response.attestationObject), ); }); @@ -82,12 +82,12 @@ test('should verify Packed (EC2) attestation', async () => { expect(verification.registrationInfo?.fmt).toEqual('packed'); expect(verification.registrationInfo?.counter).toEqual(1589874425); expect(verification.registrationInfo?.credentialPublicKey).toEqual( - base64url.toBuffer( + isoBase64URL.toBuffer( 'pQECAyYgASFYIEoxVVqK-oIGmqoDEyO4KjmMx5R2HeMM4LQQXh8sE01PIlggtzuuoMN5fWnAIuuXdlfshOGu1k3ApBUtDJ8eKiuo_6c', ), ); expect(verification.registrationInfo?.credentialID).toEqual( - base64url.toBuffer( + isoBase64URL.toBuffer( 'AYThY1csINY4JrbHyGmqTl1nL_F1zjAF3hSAIngz8kAcjugmAMNVvxZRwqpEH-bNHHAIv291OX5ko9eDf_5mu3U' + 'B2BvsScr2K-ppM4owOpGsqwg5tZglqqmxIm1Q', ), @@ -106,12 +106,12 @@ test('should verify Packed (X5C) attestation', async () => { expect(verification.registrationInfo?.fmt).toEqual('packed'); expect(verification.registrationInfo?.counter).toEqual(28); expect(verification.registrationInfo?.credentialPublicKey).toEqual( - base64url.toBuffer( + isoBase64URL.toBuffer( 'pQECAyYgASFYIGwlsYCNyRb4AD9cyTw6cH5VS-uzflmmO1UldGGe9eIaIlggvadzKD8p6wKLjgYfxRxldjCMGRV0YyM13osWbKIPrF8', ), ); expect(verification.registrationInfo?.credentialID).toEqual( - base64url.toBuffer( + isoBase64URL.toBuffer( '4rrvMciHCkdLQ2HghazIp1sMc8TmV8W8RgoX-x8tqV_1AmlqWACqUK8mBGLandr-htduQKPzgb2yWxOFV56Tlg', ), ); @@ -129,12 +129,12 @@ test('should verify None attestation', async () => { expect(verification.registrationInfo?.fmt).toEqual('none'); expect(verification.registrationInfo?.counter).toEqual(0); expect(verification.registrationInfo?.credentialPublicKey).toEqual( - base64url.toBuffer( + isoBase64URL.toBuffer( 'pQECAyYgASFYID5PQTZQQg6haZFQWFzqfAOyQ_ENsMH8xxQ4GRiNPsqrIlggU8IVUOV8qpgk_Jh-OTaLuZL52KdX1fTht07X4DiQPow', ), ); expect(verification.registrationInfo?.credentialID).toEqual( - base64url.toBuffer( + isoBase64URL.toBuffer( 'AdKXJEch1aV5Wo7bj7qLHskVY4OoNaj9qu8TPdJ7kSAgUeRxWNngXlcNIGt4gexZGKVGcqZpqqWordXb_he1izY', ), ); @@ -154,6 +154,7 @@ test('should verify None attestation w/RSA public key', async () => { }, type: 'public-key', clientExtensionResults: {}, + authenticatorAttachment: '', }, expectedChallenge, expectedOrigin: 'https://dev.dontneeda.pw', @@ -164,12 +165,12 @@ test('should verify None attestation w/RSA public key', async () => { expect(verification.registrationInfo?.fmt).toEqual('none'); expect(verification.registrationInfo?.counter).toEqual(0); expect(verification.registrationInfo?.credentialPublicKey).toEqual( - base64url.toBuffer( + isoBase64URL.toBuffer( 'pAEDAzkBACBZAQDxfpXrj0ba_AH30JJ_-W7BHSOPugOD8aEDdNBKc1gjB9AmV3FPl2aL0fwiOMKtM_byI24qXb2FzcyjC7HUVkHRtzkAQnahXckI4wY_01koaY6iwXuIE3Ya0Zjs2iZyz6u4G_abGnWdObqa_kHxc3CHR7Xy5MDkAkKyX6TqU0tgHZcEhDd_Lb5ONJDwg4wvKlZBtZYElfMuZ6lonoRZ7qR_81rGkDZyFaxp6RlyvzEbo4ijeIaHQylqCz-oFm03ifZMOfRHYuF4uTjJDRH-g4BW1f3rdi7DTHk1hJnIw1IyL_VFIQ9NifkAguYjNCySCUNpYli2eMrPhAu5dYJFFjINIUMBAAE', ), ); expect(verification.registrationInfo?.credentialID).toEqual( - base64url.toBuffer('kGXv4RJWLeXRw8Yf3T22K3Gq_GGeDv9OKYmAHLm0Ylo'), + isoBase64URL.toBuffer('kGXv4RJWLeXRw8Yf3T22K3Gq_GGeDv9OKYmAHLm0Ylo'), ); }); @@ -217,17 +218,13 @@ test('should throw when attestation type is not webauthn.create', async () => { }); test('should throw if an unexpected attestation format is specified', async () => { - const fmt = 'fizzbuzz'; - const realAtteObj = esmDecodeAttestationObject.decodeAttestationObject( - base64url.toBuffer(attestationNone.response.attestationObject), + isoBase64URL.toBuffer(attestationNone.response.attestationObject), ); + // Mangle the fmt + (realAtteObj as Map<unknown, unknown>).set('fmt', 'fizzbuzz'); - mockDecodeAttestation.mockReturnValue({ - ...realAtteObj, - // @ts-ignore 2322 - fmt, - }); + mockDecodeAttestation.mockReturnValue(realAtteObj); await expect( verifyRegistrationResponse({ @@ -240,14 +237,14 @@ test('should throw if an unexpected attestation format is specified', async () = }); test('should throw error if assertion RP ID is unexpected value', async () => { - const { authData } = esmDecodeAttestationObject.decodeAttestationObject( - base64url.toBuffer(attestationNone.response.attestationObject), - ); + const authData = esmDecodeAttestationObject + .decodeAttestationObject(isoBase64URL.toBuffer(attestationNone.response.attestationObject)) + .get('authData'); const actualAuthData = esmParseAuthenticatorData.parseAuthenticatorData(authData); mockParseAuthData.mockReturnValue({ ...actualAuthData, - rpIdHash: toHash(Buffer.from('bad.url', 'ascii')), + rpIdHash: await toHash(Buffer.from('bad.url', 'ascii')), }); await expect( @@ -262,7 +259,7 @@ test('should throw error if assertion RP ID is unexpected value', async () => { test('should throw error if user was not present', async () => { mockParseAuthData.mockReturnValue({ - rpIdHash: toHash(Buffer.from('dev.dontneeda.pw', 'ascii')), + rpIdHash: await toHash(Buffer.from('dev.dontneeda.pw', 'ascii')), flags: { up: false, }, @@ -280,7 +277,7 @@ test('should throw error if user was not present', async () => { test('should throw if the authenticator does not give back credential ID', async () => { mockParseAuthData.mockReturnValue({ - rpIdHash: toHash(Buffer.from('dev.dontneeda.pw', 'ascii')), + rpIdHash: await toHash(Buffer.from('dev.dontneeda.pw', 'ascii')), flags: { up: true, }, @@ -299,7 +296,7 @@ test('should throw if the authenticator does not give back credential ID', async test('should throw if the authenticator does not give back credential public key', async () => { mockParseAuthData.mockReturnValue({ - rpIdHash: toHash(Buffer.from('dev.dontneeda.pw', 'ascii')), + rpIdHash: await toHash(Buffer.from('dev.dontneeda.pw', 'ascii')), flags: { up: true, }, @@ -318,11 +315,8 @@ test('should throw if the authenticator does not give back credential public key }); test('should throw error if no alg is specified in public key', async () => { - mockDecodePubKey.mockReturnValue({ - get: () => undefined, - credentialID: '', - credentialPublicKey: '', - }); + const pubKey = new Map(); + mockDecodePubKey.mockReturnValue(pubKey); await expect( verifyRegistrationResponse({ @@ -335,11 +329,9 @@ test('should throw error if no alg is specified in public key', async () => { }); test('should throw error if unsupported alg is used', async () => { - mockDecodePubKey.mockReturnValue({ - get: () => -999, - credentialID: '', - credentialPublicKey: '', - }); + const pubKey = new Map(); + pubKey.set(COSEKEYS.alg, -999); + mockDecodePubKey.mockReturnValue(pubKey); await expect( verifyRegistrationResponse({ @@ -367,7 +359,7 @@ test('should not include authenticator info if not verified', async () => { test('should throw an error if user verification is required but user was not verified', async () => { mockParseAuthData.mockReturnValue({ - rpIdHash: toHash(Buffer.from('dev.dontneeda.pw', 'ascii')), + rpIdHash: await toHash(Buffer.from('dev.dontneeda.pw', 'ascii')), flags: { up: true, uv: false, @@ -399,6 +391,7 @@ test('should validate TPM RSA response (SHA256)', async () => { }, type: 'public-key', clientExtensionResults: {}, + authenticatorAttachment: '', }, expectedChallenge: expectedChallenge, expectedOrigin: 'https://dev.dontneeda.pw', @@ -409,12 +402,12 @@ test('should validate TPM RSA response (SHA256)', async () => { expect(verification.registrationInfo?.fmt).toEqual('tpm'); expect(verification.registrationInfo?.counter).toEqual(30); expect(verification.registrationInfo?.credentialPublicKey).toEqual( - base64url.toBuffer( + isoBase64URL.toBuffer( 'pAEDAzkBACBZAQCtxzw59Wsl8xWP97wPTu2TSDlushwshL8GedHAHO1R62m3nNy21hCLJlQabfLepRUQ_v9mq3PCmV81tBSqtRGU5_YlK0R2yeu756SnT39c6hKC3PBPt_xdjL_ccz4H_73DunfB63QZOtdeAsswV7WPLqMARofuM-LQ_LHnNguCypDcxhADuUqQtogfwZsknTVIPxzGcfqnQ7ERF9D9AOWIQ8YjOsTi_B2zS8SOySKIFUGwwYcPG7DiCE-QJcI-fpydRDnEq6UxbkYgB7XK4BlmPKlwuXkBDX9egl_Ma4B7W2WJvYbKevu6Z8Kc5y-OITpNVDYKbBK3qKyh4yIUpB1NIUMBAAE', ), ); expect(verification.registrationInfo?.credentialID).toEqual( - base64url.toBuffer('lGkWHPe88VpnNYgVBxzon_MRR9-gmgODveQ16uM_bPM'), + isoBase64URL.toBuffer('lGkWHPe88VpnNYgVBxzon_MRR9-gmgODveQ16uM_bPM'), ); }); @@ -432,6 +425,7 @@ test('should validate TPM RSA response (SHA1)', async () => { }, type: 'public-key', clientExtensionResults: {}, + authenticatorAttachment: '', }, expectedChallenge, expectedOrigin: 'https://dev.dontneeda.pw', @@ -442,12 +436,12 @@ test('should validate TPM RSA response (SHA1)', async () => { expect(verification.registrationInfo?.fmt).toEqual('tpm'); expect(verification.registrationInfo?.counter).toEqual(97); expect(verification.registrationInfo?.credentialPublicKey).toEqual( - base64url.toBuffer( + isoBase64URL.toBuffer( 'pAEDAzn__iBZAQCzl_wD24PZ5z-po2FrwoQVdd13got_CkL8p4B_NvJBC5OwAYKDilii_wj-0CA8ManbpSInx9Tdnz6t91OhudwUT0-W_BHSLK_MqFcjZWrR5LYVmVpz1EgH3DrOTra4AlogEq2D2CYktPrPe7joE-oT3vAYXK8vzQDLRyaxI_Z1qS4KLlLCdajW8PGpw1YRjMDw6s69GZU8mXkgNPMCUh1TZ1bnCvJTO9fnmLjDjqdQGRU4bWo8tFjCL8g1-2WD_2n0-twt6n-Uox5VnR1dQJG4awMlanBCkGGpOb3WBDQ8K10YJJ2evPhJKGJahBvu2Dxmq6pLCAXCv0ma3EHj-PmDIUMBAAE', ), ); expect(verification.registrationInfo?.credentialID).toEqual( - base64url.toBuffer('oELnad0f6-g2BtzEn_78iLNoubarlq0xFtOtAMXnflU'), + isoBase64URL.toBuffer('oELnad0f6-g2BtzEn_78iLNoubarlq0xFtOtAMXnflU'), ); }); @@ -465,6 +459,7 @@ test('should validate Android-Key response', async () => { }, type: 'public-key', clientExtensionResults: {}, + authenticatorAttachment: '', }, expectedChallenge, expectedOrigin: 'https://dev.dontneeda.pw', @@ -475,12 +470,12 @@ test('should validate Android-Key response', async () => { expect(verification.registrationInfo?.fmt).toEqual('android-key'); expect(verification.registrationInfo?.counter).toEqual(108); expect(verification.registrationInfo?.credentialPublicKey).toEqual( - base64url.toBuffer( + isoBase64URL.toBuffer( 'pQECAyYgASFYIEjCq7woGNN_42rbaqMgJvz0nuKTWNRrR29lMX3J239oIlgg6IcAXqPJPIjSrClHDAmbJv_EShYhYq0R9-G3k744n7Y', ), ); expect(verification.registrationInfo?.credentialID).toEqual( - base64url.toBuffer('PPa1spYTB680cQq5q6qBtFuPLLdG1FQ73EastkT8n0o'), + isoBase64URL.toBuffer('PPa1spYTB680cQq5q6qBtFuPLLdG1FQ73EastkT8n0o'), ); }); @@ -543,10 +538,11 @@ test('should pass verification if custom challenge verifier returns true', async type: 'public-key', clientExtensionResults: {}, transports: ['internal'], + authenticatorAttachment: '', }, expectedChallenge: (challenge: string) => { const parsedChallenge: { actualChallenge: string; arbitraryData: string } = JSON.parse( - base64url.decode(challenge), + isoBase64URL.toString(challenge), ); return parsedChallenge.actualChallenge === 'xRsYdCQv5WZOqmxReiZl6C9q5SfrZne4lNSr9QVtPig'; }, @@ -594,6 +590,7 @@ test('should return authenticator extension output', async () => { 'ZkcwcHhLd2lKN2hPazJESlE0eHZLZDQzOFEiLCJhbmRyb2lkUGFja2FnZU5hbWUiOiJjb20uZmlkby5leGFtcGxl' + 'LmZpZG8yYXBpZXhhbXBsZSJ9', }, + authenticatorAttachment: '', clientExtensionResults: {}, type: 'public-key', }, @@ -604,18 +601,79 @@ test('should return authenticator extension output', async () => { expect(verification.registrationInfo?.authenticatorExtensionResults).toMatchObject({ devicePubKey: { - dpk: Buffer.from( + dpk: isoUint8Array.fromHex( 'A5010203262001215820991AABED9DE4271A9EDEAD8806F9DC96D6DCCD0C476253A5510489EC8379BE5B225820A0973CFDEDBB79E27FEF4EE7481673FB3312504DDCA5434CFD23431D6AD29EDA', - 'hex', ), - sig: Buffer.from( + sig: isoUint8Array.fromHex( '3045022100EFB38074BD15B8C82CF09F87FBC6FB3C7169EA4F1806B7E90937374302345B7A02202B7113040731A0E727D338D48542863CE65880AA79E5EA740AC8CCD94347988E', - 'hex', ), - nonce: Buffer.from('', 'hex'), - scope: Buffer.from('00', 'hex'), - aaguid: Buffer.from('00000000000000000000000000000000', 'hex'), + nonce: isoUint8Array.fromHex(''), + scope: isoUint8Array.fromHex('00'), + aaguid: isoUint8Array.fromHex('00000000000000000000000000000000'), + }, + }); +}); + +test('should verify FIDO U2F attestation that specifies SHA-1 in its leaf cert public key', async () => { + const verified = await verifyRegistrationResponse({ + credential: { + id: '7wQcUWO9gG6mi2IktoZUogs8opnghY01DPYwaerMZms', + rawId: '7wQcUWO9gG6mi2IktoZUogs8opnghY01DPYwaerMZms', + response: { + attestationObject: + 'o2NmbXRoZmlkby11MmZnYXR0U3RtdKJjc2lnWEgwRgIhAN2iKnT1qcZPVab9eiXw6kmMqAsCjR8FMdx8DWCfc6h1AiEA8Hp4Fv2eWsokC8g3sL3tEgNEpsopz-G7l30-czGkuvBjeDVjgVkELzCCBCswggIToAMCAQICAQEwDQYJKoZIhvcNAQEFBQAwgaExGDAWBgNVBAMMD0ZJRE8yIFRFU1QgUk9PVDExMC8GCSqGSIb3DQEJARYiY29uZm9ybWFuY2UtdG9vbHNAZmlkb2FsbGlhbmNlLm9yZzEWMBQGA1UECgwNRklETyBBbGxpYW5jZTEMMAoGA1UECwwDQ1dHMQswCQYDVQQGEwJVUzELMAkGA1UECAwCTVkxEjAQBgNVBAcMCVdha2VmaWVsZDAeFw0xODAzMTYxNDM1MjdaFw0yODAzMTMxNDM1MjdaMIGsMSMwIQYDVQQDDBpGSURPMiBCQVRDSCBLRVkgcHJpbWUyNTZ2MTExMC8GCSqGSIb3DQEJARYiY29uZm9ybWFuY2UtdG9vbHNAZmlkb2FsbGlhbmNlLm9yZzEWMBQGA1UECgwNRklETyBBbGxpYW5jZTEMMAoGA1UECwwDQ1dHMQswCQYDVQQGEwJVUzELMAkGA1UECAwCTVkxEjAQBgNVBAcMCVdha2VmaWVsZDBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABE86Xl6rbB-8rpf232RJlnYse-9yAEAqdsbyMPZVbxeqmZtZf8S_UIqvjp7wzQE_Wrm9J5FL8IBDeMvMsRuJtUajLDAqMAkGA1UdEwQCMAAwHQYDVR0OBBYEFFZN98D4xlW2oR9sTRnzv0Hi_QF5MA0GCSqGSIb3DQEBBQUAA4ICAQCPv4yN9RQfvCdl8cwVzLiOGIPrwLatOwARyap0KVJrfJaTs5rydAjinMLav-26bIElQSdus4Z8lnJtavFdGW8VLzdpB_De57XiBp_giTiZBwyCPiG4h-Pk1EAiY7ggednblFi9HxlcNkddyelfiu1Oa9Dlgc5rZsMIkVU4IFW4w6W8dqKhgMM7qRt0ZgRQ19TPdrN7YMsJy6_nujWWpecmXUvFW5SRo7MA2W3WPkKG6Ngwjer8b5-U1ZLpAB4gK46QQaQJrkHymudr6kgmEaUwpue30FGdXNZ9vTrLw8NcfXJMh_I__V4JNABvjJUPUXYN4Qm-y5Ej7wv82A3ktgo_8hcOjlmoZ5yEcDureFLS7kQJC64z9U-55NM7tcIcI-2BMLb2uOZ4lloeq3coP0mZX7KYd6PzGTeQ8Cmkq1GhDum_p7phCx-Rlo44j4H4DypCKH_g-NMWilBQaTSc6K0JAGQiVrh710aQWVhVYf1ITZRoV9Joc9shZQa7o2GvQYLyJHSfCnqJOqnwJ_q-RBBV3EiPLxmOzhBdNUCl1abvPhVtLksbUPfdQHBQ-io70edZe3utb4rFIHboWUSKvW2M3giMZyuSYZt6PzSRNmzqdjZlcFXuJI7iV_O8KNwWuNW14MCKXYi1sliYUhz5iSP9Ym0U2eVzvdsWzz0p55F6xWhhdXRoRGF0YVikSZYN5YgOjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2NBAAAAAgAAAAAAAAAAAAAAAAAAAAAAIO8EHFFjvYBupotiJLaGVKILPKKZ4IWNNQz2MGnqzGZrpQECAyYgASFYIMmWvjddCcHDGxX5F8qRMl1FccFW5R8VQuZOTey6LqA8IlggZLJ8OVPsX-NPDEUjyjzkV1YLW8Nglp1Ea4qgb2n-O88', + clientDataJSON: + 'eyJvcmlnaW4iOiJodHRwOi8vbG9jYWxob3N0OjgwMDAiLCJjaGFsbGVuZ2UiOiJ3SjZtclpua2I2OUdENWQ5X2ZVejktTmdSSEUwejEwcXVYVUJTYTl4SzVvIiwidHlwZSI6IndlYmF1dGhuLmNyZWF0ZSJ9', + }, + authenticatorAttachment: '', + clientExtensionResults: {}, + type: 'public-key', }, + expectedChallenge: 'wJ6mrZnkb69GD5d9_fUz9-NgRHE0z10quXUBSa9xK5o', + expectedOrigin: 'http://localhost:8000', + expectedRPID: 'localhost', + }); +}); + +test('should verify Packed attestation with RSA-PSS SHA-256 public key', async () => { + const verified = await verifyRegistrationResponse({ + credential: { + id: 'n_dmFmW9UL7678vS4A3XSQLXvxWjefEkYVzEB5cNc_Q', + rawId: 'n_dmFmW9UL7678vS4A3XSQLXvxWjefEkYVzEB5cNc_Q', + response: { + attestationObject: + 'o2NmbXRmcGFja2VkZ2F0dFN0bXSiY2FsZzgkY3NpZ1kBAEaJQ9f_DWVWGJMJrHymDCRP7v2cOzeEA8Z1IUsd4GTq65qqg2khO05tKe6QK_NvpWbiLCRJ2E9QiMUu3xGTl7RIrIRp4T2WCjk5tLbLNwsHuFAPyjcuvIlcX2ZsKNL27tTroIz_zbzDk07vf0jhghoS3ec-qKrSZQ-B0ULgyDJf0omzgDRlH6uon7mErtunes9hVDUTn9pG9UJSL-jDptoJyu87NnBFGnlpu-Iur1lMKIEW27m5E7wYxF7IqIF2lylZGqXxh7ji93Bs7Hhik6y1T9KiGmn58rrYMxmBXzprxNQMF7rJxXbSZ9ZfjaZYamMDaoKDyKEhfAiOHXCm8AVoYXV0aERhdGFZAWZJlg3liA6MaHQ0Fw9kdmBbj-SuuaKGMseZXPO6gx2XY0EAAAB1qWxJcH1fTWqB93Yyt64CQAAgn_dmFmW9UL7678vS4A3XSQLXvxWjefEkYVzEB5cNc_SkAQMDOCQgWQEArEwu_kUDitzDgKOTthwbNnBGfGeUEwv8ksLGvqyRbTNClHnrR9fpaffqQeNor3ndNSReFnZ_3i468d677NMJC4-qoLKu7JP2FIDpt2reDCxg7-XvsaCcDIOucvKR-KIKg9CGiNpkHMhq2auXc4aqYrRjRyuoNYkzpWGENn34govaQQqC5Gdc0yHSeFJLrc9rbQoxMiZY1Ujpe3p9me0VXL4QdNmH_NlnzRclt38Rl8HqQOhrLo6rJOuRc_Ws-BjT0xh8HL8STgTxwb9aKquFkPxylztEy4TAgmOsFv-ukfGwbGO4fszqQKtpsf5-ulO8mfszgY1VrCLmuDzBzdGsdSFDAQAB', + clientDataJSON: + 'eyJvcmlnaW4iOiJodHRwOi8vbG9jYWxob3N0OjgwMDAiLCJjaGFsbGVuZ2UiOiI0MHZfaXpNcHpYLUxPTklHekdxMFlieER3TUtNZmRfWHhRenBlNld2NjRZIiwidHlwZSI6IndlYmF1dGhuLmNyZWF0ZSJ9', + }, + authenticatorAttachment: '', + clientExtensionResults: {}, + type: 'public-key', + }, + expectedChallenge: '40v_izMpzX-LONIGzGq0YbxDwMKMfd_XxQzpe6Wv64Y', + expectedOrigin: 'http://localhost:8000', + expectedRPID: 'localhost', + }); +}); + +test('should verify Packed attestation with RSA-PSS SHA-384 public key', async () => { + const verified = await verifyRegistrationResponse({ + credential: { + id: 'BCwirFmTkTdTUjVqn_uSy-UOSK-iMBgzpfFunE-Hnb0', + rawId: 'BCwirFmTkTdTUjVqn_uSy-UOSK-iMBgzpfFunE-Hnb0', + response: { + attestationObject: + 'o2NmbXRmcGFja2VkZ2F0dFN0bXSiY2FsZzglY3NpZ1kBAB7Tn5jK2sn5U4SBuxYzmR-Rg6iU5nox23mUxw6c10RsWcCw0h3aSKaon3gcn_Sfy8cov1YSsJVeUy9jVYJSpfQSS9ZMZXD5btGPf_YKH34j9YSGyTyutquZRxJ01mou2krDIaiXJOGLFpCJfVUBe-ben68MESby_Q2VFA6u3pjayC6Tu_iUJKPwdWPPaJM2P2KwyYtPy2jGIKqn6UFekfHOKpIDInW7QmzZF6JKUXNWqmwddq0vfzBpHlcyCBRDKmbGv667lkOUz9d7h_Lw0ho2HBrqEQuXhfmog5viDsezgHjQ196JZTwIgAO20vWioXiDWwJKjXGUmQxt9OGlQ1doYXV0aERhdGFZAWZJlg3liA6MaHQ0Fw9kdmBbj-SuuaKGMseZXPO6gx2XY0EAAABjBuy6aWZcQpm9f0NUYyTRzQAgBCwirFmTkTdTUjVqn_uSy-UOSK-iMBgzpfFunE-Hnb2kAQMDOCUgWQEApgFt6NaWotNSJIfFKOsdNlOtc7vdG7b78Rrnk7oCyUYg9PFVXRhgwSNAKBwimjeRILxcra5roznykpbcv3RIWNaej-tfxG2KYINh5ts8V2I3R2PgtlgwMfSSH9tv65gAzAFRk7tyizHelODhhNUbMVPMc-qTmnBzZANd06w0PN8xnWgCHPaG2MHZkFAOqiNkL4Kv0PPFbQTpy9HZd9ofdQhpKL71iXU4pMFJSSLG8jhY-HM2EwBM2HBTqb06qDjt6UOThCqCqd-ltNRllKWfstkUKQT0XOB-NpZ88037onupO2qDaMSudwolToh3-muuGAYCSANRS3TcNPuYP-s-6yFDAQAB', + clientDataJSON: + 'eyJvcmlnaW4iOiJodHRwOi8vbG9jYWxob3N0OjgwMDAiLCJjaGFsbGVuZ2UiOiJwLWphWEhmWUpkbGQ2eTVucklzYTZyblpmNnJnU0MtRm8xcTdBU01VN2s4IiwidHlwZSI6IndlYmF1dGhuLmNyZWF0ZSJ9', + }, + clientExtensionResults: {}, + authenticatorAttachment: '', + type: 'public-key', + }, + expectedChallenge: 'p-jaXHfYJdld6y5nrIsa6rnZf6rgSC-Fo1q7ASMU7k8', + expectedOrigin: 'http://localhost:8000', + expectedRPID: 'localhost', }); }); @@ -632,10 +690,11 @@ const attestationFIDOU2F: RegistrationCredentialJSON = { clientDataJSON: 'eyJjaGFsbGVuZ2UiOiJkRzkwWVd4c2VWVnVhWEYxWlZaaGJIVmxSWFpsY25sQmRIUmxjM1JoZEdsdmJnIiwiY2xpZW50RXh0ZW5zaW9ucyI6e30sImhhc2hBbGdvcml0aG0iOiJTSEEtMjU2Iiwib3JpZ2luIjoiaHR0cHM6Ly9kZXYuZG9udG5lZWRhLnB3IiwidHlwZSI6IndlYmF1dGhuLmNyZWF0ZSJ9', }, - clientExtensionResults: {}, type: 'public-key', + clientExtensionResults: {}, + authenticatorAttachment: '', }; -const attestationFIDOU2FChallenge = base64url.encode('totallyUniqueValueEveryAttestation'); +const attestationFIDOU2FChallenge = isoBase64URL.fromString('totallyUniqueValueEveryAttestation'); const attestationPacked: RegistrationCredentialJSON = { id: 'bbb', @@ -655,8 +714,9 @@ const attestationPacked: RegistrationCredentialJSON = { }, clientExtensionResults: {}, type: 'public-key', + authenticatorAttachment: '', }; -const attestationPackedChallenge = base64url.encode('s6PIbBnPPnrGNSBxNdtDrT7UrVYJK9HM'); +const attestationPackedChallenge = isoBase64URL.fromString('s6PIbBnPPnrGNSBxNdtDrT7UrVYJK9HM'); const attestationPackedX5C: RegistrationCredentialJSON = { // TODO: Grab these from another iPhone attestation @@ -684,10 +744,11 @@ const attestationPackedX5C: RegistrationCredentialJSON = { 'eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmdlIjoiZEc5MFlXeHNlVlZ1YVhG' + 'MVpWWmhiSFZsUlhabGNubFVhVzFsIiwib3JpZ2luIjoiaHR0cHM6Ly9kZXYuZG9udG5lZWRhLnB3In0=', }, - clientExtensionResults: {}, type: 'public-key', + clientExtensionResults: {}, + authenticatorAttachment: '', }; -const attestationPackedX5CChallenge = base64url.encode('totallyUniqueValueEveryTime'); +const attestationPackedX5CChallenge = isoBase64URL.fromString('totallyUniqueValueEveryTime'); const attestationNone: RegistrationCredentialJSON = { id: 'AdKXJEch1aV5Wo7bj7qLHskVY4OoNaj9qu8TPdJ7kSAgUeRxWNngXlcNIGt4gexZGKVGcqZpqqWordXb_he1izY', @@ -703,7 +764,8 @@ const attestationNone: RegistrationCredentialJSON = { 'VURBd1NEQndOV2Q0YURKZmRUVmZVRU0wVG1WWloyUSIsIm9yaWdpbiI6Imh0dHBzOlwvXC9kZXYuZG9udG5lZWRh' + 'LnB3IiwiYW5kcm9pZFBhY2thZ2VOYW1lIjoib3JnLm1vemlsbGEuZmlyZWZveCJ9', }, - clientExtensionResults: {}, type: 'public-key', + clientExtensionResults: {}, + authenticatorAttachment: '', }; -const attestationNoneChallenge = base64url.encode('hEccPWuziP00H0p5gxh2_u5_PC4NeYgd'); +const attestationNoneChallenge = isoBase64URL.fromString('hEccPWuziP00H0p5gxh2_u5_PC4NeYgd'); diff --git a/packages/server/src/registration/verifyRegistrationResponse.ts b/packages/server/src/registration/verifyRegistrationResponse.ts index 14c2110..0c1351f 100644 --- a/packages/server/src/registration/verifyRegistrationResponse.ts +++ b/packages/server/src/registration/verifyRegistrationResponse.ts @@ -1,4 +1,3 @@ -import base64url from 'base64url'; import { RegistrationCredentialJSON, COSEAlgorithmIdentifier, @@ -15,9 +14,11 @@ import { decodeClientDataJSON } from '../helpers/decodeClientDataJSON'; import { parseAuthenticatorData } from '../helpers/parseAuthenticatorData'; import { toHash } from '../helpers/toHash'; import { decodeCredentialPublicKey } from '../helpers/decodeCredentialPublicKey'; -import { COSEKEYS } from '../helpers/convertCOSEtoPKCS'; +import { COSEKEYS } from '../helpers/cose'; import { convertAAGUIDToString } from '../helpers/convertAAGUIDToString'; import { parseBackupFlags } from '../helpers/parseBackupFlags'; +import { matchExpectedRPID } from '../helpers/matchExpectedRPID'; +import { isoBase64URL } from '../helpers/iso'; import { SettingsService } from '../services/settingsService'; import { supportedCOSEAlgorithmIdentifiers } from './generateRegistrationOptions'; @@ -129,9 +130,11 @@ export async function verifyRegistrationResponse( } } - const attestationObject = base64url.toBuffer(response.attestationObject); + const attestationObject = isoBase64URL.toBuffer(response.attestationObject); const decodedAttestationObject = decodeAttestationObject(attestationObject); - const { fmt, authData, attStmt } = decodedAttestationObject; + const fmt = decodedAttestationObject.get('fmt'); + const authData = decodedAttestationObject.get('authData'); + const attStmt = decodedAttestationObject.get('attStmt'); const parsedAuthData = parseAuthenticatorData(authData); const { aaguid, rpIdHash, flags, credentialID, counter, credentialPublicKey, extensionsData } = @@ -139,22 +142,14 @@ export async function verifyRegistrationResponse( // Make sure the response's RP ID is ours if (expectedRPID) { + let expectedRPIDs: string[] = []; if (typeof expectedRPID === 'string') { - const expectedRPIDHash = toHash(Buffer.from(expectedRPID, 'ascii')); - if (!rpIdHash.equals(expectedRPIDHash)) { - throw new Error(`Unexpected RP ID hash`); - } + expectedRPIDs = [expectedRPID]; } else { - // Go through each expected RP ID and try to find one that matches - const foundMatch = expectedRPID.some(expected => { - const expectedRPIDHash = toHash(Buffer.from(expected, 'ascii')); - return rpIdHash.equals(expectedRPIDHash); - }); - - if (!foundMatch) { - throw new Error(`Unexpected RP ID hash`); - } + expectedRPIDs = expectedRPID; } + + await matchExpectedRPID(rpIdHash, expectedRPIDs); } // Make sure someone was physically present @@ -192,7 +187,7 @@ export async function verifyRegistrationResponse( throw new Error(`Unexpected public key alg "${alg}", expected one of "${supported}"`); } - const clientDataHash = toHash(base64url.toBuffer(response.clientDataJSON)); + const clientDataHash = await toHash(isoBase64URL.toBuffer(response.clientDataJSON)); const rootCertificates = SettingsService.getRootCertificates({ identifier: fmt }); // Prepare arguments to pass to the relevant verification method @@ -224,7 +219,7 @@ export async function verifyRegistrationResponse( } else if (fmt === 'apple') { verified = await verifyAttestationApple(verifierOpts); } else if (fmt === 'none') { - if (Object.keys(attStmt).length > 0) { + if (attStmt.size > 0) { throw new Error('None attestation had unexpected attestation statement'); } // This is the weaker of the attestations, so there's nothing else to really check @@ -287,10 +282,10 @@ export type VerifiedRegistrationResponse = { fmt: AttestationFormat; counter: number; aaguid: string; - credentialID: Buffer; - credentialPublicKey: Buffer; + credentialID: Uint8Array; + credentialPublicKey: Uint8Array; credentialType: 'public-key'; - attestationObject: Buffer; + attestationObject: Uint8Array; userVerified: boolean; credentialDeviceType: CredentialDeviceType; credentialBackedUp: boolean; @@ -302,13 +297,13 @@ export type VerifiedRegistrationResponse = { * Values passed to all attestation format verifiers, from which they are free to use as they please */ export type AttestationFormatVerifierOpts = { - aaguid: Buffer; + aaguid: Uint8Array; attStmt: AttestationStatement; - authData: Buffer; - clientDataHash: Buffer; - credentialID: Buffer; - credentialPublicKey: Buffer; + authData: Uint8Array; + clientDataHash: Uint8Array; + credentialID: Uint8Array; + credentialPublicKey: Uint8Array; rootCertificates: string[]; - rpIdHash: Buffer; + rpIdHash: Uint8Array; verifyTimestampMS?: boolean; }; diff --git a/packages/server/src/services/metadataService.test.ts b/packages/server/src/services/metadataService.test.ts index fdf0979..8e12abc 100644 --- a/packages/server/src/services/metadataService.test.ts +++ b/packages/server/src/services/metadataService.test.ts @@ -1,5 +1,5 @@ -jest.mock('node-fetch'); -import fetch from 'node-fetch'; +jest.mock('cross-fetch'); +import fetch from 'cross-fetch'; import { MetadataService, BaseMetadataService } from './metadataService'; import type { MetadataStatement } from '../metadata/mdsTypes'; diff --git a/packages/server/src/services/metadataService.ts b/packages/server/src/services/metadataService.ts index daed3cb..c532f11 100644 --- a/packages/server/src/services/metadataService.ts +++ b/packages/server/src/services/metadataService.ts @@ -1,4 +1,4 @@ -import fetch from 'node-fetch'; +import fetch from 'cross-fetch'; import { KJUR } from 'jsrsasign'; import { validateCertificatePath } from '../helpers/validateCertificatePath'; @@ -139,7 +139,7 @@ export class BaseMetadataService { * 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> { + async getStatement(aaguid: string | Uint8Array): Promise<MetadataStatement | undefined> { if (this.state === SERVICE_STATE.DISABLED) { return; } @@ -148,7 +148,7 @@ export class BaseMetadataService { return; } - if (aaguid instanceof Buffer) { + if (aaguid instanceof Uint8Array) { aaguid = convertAAGUIDToString(aaguid); } @@ -286,7 +286,7 @@ export class BaseMetadataService { let iterations = totalTimeoutMS / intervalMS; // Check service state every `intervalMS` milliseconds - const intervalID: NodeJS.Timeout = global.setInterval(() => { + const intervalID: NodeJS.Timeout = globalThis.setInterval(() => { if (iterations < 1) { clearInterval(intervalID); reject(`State did not become ready in ${totalTimeoutMS / 1000} seconds`); diff --git a/packages/server/src/services/settingsService.ts b/packages/server/src/services/settingsService.ts index 6dce26b..ee1779b 100644 --- a/packages/server/src/services/settingsService.ts +++ b/packages/server/src/services/settingsService.ts @@ -28,13 +28,13 @@ class BaseSettingsService { */ setRootCertificates(opts: { identifier: RootCertIdentifier; - certificates: (Buffer | string)[]; + certificates: (Uint8Array | string)[]; }): void { const { identifier, certificates } = opts; const newCertificates: string[] = []; for (const cert of certificates) { - if (cert instanceof Buffer) { + if (cert instanceof Uint8Array) { newCertificates.push(convertCertBufferToPEM(cert)); } else { newCertificates.push(cert); diff --git a/packages/server/src/setupTests.ts b/packages/server/src/setupTests.ts index 103e5fa..b23ac59 100644 --- a/packages/server/src/setupTests.ts +++ b/packages/server/src/setupTests.ts @@ -1,4 +1,18 @@ +import { webcrypto } from 'node:crypto'; // Silence some console output // jest.spyOn(console, 'log').mockImplementation(); // jest.spyOn(console, 'debug').mockImplementation(); // jest.spyOn(console, 'error').mockImplementation(); + +/** + * We can use this to test runtimes in which the WebCrypto API is available + * on `globalThis.crypto` + * + * This shouldn't be needed anymore once we move support to Node 19+ See here: + * https://nodejs.org/docs/latest-v19.x/api/webcrypto.html#web-crypto-api + */ +// Object.defineProperty(globalThis, 'crypto', { +// get(){ +// return webcrypto; +// }, +// }); |