FiloSottile / FiloSottile/passkey

example: `JSON.stringify(credential)` fails behind password manager extensions

Open
#2 0 comments 0 reactions 0 assignees View on GitHub
Dominant language
Go
Stars
12
Forks
0
PR merge metrics
No merged PRs in 30d

Description

I decided to live dangerously and deployed `filippo.io/passkey@v0.0.0-20260823133140-f9183523f3d8` on oeis.org this week. Mostly great. However, almost immediately I got a report from a Dashlane user that they couldn't add a passkey. Claude diagnosed this to JSON.stringify(credential) failing with whatever Dashlane inserts as credential. Working JS below although it's big and hairy. Not sure what the best thing to do is here. If all users of crypto/passkey are going to need the JS then maybe it is worth exporting as a constant or something, but on the other hand, ugh. Report from Claude follows.

-rsc

---

#### What happens

A user with **Dashlane** on Firefox 154 could not add a passkey. Every
attempt returned:

```
passkey: malformed registration response: json: cannot unmarshal object into
Go struct field .response.clientDataJSON of type string
```

Logging in was broken the same way, by the same line. From the server side it
looks like a malformed response, so there is nothing pointing at the client.

#### Cause

`example_test.go` serializes the credential with `JSON.stringify`, in both
ceremonies:

```js
:37 await post("/add-passkey", JSON.stringify(credential))
:48 const res = await post("/login", JSON.stringify(credential))
```

That produces the right document for a real `PublicKeyCredential` only because
`JSON.stringify` calls its `toJSON()` method. A password manager extension
replaces `navigator.credentials`, so what comes back is the extension's own
object — and Dashlane's supplies a `toJSON()` that returns the buffers
unencoded. `clientDataJSON` arrives as an object, and `Register` rejects it.

#### Why the documented contract doesn't save you

`Register` and `ParseResponse` both specify the input as "as produced by its
`toJSON()` method". Our first fix followed exactly that: use `toJSON()` where
there is one, build by hand where there is not. It shipped and failed again on
the same user with the identical error.

That is also how we know Dashlane supplies a `toJSON` rather than lacking one —
by elimination rather than by inspection. The hand-built path cannot produce
that error, because it encodes to a string or omits the field, so the response
must have gone through `toJSON`. Dashlane is the only extension we have
evidence about; we don't know how common either shape is.

#### Suggested change

Have the example build the document from the fields and not consult `toJSON` at
all. The fields are standard and present on a real `PublicKeyCredential`, so
this works everywhere and depends on nothing being a platform object. This is
what is running on oeis.org now:

```js
function pkCredentialJSON(c) {
var out = pkBuild(c);
if (typeof out.response.clientDataJSON !== "string" && typeof c.toJSON === "function") {
var alt = c.toJSON();
if (alt && alt.response && typeof alt.response.clientDataJSON === "string") {
return JSON.stringify(alt);
}
}
return JSON.stringify(out);
}

function pkBuild(c) {
var r = c.response || {};
var out = {
id: c.id,
rawId: pkBase64(c.rawId),
type: c.type,
clientExtensionResults: pkCall(c, "getClientExtensionResults") || {}
};
if (c.authenticatorAttachment) {
out.authenticatorAttachment = c.authenticatorAttachment;
}
var response = {clientDataJSON: pkBase64(r.clientDataJSON)};
if (r.attestationObject) {
// A registration.
response.attestationObject = pkBase64(r.attestationObject);
response.authenticatorData = pkBase64(pkCall(r, "getAuthenticatorData"));
response.transports = pkCall(r, "getTransports");
response.publicKeyAlgorithm = pkCall(r, "getPublicKeyAlgorithm");
} else {
// A login.
response.authenticatorData = pkBase64(r.authenticatorData);
response.signature = pkBase64(r.signature);
response.userHandle = pkBase64(r.userHandle);
}
for (var k in response) {
if (response[k] === null || response[k] === undefined) {
delete response[k];
}
}
out.response = response;
return out;
}

function pkCall(o, name) {
if (o && typeof o[name] === "function") {
try { return o[name](); } catch (e) {}
}
return null;
}

function pkBase64(v) {
if (v === null || v === undefined) {
return null;
}
if (typeof v === "string") {
return v; // already encoded
}
var bytes;
if (v instanceof Uint8Array) {
bytes = v;
} else if (v instanceof ArrayBuffer) {
bytes = new Uint8Array(v);
} else if (ArrayBuffer.isView(v)) {
bytes = new Uint8Array(v.buffer, v.byteOffset, v.byteLength);
} else {
bytes = Uint8Array.from(v);
}
var s = "";
for (var i = 0; i < bytes.length; i++) {
s += String.fromCharCode(bytes[i]);
}
return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
```

Notes on that: the `toJSON` branch is a fallback that fires only when the object
exposes nothing readable, and only if what it returns is actually encoded.
`pkCall` is defensive because an extension's stand-in need not have every
accessor. The registration/login split keys off `attestationObject`, since an
assertion response carries `authenticatorData` as a property while an
attestation response exposes it as `getAuthenticatorData()`.

If nothing else, a sentence in the docs saying that neither `JSON.stringify` nor
`toJSON()` can be relied on behind an extension would have saved us two deploys.

---

## Reproducing it in a real browser

This is worth spelling out because the bug is invisible to every test that does
not involve an extension, and because the setup is less work than it sounds —
it needs no DevTools protocol client.

### Virtual authenticators over plain WebDriver

The commands are WebDriver extension commands defined by the WebAuthn spec
itself (§11.3 Add Virtual Authenticator, §11.6 Remove, §11.9 Set User Verified),
not Chrome-specific CDP. chromedriver implements them, so an ordinary WebDriver
session can create an authenticator and drive whole ceremonies. We added about
sixty lines to our own WebDriver client for it.

The configuration that produces an ordinary synced passkey:

```json
POST /session/{id}/webauthn/authenticator
{
"protocol": "ctap2_1",
"transport": "internal",
"hasResidentKey": true,
"hasUserVerification": true,
"isUserVerified": true,
"isUserConsenting": true,
"defaultBackupEligibility": true,
"defaultBackupState": true
}
```

`isUserConsenting` matters: there is no finger to touch the prompt, so without
it nothing completes. `defaultBackupEligibility` and `defaultBackupState` are
the BE and BS flags — they are in the spec's command, not only in CDP, so the
difference between a synced credential and a device-bound one is reachable from
here. `transport: "usb"` gives a security key instead.

### The extension shim

This is the actual reproduction. It wraps the platform's `navigator.credentials`
and relays the answer through a plain object with a `toJSON` that does none of
the encoding, which is what Dashlane does:

```js
var realCreate = navigator.credentials.create.bind(navigator.credentials);
var realGet = navigator.credentials.get.bind(navigator.credentials);

function relay(c) {
if (!c) {
return c;
}
var r = c.response;
var response = {
clientDataJSON: r.clientDataJSON,
getAuthenticatorData: function() { return r.getAuthenticatorData(); },
getTransports: function() { return r.getTransports(); },
getPublicKeyAlgorithm: function() { return r.getPublicKeyAlgorithm(); }
};
if (r.attestationObject) {
response.attestationObject = r.attestationObject;
} else {
response.authenticatorData = r.authenticatorData;
response.signature = r.signature;
response.userHandle = r.userHandle;
}
var o = {
id: c.id,
rawId: c.rawId,
type: c.type,
authenticatorAttachment: c.authenticatorAttachment,
response: response,
getClientExtensionResults: function() { return c.getClientExtensionResults(); }
};
// The whole bug: a toJSON that does not do what toJSON is for.
o.toJSON = function() {
return {id: o.id, rawId: o.rawId, type: o.type,
response: o.response, clientExtensionResults: {}};
};
return o;
}

navigator.credentials.create = function(o) { return realCreate(o).then(relay); };
navigator.credentials.get = function(o) { return realGet(o).then(relay); };
```

Execute that in the page after loading it and before starting a ceremony. With
`JSON.stringify(credential)` the registration fails with the error above; with
the serializer further up it succeeds. Dropping the `o.toJSON` assignment gives
the other shape — an extension with no `toJSON` — which fails the same way for a
different reason.

### Three things that cost us time

**The RP ID must be a domain name.** `httptest` listens on `127.0.0.1`, and an
IP address is refused outright. Point the browser at `http://localhost:PORT`
instead — same listener, valid RP ID, and a secure context in every browser.

**Conditional mediation blocks everything else.** A page that calls
`navigator.credentials.get({mediation: "conditional"})` on load holds a ceremony
open, and the browser will not start a second one while it waits, so a test page
with both a sign-in button and a register button cannot register at all. We
serve them from two pages, as the real site does.

**Clicking a button from a synchronous WebDriver script can block.** The click
starts a ceremony and the browser may hold the script thread while it decides
what to show, so the command waits on the whole ceremony and times out under
load. Dispatching from `setTimeout(..., 0)` returns immediately, and neither
form is a real user gesture — `navigator.credentials.get()` does not require
one.

### What WebDriver cannot do

The response override bits your `testdata/_chrome` capture tool uses — bad UV,
bad UP, bogus signature — are CDP only. For a corrupt signature we mutate a byte
of a recorded response at replay time instead, which reaches the same code path
without needing a browser at all.

Contributor guide

Open the contributing guide

Research direction

Start in example_test.go at the JSON.stringify calls for /add-passkey and /login, then review the reported serializer and extension shim. Reproduce with the WebDriver virtual authenticator and browser-side credential relay described here. Done means registration and login accept extension-shaped credentials, with documentation covering the JSON.stringify/toJSON limitation.

Written by the indexing model from the issue text.

Assessment

Tech stack
go, javascript
Domain
authentication, web-dev
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
52/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.