import { mock, MockProxy } from "jest-mock-extended"; import { BehaviorSubject, of } from "rxjs"; import { AuthService } from "../../../auth/abstractions/auth.service"; import { AuthenticationStatus } from "../../../auth/enums/authentication-status"; import { DomainSettingsService } from "../../../autofill/services/domain-settings.service"; import { Utils } from "../../../platform/misc/utils"; import { VaultSettingsService } from "../../../vault/abstractions/vault-settings/vault-settings.service"; import { ConfigService } from "../../abstractions/config/config.service"; import { ActiveRequest, Fido2ActiveRequestEvents, Fido2ActiveRequestManager, RequestResult, } from "../../abstractions/fido2/fido2-active-request-manager.abstraction"; import { Fido2AuthenticatorError, Fido2AuthenticatorErrorCode, Fido2AuthenticatorGetAssertionResult, Fido2AuthenticatorMakeCredentialResult, } from "../../abstractions/fido2/fido2-authenticator.service.abstraction"; import { AssertCredentialParams, CreateCredentialParams, FallbackRequestedError, } from "../../abstractions/fido2/fido2-client.service.abstraction"; import { TaskSchedulerService } from "../../scheduling/task-scheduler.service"; import * as DomainUtils from "./domain-utils"; import { Fido2AuthenticatorService } from "./fido2-authenticator.service"; import { Fido2ClientService } from "./fido2-client.service"; import { Fido2Utils } from "./fido2-utils"; import { guidToRawFormat } from "./guid-utils"; const RpId = "bitwarden.com"; const Origin = "https://bitwarden.com"; const VaultUrl = "https://vault.bitwarden.com"; describe("FidoAuthenticatorService", () => { let authenticator!: MockProxy; let configService!: MockProxy; let authService!: MockProxy; let vaultSettingsService: MockProxy; let domainSettingsService: MockProxy; let taskSchedulerService: MockProxy; let activeRequest!: MockProxy; let requestManager!: MockProxy; let client!: Fido2ClientService; let tab!: chrome.tabs.Tab; let isValidRpId!: jest.SpyInstance; beforeEach(async () => { authenticator = mock(); configService = mock(); authService = mock(); vaultSettingsService = mock(); domainSettingsService = mock(); taskSchedulerService = mock(); activeRequest = mock({ subject: new BehaviorSubject({ type: Fido2ActiveRequestEvents.Continue, credentialId: "", }), }); requestManager = mock({ getActiveRequest$: (tabId: number) => new BehaviorSubject(activeRequest), getActiveRequest: (tabId: number) => activeRequest, }); isValidRpId = jest.spyOn(DomainUtils, "isValidRpId"); client = new Fido2ClientService( authenticator, configService, authService, vaultSettingsService, domainSettingsService, taskSchedulerService, requestManager, ); configService.serverConfig$ = of({ environment: { vault: VaultUrl } } as any); vaultSettingsService.enablePasskeys$ = of(true); domainSettingsService.neverDomains$ = of({}); authService.activeAccountStatus$ = of(AuthenticationStatus.Unlocked); tab = { id: 123, windowId: 456 } as chrome.tabs.Tab; }); afterEach(() => { isValidRpId.mockRestore(); }); describe("createCredential", () => { describe("input parameters validation", () => { // Spec: If sameOriginWithAncestors is false, return a "NotAllowedError" DOMException. it("should throw error if sameOriginWithAncestors is false", async () => { const params = createParams({ sameOriginWithAncestors: false }); const result = async () => await client.createCredential(params, tab); const rejects = expect(result).rejects; await rejects.toMatchObject({ name: "NotAllowedError" }); await rejects.toBeInstanceOf(DOMException); }); // Spec: If the length of options.user.id is not between 1 and 64 bytes (inclusive) then return a TypeError. it("should throw error if user.id is too small", async () => { const params = createParams({ user: { id: "", displayName: "displayName", name: "name" } }); const result = async () => await client.createCredential(params, tab); await expect(result).rejects.toBeInstanceOf(TypeError); }); // Spec: If the length of options.user.id is not between 1 and 64 bytes (inclusive) then return a TypeError. it("should throw error if user.id is too large", async () => { const params = createParams({ user: { id: "YWJzb2x1dGVseS13YXktd2F5LXRvby1sYXJnZS1iYXNlNjQtZW5jb2RlZC11c2VyLWlkLWJpbmFyeS1zZXF1ZW5jZQ", displayName: "displayName", name: "name", }, }); const result = async () => await client.createCredential(params, tab); await expect(result).rejects.toBeInstanceOf(TypeError); }); // Spec: If callerOrigin is an opaque origin, return a DOMException whose name is "NotAllowedError", and terminate this algorithm. // Not sure how to check this, or if it matters. it.todo("should throw error if origin is an opaque origin"); // Spec: Let effectiveDomain be the callerOrigin’s effective domain. If effective domain is not a valid domain, then return a DOMException whose name is "SecurityError" and terminate this algorithm. it("should throw error if origin is not a valid domain name", async () => { const params = createParams({ origin: "invalid-domain-name", }); const result = async () => await client.createCredential(params, tab); const rejects = expect(result).rejects; await rejects.toMatchObject({ name: "SecurityError" }); await rejects.toBeInstanceOf(DOMException); }); // Spec: If options.rp.id is not a registrable domain suffix of and is not equal to effectiveDomain, return a DOMException whose name is "SecurityError", and terminate this algorithm. // This is actually checked by `isValidRpId` function, but we'll test it here as well it("should throw error if rp.id is not valid for this origin", async () => { const params = createParams({ origin: "https://passwordless.dev", rp: { id: "bitwarden.com", name: "Bitwarden" }, }); const result = async () => await client.createCredential(params, tab); const rejects = expect(result).rejects; await rejects.toMatchObject({ name: "SecurityError" }); await rejects.toBeInstanceOf(DOMException); }); // Sanity check to make sure that we use `isValidRpId` to validate the rp.id it("should throw if isValidRpId returns false", async () => { const params = createParams(); authenticator.makeCredential.mockResolvedValue(createAuthenticatorMakeResult()); // `params` actually has a valid rp.id, but we're mocking the function to return false isValidRpId.mockReturnValue(false); const result = async () => await client.createCredential(params, tab); const rejects = expect(result).rejects; await rejects.toMatchObject({ name: "SecurityError" }); await rejects.toBeInstanceOf(DOMException); }); it("should fallback if origin hostname is found in neverDomains", async () => { const params = createParams({ origin: "https://bitwarden.com", rp: { id: "bitwarden.com", name: "Bitwarden" }, }); domainSettingsService.neverDomains$ = of({ "bitwarden.com": null }); const result = async () => await client.createCredential(params, tab); await expect(result).rejects.toThrow(FallbackRequestedError); }); it("should throw error if origin is not an https domain", async () => { const params = createParams({ origin: "http://passwordless.dev", rp: { id: "bitwarden.com", name: "Bitwarden" }, }); const result = async () => await client.createCredential(params, tab); const rejects = expect(result).rejects; await rejects.toMatchObject({ name: "SecurityError" }); await rejects.toBeInstanceOf(DOMException); }); it("should not throw error if localhost is http", async () => { const params = createParams({ origin: "http://localhost", rp: { id: undefined, name: "localhost" }, }); authenticator.makeCredential.mockResolvedValue(createAuthenticatorMakeResult()); await client.createCredential(params, tab); }); // Spec: If credTypesAndPubKeyAlgs is empty, return a DOMException whose name is "NotSupportedError", and terminate this algorithm. it("should throw error if no support key algorithms were found", async () => { const params = createParams({ pubKeyCredParams: [ { alg: -9001, type: "public-key" }, { alg: -7, type: "not-supported" as any }, ], }); const result = async () => await client.createCredential(params, tab); const rejects = expect(result).rejects; await rejects.toMatchObject({ name: "NotSupportedError" }); await rejects.toBeInstanceOf(DOMException); }); }); describe("aborting", () => { // Spec: If the options.signal is present and its aborted flag is set to true, return a DOMException whose name is "AbortError" and terminate this algorithm. it("should throw error if aborting using abort controller", async () => { const params = createParams({}); const abortController = new AbortController(); abortController.abort(); const result = async () => await client.createCredential(params, tab, abortController); const rejects = expect(result).rejects; await rejects.toMatchObject({ name: "AbortError" }); await rejects.toBeInstanceOf(DOMException); }); }); 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, tab); expect(authenticator.makeCredential).toHaveBeenCalledWith( expect.objectContaining({ requireResidentKey: true, requireUserVerification: true, rpEntity: expect.objectContaining({ id: RpId, }), userEntity: expect.objectContaining({ displayName: params.user.displayName, }), }), tab, expect.anything(), ); }); it("should return credProps.rk = true when creating a discoverable credential", async () => { const params = createParams({ authenticatorSelection: { residentKey: "required", userVerification: "required" }, extensions: { credProps: true }, }); authenticator.makeCredential.mockResolvedValue(createAuthenticatorMakeResult()); const result = await client.createCredential(params, tab); expect(result.extensions.credProps?.rk).toBe(true); }); it("should return credProps.rk = false when creating a non-discoverable credential", async () => { const params = createParams({ authenticatorSelection: { residentKey: "discouraged", userVerification: "required" }, extensions: { credProps: true }, }); authenticator.makeCredential.mockResolvedValue(createAuthenticatorMakeResult()); const result = await client.createCredential(params, tab); expect(result.extensions.credProps?.rk).toBe(false); }); it("should return credProps = undefiend when the extension is not requested", async () => { const params = createParams({ authenticatorSelection: { residentKey: "required", userVerification: "required" }, extensions: {}, }); authenticator.makeCredential.mockResolvedValue(createAuthenticatorMakeResult()); const result = await client.createCredential(params, tab); expect(result.extensions.credProps).toBeUndefined(); }); // 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 Fido2AuthenticatorError(Fido2AuthenticatorErrorCode.InvalidState), ); const result = async () => await client.createCredential(params, tab); 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, tab); const rejects = expect(result).rejects; await rejects.toMatchObject({ name: "NotAllowedError" }); await rejects.toBeInstanceOf(DOMException); }); it("should throw FallbackRequestedError if passkeys state is not enabled", async () => { const params = createParams(); vaultSettingsService.enablePasskeys$ = of(false); const result = async () => await client.createCredential(params, tab); const rejects = expect(result).rejects; await rejects.toThrow(FallbackRequestedError); }); it("should throw FallbackRequestedError if user is logged out", async () => { const params = createParams(); authService.getAuthStatus.mockResolvedValue(AuthenticationStatus.LoggedOut); const result = async () => await client.createCredential(params, tab); const rejects = expect(result).rejects; await rejects.toThrow(FallbackRequestedError); }); it("should throw FallbackRequestedError if origin equals the bitwarden vault", async () => { const params = createParams({ origin: VaultUrl }); const result = async () => await client.createCredential(params, tab); const rejects = expect(result).rejects; await rejects.toThrow(FallbackRequestedError); }); }); function createParams(params: Partial = {}): CreateCredentialParams { return { origin: params.origin ?? "https://bitwarden.com", sameOriginWithAncestors: params.sameOriginWithAncestors ?? true, attestation: params.attestation, authenticatorSelection: params.authenticatorSelection, challenge: params.challenge ?? "MzItYnl0ZXMtYmFzZTY0LWVuY29kZS1jaGFsbGVuZ2U", excludeCredentials: params.excludeCredentials, extensions: params.extensions, pubKeyCredParams: params.pubKeyCredParams ?? [ { alg: -7, type: "public-key", }, ], rp: params.rp ?? { id: RpId, name: "Bitwarden", }, user: params.user ?? { id: "YmFzZTY0LWVuY29kZWQtdXNlci1pZA", displayName: "User Name", name: "name", }, fallbackSupported: params.fallbackSupported ?? false, timeout: params.timeout, }; } function createAuthenticatorMakeResult(): Fido2AuthenticatorMakeCredentialResult { return { credentialId: guidToRawFormat(Utils.newGuid()), attestationObject: randomBytes(128), authData: randomBytes(64), publicKey: randomBytes(64), publicKeyAlgorithm: -7, }; } }); describe("assertCredential", () => { describe("invalid params", () => { // Spec: If callerOrigin is an opaque origin, return a DOMException whose name is "NotAllowedError", and terminate this algorithm. // Not sure how to check this, or if it matters. it.todo("should throw error if origin is an opaque origin"); // Spec: Let effectiveDomain be the callerOrigin’s effective domain. If effective domain is not a valid domain, then return a DOMException whose name is "SecurityError" and terminate this algorithm. it("should throw error if origin is not a valid domain name", async () => { const params = createParams({ origin: "invalid-domain-name", }); const result = async () => await client.assertCredential(params, tab); const rejects = expect(result).rejects; await rejects.toMatchObject({ name: "SecurityError" }); await rejects.toBeInstanceOf(DOMException); }); // Spec: If options.rp.id is not a registrable domain suffix of and is not equal to effectiveDomain, return a DOMException whose name is "SecurityError", and terminate this algorithm. // This is actually checked by `isValidRpId` function, but we'll test it here as well it("should throw error if rp.id is not valid for this origin", async () => { const params = createParams({ origin: "https://passwordless.dev", rpId: "bitwarden.com", }); const result = async () => await client.assertCredential(params, tab); const rejects = expect(result).rejects; await rejects.toMatchObject({ name: "SecurityError" }); await rejects.toBeInstanceOf(DOMException); }); // Sanity check to make sure that we use `isValidRpId` to validate the rp.id it("should throw if isValidRpId returns false", async () => { const params = createParams(); authenticator.getAssertion.mockResolvedValue(createAuthenticatorAssertResult()); // `params` actually has a valid rp.id, but we're mocking the function to return false isValidRpId.mockReturnValue(false); const result = async () => await client.assertCredential(params, tab); const rejects = expect(result).rejects; await rejects.toMatchObject({ name: "SecurityError" }); await rejects.toBeInstanceOf(DOMException); }); it("should fallback if origin hostname is found in neverDomains", async () => { const params = createParams({ origin: "https://bitwarden.com", }); domainSettingsService.neverDomains$ = of({ "bitwarden.com": null }); const result = async () => await client.assertCredential(params, tab); await expect(result).rejects.toThrow(FallbackRequestedError); }); it("should throw error if origin is not an http domain", async () => { const params = createParams({ origin: "http://passwordless.dev", rpId: "bitwarden.com", }); const result = async () => await client.assertCredential(params, tab); const rejects = expect(result).rejects; await rejects.toMatchObject({ name: "SecurityError" }); await rejects.toBeInstanceOf(DOMException); }); }); describe("aborting", () => { // Spec: If the options.signal is present and its aborted flag is set to true, return a DOMException whose name is "AbortError" and terminate this algorithm. it("should throw error if aborting using abort controller", async () => { const params = createParams({}); const abortController = new AbortController(); abortController.abort(); const result = async () => await client.assertCredential(params, tab, abortController); const rejects = expect(result).rejects; await rejects.toMatchObject({ name: "AbortError" }); await rejects.toBeInstanceOf(DOMException); }); }); describe("assert credential", () => { // 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.getAssertion.mockRejectedValue( new Fido2AuthenticatorError(Fido2AuthenticatorErrorCode.InvalidState), ); const result = async () => await client.assertCredential(params, tab); 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.getAssertion.mockRejectedValue(new Error("unknown error")); const result = async () => await client.assertCredential(params, tab); const rejects = expect(result).rejects; await rejects.toMatchObject({ name: "NotAllowedError" }); await rejects.toBeInstanceOf(DOMException); }); it("should throw FallbackRequestedError if passkeys state is not enabled", async () => { const params = createParams(); vaultSettingsService.enablePasskeys$ = of(false); const result = async () => await client.assertCredential(params, tab); const rejects = expect(result).rejects; await rejects.toThrow(FallbackRequestedError); }); it("should throw FallbackRequestedError if user is logged out", async () => { const params = createParams(); authService.getAuthStatus.mockResolvedValue(AuthenticationStatus.LoggedOut); const result = async () => await client.assertCredential(params, tab); const rejects = expect(result).rejects; await rejects.toThrow(FallbackRequestedError); }); it("should throw FallbackRequestedError if origin equals the bitwarden vault", async () => { const params = createParams({ origin: VaultUrl }); const result = async () => await client.assertCredential(params, tab); const rejects = expect(result).rejects; await rejects.toThrow(FallbackRequestedError); }); }); describe("assert non-discoverable credential", () => { it("should call authenticator.assertCredential", async () => { const allowedCredentialIds = [ Fido2Utils.bufferToString(guidToRawFormat(Utils.newGuid())), Fido2Utils.bufferToString(guidToRawFormat(Utils.newGuid())), Fido2Utils.bufferToString(Utils.fromByteStringToArray("not-a-guid")), ]; const params = createParams({ userVerification: "required", allowedCredentialIds, }); authenticator.getAssertion.mockResolvedValue(createAuthenticatorAssertResult()); await client.assertCredential(params, tab); expect(authenticator.getAssertion).toHaveBeenCalledWith( expect.objectContaining({ requireUserVerification: true, rpId: RpId, allowCredentialDescriptorList: [ expect.objectContaining({ id: Fido2Utils.stringToBuffer(allowedCredentialIds[0]), }), expect.objectContaining({ id: Fido2Utils.stringToBuffer(allowedCredentialIds[1]), }), expect.objectContaining({ id: Fido2Utils.stringToBuffer(allowedCredentialIds[2]), }), ], }), tab, expect.anything(), ); }); it("should not throw error if localhost is http", async () => { const params = createParams({ origin: "http://localhost", }); params.rpId = undefined; authenticator.getAssertion.mockResolvedValue(createAuthenticatorAssertResult()); await client.assertCredential(params, tab); }); }); describe("assert discoverable credential", () => { it("should call authenticator.assertCredential", async () => { const params = createParams({ userVerification: "required", allowedCredentialIds: [], }); authenticator.getAssertion.mockResolvedValue(createAuthenticatorAssertResult()); await client.assertCredential(params, tab); expect(authenticator.getAssertion).toHaveBeenCalledWith( expect.objectContaining({ requireUserVerification: true, rpId: RpId, allowCredentialDescriptorList: [], }), tab, expect.anything(), ); }); }); describe("assert mediated conditional ui credential", () => { const params = createParams({ userVerification: "required", mediation: "conditional", allowedCredentialIds: [], }); beforeEach(() => { requestManager.newActiveRequest.mockResolvedValue({ type: Fido2ActiveRequestEvents.Continue, credentialId: crypto.randomUUID(), }); authenticator.getAssertion.mockResolvedValue(createAuthenticatorAssertResult()); }); it("creates an active mediated conditional request", async () => { await client.assertCredential(params, tab); expect(requestManager.newActiveRequest).toHaveBeenCalled(); expect(authenticator.getAssertion).toHaveBeenCalledWith( expect.objectContaining({ assumeUserPresence: true, rpId: RpId, }), tab, ); }); it("restarts the mediated conditional request if a user aborts the request", async () => { authenticator.getAssertion.mockRejectedValueOnce(new Error()); await client.assertCredential(params, tab); expect(authenticator.getAssertion).toHaveBeenCalledTimes(2); }); it("restarts the mediated conditional request if a the abort controller aborts the request", async () => { const abortController = new AbortController(); abortController.abort(); authenticator.getAssertion.mockRejectedValueOnce(new DOMException("AbortError")); await client.assertCredential(params, tab); expect(authenticator.getAssertion).toHaveBeenCalledTimes(2); }); }); function createParams(params: Partial = {}): AssertCredentialParams { return { allowedCredentialIds: params.allowedCredentialIds ?? [], challenge: params.challenge ?? Fido2Utils.bufferToString(randomBytes(16)), origin: params.origin ?? Origin, rpId: params.rpId ?? RpId, timeout: params.timeout, userVerification: params.userVerification, sameOriginWithAncestors: true, fallbackSupported: params.fallbackSupported ?? false, mediation: params.mediation, }; } function createAuthenticatorAssertResult(): Fido2AuthenticatorGetAssertionResult { return { selectedCredential: { id: randomBytes(32), userHandle: randomBytes(32), }, authenticatorData: randomBytes(64), signature: randomBytes(64), }; } }); }); /** 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)); }