1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-18 09:13:33 +00:00

[PM-9473] Add messaging for macOS passkey extension and desktop (#10768)

* Add messaging for macos passkey provider

* fix: credential id conversion

* Make build.sh executable

Co-authored-by: Colton Hurst <colton@coltonhurst.com>

* chore: add TODO

---------

Co-authored-by: Andreas Coroiu <andreas.coroiu@gmail.com>
Co-authored-by: Andreas Coroiu <acoroiu@bitwarden.com>
Co-authored-by: Colton Hurst <colton@coltonhurst.com>
This commit is contained in:
Daniel García
2024-12-19 09:00:21 +01:00
committed by GitHub
parent 456046e095
commit 51f6594d4b
37 changed files with 1935 additions and 149 deletions

View File

@@ -56,6 +56,8 @@ import { ConfigService } from "@bitwarden/common/platform/abstractions/config/co
import { CryptoFunctionService as CryptoFunctionServiceAbstraction } from "@bitwarden/common/platform/abstractions/crypto-function.service";
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { Fido2AuthenticatorService as Fido2AuthenticatorServiceAbstraction } from "@bitwarden/common/platform/abstractions/fido2/fido2-authenticator.service.abstraction";
import { Fido2UserInterfaceService as Fido2UserInterfaceServiceAbstraction } from "@bitwarden/common/platform/abstractions/fido2/fido2-user-interface.service.abstraction";
import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service";
import { I18nService as I18nServiceAbstraction } from "@bitwarden/common/platform/abstractions/i18n.service";
import { KeyGenerationService as KeyGenerationServiceAbstraction } from "@bitwarden/common/platform/abstractions/key-generation.service";
@@ -73,6 +75,7 @@ import { Message, MessageListener, MessageSender } from "@bitwarden/common/platf
// eslint-disable-next-line no-restricted-imports -- Used for dependency injection
import { SubjectMessageSender } from "@bitwarden/common/platform/messaging/internal";
import { TaskSchedulerService } from "@bitwarden/common/platform/scheduling";
import { Fido2AuthenticatorService } from "@bitwarden/common/platform/services/fido2/fido2-authenticator.service";
import { MemoryStorageService } from "@bitwarden/common/platform/services/memory-storage.service";
import { DefaultSdkClientFactory } from "@bitwarden/common/platform/services/sdk/default-sdk-client-factory";
import { NoopSdkClientFactory } from "@bitwarden/common/platform/services/sdk/noop-sdk-client-factory";
@@ -80,6 +83,7 @@ import { SystemService } from "@bitwarden/common/platform/services/system.servic
import { GlobalStateProvider, StateProvider } from "@bitwarden/common/platform/state";
// eslint-disable-next-line import/no-restricted-paths -- Implementation for memory storage
import { MemoryStorageService as MemoryStorageServiceForStateProviders } from "@bitwarden/common/platform/state/storage/memory-storage.service";
import { SyncService } from "@bitwarden/common/platform/sync";
import { ThemeStateService } from "@bitwarden/common/platform/theming/theme-state.service";
import { VaultTimeoutStringType } from "@bitwarden/common/types/vault-timeout.type";
import { CipherService as CipherServiceAbstraction } from "@bitwarden/common/vault/abstractions/cipher.service";
@@ -99,6 +103,7 @@ import { DesktopAutofillSettingsService } from "../../autofill/services/desktop-
import { DesktopAutofillService } from "../../autofill/services/desktop-autofill.service";
import { ElectronBiometricsService } from "../../key-management/biometrics/electron-biometrics.service";
import { flagEnabled } from "../../platform/flags";
import { DesktopFido2UserInterfaceService } from "../../platform/services/desktop-fido2-user-interface.service";
import { DesktopSettingsService } from "../../platform/services/desktop-settings.service";
import { ElectronKeyService } from "../../platform/services/electron-key.service";
import { ElectronLogRendererService } from "../../platform/services/electron-log.renderer.service";
@@ -309,7 +314,29 @@ const safeProviders: SafeProvider[] = [
}),
safeProvider({
provide: DesktopAutofillService,
deps: [LogService, CipherServiceAbstraction, ConfigService],
deps: [
LogService,
CipherServiceAbstraction,
ConfigService,
Fido2AuthenticatorServiceAbstraction,
AccountService,
],
}),
safeProvider({
provide: Fido2UserInterfaceServiceAbstraction,
useClass: DesktopFido2UserInterfaceService,
deps: [AuthServiceAbstraction, CipherServiceAbstraction, AccountService, LogService],
}),
safeProvider({
provide: Fido2AuthenticatorServiceAbstraction,
useClass: Fido2AuthenticatorService,
deps: [
CipherServiceAbstraction,
Fido2UserInterfaceServiceAbstraction,
SyncService,
AccountService,
LogService,
],
}),
safeProvider({
provide: NativeMessagingManifestService,

View File

@@ -1,9 +1,92 @@
import { ipcRenderer } from "electron";
import type { autofill } from "@bitwarden/desktop-napi";
import { Command } from "../platform/main/autofill/command";
import { RunCommandParams, RunCommandResult } from "../platform/main/autofill/native-autofill.main";
export default {
runCommand: <C extends Command>(params: RunCommandParams<C>): Promise<RunCommandResult<C>> =>
ipcRenderer.invoke("autofill.runCommand", params),
listenPasskeyRegistration: (
fn: (
clientId: number,
sequenceNumber: number,
request: autofill.PasskeyRegistrationRequest,
completeCallback: (
error: Error | null,
response: autofill.PasskeyRegistrationResponse,
) => void,
) => void,
) => {
ipcRenderer.on(
"autofill.passkeyRegistration",
(
event,
data: {
clientId: number;
sequenceNumber: number;
request: autofill.PasskeyRegistrationRequest;
},
) => {
const { clientId, sequenceNumber, request } = data;
fn(clientId, sequenceNumber, request, (error, response) => {
if (error) {
ipcRenderer.send("autofill.completeError", {
clientId,
sequenceNumber,
error: error.message,
});
return;
}
ipcRenderer.send("autofill.completePasskeyRegistration", {
clientId,
sequenceNumber,
response,
});
});
},
);
},
listenPasskeyAssertion: (
fn: (
clientId: number,
sequenceNumber: number,
request: autofill.PasskeyAssertionRequest,
completeCallback: (error: Error | null, response: autofill.PasskeyAssertionResponse) => void,
) => void,
) => {
ipcRenderer.on(
"autofill.passkeyAssertion",
(
event,
data: {
clientId: number;
sequenceNumber: number;
request: autofill.PasskeyAssertionRequest;
},
) => {
const { clientId, sequenceNumber, request } = data;
fn(clientId, sequenceNumber, request, (error, response) => {
if (error) {
ipcRenderer.send("autofill.completeError", {
clientId,
sequenceNumber,
error: error.message,
});
return;
}
ipcRenderer.send("autofill.completePasskeyAssertion", {
clientId,
sequenceNumber,
response,
});
});
},
);
},
};

View File

@@ -1,12 +1,32 @@
import { Injectable, OnDestroy } from "@angular/core";
import { EMPTY, Subject, distinctUntilChanged, mergeMap, switchMap, takeUntil } from "rxjs";
import { autofill } from "desktop_native/napi";
import {
EMPTY,
Subject,
distinctUntilChanged,
firstValueFrom,
map,
mergeMap,
switchMap,
takeUntil,
} from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/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 { getCredentialsForAutofill } from "@bitwarden/common/platform/services/fido2/fido2-autofill-utils";
import { Fido2Utils } from "@bitwarden/common/platform/services/fido2/fido2-utils";
import { guidToRawFormat } from "@bitwarden/common/platform/services/fido2/guid-utils";
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";
@@ -26,6 +46,8 @@ export class DesktopAutofillService implements OnDestroy {
private logService: LogService,
private cipherService: CipherService,
private configService: ConfigService,
private fido2AuthenticatorService: Fido2AuthenticatorServiceAbstraction<void>,
private accountService: AccountService,
) {}
async init() {
@@ -47,6 +69,8 @@ export class DesktopAutofillService implements OnDestroy {
takeUntil(this.destroy$),
)
.subscribe();
this.listenIpc();
}
/** Give metadata about all available credentials in the users vault */
@@ -114,6 +138,146 @@ export class DesktopAutofillService implements OnDestroy {
});
}
listenIpc() {
ipc.autofill.listenPasskeyRegistration((clientId, sequenceNumber, request, callback) => {
this.logService.warning("listenPasskeyRegistration", clientId, sequenceNumber, request);
this.logService.warning(
"listenPasskeyRegistration2",
this.convertRegistrationRequest(request),
);
const controller = new AbortController();
void this.fido2AuthenticatorService
.makeCredential(this.convertRegistrationRequest(request), null, controller)
.then((response) => {
callback(null, this.convertRegistrationResponse(request, response));
})
.catch((error) => {
this.logService.error("listenPasskeyRegistration error", error);
callback(error, null);
});
});
ipc.autofill.listenPasskeyAssertion(async (clientId, sequenceNumber, request, callback) => {
this.logService.warning("listenPasskeyAssertion", clientId, sequenceNumber, request);
// TODO: 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 cipher = await this.cipherService.get(request.recordIdentifier);
if (!cipher) {
this.logService.error("listenPasskeyAssertion error", "Cipher not found");
callback(new Error("Cipher not found"), null);
return;
}
const activeUserId = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((a) => a?.id)),
);
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(
guidToRawFormat(decrypted.login.fido2Credentials?.[0].credentialId),
);
}
const controller = new AbortController();
void this.fido2AuthenticatorService
.getAssertion(this.convertAssertionRequest(request), null, controller)
.then((response) => {
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),
),
};
}
private convertAssertionRequest(
request: autofill.PasskeyAssertionRequest,
): Fido2AuthenticatorGetAssertionParams {
return {
rpId: request.rpId,
hash: new Uint8Array(request.clientDataHash),
allowCredentialDescriptorList: [
{
id: new Uint8Array(request.credentialId),
type: "public-key",
},
],
extensions: {},
requireUserVerification:
request.userVerification === "required" || request.userVerification === "preferred",
fallbackSupported: false,
};
}
private convertAssertionResponse(
request: autofill.PasskeyAssertionRequest,
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();

View File

@@ -261,7 +261,7 @@ export class Main {
new EphemeralValueStorageService();
new SSOLocalhostCallbackService(this.environmentService, this.messagingService);
this.nativeAutofillMain = new NativeAutofillMain(this.logService);
this.nativeAutofillMain = new NativeAutofillMain(this.logService, this.windowMain);
void this.nativeAutofillMain.init();
}

View File

@@ -3,6 +3,8 @@ import { ipcMain } from "electron";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { autofill } from "@bitwarden/desktop-napi";
import { WindowMain } from "../../../main/window.main";
import { CommandDefinition } from "./command";
export type RunCommandParams<C extends CommandDefinition> = {
@@ -14,7 +16,12 @@ export type RunCommandParams<C extends CommandDefinition> = {
export type RunCommandResult<C extends CommandDefinition> = C["output"];
export class NativeAutofillMain {
constructor(private logService: LogService) {}
private ipcServer: autofill.IpcServer | null;
constructor(
private logService: LogService,
private windowMain: WindowMain,
) {}
async init() {
ipcMain.handle(
@@ -26,6 +33,52 @@ export class NativeAutofillMain {
return this.runCommand(params);
},
);
this.ipcServer = await autofill.IpcServer.listen(
"autofill",
// RegistrationCallback
(error, clientId, sequenceNumber, request) => {
if (error) {
this.logService.error("autofill.IpcServer.registration", error);
return;
}
this.windowMain.win.webContents.send("autofill.passkeyRegistration", {
clientId,
sequenceNumber,
request,
});
},
// AssertionCallback
(error, clientId, sequenceNumber, request) => {
if (error) {
this.logService.error("autofill.IpcServer.assertion", error);
return;
}
this.windowMain.win.webContents.send("autofill.passkeyAssertion", {
clientId,
sequenceNumber,
request,
});
},
);
ipcMain.on("autofill.completePasskeyRegistration", (event, data) => {
this.logService.warning("autofill.completePasskeyRegistration", data);
const { clientId, sequenceNumber, response } = data;
this.ipcServer.completeRegistration(clientId, sequenceNumber, response);
});
ipcMain.on("autofill.completePasskeyAssertion", (event, data) => {
this.logService.warning("autofill.completePasskeyAssertion", data);
const { clientId, sequenceNumber, response } = data;
this.ipcServer.completeAssertion(clientId, sequenceNumber, response);
});
ipcMain.on("autofill.completeError", (event, data) => {
this.logService.warning("autofill.completeError", data);
const { clientId, sequenceNumber, error } = data;
this.ipcServer.completeAssertion(clientId, sequenceNumber, error);
});
}
private async runCommand<C extends CommandDefinition>(

View File

@@ -0,0 +1,125 @@
import { firstValueFrom, map } from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import {
Fido2UserInterfaceService as Fido2UserInterfaceServiceAbstraction,
Fido2UserInterfaceSession,
NewCredentialParams,
PickCredentialParams,
} from "@bitwarden/common/platform/abstractions/fido2/fido2-user-interface.service.abstraction";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherRepromptType, CipherType, SecureNoteType } from "@bitwarden/common/vault/enums";
import { CardView } from "@bitwarden/common/vault/models/view/card.view";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { IdentityView } from "@bitwarden/common/vault/models/view/identity.view";
import { LoginUriView } from "@bitwarden/common/vault/models/view/login-uri.view";
import { LoginView } from "@bitwarden/common/vault/models/view/login.view";
import { SecureNoteView } from "@bitwarden/common/vault/models/view/secure-note.view";
// TODO: This should be moved to the directory of whatever team takes this on
export class DesktopFido2UserInterfaceService
implements Fido2UserInterfaceServiceAbstraction<void>
{
constructor(
private authService: AuthService,
private cipherService: CipherService,
private accountService: AccountService,
private logService: LogService,
) {}
async newSession(
fallbackSupported: boolean,
_tab: void,
abortController?: AbortController,
): Promise<DesktopFido2UserInterfaceSession> {
this.logService.warning("newSession", fallbackSupported, abortController);
return new DesktopFido2UserInterfaceSession(
this.authService,
this.cipherService,
this.accountService,
this.logService,
);
}
}
export class DesktopFido2UserInterfaceSession implements Fido2UserInterfaceSession {
constructor(
private authService: AuthService,
private cipherService: CipherService,
private accountService: AccountService,
private logService: LogService,
) {}
async pickCredential({
cipherIds,
userVerification,
}: PickCredentialParams): Promise<{ cipherId: string; userVerified: boolean }> {
this.logService.warning("pickCredential", cipherIds, userVerification);
return { cipherId: cipherIds[0], userVerified: userVerification };
}
async confirmNewCredential({
credentialName,
userName,
userVerification,
rpId,
}: NewCredentialParams): Promise<{ cipherId: string; userVerified: boolean }> {
this.logService.warning(
"confirmNewCredential",
credentialName,
userName,
userVerification,
rpId,
);
// Store the passkey on a new cipher to avoid replacing something important
const cipher = new CipherView();
cipher.name = credentialName;
cipher.type = CipherType.Login;
cipher.login = new LoginView();
cipher.login.username = userName;
cipher.login.uris = [new LoginUriView()];
cipher.login.uris[0].uri = "https://" + rpId;
cipher.card = new CardView();
cipher.identity = new IdentityView();
cipher.secureNote = new SecureNoteView();
cipher.secureNote.type = SecureNoteType.Generic;
cipher.reprompt = CipherRepromptType.None;
const activeUserId = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((a) => a?.id)),
);
const encCipher = await this.cipherService.encrypt(cipher, activeUserId);
const createdCipher = await this.cipherService.createWithServer(encCipher);
return { cipherId: createdCipher.id, userVerified: userVerification };
}
async informExcludedCredential(existingCipherIds: string[]): Promise<void> {
this.logService.warning("informExcludedCredential", existingCipherIds);
}
async ensureUnlockedVault(): Promise<void> {
this.logService.warning("ensureUnlockedVault");
const status = await firstValueFrom(this.authService.activeAccountStatus$);
if (status !== AuthenticationStatus.Unlocked) {
throw new Error("Vault is not unlocked");
}
}
async informCredentialNotFound(): Promise<void> {
this.logService.warning("informCredentialNotFound");
}
async close() {
this.logService.warning("close");
}
}