mirror of
https://github.com/bitwarden/browser.git
synced 2024-11-25 12:15:18 +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:
parent
c7b448cdc8
commit
65d2d74348
@ -0,0 +1,5 @@
|
||||
export enum WebauthnLoginCredentialPrfStatus {
|
||||
Enabled = 0,
|
||||
Supported = 1,
|
||||
Unsupported = 2,
|
||||
}
|
@ -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);
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
@ -1 +1 @@
|
||||
export * from "./webauthn-login.service";
|
||||
export * from "./webauthn-login-admin.service";
|
||||
|
@ -4,7 +4,10 @@ import { WebauthnLoginAttestationResponseRequest } from "./webauthn-login-attest
|
||||
* Request sent to the server to save a newly created webauthn login credential.
|
||||
*/
|
||||
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;
|
||||
|
||||
/** 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.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
@ -3,7 +3,7 @@ import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
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.
|
||||
*/
|
||||
export class WebauthnLoginAttestationResponseRequest extends WebauthnLoginAuthenticatorResponseRequest {
|
||||
|
@ -1,7 +1,7 @@
|
||||
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.
|
||||
*/
|
||||
export abstract class WebauthnLoginAuthenticatorResponseRequest {
|
||||
|
@ -1,17 +1,19 @@
|
||||
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 {
|
||||
id: string;
|
||||
name: string;
|
||||
prfSupport: boolean;
|
||||
prfStatus: WebauthnLoginCredentialPrfStatus;
|
||||
|
||||
constructor(response: unknown) {
|
||||
super(response);
|
||||
this.id = this.getResponseProperty("id");
|
||||
this.name = this.getResponseProperty("name");
|
||||
this.prfSupport = this.getResponseProperty("prfSupport");
|
||||
this.id = this.getResponseProperty("Id");
|
||||
this.name = this.getResponseProperty("Name");
|
||||
this.prfStatus = this.getResponseProperty("PrfStatus");
|
||||
}
|
||||
}
|
||||
|
15
apps/web/src/app/auth/core/services/webauthn-login/utils.ts
Normal file
15
apps/web/src/app/auth/core/services/webauthn-login/utils.ts
Normal 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;
|
||||
}
|
@ -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;
|
||||
}
|
@ -1,19 +1,23 @@
|
||||
import { Injectable, Optional } from "@angular/core";
|
||||
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 { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { Verification } from "@bitwarden/common/types/verification";
|
||||
|
||||
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 { WebauthnLoginAttestationResponseRequest } from "./request/webauthn-login-attestation-response.request";
|
||||
import { createSymmetricKeyFromPrf, getLoginWithPrfSalt } from "./utils";
|
||||
import { WebauthnLoginApiService } from "./webauthn-login-api.service";
|
||||
|
||||
@Injectable({ providedIn: "root" })
|
||||
export class WebauthnLoginService {
|
||||
export class WebauthnLoginAdminService {
|
||||
static readonly MaxCredentialCount = 5;
|
||||
|
||||
private navigatorCredentials: CredentialsContainer;
|
||||
@ -31,6 +35,7 @@ export class WebauthnLoginService {
|
||||
constructor(
|
||||
private apiService: WebauthnLoginApiService,
|
||||
private userVerificationService: UserVerificationService,
|
||||
private rotateableKeySetService: RotateableKeySetService,
|
||||
@Optional() navigatorCredentials?: CredentialsContainer,
|
||||
@Optional() private logService?: LogService
|
||||
) {
|
||||
@ -48,17 +53,60 @@ export class WebauthnLoginService {
|
||||
|
||||
async createCredential(
|
||||
credentialOptions: CredentialCreateOptionsView
|
||||
): Promise<PublicKeyCredential | undefined> {
|
||||
): Promise<PendingWebauthnLoginCredentialView | undefined> {
|
||||
const nativeOptions: CredentialCreationOptions = {
|
||||
publicKey: credentialOptions.options,
|
||||
};
|
||||
// TODO: Remove `any` when typescript typings add support for PRF
|
||||
nativeOptions.publicKey.extensions = {
|
||||
prf: {},
|
||||
} as any;
|
||||
|
||||
try {
|
||||
const response = await this.navigatorCredentials.create(nativeOptions);
|
||||
if (!(response instanceof PublicKeyCredential)) {
|
||||
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) {
|
||||
this.logService?.error(error);
|
||||
return undefined;
|
||||
@ -66,14 +114,18 @@ export class WebauthnLoginService {
|
||||
}
|
||||
|
||||
async saveCredential(
|
||||
credentialOptions: CredentialCreateOptionsView,
|
||||
deviceResponse: PublicKeyCredential,
|
||||
name: string
|
||||
name: string,
|
||||
credential: PendingWebauthnLoginCredentialView,
|
||||
prfKeySet?: PrfKeySet
|
||||
) {
|
||||
const request = new SaveCredentialRequest();
|
||||
request.deviceResponse = new WebauthnLoginAttestationResponseRequest(deviceResponse);
|
||||
request.token = credentialOptions.token;
|
||||
request.deviceResponse = new WebauthnLoginAttestationResponseRequest(credential.deviceResponse);
|
||||
request.token = credential.createOptions.token;
|
||||
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);
|
||||
this.refresh();
|
||||
}
|
||||
@ -88,11 +140,11 @@ export class WebauthnLoginService {
|
||||
* - 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.
|
||||
*/
|
||||
getCredentials$(): Observable<WebauthnCredentialView[]> {
|
||||
getCredentials$(): Observable<WebauthnLoginCredentialView[]> {
|
||||
return this.credentials$;
|
||||
}
|
||||
|
||||
getCredential$(credentialId: string): Observable<WebauthnCredentialView> {
|
||||
getCredential$(credentialId: string): Observable<WebauthnLoginCredentialView> {
|
||||
return this.credentials$.pipe(
|
||||
map((credentials) => credentials.find((c) => c.id === credentialId)),
|
||||
filter((c) => c !== undefined)
|
||||
@ -105,8 +157,15 @@ export class WebauthnLoginService {
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
private fetchCredentials$(): Observable<WebauthnCredentialView[]> {
|
||||
return from(this.apiService.getCredentials()).pipe(map((response) => response.data));
|
||||
private fetchCredentials$(): Observable<WebauthnLoginCredentialView[]> {
|
||||
return from(this.apiService.getCredentials()).pipe(
|
||||
map((response) =>
|
||||
response.data.map(
|
||||
(credential) =>
|
||||
new WebauthnLoginCredentialView(credential.id, credential.name, credential.prfStatus)
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private refresh() {
|
@ -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;
|
||||
}
|
@ -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
|
||||
) {}
|
||||
}
|
@ -1,5 +0,0 @@
|
||||
export class WebauthnCredentialView {
|
||||
id: string;
|
||||
name: string;
|
||||
prfSupport: boolean;
|
||||
}
|
@ -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
|
||||
) {}
|
||||
}
|
@ -32,12 +32,12 @@
|
||||
<p bitTypography="body1">{{ "errorCreatingPasskeyInfo" | i18n }}</p>
|
||||
</div>
|
||||
|
||||
<div *ngIf="currentStep === 'credentialNaming'">
|
||||
<div *ngIf="currentStep === 'credentialNaming'" formGroupName="credentialNaming">
|
||||
<h3 bitTypography="h3">{{ "passkeySuccessfullyCreated" | i18n }}</h3>
|
||||
<p bitTypography="body1">
|
||||
{{ "customPasskeyNameInfo" | i18n }}
|
||||
</p>
|
||||
<bit-form-field disableMargin formGroupName="credentialNaming">
|
||||
<bit-form-field class="!tw-mb-0">
|
||||
<bit-label>{{ "customName" | i18n }}</bit-label>
|
||||
<input type="text" bitInput formControlName="name" appAutofocus />
|
||||
<bit-hint>{{
|
||||
@ -45,6 +45,11 @@
|
||||
| i18n : formGroup.value.credentialNaming.name.length : NameMaxCharacters
|
||||
}}</bit-hint>
|
||||
</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>
|
||||
</ng-container>
|
||||
<ng-container bitDialogFooter>
|
||||
|
@ -3,6 +3,7 @@ import { Component, OnInit } from "@angular/core";
|
||||
import { FormBuilder, Validators } from "@angular/forms";
|
||||
import { firstValueFrom, map, Observable } from "rxjs";
|
||||
|
||||
import { PrfKeySet } from "@bitwarden/auth";
|
||||
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.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 { DialogService } from "@bitwarden/components";
|
||||
|
||||
import { WebauthnLoginService } from "../../../core";
|
||||
import { WebauthnLoginAdminService } from "../../../core";
|
||||
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 { CreatePasskeyIcon } from "./create-passkey.icon";
|
||||
@ -42,17 +44,19 @@ export class CreateCredentialDialogComponent implements OnInit {
|
||||
}),
|
||||
credentialNaming: this.formBuilder.group({
|
||||
name: ["", Validators.maxLength(50)],
|
||||
useForEncryption: [false],
|
||||
}),
|
||||
});
|
||||
|
||||
protected credentialOptions?: CredentialCreateOptionsView;
|
||||
protected deviceResponse?: PublicKeyCredential;
|
||||
protected pendingCredential?: PendingWebauthnLoginCredentialView;
|
||||
protected hasPasskeys$?: Observable<boolean>;
|
||||
protected loading$ = this.webauthnService.loading$;
|
||||
|
||||
constructor(
|
||||
private formBuilder: FormBuilder,
|
||||
private dialogRef: DialogRef,
|
||||
private webauthnService: WebauthnLoginService,
|
||||
private webauthnService: WebauthnLoginAdminService,
|
||||
private platformUtilsService: PlatformUtilsService,
|
||||
private i18nService: I18nService,
|
||||
private logService: LogService
|
||||
@ -112,8 +116,8 @@ export class CreateCredentialDialogComponent implements OnInit {
|
||||
}
|
||||
|
||||
protected async submitCredentialCreation() {
|
||||
this.deviceResponse = await this.webauthnService.createCredential(this.credentialOptions);
|
||||
if (this.deviceResponse === undefined) {
|
||||
this.pendingCredential = await this.webauthnService.createCredential(this.credentialOptions);
|
||||
if (this.pendingCredential === undefined) {
|
||||
this.currentStep = "credentialCreationFailed";
|
||||
return;
|
||||
}
|
||||
@ -128,16 +132,30 @@ export class CreateCredentialDialogComponent implements OnInit {
|
||||
|
||||
protected async submitCredentialNaming() {
|
||||
this.formGroup.controls.credentialNaming.markAllAsTouched();
|
||||
if (this.formGroup.controls.credentialNaming.invalid) {
|
||||
if (this.formGroup.controls.credentialNaming.controls.name.invalid) {
|
||||
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;
|
||||
try {
|
||||
await this.webauthnService.saveCredential(
|
||||
this.credentialOptions,
|
||||
this.deviceResponse,
|
||||
this.formGroup.value.credentialNaming.name
|
||||
this.formGroup.value.credentialNaming.name,
|
||||
this.pendingCredential,
|
||||
keySet
|
||||
);
|
||||
} catch (error) {
|
||||
this.logService?.error(error);
|
||||
|
@ -10,8 +10,8 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl
|
||||
import { Verification } from "@bitwarden/common/types/verification";
|
||||
import { DialogService } from "@bitwarden/components";
|
||||
|
||||
import { WebauthnLoginService } from "../../../core";
|
||||
import { WebauthnCredentialView } from "../../../core/views/webauth-credential.view";
|
||||
import { WebauthnLoginAdminService } from "../../../core";
|
||||
import { WebauthnLoginCredentialView } from "../../../core/views/webauthn-login-credential.view";
|
||||
|
||||
export interface DeleteCredentialDialogParams {
|
||||
credentialId: string;
|
||||
@ -27,14 +27,14 @@ export class DeleteCredentialDialogComponent implements OnInit, OnDestroy {
|
||||
protected formGroup = this.formBuilder.group({
|
||||
secret: null as Verification | null,
|
||||
});
|
||||
protected credential?: WebauthnCredentialView;
|
||||
protected credential?: WebauthnLoginCredentialView;
|
||||
protected loading$ = this.webauthnService.loading$;
|
||||
|
||||
constructor(
|
||||
@Inject(DIALOG_DATA) private params: DeleteCredentialDialogParams,
|
||||
private formBuilder: FormBuilder,
|
||||
private dialogRef: DialogRef,
|
||||
private webauthnService: WebauthnLoginService,
|
||||
private webauthnService: WebauthnLoginAdminService,
|
||||
private platformUtilsService: PlatformUtilsService,
|
||||
private i18nService: I18nService,
|
||||
private logService: LogService
|
||||
|
@ -22,12 +22,20 @@
|
||||
<table *ngIf="hasCredentials" class="tw-mb-5">
|
||||
<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-pr-10">
|
||||
<ng-container *ngIf="credential.prfSupport">
|
||||
<td class="tw-p-2 tw-pr-10 tw-text-left">
|
||||
<ng-container *ngIf="credential.prfStatus === WebauthnLoginCredentialPrfStatus.Enabled">
|
||||
<i class="bwi bwi-lock-encrypted"></i>
|
||||
{{ "supportsEncryption" | i18n }}
|
||||
<span bitTypography="body1" class="tw-text-muted">{{ "usedForEncryption" | i18n }}</span>
|
||||
</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 }}
|
||||
</span>
|
||||
</td>
|
||||
|
@ -3,8 +3,9 @@ import { Subject, takeUntil } from "rxjs";
|
||||
|
||||
import { DialogService } from "@bitwarden/components";
|
||||
|
||||
import { WebauthnLoginService } from "../../core";
|
||||
import { WebauthnCredentialView } from "../../core/views/webauth-credential.view";
|
||||
import { WebauthnLoginAdminService } from "../../core";
|
||||
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 { 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 {
|
||||
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;
|
||||
|
||||
constructor(
|
||||
private webauthnService: WebauthnLoginService,
|
||||
private webauthnService: WebauthnLoginAdminService,
|
||||
private dialogService: DialogService
|
||||
) {}
|
||||
|
||||
|
@ -1,6 +1,8 @@
|
||||
import { NgModule } from "@angular/core";
|
||||
import { FormsModule, ReactiveFormsModule } from "@angular/forms";
|
||||
|
||||
import { CheckboxModule } from "@bitwarden/components";
|
||||
|
||||
import { SharedModule } from "../../../shared/shared.module";
|
||||
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";
|
||||
|
||||
@NgModule({
|
||||
imports: [SharedModule, FormsModule, ReactiveFormsModule, UserVerificationModule],
|
||||
imports: [SharedModule, FormsModule, ReactiveFormsModule, UserVerificationModule, CheckboxModule],
|
||||
declarations: [
|
||||
WebauthnLoginSettingsComponent,
|
||||
CreateCredentialDialogComponent,
|
||||
|
@ -647,9 +647,24 @@
|
||||
"customPasskeyNameInfo": {
|
||||
"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": {
|
||||
"message": "Encryption not supported"
|
||||
},
|
||||
"encryptionNotEnabled": {
|
||||
"message": "Encryption not enabled"
|
||||
},
|
||||
"usedForEncryption": {
|
||||
"message": "Used for encryption"
|
||||
},
|
||||
"loginWithPasskeyEnabled": {
|
||||
"message": "Log in with passkey turned on"
|
||||
},
|
||||
|
@ -1,2 +1,3 @@
|
||||
export * from "./components/fingerprint-dialog.component";
|
||||
export * from "./password-callout/password-callout.component";
|
||||
export * from "./models";
|
||||
|
1
libs/auth/src/models/domain/index.ts
Normal file
1
libs/auth/src/models/domain/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from "./rotateable-key-set";
|
36
libs/auth/src/models/domain/rotateable-key-set.ts
Normal file
36
libs/auth/src/models/domain/rotateable-key-set.ts
Normal 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>;
|
1
libs/auth/src/models/index.ts
Normal file
1
libs/auth/src/models/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from "./domain";
|
@ -78,6 +78,7 @@ export class SymmetricCryptoKey {
|
||||
|
||||
// Setup all separate key types as opaque types
|
||||
export type DeviceKey = Opaque<SymmetricCryptoKey, "DeviceKey">;
|
||||
export type PrfKey = Opaque<SymmetricCryptoKey, "PrfKey">;
|
||||
export type UserKey = Opaque<SymmetricCryptoKey, "UserKey">;
|
||||
export type MasterKey = Opaque<SymmetricCryptoKey, "MasterKey">;
|
||||
export type PinKey = Opaque<SymmetricCryptoKey, "PinKey">;
|
||||
|
Loading…
Reference in New Issue
Block a user