mirror of
https://github.com/bitwarden/browser
synced 2026-01-02 16:43:19 +00:00
* 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>
350 lines
12 KiB
TypeScript
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();
|
|
}
|
|
}
|