Skip to content

CTAP 2.1 platform-style passkeys: PIN, credential management, reset, PRF - #44

Open
pipobscure wants to merge 1 commit into
matejsmycka:mainfrom
pipobscure:feat/ctap21-platform-passkeys
Open

pipobscure wants to merge 1 commit into
matejsmycka:mainfrom
pipobscure:feat/ctap21-platform-passkeys

Conversation

@pipobscure

@pipobscure pipobscure commented Nov 6, 2026 •

Copy link
Copy Markdown

This makes linux-id a complete CTAP 2.1 authenticator, so it can act as the machine's passkey provider with fingerprint verification, closer to Touch ID or Windows Hello than to a security key. It's large (about 10k lines including tests), so it's a draft: I'm happy to split it or to drop parts you'd rather not take.

What changes for users

  • Firefox and Chromium: keepalives are sent while waiting for the finger, and cancelling in the browser stops the scan.
  • Several accounts per site: they work, and the browser offers an account picker.
  • Silent probes no longer prompt.
  • PIN: the fingerprint stays the everyday check, and a CTAP PIN is the fallback when the finger isn't recognised or fprintd is unavailable.
    • Chrome's "Manage security keys → passkeys" needs a PIN set, and works once one is.
  • Managing passkeys: list and delete them from the browser, fido2-token, or linux-id creds list|delete.
  • Reset: from the browser (restart the service when it asks to re-insert the key), or with linux-id reset.
  • PRF (hmac-secret) for password managers and end-to-end encrypted apps, plus credProtect.
  • OpenSSH resident keys: ssh-keygen -O resident -O verify-required and ssh-keygen -K both work.
  • Optional notification: --notify names the site while fprintd waits. Browsers already show their own prompt, so it's off by default.
  • Startup fix: the unit now has Restart=on-failure. The service used to give up at boot when it started before logind granted the TPM/uhid ACLs.

Compatibility

  • Existing credentials keep working after upgrading. That covers both passkeys and U2F/2FA key handles, until the user's first reset.
  • New state in the TPM: the credential generation is a monotonic NV counter at 0x013C1D01, and the "legacy credentials retired" marker is at 0x013C1D02. Both are in the owner range and created on first use; they assume an empty owner authValue, as everything else in linux-id already does.
  • Remaining state on disk: creds.json holds TPM-wrapped key handles and metadata; pin.json holds only a TPM-wrapped PIN object and the retry counter.
  • Go 1.27 is now required, because of the new dependency github.com/telesma-app/ctap v0.52.0. It provides the PIN/UV protocol primitives and the COSE and CBOR types, rather than re-implementing them.

Design notes

  • No secret in local files:
    • The generation secret is derived inside the TPM from the counter value.
    • The PIN is a DA-protected sealed object whose authValue is the PIN hash. It's checked over a salted HMAC session, so offline guessing needs the TPM, and the TPM's lockout applies. Note that the TPM's DA counter is machine-wide.
    • hmac-secret's CredRandom is a TPM2_HMAC under a non-exportable key.
  • Credential ID header: new credential IDs carry a small header (credProtect level, hmac-secret flag) that the key's application parameter is bound to, so a stripped or downgraded ID doesn't sign.
  • Tokens and the verification cache: a pinUvAuthToken is never issued from the verification cache. Chrome treats getting a token as "the user picked this authenticator", and a cached answer made it send a sign-in meant for a phone passkey to linux-id.

Relation to open PRs

Testing

  • Unit: go test -race ./....
  • TPM: LINUXID_TPM_TEST=1 go test ./... runs against /dev/tpmrm0. These tests use their own NV indices and remove them, and the PIN test makes one deliberate wrong guess.
  • End-to-end: e2e/ runs a throwaway instance over the real uhid transport, driven by python-fido2 (resident credentials, tokens, PIN incl. fallback, credential management, extensions, reset) and by OpenSSH in a bubblewrap sandbox. See e2e/README.md.
  • Daily use: about a day with --auth fprintd on Arch with Chrome. That covered webauthn.io, a self-hosted app with step-up re-authentication, Chrome's PIN setup and passkey management, and ssh. Also using this with firefox (though obviously not managing credentials there)

Not included

  • The AAGUID is still all zeros. A project AAGUID, plus an entry in the community AAGUID list so sites can show "linux-id", would be a good follow-up, and it's your call as maintainer.

…eset, PRF

Make linux-id a full CTAP 2.1 authenticator, so it can serve as the
machine's passkey provider with fingerprint verification.

Transport
- A CTAPHID state machine per CTAP 2.1 §11.2: one request in flight,
  ERR_CHANNEL_BUSY for others, validated reassembly and the matching
  error codes, and PING and WINK.
- KEEPALIVE every 100 ms with UPNEEDED while waiting for the user.
- CANCEL and INIT-resync cancel the request; the fingerprint scan stops.

CTAP 2.1
- Several resident credentials per RP (numberOfCredentials,
  getNextAssertion). Re-registering the same user replaces the
  credential. Name and displayName are returned only with UV.
- up=false silent probes; authenticatorSelection.
- pinUvAuthToken via built-in UV and via PIN (protocols 1 and 2). The
  fingerprint is primary; after repeated no-matches, or with fprintd
  unavailable, UV_BLOCKED sends platforms to the PIN.
- Tokens are never issued from the verification cache, which is scoped to
  the RP. Chrome reads a token as "the user chose this authenticator".
- authenticatorCredentialManagement (and the 2.1-PRE alias), plus a
  `linux-id creds list|delete` CLI. Deleted credentials stop signing even
  where an RP still holds their ID.
- credProtect, hmac-secret (WebAuthn PRF) and hmac-secret-mc. CredRandom is
  a TPM2_HMAC under a non-exportable key, so it can't be computed without
  this TPM.
- authenticatorReset, accepted within 10 s of start with a fresh UV, and
  `linux-id reset`.
- GetInfo reports FIDO_2_1 and the real limits.

Keys and secrets: nothing secret in local files
- Credential keys are bound to a credential generation. Its number is a
  TPM monotonic NV counter (0x013C1D01) and its secret is derived in the
  TPM on each use. A reset increments the counter and cannot be undone.
- Credential IDs carry their options (credProtect, hmac-secret) in a
  header the key is bound to, so a stripped or downgraded ID does not sign.
- The PIN is a DA-protected TPM sealed object whose authValue is the PIN
  hash. It is created and checked over salted HMAC sessions, so the hash
  never crosses the TPM interface in clear. pin.json holds only the
  wrapped object and the retry counter.
- Existing credentials (bare key handles) keep working after upgrading,
  until the first reset retires them (NV index 0x013C1D02, defined before
  the counter moves). Public keys of old discoverable credentials are
  derived from their key handles.

Other
- An optional desktop notification (--notify) names the site while
  fprintd waits. The service restarts if the uaccess ACLs arrive after it
  starts. --credstore selects the store. Unknown --auth values are
  rejected.

Tests: unit tests (-race), TPM tests behind LINUXID_TPM_TEST=1 that use
their own NV indices, and e2e/ — python-fido2 and OpenSSH against a
throwaway instance over real uhid.

Requires Go 1.27 (for github.com/telesma-app/ctap, whose PIN/UV protocol
primitives, COSE and CBOR types are used).
@pipobscure
pipobscure marked this pull request as ready for review November 6, 2026 21:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant