Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/holger

.nornir/passkey-wire-contract.md

Passkey login — the wire contract

Audience: the console lane. This is everything the browser side needs, written so it can be built without waiting for the server side. Nothing here requires reading holger's Rust.

The console is a courier. It never chooses the challenge, the origin or the relying-party id, and it never decides what a login grants. It passes the server's JSON into navigator.credentials.* and passes the result back. Every security decision is on the server, and that is not tidiness — rp_id is the phishing boundary, so a value the browser could influence is a boundary an attacker could choose.


Why passkeys at all, in one paragraph

holger's OIDC login works and has no far end: there is no IdP in an airgapped estate unless somebody raises, seals, upgrades and keeps one running inside the bubble, and then nobody logs in anywhere when it is down. A passkey needs nothing outside the box — the authenticator is local, and the ceremony is between the browser and the server standing in front of it. Passkeys are a fifth auth method beside OIDC, OAuth2, static token and mTLS; a deployment that has a real IdP keeps using OIDC with nothing changed.


⚠ WHICH LISTENER — read this before anything else

The ceremony is on the HTTP gateway (ron_http_url), not the gRPC port (ron_url).

holger runs two listeners with disjoint door sets. A console pointed at the gRPC port gets 404 on every route below, and a 404 on a login route reads as "this console is broken" rather than "wrong port" — which is exactly the day the console lane lost to this. It is written here first because it is the single cheapest mistake to make.

That split is the design, not a gap in it: a WebAuthn ceremony is driven by a browser calling navigator.credentials.*, and a browser speaks HTTP. The ceremony mints a bearer token, and that token is then accepted on both doors through the ordinary auth seam. So: ceremony over HTTP, then use the token wherever you like.

Routes

All live under the reserved /-/ prefix, beside /-/search and /-/properties, so they can never collide with a repository name.

route method auth purpose
/-/auth/passkey/login/start POST none mint a challenge
/-/auth/passkey/login/finish POST none verify the assertion, return a session token
/-/auth/passkey/register/start POST admin, or a valid invite mint a registration challenge
/-/auth/passkey/register/finish POST same as start store the credential
/-/auth/passkey/config GET none rp_id / rp_origin / enabled
/-/auth/passkey/invites POST admin mint an invite (secret shown once)
/-/auth/passkey/invites/list POST admin the invite audit trail (hashes, never secrets)
/-/auth/passkey/invites/revoke POST admin {"id": "<invite id>"}

login/start is unauthenticated by necessity — it is the login door. Treat it as hostile: it is rate-limited per client address and the ceremony store is partitioned, so a flood from one address cannot stop anybody else logging in.


Login

1. POST /-/auth/passkey/login/start

Request body: {} — deliberately empty. The console does not send a username. A discoverable-credential (resident key) ceremony lets the authenticator pick, and sending a name would turn this route into an account-existence oracle for anyone who can reach it.

Response 200:

{
  "ceremony_id": "3f2b9c1e-...",
  "publicKey": {
    "challenge": "<base64url>",
    "rpId": "holger.lab.internal",
    "timeout": 300000,
    "userVerification": "preferred",
    "allowCredentials": []
  }
}

Base64url throughout, unpadded. challenge and every credential id must be converted to ArrayBuffer before being handed to the browser, and converted back to unpadded base64url on the way out. That conversion is the single most common source of a ceremony that fails with no useful message.

const b64uToBuf = s =>
  Uint8Array.from(atob(s.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0)).buffer;
const bufToB64u = b =>
  btoa(String.fromCharCode(...new Uint8Array(b)))
    .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');

2. navigator.credentials.get({ publicKey })

3. POST /-/auth/passkey/login/finish

{
  "ceremony_id": "3f2b9c1e-...",
  "id": "<base64url credential id>",
  "response": {
    "clientDataJSON": "<base64url>",
    "authenticatorData": "<base64url>",
    "signature": "<base64url>",
    "userHandle": "<base64url or null>"
  }
}

Response 200:

{
  "token": "<payload>.<mac>",
  "expires_at": 1758150000,
  "display": "bo",
  "role": "Admin"
}

token is an ordinary bearer. Send it as Authorization: Bearer <token> on every subsequent request, to every holger door, exactly as a static token or an OIDC JWT would be sent. There is no passkey-specific request path after login.

display and role are for rendering only. Do not gate anything on them: the server re-resolves the role on every request from the token's subject, and a console that believes its own copy will be wrong the moment an admin changes a role map. Show the door; let the server refuse it.

Errors

Every non-200 is {"error": "<message>"} with content-type: application/json.

status meaning what the console shows
400 malformed body, or an attestation that would not parse "Something went wrong — try again"
401 assertion did not verify · unknown key · bad/spent/expired invite · no authority to enrol "That key was not recognised"
403 authenticated, but not allowed — a non-admin enrolling, or the retired bootstrap credential show the server's message; it names the fix
404 unknown or spent ceremony_id, or passkeys not configured at all "Your login timed out — start again"
409 that authenticator is already registered here "This key is already enrolled"
413 body over 64 KiB "Something went wrong — try again"
429 too many ceremonies open, globally or from this address "Too many attempts — wait a moment"

403 is the one worth rendering verbatim. It is how an operator finds out the bootstrap token has retired, and the message tells them what to do instead.

The server deliberately does not tell the caller which check failed. Do not try to surface a reason it did not give: an authenticator that learns which check it failed is being told how to pass it.

A ceremony is single-use and expires in 5 minutes. A finish against a spent or stale ceremony_id is a 404, and the only correct response is to start a new one. Do not retry a finish.


Registration

Same two-step shape, with navigator.credentials.create instead of .get, and with one difference that matters: register/start requires authority. Either

register/start request body:

{
  "display": "bo",
  "label": "bo's yubikey",
  "groups": ["readers"],
  "invite": "<code, if not authenticated as admin>"
}

The attributes are decided at start and captured into the ceremony, so finish carries only the authenticator's response. That is deliberate: it makes "the invite's word beats the body's" structural rather than a check somebody can forget, because by finish there is nothing left to disagree with. On the invite path display and groups come from the invite, and anything the body asks for is ignored.

register/finish responds:

{
  "subject": "passkey:9c1f7e40-2b11-4a6e-8f77-000000000002",
  "display": "bo",
  "credential_id": "<base64url>",
  "label": "bo's yubikey"
}

subject is the opaque identity — show it in an admin listing if you like, but never key anything on display.

Response 200 carries publicKey shaped for navigator.credentials.create, including user.id (an opaque server-minted handle — not a username), pubKeyCredParams: [{ "type": "public-key", "alg": -7 }] and excludeCredentials listing the keys already registered to this principal so the browser refuses to enrol the same authenticator twice.

register/finish body:

{
  "ceremony_id": "…",
  "id": "<base64url credential id>",
  "response": {
    "clientDataJSON": "<base64url>",
    "attestationObject": "<base64url>"
  }
}

Only ES256 on P-256 (alg: -7) is accepted. Send exactly that in pubKeyCredParams — the server refuses anything else at registration, which is the right place for it to fail, but only if the console did not offer the user an algorithm that was never going to work.


How the first passkey gets in, and the tenth

Worth knowing because it shapes the screens.


The one thing to get right in the UI

A relocated appliance invalidates every passkey. A credential is bound to rp_id — the DNS name the browser used — forever. If the box is renamed or is reached by a different name from a different network, no registered key will work, and the browser's error will name neither holger nor the cause.

So when login/finish returns 401 and the page was reached by a name that is not the configured rp_id, say so plainly: "This server is reached at <host> but its passkeys were registered for <rp_id>. Use that name, or sign in with a token or client certificate." The server exposes the expected value at GET /-/auth/passkey/config → {"rp_id": "...", "enabled": true} so the console can make that comparison without guessing.

That fallback sentence is not a nicety. It is the difference between a known property and a bricked appliance: passkeys are one method among several precisely so that mTLS and static tokens keep a renamed box reachable for re-enrolment.


What the console must never do


Changes made to this contract AFTER building it

Written down rather than quietly applied, because the console lane is building against it.

  1. The listener section at the top is new, and it is the important one. The first draft said /-/auth/passkey/... without saying where, which is precisely the ambiguity that cost a day once already.
  2. Three invite-management routes were added (config, invites/list, invites/revoke). The first draft had the mint route only, which left no way to build the admin screen that shows an invite or takes one back.
  3. The error table grew 403, 409 and 413, and all errors are now documented as JSON rather than text. 403 in particular needs rendering verbatim — it is how an operator learns the bootstrap token has retired.
  4. register/start takes display and groups, and the contract now says that attributes are fixed at start. The first draft implied finish might carry them.
  5. login/finish's role is the server's role name ("Admin", "Reader", "Writer", "Denied"). Still rendering-only, for the reason already stated.

Nothing was removed, and no route changed shape. If the console has already built against the first draft, only the additions apply.