diff --git a/build/entitlements.mac.plist b/build/entitlements.mac.plist
index 0dad3780..6cf46058 100644
--- a/build/entitlements.mac.plist
+++ b/build/entitlements.mac.plist
@@ -30,5 +30,7 @@
com.apple.security.files.user-selected.read-write
+ com.apple.developer.web-browser.public-key-credential
+
\ No newline at end of file
diff --git a/bun.lock b/bun.lock
index 441c1ffe..67a49004 100644
--- a/bun.lock
+++ b/bun.lock
@@ -4,6 +4,7 @@
"": {
"name": "flow-browser",
"dependencies": {
+ "@electron-webauthn/native": "^0.0.5",
"@ghostery/adblocker-electron": "^2.5.2",
"@phosphor-icons/core": "^2.1.1",
"better-sqlite3": "^11.10.0",
@@ -149,6 +150,18 @@
"@electron-toolkit/tsconfig": ["@electron-toolkit/tsconfig@1.0.1", "", { "peerDependencies": { "@types/node": "*" } }, "sha512-M0Mol3odspvtCuheyujLNAW7bXq7KFNYVMRtpjFa4ZfES4MuklXBC7Nli/omvc+PRKlrklgAGx3l4VakjNo8jg=="],
+ "@electron-webauthn/native": ["@electron-webauthn/native@0.0.5", "", { "optionalDependencies": { "@electron-webauthn/native-darwin-arm64": "0.0.5", "@electron-webauthn/native-darwin-x64": "0.0.5", "@electron-webauthn/native-linux-x64-gnu": "0.0.5", "@electron-webauthn/native-win32-arm64-msvc": "0.0.5", "@electron-webauthn/native-win32-x64-msvc": "0.0.5" } }, "sha512-Cd5PuHGfY/o1Sf7RD6DuJ+276CjSyGs++u7wb4NwZihXioDs8Jo7ikFnTeVVMtfWP+ddrV7Wk4KUmKOnnubclw=="],
+
+ "@electron-webauthn/native-darwin-arm64": ["@electron-webauthn/native-darwin-arm64@0.0.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-B6PKEKUpU2YJHfr9lj0N01w69vmOwvlKDumAKnicuzcZEA1Hto6DXnDlehNNu/FGH1L8nihLCTalFSEaAM2yIg=="],
+
+ "@electron-webauthn/native-darwin-x64": ["@electron-webauthn/native-darwin-x64@0.0.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-Mfa5z7pSfad1JNegA9fpMEr5WucLpB3uyMsR4qY7F7+qVT5bRBWNZDnZ3vFzLDTGkt7m8M+LlbOD0yjAMGJrOw=="],
+
+ "@electron-webauthn/native-linux-x64-gnu": ["@electron-webauthn/native-linux-x64-gnu@0.0.5", "", { "os": "linux", "cpu": "x64" }, "sha512-+0FGl9E7Q2tC3lzNlgFj21sICw2mne99rHp+L224u/JVuUxenNjsFDOx/W/a6khJj7rhmH+0xgFILHL9H8zPXw=="],
+
+ "@electron-webauthn/native-win32-arm64-msvc": ["@electron-webauthn/native-win32-arm64-msvc@0.0.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WJmMtNroY6tgb7kM7CZPd8cROsAuGejsSFEgN5PsJTCXu6wmg9bmoT7FdWLQ8PrDrdqAZOMu2Y7V0UENM4f6nw=="],
+
+ "@electron-webauthn/native-win32-x64-msvc": ["@electron-webauthn/native-win32-x64-msvc@0.0.5", "", { "os": "win32", "cpu": "x64" }, "sha512-ma5bPByzIXPIPRtg/le447nSwEZoySnVpz8zo6H6IJIYh/pwLZgoQAFDn41gzU6BSs3mMnKuzbe6mW3JrusqsQ=="],
+
"@electron/asar": ["@electron/asar@3.2.18", "", { "dependencies": { "commander": "^5.0.0", "glob": "^7.1.6", "minimatch": "^3.0.4" }, "bin": { "asar": "bin/asar.js" } }, "sha512-2XyvMe3N3Nrs8cV39IKELRHTYUWFKrmqqSY1U+GMlc0jvqjIVnoxhNd2H4JolWQncbJi1DCvb5TNxZuI2fEjWg=="],
"@electron/fuses": ["@electron/fuses@1.8.0", "", { "dependencies": { "chalk": "^4.1.1", "fs-extra": "^9.0.1", "minimist": "^1.2.5" }, "bin": { "electron-fuses": "dist/bin.js" } }, "sha512-zx0EIq78WlY/lBb1uXlziZmDZI4ubcCXIMJ4uGjXzZW0nS19TjSPeXPAjzzTmKQlJUZm0SbmZhPKP7tuQ1SsEw=="],
diff --git a/package.json b/package.json
index 2124f134..26292714 100644
--- a/package.json
+++ b/package.json
@@ -30,6 +30,7 @@
"script:upgrade-electron-to-next": "bun run scripts/electron-upgrader/next.ts"
},
"dependencies": {
+ "@electron-webauthn/native": "^0.0.5",
"@ghostery/adblocker-electron": "^2.5.2",
"@phosphor-icons/core": "^2.1.1",
"better-sqlite3": "^11.10.0",
diff --git a/src/main/ipc/main.ts b/src/main/ipc/main.ts
index 6e715bb0..a42796d0 100644
--- a/src/main/ipc/main.ts
+++ b/src/main/ipc/main.ts
@@ -25,3 +25,4 @@ import "@/ipc/app/onboarding";
// Special
import "@/ipc/listeners-manager";
+import "@/ipc/webauthn";
diff --git a/src/main/ipc/webauthn.ts b/src/main/ipc/webauthn.ts
new file mode 100644
index 00000000..a52ac87e
--- /dev/null
+++ b/src/main/ipc/webauthn.ts
@@ -0,0 +1,93 @@
+import * as webauthn from "@electron-webauthn/native";
+import { ipcMain } from "electron";
+
+// Helper function to convert BufferSource to Buffer
+function toBuffer(data: BufferSource | undefined): Buffer {
+ if (!data) return Buffer.alloc(0);
+ if (Buffer.isBuffer(data)) return data;
+ if (data instanceof ArrayBuffer) return Buffer.from(data);
+ if (ArrayBuffer.isView(data)) return Buffer.from(data.buffer, data.byteOffset, data.byteLength);
+ return Buffer.alloc(0);
+}
+
+// Helper function to convert Buffer to ArrayBuffer
+function toArrayBuffer(buffer: Buffer): ArrayBuffer {
+ const arrayBuffer = new ArrayBuffer(buffer.length);
+ const view = new Uint8Array(arrayBuffer);
+ for (let i = 0; i < buffer.length; ++i) {
+ view[i] = buffer[i];
+ }
+ return arrayBuffer;
+}
+
+ipcMain.handle(
+ "webauthn:create",
+ async (_event, options: CredentialCreationOptions | undefined): Promise => {
+ // TODO: Implement create
+ console.log("create", options);
+
+ return null;
+ }
+);
+
+ipcMain.handle(
+ "webauthn:get",
+ async (_event, options: CredentialRequestOptions | undefined): Promise => {
+ if (!options) {
+ return null;
+ }
+
+ // Conditional mediation is not supported yet
+ if (options.mediation === "conditional") {
+ return null;
+ }
+
+ console.log("get", options);
+
+ const publicKeyOptions = options.publicKey;
+ if (!publicKeyOptions) {
+ return null;
+ }
+
+ const allowCredentials: webauthn.PublicKeyCredentialDescriptor[] =
+ publicKeyOptions.allowCredentials?.map((cred) => ({
+ type: cred.type,
+ id: toBuffer(cred.id),
+ transports: cred.transports
+ })) ?? [];
+
+ const credential = await webauthn.get({
+ challenge: toBuffer(publicKeyOptions.challenge),
+ rpId: publicKeyOptions.rpId,
+ timeout: publicKeyOptions.timeout,
+ userVerification: publicKeyOptions.userVerification,
+ allowCredentials: allowCredentials
+ });
+
+ const publicKeyCredential: PublicKeyCredential = {
+ authenticatorAttachment: credential.authenticatorAttachment ?? null,
+ getClientExtensionResults: () => ({}),
+ id: credential.id ?? null,
+ rawId: toArrayBuffer(credential.rawId),
+ type: credential.type ?? null,
+ response: {
+ clientDataJSON: toArrayBuffer(credential.response)
+ },
+ toJSON() {
+ const cloned: Partial = {
+ ...publicKeyCredential
+ };
+ delete cloned.toJSON;
+ delete cloned.getClientExtensionResults;
+ return JSON.stringify(cloned);
+ }
+ };
+ return publicKeyCredential;
+ }
+);
+
+ipcMain.handle("webauthn:is-available", async (): Promise => {
+ const isSupported = await webauthn.isSupported();
+ console.log("webauthn:is-available", isSupported);
+ return isSupported;
+});
diff --git a/src/preload/index.ts b/src/preload/index.ts
index fc1aa834..0a52071d 100644
--- a/src/preload/index.ts
+++ b/src/preload/index.ts
@@ -89,6 +89,87 @@ if (hasPermission("browser")) {
injectBrowserAction();
}
+// PASSKEYS PATCH //
+const SHOULD_PATCH_PASSKEYS = "navigator" in globalThis && "credentials" in globalThis.navigator;
+if (SHOULD_PATCH_PASSKEYS) {
+ type PatchedCredentialsContainer = Pick & {
+ isAvailable: () => Promise;
+ isConditionalMediationAvailable: () => Promise;
+ };
+
+ const patchedCredentialsContainer: PatchedCredentialsContainer = {
+ create: async (options) => {
+ return ipcRenderer.invoke("webauthn:create", options);
+ },
+ get: async (options) => {
+ return ipcRenderer.invoke("webauthn:get", options);
+ },
+ isAvailable: async () => {
+ return ipcRenderer.invoke("webauthn:is-available");
+ },
+ isConditionalMediationAvailable: async () => {
+ return false;
+ }
+ };
+ contextBridge.exposeInMainWorld("electronCredentials", patchedCredentialsContainer);
+
+ const tinyPasskeysScript = () => {
+ if ("electronCredentials" in globalThis) {
+ const patchedCredentials: typeof patchedCredentialsContainer = globalThis.electronCredentials;
+
+ if ("navigator" in globalThis && "credentials" in globalThis.navigator) {
+ const credentials = globalThis.navigator.credentials;
+ const oldCredentialsCreate = credentials.create.bind(credentials);
+ const oldCredentialsGet = credentials.get.bind(credentials);
+
+ // navigator.credentials.create()
+ credentials.create = async (options) => {
+ if (options) {
+ if (options.publicKey) {
+ return await patchedCredentials.create(options);
+ }
+ }
+
+ return await oldCredentialsCreate(options);
+ };
+
+ // navigator.credentials.get()
+ credentials.get = async (options) => {
+ if (options) {
+ // Conditional mediation is not supported yet
+ if (options.mediation === "conditional") {
+ return null;
+ }
+
+ if (options.publicKey) {
+ return await patchedCredentials.get(options);
+ }
+ }
+
+ return await oldCredentialsGet(options);
+ };
+ }
+
+ if (
+ "PublicKeyCredential" in globalThis &&
+ "isUserVerifyingPlatformAuthenticatorAvailable" in globalThis.PublicKeyCredential
+ ) {
+ // PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable()
+ globalThis.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable = patchedCredentials.isAvailable;
+
+ // PublicKeyCredential.isConditionalMediationAvailable()
+ globalThis.PublicKeyCredential.isConditionalMediationAvailable =
+ patchedCredentials.isConditionalMediationAvailable;
+ }
+
+ delete globalThis.electronCredentials;
+ }
+ };
+ contextBridge.executeInMainWorld({
+ func: tinyPasskeysScript
+ });
+}
+
// INTERNAL FUNCTIONS //
function getOSFromPlatform(platform: NodeJS.Platform) {
switch (platform) {