1
0
mirror of https://github.com/bitwarden/browser synced 2026-01-02 16:43:19 +00:00
Files
browser/apps/desktop/src/autofill/services/desktop-autofill.service.ts
Jeffrey Holland 92e9dca6b4 Autofill/pm 9034 implement passkey for unlocked accounts (#13826)
* Passkey stuff

Co-authored-by: Anders Åberg <github@andersaberg.com>

* Ugly hacks

* Work On Modal State Management

* Applying modalStyles

* modal

* Improved hide/show

* fixed promise

* File name

* fix prettier

* Protecting against null API's and undefined data

* Only show fake popup to devs

* cleanup mock code

* rename minmimal-app to modal-app

* Added comment

* Added comment

* removed old comment

* Avoided changing minimum size

* Add small comment

* Rename component

* adress feedback

* Fixed uppercase file

* Fixed build

* Added codeowners

* added void

* commentary

* feat: reset setting on app start

* Moved reset to be in main / process launch

* Add comment to create window

* Added a little bit of styling

* Use Messaging service to loadUrl

* Enable passkeysautofill

* Add logging

* halfbaked

* Integration working

* And now it works without extra delay

* Clean up

* add note about messaging

* lb

* removed console.logs

* Cleanup and adress review feedback

* This hides the swift UI

* add modal components

* update modal with correct ciphers and functionality

* add create screen

* pick credential, draft

* Remove logger

* a whole lot of wiring

* not working

* Improved wiring

* Cancel after 90s

* Introduced observable

* update cipher handling

* update to use matchesUri

* Launching bitwarden if its not running

* Passing position from native to electron

* Rename inModalMode to modalMode

* remove tap

* revert spaces

* added back isDev

* cleaned up a bit

* Cleanup swift file

* tweaked logging

* clean up

* Update apps/desktop/macos/autofill-extension/CredentialProviderViewController.swift

Co-authored-by: Andreas Coroiu <acoroiu@bitwarden.com>

* Update apps/desktop/src/platform/main/autofill/native-autofill.main.ts

Co-authored-by: Andreas Coroiu <acoroiu@bitwarden.com>

* Update apps/desktop/src/platform/services/desktop-settings.service.ts

Co-authored-by: Andreas Coroiu <acoroiu@bitwarden.com>

* adress position feedback

* Update apps/desktop/macos/autofill-extension/CredentialProviderViewController.swift

Co-authored-by: Andreas Coroiu <acoroiu@bitwarden.com>

* Removed extra logging

* Adjusted error logging

* Use .error to log errors

* remove dead code

* Update desktop-autofill.service.ts

* use parseCredentialId instead of guidToRawFormat

* Update apps/desktop/src/autofill/services/desktop-autofill.service.ts

Co-authored-by: Andreas Coroiu <acoroiu@bitwarden.com>

* Change windowXy to a Record instead of [number,number]

* Update apps/desktop/src/autofill/services/desktop-fido2-user-interface.service.ts

Co-authored-by: Andreas Coroiu <acoroiu@bitwarden.com>

* Remove unsued dep and comment

* changed timeout to be spec recommended maxium, 10 minutes, for now.

* Correctly assume UP

* Removed extra cancelRequest in deinint

* Add timeout and UV to confirmChoseCipher

UV is performed by UI, not the service

* Improved docs regarding undefined cipherId

* cleanup: UP is no longer undefined

* Run completeError if ipc messages conversion failed

* don't throw, instead return undefined

* Disabled passkey provider

* Throw error if no activeUserId was found

* removed comment

* Fixed lint

* removed unsued service

* reset entitlement formatting

* Update entitlements.mas.plist

* Fix build issues

* Fix import issues

* Update route names to use `fido2`

* Fix being unable to select a passkey

* Fix linting issues

* Followup to fix merge issues and other comments

* Update `userHandle` value

* Add error handling for missing session or other errors

* Remove unused route

* Fix linting issues

* Simplify updateCredential method

* Followup to remove comments and timeouts and handle errors

* Address lint issue by using `takeUntilDestroyed`

* PR Followup for typescript and vault concerns

* Add try block for cipher creation

* Make userId manditory for cipher service

---------

Co-authored-by: Justin Baur <19896123+justindbaur@users.noreply.github.com>
Co-authored-by: Anders Åberg <github@andersaberg.com>
Co-authored-by: Anders Åberg <anders@andersaberg.com>
Co-authored-by: Colton Hurst <colton@coltonhurst.com>
Co-authored-by: Andreas Coroiu <andreas.coroiu@gmail.com>
Co-authored-by: Evan Bassler <evanbassler@Mac.attlocal.net>
Co-authored-by: Andreas Coroiu <acoroiu@bitwarden.com>
2025-04-08 16:59:23 +02:00

350 lines
12 KiB
TypeScript

import { Injectable, OnDestroy } from "@angular/core";
import { autofill } from "desktop_native/napi";
import {
Subject,
distinctUntilChanged,
filter,
firstValueFrom,
map,
mergeMap,
switchMap,
takeUntil,
} from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getOptionalUserId } from "@bitwarden/common/auth/services/account.service";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { UriMatchStrategy } from "@bitwarden/common/models/domain/domain-service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import {
Fido2AuthenticatorGetAssertionParams,
Fido2AuthenticatorGetAssertionResult,
Fido2AuthenticatorMakeCredentialResult,
Fido2AuthenticatorMakeCredentialsParams,
Fido2AuthenticatorService as Fido2AuthenticatorServiceAbstraction,
} from "@bitwarden/common/platform/abstractions/fido2/fido2-authenticator.service.abstraction";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { parseCredentialId } from "@bitwarden/common/platform/services/fido2/credential-id-utils";
import { getCredentialsForAutofill } from "@bitwarden/common/platform/services/fido2/fido2-autofill-utils";
import { Fido2Utils } from "@bitwarden/common/platform/services/fido2/fido2-utils";
import { UserId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherType } from "@bitwarden/common/vault/enums";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { NativeAutofillStatusCommand } from "../../platform/main/autofill/status.command";
import {
NativeAutofillFido2Credential,
NativeAutofillPasswordCredential,
NativeAutofillSyncCommand,
} from "../../platform/main/autofill/sync.command";
import type { NativeWindowObject } from "./desktop-fido2-user-interface.service";
@Injectable()
export class DesktopAutofillService implements OnDestroy {
private destroy$ = new Subject<void>();
constructor(
private logService: LogService,
private cipherService: CipherService,
private configService: ConfigService,
private fido2AuthenticatorService: Fido2AuthenticatorServiceAbstraction<NativeWindowObject>,
private accountService: AccountService,
) {}
async init() {
this.configService
.getFeatureFlag$(FeatureFlag.MacOsNativeCredentialSync)
.pipe(
distinctUntilChanged(),
switchMap((enabled) => {
return this.accountService.activeAccount$.pipe(
map((account) => account?.id),
filter((userId): userId is UserId => userId != null),
switchMap((userId) => this.cipherService.cipherViews$(userId)),
);
}),
// TODO: This will unset all the autofill credentials on the OS
// when the account locks. We should instead explicilty clear the credentials
// when the user logs out. Maybe by subscribing to the encrypted ciphers observable instead.
mergeMap((cipherViewMap) => this.sync(Object.values(cipherViewMap ?? []))),
takeUntil(this.destroy$),
)
.subscribe();
this.listenIpc();
}
/** Give metadata about all available credentials in the users vault */
async sync(cipherViews: CipherView[]) {
const status = await this.status();
if (status.type === "error") {
return this.logService.error("Error getting autofill status", status.error);
}
if (!status.value.state.enabled) {
// Autofill is disabled
return;
}
let fido2Credentials: NativeAutofillFido2Credential[];
let passwordCredentials: NativeAutofillPasswordCredential[];
if (status.value.support.password) {
passwordCredentials = cipherViews
.filter(
(cipher) =>
cipher.type === CipherType.Login &&
cipher.login.uris?.length > 0 &&
cipher.login.uris.some((uri) => uri.match !== UriMatchStrategy.Never) &&
cipher.login.uris.some((uri) => !Utils.isNullOrWhitespace(uri.uri)) &&
!Utils.isNullOrWhitespace(cipher.login.username),
)
.map((cipher) => ({
type: "password",
cipherId: cipher.id,
uri: cipher.login.uris.find((uri) => uri.match !== UriMatchStrategy.Never).uri,
username: cipher.login.username,
}));
}
if (status.value.support.fido2) {
fido2Credentials = (await getCredentialsForAutofill(cipherViews)).map((credential) => ({
type: "fido2",
...credential,
}));
}
const syncResult = await ipc.autofill.runCommand<NativeAutofillSyncCommand>({
namespace: "autofill",
command: "sync",
params: {
credentials: [...fido2Credentials, ...passwordCredentials],
},
});
if (syncResult.type === "error") {
return this.logService.error("Error syncing autofill credentials", syncResult.error);
}
this.logService.debug(`Synced ${syncResult.value.added} autofill credentials`);
}
/** Get autofill status from OS */
private status() {
// TODO: Investigate why this type needs to be explicitly set
return ipc.autofill.runCommand<NativeAutofillStatusCommand>({
namespace: "autofill",
command: "status",
params: {},
});
}
listenIpc() {
ipc.autofill.listenPasskeyRegistration(async (clientId, sequenceNumber, request, callback) => {
this.logService.warning("listenPasskeyRegistration", clientId, sequenceNumber, request);
this.logService.warning(
"listenPasskeyRegistration2",
this.convertRegistrationRequest(request),
);
const controller = new AbortController();
try {
const response = await this.fido2AuthenticatorService.makeCredential(
this.convertRegistrationRequest(request),
{ windowXy: request.windowXy },
controller,
);
callback(null, this.convertRegistrationResponse(request, response));
} catch (error) {
this.logService.error("listenPasskeyRegistration error", error);
callback(error, null);
}
});
ipc.autofill.listenPasskeyAssertionWithoutUserInterface(
async (clientId, sequenceNumber, request, callback) => {
this.logService.warning(
"listenPasskeyAssertion without user interface",
clientId,
sequenceNumber,
request,
);
const controller = new AbortController();
try {
// For some reason the credentialId is passed as an empty array in the request, so we need to
// get it from the cipher. For that we use the recordIdentifier, which is the cipherId.
if (request.recordIdentifier && request.credentialId.length === 0) {
const activeUserId = await firstValueFrom(
this.accountService.activeAccount$.pipe(getOptionalUserId),
);
if (!activeUserId) {
this.logService.error("listenPasskeyAssertion error", "Active user not found");
callback(new Error("Active user not found"), null);
return;
}
const cipher = await this.cipherService.get(request.recordIdentifier, activeUserId);
if (!cipher) {
this.logService.error("listenPasskeyAssertion error", "Cipher not found");
callback(new Error("Cipher not found"), null);
return;
}
const decrypted = await cipher.decrypt(
await this.cipherService.getKeyForCipherKeyDecryption(cipher, activeUserId),
);
const fido2Credential = decrypted.login.fido2Credentials?.[0];
if (!fido2Credential) {
this.logService.error("listenPasskeyAssertion error", "Fido2Credential not found");
callback(new Error("Fido2Credential not found"), null);
return;
}
request.credentialId = Array.from(
parseCredentialId(decrypted.login.fido2Credentials?.[0].credentialId),
);
}
const response = await this.fido2AuthenticatorService.getAssertion(
this.convertAssertionRequest(request),
{ windowXy: request.windowXy },
controller,
);
callback(null, this.convertAssertionResponse(request, response));
} catch (error) {
this.logService.error("listenPasskeyAssertion error", error);
callback(error, null);
return;
}
},
);
ipc.autofill.listenPasskeyAssertion(async (clientId, sequenceNumber, request, callback) => {
this.logService.warning("listenPasskeyAssertion", clientId, sequenceNumber, request);
const controller = new AbortController();
try {
const response = await this.fido2AuthenticatorService.getAssertion(
this.convertAssertionRequest(request),
{ windowXy: request.windowXy },
controller,
);
callback(null, this.convertAssertionResponse(request, response));
} catch (error) {
this.logService.error("listenPasskeyAssertion error", error);
callback(error, null);
}
});
}
private convertRegistrationRequest(
request: autofill.PasskeyRegistrationRequest,
): Fido2AuthenticatorMakeCredentialsParams {
return {
hash: new Uint8Array(request.clientDataHash),
rpEntity: {
name: request.rpId,
id: request.rpId,
},
userEntity: {
id: new Uint8Array(request.userHandle),
name: request.userName,
displayName: undefined,
icon: undefined,
},
credTypesAndPubKeyAlgs: request.supportedAlgorithms.map((alg) => ({
alg,
type: "public-key",
})),
excludeCredentialDescriptorList: [],
requireResidentKey: true,
requireUserVerification:
request.userVerification === "required" || request.userVerification === "preferred",
fallbackSupported: false,
};
}
private convertRegistrationResponse(
request: autofill.PasskeyRegistrationRequest,
response: Fido2AuthenticatorMakeCredentialResult,
): autofill.PasskeyRegistrationResponse {
return {
rpId: request.rpId,
clientDataHash: request.clientDataHash,
credentialId: Array.from(Fido2Utils.bufferSourceToUint8Array(response.credentialId)),
attestationObject: Array.from(
Fido2Utils.bufferSourceToUint8Array(response.attestationObject),
),
};
}
/**
*
* @param request
* @param assumeUserPresence For WithoutUserInterface requests, we assume the user is present
* @returns
*/
private convertAssertionRequest(
request:
| autofill.PasskeyAssertionRequest
| autofill.PasskeyAssertionWithoutUserInterfaceRequest,
): Fido2AuthenticatorGetAssertionParams {
let allowedCredentials;
if ("credentialId" in request) {
allowedCredentials = [
{
id: new Uint8Array(request.credentialId),
type: "public-key" as const,
},
];
} else {
allowedCredentials = request.allowedCredentials.map((credentialId) => ({
id: new Uint8Array(credentialId),
type: "public-key" as const,
}));
}
return {
rpId: request.rpId,
hash: new Uint8Array(request.clientDataHash),
allowCredentialDescriptorList: allowedCredentials,
extensions: {},
requireUserVerification:
request.userVerification === "required" || request.userVerification === "preferred",
fallbackSupported: false,
assumeUserPresence: true, // For desktop assertions, it's safe to assume UP has been checked by OS dialogues
};
}
private convertAssertionResponse(
request:
| autofill.PasskeyAssertionRequest
| autofill.PasskeyAssertionWithoutUserInterfaceRequest,
response: Fido2AuthenticatorGetAssertionResult,
): autofill.PasskeyAssertionResponse {
return {
userHandle: Array.from(response.selectedCredential.userHandle),
rpId: request.rpId,
signature: Array.from(response.signature),
clientDataHash: request.clientDataHash,
authenticatorData: Array.from(response.authenticatorData),
credentialId: Array.from(response.selectedCredential.id),
};
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
}