veil

Sign in with Veil

Let people sign in to your app with their Veil card. The login is a signature made by their device key, and you can check it against the public log yourself.

Veil is the public directory of cryptographic identities. Each person or organisation has one card that ties their keys to the accounts people know them by (Discord, GitHub, email, a domain, the Fediverse and more), with certified ties. Sign in with Veil is an OpenID Connect provider built on that directory. When someone signs in to your app, the identity you receive is their card, and the proof is a signature from a key that never left their browser.

If you build the app on the other side of the sign-in, this page covers registering it, the flow, the claims and the tokens, and what you can check yourself against the public log instead of taking the server's word. The issuer is https://veil.rip.

How a sign-in works#

  1. Your app sends the browser to https://veil.rip/oidc/authorize with the usual code flow parameters and a PKCE challenge.
  2. The consent page shows your app's name, its site, what it asks for, and the card this browser holds. In the browser a card is a device key: an Ed25519 key made there and kept in IndexedDB, which no page can read or export. No password is involved at any point.
  3. When the person agrees, the browser signs a consent text with the device key. The text names your app, your redirect URI, the scopes, your nonce, a hash of your state, your PKCE challenge and the time.
  4. Veil checks that signature against the card's active device keys and against the card's current version in the verified public log. It keeps the signed consent with the login and sends the browser back to you with code, state and iss.
  5. Your server exchanges the code for an access token, a refresh token and an ID token signed by Veil.

If the browser holds no card, the page offers the usual ways in: make a card, or add this browser to an existing card with the backup file or another device. Then it comes back to your request, which waits 30 minutes for that.

The person approves your app once. The next sign-in with the same scopes needs no click, but the browser still signs the consent, so every login is a fresh signature. For a native app that comes back on the loopback interface (http://127.0.0.1, [::1] or localhost) the page asks every time, because any program on the same computer could be listening at that address (RFC 8252, 8.6).

On their account page the person sees the apps they signed in to, each login with its signed consent, and can revoke any of those apps there.

Endpoints#

The discovery document, GET https://veil.rip/.well-known/openid-configuration, lists every endpoint. Each name below opens its page in the reference.

EndpointAddress
Discoveryhttps://veil.rip/.well-known/openid-configuration
Authorizationhttps://veil.rip/oidc/authorize
Tokenhttps://veil.rip/oidc/token
UserInfohttps://veil.rip/oidc/userinfo (GET or POST)
JWKShttps://veil.rip/oidc/jwks
Registration (RFC 7591)https://veil.rip/oidc/register
Revocation (RFC 7009)https://veil.rip/oidc/revoke
Introspection (RFC 7662)https://veil.rip/oidc/introspect
End sessionhttps://veil.rip/oidc/logout

The provider supports:

  • Response type code only, with response mode query.
  • PKCE with S256 for every app, public and confidential.
  • The iss parameter in every authorization response (RFC 9207).
  • ID tokens signed with EdDSA (Ed25519) or ES256.
  • Client authentication with client_secret_basic or private_key_jwt, and none for public apps.

Request objects, request_uri and the claims parameter are not supported.

The discovery document and the JWKS answer cross-origin requests, and nothing else under /oidc/ does. A browser app therefore needs a small backend of its own that holds the tokens and talks to the token endpoint (the backend for frontend pattern). Veil API keys play no part under /oidc/ either: the Authorization header there carries your app's secret or a person's access token.

Registering an app#

From /developers#

On https://veil.rip/developers a card registers its apps, signed by the device key in that browser like everything else on the card. One of an organisation's owners registers the organisation's apps. A card can have 20 apps.

FieldRules
Name1 to 60 characters. The consent page shows it.
SiteAn https URL of at most 300 characters. The consent page shows its host.
Redirect URIs1 to 10. Each one is https, or http on the loopback interface (127.0.0.1, [::1], localhost) for a native app, with no fragment and no user information, in at most 512 printable ASCII characters.
Logout redirect URIs0 to 10, with the same rules.
Typeconfidential (a server, with client_secret_basic or private_key_jwt) or public (a native app, with PKCE alone).
JWKSFor private_key_jwt, 1 to 8 public keys: Ed25519, P-256, or RSA of 2048 to 4096 bits with a small odd exponent (65537, as usual). You give the keys themselves, never a URL.
ID token algorithmEdDSA (the default) or ES256.
Refresh tokensOn by default.
LogoA PNG or GIF of at most 256 KiB and 512 × 512 pixels, checked by the same rules as a card's picture.

When the app is made, Veil shows its client id (veil_app_ and 32 hex digits) and, for a server, its secret (veil_secret_…). The secret is shown only that once, and Veil keeps only its SHA-256 hash. A new secret replaces the old one at once, and deleting the app ends every token it holds.

An app is verified when its card proves, with the directory's domain proof, the domain of the app's site and of every https redirect URI. The consent page then says "Verified: the owner of example.org". Any other app gets a quiet "Unverified app" line, and the page always names the card that registered it.

Dynamic registration#

POST https://veil.rip/oidc/register is open to anyone (RFC 7591), within these limits:

  • At most 10 registrations an hour per IP address.
  • The app belongs to no card, so it is never verified, and it may not call itself Veil.
  • It expires 30 days after registration (client_secret_expires_at and veil_expires_at in the response).
  • token_endpoint_auth_method is client_secret_basic when omitted, as RFC 7591 says, so send none for a public app.
  • For private_key_jwt the jwks go inline, jwks_uri is refused, and logo_uri is neither fetched nor kept.
cURL
curl -fsS https://veil.rip/oidc/register -H 'Content-Type: application/json' -d '{
  "client_name": "Notes CLI",
  "redirect_uris": ["http://127.0.0.1/callback"],
  "token_endpoint_auth_method": "none",
  "grant_types": ["authorization_code", "refresh_token"],
  "application_type": "native"
}'

The authorization request#

text
https://veil.rip/oidc/authorize?response_type=code
  &client_id=veil_app_62bab7367bb583e2157490ae2cb65d4d
  &redirect_uri=https%3A%2F%2Fnotes.example.org%2Fcallback
  &scope=openid%20profile%20veil
  &state=…&nonce=…
  &code_challenge=…&code_challenge_method=S256
  • redirect_uri must match a registered one exactly, except that a loopback URI matches on any port (RFC 8252). An unknown app or an unregistered redirect URI is refused on the consent page, and the browser is never sent there.
  • scope must include openid. An unknown scope is invalid_scope.
  • code_challenge (43 characters) and code_challenge_method=S256 are required for every app. Without them the answer is invalid_request.
  • state takes up to 1024 characters and nonce up to 512. Send both.
  • prompt takes none, login, consent and select_account. With none the answer is login_required when the browser holds no card, and consent_required when the card has not approved your app for these scopes or your redirect URI is a loopback one.
  • max_age=0 counts as prompt=login, so the page waits for the click. Any other max_age is met already, because every sign-in is a signature made at that moment (auth_time).
  • ui_locales=it shows the page in Italian. English is the default.

The answer reaches your redirect URI as code, state and iss=https://veil.rip, or as error, error_description, state and iss. Check state and iss before you use the code.

Try the flow#

Register a test app on /developers with a redirect URI you control, then give its client id and that redirect URI here. This page makes a PKCE verifier, a state and a nonce in your browser and writes the request for you. What you type stays on this page.

The provider's keys (JWKS)
https://veil.rip/oidc/jwks

From the app's row on /developers.

One of the app's redirect URIs, exactly as registered.

Scope
The authorization request
https://veil.rip/oidc/authorize?response_type=code
  &client_id=…
  &redirect_uri=…
  &scope=openid%20profile%20veil
  &state=…
  &nonce=…
  &code_challenge=…
  &code_challenge_method=S256
Open the request

With JavaScript, this page fills in the PKCE challenge, the state and the nonce.

Then exchange the code
curl -fsS https://veil.rip/oidc/token \
  -u "$VEIL_CLIENT_ID:$VEIL_CLIENT_SECRET" \
  -d grant_type=authorization_code \
  -d "code=$CODE" \
  --data-urlencode 'redirect_uri=…' \
  -d code_verifier=…

Put the code your redirect URI receives in CODE and the app's secret in VEIL_CLIENT_SECRET. A native app sends -d client_id=… in place of -u. A reload makes a new verifier, state and nonce.

Scopes and claims#

ScopeClaimsWhat the person is told
openidsub, and in the ID token auth_time, amr, sidYour card's number
profilename, picture, preferred_username, profileYour name and picture
veilveil_version, veil_log, veil_good_standing, veil_standing_reasons, and in the ID token veil_consentYour card's standing: whether it is in good standing, the version this sign-in is checked against, and the signature you make now
veil:accountsveil_accountsYour verified accounts, listed by platform and handle
veil:orgsveil_orgsYour organisations
veil:keysveil_keysYour public keys
ClaimMeaning
subThe card's number as a string ("7"). It stays the same for the card's whole life, and an alias never changes it.
nameThe name on the card. Names are not unique in Veil, so show it with the number and identify people by sub. While the staff hide a name, it is Card <n>.
pictureThe address of the card's picture. Absent (or null in UserInfo) when the card has none.
preferred_usernameThe card's alias when it has one, else u<n>. An alias can change or be released.
profilehttps://veil.rip/u/<n>, the card's page.
veil_versionThe card's version the login was checked against. In UserInfo, its current version.
veil_logThe log entry of that version and the checkpoint the server verified it in: {origin, index, size, root}, where root is the checkpoint's root hash in base64.
veil_good_standingtrue when none of the reasons below applies.
veil_standing_reasonsWhy not: suspended (by the Veil staff), closing (the card asked to be closed and has 24 hours to cancel), takeover (during the 7-day wait, when another card asks for one of its accounts or it asks for one), name_hidden, key_revoked (the device key that signed this login has been revoked since), key_banned.
veil_consentID token only. The consent exactly as the device key signed it, {text, signature, key, alg}. See "What to verify".
veil_accountsThe verified accounts the card holds now: [{platform, id, handle, url, verified_at}]. id is the platform's stable id (Discord and GitHub user ids, an email address, a domain, a Fediverse address), and url is null where the platform has no profile page. A pending account (during a takeover) is not listed.
veil_orgsThe organisations the person belongs to, accepted by both sides: [{n, name, role}].
veil_keys{devices: [{key, alg}], external: [{type, fp}]}: the active device keys, and the fingerprints of the public keys from elsewhere (SSH, OpenPGP, minisign…) that the card certifies.
amr["veil-key"]: the login was a device key's signature.
auth_timeWhen that signature was checked.
sidThe login's id, l<number>.

An ID token from a test server, with its addresses written as they are on veil.rip:

json
{"iss": "https://veil.rip", "sub": "1", "aud": "veil_app_62bab7367bb583e2157490ae2cb65d4d", "azp": "veil_app_62bab7367bb583e2157490ae2cb65d4d",
 "name": "Elena Ferraro", "preferred_username": "u1", "profile": "https://veil.rip/u/1",
 "veil_version": 1, "veil_log": {"origin": "log.veil.rip/staging-1", "index": 0, "size": 10, "root": "JkW148VFq8g8MXHJArRQ/38q3ix5BrhSjAmHSAW8+P4="},
 "veil_good_standing": true, "veil_standing_reasons": [],
 "iat": 1793525884, "exp": 1793526484, "auth_time": 1793525884, "nonce": "ozFe35gs4K2fjVX7jcR81jTMf5X8CNR6X12cZSMuIi0",
 "amr": ["veil-key"], "sid": "l9",
 "veil_consent": {"text": "veil-rubrica:oidc-consent:v1\n{\"card\":1,\"challenge\":\"OpmVnrcB…\",…,\"v\":1}",
                  "signature": "ac8e0fb6…026a06", "key": "120a568ad456a6dfdc16d48d1ab91af826180545a13123a49134968446f57195", "alg": "ed25519"}}

Tokens#

TokenFormLifetime
CodeOpaque, single use10 minutes. Used twice, it ends every token of its login.
Access tokenOpaque, veil_at_…, a Bearer token for UserInfo10 minutes
Refresh tokenOpaque, veil_rt_…, bound to your app30 days, a new one at every use, and at most 90 days after the login
ID tokenJWT signed by Veil, with kid10 minutes

Veil stores every code and token as a SHA-256 hash. The token endpoint takes application/x-www-form-urlencoded, each parameter once.

  • grant_type=authorization_code needs code, redirect_uri and code_verifier.
  • grant_type=refresh_token needs refresh_token, and scope may narrow the scopes as long as it keeps openid. The answer carries a new refresh token, and the old one stops working. A refresh token used a second time ends the whole login, because someone copied it.
  • A refresh fails with invalid_grant once the card is closed or suspended, once the device key that signed the login is revoked from the card, and once the person revokes your app from their account page.
  • client_secret_basic is HTTP Basic with the client id and secret. For private_key_jwt, the client assertion has iss and sub set to your client id, aud set to https://veil.rip or the token endpoint's address, exp at most 5 minutes ahead, and a jti of 8 to 256 characters that works once. Your key's type fixes its algorithm. A public app sends client_id.

The ID token is signed with EdDSA unless your app registered ES256. Many libraries expect RS256 when nothing is configured, so set id_token_signed_response_alg in your client's configuration to the algorithm you registered.

The JWKS holds both keys. Each key has a single alg, and its kid is its RFC 7638 thumbprint. Keys rotate with a 30-day overlap, so a replaced key stays in the JWKS for 30 days. Cache the JWKS for minutes, and fetch it again when a token names a kid you do not have.

UserInfo#

GET or POST /oidc/userinfo with Authorization: Bearer <access token> returns the claims of the token's scopes from the card's state now: its current version, the log's last verified checkpoint, its standing, and its accounts, organisations and keys. A revoked key or a suspension shows at once.

For a suspended card the answer is 401 with WWW-Authenticate: Bearer error="veil_suspended" and {"error": "veil_suspended"}. A closed card's tokens answer invalid_token, because closing a card revokes every token it gave.

Revocation and introspection#

POST /oidc/revoke (RFC 7009) takes token, authenticated as at the token endpoint. Revoking a refresh token ends every token of its login. A token that is not your app's, or not a token at all, still gets 200.

POST /oidc/introspect (RFC 7662) lets a confidential app ask about its own tokens. The answer is {active, scope, client_id, sub, aud, iss, iat, exp, token_type, auth_time, veil_good_standing}, or {"active": false}.

Logout#

Veil keeps no session to end, because a login is a signature. GET /oidc/logout?id_token_hint=<an ID token>&post_logout_redirect_uri=…&state=… revokes the tokens of that login (its sid) and sends the browser back to the logout URI if your app registered that exact address. Otherwise a page tells the person they are signed out of your app.

Errors#

The OAuth endpoints answer errors in their protocol's shape, {"error": "invalid_grant", "error_description": "…"}, with Cache-Control: no-store. Rate limits keep that shape on those endpoints too: 429 with Retry-After and {"error": "rate_limited", "error_description": "…"}.

The limits are 1200 calls a minute per IP on the back channel (token, revocation, introspection, UserInfo), 600 a minute per app once authenticated, 60 authorization requests a minute per IP, and 10 registrations an hour per IP. The consent page's JSON and the management APIs use the directory's envelope, {"error": {"code", "message"}}.

What to verify#

Every app#

These are the standard OpenID Connect checks. A maintained library does them for you, though many need step 3 switched on.

  1. state and iss in the authorization response are yours and https://veil.rip.

    state ties the answer to the browser that started the sign-in, so another site cannot slip its own code into your callback. iss says which provider answered, so an app that talks to several providers cannot be handed one provider's code as another's (RFC 9207).

  2. The code goes back with your PKCE verifier.

    Only your server knows the verifier behind the challenge it sent, so a code that leaks on the way (a log, a referrer, another app on the same device) is useless to whoever picks it up.

  3. The ID token's signature verifies with the JWKS key its kid names, with that key's algorithm. Never let the header choose the algorithm, and never accept none or an HMAC algorithm. Many libraries skip this check for an ID token that comes straight from the token endpoint, because OpenID Connect lets TLS stand in for it there. openid-client skips it unless you call enableNonRepudiationChecks. Turn the check on.

    With the check on, the token you keep proves what Veil said about who signed in. Taking the algorithm from the key, and never from the header, stops a forged token that names none or uses the public key as an HMAC secret.

  4. iss is https://veil.rip, aud (and azp) is your client id, exp is in the future, iat is not, and nonce is yours.

    A valid signature only says that Veil made the token. These claims say that it was made for your app, for this sign-in, and recently, so a token issued to another app or replayed from an earlier login fails here.

  5. Identify the person by sub. Show name with the card's number or address.

    sub is the card's number and stays the same for the card's whole life. Names are not unique in Veil, and an alias can change or be released, so neither can stand for the person.

  6. If your app cares about standing, read veil_good_standing, and ask UserInfo again before an important action.

    The ID token describes the card at the moment of the login. UserInfo reflects the card now, so a suspension, a takeover or a revoked key shows there at once.

Veil's edge: the login checked against the log#

The checks above trust the server to say who signed in. With the veil scope you can check the person's own signature and the public log instead.

  1. Parse veil_consent.text. The first line is veil-rubrica:oidc-consent:v1, and the rest is canonical JSON with v, iss, card, key, client, redirect, scopes, nonce, state, challenge, request and time.

    The first line says what kind of text the key signed, so a signature the key made for anything else in Veil cannot pass for a login consent.

  2. Verify veil_consent.signature (hex Ed25519) over the exact bytes of text with veil_consent.key.

    This signature comes from the person's device key. The server has no copy of that key and cannot make it.

  3. Check that challenge is the S256 of your own PKCE verifier, nonce is your nonce, state is the SHA-256 (hex) of your state, client is your client id, iss is the issuer, key is veil_consent.key and card is sub. veil_consent.alg is ed25519.

    Your server made the verifier for this login alone, so a consent that names its challenge was signed for this login and no other.

  4. Ask the server where to look. GET /v1/keys/{key} gives the card and the version that added the key (addedIn). GET /v1/identities/{sub} gives the card's token, which is how the log names the card, and its current version. veil_version must lie between the two.

    Nothing in this step is proof yet. It tells you which entries of the log to read in the next one.

  5. Check it in the log, for every version from addedIn to the current one. GET /v1/identities/{sub}/versions/{v}/proof gives the log entry, its index, the inclusion proof and a checkpoint. The entry's label must name the card (identity is the token), the version (n) and the version before it (prev is the previous proof's labelHash). The leaf hash is SHA-256(0x00 || entry), and the inclusion proof (RFC 6962) must lead to the checkpoint's root. The first of those versions must add the key as a device (label.auth holds {"op": "addKey", "key": <key>, "role": "device"}), no later one may revoke it (revokeKey), and the proof of veil_version must be at veil_log.index. A card rarely has many versions after a key came in, and you can keep the ones you checked for next time.

    Until here only the server said it. Now the public log says that the key was added to this card as a device, that no later version revoked it, and that the login's version is the entry Veil named. A revoked device, another card's entry passed off as this one's, or a moved version fails here.

  6. Check each checkpoint against a copy of the log's policy that you keep with your app (https://log.veil.rip/<log>/policy, the log of veil_log.origin): the log's own signature, cosignatures from enough of the policy's witnesses to meet its quorum, and a size of at least veil_log.size.

    The witnesses are independent operators, so a server that showed you a version of the card nobody else sees would be caught.

Veil's conformance test runs the Node and Python examples below against a server that lies. They refuse a consent signed by a device the card revoked, another card's log entry passed off as this one's, a revoked key the server calls active, and a login version moved.

Honest limits#

  • The log knows a card by its token, not by its number. Going from sub to the token is the server's word.
  • Step 5 does not check the signature on each entry. The log takes an entry only when a key with authority on the card signed it, and anyone can check those signatures again by replaying the card's authority chain (/v1/identities/{n}/authority), as the card pages do in the browser with veil-v2.js. That is the fully independent check.
  • A server that hides the card's newest versions, and says the card is still at an older one, is not caught by these steps. That check belongs to the index of the log's format 2.
  • The accounts, organisations and keys in the claims are the server's reading of the card. Each of them is also on the card's signed state, which a card page checks in the reader's browser.
  • An app that checks only the OpenID Connect part trusts the server for the login itself. A server under someone else's control could issue a token for any card to such an app. An app that checks veil_consent against its own PKCE verifier cannot be fooled that way without the person's device key. The key never leaves the browser, but the pages that use it come from the same server. A server under someone else's control could send the browser a page that signs a consent for an attacker's login the next time the person opens Veil, and the chat's hidden key frame is such a page too. Any key that a web page holds has this limit.
  • The person sees every login on their account page, with its signed text and signature. The page shows them and does not check them. Signed login receipts in the public log, so that a person could detect a token issued without them even to an app that does not check, are an open point for later.
  • A server can still refuse to answer, or serve an older checkpoint. Compare the witnesses' cosignature times with the clock if freshness matters to you.

Example: Node#

The flow uses openid-client (pinned to 6.8.8 in Veil's own conformance test), and the Veil checks use node:crypto. The PKCE verifier, the state and the nonce stay on your server, keyed by a cookie. It needs Node 20 or later.

javascript
// Sign in with Veil in a Node server: openid-client for the OpenID Connect flow, node:crypto for the Veil checks.
// npm install openid-client@6.8.8      node veil-signin.mjs
import * as oidc from "openid-client";
import { createHash, createPublicKey, verify, randomBytes } from "node:crypto";
import { createServer } from "node:http";

const ISSUER = new URL(process.env.VEIL_ISSUER ?? "https://veil.rip");
const CLIENT_ID = process.env.VEIL_CLIENT_ID;          // from /developers
const CLIENT_SECRET = process.env.VEIL_CLIENT_SECRET;  // shown once, keep it in your secret store
const REDIRECT = process.env.VEIL_REDIRECT ?? "http://localhost:3000/callback";
// A copy of the log's policy, kept with your app: the log's key and its witnesses (https://log.veil.rip/<log>/policy).
const POLICY = process.env.VEIL_POLICY_TEXT;

const sha256 = (b) => createHash("sha256").update(b).digest();
const ed25519 = (hex) => createPublicKey({ key: { kty: "OKP", crv: "Ed25519", x: Buffer.from(hex, "hex").toString("base64url") }, format: "jwk" });

// RFC 6962: the root an inclusion proof leads to, or null.
function rootFrom(leaf, index, size, path) {
  if (index >= size) return null;
  let fn = BigInt(index), sn = BigInt(size) - 1n, r = leaf;
  for (const p of path) {
    if (sn === 0n) return null;
    if ((fn & 1n) === 1n || fn === sn) {
      r = sha256(Buffer.concat([Buffer.from([1]), p, r]));
      if ((fn & 1n) === 0n) while ((fn & 1n) === 0n && fn !== 0n) { fn >>= 1n; sn >>= 1n; }
    } else r = sha256(Buffer.concat([Buffer.from([1]), r, p]));
    fn >>= 1n; sn >>= 1n;
  }
  return sn === 0n ? r : null;
}

// A C2SP note key "name+hash+base64(type || key)": type 0x01 for the log, 0x04 for a witness.
function noteKey(vkey) {
  const i = vkey.indexOf("+"), j = vkey.indexOf("+", i + 1);
  const name = vkey.slice(0, i), raw = Buffer.from(vkey.slice(j + 1), "base64");
  const id = sha256(Buffer.concat([Buffer.from(name + "\n"), raw])).subarray(0, 4);
  if (id.toString("hex") !== vkey.slice(i + 1, j)) throw new Error(`bad vkey ${name}`);
  return { name, id, key: ed25519(raw.subarray(1).toString("hex")) };
}

// The checkpoint's log signature, and how many witnesses of the policy's quorum group cosigned it.
function checkCheckpoint(note, policyText) {
  const text = note.slice(0, note.indexOf("\n\n") + 1);
  const sigs = note.slice(text.length + 1).split("\n").filter((l) => l.startsWith("— ")).map((l) => l.slice(2).split(" "));
  const lines = policyText.split("\n").map((l) => l.trim().split(/\s+/)).filter((f) => f[0] && !f[0].startsWith("#"));
  const log = noteKey(lines.find((f) => f[0] === "log")[1]);
  const witnesses = new Map(lines.filter((f) => f[0] === "witness").map((f) => [f[1], noteKey(f[2])]));
  const group = lines.find((f) => f[0] === "group" && f[1] === lines.find((q) => q[0] === "quorum")[1]);
  const members = group.slice(3);
  const needed = group[2] === "all" ? members.length : group[2] === "any" ? 1 : Number(group[2]);
  let logSigned = false; const cosigned = new Set();
  for (const [name, b64] of sigs) {
    const blob = Buffer.from(b64, "base64");
    if (name === log.name && blob.subarray(0, 4).equals(log.id)) logSigned ||= verify(null, Buffer.from(text), log.key, blob.subarray(4));
    for (const [id, w] of witnesses) {
      if (name !== w.name || blob.length !== 76 || !blob.subarray(0, 4).equals(w.id)) continue;
      const msg = `cosignature/v1\ntime ${blob.readBigUInt64BE(4)}\n${text}`;
      if (verify(null, Buffer.from(msg), w.key, blob.subarray(12))) cosigned.add(id);
    }
  }
  const [origin, size, root] = text.split("\n");
  return { logSigned, quorum: members.filter((m) => cosigned.has(m)).length >= needed, origin, size: Number(size), root };
}

/**
 * Veil's own checks on the claims of a verified ID token (scope "veil"): the person's device key signed this very login
 * (your PKCE challenge, your nonce, your client id), and the public log, not the server, says the key is on the card: the
 * version that added it, every version after it up to the card's current one (none revokes it), and the version the login
 * was checked against, each one this card's entry, in one chain, under a checkpoint the log signed and a quorum of witnesses
 * cosigned.
 */
export async function verifyVeil(claims, { verifier, nonce, state, clientId, issuer, policyText }) {
  const c = claims.veil_consent;
  if (!c || c.alg !== "ed25519" || !c.text.startsWith("veil-rubrica:oidc-consent:v1\n")) throw new Error("no signed consent");
  if (!verify(null, Buffer.from(c.text, "utf8"), ed25519(c.key), Buffer.from(c.signature, "hex"))) throw new Error("consent signature");
  const s = JSON.parse(c.text.slice(c.text.indexOf("\n") + 1));
  if (s.challenge !== sha256(verifier).toString("base64url")) throw new Error("not your PKCE challenge");
  if (s.client !== clientId || s.nonce !== nonce || s.iss !== issuer || s.key !== c.key || String(s.card) !== claims.sub) throw new Error("not your login");
  if (s.state !== sha256(state).toString("hex")) throw new Error("not your state");
  const get = async (path) => { const r = await fetch(issuer + path); if (!r.ok) throw new Error(`${path}: ${r.status}`); return r.json(); };
  // The server says where to look: the version that added the key, the card's id in the log (its token), its current version.
  const key = await get(`/v1/keys/${c.key}`);
  const card = await get(`/v1/identities/${claims.sub}`);
  if (String(key.identity?.n) !== claims.sub || key.role !== "device") throw new Error("key not on the card");
  if (!(key.addedIn <= claims.veil_version && claims.veil_version <= card.version)) throw new Error("the login's version is not one where the key was on the card");
  // The log says the rest.
  let prev = null;
  for (let v = key.addedIn; v <= card.version; v++) {
    const proof = await get(`/v1/identities/${claims.sub}/versions/${v}/proof`);
    const label = JSON.parse(JSON.parse(proof.entry).label);
    if (label.identity !== card.token || label.n !== v || (prev !== null && label.prev !== prev)) throw new Error(`the log's entry is not version ${v} of this card`);
    const leaf = sha256(Buffer.concat([Buffer.from([0]), Buffer.from(proof.entry, "utf8")]));
    const root = rootFrom(leaf, proof.logIndex, proof.checkpoint.size, proof.inclusion.map((h) => Buffer.from(h, "hex")));
    if (!root || root.toString("base64") !== proof.checkpoint.root) throw new Error("inclusion proof");
    const cp = checkCheckpoint(proof.checkpoint.note, policyText);
    if (!cp.logSigned || !cp.quorum || cp.origin !== claims.veil_log.origin || cp.root !== proof.checkpoint.root || cp.size !== proof.checkpoint.size) throw new Error("checkpoint");
    if (cp.size < claims.veil_log.size) throw new Error("an older checkpoint than the login's");
    const auth = label.auth ?? [];
    if (v === key.addedIn && !auth.some((op) => op.op === "addKey" && op.key === c.key && op.role === "device")) throw new Error("the log does not add this key here");
    if (v > key.addedIn && auth.some((op) => op.op === "revokeKey" && op.key === c.key)) throw new Error("the log revokes this key");
    if (v === claims.veil_version && proof.logIndex !== claims.veil_log.index) throw new Error("another log entry");
    prev = proof.labelHash;
  }
  return { card: Number(claims.sub), version: claims.veil_version, goodStanding: claims.veil_good_standing };
}

// The flow, with the PKCE verifier, state and nonce kept server-side per browser (a cookie names the session).
if (import.meta.url === `file://${process.argv[1]}`) {
  // enableNonRepudiationChecks: without it openid-client does not check the ID token's signature (TLS stands in for it).
  const config = await oidc.discovery(ISSUER, CLIENT_ID, { id_token_signed_response_alg: "EdDSA" }, oidc.ClientSecretBasic(CLIENT_SECRET),
    { execute: [oidc.enableNonRepudiationChecks] });
  const sessions = new Map();
  createServer(async (req, res) => {
    const url = new URL(req.url, REDIRECT);
    const sid = /sid=([A-Za-z0-9_-]+)/.exec(req.headers.cookie ?? "")?.[1];
    if (url.pathname === "/login") {
      const s = { verifier: oidc.randomPKCECodeVerifier(), state: oidc.randomState(), nonce: oidc.randomNonce() };
      const id = randomBytes(16).toString("base64url");
      sessions.set(id, s);
      const to = oidc.buildAuthorizationUrl(config, { redirect_uri: REDIRECT, scope: "openid profile veil veil:accounts", state: s.state, nonce: s.nonce,
        code_challenge: await oidc.calculatePKCECodeChallenge(s.verifier), code_challenge_method: "S256" });
      res.writeHead(302, { Location: to.href, "Set-Cookie": `sid=${id}; HttpOnly; Secure; SameSite=Lax; Path=/` }).end();
    } else if (url.pathname === "/callback" && sessions.has(sid)) {
      const s = sessions.get(sid); sessions.delete(sid);
      try {
        // state, iss (RFC 9207), PKCE, the ID token's signature (with enableNonRepudiationChecks), iss, aud, exp and nonce.
        const tokens = await oidc.authorizationCodeGrant(config, url, { pkceCodeVerifier: s.verifier, expectedState: s.state, expectedNonce: s.nonce });
        const claims = tokens.claims();
        const veil = await verifyVeil(claims, { ...s, clientId: CLIENT_ID, issuer: config.serverMetadata().issuer, policyText: POLICY });
        res.writeHead(200, { "Content-Type": "text/plain" }).end(`Signed in as card ${veil.card} (${claims.name}), checked against the log\n`);
      } catch (e) { res.writeHead(400, { "Content-Type": "text/plain" }).end(`Sign-in refused: ${e.message}\n`); }
    } else res.writeHead(404).end();
  }).listen(3000);
}

Example: Python#

Python's standard library has no Ed25519 or ECDSA, so this example uses cryptography (pyca), a widely packaged library that has both. Everything else comes from the standard library. Run the flow in your web framework: start_login gives the redirect and the values to keep in the session, finish_login handles the callback, and verify_veil adds the checks against the log.

python
"""Sign in with Veil from Python: the standard library for HTTP, JSON and hashes, and pyca/cryptography for the two
signature schemes (Ed25519, ECDSA P-256), which the standard library does not have.   pip install cryptography"""
import base64, hashlib, json, secrets, time, urllib.parse, urllib.request
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
from cryptography.hazmat.primitives.asymmetric.utils import encode_dss_signature

ISSUER = "https://veil.rip"


def b64url(data: str) -> bytes:
    return base64.urlsafe_b64decode(data + "=" * (-len(data) % 4))


def get_json(url: str, data: bytes | None = None, headers: dict | None = None):
    req = urllib.request.Request(url, data=data, headers={"Accept": "application/json", **(headers or {})})
    with urllib.request.urlopen(req, timeout=15) as r:
        return json.load(r)


def ed25519_ok(key: bytes, sig: bytes, msg: bytes) -> bool:
    try:
        Ed25519PublicKey.from_public_bytes(key).verify(sig, msg)
        return True
    except (InvalidSignature, ValueError):
        return False


# ---------- the flow ----------

def start_login(discovery: dict, client_id: str, redirect_uri: str) -> tuple[str, dict]:
    """The authorization URL, and what to keep server-side for the callback (never in the URL or a readable cookie)."""
    session = {"verifier": secrets.token_urlsafe(48), "state": secrets.token_urlsafe(24), "nonce": secrets.token_urlsafe(24)}
    challenge = base64.urlsafe_b64encode(hashlib.sha256(session["verifier"].encode()).digest()).rstrip(b"=").decode()
    query = urllib.parse.urlencode({"response_type": "code", "client_id": client_id, "redirect_uri": redirect_uri,
                                    "scope": "openid profile veil veil:accounts", "state": session["state"], "nonce": session["nonce"],
                                    "code_challenge": challenge, "code_challenge_method": "S256"})
    return f"{discovery['authorization_endpoint']}?{query}", session


def finish_login(discovery: dict, client_id: str, secret: str, redirect_uri: str, callback_query: str, session: dict) -> dict:
    """The callback: state and iss first (RFC 9207), then the code for tokens, then the ID token's checks."""
    q = dict(urllib.parse.parse_qsl(callback_query))
    if q.get("state") != session["state"] or q.get("iss") != discovery["issuer"]:
        raise ValueError("state or iss is not this sign-in's")
    if "error" in q:
        raise ValueError(f"the sign-in did not happen: {q['error']}")
    form = urllib.parse.urlencode({"grant_type": "authorization_code", "code": q["code"], "redirect_uri": redirect_uri,
                                   "code_verifier": session["verifier"]}).encode()
    basic = base64.b64encode(f"{urllib.parse.quote(client_id, safe='')}:{urllib.parse.quote(secret, safe='')}".encode()).decode()
    tokens = get_json(discovery["token_endpoint"], form, {"Authorization": f"Basic {basic}", "Content-Type": "application/x-www-form-urlencoded"})
    return verify_id_token(discovery, tokens["id_token"], client_id, session["nonce"])


def verify_id_token(discovery: dict, token: str, client_id: str, nonce: str) -> dict:
    """The JWS checked with the key its kid names, with that key's one algorithm (never the header's choice), then the claims."""
    head_b64, body_b64, sig_b64 = token.split(".")
    header, claims, sig = json.loads(b64url(head_b64)), json.loads(b64url(body_b64)), b64url(sig_b64)
    jwk = next(k for k in get_json(discovery["jwks_uri"])["keys"] if k["kid"] == header["kid"])
    signed = f"{head_b64}.{body_b64}".encode()
    if header["alg"] != jwk["alg"]:
        raise ValueError("the token's alg is not its key's")
    if jwk["alg"] == "EdDSA":
        ok = ed25519_ok(b64url(jwk["x"]), sig, signed)
    elif jwk["alg"] == "ES256":
        key = ec.EllipticCurvePublicNumbers(int.from_bytes(b64url(jwk["x"])), int.from_bytes(b64url(jwk["y"])), ec.SECP256R1()).public_key()
        try:
            key.verify(encode_dss_signature(int.from_bytes(sig[:32]), int.from_bytes(sig[32:])), signed, ec.ECDSA(hashes.SHA256()))
            ok = True
        except InvalidSignature:
            ok = False
    else:
        ok = False
    now = time.time()
    if not ok or claims["iss"] != discovery["issuer"] or claims["aud"] != client_id or claims.get("azp", client_id) != client_id:
        raise ValueError("ID token signature, issuer or audience")
    if claims["exp"] < now - 30 or claims["iat"] > now + 30 or claims.get("nonce") != nonce:
        raise ValueError("ID token expired, from the future, or not this sign-in's nonce")
    return claims


# ---------- Veil's own checks ----------

def root_from(leaf: bytes, index: int, size: int, path: list[bytes]) -> bytes | None:
    """RFC 6962: the root an inclusion proof leads to."""
    if index >= size:
        return None
    fn, sn, r = index, size - 1, leaf
    for p in path:
        if sn == 0:
            return None
        if fn & 1 or fn == sn:
            r = hashlib.sha256(b"\x01" + p + r).digest()
            while not fn & 1 and fn != 0:
                fn, sn = fn >> 1, sn >> 1
        else:
            r = hashlib.sha256(b"\x01" + r + p).digest()
        fn, sn = fn >> 1, sn >> 1
    return r if sn == 0 else None


def note_key(vkey: str) -> tuple[str, bytes, bytes]:
    name, digest, b64 = vkey.split("+", 2)
    raw = base64.b64decode(b64)
    key_id = hashlib.sha256(name.encode() + b"\n" + raw).digest()[:4]
    if key_id.hex() != digest:
        raise ValueError(f"bad vkey {name}")
    return name, key_id, raw[1:]


def check_checkpoint(note: str, policy_text: str) -> dict:
    """The log's signature on the checkpoint, and whether the policy's quorum of witnesses cosigned it."""
    text = note[: note.index("\n\n") + 1]
    sigs = [line[2:].split(" ") for line in note[len(text) + 1:].split("\n") if line.startswith("— ")]
    lines = [line.split() for line in policy_text.splitlines() if line.strip() and not line.startswith("#")]
    log_name, log_id, log_key = note_key(next(f for f in lines if f[0] == "log")[1])
    witnesses = {f[1]: note_key(f[2]) for f in lines if f[0] == "witness"}
    quorum_group = next(f for f in lines if f[0] == "quorum")[1]
    group = next(f for f in lines if f[0] == "group" and f[1] == quorum_group)
    members, rule = group[3:], group[2]
    needed = len(members) if rule == "all" else 1 if rule == "any" else int(rule)
    log_signed, cosigned = False, set()
    for name, sig_b64 in sigs:
        blob = base64.b64decode(sig_b64)
        if name == log_name and blob[:4] == log_id and ed25519_ok(log_key, blob[4:], text.encode()):
            log_signed = True
        for wid, (wname, wkey_id, wkey) in witnesses.items():
            if name == wname and len(blob) == 76 and blob[:4] == wkey_id:
                msg = f"cosignature/v1\ntime {int.from_bytes(blob[4:12])}\n{text}".encode()
                if ed25519_ok(wkey, blob[12:], msg):
                    cosigned.add(wid)
    origin, size, root = text.split("\n")[:3]
    return {"log_signed": log_signed, "quorum": len(cosigned & set(members)) >= needed, "origin": origin, "size": int(size), "root": root}


def verify_veil(claims: dict, session: dict, client_id: str, issuer: str, policy_text: str) -> dict:
    """The person's device key signed this very login, and the public log, not the server, says the key is on the card: the
    version that added it, every version after it up to the card's current one (none revokes it), the login's version."""
    c = claims["veil_consent"]
    if c.get("alg") != "ed25519" or not c["text"].startswith("veil-rubrica:oidc-consent:v1\n"):
        raise ValueError("not a consent")
    if not ed25519_ok(bytes.fromhex(c["key"]), bytes.fromhex(c["signature"]), c["text"].encode()):
        raise ValueError("consent signature")
    s = json.loads(c["text"].split("\n", 1)[1])
    challenge = base64.urlsafe_b64encode(hashlib.sha256(session["verifier"].encode()).digest()).rstrip(b"=").decode()
    if s["challenge"] != challenge or s["nonce"] != session["nonce"] or s["client"] != client_id or s["iss"] != issuer \
            or s["key"] != c["key"] or str(s["card"]) != claims["sub"]:
        raise ValueError("this consent is not for this login")
    if s["state"] != hashlib.sha256(session["state"].encode()).hexdigest():
        raise ValueError("not this login's state")
    # The server says where to look: the version that added the key, the card's id in the log (its token), its current version.
    key = get_json(f"{issuer}/v1/keys/{c['key']}")
    card = get_json(f"{issuer}/v1/identities/{claims['sub']}")
    if str(key["identity"]["n"]) != claims["sub"] or key["role"] != "device":
        raise ValueError("the key is not on the card")
    if not key["addedIn"] <= claims["veil_version"] <= card["version"]:
        raise ValueError("the login's version is not one where the key was on the card")
    # The log says the rest.
    prev = None
    for v in range(key["addedIn"], card["version"] + 1):
        proof = get_json(f"{issuer}/v1/identities/{claims['sub']}/versions/{v}/proof")
        label = json.loads(json.loads(proof["entry"])["label"])
        if label["identity"] != card["token"] or label["n"] != v or (prev is not None and label["prev"] != prev):
            raise ValueError(f"the log's entry is not version {v} of this card")
        leaf = hashlib.sha256(b"\x00" + proof["entry"].encode()).digest()
        root = root_from(leaf, proof["logIndex"], proof["checkpoint"]["size"], [bytes.fromhex(h) for h in proof["inclusion"]])
        if root is None or base64.b64encode(root).decode() != proof["checkpoint"]["root"]:
            raise ValueError("inclusion proof")
        cp = check_checkpoint(proof["checkpoint"]["note"], policy_text)
        if not (cp["log_signed"] and cp["quorum"] and cp["origin"] == claims["veil_log"]["origin"] and cp["root"] == proof["checkpoint"]["root"]):
            raise ValueError("checkpoint signatures")
        if cp["size"] < claims["veil_log"]["size"]:
            raise ValueError("an older checkpoint than the login's")
        auth = label.get("auth", [])
        if v == key["addedIn"] and not any(op.get("op") == "addKey" and op.get("key") == c["key"] and op.get("role") == "device" for op in auth):
            raise ValueError("the log does not add this key here")
        if v > key["addedIn"] and any(op.get("op") == "revokeKey" and op.get("key") == c["key"] for op in auth):
            raise ValueError("the log revokes this key")
        if v == claims["veil_version"] and proof["logIndex"] != claims["veil_log"]["index"]:
            raise ValueError("another log entry")
        prev = proof["labelHash"]
    return {"card": int(claims["sub"]), "version": claims["veil_version"], "good_standing": claims["veil_good_standing"]}

Veil's conformance test (server/browser-test/oidc-conformance.mjs, OidcConformanceTest) takes both examples from docs/oidc.md in the repository, which holds the same code as this page, and runs them against a Veil server with a real log and witnesses, on honest sign-ins and on tampered ones.

Reference#

Each endpoint has its own page with its parameters, its answers and its errors.

  • GET
    Read the OpenID configurationThe discovery document of Sign in with Veil: issuer https://veil.rip, the endpoints, scopes, algorithms and methods.
    /.well-known/openid-configuration
  • GET
    Read the provider's signing keysThe public keys that sign ID tokens: one Ed25519 (EdDSA) and one P-256 (ES256), plus the previous pair for 30 days after a rotation.
    /oidc/jwks
  • GET
    Start a sign-inThe authorization endpoint. It checks the app and its redirect URI, keeps the request 30 minutes and shows the consent page, where the person's device key signs.
    /oidc/authorize
  • POST
    Exchange a code or a refresh tokenThe token endpoint: the code with its PKCE verifier, or a refresh token, for an access token, an ID token and a new refresh token.
    /oidc/token
  • GET
    Read the person's claimsThe same claims as the ID token, from the card's state now: a revoked key, a takeover or a suspension changes the answer at once.
    /oidc/userinfo
  • POST
    Revoke a tokenRFC 7009: the app revokes one of its tokens. A refresh token ends every token of its login.
    /oidc/revoke
  • POST
    Introspect a tokenRFC 7662, for confidential apps: whether one of the app's own tokens is active now.
    /oidc/introspect
  • POST
    Register an app without a cardRFC 7591, open: an app no card answers for. The consent page calls it unverified, and it is gone 30 days after registration.
    /oidc/register
  • GET
    Sign out of an appRP-initiated logout. With id_token_hint the tokens of that login end; the browser goes back only to an address the app registered exactly.
    /oidc/logout
  • GET
    List a card's appsThe apps a card registered for Sign in with Veil, without their secrets.
    /v1/developer/accounts/{n}/oidc-clients
  • POST
    Register an appA person or an organisation owner signs the registration. A confidential app with client_secret_basic gets its secret once.
    /v1/developer/accounts/{n}/oidc-clients
  • GET
    List a person's signed-in appsThe apps the card signed in to, with the last five logins of each and the consent its device key signed, so the owner can audit every login.
    /v1/identities/{n}/oidc-apps