import { mock, MockProxy } from "jest-mock-extended"; import { BehaviorSubject } from "rxjs"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { KdfConfigService } from "@bitwarden/common/auth/abstractions/kdf-config.service"; import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service"; import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result"; import { IdentityTokenResponse } from "@bitwarden/common/auth/models/response/identity-token.response"; import { IUserDecryptionOptionsServerResponse } from "@bitwarden/common/auth/models/response/user-decryption-options/user-decryption-options.response"; import { FakeMasterPasswordService } from "@bitwarden/common/auth/services/master-password/fake-master-password.service"; import { WebAuthnLoginAssertionResponseRequest } from "@bitwarden/common/auth/services/webauthn-login/request/webauthn-login-assertion-response.request"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { VaultTimeoutAction } from "@bitwarden/common/enums/vault-timeout-action.enum"; import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; import { VaultTimeoutSettingsService } from "@bitwarden/common/services/vault-timeout/vault-timeout-settings.service"; import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec"; import { UserId } from "@bitwarden/common/types/guid"; import { PrfKey, UserKey } from "@bitwarden/common/types/key"; import { InternalUserDecryptionOptionsServiceAbstraction } from "../abstractions/user-decryption-options.service.abstraction"; import { WebAuthnLoginCredentials } from "../models/domain/login-credentials"; import { identityTokenResponseFactory } from "./login.strategy.spec"; import { WebAuthnLoginStrategy, WebAuthnLoginStrategyData } from "./webauthn-login.strategy"; describe("WebAuthnLoginStrategy", () => { let cache: WebAuthnLoginStrategyData; let accountService: FakeAccountService; let masterPasswordService: FakeMasterPasswordService; let cryptoService!: MockProxy; let encryptService!: MockProxy; let apiService!: MockProxy; let tokenService!: MockProxy; let appIdService!: MockProxy; let platformUtilsService!: MockProxy; let messagingService!: MockProxy; let logService!: MockProxy; let stateService!: MockProxy; let twoFactorService!: MockProxy; let userDecryptionOptionsService: MockProxy; let billingAccountProfileStateService: MockProxy; let vaultTimeoutSettingsService: MockProxy; let kdfConfigService: MockProxy; let webAuthnLoginStrategy!: WebAuthnLoginStrategy; const token = "mockToken"; const deviceId = Utils.newGuid(); const userId = Utils.newGuid() as UserId; let webAuthnCredentials!: WebAuthnLoginCredentials; let originalPublicKeyCredential!: PublicKeyCredential | any; let originalAuthenticatorAssertionResponse!: AuthenticatorAssertionResponse | any; beforeAll(() => { // Save off the original classes so we can restore them after all tests are done if they exist originalPublicKeyCredential = global.PublicKeyCredential; originalAuthenticatorAssertionResponse = global.AuthenticatorAssertionResponse; // We must do this to make the mocked classes available for all the // assertCredential(...) tests. global.PublicKeyCredential = MockPublicKeyCredential; global.AuthenticatorAssertionResponse = MockAuthenticatorAssertionResponse; }); beforeEach(() => { jest.clearAllMocks(); accountService = mockAccountServiceWith(userId); masterPasswordService = new FakeMasterPasswordService(); cryptoService = mock(); encryptService = mock(); apiService = mock(); tokenService = mock(); appIdService = mock(); platformUtilsService = mock(); messagingService = mock(); logService = mock(); stateService = mock(); twoFactorService = mock(); userDecryptionOptionsService = mock(); billingAccountProfileStateService = mock(); vaultTimeoutSettingsService = mock(); kdfConfigService = mock(); tokenService.getTwoFactorToken.mockResolvedValue(null); appIdService.getAppId.mockResolvedValue(deviceId); tokenService.decodeAccessToken.mockResolvedValue({ sub: userId, }); webAuthnLoginStrategy = new WebAuthnLoginStrategy( cache, accountService, masterPasswordService, cryptoService, encryptService, apiService, tokenService, appIdService, platformUtilsService, messagingService, logService, stateService, twoFactorService, userDecryptionOptionsService, billingAccountProfileStateService, vaultTimeoutSettingsService, kdfConfigService, ); // Create credentials const publicKeyCredential = new MockPublicKeyCredential(); const deviceResponse = new WebAuthnLoginAssertionResponseRequest(publicKeyCredential); const prfKey = new SymmetricCryptoKey(randomBytes(32)) as PrfKey; webAuthnCredentials = new WebAuthnLoginCredentials(token, deviceResponse, prfKey); // Mock vault timeout settings const mockVaultTimeoutAction = VaultTimeoutAction.Lock; const mockVaultTimeoutActionBSub = new BehaviorSubject( mockVaultTimeoutAction, ); vaultTimeoutSettingsService.getVaultTimeoutActionByUserId$.mockReturnValue( mockVaultTimeoutActionBSub.asObservable(), ); const mockVaultTimeout = 1000; const mockVaultTimeoutBSub = new BehaviorSubject(mockVaultTimeout); vaultTimeoutSettingsService.getVaultTimeoutByUserId$.mockReturnValue( mockVaultTimeoutBSub.asObservable(), ); }); afterAll(() => { // Restore global after all tests are done global.PublicKeyCredential = originalPublicKeyCredential; global.AuthenticatorAssertionResponse = originalAuthenticatorAssertionResponse; }); const mockEncPrfPrivateKey = "2.eh465OrUcluL9UpnCOUTAg==|2HXNXwrLwAjUfZ/U75c92rZEltt1eHxjMkp/ADAmx346oT1+GaQvaL1QIV/9Om0T72m8AnlO92iUfWdhbA/ifHZ+lhFoUVeyw1M88CMzktbVcq42rFoK7SGHSAGdTL3ccUWKI8yCCQJhpt2X6a/5+T7ey5k2CqvylKyOtkiCnVeLmYqETn5BM9Rl3tEgJW1yDLuSJ+L+Qh9xnk/Z3zJUV5HAs+YwjKwuSNrd00SXjDyx8rBEstD9MKI+lrk7to/q90vqKqCucAj/dzUpVtHe88al2AAlBVwQ13HUPdNFOyti6niUgCAWx+DzRqlhkFvl/z/rtxtQsyqq/3Eh/EL54ylxKzAya0ev9EaIOm/dD1aBmI58p4Bs0eMOCIKJjtw+Cmdql+RhCtKtumgFShqyXv+LfD/FgUsdTVNExk3YNhgwPR4jOaMa/j9LCrBMCLKxdAhQyBe7T3qoX1fBBirvY6t77ifMu1YEQ6DfmFphVSwDH5C9xGeTSh5IELSf0tGVtlWUe9RffDDzccD0L1lR8U+dqzoSTYCuXvhEhQptdIW6fpH/47u0M5MiI97/d35A7Et2I1gjHp7WF3qsY20ellBueu7ZL5P1BmqPXl58yaBBXJaCutYHDfIucspqdZmfBGEbdRT4wmuZRON0J8zLmUejM0VR/2MOmpfyYQXnJhTfrvnZ1bOg1aMhUxJ2vhDNPXUFm5b+vwsho4GEvcLAKq9WwbvOJ/sK7sEVfTfEO2IG+0X6wkWm7RpR6Wq9FGKSrv2PSjMAYnb+z3ETeWiaaiD+tVFxa2AaqsbOuX092/86GySpHES7cFWhQ/YMOgj6egUi8mEC0CqMXYsx0TTJDsn16oP+XB3a2WoRqzE0YBozp2aMXxhVf/jMZ03BmEmRQu5B+Sq1gMEZwtIfJ+srkZLMYlLjvVw92FRoFy+N6ytPiyf6RMHMUnJ3vEZSBogaElYoQAtFJ5kK811CUzb78zEHH8xWtPrCZn9zZfvf/zaWxo7fpV8VwAwUeHXHcQMraZum5QeO+5tLRUYrLm85JNelGfmUA3BjfNyFbfb32PhkWWd0CbDaPME48uIriVK32pNEtvtR/+I/f3YgA/jP9kSlDvbzG/OAg/AFBIpNwKUzsu4+va8mI+O5FDufw5D74WwdGJ9DeyEb2CHtWMR1VwtFKL0ZZsqltNf8EkBeJ5RtTNtAMM8ie4dDZaKC96ymQHKrdB4hjkAr0F1XFsU4XdOa9Nbkdcm/7KoNc6bE6oJtG9lqE8h+1CysfcbfJ7am+hvDFzT0IPmp3GDSMAk+e6xySgFQw0C/SZ7LQsxPa1s6hc+BOtTn0oClZnU7Mowxv+z+xURJj4Yp3Cy6tAoia1jEQSs6lSMNKPf9bi3xFKtPl4143hwhpvTAzJUcski9OVGd7Du+VyxwIrvLqp5Ct/oNrESVJpf1EDCs9xT1EW+PiSkRmHXoZ1t5MOLFEiMAZL2+bNe3A2661oJeMtps8zrfCVc251OUE1WvqWePlTOs5TDVqdwDH88J6rHLsbaf33Mxh5DP8gMfZQxE44Nsp6H0/Szfkss5UmFwBEpHjl1GJMWDnB3u2d+l1CSkLoB6C+diAUlY6wL/VwJBeMPHZTf6amQIS2B/lo/CnvV/E3k=|uuoY4b7xwMYBNIZi85KBsaHmNqtJl5FrKxZI9ugeNwc="; const mockEncUserKey = "4.Xht6K9GA9jKcSNy4TaIvdj7f9+WsgQycs/HdkrJi33aC//roKkjf3UTGpdzFLxVP3WhyOVGyo9f2Jymf1MFPdpg7AuMnpGJlcrWLDbnPjOJo4x5gUwwBUmy3nFw6+wamyS1LRmrBPcv56yKpf80k5Q3hUrum8q9YS9m2I10vklX/TaB1YML0yo+K1feWUxg8vIx+vloxhUdkkysvcV5xU3R+AgYLrwvJS8TLL7Ug/P5HxinCaIroRrNe8xcv84vyVnzPFdXe0cfZ0cpcrm586LwfEXP2seeldO/bC51Uk/mudeSALJURPC64f5ch2cOvk48GOTapGnssCqr6ky5yFw=="; const userDecryptionOptsServerResponseWithWebAuthnPrfOption: IUserDecryptionOptionsServerResponse = { HasMasterPassword: true, WebAuthnPrfOption: { EncryptedPrivateKey: mockEncPrfPrivateKey, EncryptedUserKey: mockEncUserKey, }, }; const mockIdTokenResponseWithModifiedWebAuthnPrfOption = (key: string, value: any) => { const userDecryptionOpts: IUserDecryptionOptionsServerResponse = { ...userDecryptionOptsServerResponseWithWebAuthnPrfOption, WebAuthnPrfOption: { ...userDecryptionOptsServerResponseWithWebAuthnPrfOption.WebAuthnPrfOption, [key]: value, }, }; return identityTokenResponseFactory(null, userDecryptionOpts); }; it("returns successful authResult when api service returns valid credentials", async () => { // Arrange const idTokenResponse: IdentityTokenResponse = identityTokenResponseFactory( null, userDecryptionOptsServerResponseWithWebAuthnPrfOption, ); apiService.postIdentityToken.mockResolvedValue(idTokenResponse); // Act const authResult = await webAuthnLoginStrategy.logIn(webAuthnCredentials); // Assert expect(apiService.postIdentityToken).toHaveBeenCalledWith( expect.objectContaining({ // webauthn specific info token: webAuthnCredentials.token, deviceResponse: webAuthnCredentials.deviceResponse, // standard info device: expect.objectContaining({ identifier: deviceId, }), }), ); expect(authResult).toBeInstanceOf(AuthResult); expect(authResult).toMatchObject({ captchaSiteKey: "", forcePasswordReset: 0, resetMasterPassword: false, twoFactorProviders: null, requiresTwoFactor: false, requiresCaptcha: false, }); }); it("decrypts and sets user key when webAuthn PRF decryption option exists with valid PRF key and enc key data", async () => { // Arrange const idTokenResponse: IdentityTokenResponse = identityTokenResponseFactory( null, userDecryptionOptsServerResponseWithWebAuthnPrfOption, ); apiService.postIdentityToken.mockResolvedValue(idTokenResponse); const mockPrfPrivateKey: Uint8Array = randomBytes(32); const mockUserKeyArray: Uint8Array = randomBytes(32); const mockUserKey = new SymmetricCryptoKey(mockUserKeyArray) as UserKey; encryptService.decryptToBytes.mockResolvedValue(mockPrfPrivateKey); cryptoService.rsaDecrypt.mockResolvedValue(mockUserKeyArray); // Act await webAuthnLoginStrategy.logIn(webAuthnCredentials); // Assert // Master key encrypted user key should be set expect(cryptoService.setMasterKeyEncryptedUserKey).toHaveBeenCalledTimes(1); expect(cryptoService.setMasterKeyEncryptedUserKey).toHaveBeenCalledWith( idTokenResponse.key, userId, ); expect(encryptService.decryptToBytes).toHaveBeenCalledTimes(1); expect(encryptService.decryptToBytes).toHaveBeenCalledWith( idTokenResponse.userDecryptionOptions.webAuthnPrfOption.encryptedPrivateKey, webAuthnCredentials.prfKey, ); expect(cryptoService.rsaDecrypt).toHaveBeenCalledTimes(1); expect(cryptoService.rsaDecrypt).toHaveBeenCalledWith( idTokenResponse.userDecryptionOptions.webAuthnPrfOption.encryptedUserKey.encryptedString, mockPrfPrivateKey, ); expect(cryptoService.setUserKey).toHaveBeenCalledWith(mockUserKey, userId); expect(cryptoService.setPrivateKey).toHaveBeenCalledWith(idTokenResponse.privateKey, userId); // Master key and private key should not be set expect(masterPasswordService.mock.setMasterKey).not.toHaveBeenCalled(); }); it("does not try to set the user key when prfKey is missing", async () => { // Arrange const idTokenResponse: IdentityTokenResponse = identityTokenResponseFactory( null, userDecryptionOptsServerResponseWithWebAuthnPrfOption, ); apiService.postIdentityToken.mockResolvedValue(idTokenResponse); // Remove PRF key webAuthnCredentials.prfKey = null; // Act await webAuthnLoginStrategy.logIn(webAuthnCredentials); // Assert expect(encryptService.decryptToBytes).not.toHaveBeenCalled(); expect(cryptoService.rsaDecrypt).not.toHaveBeenCalled(); expect(cryptoService.setUserKey).not.toHaveBeenCalled(); }); describe.each([ { valueName: "encPrfPrivateKey", }, { valueName: "encUserKey", }, ])("given webAuthn PRF decryption option has missing encrypted key data", ({ valueName }) => { it(`does not set the user key when ${valueName} is missing`, async () => { // Arrange const idTokenResponse = mockIdTokenResponseWithModifiedWebAuthnPrfOption(valueName, null); apiService.postIdentityToken.mockResolvedValue(idTokenResponse); // Act await webAuthnLoginStrategy.logIn(webAuthnCredentials); // Assert expect(cryptoService.setUserKey).not.toHaveBeenCalled(); }); }); it("does not set the user key when the PRF encrypted private key decryption fails", async () => { // Arrange const idTokenResponse: IdentityTokenResponse = identityTokenResponseFactory( null, userDecryptionOptsServerResponseWithWebAuthnPrfOption, ); apiService.postIdentityToken.mockResolvedValue(idTokenResponse); encryptService.decryptToBytes.mockResolvedValue(null); // Act await webAuthnLoginStrategy.logIn(webAuthnCredentials); // Assert expect(cryptoService.setUserKey).not.toHaveBeenCalled(); }); it("does not set the user key when the encrypted user key decryption fails", async () => { // Arrange const idTokenResponse: IdentityTokenResponse = identityTokenResponseFactory( null, userDecryptionOptsServerResponseWithWebAuthnPrfOption, ); apiService.postIdentityToken.mockResolvedValue(idTokenResponse); cryptoService.rsaDecrypt.mockResolvedValue(null); // Act await webAuthnLoginStrategy.logIn(webAuthnCredentials); // Assert expect(cryptoService.setUserKey).not.toHaveBeenCalled(); }); }); // Helpers and mocks function randomBytes(length: number): Uint8Array { return new Uint8Array(Array.from({ length }, (_, k) => k % 255)); } // AuthenticatorAssertionResponse && PublicKeyCredential are only available in secure contexts // so we need to mock them and assign them to the global object to make them available // for the tests export class MockAuthenticatorAssertionResponse implements AuthenticatorAssertionResponse { clientDataJSON: ArrayBuffer = randomBytes(32).buffer; authenticatorData: ArrayBuffer = randomBytes(196).buffer; signature: ArrayBuffer = randomBytes(72).buffer; userHandle: ArrayBuffer = randomBytes(16).buffer; clientDataJSONB64Str = Utils.fromBufferToUrlB64(this.clientDataJSON); authenticatorDataB64Str = Utils.fromBufferToUrlB64(this.authenticatorData); signatureB64Str = Utils.fromBufferToUrlB64(this.signature); userHandleB64Str = Utils.fromBufferToUrlB64(this.userHandle); } export class MockPublicKeyCredential implements PublicKeyCredential { authenticatorAttachment = "cross-platform"; id = "mockCredentialId"; type = "public-key"; rawId: ArrayBuffer = randomBytes(32).buffer; rawIdB64Str = Utils.fromBufferToB64(this.rawId); response: MockAuthenticatorAssertionResponse = new MockAuthenticatorAssertionResponse(); // Use random 64 character hex string (32 bytes - matters for symmetric key creation) // to represent the prf key binary data and convert to ArrayBuffer // Creating the array buffer from a known hex value allows us to // assert on the value in tests private prfKeyArrayBuffer: ArrayBuffer = Utils.hexStringToArrayBuffer( "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", ); getClientExtensionResults(): any { return { prf: { results: { first: this.prfKeyArrayBuffer, }, }, }; } static isConditionalMediationAvailable(): Promise { return Promise.resolve(false); } static isUserVerifyingPlatformAuthenticatorAvailable(): Promise { return Promise.resolve(false); } }