| 1 | import type { CreateCredentialResult, GetCredentialResult } from "electron-webauthn"; |
| 2 | |
| 3 | import { PASSKEY_CREATE_CHANNEL, PASSKEY_GET_CHANNEL } from "./GuestProtocol.ts"; |
| 4 | |
| 5 | /** What the main process answers a guest passkey ceremony with. */ |
| 6 | export type PasskeyCeremonyResult = |
| 7 | | Extract<CreateCredentialResult, { success: true }> |
| 8 | | Extract<GetCredentialResult, { success: true }> |
| 9 | | { readonly success: false; readonly error: string }; |
| 10 | |
| 11 | type PasskeyChannel = typeof PASSKEY_CREATE_CHANNEL | typeof PASSKEY_GET_CHANNEL; |
| 12 | // Credential Management Level 1 added `mediation` to creation requests. |
| 13 | type CreationOptions = CredentialCreationOptions & { |
| 14 | readonly mediation?: CredentialMediationRequirement; |
| 15 | }; |
| 16 | type InvokePasskeyCeremony = (channel: PasskeyChannel, publicKey: unknown) => Promise<unknown>; |
| 17 | |
| 18 | const NOT_ALLOWED_MESSAGE = |
| 19 | "The operation either timed out or was not allowed. See: https://www.w3.org/TR/webauthn-2/#sctn-privacy-considerations-client."; |
| 20 | const DOM_EXCEPTION_NAMES = new Set([ |
| 21 | "AbortError", |
| 22 | "InvalidStateError", |
| 23 | "NotAllowedError", |
| 24 | "NotSupportedError", |
| 25 | "SecurityError", |
| 26 | ]); |
| 27 | |
| 28 | const fromBase64Url = (value: string) => { |
| 29 | const base64 = value.replaceAll("-", "+").replaceAll("_", "/"); |
| 30 | const binary = atob(base64.padEnd(base64.length + ((4 - (base64.length % 4)) % 4), "=")); |
| 31 | return Uint8Array.from(binary, (character) => character.charCodeAt(0)).buffer; |
| 32 | }; |
| 33 | |
| 34 | const toBase64Url = (buffer: ArrayBuffer) => |
| 35 | btoa(String.fromCharCode(...new Uint8Array(buffer))) |
| 36 | .replaceAll("+", "-") |
| 37 | .replaceAll("/", "_") |
| 38 | .replace(/=+$/u, ""); |
| 39 | |
| 40 | const ceremonyError = (name: string) => |
| 41 | name === "TypeError" |
| 42 | ? new TypeError("The passkey request options are invalid.") |
| 43 | : new DOMException( |
| 44 | NOT_ALLOWED_MESSAGE, |
| 45 | DOM_EXCEPTION_NAMES.has(name) ? name : "NotAllowedError", |
| 46 | ); |
| 47 | |
| 48 | /** |
| 49 | * Copies request options into plain IPC-cloneable data. Chromium ignores |
| 50 | * members it does not know, so functions and other uncloneable values drop out |
| 51 | * here instead of failing the whole ceremony. |
| 52 | */ |
| 53 | const toCloneable = (value: unknown): unknown => { |
| 54 | if (value instanceof ArrayBuffer) return value.slice(0); |
| 55 | if (ArrayBuffer.isView(value)) { |
| 56 | return new Uint8Array(value.buffer, value.byteOffset, value.byteLength).slice(); |
| 57 | } |
| 58 | if (Array.isArray(value)) return value.map(toCloneable); |
| 59 | if (typeof value === "object" && value !== null) { |
| 60 | return Object.fromEntries( |
| 61 | Object.entries(value).flatMap(([key, member]) => |
| 62 | typeof member === "function" || member === undefined ? [] : [[key, toCloneable(member)]], |
| 63 | ), |
| 64 | ); |
| 65 | } |
| 66 | return value; |
| 67 | }; |
| 68 | |
| 69 | // The objects below borrow the native prototypes so `instanceof` checks pass. |
| 70 | // Their own properties shadow the native getters, which only work on objects |
| 71 | // Chromium created itself. |
| 72 | const withValues = <T extends object>(target: T, values: Record<string, unknown>) => { |
| 73 | for (const [key, value] of Object.entries(values)) { |
| 74 | Object.defineProperty(target, key, { value, enumerable: true, configurable: true }); |
| 75 | } |
| 76 | return target; |
| 77 | }; |
| 78 | |
| 79 | const prfOutputs = (prf: { |
| 80 | readonly enabled?: boolean; |
| 81 | readonly results?: { readonly first?: string; readonly second?: string }; |
| 82 | }): AuthenticationExtensionsPRFOutputs => { |
| 83 | const first = prf.results?.first; |
| 84 | const second = prf.results?.second; |
| 85 | return { |
| 86 | ...(prf.enabled === undefined ? {} : { enabled: prf.enabled }), |
| 87 | ...(first === undefined |
| 88 | ? {} |
| 89 | : { |
| 90 | results: { |
| 91 | first: fromBase64Url(first), |
| 92 | ...(second === undefined ? {} : { second: fromBase64Url(second) }), |
| 93 | }, |
| 94 | }), |
| 95 | }; |
| 96 | }; |
| 97 | |
| 98 | const extensionResultsJson = (results: AuthenticationExtensionsClientOutputs) => |
| 99 | JSON.parse( |
| 100 | JSON.stringify(results, (_key, value: unknown) => |
| 101 | value instanceof ArrayBuffer ? toBase64Url(value) : value, |
| 102 | ), |
| 103 | ) as unknown; |
| 104 | |
| 105 | const makeCredential = (input: { |
| 106 | readonly id: string; |
| 107 | readonly authenticatorAttachment: AuthenticatorAttachment | null; |
| 108 | readonly response: AuthenticatorResponse; |
| 109 | readonly responseJson: Record<string, unknown>; |
| 110 | readonly extensionResults: AuthenticationExtensionsClientOutputs; |
| 111 | }) => |
| 112 | withValues(Object.create(PublicKeyCredential.prototype) as PublicKeyCredential, { |
| 113 | id: input.id, |
| 114 | rawId: fromBase64Url(input.id), |
| 115 | type: "public-key", |
| 116 | authenticatorAttachment: input.authenticatorAttachment, |
| 117 | response: input.response, |
| 118 | getClientExtensionResults: () => input.extensionResults, |
| 119 | toJSON: () => ({ |
| 120 | id: input.id, |
| 121 | rawId: input.id, |
| 122 | type: "public-key", |
| 123 | ...(input.authenticatorAttachment === null |
| 124 | ? {} |
| 125 | : { authenticatorAttachment: input.authenticatorAttachment }), |
| 126 | response: input.responseJson, |
| 127 | clientExtensionResults: extensionResultsJson(input.extensionResults), |
| 128 | }), |
| 129 | }); |
| 130 | |
| 131 | const credentialFromCreateResult = ( |
| 132 | data: Extract<CreateCredentialResult, { success: true }>["data"], |
| 133 | ) => { |
| 134 | const transports = [...data.transports]; |
| 135 | const publicKey = data.publicKey.length > 0 ? data.publicKey : null; |
| 136 | const extensionResults: AuthenticationExtensionsClientOutputs = { |
| 137 | ...(data.extensions.credProps ? { credProps: { ...data.extensions.credProps } } : {}), |
| 138 | ...(data.extensions.prf ? { prf: prfOutputs(data.extensions.prf) } : {}), |
| 139 | ...(data.extensions.largeBlob ? { largeBlob: { ...data.extensions.largeBlob } } : {}), |
| 140 | }; |
| 141 | return makeCredential({ |
| 142 | id: data.credentialId, |
| 143 | // The native layer cannot tell a synced passkey from a security key. |
| 144 | authenticatorAttachment: null, |
| 145 | response: withValues( |
| 146 | Object.create(AuthenticatorAttestationResponse.prototype) as AuthenticatorAttestationResponse, |
| 147 | { |
| 148 | clientDataJSON: fromBase64Url(data.clientDataJSON), |
| 149 | attestationObject: fromBase64Url(data.attestationObject), |
| 150 | getTransports: () => [...transports], |
| 151 | getAuthenticatorData: () => fromBase64Url(data.authData), |
| 152 | getPublicKey: () => (publicKey === null ? null : fromBase64Url(publicKey)), |
| 153 | getPublicKeyAlgorithm: () => data.publicKeyAlgorithm, |
| 154 | }, |
| 155 | ), |
| 156 | responseJson: { |
| 157 | clientDataJSON: data.clientDataJSON, |
| 158 | attestationObject: data.attestationObject, |
| 159 | authenticatorData: data.authData, |
| 160 | transports, |
| 161 | ...(publicKey === null ? {} : { publicKey }), |
| 162 | publicKeyAlgorithm: data.publicKeyAlgorithm, |
| 163 | }, |
| 164 | extensionResults, |
| 165 | }); |
| 166 | }; |
| 167 | |
| 168 | const credentialFromGetResult = (data: Extract<GetCredentialResult, { success: true }>["data"]) => { |
| 169 | const userHandle = data.userHandle.length > 0 ? data.userHandle : null; |
| 170 | const { prf, largeBlob } = data.extensions ?? {}; |
| 171 | const extensionResults: AuthenticationExtensionsClientOutputs = { |
| 172 | ...(prf ? { prf: prfOutputs(prf) } : {}), |
| 173 | ...(largeBlob |
| 174 | ? { |
| 175 | largeBlob: { |
| 176 | ...(largeBlob.blob === undefined ? {} : { blob: fromBase64Url(largeBlob.blob) }), |
| 177 | ...(largeBlob.written === undefined ? {} : { written: largeBlob.written }), |
| 178 | }, |
| 179 | } |
| 180 | : {}), |
| 181 | }; |
| 182 | return makeCredential({ |
| 183 | id: data.credentialId, |
| 184 | authenticatorAttachment: null, |
| 185 | response: withValues( |
| 186 | Object.create(AuthenticatorAssertionResponse.prototype) as AuthenticatorAssertionResponse, |
| 187 | { |
| 188 | clientDataJSON: fromBase64Url(data.clientDataJSON), |
| 189 | authenticatorData: fromBase64Url(data.authenticatorData), |
| 190 | signature: fromBase64Url(data.signature), |
| 191 | userHandle: userHandle === null ? null : fromBase64Url(userHandle), |
| 192 | }, |
| 193 | ), |
| 194 | responseJson: { |
| 195 | clientDataJSON: data.clientDataJSON, |
| 196 | authenticatorData: data.authenticatorData, |
| 197 | signature: data.signature, |
| 198 | ...(userHandle === null ? {} : { userHandle }), |
| 199 | }, |
| 200 | extensionResults, |
| 201 | }); |
| 202 | }; |
| 203 | |
| 204 | const abortReason = (signal: AbortSignal) => |
| 205 | signal.reason ?? new DOMException("The operation was aborted.", "AbortError"); |
| 206 | |
| 207 | /** |
| 208 | * Routes this page's WebAuthn ceremonies to the system passkey sheet through |
| 209 | * the main process, which supplies the frame's real origin. Runs in the page's |
| 210 | * own world (the preview preload has contextIsolation off), before any page |
| 211 | * script. Conditional (autofill and automatic upgrade) requests stay native: |
| 212 | * the system sheet is modal and must not open on its own. |
| 213 | */ |
| 214 | export function installPasskeyBridge(invoke: InvokePasskeyCeremony) { |
| 215 | // Insecure contexts have no WebAuthn to bridge. |
| 216 | if (typeof CredentialsContainer === "undefined" || typeof PublicKeyCredential === "undefined") { |
| 217 | return; |
| 218 | } |
| 219 | const run = async ( |
| 220 | channel: PasskeyChannel, |
| 221 | publicKey: unknown, |
| 222 | signal: AbortSignal | undefined, |
| 223 | ): Promise<PublicKeyCredential> => { |
| 224 | if (signal?.aborted) throw abortReason(signal); |
| 225 | const ceremony = invoke(channel, toCloneable(publicKey)).then( |
| 226 | (value) => { |
| 227 | const result = value as PasskeyCeremonyResult; |
| 228 | if (!result.success) throw ceremonyError(result.error); |
| 229 | return "attestationObject" in result.data |
| 230 | ? credentialFromCreateResult(result.data) |
| 231 | : credentialFromGetResult(result.data); |
| 232 | }, |
| 233 | // No handler yet (or any more) for this guest: answer like a refusal. |
| 234 | (error: unknown) => { |
| 235 | throw error instanceof DOMException || error instanceof TypeError |
| 236 | ? error |
| 237 | : ceremonyError("NotAllowedError"); |
| 238 | }, |
| 239 | ); |
| 240 | if (!signal) return ceremony; |
| 241 | return new Promise((resolve, reject) => { |
| 242 | const onAbort = () => reject(abortReason(signal)); |
| 243 | signal.addEventListener("abort", onAbort, { once: true }); |
| 244 | ceremony.then(resolve, reject).finally(() => signal.removeEventListener("abort", onAbort)); |
| 245 | }); |
| 246 | }; |
| 247 | |
| 248 | const container = CredentialsContainer.prototype; |
| 249 | const nativeCreate = container.create; |
| 250 | const nativeGet = container.get; |
| 251 | const define = (target: object, key: string, value: unknown) => |
| 252 | Object.defineProperty(target, key, { value, configurable: true, writable: true }); |
| 253 | |
| 254 | define( |
| 255 | container, |
| 256 | "create", |
| 257 | function create(this: CredentialsContainer, options?: CreationOptions) { |
| 258 | return options?.publicKey && options.mediation !== "conditional" |
| 259 | ? run(PASSKEY_CREATE_CHANNEL, options.publicKey, options.signal) |
| 260 | : Reflect.apply(nativeCreate, this, [options]); |
| 261 | }, |
| 262 | ); |
| 263 | define( |
| 264 | container, |
| 265 | "get", |
| 266 | function get(this: CredentialsContainer, options?: CredentialRequestOptions) { |
| 267 | return options?.publicKey && options.mediation !== "conditional" |
| 268 | ? run(PASSKEY_GET_CHANNEL, options.publicKey, options.signal) |
| 269 | : Reflect.apply(nativeGet, this, [options]); |
| 270 | }, |
| 271 | ); |
| 272 | |
| 273 | define(PublicKeyCredential, "isUserVerifyingPlatformAuthenticatorAvailable", async () => true); |
| 274 | define(PublicKeyCredential, "isConditionalMediationAvailable", async () => false); |
| 275 | const nativeCapabilities = PublicKeyCredential.getClientCapabilities; |
| 276 | if (typeof nativeCapabilities === "function") { |
| 277 | define(PublicKeyCredential, "getClientCapabilities", async () => ({ |
| 278 | ...(await Reflect.apply(nativeCapabilities, PublicKeyCredential, [])), |
| 279 | conditionalCreate: false, |
| 280 | conditionalGet: false, |
| 281 | hybridTransport: true, |
| 282 | passkeyPlatformAuthenticator: true, |
| 283 | userVerifyingPlatformAuthenticator: true, |
| 284 | })); |
| 285 | } |
| 286 | } |