# Port: `edpass` → `extensions/passkeys`

2026-08-11

The ninth port. Sign in with a fingerprint, a face or a hardware key, and never
type a password into this site again.

Convoro has TOTP two-factor already, which is the right thing to have and the
wrong thing to stop at: it protects an account whose password has leaked, and
does nothing about the password leaking. A passkey has no shared secret to leak.
There is nothing on the server worth stealing — a public key and a counter — and
nothing a convincing copy of the login page can collect, because the browser
will not hand a credential to a domain it was not registered against.

## Why this one, and why it is safe to port

The application it comes from had **one** interesting property: the WebAuthn
code was right the first time and never needed a fix. Every defect in six
releases was in how it plugged into Invision — a base class that turned out to
be a trait, a handler with no title, a login page with no JavaScript. None of
that comes across, because none of it is about WebAuthn.

So this port is the reverse of the usual shape: the hard part travels unchanged
and the easy part is rewritten.

## No dependency, and that is the requirement rather than a preference

Convoro installs by uploading a zip. There is no Composer, so there is no
WebAuthn library — the whole thing is three files:

- **`Cbor`** — a strict decoder. Rejects indefinite lengths, 64-bit lengths,
  trailing bytes and nesting past sixteen deep, and checks every length against
  the input actually remaining. A permissive parser here is a parser somebody
  feeds a hostile attestation object.
- **`Cose`** — a COSE key turned into SPKI DER and then PEM, by hand. ES256 and
  RS256 only. 🚨 The test that matters is not that the DER parses: it is that
  the bytes are **identical to OpenSSL's own encoding of the same key**. A
  nearly-right DER that OpenSSL happens to accept is a bug waiting for a
  different key.
- **`Ceremony`** — the checks, each of which exists because skipping it breaks
  the model.

## The seven checks, and what each one stops

- **The challenge is ours, unused and unexpired.** Otherwise the ceremony
  replays. It is consumed *before* anything can fail, so a wrong guess cannot be
  retried against the same challenge.
- **The origin matches exactly**, by `hash_equals`. Not "starts with", not
  "contains" — an origin check that can be prefixed is not a check.
- **The RP ID hash matches**, or a credential registered on another site is
  accepted here.
- **The ceremony type matches**, or a registration is replayed as a login.
- **The user-present flag is set**, or a key left in a USB port authorises
  sign-ins nobody asked for.
- **The signature verifies over exactly `authenticatorData ‖
  SHA-256(clientDataJSON)`** — the bytes as received, not a re-encoding of them.
- **The sign counter never goes backwards.** A counter that decreases means two
  authenticators are claiming one credential, so the credential is disabled
  rather than trusted. 🚨 A counter that is *always zero* is normal — most
  platform authenticators do that — and must not trip it.

**Attestation is deliberately not verified.** Only the public key is needed, and
verifying attestation statements means shipping and maintaining a root
certificate store, then keeping it current for ever. `attestation: none`.

## 🚨 What the port decides that the original did not have to

**A passkey and the TOTP code are not both required, and the setting says which
one you have.**

A passkey with user verification is already two factors — something you have,
and something you are or know, checked by the authenticator. Asking for a TOTP
code afterwards is a third factor nobody asked for, and it is exactly the
friction that makes people turn the good thing off.

**Amended the same day, after Ernest's instruction that everything match the
IPS original.** The application this comes from returns a member to Invision's
own login stack, which then applies whatever multi-factor the site has
configured — so on IPS, a passkey sign-in *does* still meet the code step.

So parity is the default: **a member with two-step verification on still gets
the code step after a passkey**, exactly as they would on Invision. The
improvement is available and switched off — *"a verified passkey is enough on
its own"* — and it only has any effect when user verification is required, since
a passkey without it proves possession and nothing more.

User verification itself stays required by default, which is what makes that
option safe to offer at all.

**Discoverable credentials, so the sign-in button asks for nothing first.** The
assertion is requested with an empty `allowCredentials` list, which means the
browser offers whichever passkey it holds for this site and the member never
types an email address. That is the difference between "passwordless" and
"password, but afterwards".

**A member cannot remove their last sign-in method.** Removing the only passkey
on an account with no usable password is locking yourself out of your own
community, and it must be refused rather than confirmed.

## Where it plugs in

The sign-in itself needs no seam, which is worth recording after three ports
that did. `Auth::login()`, `createRememberToken()` and `UserLoggedIn` are all
public already, so the extension signs somebody in exactly the way the login
form does — one code path, one event, one remember cookie.

The interface needed one hook that did not exist. `member.login.after` was
already there for third-party sign-in buttons; **`member.security.after` is
new**, because passkeys belong on Settings → Security beside two-step
verification rather than on a page of their own. Somebody deciding how they get
into their account should find every answer in one place. Nothing else in the
product is touched.

## 🚨 It hides itself when it cannot work

Browsers refuse WebAuthn outside a secure context, so on plain HTTP the button
is not rendered at all rather than rendered and broken. `localhost` counts as
secure, which is why it can be developed against at all.

The same rule covers a member with no passkeys and a site with the feature
switched off: a control that cannot succeed does not appear.
