FiloSottile / FiloSottile/passkey

passkey: export Transports again

Open Beginner friendly
#3 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

Another suggestion, written up by Claude below. For the cases where Chrome zeros AAGUID (which is not most but some), having Transports would let us describe the passkey at least a little bit to users deciding which ones to keep / delete.

-rsc

---

The prototype API had `Transports`, `CredentialID` and `BackedUp` as accessors
on a record. The shipped package keeps only `AAGUID` and
`UserVerificationAvailable`. We would like `Transports` back. This is a small
request, and it is worth explaining why, because the obvious objection is
correct: for most passkeys the AAGUID already answers the question.

### What we use it for

oeis.org has a passkey management page. Somebody with three passkeys has to be
able to tell which is which in order to remove the right one, and a row reads
roughly:

```
iCloud Keychain — synced · added 3 Mar 2026 · last used 2 hours ago
YubiKey — security key, USB/NFC · added 14 Jan 2026 · never used
```

The AAGUID supplies the provider name, via the community list at
`passkeydeveloper/passkey-authenticator-aaguids`. That works well. Creating a
passkey in Chrome against Apple's provider yields
`fbfc3007-154e-4ecc-8c0b-6e020557d7bd`, which the list names "Apple Passwords",
and the row is correct with no help from the transports at all.

So the AAGUID is not the problem. The transports matter in three cases where it
runs out.

### 1. Chrome zeroes the AAGUID for roaming authenticators

This is the case that motivated the request, and your own testdata demonstrates
it. From `testdata/chrome.json`, one virtual authenticator configured with
AAGUID `01020304…` throughout:

| recording | transport | AAGUID in the record |
|---|---|---|
| `platform` | `internal` | `01020304050607080102030405060708` |
| `synced` | `internal` | `01020304050607080102030405060708` |
| `backup-eligible` | `internal` | `01020304050607080102030405060708` |
| **`security-key`** | **`usb`** | **`00000000000000000000000000000000`** |

Same authenticator, same AAGUID configured, preserved for the platform
transport and zeroed for the roaming one. Your capture tool notes this in
passing — "A roaming authenticator, for which Chrome zeroes the AAGUID" — and
the recordings bear it out.

For those records there is nothing left to identify the credential by, and the
row degrades to a bare "Passkey" with a date. The transports still say `usb`
and `nfc`, which is enough to call it a security key and let somebody
distinguish it from the passkey on their phone.

Worth noting the spec has moved underneath this: WebAuthn L2 required clients
to zero the AAGUID when the attestation preference was `none`, and the L3
change log records that this is no longer required. Chrome evidently still does
it for roaming authenticators. Either way the records exist and will keep
existing, so a management UI has to cope with them.

### 2. A provider the list does not name

The community list has about fifty entries. A provider that is not in it, or
one added since the table was last generated, yields a non-zero AAGUID that
maps to nothing. The transports still distinguish a phone (`hybrid`), this
device (`internal`), and a key you plug in (`usb`, `nfc`, `ble`).

### 3. Two credentials from the same provider

Two passkeys from the same provider have identical AAGUIDs, so the AAGUID
cannot tell them apart at all — and the person removing one needs to. The
transports recorded at registration can differ: a credential created on this
device reports `internal`, one created on a phone through the QR flow reports
`hybrid`. Not always decisive, but it is the only per-credential signal in the
record that ever differs between two rows from one provider.

### What we do instead

Parse the record's prefix ourselves:

```
$webauthn$v=1$transports=hybrid+internal$YyL05g6akpZ33N7le5p…
$webauthn$v=1$transports=nfc+usb$YyL05g6akpZ33N7le5p0NtXRIEG…
```

This is fine as far as it goes — the format is published and versioned at
c2sp.org/passkey-record, so it is not reaching into anything private. But it
means every application that wants to build a passkey list writes the same
small parser against a format the package owns, and gets to keep it working.
`AAGUID` and `UserVerificationAvailable` are exported for exactly this kind of
use; `Transports` is the third member of that set, and the package already
parses it internally to populate credential descriptors.

`CredentialID` and `BackedUp` we have not missed. `BackedUp` in particular is
better as `LoginResult.BackedUp`, since backup state is a thing that changes
and the record's copy is a snapshot from registration.

Contributor guide

Open the contributing guide

Research direction

Start by tracing how the package parses the record prefix and how it exposes AAGUID and UserVerificationAvailable. Review testdata/chrome.json, especially the security-key recording, and verify that parsed transport values such as usb, nfc, internal, and hybrid are available through the record API without application-side parsing.

Written by the indexing model from the issue text.

Assessment

Tech stack
go
Domain
authentication, security
Issue type
Feature
Difficulty
2/5
Estimated time
1-3 hours
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
72/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.