1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-09 21:20:27 +00:00

PM-2035: PRF Unlock (web + extension) (#16662)

* PM-13632: Enable sign in with passkeys in the browser extension

* Refactor component + Icon fix

This commit refactors the login-via-webauthn commit as per @JaredSnider-Bitwarden suggestions. It also fixes an existing issue where Icons are not displayed properly on the web vault.

Remove old one.

Rename the file

Working refactor

Removed the icon from the component

Fixed icons not showing. Changed layout to be 'embedded'

* Add tracking links

* Update app.module.ts

* Remove default Icons on load

* Remove login.module.ts

* Add env changer to the passkey component

* Remove leftover dependencies

* PRF Unlock

Cleanup and testes

* Workaround prf type missing

* Fix any type

* Undo accidental cleanup to keep PR focused

* Undo accidental cleanup to keep PR focused

* Cleaned up public interface

* Use UserId type

* Typed UserId and improved isPrfUnlockAvailable

* Rename key and use zero challenge array

* logservice

* Cleanup rpId handling

* Refactor to separate component + icon

* Moved the prf unlock service impl.

* Fix broken test

* fix tests

* Use isChromium

* Update services.module.ts

* missing , in locales

* Update desktop-lock-component.service.ts

* Fix more desktoptests

* Expect a single UnlockOption from IdTokenResponse, but multiple from sync

* Missing s

* remove catches

* Use new control flow in unlock-via-prf.component.ts

Co-authored-by: Thomas Avery <43214426+Thomas-Avery@users.noreply.github.com>

* Changed throw behaviour of unlockVaultWithPrf

* remove timeout comment

* refactired webauthm-prf-unlock.service internally

* WebAuthnPrfUnlockServiceAbstraction -> WebAuthnPrfUnlockService

* Fixed any and bad import

* Fix errors after merge

* Added missing PinServiceAbstraction

* Fixed format

* Removed @Inject()

* Fix broken tests after Inject removal

* Return userkey instead of setting it

* Used input/output signals

* removed duplicate MessageSender registration

* nit: Made import relative

* Disable onPush requirement because it would need refactoring the component

* Added feature flag (#17494)

* Fixed ById from main

* Import feature flag from file

* Add missing test providers for MasterPasswordLockComponent

Add WebAuthnPrfUnlockService and DialogService mocks to fix test failures
caused by UnlockViaPrfComponent dependencies.

---------

Co-authored-by: Thomas Avery <43214426+Thomas-Avery@users.noreply.github.com>
This commit is contained in:
Anders Åberg
2026-01-26 10:53:20 +01:00
committed by GitHub
parent 644caceb08
commit 903026b574
31 changed files with 810 additions and 69 deletions

View File

@@ -28,6 +28,9 @@
"logInWithPasskey": {
"message": "Log in with passkey"
},
"unlockWithPasskey": {
"message": "Unlock with passkey"
},
"useSingleSignOn": {
"message": "Use single sign-on"
},
@@ -3367,6 +3370,12 @@
"error": {
"message": "Error"
},
"prfUnlockFailed": {
"message": "Failed to unlock with passkey. Please try again or use another unlock method."
},
"noPrfCredentialsAvailable": {
"message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first."
},
"decryptionError": {
"message": "Decryption error"
},

View File

@@ -14,7 +14,7 @@ import {
BiometricsStatus,
BiometricStateService,
} from "@bitwarden/key-management";
import { UnlockOptions } from "@bitwarden/key-management-ui";
import { UnlockOptions, WebAuthnPrfUnlockService } from "@bitwarden/key-management-ui";
import { BrowserApi } from "../../../platform/browser/browser-api";
import BrowserPopupUtils from "../../../platform/browser/browser-popup-utils";
@@ -34,6 +34,7 @@ describe("ExtensionLockComponentService", () => {
let vaultTimeoutSettingsService: MockProxy<VaultTimeoutSettingsService>;
let routerService: MockProxy<BrowserRouterService>;
let biometricStateService: MockProxy<BiometricStateService>;
let webAuthnPrfUnlockService: MockProxy<WebAuthnPrfUnlockService>;
beforeEach(() => {
userDecryptionOptionsService = mock<UserDecryptionOptionsServiceAbstraction>();
@@ -43,37 +44,21 @@ describe("ExtensionLockComponentService", () => {
vaultTimeoutSettingsService = mock<VaultTimeoutSettingsService>();
routerService = mock<BrowserRouterService>();
biometricStateService = mock<BiometricStateService>();
webAuthnPrfUnlockService = mock<WebAuthnPrfUnlockService>();
TestBed.configureTestingModule({
providers: [
ExtensionLockComponentService,
{
provide: UserDecryptionOptionsServiceAbstraction,
useValue: userDecryptionOptionsService,
},
{
provide: PlatformUtilsService,
useValue: platformUtilsService,
},
{
provide: BiometricsService,
useValue: biometricsService,
},
{
provide: PinServiceAbstraction,
useValue: pinService,
},
{
provide: VaultTimeoutSettingsService,
useValue: vaultTimeoutSettingsService,
},
{
provide: BrowserRouterService,
useValue: routerService,
},
{
provide: BiometricStateService,
useValue: biometricStateService,
provide: ExtensionLockComponentService,
useFactory: () =>
new ExtensionLockComponentService(
userDecryptionOptionsService,
biometricsService,
pinService,
biometricStateService,
routerService,
webAuthnPrfUnlockService,
),
},
],
});
@@ -212,6 +197,9 @@ describe("ExtensionLockComponentService", () => {
enabled: true,
biometricsStatus: BiometricsStatus.Available,
},
prf: {
enabled: false,
},
},
],
[
@@ -234,6 +222,9 @@ describe("ExtensionLockComponentService", () => {
enabled: true,
biometricsStatus: BiometricsStatus.Available,
},
prf: {
enabled: false,
},
},
],
[
@@ -256,6 +247,9 @@ describe("ExtensionLockComponentService", () => {
enabled: true,
biometricsStatus: BiometricsStatus.Available,
},
prf: {
enabled: false,
},
},
],
[
@@ -278,6 +272,9 @@ describe("ExtensionLockComponentService", () => {
enabled: true,
biometricsStatus: BiometricsStatus.Available,
},
prf: {
enabled: false,
},
},
],
[
@@ -300,6 +297,9 @@ describe("ExtensionLockComponentService", () => {
enabled: false,
biometricsStatus: BiometricsStatus.UnlockNeeded,
},
prf: {
enabled: false,
},
},
],
[
@@ -322,6 +322,9 @@ describe("ExtensionLockComponentService", () => {
enabled: false,
biometricsStatus: BiometricsStatus.NotEnabledInConnectedDesktopApp,
},
prf: {
enabled: false,
},
},
],
[
@@ -344,6 +347,9 @@ describe("ExtensionLockComponentService", () => {
enabled: false,
biometricsStatus: BiometricsStatus.HardwareUnavailable,
},
prf: {
enabled: false,
},
},
],
];
@@ -374,6 +380,9 @@ describe("ExtensionLockComponentService", () => {
// PIN
pinService.isPinDecryptionAvailable.mockResolvedValue(mockInputs.pinDecryptionAvailable);
// PRF
webAuthnPrfUnlockService.isPrfUnlockAvailable.mockResolvedValue(false);
const unlockOptions = await firstValueFrom(service.getAvailableUnlockOptions$(userId));
expect(unlockOptions).toEqual(expectedOutput);

View File

@@ -1,6 +1,3 @@
// FIXME (PM-22628): angular imports are forbidden in background
// eslint-disable-next-line no-restricted-imports
import { inject } from "@angular/core";
import { combineLatest, defer, firstValueFrom, map, Observable } from "rxjs";
import { UserDecryptionOptionsServiceAbstraction } from "@bitwarden/auth/common";
@@ -11,7 +8,11 @@ import {
BiometricsStatus,
BiometricStateService,
} from "@bitwarden/key-management";
import { LockComponentService, UnlockOptions } from "@bitwarden/key-management-ui";
import {
LockComponentService,
UnlockOptions,
WebAuthnPrfUnlockService,
} from "@bitwarden/key-management-ui";
import { BiometricErrors, BiometricErrorTypes } from "../../../models/biometricErrors";
import { BrowserApi } from "../../../platform/browser/browser-api";
@@ -21,11 +22,14 @@ import BrowserPopupUtils from "../../../platform/browser/browser-popup-utils";
import { BrowserRouterService } from "../../../platform/popup/services/browser-router.service";
export class ExtensionLockComponentService implements LockComponentService {
private readonly userDecryptionOptionsService = inject(UserDecryptionOptionsServiceAbstraction);
private readonly biometricsService = inject(BiometricsService);
private readonly pinService = inject(PinServiceAbstraction);
private readonly routerService = inject(BrowserRouterService);
private readonly biometricStateService = inject(BiometricStateService);
constructor(
private readonly userDecryptionOptionsService: UserDecryptionOptionsServiceAbstraction,
private readonly biometricsService: BiometricsService,
private readonly pinService: PinServiceAbstraction,
private readonly biometricStateService: BiometricStateService,
private readonly routerService: BrowserRouterService,
private readonly webAuthnPrfUnlockService: WebAuthnPrfUnlockService,
) {}
getPreviousUrl(): string | null {
return this.routerService.getPreviousUrl() ?? null;
@@ -81,8 +85,12 @@ export class ExtensionLockComponentService implements LockComponentService {
}),
this.userDecryptionOptionsService.userDecryptionOptionsById$(userId),
defer(() => this.pinService.isPinDecryptionAvailable(userId)),
defer(async () => {
const available = await this.webAuthnPrfUnlockService.isPrfUnlockAvailable(userId);
return { available };
}),
]).pipe(
map(([biometricsStatus, userDecryptionOptions, pinDecryptionAvailable]) => {
map(([biometricsStatus, userDecryptionOptions, pinDecryptionAvailable, prfUnlockInfo]) => {
const unlockOpts: UnlockOptions = {
masterPassword: {
enabled: userDecryptionOptions?.hasMasterPassword,
@@ -94,6 +102,9 @@ export class ExtensionLockComponentService implements LockComponentService {
enabled: biometricsStatus === BiometricsStatus.Available,
biometricsStatus: biometricsStatus,
},
prf: {
enabled: prfUnlockInfo.available,
},
};
return unlockOpts;
}),

View File

@@ -54,6 +54,7 @@ import {
} from "@bitwarden/auto-confirm";
import { ExtensionAuthRequestAnsweringService } from "@bitwarden/browser/auth/services/auth-request-answering/extension-auth-request-answering.service";
import { ExtensionNewDeviceVerificationComponentService } from "@bitwarden/browser/auth/services/new-device-verification/extension-new-device-verification-component.service";
import { BrowserRouterService } from "@bitwarden/browser/platform/popup/services/browser-router.service";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { EventCollectionService as EventCollectionServiceAbstraction } from "@bitwarden/common/abstractions/event/event-collection.service";
import {
@@ -71,6 +72,7 @@ import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/ma
import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction";
import { TokenService } from "@bitwarden/common/auth/abstractions/token.service";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { WebAuthnLoginPrfKeyServiceAbstraction } from "@bitwarden/common/auth/abstractions/webauthn/webauthn-login-prf-key.service.abstraction";
import { PendingAuthRequestsStateService } from "@bitwarden/common/auth/services/auth-request-answering/pending-auth-requests.state";
import {
AutofillSettingsService,
@@ -96,6 +98,7 @@ import {
InternalMasterPasswordServiceAbstraction,
MasterPasswordServiceAbstraction,
} from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction";
import { SessionTimeoutTypeService } from "@bitwarden/common/key-management/session-timeout";
import {
VaultTimeoutService,
@@ -160,12 +163,15 @@ import { GeneratorServicesModule } from "@bitwarden/generator-components";
import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy";
import {
BiometricsService,
BiometricStateService,
DefaultKeyService,
KdfConfigService,
KeyService,
} from "@bitwarden/key-management";
import {
LockComponentService,
WebAuthnPrfUnlockService,
DefaultWebAuthnPrfUnlockService,
SessionTimeoutSettingsComponentService,
} from "@bitwarden/key-management-ui";
import { DerivedStateProvider, GlobalStateProvider, StateProvider } from "@bitwarden/state";
@@ -572,15 +578,6 @@ const safeProviders: SafeProvider[] = [
useFactory: () => new Subject<Message<Record<string, unknown>>>(),
deps: [],
}),
safeProvider({
provide: MessageSender,
useFactory: (subject: Subject<Message<Record<string, unknown>>>, logService: LogService) =>
MessageSender.combine(
new SubjectMessageSender(subject), // For sending messages in the same context
new ChromeMessageSender(logService), // For sending messages to different contexts
),
deps: [INTRAPROCESS_MESSAGING_SUBJECT, LogService],
}),
safeProvider({
provide: DISK_BACKUP_LOCAL_STORAGE,
useFactory: (diskStorage: AbstractStorageService & ObservableStorageService) =>
@@ -604,7 +601,14 @@ const safeProviders: SafeProvider[] = [
safeProvider({
provide: LockComponentService,
useClass: ExtensionLockComponentService,
deps: [],
deps: [
UserDecryptionOptionsServiceAbstraction,
BiometricsService,
PinServiceAbstraction,
BiometricStateService,
BrowserRouterService,
WebAuthnPrfUnlockService,
],
}),
// TODO: PM-18182 - Refactor component services into lazy loaded modules
safeProvider({
@@ -653,6 +657,21 @@ const safeProviders: SafeProvider[] = [
AccountServiceAbstraction,
],
}),
safeProvider({
provide: WebAuthnPrfUnlockService,
useClass: DefaultWebAuthnPrfUnlockService,
deps: [
WebAuthnLoginPrfKeyServiceAbstraction,
KeyService,
UserDecryptionOptionsServiceAbstraction,
EncryptService,
EnvironmentService,
PlatformUtilsService,
WINDOW,
LogService,
ConfigService,
],
}),
safeProvider({
provide: AnimationControlService,
useClass: DefaultAnimationControlService,

View File

@@ -177,6 +177,9 @@ describe("DesktopLockComponentService", () => {
enabled: true,
biometricsStatus: BiometricsStatus.Available,
},
prf: {
enabled: false,
},
},
],
[
@@ -197,6 +200,9 @@ describe("DesktopLockComponentService", () => {
enabled: true,
biometricsStatus: BiometricsStatus.Available,
},
prf: {
enabled: false,
},
},
],
[
@@ -218,6 +224,9 @@ describe("DesktopLockComponentService", () => {
enabled: false,
biometricsStatus: BiometricsStatus.NotEnabledLocally,
},
prf: {
enabled: false,
},
},
],
[
@@ -238,6 +247,9 @@ describe("DesktopLockComponentService", () => {
enabled: false,
biometricsStatus: BiometricsStatus.HardwareUnavailable,
},
prf: {
enabled: false,
},
},
],
[
@@ -258,6 +270,9 @@ describe("DesktopLockComponentService", () => {
enabled: false,
biometricsStatus: BiometricsStatus.PlatformUnsupported,
},
prf: {
enabled: false,
},
},
],
];

View File

@@ -69,6 +69,9 @@ export class DesktopLockComponentService implements LockComponentService {
enabled: biometricsStatus == BiometricsStatus.Available,
biometricsStatus: biometricsStatus,
},
prf: {
enabled: false,
},
};
return unlockOpts;

View File

@@ -65,6 +65,7 @@ import { AuthRequestAnsweringService } from "@bitwarden/common/auth/abstractions
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/master-password-api.service.abstraction";
import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction";
import { WebAuthnLoginPrfKeyServiceAbstraction } from "@bitwarden/common/auth/abstractions/webauthn/webauthn-login-prf-key.service.abstraction";
import { NoopAuthRequestAnsweringService } from "@bitwarden/common/auth/services/auth-request-answering/noop-auth-request-answering.service";
import { OrganizationInviteService } from "@bitwarden/common/auth/services/organization-invite/organization-invite.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
@@ -127,6 +128,8 @@ import {
} from "@bitwarden/key-management";
import {
LockComponentService,
WebAuthnPrfUnlockService,
DefaultWebAuthnPrfUnlockService,
SessionTimeoutSettingsComponentService,
} from "@bitwarden/key-management-ui";
import { SerializedMemoryStorageService } from "@bitwarden/storage-core";
@@ -495,6 +498,21 @@ const safeProviders: SafeProvider[] = [
useClass: NoopAuthRequestAnsweringService,
deps: [],
}),
safeProvider({
provide: WebAuthnPrfUnlockService,
useClass: DefaultWebAuthnPrfUnlockService,
deps: [
WebAuthnLoginPrfKeyServiceAbstraction,
KeyServiceAbstraction,
InternalUserDecryptionOptionsServiceAbstraction,
EncryptService,
EnvironmentService,
PlatformUtilsService,
WINDOW,
LogService,
ConfigService,
],
}),
];
@NgModule({

View File

@@ -5,6 +5,7 @@ import { firstValueFrom, of } from "rxjs";
import { UserDecryptionOptionsServiceAbstraction } from "@bitwarden/auth/common";
import { UserId } from "@bitwarden/common/types/guid";
import { BiometricsStatus } from "@bitwarden/key-management";
import { WebAuthnPrfUnlockService } from "@bitwarden/key-management-ui";
import { WebLockComponentService } from "./web-lock-component.service";
@@ -12,9 +13,11 @@ describe("WebLockComponentService", () => {
let service: WebLockComponentService;
let userDecryptionOptionsService: MockProxy<UserDecryptionOptionsServiceAbstraction>;
let webAuthnPrfUnlockService: MockProxy<WebAuthnPrfUnlockService>;
beforeEach(() => {
userDecryptionOptionsService = mock<UserDecryptionOptionsServiceAbstraction>();
webAuthnPrfUnlockService = mock<WebAuthnPrfUnlockService>();
TestBed.configureTestingModule({
providers: [
@@ -23,6 +26,10 @@ describe("WebLockComponentService", () => {
provide: UserDecryptionOptionsServiceAbstraction,
useValue: userDecryptionOptionsService,
},
{
provide: WebAuthnPrfUnlockService,
useValue: webAuthnPrfUnlockService,
},
],
});
@@ -91,6 +98,7 @@ describe("WebLockComponentService", () => {
userDecryptionOptionsService.userDecryptionOptionsById$.mockReturnValueOnce(
of(userDecryptionOptions),
);
webAuthnPrfUnlockService.isPrfUnlockAvailable.mockResolvedValue(false);
const unlockOptions = await firstValueFrom(service.getAvailableUnlockOptions$(userId));
@@ -105,6 +113,9 @@ describe("WebLockComponentService", () => {
enabled: false,
biometricsStatus: BiometricsStatus.PlatformUnsupported,
},
prf: {
enabled: false,
},
});
});
});

View File

@@ -1,16 +1,18 @@
import { inject } from "@angular/core";
import { map, Observable } from "rxjs";
import { combineLatest, defer, map, Observable } from "rxjs";
import {
UserDecryptionOptions,
UserDecryptionOptionsServiceAbstraction,
} from "@bitwarden/auth/common";
import { UserDecryptionOptionsServiceAbstraction } from "@bitwarden/auth/common";
import { UserId } from "@bitwarden/common/types/guid";
import { BiometricsStatus } from "@bitwarden/key-management";
import { LockComponentService, UnlockOptions } from "@bitwarden/key-management-ui";
import {
LockComponentService,
UnlockOptions,
WebAuthnPrfUnlockService,
} from "@bitwarden/key-management-ui";
export class WebLockComponentService implements LockComponentService {
private readonly userDecryptionOptionsService = inject(UserDecryptionOptionsServiceAbstraction);
private readonly webAuthnPrfUnlockService = inject(WebAuthnPrfUnlockService);
constructor() {}
@@ -43,8 +45,14 @@ export class WebLockComponentService implements LockComponentService {
}
getAvailableUnlockOptions$(userId: UserId): Observable<UnlockOptions | null> {
return this.userDecryptionOptionsService.userDecryptionOptionsById$(userId)?.pipe(
map((userDecryptionOptions: UserDecryptionOptions) => {
return combineLatest([
this.userDecryptionOptionsService.userDecryptionOptionsById$(userId),
defer(async () => {
const available = await this.webAuthnPrfUnlockService.isPrfUnlockAvailable(userId);
return { available };
}),
]).pipe(
map(([userDecryptionOptions, prfUnlockInfo]) => {
const unlockOpts: UnlockOptions = {
masterPassword: {
enabled: userDecryptionOptions.hasMasterPassword,
@@ -56,6 +64,9 @@ export class WebLockComponentService implements LockComponentService {
enabled: false,
biometricsStatus: BiometricsStatus.PlatformUnsupported,
},
prf: {
enabled: prfUnlockInfo.available,
},
};
return unlockOpts;
}),

View File

@@ -12101,6 +12101,15 @@
"verifyNow": {
"message": "Verify now."
},
"unlockWithPasskey": {
"message": "Unlock with passkey"
},
"prfUnlockFailed": {
"message": "Failed to unlock with passkey. Please try again or use another unlock method."
},
"noPrfCredentialsAvailable": {
"message": "No PRF-enabled passkeys are available for unlock."
},
"additionalStorageGB": {
"message": "Additional storage GB"
},

View File

@@ -886,7 +886,7 @@ const safeProviders: SafeProvider[] = [
FolderApiServiceAbstraction,
InternalOrganizationServiceAbstraction,
SendApiServiceAbstraction,
UserDecryptionOptionsServiceAbstraction,
InternalUserDecryptionOptionsServiceAbstraction,
AvatarServiceAbstraction,
LOGOUT_CALLBACK,
BillingAccountProfileStateService,

View File

@@ -175,6 +175,8 @@ describe("WebAuthnLoginStrategy", () => {
WebAuthnPrfOption: {
EncryptedPrivateKey: mockEncPrfPrivateKey,
EncryptedUserKey: mockEncUserKey,
CredentialId: "mockCredentialId",
Transports: ["usb", "nfc"],
},
};

View File

@@ -73,14 +73,15 @@ export class WebAuthnLoginStrategy extends LoginStrategy {
const userDecryptionOptions = idTokenResponse?.userDecryptionOptions;
if (userDecryptionOptions?.webAuthnPrfOption) {
const webAuthnPrfOption = idTokenResponse.userDecryptionOptions?.webAuthnPrfOption;
const credentials = this.cache.value.credentials;
// confirm we still have the prf key
if (!credentials.prfKey) {
return;
}
const webAuthnPrfOption = userDecryptionOptions.webAuthnPrfOption;
// decrypt prf encrypted private key
const privateKey = await this.encryptService.unwrapDecapsulationKey(
webAuthnPrfOption.encryptedPrivateKey,

View File

@@ -5,6 +5,7 @@ import { Jsonify } from "type-fest";
import { IdentityTokenResponse } from "@bitwarden/common/auth/models/response/identity-token.response";
import { KeyConnectorUserDecryptionOptionResponse } from "@bitwarden/common/auth/models/response/user-decryption-options/key-connector-user-decryption-option.response";
import { TrustedDeviceUserDecryptionOptionResponse } from "@bitwarden/common/auth/models/response/user-decryption-options/trusted-device-user-decryption-option.response";
import { WebAuthnPrfDecryptionOptionResponse } from "@bitwarden/common/auth/models/response/user-decryption-options/webauthn-prf-decryption-option.response";
/**
* Key Connector decryption options. Intended to be sent to the client for use after authentication.
@@ -45,6 +46,61 @@ export class KeyConnectorUserDecryptionOption {
}
}
/**
* Trusted device decryption options. Intended to be sent to the client for use after authentication.
* @see {@link UserDecryptionOptions}
*/
/**
* WebAuthn PRF decryption options. Intended to be sent to the client for use after authentication.
* @see {@link UserDecryptionOptions}
*/
export class WebAuthnPrfUserDecryptionOption {
/** The encrypted private key that can be decrypted with the PRF key. */
encryptedPrivateKey: string;
/** The encrypted user key that can be decrypted with the private key. */
encryptedUserKey: string;
/** The credential ID for this WebAuthn PRF credential. */
credentialId: string;
/** The transports supported by this credential. */
transports: string[];
/**
* Initializes a new instance of the WebAuthnPrfUserDecryptionOption from a response object.
* @param response The WebAuthn PRF user decryption option response object.
* @returns A new instance of the WebAuthnPrfUserDecryptionOption or undefined if `response` is nullish.
*/
static fromResponse(
response: WebAuthnPrfDecryptionOptionResponse,
): WebAuthnPrfUserDecryptionOption | undefined {
if (response == null) {
return undefined;
}
if (!response.encryptedPrivateKey || !response.encryptedUserKey) {
return undefined;
}
const options = new WebAuthnPrfUserDecryptionOption();
options.encryptedPrivateKey = response.encryptedPrivateKey.encryptedString;
options.encryptedUserKey = response.encryptedUserKey.encryptedString;
options.credentialId = response.credentialId;
options.transports = response.transports || [];
return options;
}
/**
* Initializes a new instance of a WebAuthnPrfUserDecryptionOption from a JSON object.
* @param obj JSON object to deserialize.
* @returns A new instance of the WebAuthnPrfUserDecryptionOption or undefined if `obj` is nullish.
*/
static fromJSON(
obj: Jsonify<WebAuthnPrfUserDecryptionOption>,
): WebAuthnPrfUserDecryptionOption | undefined {
if (obj == null) {
return undefined;
}
return Object.assign(new WebAuthnPrfUserDecryptionOption(), obj);
}
}
/**
* Trusted device decryption options. Intended to be sent to the client for use after authentication.
* @see {@link UserDecryptionOptions}
@@ -104,6 +160,8 @@ export class UserDecryptionOptions {
trustedDeviceOption?: TrustedDeviceUserDecryptionOption;
/** {@link KeyConnectorUserDecryptionOption} */
keyConnectorOption?: KeyConnectorUserDecryptionOption;
/** Array of {@link WebAuthnPrfUserDecryptionOption} */
webAuthnPrfOptions?: WebAuthnPrfUserDecryptionOption[];
/**
* Initializes a new instance of the UserDecryptionOptions from a response object.
@@ -134,6 +192,18 @@ export class UserDecryptionOptions {
decryptionOptions.keyConnectorOption = KeyConnectorUserDecryptionOption.fromResponse(
responseOptions.keyConnectorOption,
);
// The IdTokenResponse only returns a single WebAuthn PRF option to support immediate unlock after logging in
// with the same PRF passkey.
// Since our domain model supports multiple WebAuthn PRF options, we convert the single option into an array.
if (responseOptions.webAuthnPrfOption) {
const option = WebAuthnPrfUserDecryptionOption.fromResponse(
responseOptions.webAuthnPrfOption,
);
if (option) {
decryptionOptions.webAuthnPrfOptions = [option];
}
}
} else {
throw new Error(
"User Decryption Options are required for client initialization. userDecryptionOptions is missing in response.",
@@ -158,6 +228,12 @@ export class UserDecryptionOptions {
obj?.keyConnectorOption,
);
if (obj?.webAuthnPrfOptions && Array.isArray(obj.webAuthnPrfOptions)) {
decryptionOptions.webAuthnPrfOptions = obj.webAuthnPrfOptions
.map((option) => WebAuthnPrfUserDecryptionOption.fromJSON(option))
.filter((option) => option !== undefined);
}
return decryptionOptions;
}
}

View File

@@ -27,6 +27,10 @@ export class UserDecryptionOptionsResponse extends BaseResponse {
masterPasswordUnlock?: MasterPasswordUnlockResponse;
trustedDeviceOption?: TrustedDeviceUserDecryptionOptionResponse;
keyConnectorOption?: KeyConnectorUserDecryptionOptionResponse;
/**
* The IdTokenresponse only returns a single WebAuthn PRF option.
* To support immediate unlock after logging in with the same PRF passkey.
*/
webAuthnPrfOption?: WebAuthnPrfDecryptionOptionResponse;
constructor(response: IUserDecryptionOptionsServerResponse) {

View File

@@ -6,19 +6,30 @@ import { BaseResponse } from "../../../../models/response/base.response";
export interface IWebAuthnPrfDecryptionOptionServerResponse {
EncryptedPrivateKey: string;
EncryptedUserKey: string;
CredentialId: string;
Transports: string[];
}
export class WebAuthnPrfDecryptionOptionResponse extends BaseResponse {
encryptedPrivateKey: EncString;
encryptedUserKey: EncString;
credentialId: string;
transports: string[];
constructor(response: IWebAuthnPrfDecryptionOptionServerResponse) {
super(response);
if (response.EncryptedPrivateKey) {
this.encryptedPrivateKey = new EncString(this.getResponseProperty("EncryptedPrivateKey"));
const encPrivateKey = this.getResponseProperty("EncryptedPrivateKey");
if (encPrivateKey) {
this.encryptedPrivateKey = new EncString(encPrivateKey);
}
if (response.EncryptedUserKey) {
this.encryptedUserKey = new EncString(this.getResponseProperty("EncryptedUserKey"));
const encUserKey = this.getResponseProperty("EncryptedUserKey");
if (encUserKey) {
this.encryptedUserKey = new EncString(encUserKey);
}
this.credentialId = this.getResponseProperty("CredentialId");
this.transports = this.getResponseProperty("Transports") || [];
}
}

View File

@@ -42,6 +42,7 @@ export enum FeatureFlag {
ForceUpdateKDFSettings = "pm-18021-force-update-kdf-settings",
LinuxBiometricsV2 = "pm-26340-linux-biometrics-v2",
NoLogoutOnKdfChange = "pm-23995-no-logout-on-kdf-change",
PasskeyUnlock = "pm-2035-passkey-unlock",
DataRecoveryTool = "pm-28813-data-recovery-tool",
ConsolidatedSessionTimeoutComponent = "pm-26056-consolidated-session-timeout-component",
PM27279_V2RegistrationTdeJit = "pm-27279-v2-registration-tde-jit",
@@ -153,6 +154,7 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.ForceUpdateKDFSettings]: FALSE,
[FeatureFlag.LinuxBiometricsV2]: FALSE,
[FeatureFlag.NoLogoutOnKdfChange]: FALSE,
[FeatureFlag.PasskeyUnlock]: FALSE,
[FeatureFlag.DataRecoveryTool]: FALSE,
[FeatureFlag.ConsolidatedSessionTimeoutComponent]: FALSE,
[FeatureFlag.PM27279_V2RegistrationTdeJit]: FALSE,

View File

@@ -1,9 +1,15 @@
import { WebAuthnPrfDecryptionOptionResponse } from "../../../auth/models/response/user-decryption-options/webauthn-prf-decryption-option.response";
import { BaseResponse } from "../../../models/response/base.response";
import { MasterPasswordUnlockResponse } from "../../master-password/models/response/master-password-unlock.response";
export class UserDecryptionResponse extends BaseResponse {
masterPasswordUnlock?: MasterPasswordUnlockResponse;
/**
* The sync service returns an array of WebAuthn PRF options.
*/
webAuthnPrfOptions?: WebAuthnPrfDecryptionOptionResponse[];
constructor(response: unknown) {
super(response);
@@ -11,5 +17,12 @@ export class UserDecryptionResponse extends BaseResponse {
if (masterPasswordUnlock != null && typeof masterPasswordUnlock === "object") {
this.masterPasswordUnlock = new MasterPasswordUnlockResponse(masterPasswordUnlock);
}
const webAuthnPrfOptions = this.getResponseProperty("WebAuthnPrfOptions");
if (webAuthnPrfOptions != null && Array.isArray(webAuthnPrfOptions)) {
this.webAuthnPrfOptions = webAuthnPrfOptions.map(
(option) => new WebAuthnPrfDecryptionOptionResponse(option),
);
}
}
}

View File

@@ -9,7 +9,7 @@ import { CollectionService } from "@bitwarden/admin-console/common";
import {
LogoutReason,
UserDecryptionOptions,
UserDecryptionOptionsServiceAbstraction,
InternalUserDecryptionOptionsServiceAbstraction,
} from "@bitwarden/auth/common";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
@@ -68,7 +68,7 @@ describe("DefaultSyncService", () => {
let folderApiService: MockProxy<FolderApiServiceAbstraction>;
let organizationService: MockProxy<InternalOrganizationServiceAbstraction>;
let sendApiService: MockProxy<SendApiService>;
let userDecryptionOptionsService: MockProxy<UserDecryptionOptionsServiceAbstraction>;
let userDecryptionOptionsService: MockProxy<InternalUserDecryptionOptionsServiceAbstraction>;
let avatarService: MockProxy<AvatarService>;
let logoutCallback: jest.Mock<Promise<void>, [logoutReason: LogoutReason, userId?: UserId]>;
let billingAccountProfileStateService: MockProxy<BillingAccountProfileStateService>;

View File

@@ -6,8 +6,8 @@ import { firstValueFrom, map } from "rxjs";
// eslint-disable-next-line no-restricted-imports
import { CollectionService } from "@bitwarden/admin-console/common";
import {
CollectionDetailsResponse,
CollectionData,
CollectionDetailsResponse,
} from "@bitwarden/common/admin-console/models/collections";
import { AccountCryptographicStateService } from "@bitwarden/common/key-management/account-cryptography/account-cryptographic-state.service";
import { SecurityStateService } from "@bitwarden/common/key-management/security-state/abstractions/security-state.service";
@@ -15,9 +15,13 @@ import { SecurityStateService } from "@bitwarden/common/key-management/security-
// eslint-disable-next-line no-restricted-imports
import { KdfConfigService, KeyService } from "@bitwarden/key-management";
// FIXME: remove `src` and fix import
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import { UserDecryptionOptionsServiceAbstraction } from "../../../../auth/src/common/abstractions";
import {
InternalUserDecryptionOptionsServiceAbstraction,
UserDecryptionOptions,
WebAuthnPrfUserDecryptionOption,
} from "../../../../auth/src/common";
// FIXME: remove `src` and fix import
// eslint-disable-next-line no-restricted-imports
import { LogoutReason } from "../../../../auth/src/common/types";
@@ -93,7 +97,7 @@ export class DefaultSyncService extends CoreSyncService {
folderApiService: FolderApiServiceAbstraction,
private organizationService: InternalOrganizationServiceAbstraction,
sendApiService: SendApiService,
private userDecryptionOptionsService: UserDecryptionOptionsServiceAbstraction,
private userDecryptionOptionsService: InternalUserDecryptionOptionsServiceAbstraction,
private avatarService: AvatarService,
private logoutCallback: (logoutReason: LogoutReason, userId?: UserId) => Promise<void>,
private billingAccountProfileStateService: BillingAccountProfileStateService,
@@ -450,5 +454,43 @@ export class DefaultSyncService extends CoreSyncService {
);
await this.kdfConfigService.setKdfConfig(userId, masterPasswordUnlockData.kdf);
}
// Update WebAuthn PRF options if present
if (userDecryption.webAuthnPrfOptions != null && userDecryption.webAuthnPrfOptions.length > 0) {
try {
// Only update if this is the active user, since setUserDecryptionOptions()
// operates on the active user's state
const activeAccount = await firstValueFrom(this.accountService.activeAccount$);
if (activeAccount?.id !== userId) {
return;
}
// Get current options without blocking if they don't exist yet
const currentUserDecryptionOptions = await firstValueFrom(
this.userDecryptionOptionsService.userDecryptionOptionsById$(userId),
).catch((): UserDecryptionOptions | null => {
return null;
});
if (currentUserDecryptionOptions != null) {
// Update the PRF options while preserving other decryption options
const updatedOptions = Object.assign(
new UserDecryptionOptions(),
currentUserDecryptionOptions,
);
updatedOptions.webAuthnPrfOptions = userDecryption.webAuthnPrfOptions
.map((option) => WebAuthnPrfUserDecryptionOption.fromResponse(option))
.filter((option) => option !== undefined);
await this.userDecryptionOptionsService.setUserDecryptionOptionsById(
activeAccount.id,
updatedOptions,
);
}
} catch (error) {
this.logService.error("[Sync] Failed to update WebAuthn PRF options:", error);
}
}
}
}

View File

@@ -4,6 +4,8 @@
export { LockComponent } from "./lock/components/lock.component";
export { LockComponentService, UnlockOptions } from "./lock/services/lock-component.service";
export { WebAuthnPrfUnlockService } from "./lock/services/webauthn-prf-unlock.service";
export { DefaultWebAuthnPrfUnlockService } from "./lock/services/default-webauthn-prf-unlock.service";
export { KeyRotationTrustInfoComponent } from "./key-rotation/key-rotation-trust-info.component";
export { AccountRecoveryTrustComponent } from "./trust/account-recovery-trust.component";
export { EmergencyAccessTrustComponent } from "./trust/emergency-access-trust.component";

View File

@@ -49,6 +49,8 @@
</button>
</ng-container>
<bit-unlock-via-prf (unlockSuccess)="onPrfUnlockSuccess($event)"></bit-unlock-via-prf>
<button type="button" bitButton block (click)="logOut()">
{{ "logOut" | i18n }}
</button>
@@ -113,6 +115,11 @@
</button>
</ng-container>
<bit-unlock-via-prf
[formButton]="true"
(unlockSuccess)="onPrfUnlockSuccess($event)"
></bit-unlock-via-prf>
<button type="button" bitButton bitFormButton block (click)="logOut()">
{{ "logOut" | i18n }}
</button>
@@ -127,6 +134,7 @@
[unlockOptions]="unlockOptions"
[biometricUnlockBtnText]="biometricUnlockBtnText"
(successfulUnlock)="successfulMasterPasswordUnlock($event)"
(prfUnlockSuccess)="onPrfUnlockSuccess($event)"
(logOut)="logOut()"
></bit-master-password-lock>
}

View File

@@ -51,6 +51,7 @@ import {
UnlockOptionValue,
UnlockOptions,
} from "../services/lock-component.service";
import { WebAuthnPrfUnlockService } from "../services/webauthn-prf-unlock.service";
import { LockComponent } from "./lock.component";
@@ -84,6 +85,7 @@ describe("LockComponent", () => {
const mockLockComponentService = mock<LockComponentService>();
const mockAnonLayoutWrapperDataService = mock<AnonLayoutWrapperDataService>();
const mockBroadcasterService = mock<BroadcasterService>();
const mockWebAuthnPrfUnlockService = mock<WebAuthnPrfUnlockService>();
const mockEncryptedMigrator = mock<EncryptedMigrator>();
const mockActivatedRoute = {
snapshot: {
@@ -149,6 +151,7 @@ describe("LockComponent", () => {
{ provide: LockComponentService, useValue: mockLockComponentService },
{ provide: AnonLayoutWrapperDataService, useValue: mockAnonLayoutWrapperDataService },
{ provide: BroadcasterService, useValue: mockBroadcasterService },
{ provide: WebAuthnPrfUnlockService, useValue: mockWebAuthnPrfUnlockService },
{ provide: ActivatedRoute, useValue: mockActivatedRoute },
{ provide: EncryptedMigrator, useValue: mockEncryptedMigrator },
],

View File

@@ -60,6 +60,7 @@ import {
} from "../services/lock-component.service";
import { MasterPasswordLockComponent } from "./master-password-lock/master-password-lock.component";
import { UnlockViaPrfComponent } from "./unlock-via-prf.component";
const BroadcasterSubscriptionId = "LockComponent";
@@ -98,6 +99,7 @@ const BIOMETRIC_UNLOCK_TEMPORARY_UNAVAILABLE_STATUSES = [
FormFieldModule,
AsyncActionsModule,
IconButtonModule,
UnlockViaPrfComponent,
MasterPasswordLockComponent,
TooltipDirective,
],
@@ -460,6 +462,14 @@ export class LockComponent implements OnInit, OnDestroy {
}
}
async onPrfUnlockSuccess(userKey: UserKey): Promise<void> {
await this.setUserKeyAndContinue(userKey);
}
togglePassword() {
this.showPassword = !this.showPassword;
}
private validatePin(): boolean {
if (this.formGroup?.invalid) {
this.toastService.showToast({

View File

@@ -54,6 +54,11 @@
</button>
}
<bit-unlock-via-prf
[formButton]="true"
(unlockSuccess)="onPrfUnlockSuccess($event)"
></bit-unlock-via-prf>
<button type="button" bitButton bitFormButton block (click)="logOut.emit()">
{{ "logOut" | i18n }}
</button>

View File

@@ -18,6 +18,7 @@ import { UserKey } from "@bitwarden/common/types/key";
import {
AsyncActionsModule,
ButtonModule,
DialogService,
FormFieldModule,
IconButtonModule,
ToastService,
@@ -27,6 +28,7 @@ import { CommandDefinition, MessageListener } from "@bitwarden/messaging";
import { UserId } from "@bitwarden/user-core";
import { UnlockOption, UnlockOptions } from "../../services/lock-component.service";
import { WebAuthnPrfUnlockService } from "../../services/webauthn-prf-unlock.service";
import { MasterPasswordLockComponent } from "./master-password-lock.component";
@@ -41,6 +43,8 @@ describe("MasterPasswordLockComponent", () => {
const logService = mock<LogService>();
const platformUtilsService = mock<PlatformUtilsService>();
const messageListener = mock<MessageListener>();
const webAuthnPrfUnlockService = mock<WebAuthnPrfUnlockService>();
const dialogService = mock<DialogService>();
const mockMasterPassword = "testExample";
const activeAccount: Account = {
@@ -64,6 +68,7 @@ describe("MasterPasswordLockComponent", () => {
enabled: false,
biometricsStatus: BiometricsStatus.NotEnabledLocally,
},
prf: { enabled: false },
};
accountService.activeAccount$ = of(account);
@@ -110,6 +115,8 @@ describe("MasterPasswordLockComponent", () => {
{ provide: LogService, useValue: logService },
{ provide: PlatformUtilsService, useValue: platformUtilsService },
{ provide: MessageListener, useValue: messageListener },
{ provide: WebAuthnPrfUnlockService, useValue: webAuthnPrfUnlockService },
{ provide: DialogService, useValue: dialogService },
],
}).compileComponents();

View File

@@ -36,6 +36,7 @@ import {
UnlockOptions,
UnlockOptionValue,
} from "../../services/lock-component.service";
import { UnlockViaPrfComponent } from "../unlock-via-prf.component";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@@ -49,6 +50,7 @@ import {
FormFieldModule,
AsyncActionsModule,
IconButtonModule,
UnlockViaPrfComponent,
],
})
export class MasterPasswordLockComponent implements OnInit, OnDestroy {
@@ -76,6 +78,7 @@ export class MasterPasswordLockComponent implements OnInit, OnDestroy {
});
successfulUnlock = output<{ userKey: UserKey; masterPassword: string }>();
prfUnlockSuccess = output<UserKey>();
logOut = output<void>();
protected showPassword = false;
@@ -143,4 +146,8 @@ export class MasterPasswordLockComponent implements OnInit, OnDestroy {
});
}
}
onPrfUnlockSuccess(userKey: UserKey): void {
this.prfUnlockSuccess.emit(userKey);
}
}

View File

@@ -0,0 +1,114 @@
import { CommonModule } from "@angular/common";
import { Component, OnInit, input, output } from "@angular/core";
import { firstValueFrom } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { UserId } from "@bitwarden/common/types/guid";
import { UserKey } from "@bitwarden/common/types/key";
import { AsyncActionsModule, ButtonModule, DialogService } from "@bitwarden/components";
import { WebAuthnPrfUnlockService } from "../services/webauthn-prf-unlock.service";
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "bit-unlock-via-prf",
standalone: true,
imports: [CommonModule, JslibModule, ButtonModule, AsyncActionsModule],
template: `
@if (isAvailable) {
@if (formButton()) {
<button
type="button"
bitButton
bitFormButton
buttonType="secondary"
block
(click)="unlockViaPrf()"
[disabled]="unlocking"
[loading]="unlocking"
>
<i class="bwi bwi-passkey tw-mr-1" aria-hidden="true"></i>
{{ "unlockWithPasskey" | i18n }}
</button>
}
@if (!formButton()) {
<button
type="button"
bitButton
buttonType="secondary"
block
(click)="unlockViaPrf()"
[disabled]="unlocking"
[loading]="unlocking"
>
<i class="bwi bwi-passkey tw-mr-1" aria-hidden="true"></i>
{{ "unlockWithPasskey" | i18n }}
</button>
}
}
`,
})
export class UnlockViaPrfComponent implements OnInit {
readonly formButton = input<boolean>(false);
readonly unlockSuccess = output<UserKey>();
unlocking = false;
isAvailable = false;
private userId: UserId | null = null;
constructor(
private accountService: AccountService,
private webAuthnPrfUnlockService: WebAuthnPrfUnlockService,
private dialogService: DialogService,
private i18nService: I18nService,
private logService: LogService,
) {}
async ngOnInit(): Promise<void> {
const activeAccount = await firstValueFrom(this.accountService.activeAccount$);
if (activeAccount?.id) {
this.userId = activeAccount.id;
this.isAvailable = await this.webAuthnPrfUnlockService.isPrfUnlockAvailable(this.userId);
}
}
async unlockViaPrf(): Promise<void> {
if (!this.userId || !this.isAvailable) {
return;
}
this.unlocking = true;
try {
const userKey = await this.webAuthnPrfUnlockService.unlockVaultWithPrf(this.userId);
this.unlockSuccess.emit(userKey);
} catch (error) {
this.logService.error("[UnlockViaPrfComponent] Failed to unlock via PRF:", error);
let errorMessage = this.i18nService.t("unexpectedError");
// Handle specific PRF error cases
if (error instanceof Error) {
if (error.message.includes("No PRF credentials")) {
errorMessage = this.i18nService.t("noPrfCredentialsAvailable");
} else if (error.message.includes("canceled")) {
// User canceled the operation, don't show error
this.unlocking = false;
return;
}
}
await this.dialogService.openSimpleDialog({
title: { key: "error" },
content: errorMessage,
acceptButtonText: { key: "ok" },
type: "danger",
});
} finally {
this.unlocking = false;
}
}
}

View File

@@ -0,0 +1,288 @@
import { firstValueFrom } from "rxjs";
import {
UserDecryptionOptions,
UserDecryptionOptionsServiceAbstraction,
WebAuthnPrfUserDecryptionOption,
} from "@bitwarden/auth/common";
import { WebAuthnLoginPrfKeyServiceAbstraction } from "@bitwarden/common/auth/abstractions/webauthn/webauthn-login-prf-key.service.abstraction";
import { ClientType } from "@bitwarden/common/enums";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { Fido2Utils } from "@bitwarden/common/platform/services/fido2/fido2-utils";
import { UserId } from "@bitwarden/common/types/guid";
import { PrfKey, UserKey } from "@bitwarden/common/types/key";
import { KeyService } from "@bitwarden/key-management";
import { WebAuthnPrfUnlockService } from "./webauthn-prf-unlock.service";
export class DefaultWebAuthnPrfUnlockService implements WebAuthnPrfUnlockService {
private navigatorCredentials: CredentialsContainer;
constructor(
private webAuthnLoginPrfKeyService: WebAuthnLoginPrfKeyServiceAbstraction,
private keyService: KeyService,
private userDecryptionOptionsService: UserDecryptionOptionsServiceAbstraction,
private encryptService: EncryptService,
private environmentService: EnvironmentService,
private platformUtilsService: PlatformUtilsService,
private window: Window,
private logService: LogService,
private configService: ConfigService,
) {
this.navigatorCredentials = this.window.navigator.credentials;
}
async isPrfUnlockAvailable(userId: UserId): Promise<boolean> {
try {
// Check if feature flag is enabled
const passkeyUnlockEnabled = await this.configService.getFeatureFlag(
FeatureFlag.PasskeyUnlock,
);
if (!passkeyUnlockEnabled) {
return false;
}
// Check if browser supports WebAuthn
if (!this.navigatorCredentials || !this.navigatorCredentials.get) {
return false;
}
// If we're in the browser extension, check if we're in a Chromium browser
if (
this.platformUtilsService.getClientType() === ClientType.Browser &&
!this.platformUtilsService.isChromium()
) {
return false;
}
// Check if user has any WebAuthn PRF credentials registered
const credentials = await this.getPrfUnlockCredentials(userId);
if (credentials.length === 0) {
return false;
}
return true;
} catch (error) {
this.logService.error("Error checking PRF unlock availability:", error);
return false;
}
}
private async getPrfUnlockCredentials(
userId: UserId,
): Promise<{ credentialId: string; transports: string[] }[]> {
try {
const userDecryptionOptions = await this.getUserDecryptionOptions(userId);
if (!userDecryptionOptions?.webAuthnPrfOptions) {
return [];
}
return userDecryptionOptions.webAuthnPrfOptions.map((option) => ({
credentialId: option.credentialId,
transports: option.transports,
}));
} catch (error) {
this.logService.error("Error getting PRF unlock credentials:", error);
return [];
}
}
/**
* Unlocks the vault using WebAuthn PRF.
*
* @param userId The user ID to unlock vault for
* @returns Promise<UserKey> the decrypted user key
* @throws Error if unlock fails for any reason
*/
async unlockVaultWithPrf(userId: UserId): Promise<UserKey> {
// Get offline PRF credentials from user decryption options
const credentials = await this.getPrfUnlockCredentials(userId);
if (credentials.length === 0) {
throw new Error("No PRF credentials available for unlock");
}
const response = await this.performWebAuthnGetWithPrf(credentials, userId);
const prfKey = await this.createPrfKeyFromResponse(response);
const prfOption = await this.getPrfOptionForCredential(response.id, userId);
// PRF unlock follows the same key derivation process as PRF login:
// PRF key → decrypt private key → use private key to decrypt user key
// Step 1: Decrypt PRF encrypted private key using the PRF key
const privateKey = await this.encryptService.unwrapDecapsulationKey(
new EncString(prfOption.encryptedPrivateKey),
prfKey,
);
// Step 2: Use private key to decrypt user key
const userKey = await this.encryptService.decapsulateKeyUnsigned(
new EncString(prfOption.encryptedUserKey),
privateKey,
);
if (!userKey) {
throw new Error("Failed to decrypt user key from private key");
}
return userKey as UserKey;
}
/**
* Performs WebAuthn get operation with PRF extension.
*
* @param credentials Available PRF credentials for the user
* @returns PublicKeyCredential response from the authenticator
* @throws Error if WebAuthn operation fails or returns invalid response
*/
private async performWebAuthnGetWithPrf(
credentials: { credentialId: string; transports: string[] }[],
userId: UserId,
): Promise<PublicKeyCredential> {
const rpId = await this.getRpIdForUser(userId);
const prfSalt = await this.getUnlockWithPrfSalt();
const options: CredentialRequestOptions = {
publicKey: {
challenge: new Uint8Array(32),
allowCredentials: credentials.map(({ credentialId, transports }) => {
// The credential ID is already base64url encoded from login storage
// We need to decode it to ArrayBuffer for WebAuthn
const decodedId = Fido2Utils.stringToBuffer(credentialId);
return {
type: "public-key",
id: decodedId,
transports: (transports || []) as AuthenticatorTransport[],
};
}),
rpId,
userVerification: "preferred", // Allow platform authenticators to work properly
extensions: {
prf: { eval: { first: prfSalt } },
} as any,
},
};
const response = await this.navigatorCredentials.get(options);
if (!response) {
throw new Error("WebAuthn get() returned null/undefined");
}
if (!(response instanceof PublicKeyCredential)) {
throw new Error("Failed to get PRF credential for unlock");
}
return response;
}
/**
* Extracts PRF result from WebAuthn response and creates a PrfKey.
*
* @param response PublicKeyCredential response from authenticator
* @returns PrfKey derived from the PRF extension output
* @throws Error if no PRF result is present in the response
*/
private async createPrfKeyFromResponse(response: PublicKeyCredential): Promise<PrfKey> {
// Extract PRF result
// TODO: Remove `any` when typescript typings add support for PRF
const extensionResults = response.getClientExtensionResults() as any;
const prfResult = extensionResults.prf?.results?.first;
if (!prfResult) {
throw new Error("No PRF result received from authenticator");
}
try {
return await this.webAuthnLoginPrfKeyService.createSymmetricKeyFromPrf(prfResult);
} catch (error) {
this.logService.error("Failed to create unlock key from PRF:", error);
throw error;
}
}
/**
* Gets the WebAuthn PRF option that matches the credential used in the response.
*
* @param credentialId Credential ID to match
* @param userId User ID to get decryption options for
* @returns Matching WebAuthnPrfUserDecryptionOption with encrypted keys
* @throws Error if no PRF options exist or no matching option is found
*/
private async getPrfOptionForCredential(
credentialId: string,
userId: UserId,
): Promise<WebAuthnPrfUserDecryptionOption> {
const userDecryptionOptions = await this.getUserDecryptionOptions(userId);
if (
!userDecryptionOptions?.webAuthnPrfOptions ||
userDecryptionOptions.webAuthnPrfOptions.length === 0
) {
throw new Error("No WebAuthn PRF option found for user - cannot perform PRF unlock");
}
const prfOption = userDecryptionOptions.webAuthnPrfOptions.find(
(option) => option.credentialId === credentialId,
);
if (!prfOption) {
throw new Error("No matching WebAuthn PRF option found for this credential");
}
return prfOption;
}
private async getUnlockWithPrfSalt(): Promise<ArrayBuffer> {
try {
// Use the same salt as login to ensure PRF keys match
return await this.webAuthnLoginPrfKeyService.getLoginWithPrfSalt();
} catch (error) {
this.logService.error("Error getting unlock PRF salt:", error);
throw error;
}
}
/**
* Helper method to get user decryption options for a user
*/
private async getUserDecryptionOptions(userId: UserId): Promise<UserDecryptionOptions | null> {
try {
return (await firstValueFrom(
this.userDecryptionOptionsService.userDecryptionOptionsById$(userId),
)) as UserDecryptionOptions;
} catch (error) {
this.logService.error("Error getting user decryption options:", error);
return null;
}
}
/**
* Helper method to get the appropriate rpId for WebAuthn PRF operations
* Returns the hostname from the user's environment configuration
*/
private async getRpIdForUser(userId: UserId): Promise<string | undefined> {
try {
const environment = await firstValueFrom(this.environmentService.getEnvironment$(userId));
const hostname = environment.getHostname();
// The navigator.credentials.get call will fail if rpId is set but is null/empty. Undefined uses the current host.
if (!hostname) {
return undefined;
}
// Extract hostname using URL parsing to handle IPv6 and ports correctly
// This removes ports etc.
const url = new URL(`https://${hostname}`);
const rpId = url.hostname;
return rpId;
} catch (error) {
this.logService.error("Error getting rpId", error);
return undefined;
}
}
}

View File

@@ -10,6 +10,7 @@ export const UnlockOption = Object.freeze({
MasterPassword: "masterPassword",
Pin: "pin",
Biometrics: "biometrics",
Prf: "prf",
}) satisfies { [Prop in keyof UnlockOptions as Capitalize<Prop>]: Prop };
export type UnlockOptions = {
@@ -23,6 +24,9 @@ export type UnlockOptions = {
enabled: boolean;
biometricsStatus: BiometricsStatus;
};
prf: {
enabled: boolean;
};
};
/**

View File

@@ -0,0 +1,27 @@
import { UserKey } from "@bitwarden/common/types/key";
import { UserId } from "@bitwarden/user-core";
/**
* Service for unlocking vault using WebAuthn PRF.
* Provides offline vault unlock capabilities by deriving unlock keys from PRF outputs.
*/
export abstract class WebAuthnPrfUnlockService {
/**
* Check if PRF unlock is available for the current user
* @param userId The user ID to check PRF unlock availability for
* @returns Promise<boolean> true if PRF unlock is available
*/
abstract isPrfUnlockAvailable(userId: UserId): Promise<boolean>;
/**
* Attempt to unlock the vault using WebAuthn PRF
* @param userId The user ID to unlock vault for
* @returns Promise<UserKey> the decrypted user key
* @throws Error if no PRF credentials are available
* @throws Error if the authenticator returns no PRF result
* @throws Error if the user cancels the WebAuthn operation
* @throws Error if decryption of the user key fails
* @throws Error if no matching PRF option is found for the credential
*/
abstract unlockVaultWithPrf(userId: UserId): Promise<UserKey>;
}