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