Skip to content

Passkey (WebAuthn) support: registration, login, and management#4

Draft
lr00rl wants to merge 3 commits into
feat/lattice-vpn-corefrom
feat/webauthn-passkeys
Draft

Passkey (WebAuthn) support: registration, login, and management#4
lr00rl wants to merge 3 commits into
feat/lattice-vpn-corefrom
feat/webauthn-passkeys

Conversation

@lr00rl

@lr00rl lr00rl commented Jul 5, 2026

Copy link
Copy Markdown
Contributor

What

Native passkey (WebAuthn) support for the Lattice control plane: operators can register passkeys (stored by Apple Passwords / iCloud Keychain or any platform/roaming authenticator) and sign in with them.

New endpoints:

  • Registration (interactive session): POST /api/security/webauthn/register/{begin,finish}
  • Management (interactive session): GET /api/security/webauthn/credentials, POST /api/security/webauthn/credentials/{rename,delete}
  • Login (pre-auth, usernameless/discoverable): POST /api/auth/webauthn/login/{begin,finish}

Data layer: auth.WebAuthnCredential (persisted plaintext — public keys/credential ids are not secret) and a short-lived, single-use, IP-bound auth.WebAuthnChallenge, both following the existing TOTP-challenge/store conventions. Per-user credential cap of 10; sweep-on-write bounded challenge set.

Why / security decisions

  • Library choice — github.com/go-webauthn/webauthn (v0.17.4). Hand-rolling CBOR/COSE/attestation parsing and assertion verification is a security anti-pattern; the repo already carries vetted third-party auth deps (go-oidc). Dependency surface is kept minimal: attestation conveyance none, no MDS/TPM/Apple attestation verification paths. github.com/descope/virtualwebauthn is a test-only dependency used to drive real begin→finish ceremonies.
  • RP identity — derived from PublicURL (the same canonical external-URL config that anchors OIDC): RPID = host (no scheme/port), RPOrigin = scheme://host[:port]. For https://lattice.roobli.orgRPID=lattice.roobli.org, origin https://lattice.roobli.org, zero Apple-specific config. With no PublicURL, passkey endpoints return 503 unless the new opt-in WebAuthnAllowHostFallback is set (derives RP identity from the request Host — development only; an arbitrary Host is never trusted silently). Config field chosen: reuse Options.PublicURL; default no fallback.
  • Step-up gating. Registering and deleting a login-capable credential require a fresh 60s StepUpGrant (the existing sensitive-action verb, passed as step_up_grant) when the account has TOTP enrolled. Rename is not gated. Delete blocks removing the last sign-in method only if the account has no password — password always exists here, so removing all passkeys is allowed.
  • Login = full session. Finish resolves the user from the authenticator user handle and issues the same session the password+TOTP path issues; a user-verified passkey is possession + inherence, so no separate second factor. ResidentKey=required, UserVerification=required. Login begin/finish share the existing per-IP login rate limiter; challenges are ≤5 min, single-use, IP-bound.
  • Sign-count policy. A zero / non-incrementing counter is tolerated (synced passkeys such as Apple's always report 0). Only a regression from a previously non-zero counter is logged as a possible-clone warning; login is never hard-failed on the counter.

Test evidence

  • gofmt -l clean; go vet ./... clean.
  • go test ./internal/... — all packages pass, including new tests:
    • internal/store: credential CRUD + per-user cap, ownership-scoped rename/delete/touch, challenge lifecycle (single-use / expiry / IP bind).
    • internal/server: full register→login round trip (virtual authenticator), bad origin, bad RPID, challenge reuse, IP binding, sign-count regression warns-but-allows, step-up gating on register + delete, passkeys unavailable without PublicURL, bearer PAT rejected from management.

Manual test plan (browser flows can't be E2E-verified headlessly here)

  1. Serve the dashboard over HTTPS at the RP-ID host with PublicURL set.
  2. In Safari on macOS, go to Settings → Security → Passkeys → Add a passkey → complete Touch ID / Face ID. Confirm the passkey appears in Apple Passwords (System Settings → Passwords) and syncs via iCloud Keychain.
  3. Rename and delete a passkey; with TOTP enrolled, confirm the step-up code prompt appears for add/delete but not rename.
  4. Sign out; on the login screen click Sign in with a passkey, authenticate, and confirm you land signed-in with no second-factor prompt.

Related

Dashboard PR: LatticeNet/lattice-dashboard#5

https://claude.ai/code/session_014jDcsi9xQEcy7DJQVksJac

Introduce the persisted data layer for passkeys: a WebAuthnCredential
record (id, user, credential id, public key, AAGUID, sign count,
transports, backup flags, timestamps) and a short-lived, single-use,
IP-bound WebAuthnChallenge that ties a ceremony's begin and finish steps
together, mirroring the TOTP challenge conventions.

Public keys and credential ids are not secret, so the credential map is
persisted as-is (no per-record envelope). The store gains ownership-scoped
CRUD with a per-user cap, credential-id lookup, sign-count/backup/last-used
touch, and a sweep-on-write bounded challenge lifecycle.
lr00rl added 2 commits July 5, 2026 07:33
Wire the WebAuthn ceremonies through go-webauthn (attestation none; no
TPM/Apple attestation paths) rather than hand-rolling CBOR/COSE parsing,
consistent with the repo's existing vetted auth deps.

Registration (interactive) and delete require a fresh 2FA step-up grant
when the account has TOTP enrolled; rename does not. Login is usernameless
and discoverable: it resolves the user from the authenticator's user handle
and issues the same session the password+TOTP path issues, since a
user-verified passkey satisfies possession and inherence. A zero or
non-incrementing sign counter is tolerated (synced passkeys report 0); only
a regression from a previously non-zero counter is logged as a clone
warning.

The relying-party id and origin derive from the configured external URL
(PublicURL) — the same value that anchors OIDC, so no new config surface is
introduced. When PublicURL is unset, passkey endpoints fail closed (503),
mirroring the OIDC handlers. Tests drive full begin->finish ceremonies with
a virtual authenticator and cover challenge lifecycle, bad origin/RPID,
challenge reuse, the sign-count warning, and step-up gating.
Describe the passkey endpoints, the library choice, the RP id/origin
derivation from PublicURL (the same value that anchors OIDC — no new config
field), the fail-closed behavior when it is unset, the step-up gating, the
sign-count policy, and the zero-config Apple Passwords / iCloud Keychain
behavior over HTTPS.
@lr00rl lr00rl force-pushed the feat/webauthn-passkeys branch from 185c046 to 15df231 Compare July 5, 2026 11:33
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