364 lines
16 KiB
TypeScript
364 lines
16 KiB
TypeScript
import { mock, MockProxy } from "jest-mock-extended";
|
|
|
|
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 { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service";
|
|
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.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 { 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<CryptoService>;
|
|
let apiService!: MockProxy<ApiService>;
|
|
let tokenService!: MockProxy<TokenService>;
|
|
let appIdService!: MockProxy<AppIdService>;
|
|
let platformUtilsService!: MockProxy<PlatformUtilsService>;
|
|
let messagingService!: MockProxy<MessagingService>;
|
|
let logService!: MockProxy<LogService>;
|
|
let stateService!: MockProxy<StateService>;
|
|
let twoFactorService!: MockProxy<TwoFactorService>;
|
|
let userDecryptionOptionsService: MockProxy<InternalUserDecryptionOptionsServiceAbstraction>;
|
|
let billingAccountProfileStateService: MockProxy<BillingAccountProfileStateService>;
|
|
let kdfConfigService: MockProxy<KdfConfigService>;
|
|
|
|
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<CryptoService>();
|
|
apiService = mock<ApiService>();
|
|
tokenService = mock<TokenService>();
|
|
appIdService = mock<AppIdService>();
|
|
platformUtilsService = mock<PlatformUtilsService>();
|
|
messagingService = mock<MessagingService>();
|
|
logService = mock<LogService>();
|
|
stateService = mock<StateService>();
|
|
twoFactorService = mock<TwoFactorService>();
|
|
userDecryptionOptionsService = mock<InternalUserDecryptionOptionsServiceAbstraction>();
|
|
billingAccountProfileStateService = mock<BillingAccountProfileStateService>();
|
|
kdfConfigService = mock<KdfConfigService>();
|
|
|
|
tokenService.getTwoFactorToken.mockResolvedValue(null);
|
|
appIdService.getAppId.mockResolvedValue(deviceId);
|
|
tokenService.decodeAccessToken.mockResolvedValue({
|
|
sub: userId,
|
|
});
|
|
|
|
webAuthnLoginStrategy = new WebAuthnLoginStrategy(
|
|
cache,
|
|
accountService,
|
|
masterPasswordService,
|
|
cryptoService,
|
|
apiService,
|
|
tokenService,
|
|
appIdService,
|
|
platformUtilsService,
|
|
messagingService,
|
|
logService,
|
|
stateService,
|
|
twoFactorService,
|
|
userDecryptionOptionsService,
|
|
billingAccountProfileStateService,
|
|
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);
|
|
});
|
|
|
|
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;
|
|
|
|
cryptoService.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);
|
|
|
|
expect(cryptoService.decryptToBytes).toHaveBeenCalledTimes(1);
|
|
expect(cryptoService.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);
|
|
expect(cryptoService.setPrivateKey).toHaveBeenCalledWith(idTokenResponse.privateKey);
|
|
|
|
// 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(cryptoService.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);
|
|
|
|
cryptoService.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<boolean> {
|
|
return Promise.resolve(false);
|
|
}
|
|
|
|
static isUserVerifyingPlatformAuthenticatorAvailable(): Promise<boolean> {
|
|
return Promise.resolve(false);
|
|
}
|
|
}
|