[Bug]: macOS passkey algorithm negotiation rewrites unsupported credential types #35962
Send to my agentSummary#
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#
- Use a clean checkout at
59e64eef881632a23f89c4138bcf37f23d92bf93with Node supportingnode:module.stripTypeScriptTypes(the observed run used Node v26.12.1) and Git on PATH. - 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. - 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.
tanstack-source added labelsbug
Sign in to join this conversation.
Checked on
mainat 43ce39595bf.macPasskeyBackend.createinapps/desktop/src/preview/PasskeyBackendMac.tsonly testsalg === -7, then sendspublic-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 inPasskeys.test.tsthat expectsNotSupportedErrorwith no native call. Its checks are green at93a59233a904. I did not rerun its test here, and I left the fix to that PR.