Attestations¶
Statements one domain signs about an identity, usually one issued by another domain. An attestation is created as an offer; its subject accepts it by proving possession of an identity key, and receives a credential bound to that key. The credential is an SD-JWT VC: each claim is disclosed or withheld by its holder, and a presentation is bound to one verifier and one occasion. The issuer's key is certified by its root key, so the DNS pin anchors every credential, and a status list tells whether a credential still stands without the issuer learning who asked. Which attesters to believe is left to whoever verifies.
GET /.well-known/jwt-vc-issuer¶
Credential issuer metadata
Unauthenticated
Callable with no credential.
The key this server signs credentials and status lists with, in the
form SD-JWT VC defines for issuer metadata. issuer is
https://<domain>, the value every credential issued here carries
as iss.
The key is ECDSA P-256 (ES256). It is not the root key: it is a
separate key the root key certifies. revoked_certification carries
that certification in the format of every root-key statement —
payload (base64url of a JSON body with type: credential-key-v1,
the domain, and the key's RFC 7638 thumbprint as kid) and
signature (hex). A verifier that walked the DNS pin to the root
key checks the certification and so ties every credential to the
domain. A key served here without a certification under the pinned
root key proves nothing.
Responses
| Status | Meaning |
|---|---|
200 |
The issuer identifier and its key set. |
Response fields
| Field | Type | Notes |
|---|---|---|
issuer |
string | https://<domain>. |
jwks |
object |
GET /api/attestations/status¶
Status list of the credentials issued here
Unauthenticated
Callable with no credential.
The revocation state of every credential this server issued, as one
Token Status List: a JWT of type statuslist+jwt, signed with the
credential key, whose status_list.lst is a zlib-compressed bit
array, one bit per credential. A set bit means revoked. Each
credential names its position in its status claim.
A verifier reads the whole list and looks its credential up in it, so this server does not learn which credential was checked or by whom. The list is at least twice as long as the number of credentials issued, and positions are assigned at random.
A revocation is never undone, and it outlives the attestation: a deleted attestation, or one whose workspace was deleted, stays revoked. A position the list does not reach says nothing about its credential, which is not the same as revoked.
Responses
| Status | Meaning |
|---|---|
200 |
The status list token, as application/statuslist+jwt. It may be kept for ttl seconds (five minutes), which is the revocation latency in the usual case, and is not valid past exp. |
429 |
Too many attempts. Password gates, probes and challenges are limited per client, so a slug cannot be brute-forced. |
GET /api/attestations/offers/{token}¶
What an offer states
Unauthenticated
Callable with no credential.
Shows an offer to whoever holds its token, before anything is decided: the attesting server, the workspace, the type, and every claim with its value, so the subject sees what would be stated about them. Once the offer was accepted the claims are left out.
server names the domain and root key fingerprint to walk the DNS
chain against.
Parameters
| Name | In | Required | Notes |
|---|---|---|---|
token |
path | yes | The offer's one-time token, returned once in the X-Attestation-Token header when the attestation was created. |
Responses
| Status | Meaning |
|---|---|
200 |
The offer. |
404 |
No such resource, or the caller may not see it. |
410 |
attestation_offer_expired or attestation_offer_revoked. |
429 |
Too many attempts. Password gates, probes and challenges are limited per client, so a slug cannot be brute-forced. |
Response fields
| Field | Type | Notes |
|---|---|---|
type |
string | |
label |
string | |
status |
string (offered | active) |
|
claims |
object | Present while the offer is open. |
expiresAt |
string | When the credential stops being valid; empty for none. |
offerExpiresAt |
string | Until when the offer can be accepted. |
subjectFingerprint |
string | Set when the offer was made for one identity, or accepted by one. |
server |
ServerClaim | |
workspace |
object |
POST /api/attestations/offers/{token}/claim¶
Accept an offer and receive the credential
Unauthenticated
Callable with no credential.
Issues the credential to an identity that proves its key. The caller
is usually the subject's own server, relaying for the app
(POST /api/credentials/claim there); nothing but the token and the
proof is needed.
The challenge comes from GET /api/challenges/attestation/{token}
with the identity's id, and is signed on the device with the
identity key. This server then checks the identity with the server
that issued it: the certificate is the one fingerprinted, that
server's DNS-pinned root key signed the fingerprint, and it still
vouches for the identity. An identity issued here is checked against
this server's own records.
The credential is an SD-JWT VC signed with the credential key. It
names the issuer (iss), the type (vct), the holder's key
(cnf.jwk), the identity it is about (revoked_holder: fingerprint
and domain) and its position in the status list (status). Every
claim of the attestation is selectively disclosable.
An offer is accepted once. The identity that accepted it may claim
it again until the offer's date and receives the same statement
again, so a lost answer costs nothing; any other identity is refused
with attestation_offer_claimed.
Parameters
| Name | In | Required | Notes |
|---|---|---|---|
token |
path | yes |
Request body
application/json
| Field | Type | Notes |
|---|---|---|
identity |
object | The public half of the subject's identity. |
challengeNonce |
string | |
challengeSignature |
string |
Responses
| Status | Meaning |
|---|---|
200 |
The credential, with every disclosure. |
400 |
Refused, with a named reason. |
401 |
challenge_required, challenge_invalid or signature_invalid. |
403 |
remote_identity_invalid when the identity could not be verified with the server that issued it, identity_revoked when that server withdrew it, attestation_identity_mismatch when the offer was made for another identity. |
404 |
No such resource, or the caller may not see it. |
410 |
attestation_offer_claimed, attestation_offer_expired or attestation_offer_revoked. |
429 |
Too many attempts. Password gates, probes and challenges are limited per client, so a slug cannot be brute-forced. |
Response fields
| Field | Type | Notes |
|---|---|---|
credential |
string | <issuer JWT>~<disclosure>~…~ |
issuer |
string | The attesting domain. |
POST /api/attestations/verify¶
Verify a presented credential
Unauthenticated
Callable with no credential.
Checks a presentation along both chains and answers with a verdict.
The attester's chain: the issuer's domain is read from iss, its
root key is tied to the domain through the _revoked TXT record,
the credential key is checked against the root key's certification,
the credential's signature against that key, and its position in the
issuer's status list.
The holder's chain: the key binding is checked against the key the
credential names, for the audience and nonce given here, and the
server that issued the holder's identity is asked whether that
identity still stands.
The verdict states who attested and what was disclosed. It does not decide whether the attester deserves to be believed: which domains are accepted for which statement is the caller's policy.
Without audience and nonce no key binding is checked, and the
verdict is never verified: a credential without a key binding
could be presented by anyone holding a copy. A service chooses a
fresh nonce for every request and passes its own origin as audience.
Always answers 200; a presentation that cannot be read is the
verdict invalid, and a check that could not be completed is
unverified, never an error to ignore.
Request body
application/json
| Field | Type | Notes |
|---|---|---|
presentation |
string | <issuer JWT>~<disclosure>~…~<key binding JWT> |
audience |
string | The verifier as the presentation names it |
nonce |
string | The value the verifier chose for this occasion. |
Responses
| Status | Meaning |
|---|---|
200 |
The verdict. |
400 |
Refused, with a named reason. |
429 |
Too many attempts. Password gates, probes and challenges are limited per client, so a slug cannot be brute-forced. |
Response fields
| Field | Type | Notes |
|---|---|---|
state |
string (verified | unverified | dnsMissing | spoofed | revoked | expired | invalid) |
|
reason |
string | The first reason the credential was held back |
issuer |
string | The attesting domain. |
issuerState |
string | How the issuer's key was tied to its domain. |
type |
string | The credential's vct. |
claims |
object | The disclosed claims. Left out unless the issuer's signature verified. |
withheld |
integer | How many claims the credential holds that were not disclosed. |
status |
string (valid | revoked | unknown) |
What the issuer's status list says. |
issuedAt |
integer | Unix seconds. |
expiresAt |
integer | Unix seconds; absent when the credential does not expire. |
holder |
object | The identity the credential was issued to. |
keyBound |
boolean | Whether the presentation proved possession of the holder's key for the audience and nonce given. |
POST /api/credentials/claim¶
Accept an offer and keep the credential
Auth: Session
Accepts an attestation offer for the person signed in here and keeps the credential for them. This is what the app calls; the offer may live on this server or on any other.
The app obtains the challenge from the offer's server and signs it on
the device. This server adds the identity's public half, calls the
offer's server, and checks what comes back before keeping it: the
server that was asked signed it, with a key tied to its domain, and
it is bound to the key and the identity that asked. A credential
that fails this is not kept, and credential_invalid is returned;
the offer can be accepted again.
origin is the address of the offer's server and is left out for an
offer on this server. Accepting an offer again replaces the kept
copy.
Request body
application/json
| Field | Type | Notes |
|---|---|---|
origin |
string | The other server's address, e.g. https://verify.example. Plain http is accepted only for a domain listed in PEER_OVERRIDES. |
token |
string | |
identityId |
string | An identity of the signed-in person on this server. |
challengeNonce |
string | |
challengeSignature |
string |
Responses
| Status | Meaning |
|---|---|
200 |
The kept credential, as its credentials record. |
400 |
Refused, with a named reason. |
401 |
Refused, with a named reason. |
403 |
Refused, with a named reason. |
404 |
No such resource, or the caller may not see it. |
410 |
Refused, with a named reason. |
429 |
Too many attempts. Password gates, probes and challenges are limited per client, so a slug cannot be brute-forced. |
502 |
remote_unreachable when the other server could not be reached, credential_invalid when what it returned did not verify. |
Response fields
| Field | Type | Notes |
|---|---|---|
id |
string | |
issuer |
string | The attesting domain. |
type |
string | |
identity |
string | |
credential |
string | The SD-JWT with every disclosure. |
claims |
object | |
issuedAt |
string | |
expiresAt |
string |
GET /api/credentials/{id}/status¶
Whether a held credential still stands
Auth: Session
The holder's view of a credential they keep: the same checks as
POST /api/attestations/verify, without a key binding, since
nothing is being presented. It tells whether the attester's key
still verifies, whether the credential was revoked or has expired,
and whether the identity it is bound to still stands.
Parameters
| Name | In | Required | Notes |
|---|---|---|---|
id |
path | yes |
Responses
| Status | Meaning |
|---|---|
200 |
The verdict. keyBound is always false here. |
401 |
Refused, with a named reason. |
404 |
No such resource, or the caller may not see it. |
429 |
Too many attempts. Password gates, probes and challenges are limited per client, so a slug cannot be brute-forced. |
Response fields
| Field | Type | Notes |
|---|---|---|
state |
string (verified | unverified | dnsMissing | spoofed | revoked | expired | invalid) |
|
reason |
string | The first reason the credential was held back |
issuer |
string | The attesting domain. |
issuerState |
string | How the issuer's key was tied to its domain. |
type |
string | The credential's vct. |
claims |
object | The disclosed claims. Left out unless the issuer's signature verified. |
withheld |
integer | How many claims the credential holds that were not disclosed. |
status |
string (valid | revoked | unknown) |
What the issuer's status list says. |
issuedAt |
integer | Unix seconds. |
expiresAt |
integer | Unix seconds; absent when the credential does not expire. |
holder |
object | The identity the credential was issued to. |
keyBound |
boolean | Whether the presentation proved possession of the holder's key for the audience and nonce given. |