WebAuthn / Passkeys

Tetrapus supports W3C WebAuthn for both first-factor passkey login and second-factor MFA. Roaming authenticators (YubiKey, Solo, Titan), platform authenticators (Touch ID, Windows Hello, Android), and synced passkeys (iCloud Keychain, Google Password Manager) all work. No SMS, no TOTP fallback required — modern hardware only.

Two-step ceremonies

Both enrolment and authentication are two-step: the server issues a challenge in start, the browser performs the cryptographic ceremony with the authenticator, and the resulting attestation or assertion is POSTed back to finish.

Enrolment flow

graph TD USER["User clicks 'Add passkey' in Profile"] --> RSTART["POST /api/v1/auth/mfa/webauthn/register/start"] RSTART --> CHALLENGE["Server: generate challenge\n+ user_handle\n+ allowed algorithms"] CHALLENGE --> JSON["{ publicKey: { challenge, user, pubKeyCredParams, ... } }"] JSON --> BROWSER["browser: navigator.credentials.create(publicKey)"] BROWSER --> AUTH["User taps YubiKey / Touch ID"] AUTH --> ATTEST["Authenticator returns attestation"] ATTEST --> RFINISH["POST /api/v1/auth/mfa/webauthn/register/finish\n+ attestationObject + clientDataJSON"] RFINISH --> VERIFY["Verify attestation\nExtract credential_id + public_key"] VERIFY --> CBOR["Encode credential as CBOR"] CBOR --> STORE["INSERT INTO webauthn_credentials"] STORE --> OK["✓ Passkey enrolled"]

Authentication flow

graph TD LOGIN["User submits username on /login"] --> ASTART["POST /api/v1/auth/mfa/webauthn/auth/start"] ASTART --> LIST["Server: load all credentials for user"] LIST --> CHAL["Generate challenge\n+ allowCredentials list"] CHAL --> BR["browser: navigator.credentials.get(publicKey)"] BR --> TOUCH["User taps authenticator"] TOUCH --> ASSERT["Authenticator signs challenge"] ASSERT --> AFINISH["POST /api/v1/auth/mfa/webauthn/auth/finish\n+ assertion + clientDataJSON"] AFINISH --> VERIFY["Verify signature against stored public_key\nCheck signCount monotonic"] VERIFY --> SESSION["Mint session cookie"]

REST routes

MethodPathStep
POST/api/v1/auth/mfa/webauthn/register/startIssue enrolment challenge
POST/api/v1/auth/mfa/webauthn/register/finishVerify + persist credential
POST/api/v1/auth/mfa/webauthn/auth/startIssue auth challenge
POST/api/v1/auth/mfa/webauthn/auth/finishVerify assertion → mint session

Browser-side handoff

The server returns options pre-decoded for direct passing into the WebAuthn JS API. A typical RP page:

JavaScript
// 1) Ask server for the challenge
const startRes = await fetch('/api/v1/auth/mfa/webauthn/register/start', {
  method: 'POST',
  credentials: 'include',
});
const { publicKey } = await startRes.json();

// 2) Hand to the browser. User taps key.
const cred = await navigator.credentials.create({ publicKey });

// 3) Send the attestation back to finish
await fetch('/api/v1/auth/mfa/webauthn/register/finish', {
  method: 'POST',
  credentials: 'include',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    id:       cred.id,
    rawId:    base64url(cred.rawId),
    type:     cred.type,
    response: {
      attestationObject: base64url(cred.response.attestationObject),
      clientDataJSON:    base64url(cred.response.clientDataJSON),
    },
  }),
});

Persistence — webauthn_credentials

SQL
CREATE TABLE webauthn_credentials (
    credential_id   BLOB PRIMARY KEY,           -- raw credential ID from the authenticator
    user_id         TEXT NOT NULL REFERENCES users(id),
    public_key_cbor BLOB NOT NULL,              -- COSE_Key in CBOR
    sign_count      INTEGER NOT NULL DEFAULT 0, -- monotonic counter, replay defense
    transports      TEXT,                       -- JSON: ["usb","nfc","internal"]
    aaguid          BLOB,                       -- authenticator model GUID
    label           TEXT NOT NULL,              -- "YubiKey 5C NFC", user-editable
    created_at      TEXT NOT NULL,
    last_used_at    TEXT
);

Replay defence

The authenticator increments signCount on every assertion. Tetrapus stores the last seen value and rejects any assertion whose counter is less-than-or-equal to it. A counter regression is logged as a forensic event — it usually means a cloned authenticator and warrants killing the credential.

Status

The full WebAuthnService lives behind the webauthn Cargo feature flag. Builds without that feature ship the route stubs that return 501 Not Implemented — useful for embedded edge deployments that don't need passkeys and want to drop the dependency footprint. Enrol credential management UI (rename, delete, list) is shipped under Profile → Security → Passkeys.

Related

Questions?

Reach out for help with integration, deployment, or custom domain codecs.