Skip to content
Source
Sign inSign up

PasskeyBackendMac.ts

86 lines3.6 KB

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 public suffix RP IDs, including private registries such as github.io.
10const isPublicSuffix = (domain: string) =>
11 domain !== "localhost" && getPublicSuffix(domain, { allowPrivateDomains: true }) === domain;
12​
13// Loaded only once a ceremony runs: the module and its native addon ship on macOS alone.
14const nativeOptions = async (context: PasskeyCeremonyContext) => ({
15 webauthn: await import("electron-webauthn"),
16 options: {
17 currentOrigin: context.origin,
18 topFrameOrigin: context.origin,
19 nativeWindowHandle: context.nativeWindowHandle,
20 isPublicSuffix,
21 },
22});
23​
24const isBufferSource = (value: unknown): value is BufferSource =>
25 value instanceof ArrayBuffer || ArrayBuffer.isView(value);
26​
27const toBase64Url = (value: BufferSource) =>
28 Buffer.from(
29 ArrayBuffer.isView(value)
30 ? new Uint8Array(value.buffer, value.byteOffset, value.byteLength)
31 : new Uint8Array(value),
32 ).toString("base64url");
33​
34/** Requires Apple's managed browser entitlement in the signed build. */
35export const macPasskeyBackend: PasskeyBackend = {
36 create: async (publicKey, context): Promise<PasskeyCeremonyResult> => {
37 // The native layer only converts P-256 keys, so negotiate only ES256.
38 const params: unknown = publicKey.pubKeyCredParams;
39 if (params !== undefined && !Array.isArray(params))
40 return { success: false, error: "TypeError" };
41 const allowsEs256 =
42 params === undefined ||
43 params.length === 0 ||
44 params.some(
45 (param: unknown) =>
46 typeof param === "object" && param !== null && "alg" in param && param.alg === ES256,
47 );
48 if (!allowsEs256) return { success: false, error: "NotSupportedError" };
49 const { webauthn, options } = await nativeOptions(context);
50 const result = await webauthn.createCredential(
51 { ...publicKey, pubKeyCredParams: [{ type: "public-key", alg: ES256 }] },
52 options,
53 );
54 if (!result.success) return { success: false, error: result.error };
55 // Native parsed authenticator JSON and synced-platform-passkey claims are unreliable.
56 const authData = authenticatorDataFromAttestation(
57 Buffer.from(result.data.attestationObject, "base64url"),
58 );
59 if (!authData) return { success: false, error: "NotAllowedError" };
60 return {
61 success: true,
62 data: {
63 ...result.data,
64 authData: Buffer.from(authData).toString("base64url"),
65 transports: [],
66 },
67 };
68 },
69 get: async (publicKey, context): Promise<PasskeyCeremonyResult> => {
70 const { webauthn, options } = await nativeOptions(context);
71 // WebAuthn defaults the RP ID to the caller's host; the native layer requires it.
72 const result = await webauthn.getCredential(
73 { ...publicKey, rpId: publicKey.rpId ?? new URL(context.origin).hostname },
74 options,
75 );
76 if (!result.success) return { success: false, error: result.error };
77 // Native allow lists cover only platform passkeys; security-key responses need checking here.
78 const allowed = (Array.isArray(publicKey.allowCredentials) ? publicKey.allowCredentials : [])
79 .filter((credential) => credential?.type === "public-key" && isBufferSource(credential.id))
80 .map((credential) => toBase64Url(credential.id));
81 if (allowed.length > 0 && !allowed.includes(result.data.credentialId)) {
82 return { success: false, error: "NotAllowedError" };
83 }
84 return result;
85 },
86};