diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index dabd238e039..61085828cf2 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -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" }, diff --git a/apps/browser/src/key-management/lock/services/extension-lock-component.service.spec.ts b/apps/browser/src/key-management/lock/services/extension-lock-component.service.spec.ts index 7678b65d29e..ecdb899b9a7 100644 --- a/apps/browser/src/key-management/lock/services/extension-lock-component.service.spec.ts +++ b/apps/browser/src/key-management/lock/services/extension-lock-component.service.spec.ts @@ -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; let routerService: MockProxy; let biometricStateService: MockProxy; + let webAuthnPrfUnlockService: MockProxy; beforeEach(() => { userDecryptionOptionsService = mock(); @@ -43,37 +44,21 @@ describe("ExtensionLockComponentService", () => { vaultTimeoutSettingsService = mock(); routerService = mock(); biometricStateService = mock(); + webAuthnPrfUnlockService = mock(); 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); diff --git a/apps/browser/src/key-management/lock/services/extension-lock-component.service.ts b/apps/browser/src/key-management/lock/services/extension-lock-component.service.ts index 9f137d694a9..5e6e564bbc2 100644 --- a/apps/browser/src/key-management/lock/services/extension-lock-component.service.ts +++ b/apps/browser/src/key-management/lock/services/extension-lock-component.service.ts @@ -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; }), diff --git a/apps/browser/src/popup/services/services.module.ts b/apps/browser/src/popup/services/services.module.ts index 7b207f0fac1..a8bfb23d83f 100644 --- a/apps/browser/src/popup/services/services.module.ts +++ b/apps/browser/src/popup/services/services.module.ts @@ -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>>(), deps: [], }), - safeProvider({ - provide: MessageSender, - useFactory: (subject: Subject>>, 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, diff --git a/apps/desktop/src/key-management/lock/services/desktop-lock-component.service.spec.ts b/apps/desktop/src/key-management/lock/services/desktop-lock-component.service.spec.ts index dd21cf883f3..b01e62d2af3 100644 --- a/apps/desktop/src/key-management/lock/services/desktop-lock-component.service.spec.ts +++ b/apps/desktop/src/key-management/lock/services/desktop-lock-component.service.spec.ts @@ -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, + }, }, ], ]; diff --git a/apps/desktop/src/key-management/lock/services/desktop-lock-component.service.ts b/apps/desktop/src/key-management/lock/services/desktop-lock-component.service.ts index fc57a3873ef..0b1896f02f9 100644 --- a/apps/desktop/src/key-management/lock/services/desktop-lock-component.service.ts +++ b/apps/desktop/src/key-management/lock/services/desktop-lock-component.service.ts @@ -69,6 +69,9 @@ export class DesktopLockComponentService implements LockComponentService { enabled: biometricsStatus == BiometricsStatus.Available, biometricsStatus: biometricsStatus, }, + prf: { + enabled: false, + }, }; return unlockOpts; diff --git a/apps/web/src/app/core/core.module.ts b/apps/web/src/app/core/core.module.ts index 7b248eee8a3..d21b5039d2a 100644 --- a/apps/web/src/app/core/core.module.ts +++ b/apps/web/src/app/core/core.module.ts @@ -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({ diff --git a/apps/web/src/app/key-management/lock/services/web-lock-component.service.spec.ts b/apps/web/src/app/key-management/lock/services/web-lock-component.service.spec.ts index 9e993259830..a8e1830971e 100644 --- a/apps/web/src/app/key-management/lock/services/web-lock-component.service.spec.ts +++ b/apps/web/src/app/key-management/lock/services/web-lock-component.service.spec.ts @@ -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; + let webAuthnPrfUnlockService: MockProxy; beforeEach(() => { userDecryptionOptionsService = mock(); + webAuthnPrfUnlockService = mock(); 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, + }, }); }); }); diff --git a/apps/web/src/app/key-management/lock/services/web-lock-component.service.ts b/apps/web/src/app/key-management/lock/services/web-lock-component.service.ts index ea038ca2c67..0451aa08689 100644 --- a/apps/web/src/app/key-management/lock/services/web-lock-component.service.ts +++ b/apps/web/src/app/key-management/lock/services/web-lock-component.service.ts @@ -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 { - 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; }), diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index b15d60bf6b5..5a83bc75810 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -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" }, diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index cf41b28baca..7b504548ff5 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -886,7 +886,7 @@ const safeProviders: SafeProvider[] = [ FolderApiServiceAbstraction, InternalOrganizationServiceAbstraction, SendApiServiceAbstraction, - UserDecryptionOptionsServiceAbstraction, + InternalUserDecryptionOptionsServiceAbstraction, AvatarServiceAbstraction, LOGOUT_CALLBACK, BillingAccountProfileStateService, diff --git a/libs/auth/src/common/login-strategies/webauthn-login.strategy.spec.ts b/libs/auth/src/common/login-strategies/webauthn-login.strategy.spec.ts index 2ae79f46d7c..94d2c6b65aa 100644 --- a/libs/auth/src/common/login-strategies/webauthn-login.strategy.spec.ts +++ b/libs/auth/src/common/login-strategies/webauthn-login.strategy.spec.ts @@ -175,6 +175,8 @@ describe("WebAuthnLoginStrategy", () => { WebAuthnPrfOption: { EncryptedPrivateKey: mockEncPrfPrivateKey, EncryptedUserKey: mockEncUserKey, + CredentialId: "mockCredentialId", + Transports: ["usb", "nfc"], }, }; diff --git a/libs/auth/src/common/login-strategies/webauthn-login.strategy.ts b/libs/auth/src/common/login-strategies/webauthn-login.strategy.ts index 77a881b5964..019e1d9860e 100644 --- a/libs/auth/src/common/login-strategies/webauthn-login.strategy.ts +++ b/libs/auth/src/common/login-strategies/webauthn-login.strategy.ts @@ -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, diff --git a/libs/auth/src/common/models/domain/user-decryption-options.ts b/libs/auth/src/common/models/domain/user-decryption-options.ts index 44d8bff4d2c..561a833f3c9 100644 --- a/libs/auth/src/common/models/domain/user-decryption-options.ts +++ b/libs/auth/src/common/models/domain/user-decryption-options.ts @@ -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 | 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; } } diff --git a/libs/common/src/auth/models/response/user-decryption-options/user-decryption-options.response.ts b/libs/common/src/auth/models/response/user-decryption-options/user-decryption-options.response.ts index 4ebadc0daa9..4c5a67d2c31 100644 --- a/libs/common/src/auth/models/response/user-decryption-options/user-decryption-options.response.ts +++ b/libs/common/src/auth/models/response/user-decryption-options/user-decryption-options.response.ts @@ -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) { diff --git a/libs/common/src/auth/models/response/user-decryption-options/webauthn-prf-decryption-option.response.ts b/libs/common/src/auth/models/response/user-decryption-options/webauthn-prf-decryption-option.response.ts index 478f6d88b5b..b2b5a57ce8f 100644 --- a/libs/common/src/auth/models/response/user-decryption-options/webauthn-prf-decryption-option.response.ts +++ b/libs/common/src/auth/models/response/user-decryption-options/webauthn-prf-decryption-option.response.ts @@ -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") || []; } } diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index c96f6996078..f761aea1b08 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -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, diff --git a/libs/common/src/key-management/models/response/user-decryption.response.ts b/libs/common/src/key-management/models/response/user-decryption.response.ts index b3ac5b80b32..b662834ab01 100644 --- a/libs/common/src/key-management/models/response/user-decryption.response.ts +++ b/libs/common/src/key-management/models/response/user-decryption.response.ts @@ -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), + ); + } } } diff --git a/libs/common/src/platform/sync/default-sync.service.spec.ts b/libs/common/src/platform/sync/default-sync.service.spec.ts index fc83954ee7d..bf086ceceaf 100644 --- a/libs/common/src/platform/sync/default-sync.service.spec.ts +++ b/libs/common/src/platform/sync/default-sync.service.spec.ts @@ -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; let organizationService: MockProxy; let sendApiService: MockProxy; - let userDecryptionOptionsService: MockProxy; + let userDecryptionOptionsService: MockProxy; let avatarService: MockProxy; let logoutCallback: jest.Mock, [logoutReason: LogoutReason, userId?: UserId]>; let billingAccountProfileStateService: MockProxy; diff --git a/libs/common/src/platform/sync/default-sync.service.ts b/libs/common/src/platform/sync/default-sync.service.ts index 3c8f6e57e1e..52de14bbc67 100644 --- a/libs/common/src/platform/sync/default-sync.service.ts +++ b/libs/common/src/platform/sync/default-sync.service.ts @@ -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, 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); + } + } } } diff --git a/libs/key-management-ui/src/index.ts b/libs/key-management-ui/src/index.ts index b273b49cb73..7b9d5a629ac 100644 --- a/libs/key-management-ui/src/index.ts +++ b/libs/key-management-ui/src/index.ts @@ -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"; diff --git a/libs/key-management-ui/src/lock/components/lock.component.html b/libs/key-management-ui/src/lock/components/lock.component.html index c1577b76a4d..a93464b265c 100644 --- a/libs/key-management-ui/src/lock/components/lock.component.html +++ b/libs/key-management-ui/src/lock/components/lock.component.html @@ -49,6 +49,8 @@ + + @@ -113,6 +115,11 @@ + + @@ -127,6 +134,7 @@ [unlockOptions]="unlockOptions" [biometricUnlockBtnText]="biometricUnlockBtnText" (successfulUnlock)="successfulMasterPasswordUnlock($event)" + (prfUnlockSuccess)="onPrfUnlockSuccess($event)" (logOut)="logOut()" > } diff --git a/libs/key-management-ui/src/lock/components/lock.component.spec.ts b/libs/key-management-ui/src/lock/components/lock.component.spec.ts index 054212f8851..47c4d14fc98 100644 --- a/libs/key-management-ui/src/lock/components/lock.component.spec.ts +++ b/libs/key-management-ui/src/lock/components/lock.component.spec.ts @@ -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(); const mockAnonLayoutWrapperDataService = mock(); const mockBroadcasterService = mock(); + const mockWebAuthnPrfUnlockService = mock(); const mockEncryptedMigrator = mock(); 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 }, ], diff --git a/libs/key-management-ui/src/lock/components/lock.component.ts b/libs/key-management-ui/src/lock/components/lock.component.ts index 03ab6033441..6057fe06456 100644 --- a/libs/key-management-ui/src/lock/components/lock.component.ts +++ b/libs/key-management-ui/src/lock/components/lock.component.ts @@ -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 { + await this.setUserKeyAndContinue(userKey); + } + + togglePassword() { + this.showPassword = !this.showPassword; + } + private validatePin(): boolean { if (this.formGroup?.invalid) { this.toastService.showToast({ diff --git a/libs/key-management-ui/src/lock/components/master-password-lock/master-password-lock.component.html b/libs/key-management-ui/src/lock/components/master-password-lock/master-password-lock.component.html index 4c7cdd48353..878915ec6ff 100644 --- a/libs/key-management-ui/src/lock/components/master-password-lock/master-password-lock.component.html +++ b/libs/key-management-ui/src/lock/components/master-password-lock/master-password-lock.component.html @@ -54,6 +54,11 @@ } + + diff --git a/libs/key-management-ui/src/lock/components/master-password-lock/master-password-lock.component.spec.ts b/libs/key-management-ui/src/lock/components/master-password-lock/master-password-lock.component.spec.ts index dabab3e558a..6d0da1033b7 100644 --- a/libs/key-management-ui/src/lock/components/master-password-lock/master-password-lock.component.spec.ts +++ b/libs/key-management-ui/src/lock/components/master-password-lock/master-password-lock.component.spec.ts @@ -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(); const platformUtilsService = mock(); const messageListener = mock(); + const webAuthnPrfUnlockService = mock(); + const dialogService = mock(); 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(); diff --git a/libs/key-management-ui/src/lock/components/master-password-lock/master-password-lock.component.ts b/libs/key-management-ui/src/lock/components/master-password-lock/master-password-lock.component.ts index 1237869717f..5229effd366 100644 --- a/libs/key-management-ui/src/lock/components/master-password-lock/master-password-lock.component.ts +++ b/libs/key-management-ui/src/lock/components/master-password-lock/master-password-lock.component.ts @@ -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(); logOut = output(); protected showPassword = false; @@ -143,4 +146,8 @@ export class MasterPasswordLockComponent implements OnInit, OnDestroy { }); } } + + onPrfUnlockSuccess(userKey: UserKey): void { + this.prfUnlockSuccess.emit(userKey); + } } diff --git a/libs/key-management-ui/src/lock/components/unlock-via-prf.component.ts b/libs/key-management-ui/src/lock/components/unlock-via-prf.component.ts new file mode 100644 index 00000000000..7a0b99b232d --- /dev/null +++ b/libs/key-management-ui/src/lock/components/unlock-via-prf.component.ts @@ -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()) { + + } + @if (!formButton()) { + + } + } + `, +}) +export class UnlockViaPrfComponent implements OnInit { + readonly formButton = input(false); + readonly unlockSuccess = output(); + + 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 { + 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 { + 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; + } + } +} diff --git a/libs/key-management-ui/src/lock/services/default-webauthn-prf-unlock.service.ts b/libs/key-management-ui/src/lock/services/default-webauthn-prf-unlock.service.ts new file mode 100644 index 00000000000..960a663b589 --- /dev/null +++ b/libs/key-management-ui/src/lock/services/default-webauthn-prf-unlock.service.ts @@ -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 { + 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 the decrypted user key + * @throws Error if unlock fails for any reason + */ + async unlockVaultWithPrf(userId: UserId): Promise { + // 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 { + 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 { + // 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 { + 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 { + 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 { + 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 { + 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; + } + } +} diff --git a/libs/key-management-ui/src/lock/services/lock-component.service.ts b/libs/key-management-ui/src/lock/services/lock-component.service.ts index 0fc25ca7dfb..53cb256f251 100644 --- a/libs/key-management-ui/src/lock/services/lock-component.service.ts +++ b/libs/key-management-ui/src/lock/services/lock-component.service.ts @@ -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 }; export type UnlockOptions = { @@ -23,6 +24,9 @@ export type UnlockOptions = { enabled: boolean; biometricsStatus: BiometricsStatus; }; + prf: { + enabled: boolean; + }; }; /** diff --git a/libs/key-management-ui/src/lock/services/webauthn-prf-unlock.service.ts b/libs/key-management-ui/src/lock/services/webauthn-prf-unlock.service.ts new file mode 100644 index 00000000000..f0b02a0ed3f --- /dev/null +++ b/libs/key-management-ui/src/lock/services/webauthn-prf-unlock.service.ts @@ -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 true if PRF unlock is available + */ + abstract isPrfUnlockAvailable(userId: UserId): Promise; + + /** + * Attempt to unlock the vault using WebAuthn PRF + * @param userId The user ID to unlock vault for + * @returns Promise 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; +}