Attestations¶
An attestation is a statement one domain signs about an identity, usually an
identity issued by another domain: verify.example confirms that an identity
belongs to an adult, club.example confirms a membership. The person the
statement is about holds it as a credential and shows it to a service when
asked, claim by claim.
Three parties take part, and each of them can be a different Revoked server or none at all.
| Party | Role | What it runs |
|---|---|---|
| Attester | Signs the statement in the name of its domain. | A Revoked server with attestations switched on. |
| Holder | Keeps the credential and presents it. | The Revoked app, signed in to the holder's own server. |
| Verifier | Asks for an attestation and checks what is presented. | Any service. A Revoked server can do the checking for it. |
The endpoint reference is under Attestations. The user-facing side is described in the guide.
What a credential holds¶
A credential is an SD-JWT VC (RFC 9901 with the SD-JWT VC profile), signed with ES256. The format is the standard one, so that a credential issued today is not reissued when wallets and verifiers outside Revoked are added. What is specific to Revoked is how the issuer's key is found and checked: through the DNS pin, as for every other signature.
| Claim | Content | Disclosure |
|---|---|---|
iss |
https://<attester domain> |
Always |
vct |
The type the attester chose, such as membership. |
Always |
iat, exp |
Issue date, and expiry where one was set. | Always |
cnf.jwk |
The holder's identity key. Only its holder can present the credential. | Always |
revoked_holder |
The identity the statement is about: its fingerprint and the domain that issued it. | Always |
status |
The credential's position in the attester's status list. | Always |
| Everything the attester states | One claim per name, with a text, number or true/false value. | Chosen by the holder, claim by claim |
A claim the holder withholds is present only as a salted digest. The verifier learns that the credential holds more, not what.
Issuing¶
Issuing is off unless the operator sets ALLOW_ATTESTATIONS=true: an
attestation speaks for the server's domain, and on a server several people
share that is the operator's decision. Within a workspace, issuing needs the
Issue attestations permission, which workspace administrators hold and
can grant, and which an API key can carry.
An attestation is created as an offer, through the collection API:
curl -i "https://verify.example/api/collections/attestations/records" \
-H "X-API-Key: $TOKEN" -H "Content-Type: application/json" \
-d '{
"workspace": "<workspace id>",
"type": "verified_adult",
"label": "Age check, order 4711",
"claims": { "age_over_18": true },
"expiresAt": "2027-10-01 00:00:00.000Z"
}'
| Field | Meaning |
|---|---|
type |
Required. Up to 255 characters without spaces: a short key or a URI. |
claims |
A flat set of named values. Names are letters, digits and underscores; values are text, numbers or true/false. Names the credential format sets itself, such as iss or exp, are refused. |
label |
A name for the attester's own list. It is shown to the subject, and is not part of the credential. |
expiresAt |
When the credential stops being valid. Left out, it is valid until revoked. |
offerExpiresAt |
Until when the offer can be accepted. Fourteen days when left out. |
subjectFingerprint |
Makes the offer for one identity only, named by its fingerprint — for example the identity a request was answered with. |
The response carries the offer's one-time token in the X-Attestation-Token
header. Only its hash is stored, so the token is shown this once. It is sent
to the subject as a link, which names the server the offer lives on:
revoked://a/verify.example/<token>
Nothing is signed at this point. The link opens the subject's app, which
shows the attesting domain, the type and every claim. On acceptance the
subject's identity key signs a challenge from the attester's server, the
attester checks the identity with the server that issued it, and the
credential is issued, bound to that key. The attestation then reads active
and names its subject (subjectFingerprint, subjectDomain, subjectName).
The credential is handed over once and not kept by the attester.
An offer works for one identity. That identity may accept it again until the offer's date and receives the same statement again, so an answer lost on the way or a credential removed by mistake costs nothing. The identity's key is needed each time.
What an attestation states cannot be changed after it was created. It is revoked, and a new one is issued:
curl -X PATCH "https://verify.example/api/collections/attestations/records/<id>" \
-H "X-API-Key: $TOKEN" -H "Content-Type: application/json" \
-d '{ "status": "revoked" }'
Revocation is published through the attester's status list and cannot be undone. A deleted attestation is revoked as well, as is every attestation of a deleted workspace: a credential is never left standing with nobody able to withdraw it.
Asking a person for an attestation¶
A service asks through the person's app, with a link the app answers directly:
revoked://present?client_id=https%3A%2F%2Fshop.example
&response_uri=https%3A%2F%2Fshop.example%2Fpresent%2Fcallback
&nonce=<random>&state=<random>
&type=verified_adult&issuers=verify.example&claims=age_over_18
| Parameter | Required | Meaning |
|---|---|---|
client_id |
yes | The service's origin, as for a connected tool. It is the audience of the presentation. |
response_uri |
yes | Where the presentation is posted; on client_id's origin. |
nonce |
yes | A fresh value per request, 8–256 characters of [A-Za-z0-9._~-]. The presentation commits to it. |
state |
The service's own value, returned unchanged. | |
type |
The kind of attestation asked for. Left out, any is offered. | |
issuers |
Comma-separated attester domains the service accepts. Left out, the holder may offer a credential from any attester. | |
claims |
Comma-separated claims the service needs. They start ticked; the holder decides. |
The app shows who is asking and for what, lists the held credentials that fit, and lets the person tick the claims to disclose. A link that is malformed in any part is refused as a whole.
On agreement the app posts to response_uri:
{ "presentation": "<issuer JWT>~<disclosure>~<key binding JWT>", "state": "…" }
The presentation is made on the person's device and signed with the identity
key. Its key binding names client_id as audience and carries the nonce,
so it is of no use to any other service or for any other request. The
service may answer with { "redirect_uri": "…" } to continue in the
browser; an address outside its own origin is not followed. A declined
request sends nothing.
This link is Revoked's own, and it is the interim way to ask. The standard presentation protocol, OpenID4VP, is step 4 of the roadmap; the credential and the presentation it carries are already the standard ones.
Verifying¶
A presentation is verified by any Revoked server the service relies on — usually its own:
curl "https://shop-revoked.example/api/attestations/verify" \
-H "Content-Type: application/json" \
-d '{ "presentation": "…", "audience": "https://shop.example", "nonce": "<the nonce sent>" }'
Two chains are checked, as described under Trust chain. The attester's: DNS pin, root key, certified credential key, signature, status list. The holder's: the key binding made with the key the credential names, and the current word of the server that issued the holder's identity.
{
"state": "verified",
"reason": "verify.example attests this, its key is tied to the domain through DNS, …",
"issuer": "verify.example",
"issuerState": "verified",
"type": "verified_adult",
"claims": { "age_over_18": true },
"withheld": 0,
"status": "valid",
"holder": { "fingerprint": "3fa2…", "domain": "home.example", "state": "verified", "status": "active" },
"keyBound": true
}
state |
Meaning |
|---|---|
verified |
Both chains hold, and the holder presented it for this audience and nonce. |
unverified |
A check could not be completed: a server was unreachable, the status list does not reach the credential, or no audience and nonce were given. |
dnsMissing |
The attester publishes no _revoked TXT record, so no key can be tied to its domain. |
spoofed |
The credential is not signed by a key the attester's DNS-pinned root key certified. |
revoked |
The credential is genuine, and the attester revoked it or the holder's identity was withdrawn. |
expired |
The credential is genuine and past its own validity. |
invalid |
Not a readable credential, or the key binding does not verify. |
Only verified should be acted on. Claims are reported only when the
attester's signature verified.
Which attesters are believed¶
The verdict states who attested. It does not decide whether that domain
deserves to be believed: DNS proves which domain signed, nothing more. A
service therefore compares issuer with the domains it accepts for the
statement in question, and type with the type it asked for, before it
relies on a claim. Anyone can run a server and attest anything about
themselves.
audience and nonce are what tell the holder from someone holding a copy
of the credential. Without them no key binding is checked and the verdict is
never verified.
Limits¶
- Presentations are linkable. Every presentation of a credential shows the same holder key and the same identity. Services that compare notes can tell that the same person presented to both, unlike with the pairwise subjects of sign-in.
- Revocation takes minutes. A status list may be kept for five minutes, so a revocation reaches a verifier within that time.
- A restored backup forgets later revocations. As with identities, the status list is part of the database. Revocations made after the backup are lost with a restore of it.
- The verifier contacts the holder's server. The identity's status is asked for at the domain that issued it, which thereby learns that a check took place and from which address. The attester learns nothing: its status list is read as a whole.