diff --git a/libs/common/src/webauthn/abstractions/fido2-client.service.abstraction.ts b/libs/common/src/webauthn/abstractions/fido2-client.service.abstraction.ts index 3f5412eff06..c0cd0bcc39b 100644 --- a/libs/common/src/webauthn/abstractions/fido2-client.service.abstraction.ts +++ b/libs/common/src/webauthn/abstractions/fido2-client.service.abstraction.ts @@ -25,7 +25,7 @@ export interface CreateCredentialParams { excludeCredentials?: { id: string; // b64 encoded transports?: ("ble" | "internal" | "nfc" | "usb")[]; - // type: "public-key"; // not used + type: "public-key"; }[]; extensions?: { appid?: string; diff --git a/libs/common/src/webauthn/services/fido2-authenticator.service.ts b/libs/common/src/webauthn/services/fido2-authenticator.service.ts index 71a203d5ff1..6df532982a1 100644 --- a/libs/common/src/webauthn/services/fido2-authenticator.service.ts +++ b/libs/common/src/webauthn/services/fido2-authenticator.service.ts @@ -137,7 +137,7 @@ export class Fido2AuthenticatorService implements Fido2AuthenticatorServiceAbstr ); return { - credentialId: Fido2Utils.stringToBuffer(credentialId), + credentialId: Utils.guidToRawFormat(credentialId), attestationObject, authData, publicKeyAlgorithm: -7, diff --git a/libs/common/src/webauthn/services/fido2-client.service.spec.ts b/libs/common/src/webauthn/services/fido2-client.service.spec.ts index 882042358c5..96ff96268de 100644 --- a/libs/common/src/webauthn/services/fido2-client.service.spec.ts +++ b/libs/common/src/webauthn/services/fido2-client.service.spec.ts @@ -1,5 +1,11 @@ import { mock, MockProxy } from "jest-mock-extended"; +import { Utils } from "../../misc/utils"; +import { + Fido2AutenticatorError, + Fido2AutenticatorErrorCode, + Fido2AuthenticatorMakeCredentialResult, +} from "../abstractions/fido2-authenticator.service.abstraction"; import { CreateCredentialParams } from "../abstractions/fido2-client.service.abstraction"; import { Fido2AuthenticatorService } from "./fido2-authenticator.service"; @@ -115,6 +121,57 @@ describe("FidoAuthenticatorService", () => { }); }); + describe("creating a new credential", () => { + it("should call authenticator.makeCredential", async () => { + const params = createParams({ + authenticatorSelection: { residentKey: "required", userVerification: "required" }, + }); + authenticator.makeCredential.mockResolvedValue(createAuthenticatorMakeResult()); + + await client.createCredential(params); + + expect(authenticator.makeCredential).toHaveBeenCalledWith( + expect.objectContaining({ + requireResidentKey: true, + requireUserVerification: true, + rpEntity: expect.objectContaining({ + id: RpId, + }), + userEntity: expect.objectContaining({ + displayName: params.user.displayName, + }), + }), + expect.anything() + ); + }); + + // Spec: If any authenticator returns an error status equivalent to "InvalidStateError", Return a DOMException whose name is "InvalidStateError" and terminate this algorithm. + it("should throw error if authenticator throws InvalidState", async () => { + const params = createParams(); + authenticator.makeCredential.mockRejectedValue( + new Fido2AutenticatorError(Fido2AutenticatorErrorCode.InvalidState) + ); + + const result = async () => await client.createCredential(params); + + const rejects = expect(result).rejects; + await rejects.toMatchObject({ name: "InvalidStateError" }); + await rejects.toBeInstanceOf(DOMException); + }); + + // This keeps sensetive information form leaking + it("should throw NotAllowedError if authenticator throws unknown error", async () => { + const params = createParams(); + authenticator.makeCredential.mockRejectedValue(new Error("unknown error")); + + const result = async () => await client.createCredential(params); + + const rejects = expect(result).rejects; + await rejects.toMatchObject({ name: "NotAllowedError" }); + await rejects.toBeInstanceOf(DOMException); + }); + }); + function createParams(params: Partial = {}): CreateCredentialParams { return { origin: params.origin ?? "bitwarden.com", @@ -141,5 +198,19 @@ describe("FidoAuthenticatorService", () => { timeout: params.timeout, }; } + + function createAuthenticatorMakeResult(): Fido2AuthenticatorMakeCredentialResult { + return { + credentialId: Utils.guidToRawFormat(Utils.newGuid()), + attestationObject: randomBytes(128), + authData: randomBytes(64), + publicKeyAlgorithm: -7, + }; + } }); }); + +/** This is a fake function that always returns the same byte sequence */ +function randomBytes(length: number) { + return new Uint8Array(Array.from({ length }, (_, k) => k % 255)); +} diff --git a/libs/common/src/webauthn/services/fido2-client.service.ts b/libs/common/src/webauthn/services/fido2-client.service.ts index e625ceecc7f..1918764472d 100644 --- a/libs/common/src/webauthn/services/fido2-client.service.ts +++ b/libs/common/src/webauthn/services/fido2-client.service.ts @@ -1,7 +1,12 @@ import { parse } from "tldts"; import { Utils } from "../../misc/utils"; -import { Fido2AuthenticatorService } from "../abstractions/fido2-authenticator.service.abstraction"; +import { + Fido2AutenticatorError, + Fido2AutenticatorErrorCode, + Fido2AuthenticatorMakeCredentialsParams, + Fido2AuthenticatorService, +} from "../abstractions/fido2-authenticator.service.abstraction"; import { AssertCredentialParams, AssertCredentialResult, @@ -9,6 +14,7 @@ import { CreateCredentialResult, Fido2ClientService as Fido2ClientServiceAbstraction, PublicKeyCredentialParam, + UserVerification, } from "../abstractions/fido2-client.service.abstraction"; import { Fido2Utils } from "../abstractions/fido2-utils"; @@ -62,15 +68,72 @@ export class Fido2ClientService implements Fido2ClientServiceAbstraction { // tokenBinding: {} // Not currently supported }; const clientDataJSON = JSON.stringify(collectedClientData); - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const clientDataHash = await crypto.subtle.digest( - { name: "SHA-256" }, - Utils.fromByteStringToArray(clientDataJSON) - ); + const clientDataJSONBytes = Utils.fromByteStringToArray(clientDataJSON); + const clientDataHash = await crypto.subtle.digest({ name: "SHA-256" }, clientDataJSONBytes); if (abortController.signal.aborted) { throw new DOMException(undefined, "AbortError"); } + + const timeout = setAbortTimeout( + abortController, + params.authenticatorSelection?.userVerification, + params.timeout + ); + + const makeCredentialParams: Fido2AuthenticatorMakeCredentialsParams = { + requireResidentKey: + params.authenticatorSelection?.residentKey === "required" || + (params.authenticatorSelection?.residentKey === undefined && + params.authenticatorSelection?.requireResidentKey === true), + requireUserVerification: params.authenticatorSelection?.userVerification === "required", + enterpriseAttestationPossible: params.attestation === "enterprise", + excludeCredentialDescriptorList: params.excludeCredentials?.map((c) => ({ + id: Fido2Utils.stringToBuffer(c.id), + transports: c.transports, + type: c.type, + })), + credTypesAndPubKeyAlgs, + hash: clientDataHash, + rpEntity: { + id: rpId, + name: params.rp.name, + }, + userEntity: { + id: Fido2Utils.stringToBuffer(params.user.id), + displayName: params.user.displayName, + }, + }; + + let makeCredentialResult; + try { + makeCredentialResult = await this.authenticator.makeCredential( + makeCredentialParams, + abortController + ); + } catch (error) { + if ( + error instanceof Fido2AutenticatorError && + error.errorCode === Fido2AutenticatorErrorCode.InvalidState + ) { + throw new DOMException(undefined, "InvalidStateError"); + } + throw new DOMException(undefined, "NotAllowedError"); + } + + if (abortController.signal.aborted) { + throw new DOMException(undefined, "AbortError"); + } + clearTimeout(timeout); + + return { + credentialId: Fido2Utils.bufferToString(makeCredentialResult.credentialId), + attestationObject: Fido2Utils.bufferToString(makeCredentialResult.attestationObject), + authData: Fido2Utils.bufferToString(makeCredentialResult.authData), + publicKeyAlgorithm: makeCredentialResult.publicKeyAlgorithm, + clientDataJSON, + transports: ["web-extension"], + }; } assertCredential( @@ -80,3 +143,40 @@ export class Fido2ClientService implements Fido2ClientServiceAbstraction { throw new Error("Not implemented"); } } + +const TIMEOUTS = { + NO_VERIFICATION: { + DEFAULT: 120000, + MIN: 30000, + MAX: 180000, + }, + WITH_VERIFICATION: { + DEFAULT: 300000, + MIN: 30000, + MAX: 600000, + }, +}; + +function setAbortTimeout( + abortController: AbortController, + userVerification?: UserVerification, + timeout?: number +): number { + let clampedTimeout: number; + + if (userVerification === "required") { + timeout = timeout ?? TIMEOUTS.WITH_VERIFICATION.DEFAULT; + clampedTimeout = Math.max( + TIMEOUTS.WITH_VERIFICATION.MIN, + Math.min(timeout, TIMEOUTS.WITH_VERIFICATION.MAX) + ); + } else { + timeout = timeout ?? TIMEOUTS.NO_VERIFICATION.DEFAULT; + clampedTimeout = Math.max( + TIMEOUTS.NO_VERIFICATION.MIN, + Math.min(timeout, TIMEOUTS.NO_VERIFICATION.MAX) + ); + } + + return window.setTimeout(() => abortController.abort(), clampedTimeout); +}