1
0
mirror of https://github.com/bitwarden/browser.git synced 2024-11-29 12:55:21 +01:00

[PM-2241] Add PRF attestation flow during passkey registration (#6525)

* [PM-2241] chore: refactor into new "pending" view type

* [PM-2241] feat: record PRF support

* [PM-2241] feat: add prf checkbox to dialog

* [PM-2241] chore: remove `disableMargin` instead

Will expressed his concern that these things aren't sustainable, and that we should try using `!important` statements instead, which is a good point!

* [PM-2241] feat: add prf registration

* [PM-2241] feat: add support for `prfStatus`

* [PM-2241] feat: add rotateable key set

* [PM-2241] feat: add PRF creation error handling

* [PM-2241] chore: improve rotateable key docs

* [PM-2241] feat: add basic test

* [PM-2241] chore: update `SaveCredentialRequest` docs

* [PM-2241] chore: rename to `WebauthnLoginAdminService`

* [PM-2241] fix: typo in `save-credential.request.ts`

* [PM-2241] fix: typo in more places
This commit is contained in:
Andreas Coroiu 2023-11-08 14:35:36 +01:00 committed by GitHub
parent c7b448cdc8
commit 65d2d74348
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
27 changed files with 458 additions and 119 deletions

View File

@ -0,0 +1,5 @@
export enum WebauthnLoginCredentialPrfStatus {
Enabled = 0,
Supported = 1,
Unsupported = 2,
}

View File

@ -0,0 +1,57 @@
import { TestBed } from "@angular/core/testing";
import { mock, MockProxy } from "jest-mock-extended";
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { RotateableKeySetService } from "./rotateable-key-set.service";
describe("RotateableKeySetService", () => {
let testBed!: TestBed;
let cryptoService!: MockProxy<CryptoService>;
let encryptService!: MockProxy<EncryptService>;
let service!: RotateableKeySetService;
beforeEach(() => {
cryptoService = mock<CryptoService>();
encryptService = mock<EncryptService>();
testBed = TestBed.configureTestingModule({
providers: [
{ provide: CryptoService, useValue: cryptoService },
{ provide: EncryptService, useValue: encryptService },
],
});
service = testBed.inject(RotateableKeySetService);
});
describe("createKeySet", () => {
it("should create a new key set", async () => {
const externalKey = createSymmetricKey();
const userKey = createSymmetricKey();
const encryptedUserKey = Symbol();
const encryptedPublicKey = Symbol();
const encryptedPrivateKey = Symbol();
cryptoService.makeKeyPair.mockResolvedValue(["publicKey", encryptedPrivateKey as any]);
cryptoService.getUserKey.mockResolvedValue({ key: userKey.key } as any);
cryptoService.rsaEncrypt.mockResolvedValue(encryptedUserKey as any);
encryptService.encrypt.mockResolvedValue(encryptedPublicKey as any);
const result = await service.createKeySet(externalKey as any);
expect(result).toEqual({
encryptedUserKey,
encryptedPublicKey,
encryptedPrivateKey,
});
});
});
});
function createSymmetricKey() {
const key = Utils.fromB64ToArray(
"1h-TuPwSbX5qoX0aVgjmda_Lfq85qAcKssBlXZnPIsQC3HNDGIecunYqXhJnp55QpdXRh-egJiLH3a0wqlVQsQ"
);
return new SymmetricCryptoKey(key);
}

View File

@ -0,0 +1,32 @@
import { inject, Injectable } from "@angular/core";
import { RotateableKeySet } from "@bitwarden/auth";
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
@Injectable({ providedIn: "root" })
export class RotateableKeySetService {
private readonly cryptoService = inject(CryptoService);
private readonly encryptService = inject(EncryptService);
/**
* Create a new rotateable key set for the current user, using the provided external key.
* For more information on rotateable key sets, see {@link RotateableKeySet}
*
* @param externalKey The `ExternalKey` used to encrypt {@link RotateableKeySet.encryptedPrivateKey}
* @returns RotateableKeySet containing the current users `UserKey`
*/
async createKeySet<ExternalKey extends SymmetricCryptoKey>(
externalKey: ExternalKey
): Promise<RotateableKeySet<ExternalKey>> {
const [publicKey, encryptedPrivateKey] = await this.cryptoService.makeKeyPair(externalKey);
const userKey = await this.cryptoService.getUserKey();
const rawPublicKey = Utils.fromB64ToArray(publicKey);
const encryptedUserKey = await this.cryptoService.rsaEncrypt(userKey.key, rawPublicKey);
const encryptedPublicKey = await this.encryptService.encrypt(rawPublicKey, userKey);
return new RotateableKeySet(encryptedUserKey, encryptedPublicKey, encryptedPrivateKey);
}
}

View File

@ -1 +1 @@
export * from "./webauthn-login.service"; export * from "./webauthn-login-admin.service";

View File

@ -4,7 +4,10 @@ import { WebauthnLoginAttestationResponseRequest } from "./webauthn-login-attest
* Request sent to the server to save a newly created webauthn login credential. * Request sent to the server to save a newly created webauthn login credential.
*/ */
export class SaveCredentialRequest { export class SaveCredentialRequest {
/** The response recieved from the authenticator. This contains the public key */ /**
* The response received from the authenticator.
* This contains all information needed for future authentication flows.
*/
deviceResponse: WebauthnLoginAttestationResponseRequest; deviceResponse: WebauthnLoginAttestationResponseRequest;
/** Nickname chosen by the user to identify this credential */ /** Nickname chosen by the user to identify this credential */
@ -15,4 +18,18 @@ export class SaveCredentialRequest {
* It contains encrypted information that the server needs to verify the credential. * It contains encrypted information that the server needs to verify the credential.
*/ */
token: string; token: string;
/**
* True if the credential was created with PRF support.
*/
supportsPrf: boolean;
/** 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"; import { WebauthnLoginAuthenticatorResponseRequest } from "./webauthn-login-authenticator-response.request";
/** /**
* The response recieved from an authentiator after a successful attestation. * The response received from an authentiator after a successful attestation.
* This request is used to save newly created webauthn login credentials to the server. * This request is used to save newly created webauthn login credentials to the server.
*/ */
export class WebauthnLoginAttestationResponseRequest extends WebauthnLoginAuthenticatorResponseRequest { export class WebauthnLoginAttestationResponseRequest extends WebauthnLoginAuthenticatorResponseRequest {

View File

@ -1,7 +1,7 @@
import { Utils } from "@bitwarden/common/platform/misc/utils"; import { Utils } from "@bitwarden/common/platform/misc/utils";
/** /**
* An abstract class that represents responses recieved from the webauthn authenticator. * An abstract class that represents responses received from the webauthn authenticator.
* It contains data that is commonly returned during different types of authenticator interactions. * It contains data that is commonly returned during different types of authenticator interactions.
*/ */
export abstract class WebauthnLoginAuthenticatorResponseRequest { export abstract class WebauthnLoginAuthenticatorResponseRequest {

View File

@ -1,17 +1,19 @@
import { BaseResponse } from "@bitwarden/common/models/response/base.response"; import { BaseResponse } from "@bitwarden/common/models/response/base.response";
import { WebauthnLoginCredentialPrfStatus } from "../../../enums/webauthn-login-credential-prf-status.enum";
/** /**
* A webauthn login credential recieved from the server. * A webauthn login credential received from the server.
*/ */
export class WebauthnLoginCredentialResponse extends BaseResponse { export class WebauthnLoginCredentialResponse extends BaseResponse {
id: string; id: string;
name: string; name: string;
prfSupport: boolean; prfStatus: WebauthnLoginCredentialPrfStatus;
constructor(response: unknown) { constructor(response: unknown) {
super(response); super(response);
this.id = this.getResponseProperty("id"); this.id = this.getResponseProperty("Id");
this.name = this.getResponseProperty("name"); this.name = this.getResponseProperty("Name");
this.prfSupport = this.getResponseProperty("prfSupport"); this.prfStatus = this.getResponseProperty("PrfStatus");
} }
} }

View File

@ -0,0 +1,15 @@
import { Utils } from "@bitwarden/common/platform/misc/utils";
import {
PrfKey,
SymmetricCryptoKey,
} from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
const LoginWithPrfSalt = "passwordless-login";
export async function getLoginWithPrfSalt(): Promise<ArrayBuffer> {
return await crypto.subtle.digest("sha-256", Utils.fromUtf8ToArray(LoginWithPrfSalt));
}
export function createSymmetricKeyFromPrf(prf: ArrayBuffer) {
return new SymmetricCryptoKey(new Uint8Array(prf)) as PrfKey;
}

View File

@ -0,0 +1,113 @@
import { mock, MockProxy } from "jest-mock-extended";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
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 { WebauthnLoginAdminService } from "./webauthn-login-admin.service";
import { WebauthnLoginApiService } from "./webauthn-login-api.service";
describe("WebauthnAdminService", () => {
let apiService!: MockProxy<WebauthnLoginApiService>;
let userVerificationService!: MockProxy<UserVerificationService>;
let rotateableKeySetService!: MockProxy<RotateableKeySetService>;
let credentials: MockProxy<CredentialsContainer>;
let service!: WebauthnLoginAdminService;
beforeAll(() => {
// Polyfill missing class
window.PublicKeyCredential = class {} as any;
window.AuthenticatorAttestationResponse = class {} as any;
apiService = mock<WebauthnLoginApiService>();
userVerificationService = mock<UserVerificationService>();
rotateableKeySetService = mock<RotateableKeySetService>();
credentials = mock<CredentialsContainer>();
service = new WebauthnLoginAdminService(
apiService,
userVerificationService,
rotateableKeySetService,
credentials
);
});
describe("createCredential", () => {
it("should return undefined when navigator.credentials throws", async () => {
credentials.create.mockRejectedValue(new Error("Mocked error"));
const options = createCredentialCreateOptions();
const result = await service.createCredential(options);
expect(result).toBeUndefined();
});
it("should return credential when navigator.credentials does not throw", async () => {
const deviceResponse = createDeviceResponse({ prf: false });
credentials.create.mockResolvedValue(deviceResponse as PublicKeyCredential);
const createOptions = createCredentialCreateOptions();
const result = await service.createCredential(createOptions);
expect(result).toEqual({
deviceResponse,
createOptions,
supportsPrf: false,
} as PendingWebauthnLoginCredentialView);
});
it("should return supportsPrf=true when extensions contain prf", async () => {
const deviceResponse = createDeviceResponse({ prf: true });
credentials.create.mockResolvedValue(deviceResponse as PublicKeyCredential);
const createOptions = createCredentialCreateOptions();
const result = await service.createCredential(createOptions);
expect(result.supportsPrf).toBe(true);
});
});
});
function createCredentialCreateOptions(): CredentialCreateOptionsView {
const challenge = {
publicKey: {
extensions: {},
},
rp: {
id: "bitwarden.com",
},
authenticatorSelection: {
userVerification: "required",
residentKey: "required",
},
};
return new CredentialCreateOptionsView(challenge as any, Symbol() as any);
}
function createDeviceResponse({ prf = false }: { prf?: boolean } = {}): PublicKeyCredential {
const credential = {
id: "Y29yb2l1IHdhcyBoZXJl",
rawId: new Uint8Array([0x74, 0x65, 0x73, 0x74]),
type: "public-key",
response: {
attestationObject: new Uint8Array([0, 0, 0]),
clientDataJSON: "eyJ0ZXN0IjoidGVzdCJ9",
},
getClientExtensionResults: () => {
if (!prf) {
return {};
}
return {
prf: {
enabled: true,
},
};
},
} as any;
Object.setPrototypeOf(credential, PublicKeyCredential.prototype);
Object.setPrototypeOf(credential.response, AuthenticatorAttestationResponse.prototype);
return credential;
}

View File

@ -1,19 +1,23 @@
import { Injectable, Optional } from "@angular/core"; import { Injectable, Optional } from "@angular/core";
import { BehaviorSubject, filter, from, map, Observable, shareReplay, switchMap, tap } from "rxjs"; import { BehaviorSubject, filter, from, map, Observable, shareReplay, switchMap, tap } from "rxjs";
import { PrfKeySet } from "@bitwarden/auth";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { Verification } from "@bitwarden/common/types/verification"; import { Verification } from "@bitwarden/common/types/verification";
import { CredentialCreateOptionsView } from "../../views/credential-create-options.view"; import { CredentialCreateOptionsView } from "../../views/credential-create-options.view";
import { WebauthnCredentialView } from "../../views/webauth-credential.view"; import { PendingWebauthnLoginCredentialView } from "../../views/pending-webauthn-login-credential.view";
import { WebauthnLoginCredentialView } from "../../views/webauthn-login-credential.view";
import { RotateableKeySetService } from "../rotateable-key-set.service";
import { SaveCredentialRequest } from "./request/save-credential.request"; import { SaveCredentialRequest } from "./request/save-credential.request";
import { WebauthnLoginAttestationResponseRequest } from "./request/webauthn-login-attestation-response.request"; import { WebauthnLoginAttestationResponseRequest } from "./request/webauthn-login-attestation-response.request";
import { createSymmetricKeyFromPrf, getLoginWithPrfSalt } from "./utils";
import { WebauthnLoginApiService } from "./webauthn-login-api.service"; import { WebauthnLoginApiService } from "./webauthn-login-api.service";
@Injectable({ providedIn: "root" }) @Injectable({ providedIn: "root" })
export class WebauthnLoginService { export class WebauthnLoginAdminService {
static readonly MaxCredentialCount = 5; static readonly MaxCredentialCount = 5;
private navigatorCredentials: CredentialsContainer; private navigatorCredentials: CredentialsContainer;
@ -31,6 +35,7 @@ export class WebauthnLoginService {
constructor( constructor(
private apiService: WebauthnLoginApiService, private apiService: WebauthnLoginApiService,
private userVerificationService: UserVerificationService, private userVerificationService: UserVerificationService,
private rotateableKeySetService: RotateableKeySetService,
@Optional() navigatorCredentials?: CredentialsContainer, @Optional() navigatorCredentials?: CredentialsContainer,
@Optional() private logService?: LogService @Optional() private logService?: LogService
) { ) {
@ -48,17 +53,60 @@ export class WebauthnLoginService {
async createCredential( async createCredential(
credentialOptions: CredentialCreateOptionsView credentialOptions: CredentialCreateOptionsView
): Promise<PublicKeyCredential | undefined> { ): Promise<PendingWebauthnLoginCredentialView | undefined> {
const nativeOptions: CredentialCreationOptions = { const nativeOptions: CredentialCreationOptions = {
publicKey: credentialOptions.options, publicKey: credentialOptions.options,
}; };
// TODO: Remove `any` when typescript typings add support for PRF
nativeOptions.publicKey.extensions = {
prf: {},
} as any;
try { try {
const response = await this.navigatorCredentials.create(nativeOptions); const response = await this.navigatorCredentials.create(nativeOptions);
if (!(response instanceof PublicKeyCredential)) { if (!(response instanceof PublicKeyCredential)) {
return undefined; return undefined;
} }
return response; // TODO: Remove `any` when typescript typings add support for PRF
const supportsPrf = Boolean((response.getClientExtensionResults() as any).prf?.enabled);
return new PendingWebauthnLoginCredentialView(credentialOptions, response, supportsPrf);
} catch (error) {
this.logService?.error(error);
return undefined;
}
}
async createKeySet(
pendingCredential: PendingWebauthnLoginCredentialView
): Promise<PrfKeySet | undefined> {
const nativeOptions: CredentialRequestOptions = {
publicKey: {
challenge: pendingCredential.createOptions.options.challenge,
allowCredentials: [{ id: pendingCredential.deviceResponse.rawId, type: "public-key" }],
rpId: pendingCredential.createOptions.options.rp.id,
timeout: pendingCredential.createOptions.options.timeout,
userVerification:
pendingCredential.createOptions.options.authenticatorSelection.userVerification,
// TODO: Remove `any` when typescript typings add support for PRF
extensions: { prf: { eval: { first: await getLoginWithPrfSalt() } } } as any,
},
};
try {
const response = await this.navigatorCredentials.get(nativeOptions);
if (!(response instanceof PublicKeyCredential)) {
return undefined;
}
// TODO: Remove `any` when typescript typings add support for PRF
const prfResult = (response.getClientExtensionResults() as any).prf?.results?.first;
if (prfResult === undefined) {
return undefined;
}
const symmetricPrfKey = createSymmetricKeyFromPrf(prfResult);
return await this.rotateableKeySetService.createKeySet(symmetricPrfKey);
} catch (error) { } catch (error) {
this.logService?.error(error); this.logService?.error(error);
return undefined; return undefined;
@ -66,14 +114,18 @@ export class WebauthnLoginService {
} }
async saveCredential( async saveCredential(
credentialOptions: CredentialCreateOptionsView, name: string,
deviceResponse: PublicKeyCredential, credential: PendingWebauthnLoginCredentialView,
name: string prfKeySet?: PrfKeySet
) { ) {
const request = new SaveCredentialRequest(); const request = new SaveCredentialRequest();
request.deviceResponse = new WebauthnLoginAttestationResponseRequest(deviceResponse); request.deviceResponse = new WebauthnLoginAttestationResponseRequest(credential.deviceResponse);
request.token = credentialOptions.token; request.token = credential.createOptions.token;
request.name = name; request.name = name;
request.supportsPrf = credential.supportsPrf;
request.encryptedUserKey = prfKeySet?.encryptedUserKey.encryptedString;
request.encryptedPublicKey = prfKeySet?.encryptedPublicKey.encryptedString;
request.encryptedPrivateKey = prfKeySet?.encryptedPrivateKey.encryptedString;
await this.apiService.saveCredential(request); await this.apiService.saveCredential(request);
this.refresh(); this.refresh();
} }
@ -88,11 +140,11 @@ export class WebauthnLoginService {
* - The observable is lazy and will only fetch credentials when subscribed to. * - The observable is lazy and will only fetch credentials when subscribed to.
* - Don't subscribe to this in the constructor of a long-running service, as it will keep the observable alive. * - Don't subscribe to this in the constructor of a long-running service, as it will keep the observable alive.
*/ */
getCredentials$(): Observable<WebauthnCredentialView[]> { getCredentials$(): Observable<WebauthnLoginCredentialView[]> {
return this.credentials$; return this.credentials$;
} }
getCredential$(credentialId: string): Observable<WebauthnCredentialView> { getCredential$(credentialId: string): Observable<WebauthnLoginCredentialView> {
return this.credentials$.pipe( return this.credentials$.pipe(
map((credentials) => credentials.find((c) => c.id === credentialId)), map((credentials) => credentials.find((c) => c.id === credentialId)),
filter((c) => c !== undefined) filter((c) => c !== undefined)
@ -105,8 +157,15 @@ export class WebauthnLoginService {
this.refresh(); this.refresh();
} }
private fetchCredentials$(): Observable<WebauthnCredentialView[]> { private fetchCredentials$(): Observable<WebauthnLoginCredentialView[]> {
return from(this.apiService.getCredentials()).pipe(map((response) => response.data)); return from(this.apiService.getCredentials()).pipe(
map((response) =>
response.data.map(
(credential) =>
new WebauthnLoginCredentialView(credential.id, credential.name, credential.prfStatus)
)
)
);
} }
private refresh() { private refresh() {

View File

@ -1,67 +0,0 @@
import { mock, MockProxy } from "jest-mock-extended";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { CredentialCreateOptionsView } from "../../views/credential-create-options.view";
import { WebauthnLoginApiService } from "./webauthn-login-api.service";
import { WebauthnLoginService } from "./webauthn-login.service";
describe("WebauthnService", () => {
let apiService!: MockProxy<WebauthnLoginApiService>;
let userVerificationService!: MockProxy<UserVerificationService>;
let credentials: MockProxy<CredentialsContainer>;
let webauthnService!: WebauthnLoginService;
beforeAll(() => {
// Polyfill missing class
window.PublicKeyCredential = class {} as any;
window.AuthenticatorAttestationResponse = class {} as any;
apiService = mock<WebauthnLoginApiService>();
userVerificationService = mock<UserVerificationService>();
credentials = mock<CredentialsContainer>();
webauthnService = new WebauthnLoginService(apiService, userVerificationService, credentials);
});
describe("createCredential", () => {
it("should return undefined when navigator.credentials throws", async () => {
credentials.create.mockRejectedValue(new Error("Mocked error"));
const options = createCredentialCreateOptions();
const result = await webauthnService.createCredential(options);
expect(result).toBeUndefined();
});
it("should return credential when navigator.credentials does not throw", async () => {
const credential = createDeviceResponse();
credentials.create.mockResolvedValue(credential as PublicKeyCredential);
const options = createCredentialCreateOptions();
const result = await webauthnService.createCredential(options);
expect(result).toBe(credential);
});
});
});
function createCredentialCreateOptions(): CredentialCreateOptionsView {
return new CredentialCreateOptionsView(Symbol() as any, Symbol() as any);
}
function createDeviceResponse(): PublicKeyCredential {
const credential = {
id: "dGVzdA==",
rawId: new Uint8Array([0x74, 0x65, 0x73, 0x74]),
type: "public-key",
response: {
attestationObject: new Uint8Array([0, 0, 0]),
clientDataJSON: "eyJ0ZXN0IjoidGVzdCJ9",
},
} as any;
Object.setPrototypeOf(credential, PublicKeyCredential.prototype);
Object.setPrototypeOf(credential.response, AuthenticatorAttestationResponse.prototype);
return credential;
}

View File

@ -0,0 +1,12 @@
import { CredentialCreateOptionsView } from "./credential-create-options.view";
/**
* Represents a WebAuthn credential that has been created by an authenticator but not yet saved to the server.
*/
export class PendingWebauthnLoginCredentialView {
constructor(
readonly createOptions: CredentialCreateOptionsView,
readonly deviceResponse: PublicKeyCredential,
readonly supportsPrf: boolean
) {}
}

View File

@ -1,5 +0,0 @@
export class WebauthnCredentialView {
id: string;
name: string;
prfSupport: boolean;
}

View File

@ -0,0 +1,9 @@
import { WebauthnLoginCredentialPrfStatus } from "../enums/webauthn-login-credential-prf-status.enum";
export class WebauthnLoginCredentialView {
constructor(
readonly id: string,
readonly name: string,
readonly prfStatus: WebauthnLoginCredentialPrfStatus
) {}
}

View File

@ -32,12 +32,12 @@
<p bitTypography="body1">{{ "errorCreatingPasskeyInfo" | i18n }}</p> <p bitTypography="body1">{{ "errorCreatingPasskeyInfo" | i18n }}</p>
</div> </div>
<div *ngIf="currentStep === 'credentialNaming'"> <div *ngIf="currentStep === 'credentialNaming'" formGroupName="credentialNaming">
<h3 bitTypography="h3">{{ "passkeySuccessfullyCreated" | i18n }}</h3> <h3 bitTypography="h3">{{ "passkeySuccessfullyCreated" | i18n }}</h3>
<p bitTypography="body1"> <p bitTypography="body1">
{{ "customPasskeyNameInfo" | i18n }} {{ "customPasskeyNameInfo" | i18n }}
</p> </p>
<bit-form-field disableMargin formGroupName="credentialNaming"> <bit-form-field class="!tw-mb-0">
<bit-label>{{ "customName" | i18n }}</bit-label> <bit-label>{{ "customName" | i18n }}</bit-label>
<input type="text" bitInput formControlName="name" appAutofocus /> <input type="text" bitInput formControlName="name" appAutofocus />
<bit-hint>{{ <bit-hint>{{
@ -45,6 +45,11 @@
| i18n : formGroup.value.credentialNaming.name.length : NameMaxCharacters | i18n : formGroup.value.credentialNaming.name.length : NameMaxCharacters
}}</bit-hint> }}</bit-hint>
</bit-form-field> </bit-form-field>
<bit-form-control *ngIf="pendingCredential?.supportsPrf" class="!tw-mb-0 tw-mt-6">
<input type="checkbox" bitCheckbox formControlName="useForEncryption" />
<bit-label>{{ "useForVaultEncryption" | i18n }}</bit-label>
<bit-hint>{{ "useForVaultEncryptionInfo" | i18n }}</bit-hint>
</bit-form-control>
</div> </div>
</ng-container> </ng-container>
<ng-container bitDialogFooter> <ng-container bitDialogFooter>

View File

@ -3,6 +3,7 @@ import { Component, OnInit } from "@angular/core";
import { FormBuilder, Validators } from "@angular/forms"; import { FormBuilder, Validators } from "@angular/forms";
import { firstValueFrom, map, Observable } from "rxjs"; import { firstValueFrom, map, Observable } from "rxjs";
import { PrfKeySet } from "@bitwarden/auth";
import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
@ -10,8 +11,9 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl
import { Verification } from "@bitwarden/common/types/verification"; import { Verification } from "@bitwarden/common/types/verification";
import { DialogService } from "@bitwarden/components"; import { DialogService } from "@bitwarden/components";
import { WebauthnLoginService } from "../../../core"; import { WebauthnLoginAdminService } from "../../../core";
import { CredentialCreateOptionsView } from "../../../core/views/credential-create-options.view"; import { CredentialCreateOptionsView } from "../../../core/views/credential-create-options.view";
import { PendingWebauthnLoginCredentialView } from "../../../core/views/pending-webauthn-login-credential.view";
import { CreatePasskeyFailedIcon } from "./create-passkey-failed.icon"; import { CreatePasskeyFailedIcon } from "./create-passkey-failed.icon";
import { CreatePasskeyIcon } from "./create-passkey.icon"; import { CreatePasskeyIcon } from "./create-passkey.icon";
@ -42,17 +44,19 @@ export class CreateCredentialDialogComponent implements OnInit {
}), }),
credentialNaming: this.formBuilder.group({ credentialNaming: this.formBuilder.group({
name: ["", Validators.maxLength(50)], name: ["", Validators.maxLength(50)],
useForEncryption: [false],
}), }),
}); });
protected credentialOptions?: CredentialCreateOptionsView; protected credentialOptions?: CredentialCreateOptionsView;
protected deviceResponse?: PublicKeyCredential; protected pendingCredential?: PendingWebauthnLoginCredentialView;
protected hasPasskeys$?: Observable<boolean>; protected hasPasskeys$?: Observable<boolean>;
protected loading$ = this.webauthnService.loading$; protected loading$ = this.webauthnService.loading$;
constructor( constructor(
private formBuilder: FormBuilder, private formBuilder: FormBuilder,
private dialogRef: DialogRef, private dialogRef: DialogRef,
private webauthnService: WebauthnLoginService, private webauthnService: WebauthnLoginAdminService,
private platformUtilsService: PlatformUtilsService, private platformUtilsService: PlatformUtilsService,
private i18nService: I18nService, private i18nService: I18nService,
private logService: LogService private logService: LogService
@ -112,8 +116,8 @@ export class CreateCredentialDialogComponent implements OnInit {
} }
protected async submitCredentialCreation() { protected async submitCredentialCreation() {
this.deviceResponse = await this.webauthnService.createCredential(this.credentialOptions); this.pendingCredential = await this.webauthnService.createCredential(this.credentialOptions);
if (this.deviceResponse === undefined) { if (this.pendingCredential === undefined) {
this.currentStep = "credentialCreationFailed"; this.currentStep = "credentialCreationFailed";
return; return;
} }
@ -128,16 +132,30 @@ export class CreateCredentialDialogComponent implements OnInit {
protected async submitCredentialNaming() { protected async submitCredentialNaming() {
this.formGroup.controls.credentialNaming.markAllAsTouched(); this.formGroup.controls.credentialNaming.markAllAsTouched();
if (this.formGroup.controls.credentialNaming.invalid) { if (this.formGroup.controls.credentialNaming.controls.name.invalid) {
return; return;
} }
let keySet: PrfKeySet | undefined;
if (this.formGroup.value.credentialNaming.useForEncryption) {
keySet = await this.webauthnService.createKeySet(this.pendingCredential);
if (keySet === undefined) {
this.formGroup.controls.credentialNaming.controls.useForEncryption?.setErrors({
error: {
message: this.i18nService.t("useForVaultEncryptionErrorReadingPasskey"),
},
});
return;
}
}
const name = this.formGroup.value.credentialNaming.name; const name = this.formGroup.value.credentialNaming.name;
try { try {
await this.webauthnService.saveCredential( await this.webauthnService.saveCredential(
this.credentialOptions, this.formGroup.value.credentialNaming.name,
this.deviceResponse, this.pendingCredential,
this.formGroup.value.credentialNaming.name keySet
); );
} catch (error) { } catch (error) {
this.logService?.error(error); this.logService?.error(error);

View File

@ -10,8 +10,8 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl
import { Verification } from "@bitwarden/common/types/verification"; import { Verification } from "@bitwarden/common/types/verification";
import { DialogService } from "@bitwarden/components"; import { DialogService } from "@bitwarden/components";
import { WebauthnLoginService } from "../../../core"; import { WebauthnLoginAdminService } from "../../../core";
import { WebauthnCredentialView } from "../../../core/views/webauth-credential.view"; import { WebauthnLoginCredentialView } from "../../../core/views/webauthn-login-credential.view";
export interface DeleteCredentialDialogParams { export interface DeleteCredentialDialogParams {
credentialId: string; credentialId: string;
@ -27,14 +27,14 @@ export class DeleteCredentialDialogComponent implements OnInit, OnDestroy {
protected formGroup = this.formBuilder.group({ protected formGroup = this.formBuilder.group({
secret: null as Verification | null, secret: null as Verification | null,
}); });
protected credential?: WebauthnCredentialView; protected credential?: WebauthnLoginCredentialView;
protected loading$ = this.webauthnService.loading$; protected loading$ = this.webauthnService.loading$;
constructor( constructor(
@Inject(DIALOG_DATA) private params: DeleteCredentialDialogParams, @Inject(DIALOG_DATA) private params: DeleteCredentialDialogParams,
private formBuilder: FormBuilder, private formBuilder: FormBuilder,
private dialogRef: DialogRef, private dialogRef: DialogRef,
private webauthnService: WebauthnLoginService, private webauthnService: WebauthnLoginAdminService,
private platformUtilsService: PlatformUtilsService, private platformUtilsService: PlatformUtilsService,
private i18nService: I18nService, private i18nService: I18nService,
private logService: LogService private logService: LogService

View File

@ -22,12 +22,20 @@
<table *ngIf="hasCredentials" class="tw-mb-5"> <table *ngIf="hasCredentials" class="tw-mb-5">
<tr *ngFor="let credential of credentials"> <tr *ngFor="let credential of credentials">
<td class="tw-p-2 tw-pl-0 tw-font-semibold">{{ credential.name }}</td> <td class="tw-p-2 tw-pl-0 tw-font-semibold">{{ credential.name }}</td>
<td class="tw-p-2 tw-pr-10"> <td class="tw-p-2 tw-pr-10 tw-text-left">
<ng-container *ngIf="credential.prfSupport"> <ng-container *ngIf="credential.prfStatus === WebauthnLoginCredentialPrfStatus.Enabled">
<i class="bwi bwi-lock-encrypted"></i> <i class="bwi bwi-lock-encrypted"></i>
{{ "supportsEncryption" | i18n }} <span bitTypography="body1" class="tw-text-muted">{{ "usedForEncryption" | i18n }}</span>
</ng-container> </ng-container>
<span bitTypography="body1" class="tw-text-muted"> <ng-container *ngIf="credential.prfStatus === WebauthnLoginCredentialPrfStatus.Supported">
<i class="bwi bwi-lock-encrypted"></i>
<span bitTypography="body1" class="tw-text-muted">{{ "encryptionNotEnabled" | i18n }}</span>
</ng-container>
<span
*ngIf="credential.prfStatus === WebauthnLoginCredentialPrfStatus.Unsupported"
bitTypography="body1"
class="tw-text-muted"
>
{{ "encryptionNotSupported" | i18n }} {{ "encryptionNotSupported" | i18n }}
</span> </span>
</td> </td>

View File

@ -3,8 +3,9 @@ import { Subject, takeUntil } from "rxjs";
import { DialogService } from "@bitwarden/components"; import { DialogService } from "@bitwarden/components";
import { WebauthnLoginService } from "../../core"; import { WebauthnLoginAdminService } from "../../core";
import { WebauthnCredentialView } from "../../core/views/webauth-credential.view"; import { WebauthnLoginCredentialPrfStatus } from "../../core/enums/webauthn-login-credential-prf-status.enum";
import { WebauthnLoginCredentialView } from "../../core/views/webauthn-login-credential.view";
import { openCreateCredentialDialog } from "./create-credential-dialog/create-credential-dialog.component"; import { openCreateCredentialDialog } from "./create-credential-dialog/create-credential-dialog.component";
import { openDeleteCredentialDialogComponent } from "./delete-credential-dialog/delete-credential-dialog.component"; import { openDeleteCredentialDialogComponent } from "./delete-credential-dialog/delete-credential-dialog.component";
@ -19,13 +20,14 @@ import { openDeleteCredentialDialogComponent } from "./delete-credential-dialog/
export class WebauthnLoginSettingsComponent implements OnInit, OnDestroy { export class WebauthnLoginSettingsComponent implements OnInit, OnDestroy {
private destroy$ = new Subject<void>(); private destroy$ = new Subject<void>();
protected readonly MaxCredentialCount = WebauthnLoginService.MaxCredentialCount; protected readonly MaxCredentialCount = WebauthnLoginAdminService.MaxCredentialCount;
protected readonly WebauthnLoginCredentialPrfStatus = WebauthnLoginCredentialPrfStatus;
protected credentials?: WebauthnCredentialView[]; protected credentials?: WebauthnLoginCredentialView[];
protected loading = true; protected loading = true;
constructor( constructor(
private webauthnService: WebauthnLoginService, private webauthnService: WebauthnLoginAdminService,
private dialogService: DialogService private dialogService: DialogService
) {} ) {}

View File

@ -1,6 +1,8 @@
import { NgModule } from "@angular/core"; import { NgModule } from "@angular/core";
import { FormsModule, ReactiveFormsModule } from "@angular/forms"; import { FormsModule, ReactiveFormsModule } from "@angular/forms";
import { CheckboxModule } from "@bitwarden/components";
import { SharedModule } from "../../../shared/shared.module"; import { SharedModule } from "../../../shared/shared.module";
import { UserVerificationModule } from "../../shared/components/user-verification"; import { UserVerificationModule } from "../../shared/components/user-verification";
@ -9,7 +11,7 @@ import { DeleteCredentialDialogComponent } from "./delete-credential-dialog/dele
import { WebauthnLoginSettingsComponent } from "./webauthn-login-settings.component"; import { WebauthnLoginSettingsComponent } from "./webauthn-login-settings.component";
@NgModule({ @NgModule({
imports: [SharedModule, FormsModule, ReactiveFormsModule, UserVerificationModule], imports: [SharedModule, FormsModule, ReactiveFormsModule, UserVerificationModule, CheckboxModule],
declarations: [ declarations: [
WebauthnLoginSettingsComponent, WebauthnLoginSettingsComponent,
CreateCredentialDialogComponent, CreateCredentialDialogComponent,

View File

@ -647,9 +647,24 @@
"customPasskeyNameInfo": { "customPasskeyNameInfo": {
"message": "Name your passkey to help you identify it." "message": "Name your passkey to help you identify it."
}, },
"useForVaultEncryption": {
"message": "Use for vault encryption"
},
"useForVaultEncryptionInfo": {
"message": "Log in and unlock on supported devices without your master password. Follow the prompts from your browser to finalize setup."
},
"useForVaultEncryptionErrorReadingPasskey": {
"message": "Error reading passkey. Try again or uncheck this option."
},
"encryptionNotSupported": { "encryptionNotSupported": {
"message": "Encryption not supported" "message": "Encryption not supported"
}, },
"encryptionNotEnabled": {
"message": "Encryption not enabled"
},
"usedForEncryption": {
"message": "Used for encryption"
},
"loginWithPasskeyEnabled": { "loginWithPasskeyEnabled": {
"message": "Log in with passkey turned on" "message": "Log in with passkey turned on"
}, },

View File

@ -1,2 +1,3 @@
export * from "./components/fingerprint-dialog.component"; export * from "./components/fingerprint-dialog.component";
export * from "./password-callout/password-callout.component"; export * from "./password-callout/password-callout.component";
export * from "./models";

View File

@ -0,0 +1 @@
export * from "./rotateable-key-set";

View File

@ -0,0 +1,36 @@
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
import {
PrfKey,
SymmetricCryptoKey,
} from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
declare const tag: unique symbol;
/**
* A set of keys where a `UserKey` is protected by an encrypted public/private key-pair.
* The `UserKey` is used to encrypt/decrypt data, while the public/private key-pair is
* used to rotate the `UserKey`.
*
* The `PrivateKey` is protected by an `ExternalKey`, such as a `DeviceKey`, or `PrfKey`,
* and the `PublicKey` is protected by the `UserKey`. This setup allows:
*
* - Access to `UserKey` by knowing the `ExternalKey`
* - Rotation to a `NewUserKey` by knowing the current `UserKey`,
* without needing access to the `ExternalKey`
*/
export class RotateableKeySet<ExternalKey extends SymmetricCryptoKey = SymmetricCryptoKey> {
private readonly [tag]: ExternalKey;
constructor(
/** PublicKey encrypted UserKey */
readonly encryptedUserKey: EncString,
/** UserKey encrypted PublicKey */
readonly encryptedPublicKey: EncString,
/** ExternalKey encrypted PrivateKey */
readonly encryptedPrivateKey: EncString
) {}
}
export type PrfKeySet = RotateableKeySet<PrfKey>;

View File

@ -0,0 +1 @@
export * from "./domain";

View File

@ -78,6 +78,7 @@ export class SymmetricCryptoKey {
// Setup all separate key types as opaque types // Setup all separate key types as opaque types
export type DeviceKey = Opaque<SymmetricCryptoKey, "DeviceKey">; export type DeviceKey = Opaque<SymmetricCryptoKey, "DeviceKey">;
export type PrfKey = Opaque<SymmetricCryptoKey, "PrfKey">;
export type UserKey = Opaque<SymmetricCryptoKey, "UserKey">; export type UserKey = Opaque<SymmetricCryptoKey, "UserKey">;
export type MasterKey = Opaque<SymmetricCryptoKey, "MasterKey">; export type MasterKey = Opaque<SymmetricCryptoKey, "MasterKey">;
export type PinKey = Opaque<SymmetricCryptoKey, "PinKey">; export type PinKey = Opaque<SymmetricCryptoKey, "PinKey">;