1import { getPublicSuffix } from "tldts";
2
3import type { PasskeyBackend, PasskeyCeremonyContext } from "./PasskeyBackend.ts";
4import { authenticatorDataFromAttestation } from "./PasskeyAttestation.ts";
5import type { PasskeyCeremonyResult } from "./PasskeyBridge.ts";
6
7const ES256 = -7;
8
9// WebAuthn rejects RP IDs that are public suffixes, private registries
10// included, so one github.io site cannot mint passkeys for every other one.
11const isPublicSuffix = (domain: string) =>
12 domain !== "localhost" && getPublicSuffix(domain, { allowPrivateDomains: true }) === domain;
13
14// Loaded only once a ceremony runs: the module and its native addon ship on macOS alone.
15const nativeOptions = async (context: PasskeyCeremonyContext) => ({
16 webauthn: await import("electron-webauthn"),
17 options: {
18 currentOrigin: context.origin,
19 topFrameOrigin: context.origin,
20 nativeWindowHandle: context.nativeWindowHandle,
21 isPublicSuffix,
22 },
23});
24
25const isBufferSource = (value: unknown): value is BufferSource =>
26 value instanceof ArrayBuffer || ArrayBuffer.isView(value);
27
28const toBase64Url = (value: BufferSource) =>
29 Buffer.from(
30 ArrayBuffer.isView(value)
31 ? new Uint8Array(value.buffer, value.byteOffset, value.byteLength)
32 : new Uint8Array(value),
33 ).toString("base64url");
34
35/**
36 * macOS passkeys through AuthenticationServices (`electron-webauthn`): the
37 * system sheet offers iCloud Keychain, password managers, phones, and security
38 * keys. Needs Apple's managed browser entitlement in the signed build.
39 */
40export const macPasskeyBackend: PasskeyBackend = {
41 create: async (publicKey, context): Promise<PasskeyCeremonyResult> => {
42 // The native layer only converts P-256 keys and never settles for any
43 // other algorithm, so ES256 is the only one it may negotiate.
44 const params: unknown = publicKey.pubKeyCredParams;
45 if (params !== undefined && !Array.isArray(params))
46 return { success: false, error: "TypeError" };
47 const allowsEs256 =
48 params === undefined ||
49 params.length === 0 ||
50 params.some(
51 (param: unknown) =>
52 typeof param === "object" && param !== null && "alg" in param && param.alg === ES256,
53 );
54 if (!allowsEs256) return { success: false, error: "NotSupportedError" };
55 const { webauthn, options } = await nativeOptions(context);
56 const result = await webauthn.createCredential(
57 { ...publicKey, pubKeyCredParams: [{ type: "public-key", alg: ES256 }] },
58 options,
59 );
60 if (!result.success) return { success: false, error: result.error };
61 // The native layer reports parsed authenticator data as JSON and claims
62 // every credential is a synced platform passkey; neither is reliable.
63 const authData = authenticatorDataFromAttestation(
64 Buffer.from(result.data.attestationObject, "base64url"),
65 );
66 // Sites verify the new credential from its authenticator data; without it
67 // the passkey is unusable, so say so now rather than let sign-up fail later.
68 if (!authData) return { success: false, error: "NotAllowedError" };
69 return {
70 success: true,
71 data: {
72 ...result.data,
73 authData: Buffer.from(authData).toString("base64url"),
74 transports: [],
75 },
76 };
77 },
78 get: async (publicKey, context): Promise<PasskeyCeremonyResult> => {
79 const { webauthn, options } = await nativeOptions(context);
80 // WebAuthn defaults the RP ID to the caller's host; the native layer requires it.
81 const result = await webauthn.getCredential(
82 { ...publicKey, rpId: publicKey.rpId ?? new URL(context.origin).hostname },
83 options,
84 );
85 if (!result.success) return { success: false, error: result.error };
86 // The native layer applies the site's allow list to platform passkeys only,
87 // so a security key can answer with a credential the site did not ask for.
88 const allowed = (Array.isArray(publicKey.allowCredentials) ? publicKey.allowCredentials : [])
89 .filter((credential) => credential?.type === "public-key" && isBufferSource(credential.id))
90 .map((credential) => toBase64Url(credential.id));
91 if (allowed.length > 0 && !allowed.includes(result.data.credentialId)) {
92 return { success: false, error: "NotAllowedError" };
93 }
94 return result;
95 },
96};