KMS includes a WebAuthn/passkeys reference implementation backed by the wax_ FIDO2 library.

Use this for:

  • passwordless principal authentication;
  • MFA/factor authentication after password login.

Architecture

Keep browser traffic on your application server.

  1. App server calls KMS.WebAuthn or KMS.Client.Local/KMS.Client.Remote.
  2. KMS returns JSON-compatible publicKey options.
  3. App server sends those options to browser JavaScript.
  4. Browser calls navigator.credentials.create() or navigator.credentials.get().
  5. App server forwards the browser response to KMS.
  6. KMS verifies the WebAuthn ceremony server-side.

Do not expose KMS directly to browsers. The remote HTTP API is still admin-token backend API only.

Configuration

config :kms, :webauthn,
  rp_id: "example.com",
  rp_name: "Example App",
  origins: ["https://app.example.com"],
  attestation: "none",
  user_verification: "preferred",
  challenge_ttl_ms: :timer.minutes(5)

Per-call :rp_id and :origin are accepted only when present in configured allowlists. Add more RP IDs with :rp_ids and more browser origins with :origins.

Attestation defaults to "none".

Enrollment / registration

{:ok, enrollment} =
  KMS.begin_webauthn_registration("users", "alice",
    rp_id: "example.com",
    rp_name: "Example App",
    origin: "https://app.example.com"
  )

KMS returns:

%{
  challenge_id: "...",
  public_key: %{...}
}

Browser side must convert base64url fields to ArrayBuffer before calling WebAuthn:

const publicKey = decodeCreationOptions(enrollment.public_key)
const credential = await navigator.credentials.create({ publicKey })
const response = encodeCredential(credential)

Finish enrollment:

{:ok, credential} =
  KMS.finish_webauthn_registration(enrollment.challenge_id, response,
    label: "MacBook passkey",
    transports: ["internal"]
  )

KMS stores credential ID, COSE public key, RP ID, sign count, AAGUID, transports, backup flags, and label. Private keys never leave authenticators and are never stored by KMS.

Passwordless authentication

{:ok, options} = KMS.begin_webauthn_authentication("users", "alice")

Browser:

const publicKey = decodeRequestOptions(options.public_key)
const assertion = await navigator.credentials.get({ publicKey })
const response = encodeCredential(assertion)

Finish:

{:ok, session} = KMS.finish_webauthn_authentication(options.challenge_id, response)

Passwordless sessions authenticate the principal but do not unwrap the password-protected principal private key. KMS.Sessions.decrypt_private_key/1 returns {:error, :private_key_unavailable} for those sessions. KMS authorization checks based on principal/session still work.

MFA factor flow

Create a factor after enrolling at least one WebAuthn credential:

{:ok, factor} =
  KMS.create_principal_factor("users", "alice", %{
    provider: "webauthn",
    label: "passkey",
    position: 1,
    config: %{"credential_ids" => [credential.credential_id]}
  })

credential_ids is optional. Omit it to allow any enabled WebAuthn credential for the principal/RP ID.

Password login returns pending session:

{:ok, pending} = KMS.create_session("users", "alice", %{password: password})

pending.challenge == %{
  provider: "webauthn",
  type: "webauthn",
  public_key: %{...}
}

Advance session:

{:ok, session} =
  KMS.advance_session(pending.secret, %{
    challenge_id: pending.challenge.challenge_id,
    webauthn_assertion: response
  })

Factor mode preserves the password-unwrapped principal private key inside the session flow.

Remote HTTP API

All routes require the existing admin bearer token:

POST /api/webauthn/registration/begin
POST /api/webauthn/registration/finish
POST /api/webauthn/authentication/begin
POST /api/webauthn/authentication/finish

Example begin request:

{
  "kind": "users",
  "ref": "alice",
  "opts": {
    "rp_id": "example.com",
    "origin": "https://app.example.com"
  }
}

Example finish request:

{
  "challenge_id": "...",
  "response": { "id": "...", "rawId": "...", "type": "public-key", "response": { } }
}

JavaScript encoding helpers

KMS represents binary WebAuthn fields as base64url strings in JSON. Convert before and after browser APIs.

function base64urlToBuffer(value) {
  const base64 = value.replace(/-/g, "+").replace(/_/g, "/")
  const padded = base64.padEnd(Math.ceil(base64.length / 4) * 4, "=")
  const binary = atob(padded)
  return Uint8Array.from(binary, c => c.charCodeAt(0)).buffer
}

function bufferToBase64url(buffer) {
  const bytes = new Uint8Array(buffer)
  let binary = ""
  for (const byte of bytes) binary += String.fromCharCode(byte)
  return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "")
}

function decodeCreationOptions(publicKey) {
  return {
    ...publicKey,
    challenge: base64urlToBuffer(publicKey.challenge),
    user: {...publicKey.user, id: base64urlToBuffer(publicKey.user.id)},
    excludeCredentials: (publicKey.excludeCredentials || []).map(c => ({
      ...c,
      id: base64urlToBuffer(c.id)
    }))
  }
}

function decodeRequestOptions(publicKey) {
  return {
    ...publicKey,
    challenge: base64urlToBuffer(publicKey.challenge),
    allowCredentials: (publicKey.allowCredentials || []).map(c => ({
      ...c,
      id: base64urlToBuffer(c.id)
    }))
  }
}

function encodeCredential(credential) {
  const response = {}
  for (const key of Object.keys(credential.response)) {
    const value = credential.response[key]
    response[key] = value instanceof ArrayBuffer ? bufferToBase64url(value) : value
  }

  return {
    id: credential.id,
    rawId: bufferToBase64url(credential.rawId),
    type: credential.type,
    response
  }
}

Security notes

  • KMS validates challenge, origin, RP ID, user presence, user verification policy, signature, and sign count.
  • Challenges are random, short-lived, stored server-side, and deleted after successful use.
  • Registration/assertion JSON is untrusted input.
  • Credential public keys are stored; private keys stay in authenticators.
  • Credential/challenge internals are redacted from inspect output and HTTP responses.
  • Use HTTPS origins in production.
  • Configure tight origin/RP ID allowlists per deployment/tenant.