1import type { CreateCredentialResult, GetCredentialResult } from "electron-webauthn";
2
3import { PASSKEY_CREATE_CHANNEL, PASSKEY_GET_CHANNEL } from "./GuestProtocol.ts";
4
5/** What the main process answers a guest passkey ceremony with. */
6export type PasskeyCeremonyResult =
7 | Extract<CreateCredentialResult, { success: true }>
8 | Extract<GetCredentialResult, { success: true }>
9 | { readonly success: false; readonly error: string };
10
11type PasskeyChannel = typeof PASSKEY_CREATE_CHANNEL | typeof PASSKEY_GET_CHANNEL;
12// Credential Management Level 1 added `mediation` to creation requests.
13type CreationOptions = CredentialCreationOptions & {
14 readonly mediation?: CredentialMediationRequirement;
15};
16type InvokePasskeyCeremony = (channel: PasskeyChannel, publicKey: unknown) => Promise<unknown>;
17
18const 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.";
20const DOM_EXCEPTION_NAMES = new Set([
21 "AbortError",
22 "InvalidStateError",
23 "NotAllowedError",
24 "NotSupportedError",
25 "SecurityError",
26]);
27
28const 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
34const toBase64Url = (buffer: ArrayBuffer) =>
35 btoa(String.fromCharCode(...new Uint8Array(buffer)))
36 .replaceAll("+", "-")
37 .replaceAll("/", "_")
38 .replace(/=+$/u, "");
39
40const 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 */
53const 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.
72const 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
79const 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
98const 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
105const 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
131const 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
168const 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
204const 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 */
214export 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}