[PM-4168] Enable encryption for registered passkeys (#7074)

* Added enable encryption

* various updates and tests added.

* fixing linter errors

* updated spec file
This commit is contained in:
Ike 2023-12-13 07:02:35 -08:00 committed by GitHub
parent 180d3a99e3
commit 7051f255ed
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 418 additions and 9 deletions

View File

@ -0,0 +1,25 @@
import { WebAuthnLoginAssertionResponseRequest } from "@bitwarden/common/auth/services/webauthn-login/request/webauthn-login-assertion-response.request";
/**
* Request sent to the server to save a newly created prf key set for a credential.
*/
export class EnableCredentialEncryptionRequest {
/**
* The response received from the authenticator.
*/
deviceResponse: WebAuthnLoginAssertionResponseRequest;
/**
* An encrypted token containing information the server needs to verify the credential.
*/
token: string;
/** Used for vault encryption. See {@link RotateableKeySet.encryptedUserKey } */
encryptedUserKey?: string;
/** Used for vault encryption. See {@link RotateableKeySet.encryptedPublicKey } */
encryptedPublicKey?: string;
/** Used for vault encryption. See {@link RotateableKeySet.encryptedPrivateKey } */
encryptedPrivateKey?: string;
}

View File

@ -3,7 +3,7 @@ import { Utils } from "@bitwarden/common/platform/misc/utils";
import { WebauthnLoginAuthenticatorResponseRequest } from "./webauthn-login-authenticator-response.request";
/**
* The response received from an authentiator after a successful attestation.
* The response received from an authenticator after a successful attestation.
* This request is used to save newly created webauthn login credentials to the server.
*/
export class WebauthnLoginAttestationResponseRequest extends WebauthnLoginAuthenticatorResponseRequest {

View File

@ -2,8 +2,10 @@ import { Injectable } from "@angular/core";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { SecretVerificationRequest } from "@bitwarden/common/auth/models/request/secret-verification.request";
import { CredentialAssertionOptionsResponse } from "@bitwarden/common/auth/services/webauthn-login/response/credential-assertion-options.response";
import { ListResponse } from "@bitwarden/common/models/response/list.response";
import { EnableCredentialEncryptionRequest } from "./request/enable-credential-encryption.request";
import { SaveCredentialRequest } from "./request/save-credential.request";
import { WebauthnLoginCredentialCreateOptionsResponse } from "./response/webauthn-login-credential-create-options.response";
import { WebauthnLoginCredentialResponse } from "./response/webauthn-login-credential.response";
@ -15,10 +17,29 @@ export class WebAuthnLoginAdminApiService {
async getCredentialCreateOptions(
request: SecretVerificationRequest,
): Promise<WebauthnLoginCredentialCreateOptionsResponse> {
const response = await this.apiService.send("POST", "/webauthn/options", request, true, true);
const response = await this.apiService.send(
"POST",
"/webauthn/attestation-options",
request,
true,
true,
);
return new WebauthnLoginCredentialCreateOptionsResponse(response);
}
async getCredentialAssertionOptions(
request: SecretVerificationRequest,
): Promise<CredentialAssertionOptionsResponse> {
const response = await this.apiService.send(
"POST",
"/webauthn/assertion-options",
request,
true,
true,
);
return new CredentialAssertionOptionsResponse(response);
}
async saveCredential(request: SaveCredentialRequest): Promise<boolean> {
await this.apiService.send("POST", "/webauthn", request, true, true);
return true;
@ -31,4 +52,8 @@ export class WebAuthnLoginAdminApiService {
async deleteCredential(credentialId: string, request: SecretVerificationRequest): Promise<void> {
await this.apiService.send("POST", `/webauthn/${credentialId}/delete`, request, true, true);
}
async updateCredential(request: EnableCredentialEncryptionRequest): Promise<void> {
await this.apiService.send("PUT", `/webauthn`, request, true, true);
}
}

View File

@ -1,12 +1,21 @@
import { randomBytes } from "crypto";
import { mock, MockProxy } from "jest-mock-extended";
import { RotateableKeySet } from "@bitwarden/auth";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { WebAuthnLoginPrfCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/webauthn/webauthn-login-prf-crypto.service.abstraction";
import { WebAuthnLoginCredentialAssertionView } from "@bitwarden/common/auth/models/view/webauthn-login/webauthn-login-credential-assertion.view";
import { WebAuthnLoginAssertionResponseRequest } from "@bitwarden/common/auth/services/webauthn-login/request/webauthn-login-assertion-response.request";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
import { PrfKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { CredentialCreateOptionsView } from "../../views/credential-create-options.view";
import { PendingWebauthnLoginCredentialView } from "../../views/pending-webauthn-login-credential.view";
import { RotateableKeySetService } from "../rotateable-key-set.service";
import { EnableCredentialEncryptionRequest } from "./request/enable-credential-encryption.request";
import { WebAuthnLoginAdminApiService } from "./webauthn-login-admin-api.service";
import { WebauthnLoginAdminService } from "./webauthn-login-admin.service";
@ -18,10 +27,13 @@ describe("WebauthnAdminService", () => {
let credentials: MockProxy<CredentialsContainer>;
let service!: WebauthnLoginAdminService;
let originalAuthenticatorAssertionResponse!: AuthenticatorAssertionResponse | any;
beforeAll(() => {
// Polyfill missing class
window.PublicKeyCredential = class {} as any;
window.AuthenticatorAttestationResponse = class {} as any;
window.AuthenticatorAssertionResponse = class {} as any;
apiService = mock<WebAuthnLoginAdminApiService>();
userVerificationService = mock<UserVerificationService>();
rotateableKeySetService = mock<RotateableKeySetService>();
@ -34,6 +46,20 @@ describe("WebauthnAdminService", () => {
webAuthnLoginPrfCryptoService,
credentials,
);
// Save original global class
originalAuthenticatorAssertionResponse = global.AuthenticatorAssertionResponse;
// Mock the global AuthenticatorAssertionResponse class b/c the class is only available in secure contexts
global.AuthenticatorAssertionResponse = MockAuthenticatorAssertionResponse;
});
beforeEach(() => {
jest.clearAllMocks();
});
afterAll(() => {
// Restore global after all tests are done
global.AuthenticatorAssertionResponse = originalAuthenticatorAssertionResponse;
});
describe("createCredential", () => {
@ -70,6 +96,94 @@ describe("WebauthnAdminService", () => {
expect(result.supportsPrf).toBe(true);
});
});
describe("enableCredentialEncryption", () => {
it("should call the necessary methods to update the credential", async () => {
// Arrange
const response = new MockPublicKeyCredential();
const prfKeySet = new RotateableKeySet<PrfKey>(
new EncString("test_encryptedUserKey"),
new EncString("test_encryptedPublicKey"),
new EncString("test_encryptedPrivateKey"),
);
const assertionOptions: WebAuthnLoginCredentialAssertionView =
new WebAuthnLoginCredentialAssertionView(
"enable_credential_encryption_test_token",
new WebAuthnLoginAssertionResponseRequest(response),
{} as PrfKey,
);
const request = new EnableCredentialEncryptionRequest();
request.token = assertionOptions.token;
request.deviceResponse = assertionOptions.deviceResponse;
request.encryptedUserKey = prfKeySet.encryptedUserKey.encryptedString;
request.encryptedPublicKey = prfKeySet.encryptedPublicKey.encryptedString;
request.encryptedPrivateKey = prfKeySet.encryptedPrivateKey.encryptedString;
// Mock the necessary methods and services
const createKeySetMock = jest
.spyOn(rotateableKeySetService, "createKeySet")
.mockResolvedValue(prfKeySet);
const updateCredentialMock = jest.spyOn(apiService, "updateCredential").mockResolvedValue();
// Act
await service.enableCredentialEncryption(assertionOptions);
// Assert
expect(createKeySetMock).toHaveBeenCalledWith(assertionOptions.prfKey);
expect(updateCredentialMock).toHaveBeenCalledWith(request);
});
it("should throw error when PRF Key is undefined", async () => {
// Arrange
const response = new MockPublicKeyCredential();
const assertionOptions: WebAuthnLoginCredentialAssertionView =
new WebAuthnLoginCredentialAssertionView(
"enable_credential_encryption_test_token",
new WebAuthnLoginAssertionResponseRequest(response),
undefined,
);
// Mock the necessary methods and services
const createKeySetMock = jest
.spyOn(rotateableKeySetService, "createKeySet")
.mockResolvedValue(null);
const updateCredentialMock = jest.spyOn(apiService, "updateCredential").mockResolvedValue();
// Act
try {
await service.enableCredentialEncryption(assertionOptions);
} catch (error) {
// Assert
expect(error).toEqual(new Error("invalid credential"));
expect(createKeySetMock).not.toHaveBeenCalled();
expect(updateCredentialMock).not.toHaveBeenCalled();
}
});
it("should throw error when WehAuthnLoginCredentialAssertionView is undefined", async () => {
// Arrange
const assertionOptions: WebAuthnLoginCredentialAssertionView = undefined;
// Mock the necessary methods and services
const createKeySetMock = jest
.spyOn(rotateableKeySetService, "createKeySet")
.mockResolvedValue(null);
const updateCredentialMock = jest.spyOn(apiService, "updateCredential").mockResolvedValue();
// Act
try {
await service.enableCredentialEncryption(assertionOptions);
} catch (error) {
// Assert
expect(error).toEqual(new Error("invalid credential"));
expect(createKeySetMock).not.toHaveBeenCalled();
expect(updateCredentialMock).not.toHaveBeenCalled();
}
});
});
});
function createCredentialCreateOptions(): CredentialCreateOptionsView {
@ -115,3 +229,58 @@ function createDeviceResponse({ prf = false }: { prf?: boolean } = {}): PublicKe
return credential;
}
/**
* Mocks for the PublicKeyCredential and AuthenticatorAssertionResponse classes copied from webauthn-login.service.spec.ts
*/
// 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
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);
}
class MockPublicKeyCredential implements PublicKeyCredential {
authenticatorAttachment = "cross-platform";
id = "mockCredentialId";
type = "public-key";
rawId: ArrayBuffer = randomBytes(32).buffer;
rawIdB64Str = Utils.fromBufferToUrlB64(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);
}
}

View File

@ -4,6 +4,8 @@ import { BehaviorSubject, filter, from, map, Observable, shareReplay, switchMap,
import { PrfKeySet } from "@bitwarden/auth";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { WebAuthnLoginPrfCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/webauthn/webauthn-login-prf-crypto.service.abstraction";
import { WebAuthnLoginCredentialAssertionOptionsView } from "@bitwarden/common/auth/models/view/webauthn-login/webauthn-login-credential-assertion-options.view";
import { WebAuthnLoginCredentialAssertionView } from "@bitwarden/common/auth/models/view/webauthn-login/webauthn-login-credential-assertion.view";
import { Verification } from "@bitwarden/common/auth/types/verification";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
@ -12,6 +14,7 @@ import { PendingWebauthnLoginCredentialView } from "../../views/pending-webauthn
import { WebauthnLoginCredentialView } from "../../views/webauthn-login-credential.view";
import { RotateableKeySetService } from "../rotateable-key-set.service";
import { EnableCredentialEncryptionRequest } from "./request/enable-credential-encryption.request";
import { SaveCredentialRequest } from "./request/save-credential.request";
import { WebauthnLoginAttestationResponseRequest } from "./request/webauthn-login-attestation-response.request";
import { WebAuthnLoginAdminApiService } from "./webauthn-login-admin-api.service";
@ -52,14 +55,31 @@ export class WebauthnLoginAdminService {
}
/**
* Get the credential attestation options needed for initiating the WebAuthnLogin credentail creation process.
* Get the credential assertion options needed for initiating the WebAuthnLogin credential update process.
* The options contains assertion options and other data for the authenticator.
* This method requires user verification.
*
* @param verification User verification data to be used for the request.
* @returns The credential assertion options and a token to be used for the credential update request.
*/
async getCredentialAssertOptions(
verification: Verification,
): Promise<WebAuthnLoginCredentialAssertionOptionsView> {
const request = await this.userVerificationService.buildRequest(verification);
const response = await this.apiService.getCredentialAssertionOptions(request);
return new WebAuthnLoginCredentialAssertionOptionsView(response.options, response.token);
}
/**
* Get the credential attestation options needed for initiating the WebAuthnLogin credential creation process.
* The options contains a challenge and other data for the authenticator.
* This method requires user verification.
*
* @param verification User verification data to be used for the request.
* @returns The credential attestation options and a token to be used for the credential creation request.
*/
async getCredentialCreateOptions(
async getCredentialAttestationOptions(
verification: Verification,
): Promise<CredentialCreateOptionsView> {
const request = await this.userVerificationService.buildRequest(verification);
@ -169,6 +189,36 @@ export class WebauthnLoginAdminService {
this.refresh();
}
/**
* Enable encryption for a credential that has already been saved to the server.
* This will update the KeySet associated with the credential in the database.
* We short circuit the process here incase the WebAuthnLoginCredential doesn't support PRF or
* if there was a problem with the Credential Assertion.
*
* @param assertionOptions Options received from the server using `getCredentialAssertOptions`.
* @returns void
*/
async enableCredentialEncryption(
assertionOptions: WebAuthnLoginCredentialAssertionView,
): Promise<void> {
if (assertionOptions === undefined || assertionOptions?.prfKey === undefined) {
throw new Error("invalid credential");
}
const prfKeySet: PrfKeySet = await this.rotateableKeySetService.createKeySet(
assertionOptions.prfKey,
);
const request = new EnableCredentialEncryptionRequest();
request.token = assertionOptions.token;
request.deviceResponse = assertionOptions.deviceResponse;
request.encryptedUserKey = prfKeySet.encryptedUserKey.encryptedString;
request.encryptedPublicKey = prfKeySet.encryptedPublicKey.encryptedString;
request.encryptedPrivateKey = prfKeySet.encryptedPrivateKey.encryptedString;
await this.apiService.updateCredential(request);
this.refresh();
}
/**
* List of webauthn credentials saved on the server.
*

View File

@ -94,7 +94,7 @@ export class CreateCredentialDialogComponent implements OnInit {
}
try {
this.credentialOptions = await this.webauthnService.getCredentialCreateOptions(
this.credentialOptions = await this.webauthnService.getCredentialAttestationOptions(
this.formGroup.value.userVerification.secret,
);
} catch (error) {

View File

@ -0,0 +1,34 @@
<form [formGroup]="formGroup" [bitSubmit]="submit">
<bit-dialog dialogSize="large" [loading]="loading$ | async">
<span bitDialogTitle
>{{ "enablePasskeyEncryption" | i18n }}
<span *ngIf="credential" class="tw-text-sm tw-normal-case tw-text-muted">{{
credential.name
}}</span>
</span>
<ng-container bitDialogContent>
<ng-container *ngIf="!credential">
<i class="bwi bwi-spinner bwi-spin tw-ml-1" aria-hidden="true"></i>
</ng-container>
<ng-container *ngIf="credential">
<p bitTypography="body1">{{ "useForVaultEncryptionInfo" | i18n }}</p>
<ng-container formGroupName="userVerification">
<app-user-verification
formControlName="secret"
[(invalidSecret)]="invalidSecret"
></app-user-verification>
</ng-container>
</ng-container>
</ng-container>
<ng-container bitDialogFooter>
<button type="submit" bitButton bitFormButton buttonType="primary">
{{ "submit" | i18n }}
</button>
<button type="button" bitButton bitFormButton buttonType="secondary" bitDialogClose>
{{ "cancel" | i18n }}
</button>
</ng-container>
</bit-dialog>
</form>

View File

@ -0,0 +1,91 @@
import { DIALOG_DATA, DialogConfig, DialogRef } from "@angular/cdk/dialog";
import { Component, Inject, OnDestroy, OnInit } from "@angular/core";
import { FormBuilder, Validators } from "@angular/forms";
import { Subject } from "rxjs";
import { takeUntil } from "rxjs/operators";
import { WebAuthnLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/webauthn/webauthn-login.service.abstraction";
import { WebAuthnLoginCredentialAssertionOptionsView } from "@bitwarden/common/auth/models/view/webauthn-login/webauthn-login-credential-assertion-options.view";
import { Verification } from "@bitwarden/common/auth/types/verification";
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
import { DialogService } from "@bitwarden/components/src/dialog/dialog.service";
import { WebauthnLoginAdminService } from "../../../core/services/webauthn-login/webauthn-login-admin.service";
import { WebauthnLoginCredentialView } from "../../../core/views/webauthn-login-credential.view";
export interface EnableEncryptionDialogParams {
credentialId: string;
}
@Component({
templateUrl: "enable-encryption-dialog.component.html",
})
export class EnableEncryptionDialogComponent implements OnInit, OnDestroy {
private destroy$ = new Subject<void>();
protected invalidSecret = false;
protected formGroup = this.formBuilder.group({
userVerification: this.formBuilder.group({
secret: [null as Verification | null, Validators.required],
}),
});
protected credential?: WebauthnLoginCredentialView;
protected credentialOptions?: WebAuthnLoginCredentialAssertionOptionsView;
protected loading$ = this.webauthnService.loading$;
constructor(
@Inject(DIALOG_DATA) private params: EnableEncryptionDialogParams,
private formBuilder: FormBuilder,
private dialogRef: DialogRef,
private webauthnService: WebauthnLoginAdminService,
private webauthnLoginService: WebAuthnLoginServiceAbstraction,
) {}
ngOnInit(): void {
this.webauthnService
.getCredential$(this.params.credentialId)
.pipe(takeUntil(this.destroy$))
.subscribe((credential: any) => (this.credential = credential));
}
submit = async () => {
if (this.credential === undefined) {
return;
}
this.dialogRef.disableClose = true;
try {
this.credentialOptions = await this.webauthnService.getCredentialAssertOptions(
this.formGroup.value.userVerification.secret,
);
await this.webauthnService.enableCredentialEncryption(
await this.webauthnLoginService.assertCredential(this.credentialOptions),
);
} catch (error) {
if (error instanceof ErrorResponse && error.statusCode === 400) {
this.invalidSecret = true;
}
throw error;
}
this.dialogRef.close();
};
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
}
/**
* Strongly typed helper to open a EnableEncryptionDialogComponent
* @param dialogService Instance of the dialog service that will be used to open the dialog
* @param config Configuration for the dialog
*/
export const openEnableCredentialDialogComponent = (
dialogService: DialogService,
config: DialogConfig<EnableEncryptionDialogParams>,
) => {
return dialogService.open<unknown>(EnableEncryptionDialogComponent, config);
};

View File

@ -39,8 +39,16 @@
<span bitTypography="body1" class="tw-text-muted">{{ "usedForEncryption" | i18n }}</span>
</ng-container>
<ng-container *ngIf="credential.prfStatus === WebauthnLoginCredentialPrfStatus.Supported">
<i class="bwi bwi-lock-encrypted"></i>
<span bitTypography="body1" class="tw-text-muted">{{ "encryptionNotEnabled" | i18n }}</span>
<button
type="button"
bitLink
[disabled]="loading"
[attr.aria-label]="('enablePasskeyEncryption' | i18n) + ' ' + credential.name"
(click)="enableEncryption(credential.id)"
>
<i class="bwi bwi-lock-encrypted"></i>
{{ "enablePasskeyEncryption" | i18n }}
</button>
</ng-container>
<span
*ngIf="credential.prfStatus === WebauthnLoginCredentialPrfStatus.Unsupported"

View File

@ -11,6 +11,7 @@ import { WebauthnLoginCredentialView } from "../../core/views/webauthn-login-cre
import { openCreateCredentialDialog } from "./create-credential-dialog/create-credential-dialog.component";
import { openDeleteCredentialDialogComponent } from "./delete-credential-dialog/delete-credential-dialog.component";
import { openEnableCredentialDialogComponent } from "./enable-encryption-dialog/enable-encryption-dialog.component";
@Component({
selector: "app-webauthn-login-settings",
@ -83,4 +84,8 @@ export class WebauthnLoginSettingsComponent implements OnInit, OnDestroy {
protected deleteCredential(credentialId: string) {
openDeleteCredentialDialogComponent(this.dialogService, { data: { credentialId } });
}
protected enableEncryption(credentialId: string) {
openEnableCredentialDialogComponent(this.dialogService, { data: { credentialId } });
}
}

View File

@ -8,6 +8,7 @@ import { UserVerificationModule } from "../../shared/components/user-verificatio
import { CreateCredentialDialogComponent } from "./create-credential-dialog/create-credential-dialog.component";
import { DeleteCredentialDialogComponent } from "./delete-credential-dialog/delete-credential-dialog.component";
import { EnableEncryptionDialogComponent } from "./enable-encryption-dialog/enable-encryption-dialog.component";
import { WebauthnLoginSettingsComponent } from "./webauthn-login-settings.component";
@NgModule({
@ -16,6 +17,7 @@ import { WebauthnLoginSettingsComponent } from "./webauthn-login-settings.compon
WebauthnLoginSettingsComponent,
CreateCredentialDialogComponent,
DeleteCredentialDialogComponent,
EnableEncryptionDialogComponent,
],
exports: [WebauthnLoginSettingsComponent],
})

View File

@ -674,8 +674,8 @@
"encryptionNotSupported": {
"message": "Encryption not supported"
},
"encryptionNotEnabled": {
"message": "Encryption not enabled"
"enablePasskeyEncryption": {
"message": "Set up encryption"
},
"usedForEncryption": {
"message": "Used for encryption"