veil

Accedi con Veil

Fai entrare le persone nella tua app con la loro scheda Veil. L'accesso è una firma della loro chiave di dispositivo, e puoi controllarla da solo sul registro pubblico.

Veil è la rubrica pubblica delle identità crittografiche. Ogni persona o organizzazione ha una scheda che lega le sue chiavi agli account con cui la gente la conosce (Discord, GitHub, email, un dominio, il Fediverso e altri), con legami certificati. Accedi con Veil è un provider OpenID Connect costruito su questa rubrica. Quando qualcuno entra nella tua app, l'identità che ricevi è la sua scheda, e la prova è una firma fatta da una chiave che non ha mai lasciato il suo browser.

Se costruisci l'app dall'altra parte dell'accesso, qui trovi come registrarla, il flusso, i claim e i token, e cosa puoi controllare da solo sul registro pubblico invece di fidarti della parola del server. L'issuer è https://veil.rip.

Come funziona un accesso#

  1. La tua app manda il browser a https://veil.rip/oidc/authorize con i soliti parametri del flusso del codice e il code_challenge di PKCE.
  2. La pagina di consenso mostra il nome della tua app, il suo sito, cosa chiede e la scheda che questo browser tiene. Nel browser una scheda è una chiave di dispositivo: una chiave Ed25519 creata lì e tenuta in IndexedDB, che nessuna pagina può leggere o esportare. In nessun momento c'è una password.
  3. Quando la persona accetta, il browser firma un testo di consenso con la chiave di dispositivo. Il testo nomina la tua app, il tuo redirect URI, gli scope, il tuo nonce, un hash del tuo state, il tuo code_challenge PKCE e l'ora.
  4. Veil controlla quella firma con le chiavi di dispositivo attive della scheda e con la versione corrente della scheda nel registro pubblico verificato. Tiene il consenso firmato insieme all'accesso e rimanda il browser da te con code, state e iss.
  5. Il tuo server scambia il codice con un token di accesso, un refresh token e un token ID firmato da Veil.

Se il browser non ha una scheda, la pagina offre le solite strade: crearne una, o aggiungere questo browser a una scheda che esiste già con il file di backup o con un altro dispositivo. Poi torna alla tua richiesta, che per questo aspetta 30 minuti.

La persona approva la tua app una volta. L'accesso dopo, con gli stessi scope, non chiede un clic, ma il browser firma comunque il consenso, quindi ogni accesso è una firma nuova. Per un'app nativa che torna sull'interfaccia di loopback (http://127.0.0.1, [::1] o localhost) la pagina chiede ogni volta, perché qualunque programma sullo stesso computer potrebbe ascoltare a quell'indirizzo (RFC 8252, 8.6).

Sulla pagina del suo account la persona vede le app in cui è entrata, ogni accesso con il suo consenso firmato, e lì può revocare ciascuna di quelle app.

Endpoint#

Il documento di discovery, GET https://veil.rip/.well-known/openid-configuration, elenca ogni endpoint. Ogni nome qui sotto apre la sua pagina nel riferimento.

EndpointIndirizzo
Discoveryhttps://veil.rip/.well-known/openid-configuration
Autorizzazionehttps://veil.rip/oidc/authorize
Tokenhttps://veil.rip/oidc/token
UserInfohttps://veil.rip/oidc/userinfo (GET o POST)
JWKShttps://veil.rip/oidc/jwks
Registrazione (RFC 7591)https://veil.rip/oidc/register
Revoca (RFC 7009)https://veil.rip/oidc/revoke
Introspezione (RFC 7662)https://veil.rip/oidc/introspect
Fine della sessionehttps://veil.rip/oidc/logout

Il provider supporta:

  • Solo il response type code, con response mode query.
  • PKCE con S256 per ogni app, pubblica o confidenziale.
  • Il parametro iss in ogni risposta di autorizzazione (RFC 9207).
  • Token ID firmati con EdDSA (Ed25519) o ES256.
  • L'autenticazione del client con client_secret_basic o private_key_jwt, e none per le app pubbliche.

I request object, request_uri e il parametro claims non sono supportati.

Il documento di discovery e il JWKS rispondono alle richieste da altre origini, e nient'altro sotto /oidc/ lo fa. Per questo un'app nel browser ha bisogno di un piccolo backend suo che tenga i token e parli con l'endpoint dei token (il modello backend for frontend). Sotto /oidc/ le chiavi API di Veil non servono: lì l'intestazione Authorization porta il segreto della tua app o il token di accesso di una persona.

Registrare un'app#

Da /developers#

Su https://veil.rip/developers una scheda registra le sue app, con la firma della chiave di dispositivo di quel browser, come tutto il resto della scheda. Le app di un'organizzazione le registra uno dei suoi proprietari. Una scheda può avere 20 app.

CampoRegole
NomeDa 1 a 60 caratteri. La pagina di consenso lo mostra.
SitoUn URL https di al più 300 caratteri. La pagina di consenso ne mostra l'host.
Redirect URIDa 1 a 10. Ognuno è https, o http sull'interfaccia di loopback (127.0.0.1, [::1], localhost) per un'app nativa, senza frammento e senza informazioni dell'utente, in al più 512 caratteri ASCII stampabili.
Redirect URI di logoutDa 0 a 10, con le stesse regole.
Tipoconfidential (un server, con client_secret_basic o private_key_jwt) o public (un'app nativa, solo con PKCE).
JWKSPer private_key_jwt, da 1 a 8 chiavi pubbliche: Ed25519, P-256, o RSA da 2048 a 4096 bit con un esponente dispari piccolo (65537, come al solito). Dai le chiavi stesse, mai un URL.
Algoritmo del token IDEdDSA (quello predefinito) o ES256.
Refresh tokenAttivi in modo predefinito.
LogoUn PNG o un GIF di al più 256 KiB e 512 × 512 pixel, controllato con le stesse regole dell'immagine di una scheda.

Quando l'app nasce, Veil mostra il suo client id (veil_app_ e 32 cifre esadecimali) e, per un server, il suo segreto (veil_secret_…). Il segreto si vede solo quella volta, e Veil ne tiene solo l'hash SHA-256. Un segreto nuovo sostituisce subito il vecchio, e cancellare l'app chiude ogni token che ha.

Un'app è verificata quando la sua scheda prova, con la prova del dominio della rubrica, il dominio del sito dell'app e di ogni redirect URI https. La pagina di consenso dice allora "Verificata: il titolare di example.org". Ogni altra app ha una riga discreta, "App non verificata", e la pagina nomina sempre la scheda che l'ha registrata.

Registrazione dinamica#

POST https://veil.rip/oidc/register è aperto a chiunque (RFC 7591), con questi limiti:

  • Al più 10 registrazioni l'ora per indirizzo IP.
  • L'app non appartiene a nessuna scheda, quindi non è mai verificata, e non può chiamarsi Veil.
  • Scade 30 giorni dopo la registrazione (client_secret_expires_at e veil_expires_at nella risposta).
  • token_endpoint_auth_method vale client_secret_basic se manca, come dice RFC 7591, quindi per un'app pubblica manda none.
  • Per private_key_jwt il jwks va nella richiesta, jwks_uri si rifiuta, e logo_uri non si scarica né si tiene.
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"
}'

La richiesta di autorizzazione#

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 deve essere uguale a uno di quelli registrati, tranne che un URI di loopback vale con qualunque porta (RFC 8252). Un'app sconosciuta o un redirect URI non registrato si rifiutano sulla pagina di consenso, e il browser non ci viene mai mandato.
  • scope deve contenere openid. Uno scope sconosciuto dà invalid_scope.
  • code_challenge (43 caratteri) e code_challenge_method=S256 sono obbligatori per ogni app. Senza, la risposta è invalid_request.
  • state arriva a 1024 caratteri e nonce a 512. Mandali tutti e due.
  • prompt accetta none, login, consent e select_account. Con none la risposta è login_required quando il browser non ha una scheda, e consent_required quando la scheda non ha approvato la tua app per questi scope o il tuo redirect URI è di loopback.
  • max_age=0 vale prompt=login, quindi la pagina aspetta il clic. Ogni altro max_age è già rispettato, perché ogni accesso è una firma fatta in quel momento (auth_time).
  • ui_locales=it mostra la pagina in italiano. La lingua predefinita è l'inglese.

La risposta arriva al tuo redirect URI come code, state e iss=https://veil.rip, oppure come error, error_description, state e iss. Controlla state e iss prima di usare il codice.

Prova il flusso#

Registra un'app di prova su /developers con un redirect URI che controlli, poi scrivi qui il suo client id e quel redirect URI. Questa pagina crea nel tuo browser un verificatore PKCE, uno state e un nonce e scrive la richiesta per te. Quello che scrivi resta su questa pagina.

Le chiavi del provider (JWKS)
https://veil.rip/oidc/jwks

Dalla riga dell'app su /developers.

Uno dei redirect URI dell'app, esattamente come l'hai registrato.

Scope
La richiesta di autorizzazione
https://veil.rip/oidc/authorize?response_type=code
  &client_id=…
  &redirect_uri=…
  &scope=openid%20profile%20veil
  &state=…
  &nonce=…
  &code_challenge=…
  &code_challenge_method=S256
Apri la richiesta

Con JavaScript, questa pagina mette il code_challenge PKCE, lo state e il nonce.

Poi scambia il codice
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=…

Metti in CODE il code che riceve il tuo redirect URI, e il segreto dell'app in VEIL_CLIENT_SECRET. Un'app nativa manda -d client_id=… al posto di -u. Ricaricare la pagina crea un verificatore, uno state e un nonce nuovi.

Scope e claim#

ScopeClaimCosa legge la persona
openidsub, e nel token ID auth_time, amr, sidIl numero della tua scheda
profilename, picture, preferred_username, profileIl tuo nome e la tua immagine
veilveil_version, veil_log, veil_good_standing, veil_standing_reasons, e nel token ID veil_consentLo stato della tua scheda: se è in regola, la versione su cui si controlla questo accesso e la firma che fai adesso
veil:accountsveil_accountsI tuoi account verificati, con la piattaforma e il nome di ciascuno
veil:orgsveil_orgsLe tue organizzazioni
veil:keysveil_keysLe tue chiavi pubbliche
ClaimSignificato
subIl numero della scheda come stringa ("7"). Resta lo stesso per tutta la vita della scheda, e un alias non lo cambia mai.
nameIl nome sulla scheda. In Veil i nomi non sono unici, quindi mostralo con il numero e riconosci le persone da sub. Mentre lo staff nasconde un nome, vale Card <n>.
pictureL'indirizzo dell'immagine della scheda. Manca (o è null in UserInfo) se la scheda non ne ha.
preferred_usernameL'alias della scheda se ne ha uno, altrimenti u<n>. Un alias può cambiare o essere lasciato.
profilehttps://veil.rip/u/<n>, la pagina della scheda.
veil_versionLa versione della scheda con cui è stato controllato l'accesso. In UserInfo, la sua versione corrente.
veil_logLa voce del registro di quella versione e il checkpoint in cui il server l'ha verificata: {origin, index, size, root}, dove root è l'hash della radice del checkpoint in base64.
veil_good_standingtrue quando non vale nessuno dei motivi qui sotto.
veil_standing_reasonsIl perché: suspended (dallo staff di Veil), closing (la scheda ha chiesto di essere chiusa e ha 24 ore per ripensarci), takeover (durante l'attesa di 7 giorni, quando un'altra scheda chiede uno dei suoi account o lei ne chiede uno), name_hidden, key_revoked (la chiave di dispositivo che ha firmato questo accesso è stata revocata dopo), key_banned.
veil_consentSolo nel token ID. Il consenso esattamente come l'ha firmato la chiave di dispositivo, {text, signature, key, alg}. Vedi "Cosa verificare".
veil_accountsGli account verificati che la scheda ha adesso: [{platform, id, handle, url, verified_at}]. id è l'id stabile della piattaforma (gli id utente di Discord e GitHub, un indirizzo email, un dominio, un indirizzo del Fediverso), e url è null dove la piattaforma non ha una pagina di profilo. Un account in sospeso (durante un subentro) non c'è.
veil_orgsLe organizzazioni di cui la persona fa parte, accettate da tutte e due le parti: [{n, name, role}].
veil_keys{devices: [{key, alg}], external: [{type, fp}]}: le chiavi di dispositivo attive, e le impronte delle chiavi pubbliche di altri sistemi (SSH, OpenPGP, minisign…) che la scheda certifica.
amr["veil-key"]: l'accesso è stato la firma di una chiave di dispositivo.
auth_timeQuando è stata controllata quella firma.
sidL'id dell'accesso, l<numero>.

Un token ID di un server di prova, con gli indirizzi scritti come sono su 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"}}

Token#

TokenFormaDurata
CodiceOpaco, si usa una volta10 minuti. Usato due volte, chiude ogni token del suo accesso.
Token di accessoOpaco, veil_at_…, un token Bearer per UserInfo10 minuti
Refresh tokenOpaco, veil_rt_…, legato alla tua app30 giorni, uno nuovo a ogni uso, e al più 90 giorni dopo l'accesso
Token IDJWT firmato da Veil, con kid10 minuti

Veil tiene ogni codice e ogni token come hash SHA-256. L'endpoint dei token accetta application/x-www-form-urlencoded, con ogni parametro una volta sola.

  • grant_type=authorization_code vuole code, redirect_uri e code_verifier.
  • grant_type=refresh_token vuole refresh_token, e scope può restringere gli scope purché tenga openid. La risposta porta un refresh token nuovo, e il vecchio smette di funzionare. Un refresh token usato una seconda volta chiude tutto l'accesso, perché qualcuno l'ha copiato.
  • Un refresh fallisce con invalid_grant quando la scheda è chiusa o sospesa, quando la chiave di dispositivo che ha firmato l'accesso è revocata dalla scheda, e quando la persona revoca la tua app dalla pagina del suo account.
  • client_secret_basic è HTTP Basic con il client id e il segreto. Per private_key_jwt, l'asserzione del client ha iss e sub uguali al tuo client id, aud uguale a https://veil.rip o all'indirizzo dell'endpoint dei token, exp al più 5 minuti avanti e un jti da 8 a 256 caratteri che vale una volta. Il tipo della tua chiave ne fissa l'algoritmo. Un'app pubblica manda client_id.

Il token ID è firmato con EdDSA, a meno che la tua app non abbia registrato ES256. Molte librerie si aspettano RS256 se non si configura niente, quindi imposta id_token_signed_response_alg nella configurazione del tuo client con l'algoritmo che hai registrato.

Il JWKS ha tutte e due le chiavi. Ogni chiave ha un solo alg, e il suo kid è la sua impronta RFC 7638. Le chiavi ruotano con 30 giorni di sovrapposizione, quindi una chiave sostituita resta nel JWKS per 30 giorni. Tieni il JWKS in cache per qualche minuto, e scaricalo di nuovo quando un token nomina un kid che non hai.

UserInfo#

GET o POST /oidc/userinfo con Authorization: Bearer <token di accesso> restituisce i claim degli scope del token dallo stato della scheda adesso: la sua versione corrente, l'ultimo checkpoint verificato del registro, se è in regola, e i suoi account, le organizzazioni e le chiavi. Una chiave revocata o una sospensione si vedono subito.

Per una scheda sospesa la risposta è 401 con WWW-Authenticate: Bearer error="veil_suspended" e {"error": "veil_suspended"}. I token di una scheda chiusa rispondono invalid_token, perché chiudere una scheda revoca ogni token che ha dato.

Revoca e introspezione#

POST /oidc/revoke (RFC 7009) accetta token, con l'autenticazione dell'endpoint dei token. Revocare un refresh token chiude ogni token del suo accesso. Un token che non è della tua app, o che non è affatto un token, riceve comunque 200.

POST /oidc/introspect (RFC 7662) permette a un'app confidenziale di chiedere dei propri token. La risposta è {active, scope, client_id, sub, aud, iss, iat, exp, token_type, auth_time, veil_good_standing}, oppure {"active": false}.

Logout#

Veil non tiene una sessione da chiudere, perché un accesso è una firma. GET /oidc/logout?id_token_hint=<un token ID>&post_logout_redirect_uri=…&state=… revoca i token di quell'accesso (il suo sid) e rimanda il browser all'URI di logout se la tua app ha registrato esattamente quell'indirizzo. Altrimenti una pagina dice alla persona che è uscita dalla tua app.

Errori#

Gli endpoint OAuth rispondono agli errori nella forma del loro protocollo, {"error": "invalid_grant", "error_description": "…"}, con Cache-Control: no-store. Su quegli endpoint anche i limiti tengono quella forma: 429 con Retry-After e {"error": "rate_limited", "error_description": "…"}.

I limiti sono 1200 chiamate al minuto per IP sul canale tra server (token, revoca, introspezione, UserInfo), 600 al minuto per app dopo l'autenticazione, 60 richieste di autorizzazione al minuto per IP e 10 registrazioni l'ora per IP. Il JSON della pagina di consenso e le API di gestione usano la busta della rubrica, {"error": {"code", "message"}}.

Cosa verificare#

Ogni app#

Sono i controlli standard di OpenID Connect. Una libreria mantenuta li fa per te, anche se in molte il passo 3 va acceso.

  1. state e iss nella risposta di autorizzazione sono il tuo state e https://veil.rip.

    state lega la risposta al browser che ha cominciato l'accesso, così un altro sito non può infilare un suo codice nel tuo callback. iss dice quale provider ha risposto, così a un'app che parla con più provider non si può passare il codice di uno come se fosse di un altro (RFC 9207).

  2. Il codice torna indietro con il tuo verificatore PKCE.

    Solo il tuo server conosce il verificatore dietro il code_challenge che ha mandato, quindi un codice uscito per strada (un log, un referer, un'altra app sullo stesso dispositivo) non serve a chi lo raccoglie.

  3. La firma del token ID si verifica con la chiave del JWKS che il suo kid nomina, con l'algoritmo di quella chiave. Non lasciare mai che sia l'intestazione a scegliere l'algoritmo, e non accettare mai none o un algoritmo HMAC. Molte librerie saltano questo controllo per un token ID che arriva diretto dall'endpoint dei token, perché lì OpenID Connect lascia che TLS ne faccia le veci. openid-client lo salta se non chiami enableNonRepudiationChecks. Accendi il controllo.

    Con il controllo acceso, il token che tieni prova quello che Veil ha detto su chi è entrato. Prendere l'algoritmo dalla chiave, e mai dall'intestazione, ferma un token falso che dice none o usa la chiave pubblica come segreto HMAC.

  4. iss è https://veil.rip, aud (e azp) è il tuo client id, exp è nel futuro, iat no, e nonce è il tuo.

    Una firma valida dice solo che il token l'ha fatto Veil. Questi claim dicono che l'ha fatto per la tua app, per questo accesso e da poco, quindi un token dato a un'altra app o ripreso da un accesso di prima qui non passa.

  5. Riconosci la persona da sub. Mostra name con il numero o l'indirizzo della scheda.

    sub è il numero della scheda e resta lo stesso per tutta la sua vita. In Veil i nomi non sono unici, e un alias può cambiare o essere lasciato, quindi nessuno dei due può stare al posto della persona.

  6. Se alla tua app importa che la scheda sia in regola, leggi veil_good_standing, e chiedi di nuovo a UserInfo prima di un'azione importante.

    Il token ID descrive la scheda al momento dell'accesso. UserInfo riflette la scheda adesso, quindi una sospensione, un subentro o una chiave revocata lì si vedono subito.

Il vantaggio di Veil: l'accesso controllato sul registro#

I controlli qui sopra si fidano del server per sapere chi è entrato. Con lo scope veil puoi controllare invece la firma della persona e il registro pubblico.

  1. Leggi veil_consent.text. La prima riga è veil-rubrica:oidc-consent:v1, e il resto è JSON canonico con v, iss, card, key, client, redirect, scopes, nonce, state, challenge, request e time.

    La prima riga dice che tipo di testo ha firmato la chiave, così una firma che la chiave ha fatto per qualunque altra cosa in Veil non può passare per il consenso di un accesso.

  2. Verifica veil_consent.signature (Ed25519 in esadecimale) sui byte esatti di text con veil_consent.key.

    Questa firma viene dalla chiave di dispositivo della persona. Il server non ha una copia di quella chiave e non può farla.

  3. Controlla che challenge sia lo S256 del tuo verificatore PKCE, nonce il tuo nonce, state lo SHA-256 (in esadecimale) del tuo state, client il tuo client id, iss l'issuer, key uguale a veil_consent.key e card uguale a sub. veil_consent.alg è ed25519.

    Il tuo server ha fatto il verificatore solo per questo accesso, quindi un consenso che nomina il suo code_challenge è stato firmato per questo accesso e per nessun altro.

  4. Chiedi al server dove guardare. GET /v1/keys/{key} dà la scheda e la versione che ha aggiunto la chiave (addedIn). GET /v1/identities/{sub} dà il token della scheda, con cui il registro la nomina, e la sua version corrente. veil_version deve stare tra le due.

    In questo passo niente è ancora una prova. Ti dice quali voci del registro leggere nel passo dopo.

  5. Controllalo nel registro, per ogni versione da addedIn alla corrente. GET /v1/identities/{sub}/versions/{v}/proof dà la voce del registro, il suo indice, la prova di inclusione e un checkpoint. Il label della voce deve nominare la scheda (identity è il token), la versione (n) e quella prima (prev è il labelHash della prova precedente). L'hash della foglia è SHA-256(0x00 || entry), e la prova di inclusione (RFC 6962) deve portare alla radice del checkpoint. La prima di quelle versioni deve aggiungere la chiave come dispositivo (label.auth contiene {"op": "addKey", "key": <key>, "role": "device"}), nessuna dopo può revocarla (revokeKey), e la prova di veil_version deve stare a veil_log.index. Di rado una scheda ha molte versioni dopo l'arrivo di una chiave, e puoi tenere quelle già controllate per la volta dopo.

    Fin qui lo diceva solo il server. Adesso lo dice il registro pubblico: la chiave è stata aggiunta a questa scheda come dispositivo, nessuna versione dopo l'ha revocata, e la versione dell'accesso è la voce che Veil ha nominato. Un dispositivo revocato, la voce di un'altra scheda spacciata per questa o una versione spostata qui non passano.

  6. Controlla ogni checkpoint con una copia della policy del registro che tieni con la tua app (https://log.veil.rip/<log>/policy, il registro di veil_log.origin): la firma del registro, le controfirme di abbastanza testimoni della policy da fare il suo quorum, e una dimensione almeno pari a veil_log.size.

    I testimoni sono operatori indipendenti, quindi un server che ti mostrasse una versione della scheda che nessun altro vede verrebbe scoperto.

La prova di conformità di Veil fa girare gli esempi Node e Python qui sotto contro un server che mente. Rifiutano un consenso firmato da un dispositivo che la scheda ha revocato, la voce del registro di un'altra scheda spacciata per questa, una chiave revocata che il server dice attiva e una versione dell'accesso spostata.

Limiti dichiarati#

  • Il registro conosce la scheda per il token, non per il numero. Il passaggio da sub al token resta la parola del server.
  • Il passo 5 non controlla la firma di ogni voce. Il registro prende una voce solo se l'ha firmata una chiave con autorità sulla scheda, e chiunque può ricontrollare quelle firme rifacendo l'autorità della scheda dalle versioni nel registro (/v1/identities/{n}/authority), come fanno le pagine delle schede nel browser con veil-v2.js. Quello è il controllo del tutto indipendente.
  • Un server che nasconde le versioni più nuove della scheda, e dice che la scheda è ancora a una più vecchia, non viene scoperto da questi passi. È il punto dell'indice del formato 2 del registro.
  • Gli account, le organizzazioni e le chiavi nei claim sono la lettura che il server fa della scheda. Ognuno sta anche nello stato firmato della scheda, che la pagina di una scheda controlla nel browser di chi la guarda.
  • Un'app che controlla solo la parte OpenID Connect si fida del server per l'accesso stesso, come con qualunque provider OpenID Connect. Un server in mano ad altri potrebbe dare a un'app così un token per qualunque scheda. Un'app che controlla veil_consent con il proprio verificatore PKCE non si inganna così senza la chiave di dispositivo della persona. La chiave non lascia mai il browser, ma le pagine che la usano arrivano dallo stesso server. Un server in mano ad altri può mandare una pagina che firma il consenso per l'accesso di un attaccante la prossima volta che la persona apre Veil, anche dentro la cornice nascosta della chat, che si carica a ogni visita. È il limite di ogni chiave tenuta da una pagina web.
  • La persona vede ogni accesso sulla pagina del suo account, con il testo firmato e la firma. La pagina li mostra e non li controlla. Le ricevute firmate degli accessi nel registro pubblico, con cui una persona potrebbe accorgersi di un token dato senza di lei anche a un'app che non controlla, sono un punto aperto per dopo.
  • Un server può ancora rifiutare di rispondere, o servire un checkpoint più vecchio. Se ti importa che sia recente, confronta l'ora delle controfirme dei testimoni con l'orologio.

Esempio: Node#

Il flusso usa openid-client (fissato alla 6.8.8 nella prova di conformità di Veil), e i controlli di Veil usano node:crypto. Il verificatore PKCE, lo state e il nonce restano sul tuo server, legati a un cookie. Serve Node 20 o più recente.

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);
}

Esempio: Python#

La libreria standard di Python non ha Ed25519 né ECDSA, quindi questo esempio usa cryptography (pyca), una libreria presente in quasi ogni distribuzione che li ha tutti e due. Il resto viene dalla libreria standard. Fai girare il flusso nel tuo framework web: start_login dà il redirect e i valori da tenere nella sessione, finish_login gestisce il callback, e verify_veil aggiunge i controlli sul registro.

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"]}

La prova di conformità di Veil (server/browser-test/oidc-conformance.mjs, OidcConformanceTest) prende tutti e due gli esempi da docs/oidc.md nel repository, che ha lo stesso codice di questa pagina, e li fa girare contro un server Veil con un registro e testimoni veri, su accessi onesti e su accessi manomessi.

Riferimento#

Ogni endpoint ha la sua pagina, con i parametri, le risposte e gli errori.

  • GET
    Leggi la configurazione OpenIDIl documento di discovery di Accedi con Veil: issuer https://veil.rip, gli endpoint, gli scope, gli algoritmi e i metodi.
    /.well-known/openid-configuration
  • GET
    Leggi le chiavi di firma del providerLe chiavi pubbliche che firmano i token ID: una Ed25519 (EdDSA) e una P-256 (ES256), più la coppia precedente per 30 giorni dopo una rotazione.
    /oidc/jwks
  • GET
    Comincia un accessoL'endpoint di autorizzazione. Controlla l'app e il suo redirect URI, tiene la richiesta 30 minuti e mostra la pagina di consenso, dove firma la chiave di dispositivo della persona.
    /oidc/authorize
  • POST
    Scambia un codice o un refresh tokenL'endpoint dei token: il codice con il verificatore PKCE, o un refresh token, per un token di accesso, un token ID e un nuovo refresh token.
    /oidc/token
  • GET
    Leggi i dati della personaGli stessi dati del token ID, dallo stato della scheda adesso: una chiave revocata, un subentro o una sospensione cambiano subito la risposta.
    /oidc/userinfo
  • POST
    Revoca un tokenRFC 7009: l'app revoca uno dei suoi token. Un refresh token chiude tutti i token del suo accesso.
    /oidc/revoke
  • POST
    Ispeziona un tokenRFC 7662, per le app confidenziali: se uno dei token dell'app è attivo adesso.
    /oidc/introspect
  • POST
    Registra un'app senza schedaRFC 7591, aperta: un'app di cui nessuna scheda risponde. La pagina di consenso la dice non verificata, e sparisce 30 giorni dopo la registrazione.
    /oidc/register
  • GET
    Esci da un'appLogout avviato dall'app. Con id_token_hint finiscono i token di quell'accesso; il browser torna solo a un indirizzo che l'app ha registrato esattamente.
    /oidc/logout
  • GET
    Elenca le app di una schedaLe app registrate da una scheda per Accedi con Veil, senza i segreti.
    /v1/developer/accounts/{n}/oidc-clients
  • POST
    Registra un'appUna persona o un proprietario dell'organizzazione firma la registrazione. Un'app confidenziale con client_secret_basic riceve il segreto una volta.
    /v1/developer/accounts/{n}/oidc-clients
  • GET
    Elenca le app in cui una persona è entrataLe app in cui la scheda è entrata, con gli ultimi cinque accessi di ognuna e il consenso firmato dalla sua chiave di dispositivo, così il proprietario controlla ogni accesso.
    /v1/identities/{n}/oidc-apps