Skip to content
Source
Sign inSign up

[Bug]: macOS passkey algorithm negotiation rewrites unsupported credential types #35962

Send to my agent
Opencoygeek(GitHub profile, opens in a new tab) opened this issue 1 comment

Summary

The macOS browser passkey bridge accepts an ES256 algorithm entry even when its credential type is unsupported, then rewrites that entry as a public-key credential before calling the native backend. A request with no supported credential type should fail negotiation without starting a native operation. A fake-backend reproduction observed the rewritten request, while supported and unsupported algorithm controls passed.

Before submitting

  • Related issues: searched open and closed issues and PRs; distinct related coverage is listed under Additional context.
  • Removed credentials, tokens, private paths, and sensitive data.

Area

apps/desktop

Actual behavior

For pubKeyCredParams: [{ type: "future-credential-type", alg: -7 }], the actual macPasskeyBackend.create calls the fake native backend once with [{ type: "public-key", alg: -7 }]. The fake deliberately returns NotAllowedError so no successful native result is simulated. A supported public-key / ES256 control is forwarded, and public-key / EdDSA is rejected with NotSupportedError without calling native code.

Steps to reproduce

  1. Use a clean checkout at 59e64eef881632a23f89c4138bcf37f23d92bf93 with Node supporting node:module.stripTypeScriptTypes (the observed run used Node v26.12.1) and Git on PATH.
  2. Save the script below outside the checkout and run node repro-algorithms.mjs /path/to/t3code. No workspace dependency installation is needed. The native module is replaced with a recording fake.
  3. Inspect the controls and mismatch output below.

The recorded run reproduced the mismatch deterministically. The script creates disposable state directories with mkdtemp, redirects HOME, USERPROFILE, APPDATA, LOCALAPPDATA, XDG directories, TMPDIR, and T3CODE_HOME into them, and removes them on exit. It reads the checkout without changing it, opens no native passkey sheet, and uses no credentials. Exit 1 means defect reproduced, 0 means expected behavior, and 2 means setup or a control failed.

Reproduction script: repro-algorithms.mjs
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import vm from 'node:vm';
import { stripTypeScriptTypes, createRequire } from 'node:module';
import net from 'node:net';
import { domainToASCII } from 'node:url';
import { execFileSync } from 'node:child_process';
const checkout = path.resolve(process.argv[2] ?? '.');
const scratch = await fs.mkdtemp(path.join(os.tmpdir(), 'passkey-repro-'));
for (const key of ['HOME','USERPROFILE','APPDATA','LOCALAPPDATA','XDG_CONFIG_HOME','XDG_DATA_HOME','XDG_CACHE_HOME','XDG_STATE_HOME','T3CODE_HOME','TMPDIR']) {
  process.env[key] = path.join(scratch, key);
  await fs.mkdir(process.env[key]);
}
const read = (name) => fs.readFile(path.join(checkout, 'apps/desktop/src/preview', name), 'utf8');
const strip = (text) => stripTypeScriptTypes(text, {mode:'strip'});
const fn = (text, name) => {
  const start = text.indexOf(`function ${name}(`);
  const end = text.indexOf('\n}', start);
  if (start < 0 || end < 0) throw Error(`Missing function ${name}`);
  return text.slice(start, end + 2);
};
const download = async (name, version, file) => {
  const url = `https://unpkg.com/${name}@${version}/${file}`;
  const response = await fetch(url);
  if (!response.ok) throw Error(`Package download failed: ${response.status}`);
  return response.text();
};
let status = 2;
try {
  console.log(`Node ${process.version}; ${process.platform} ${process.arch}`);
  console.log(execFileSync('git', ['--version'], {encoding:'utf8'}).trim());
  console.log(`T3 commit ${execFileSync('git', ['-C', checkout, 'rev-parse', 'HEAD'], {encoding:'utf8'}).trim()}`);
  const nativeCalls = [];
  const webauthn = {createCredential: async (...args) => {nativeCalls.push(args); return {success:false,error:'NotAllowedError'};}};
  const adapter = strip(await read('PasskeyBackendMac.ts'))
    .replace(/^import .*;$/gm, '')
    .replace('export const macPasskeyBackend', 'const macPasskeyBackend')
    .replace('await import("electron-webauthn")', 'webauthn');
  const backend = new Function('getPublicSuffix','authenticatorDataFromAttestation','webauthn', adapter + '; return macPasskeyBackend;')(() => null, () => null, webauthn);
  const context = {origin:'https://example.com',nativeWindowHandle:Buffer.from([0])};
  const publicKey = {rp:{id:'example.com',name:'Example'}, user:{id:new Uint8Array([1]),name:'test-user',displayName:'Test User'},challenge:new Uint8Array([1]),pubKeyCredParams:[{type:'public-key',alg:-7}]};
  await backend.create(publicKey, context);
  if (nativeCalls.length !== 1 || nativeCalls[0][0].pubKeyCredParams[0].type !== 'public-key') throw Error('Supported algorithm control failed');
  console.log('Control: public-key / ES256 forwarded');
  nativeCalls.length = 0;
  const unsupported = await backend.create({...publicKey,pubKeyCredParams:[{type:'public-key',alg:-8}]}, context);
  if (nativeCalls.length || unsupported.error !== 'NotSupportedError') throw Error('Unsupported algorithm control failed');
  console.log('Control: public-key / EdDSA rejected with NotSupportedError');
  const result = await backend.create({...publicKey,pubKeyCredParams:[{type:'future-credential-type',alg:-7}]}, context);
  console.log(`Unknown-type ES256: native calls=${nativeCalls.length}; result=${JSON.stringify(result)}`);
  if (nativeCalls.length) console.log(`Forwarded params=${JSON.stringify(nativeCalls[0][0].pubKeyCredParams)}`);
  status = nativeCalls.length === 0 && result.error === 'NotSupportedError' ? 0 : 1;
} catch (error) {
  console.log(`Setup/control failure: ${error.message}`);
  status = 2;
} finally {
  await fs.rm(scratch, {recursive:true, force:true});
}
console.log(status === 0 ? 'RESULT: expected behavior' : status === 1 ? 'RESULT: defect reproduced' : 'RESULT: inconclusive');
process.exitCode = status;

Expected behavior

WebAuthn creation §5.1.3, algorithm negotiation filters unsupported credential types before selecting algorithms. If no supported type/algorithm pair remains, reject with NotSupportedError; an unsupported type must not become a public-key credential.

Impact

Minor bug or occasional failure

Impact: P2 - The bridge changes the requested credential type and starts an operation when negotiation should reject. This is limited to requests whose ES256 entries use unsupported types.

The mismatch is in T3 code, before the native package. No installed release or native UI was run. A site supplying a supported public-key / ES256 pair passes the control. NOT_ENOUGH_INFO: no affected real-world relying party was identified; the evidence is a synthetic request at the supported create API boundary.

Version

T3 Code source commit 59e64eef881632a23f89c4138bcf37f23d92bf93. electron-webauthn 1.3.1 and @electron-webauthn/macos 1.3.1 are repository pins; neither is loaded by this fake-backend reproduction.

Runtime and platform

Observed on macOS 27.2, arm64, Node v26.12.1, Git 2.56.0. Electron 44.10.1 is the repository pin; Electron was not launched.

Interface

Library: desktop preview WebAuthn creation request mapping, reached by navigator.credentials create through the browser bridge.

Earliest failure stage

Input parsing or validation

Evidence

apps/desktop/src/preview/PasskeyBackendMac.ts#L35-L53 tests only param.alg === -7 before replacing the entire list with a public-key / ES256 entry. The reproduction strips TypeScript annotations from the actual file, substitutes only module dependencies, and invokes its exported backend with a recording fake.

Node v26.12.1; darwin arm64
git version 2.56.0
T3 commit 59e64eef881632a23f89c4138bcf37f23d92bf93
Control: public-key / ES256 forwarded
Control: public-key / EdDSA rejected with NotSupportedError
Unknown-type ES256: native calls=1; result={"success":false,"error":"NotAllowedError"}
Forwarded params=[{"type":"public-key","alg":-7}]
RESULT: defect reproduced

The public output omits Node's process-specific experimental-warning lines; the functional output is unchanged.

Restoration check

The unknown-type request must return NotSupportedError with zero native calls; the supported ES256 and unsupported EdDSA controls must retain their outcomes.

Additional context

This report does not challenge the deliberate ES256-only limitation. It concerns accepting ES256 under a credential type the bridge does not support.

  • https://github.com/pingdotgg/t3code/pull/16952 — introduces the macOS bridge; this report concerns a specific request-semantics mismatch rather than adding the bridge.
  • https://github.com/pingdotgg/t3code/issues/34661 — tracks enabling the bridge in signed releases; the reproduction here exercises its source with synthetic inputs, independently of release capability approval.
  1. tanstack-source added labelsbug

  2. [Haiku 5.5][Claude Code] on behalf of shivam.

    Checked on main at 43ce39595bf. macPasskeyBackend.create in apps/desktop/src/preview/PasskeyBackendMac.ts only tests alg === -7, then sends public-key/ES256 to native, so an entry with an unsupported type still starts a native operation.

    #35969 adds the missing type === "public-key" check and an unknown-type case in Passkeys.test.ts that expects NotSupportedError with no native call. Its checks are green at 93a59233a904. I did not rerun its test here, and I left the fix to that PR.

Sign in to join this conversation.