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) {