Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,5 @@ node_modules/

# pnpm content-addressable store; never belongs in the repo.
.pnpm-store/

apps/desktop/resources/preview-passkeys.dylib
4 changes: 3 additions & 1 deletion apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,16 @@
"@t3tools/shared": "workspace:*",
"@t3tools/ssh": "workspace:*",
"@t3tools/tailscale": "workspace:*",
"cborg": "6.1.2",
"dbus-next": "0.10.2",
"effect": "catalog:",
"electron": "44.4.2",
"electron-store": "^8.2.0",
"electron-updater": "^6.8.9",
"ffi-rs": "1.3.2",
"playwright-core": "1.60.0",
"react-grab": "^0.1.32"
"react-grab": "^0.1.32",
"tldts": "7.4.2"
},
"devDependencies": {
"@effect/vitest": "catalog:",
Expand Down
54 changes: 54 additions & 0 deletions apps/desktop/scripts/build-preview-passkeys.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import * as NodeChildProcess from "node:child_process";
import * as NodeFS from "node:fs";
import * as NodePath from "node:path";
import * as NodeURL from "node:url";
import * as NodeUtil from "node:util";

// oxlint-disable-next-line t3code/no-global-process-runtime -- Standalone compiler script targets the host.
const hostArch = process.arch;
// oxlint-disable-next-line t3code/no-global-process-runtime -- Standalone compiler script has no Effect runtime.
const hostPlatform = process.platform;

const { values } = NodeUtil.parseArgs({
options: {
output: { type: "string" },
arch: { type: "string", default: hostArch },
},
});
if (hostPlatform === "darwin") {
const output =
values.output ??
NodeURL.fileURLToPath(new URL("../resources/preview-passkeys.dylib", import.meta.url));
const architectures = { arm64: ["arm64"], x64: ["x86_64"], universal: ["arm64", "x86_64"] }[
values.arch
];
if (!architectures) throw new Error(`Unsupported Mac architecture: ${values.arch}`);
NodeFS.mkdirSync(NodePath.dirname(output), { recursive: true });
NodeChildProcess.execFileSync(
"xcrun",
[
"clang",
"-dynamiclib",
"-fobjc-arc",
"-O2",
"-Wall",
"-Wextra",
"-Wno-unused-parameter",
"-Werror",
"-mmacosx-version-min=12.0",
...architectures.flatMap((arch) => ["-arch", arch]),
"-framework",
"AppKit",
"-framework",
"AuthenticationServices",
"-framework",
"Security",
NodeURL.fileURLToPath(new URL("../../../native/preview-passkeys/main.m", import.meta.url)),
"-o",
output,
],
{ stdio: "inherit" },
);
} else if (values.output) {
throw new Error("The preview passkey bridge must be built on macOS.");
}
19 changes: 19 additions & 0 deletions apps/desktop/src/preview-passkey-preload.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { contextBridge, ipcRenderer } from "electron";
import { installPasskeyShim, type PreviewPasskeyBridge } from "./preview/PasskeyShim.ts";

// oxlint-disable-next-line t3code/no-global-process-runtime -- Sandboxed preload cannot access the main-process Effect runtime.
if (process.platform === "darwin") {
const bridge: PreviewPasskeyBridge = {
available: () => ipcRenderer.invoke("preview:passkey-available"),
request: (id, operation, options) =>
ipcRenderer.invoke("preview:passkey-request", id, operation, options),
cancel: (id) => ipcRenderer.send("preview:passkey-cancel", id),
};
if (process.contextIsolated) {
contextBridge.exposeInMainWorld("__t3PreviewPasskeys", bridge);
contextBridge.executeInMainWorld({ func: installPasskeyShim });
} else {
window.__t3PreviewPasskeys = bridge;
installPasskeyShim();
}
}
1 change: 1 addition & 0 deletions apps/desktop/src/preview-pick-preload.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
import "./preview-passkey-preload.ts";
import "./preview/PickPreload.ts";
14 changes: 13 additions & 1 deletion apps/desktop/src/preview/Manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2049,7 +2049,19 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
wc.ipc.on(MOUSE_NAVIGATE_CHANNEL, mouseNavigate);
wc.setWindowOpenHandler((details) => {
if (previewWindowOpenAction(details) === "popup") {
return { action: "allow", overrideBrowserWindowOptions: POPUP_WINDOW_OPTIONS };
return {
action: "allow",
overrideBrowserWindowOptions: {
...POPUP_WINDOW_OPTIONS,
webPreferences: {
...POPUP_WINDOW_OPTIONS.webPreferences,
preload: path.join(
path.dirname(pictureInPicturePreloadPath),
"preview-passkey-preload.cjs",
),
},
},
};
}
runFork(
attemptPromise({ operation: "openPreviewWindow", tabId, webContentsId: wc.id }, () =>
Expand Down
57 changes: 57 additions & 0 deletions apps/desktop/src/preview/NativePasskeys.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import * as NodeCrypto from "node:crypto";
import { encode } from "cborg";
import { describe, expect, it } from "vite-plus/test";
import { decodePasskeyResult } from "./NativePasskeys.ts";

describe("native passkey response conversion", () => {
it("extracts a verifiable SPKI key even when authenticator extensions follow it", () => {
const { publicKey } = NodeCrypto.generateKeyPairSync("ec", { namedCurve: "P-256" });
const jwk = publicKey.export({ format: "jwk" });
const cose = new Map<number, number | Buffer>([
[1, 2],
[3, -7],
[-1, 1],
[-2, Buffer.from(jwk.x!, "base64url")],
[-3, Buffer.from(jwk.y!, "base64url")],
]);
const prefix = Buffer.alloc(55);
prefix[32] = 0xc5;
prefix.writeUInt16BE(3, 53);
const authData = Buffer.concat([
prefix,
Buffer.from([1, 2, 3]),
encode(cose),
encode({ credProtect: 2 }),
]);
const attestation = encode({ fmt: "none", authData, attStmt: {} });
const result = decodePasskeyResult(
JSON.stringify({
id: "AQID",
authenticatorAttachment: "platform",
response: {
clientDataJSON: "BAUG",
attestationObject: Buffer.from(attestation).toString("base64url"),
},
}),
{ operation: "create", credProps: true },
);
expect("error" in result).toBe(false);
if ("error" in result) throw new Error(result.error);
expect(result.response.authenticatorData).toBe(authData.toString("base64url"));
expect(result.response.publicKey).toBe(
publicKey.export({ format: "der", type: "spki" }).toString("base64url"),
);
expect(result.clientExtensionResults).toEqual({ credProps: { rk: true } });
});
it("preserves cancellation and rejects malformed attestations", () => {
expect(
decodePasskeyResult('{"error":"NotAllowedError"}', { operation: "get", credProps: false }),
).toEqual({ error: "NotAllowedError" });
expect(() =>
decodePasskeyResult('{"id":"AQID","response":{"attestationObject":""}}', {
operation: "create",
credProps: false,
}),
).toThrow();
});
});
146 changes: 146 additions & 0 deletions apps/desktop/src/preview/NativePasskeys.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
// @effect-diagnostics nodeBuiltinImport:off - native adapter loads a packaged dylib and translates its byte responses.
import * as NodeFS from "node:fs";
import * as NodeCrypto from "node:crypto";
import { decode, decodeFirst } from "cborg";
import type { normalizePasskeyRequest } from "./PasskeyRequest.ts";

export type NativePasskeyRequest = ReturnType<typeof normalizePasskeyRequest>;
export type PasskeyResult =
| { error: string }
| {
id: string;
authenticatorAttachment: string;
response: Record<string, string | number | null>;
clientExtensionResults?: { credProps: { rk: boolean } };
};
export type NativePasskeys = {
available: () => boolean;
start: (
options: NativePasskeyRequest,
window: Buffer,
signal: AbortSignal,
) => Promise<PasskeyResult>;
};

/** Convert Apple's attestation to the WebAuthn response accessors, including SPKI public-key bytes. */
export function decodePasskeyResult(
json: string,
options: Pick<NativePasskeyRequest, "operation" | "credProps">,
): PasskeyResult {
const result = JSON.parse(json) as PasskeyResult;
if (!("error" in result) && options.operation === "create") {
const attestation = decode(
Buffer.from(String(result.response.attestationObject), "base64url"),
{ useMaps: true },
) as Map<string, Uint8Array>;
const authData = Buffer.from(attestation.get("authData")!);
const credentialLength = authData.readUInt16BE(53);
// Authenticator extensions can follow the COSE key in authenticatorData.
const [cose] = decodeFirst(authData.subarray(55 + credentialLength), { useMaps: true }) as [
Map<number, Uint8Array | number>,
Uint8Array,
];
if (cose.get(3) !== -7) throw new Error("Unexpected platform credential algorithm.");
const publicKey = NodeCrypto.createPublicKey({
format: "jwk",
key: {
kty: "EC",
crv: "P-256",
x: Buffer.from(cose.get(-2) as Uint8Array).toString("base64url"),
y: Buffer.from(cose.get(-3) as Uint8Array).toString("base64url"),
},
}).export({ format: "der", type: "spki" });
result.response.authenticatorData = authData.toString("base64url");
result.response.publicKey = publicKey.toString("base64url");
result.response.publicKeyAlgorithm = -7;
if (options.credProps) result.clientExtensionResults = { credProps: { rk: true } };
}
return result;
}

let native: Promise<NativePasskeys | undefined> | undefined;
let nextId = 0;
/** Load once, only when a preview asks for passkeys. A stock unsigned Electron stays on Chromium's path. */
export function loadNativePasskeys(paths: readonly string[]) {
native ??= (async () => {
const path = paths.find(NodeFS.existsSync);
if (!path) return undefined;
const ffi = await import("ffi-rs");
const { DataType: T } = ffi;
const library = "t3-preview-passkeys";
ffi.open({ library, path });
const callbackType = ffi.funcConstructor({ paramsType: [T.String], retType: T.Void });
const command = (funcName: string, id: number) =>
ffi.load({
library,
funcName,
retType: T.Void,
paramsType: [T.I32],
paramsValue: [id],
});
return {
available: () =>
Boolean(
ffi.load({
library,
funcName: "t3_passkeys_available",
retType: T.Boolean,
paramsType: [],
paramsValue: [],
}),
),
start: (options, window, signal) =>
new Promise<PasskeyResult>((resolve, reject) => {
if (signal.aborted) {
resolve({ error: "AbortError" });
return;
}
const id = ++nextId;
const cancel = () => command("t3_passkeys_cancel", id);
const pointer = ffi.createPointer({
paramsType: [callbackType],
paramsValue: [
(json: string) => {
signal.removeEventListener("abort", cancel);
try {
resolve(decodePasskeyResult(json, options));
} catch (error) {
reject(error);
} finally {
command("t3_passkeys_release", id);
ffi.freePointer({
paramsType: [callbackType],
paramsValue: pointer,
pointerType: ffi.PointerType.RsPointer,
});
}
},
],
});
try {
ffi.load({
library,
funcName: "t3_passkeys_start",
retType: T.Void,
paramsType: [T.I32, T.BigInt, T.String, T.External],
paramsValue: [
id,
window.readBigUInt64LE(),
JSON.stringify(options),
...ffi.unwrapPointer(pointer),
],
});
signal.addEventListener("abort", cancel, { once: true });
} catch (error) {
ffi.freePointer({
paramsType: [callbackType],
paramsValue: pointer,
pointerType: ffi.PointerType.RsPointer,
});
reject(error);
}
}),
} satisfies NativePasskeys;
})();
return native;
}
Loading
Loading