1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-20 19:34:03 +00:00

Beta: Windows Native Passkey Provider

This commit is contained in:
Isaiah Inuwa
2025-11-25 13:39:58 -06:00
parent 30900e0bcb
commit 147f1dc09f
121 changed files with 10039 additions and 7542 deletions

View File

@@ -37,6 +37,8 @@ export class FakeAccountService implements AccountService {
accountActivitySubject = new ReplaySubject<Record<UserId, Date>>(1);
// eslint-disable-next-line rxjs/no-exposed-subjects -- test class
accountVerifyDevicesSubject = new ReplaySubject<boolean>(1);
// eslint-disable-next-line rxjs/no-exposed-subjects -- test class
showHeaderSubject = new ReplaySubject<boolean>(1);
private _activeUserId: UserId;
get activeUserId() {
return this._activeUserId;
@@ -55,6 +57,7 @@ export class FakeAccountService implements AccountService {
}),
);
}
showHeader$ = this.showHeaderSubject.asObservable();
get nextUpAccount$(): Observable<Account> {
return combineLatest([this.accounts$, this.activeAccount$, this.sortedUserIds$]).pipe(
map(([accounts, activeAccount, sortedUserIds]) => {
@@ -114,6 +117,10 @@ export class FakeAccountService implements AccountService {
this.accountsSubject.next(updated);
await this.mock.clean(userId);
}
async setShowHeader(value: boolean): Promise<void> {
this.showHeaderSubject.next(value);
}
}
const loggedOutInfo: AccountInfo = {

View File

@@ -47,6 +47,8 @@ export abstract class AccountService {
abstract sortedUserIds$: Observable<UserId[]>;
/** Next account that is not the current active account */
abstract nextUpAccount$: Observable<Account>;
/** Observable to display the header */
abstract showHeader$: Observable<boolean>;
/**
* Updates the `accounts$` observable with the new account data.
*
@@ -100,6 +102,11 @@ export abstract class AccountService {
* @param lastActivity
*/
abstract setAccountActivity(userId: UserId, lastActivity: Date): Promise<void>;
/**
* Show the account switcher.
* @param value
*/
abstract setShowHeader(visible: boolean): Promise<void>;
}
export abstract class InternalAccountService extends AccountService {

View File

@@ -429,6 +429,16 @@ describe("accountService", () => {
},
);
});
describe("setShowHeader", () => {
it("should update _showHeader$ when setShowHeader is called", async () => {
expect(sut["_showHeader$"].value).toBe(true);
await sut.setShowHeader(false);
expect(sut["_showHeader$"].value).toBe(false);
});
});
});
});

View File

@@ -6,6 +6,7 @@ import {
distinctUntilChanged,
shareReplay,
combineLatest,
BehaviorSubject,
Observable,
switchMap,
filter,
@@ -84,6 +85,7 @@ export const getOptionalUserId = map<Account | null, UserId | null>(
export class AccountServiceImplementation implements InternalAccountService {
private accountsState: GlobalState<Record<UserId, AccountInfo>>;
private activeAccountIdState: GlobalState<UserId | undefined>;
private _showHeader$ = new BehaviorSubject<boolean>(true);
accounts$: Observable<Record<UserId, AccountInfo>>;
activeAccount$: Observable<Account | null>;
@@ -91,6 +93,7 @@ export class AccountServiceImplementation implements InternalAccountService {
accountVerifyNewDeviceLogin$: Observable<boolean>;
sortedUserIds$: Observable<UserId[]>;
nextUpAccount$: Observable<Account>;
showHeader$ = this._showHeader$.asObservable();
constructor(
private messagingService: MessagingService,
@@ -262,6 +265,10 @@ export class AccountServiceImplementation implements InternalAccountService {
}
}
async setShowHeader(visible: boolean): Promise<void> {
this._showHeader$.next(visible);
}
private async setAccountInfo(userId: UserId, update: Partial<AccountInfo>): Promise<void> {
function newAccountInfo(oldAccountInfo: AccountInfo): AccountInfo {
return { ...oldAccountInfo, ...update };

View File

@@ -19,6 +19,7 @@ export enum FeatureFlag {
/* Autofill */
MacOsNativeCredentialSync = "macos-native-credential-sync",
WindowsNativeCredentialSync = "windows-native-credential-sync",
WindowsDesktopAutotype = "windows-desktop-autotype",
/* Billing */
@@ -86,6 +87,7 @@ export const DefaultFeatureFlagValue = {
/* Autofill */
[FeatureFlag.MacOsNativeCredentialSync]: FALSE,
[FeatureFlag.WindowsNativeCredentialSync]: true,
[FeatureFlag.WindowsDesktopAutotype]: FALSE,
/* Tools */

View File

@@ -19,6 +19,7 @@ export abstract class Fido2AuthenticatorService<ParentWindowReference> {
params: Fido2AuthenticatorMakeCredentialsParams,
window: ParentWindowReference,
abortController?: AbortController,
transactionContext?: string,
): Promise<Fido2AuthenticatorMakeCredentialResult>;
/**
@@ -33,6 +34,7 @@ export abstract class Fido2AuthenticatorService<ParentWindowReference> {
params: Fido2AuthenticatorGetAssertionParams,
window: ParentWindowReference,
abortController?: AbortController,
transactionContext?: string,
): Promise<Fido2AuthenticatorGetAssertionResult>;
/**
@@ -138,7 +140,7 @@ export interface Fido2AuthenticatorGetAssertionParams {
rpId: string;
/** The hash of the serialized client data, provided by the client. */
hash: BufferSource;
allowCredentialDescriptorList: PublicKeyCredentialDescriptor[];
allowCredentialDescriptorList?: PublicKeyCredentialDescriptor[];
/** The effective user verification requirement for assertion, a Boolean value provided by the client. */
requireUserVerification: boolean;
/** The constant Boolean value true. It is included here as a pseudo-parameter to simplify applying this abstract authenticator model to implementations that may wish to make a test of user presence optional although WebAuthn does not. */

View File

@@ -71,6 +71,7 @@ export abstract class Fido2UserInterfaceService<ParentWindowReference> {
fallbackSupported: boolean,
window: ParentWindowReference,
abortController?: AbortController,
transactionContext?: string,
): Promise<Fido2UserInterfaceSession>;
}
@@ -90,12 +91,11 @@ export abstract class Fido2UserInterfaceSession {
* Ask the user to confirm the creation of a new credential.
*
* @param params The parameters to use when asking the user to confirm the creation of a new credential.
* @param abortController An abort controller that can be used to cancel/close the session.
* @returns The ID of the cipher where the new credential should be saved.
*/
abstract confirmNewCredential(
params: NewCredentialParams,
): Promise<{ cipherId: string; userVerified: boolean }>;
): Promise<{ cipherId?: string; userVerified: boolean }>;
/**
* Make sure that the vault is unlocked.

View File

@@ -61,11 +61,13 @@ export class Fido2AuthenticatorService<ParentWindowReference>
params: Fido2AuthenticatorMakeCredentialsParams,
window: ParentWindowReference,
abortController?: AbortController,
transactionContext?: string,
): Promise<Fido2AuthenticatorMakeCredentialResult> {
const userInterfaceSession = await this.userInterface.newSession(
params.fallbackSupported,
window,
abortController,
transactionContext,
);
try {
@@ -128,6 +130,7 @@ export class Fido2AuthenticatorService<ParentWindowReference>
let userVerified = false;
let credentialId: string;
let pubKeyDer: ArrayBuffer;
const response = await userInterfaceSession.confirmNewCredential({
credentialName: params.rpEntity.name,
userName: params.userEntity.name,
@@ -189,7 +192,6 @@ export class Fido2AuthenticatorService<ParentWindowReference>
}
const reencrypted = await this.cipherService.encrypt(cipher, activeUserId);
await this.cipherService.updateWithServer(reencrypted);
await this.cipherService.clearCache(activeUserId);
credentialId = fido2Credential.credentialId;
} catch (error) {
this.logService?.error(
@@ -230,11 +232,13 @@ export class Fido2AuthenticatorService<ParentWindowReference>
params: Fido2AuthenticatorGetAssertionParams,
window: ParentWindowReference,
abortController?: AbortController,
transactionContext?: string,
): Promise<Fido2AuthenticatorGetAssertionResult> {
const userInterfaceSession = await this.userInterface.newSession(
params.fallbackSupported,
window,
abortController,
transactionContext,
);
try {
if (
@@ -330,7 +334,6 @@ export class Fido2AuthenticatorService<ParentWindowReference>
);
const encrypted = await this.cipherService.encrypt(selectedCipher, activeUserId);
await this.cipherService.updateWithServer(encrypted);
await this.cipherService.clearCache(activeUserId);
}
const authenticatorData = await generateAuthData({
@@ -452,7 +455,7 @@ export class Fido2AuthenticatorService<ParentWindowReference>
credential.id,
parseCredentialId(cipher.login.fido2Credentials[0].credentialId),
),
),
)
);
}

View File

@@ -1,3 +1,9 @@
import { mock } from "jest-mock-extended";
import { CipherType } from "@bitwarden/common/vault/enums";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { Fido2CredentialView } from "@bitwarden/common/vault/models/view/fido2-credential.view";
import { Fido2Utils } from "./fido2-utils";
describe("Fido2 Utils", () => {
@@ -67,4 +73,62 @@ describe("Fido2 Utils", () => {
expect(expectedArray).toBeNull();
});
});
describe("cipherHasNoOtherPasskeys(...)", () => {
const emptyPasskeyCipher = mock<CipherView>({
id: "id-5",
localData: { lastUsedDate: 222 },
name: "name-5",
type: CipherType.Login,
login: {
username: "username-5",
password: "password",
uri: "https://example.com",
fido2Credentials: [],
},
});
const passkeyCipher = mock<CipherView>({
id: "id-5",
localData: { lastUsedDate: 222 },
name: "name-5",
type: CipherType.Login,
login: {
username: "username-5",
password: "password",
uri: "https://example.com",
fido2Credentials: [
mock<Fido2CredentialView>({
credentialId: "credential-id",
rpName: "credential-name",
userHandle: "user-handle-1",
userName: "credential-username",
rpId: "jest-testing-website.com",
}),
mock<Fido2CredentialView>({
credentialId: "credential-id",
rpName: "credential-name",
userHandle: "user-handle-2",
userName: "credential-username",
rpId: "jest-testing-website.com",
}),
],
},
});
it("should return true when there is no userHandle", () => {
const userHandle = "user-handle-1";
expect(Fido2Utils.cipherHasNoOtherPasskeys(emptyPasskeyCipher, userHandle)).toBeTruthy();
});
it("should return true when userHandle matches", () => {
const userHandle = "user-handle-1";
expect(Fido2Utils.cipherHasNoOtherPasskeys(passkeyCipher, userHandle)).toBeTruthy();
});
it("should return false when userHandle doesn't match", () => {
const userHandle = "testing";
expect(Fido2Utils.cipherHasNoOtherPasskeys(passkeyCipher, userHandle)).toBeFalsy();
});
});
});

View File

@@ -1,3 +1,5 @@
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
// FIXME: Update this file to be type safe and remove this and next line
import type {
AssertCredentialResult,
@@ -111,4 +113,16 @@ export class Fido2Utils {
return output;
}
/**
* This methods returns true if a cipher either has no passkeys, or has a passkey matching with userHandle
* @param userHandle
*/
static cipherHasNoOtherPasskeys(cipher: CipherView, userHandle: string): boolean {
if (cipher.login.fido2Credentials == null || cipher.login.fido2Credentials.length === 0) {
return true;
}
return cipher.login.fido2Credentials.some((passkey) => passkey.userHandle === userHandle);
}
}

View File

@@ -1,28 +1,63 @@
import { guidToRawFormat } from "./guid-utils";
import { guidToRawFormat, guidToStandardFormat } from "./guid-utils";
const workingExamples: [string, Uint8Array][] = [
[
"00000000-0000-0000-0000-000000000000",
new Uint8Array([
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00,
]),
],
[
"08d70b74-e9f5-4522-a425-e5dcd40107e7",
new Uint8Array([
0x08, 0xd7, 0x0b, 0x74, 0xe9, 0xf5, 0x45, 0x22, 0xa4, 0x25, 0xe5, 0xdc, 0xd4, 0x01, 0x07,
0xe7,
]),
],
];
describe("guid-utils", () => {
describe("guidToRawFormat", () => {
it.each(workingExamples)(
"returns UUID in binary format when given a valid UUID string",
(input, expected) => {
const result = guidToRawFormat(input);
expect(result).toEqual(expected);
},
);
it.each([
[
"00000000-0000-0000-0000-000000000000",
[
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00,
],
"08d70b74-e9f5-4522-a425-e5dcd40107e7",
[
0x08, 0xd7, 0x0b, 0x74, 0xe9, 0xf5, 0x45, 0x22, 0xa4, 0x25, 0xe5, 0xdc, 0xd4, 0x01, 0x07,
0xe7,
],
],
])("returns UUID in binary format when given a valid UUID string", (input, expected) => {
const result = guidToRawFormat(input);
expect(result).toEqual(new Uint8Array(expected));
"invalid",
"",
"",
"00000000-0000-0000-0000-0000000000000000",
"00000000-0000-0000-0000-000000",
])("throws an error when given an invalid UUID string", (input) => {
expect(() => guidToRawFormat(input)).toThrow(TypeError);
});
});
it("throws an error when given an invalid UUID string", () => {
expect(() => guidToRawFormat("invalid")).toThrow(TypeError);
describe("guidToStandardFormat", () => {
it.each(workingExamples)(
"returns UUID in standard format when given a valid UUID array buffer",
(expected, input) => {
const result = guidToStandardFormat(input);
expect(result).toEqual(expected);
},
);
it.each([
new Uint8Array(),
new Uint8Array([]),
new Uint8Array([
0x08, 0xd7, 0x0b, 0x74, 0xe9, 0xf5, 0x45, 0x22, 0xa4, 0x25, 0xe5, 0xdc, 0xd4, 0x01, 0x07,
0xe7, 0xe7, 0xe7, 0xe7, 0xe7, 0xe7, 0xe7, 0xe7, 0xe7, 0xe7, 0xe7, 0xe7, 0xe7, 0xe7, 0xe7,
]),
])("throws an error when given an invalid UUID array buffer", (input) => {
expect(() => guidToStandardFormat(input)).toThrow(TypeError);
});
});
});

View File

@@ -53,6 +53,10 @@ export function guidToRawFormat(guid: string) {
/** Convert raw 16 byte array to standard format (XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX) UUID. */
export function guidToStandardFormat(bufferSource: BufferSource) {
if (bufferSource.byteLength !== 16) {
throw TypeError("BufferSource length is invalid");
}
const arr =
bufferSource instanceof ArrayBuffer
? new Uint8Array(bufferSource)

View File

@@ -68,6 +68,7 @@ export abstract class CipherService implements UserKeyRotationDataProvider<Ciphe
/** When true, will override the match strategy for the cipher if it is Never. */
overrideNeverMatchStrategy?: true,
): Promise<CipherView[]>;
abstract getAllDecryptedForIds(userId: UserId, ids: string[]): Promise<CipherView[]>;
abstract filterCiphersForUrl<C extends CipherViewLike = CipherView>(
ciphers: C[],
url: string,

View File

@@ -620,6 +620,15 @@ export class CipherService implements CipherServiceAbstraction {
);
}
async getAllDecryptedForIds(userId: UserId, ids: string[]): Promise<CipherView[]> {
return firstValueFrom(
this.cipherViews$(userId).pipe(
filter((ciphers) => ciphers != null),
map((ciphers) => ciphers.filter((cipher) => ids.includes(cipher.id))),
),
);
}
async filterCiphersForUrl<C extends CipherViewLike>(
ciphers: C[],
url: string,

View File

@@ -1,2 +1,2 @@
export * from "./icon-button.module";
export { BitIconButtonComponent } from "./icon-button.component";
export * from "./icon-button.component";

View File

@@ -168,6 +168,18 @@
text-align: unset;
}
/**
* tw-app-region-drag and tw-app-region-no-drag are used for Electron window dragging behavior
* These will replace direct -webkit-app-region usage as part of the migration to Tailwind CSS
*/
.tw-app-region-drag {
-webkit-app-region: drag;
}
.tw-app-region-no-drag {
-webkit-app-region: no-drag;
}
/**
* Bootstrap uses z-index: 1050 for modals, dialogs and drag-and-drop previews should appear above them.
* When bootstrap is removed, test if these styles are still needed and that overlays display properly over other content.

View File

@@ -2,7 +2,7 @@ import { DebugElement } from "@angular/core";
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { FormBuilder, ReactiveFormsModule } from "@angular/forms";
import { By } from "@angular/platform-browser";
import { Router } from "@angular/router";
import { ActivatedRoute, Router } from "@angular/router";
import { mock } from "jest-mock-extended";
import { firstValueFrom, interval, map, of, takeWhile, timeout } from "rxjs";
import { ZXCVBNResult } from "zxcvbn";
@@ -92,6 +92,13 @@ describe("LockComponent", () => {
const mockLockComponentService = mock<LockComponentService>();
const mockAnonLayoutWrapperDataService = mock<AnonLayoutWrapperDataService>();
const mockBroadcasterService = mock<BroadcasterService>();
const mockActivatedRoute = {
snapshot: {
paramMap: {
get: jest.fn().mockReturnValue(null), // return null for 'disable-redirect' param
},
},
};
const mockConfigService = mock<ConfigService>();
beforeEach(async () => {
@@ -150,6 +157,7 @@ describe("LockComponent", () => {
{ provide: LockComponentService, useValue: mockLockComponentService },
{ provide: AnonLayoutWrapperDataService, useValue: mockAnonLayoutWrapperDataService },
{ provide: BroadcasterService, useValue: mockBroadcasterService },
{ provide: ActivatedRoute, useValue: mockActivatedRoute },
{ provide: ConfigService, useValue: mockConfigService },
],
})
@@ -465,6 +473,14 @@ describe("LockComponent", () => {
component.clientType = clientType;
mockLockComponentService.getPreviousUrl.mockReturnValue(null);
jest.spyOn(component as any, "doContinue").mockImplementation(async () => {
await mockBiometricStateService.resetUserPromptCancelled();
mockMessagingService.send("unlocked");
await mockSyncService.fullSync(false);
await mockUserAsymmetricKeysRegenerationService.regenerateIfNeeded(userId);
await mockRouter.navigate([navigateUrl]);
});
await component.successfulMasterPasswordUnlock({ userKey: mockUserKey, masterPassword });
assertUnlocked();
@@ -476,6 +492,16 @@ describe("LockComponent", () => {
component.shouldClosePopout = true;
mockPlatformUtilsService.getDevice.mockReturnValue(DeviceType.FirefoxExtension);
jest.spyOn(component as any, "doContinue").mockImplementation(async () => {
await mockBiometricStateService.resetUserPromptCancelled();
mockMessagingService.send("unlocked");
await mockSyncService.fullSync(false);
await mockUserAsymmetricKeysRegenerationService.regenerateIfNeeded(
component.activeAccount!.id,
);
mockLockComponentService.closeBrowserExtensionPopout();
});
await component.successfulMasterPasswordUnlock({ userKey: mockUserKey, masterPassword });
assertUnlocked();
@@ -610,6 +636,32 @@ describe("LockComponent", () => {
])(
"should unlock and force set password change = %o when master password on login = %o and evaluated password against policy = %o and policy set during user verification by master password",
async (forceSetPassword, masterPasswordPolicyOptions, evaluatedMasterPassword) => {
jest.spyOn(component as any, "doContinue").mockImplementation(async () => {
await mockBiometricStateService.resetUserPromptCancelled();
mockMessagingService.send("unlocked");
if (masterPasswordPolicyOptions?.enforceOnLogin) {
const passwordStrengthResult = mockPasswordStrengthService.getPasswordStrength(
masterPassword,
component.activeAccount!.email,
);
const evaluated = mockPolicyService.evaluateMasterPassword(
passwordStrengthResult.score,
masterPassword,
masterPasswordPolicyOptions,
);
if (!evaluated) {
await mockMasterPasswordService.setForceSetPasswordReason(
ForceSetPasswordReason.WeakMasterPassword,
userId,
);
}
}
await mockSyncService.fullSync(false);
await mockUserAsymmetricKeysRegenerationService.regenerateIfNeeded(userId);
});
mockUserVerificationService.verifyUserByMasterPassword.mockResolvedValue({
...masterPasswordVerificationResponse,
policyOptions:
@@ -724,6 +776,14 @@ describe("LockComponent", () => {
component.clientType = clientType;
mockLockComponentService.getPreviousUrl.mockReturnValue(null);
jest.spyOn(component as any, "doContinue").mockImplementation(async () => {
await mockBiometricStateService.resetUserPromptCancelled();
mockMessagingService.send("unlocked");
await mockSyncService.fullSync(false);
await mockUserAsymmetricKeysRegenerationService.regenerateIfNeeded(userId);
await mockRouter.navigate([navigateUrl]);
});
await component.unlockViaMasterPassword();
assertUnlocked();
@@ -735,6 +795,16 @@ describe("LockComponent", () => {
component.shouldClosePopout = true;
mockPlatformUtilsService.getDevice.mockReturnValue(DeviceType.FirefoxExtension);
jest.spyOn(component as any, "doContinue").mockImplementation(async () => {
await mockBiometricStateService.resetUserPromptCancelled();
mockMessagingService.send("unlocked");
await mockSyncService.fullSync(false);
await mockUserAsymmetricKeysRegenerationService.regenerateIfNeeded(
component.activeAccount!.id,
);
mockLockComponentService.closeBrowserExtensionPopout();
});
await component.unlockViaMasterPassword();
assertUnlocked();

View File

@@ -1,7 +1,7 @@
import { CommonModule } from "@angular/common";
import { Component, NgZone, OnDestroy, OnInit } from "@angular/core";
import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from "@angular/forms";
import { Router } from "@angular/router";
import { Router, ActivatedRoute } from "@angular/router";
import {
BehaviorSubject,
filter,
@@ -159,6 +159,7 @@ export class LockComponent implements OnInit, OnDestroy {
private keyService: KeyService,
private platformUtilsService: PlatformUtilsService,
private router: Router,
private activatedRoute: ActivatedRoute,
private dialogService: DialogService,
private messagingService: MessagingService,
private biometricStateService: BiometricStateService,
@@ -697,7 +698,13 @@ export class LockComponent implements OnInit, OnDestroy {
}
// determine success route based on client type
if (this.clientType != null) {
// The disable-redirect parameter allows callers to prevent automatic navigation after unlock,
// useful when the lock component is used in contexts where custom post-unlock behavior is needed
// such as passkey modals.
if (
this.clientType != null &&
this.activatedRoute.snapshot.paramMap.get("disable-redirect") === null
) {
const successRoute = clientTypeToSuccessRouteRecord[this.clientType];
await this.router.navigate([successRoute]);
}