| 1 | import { getPublicSuffix } from "tldts"; |
| 2 | |
| 3 | import type { PasskeyBackend, PasskeyCeremonyContext } from "./PasskeyBackend.ts"; |
| 4 | import { authenticatorDataFromAttestation } from "./PasskeyAttestation.ts"; |
| 5 | import type { PasskeyCeremonyResult } from "./PasskeyBridge.ts"; |
| 6 | |
| 7 | const 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. |
| 11 | const 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. |
| 15 | const 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 | |
| 25 | const isBufferSource = (value: unknown): value is BufferSource => |
| 26 | value instanceof ArrayBuffer || ArrayBuffer.isView(value); |
| 27 | |
| 28 | const 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 | */ |
| 40 | export 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 | }; |