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