Create an identifier
This page gives the organization acme a decentralized identifier (a DID)
that anyone can resolve and check without trusting OktoID. At the end, acme
has an identifier of the form
did:webvh:<scid>:acme.identity.local.oktoid.app, its log is served on its own
host, and an independent resolver has fetched that log and verified every
signature in it.
An identifier is published in two decisions, as an organization's statements are. Creating it generates its keys and renders its document; nothing is public yet. Publishing signs the document and serves it, and it is refused until you have read the exact document that will be signed. A published version cannot be withdrawn, so the reading comes first.
What the log is, and what a resolver checks in it, is in How a did:webvh log is resolved.
Before you start
You need the local stack and the organization acme from
Install and sign in, steps 1 to 6.
Work in the same terminal: this page reuses CA, API, TOKEN, ANCHOR and
ACME, and the key and field helpers defined there.
acme does not need to be admitted to the trust tree first. If it was, as in
Admit an organization and publish it, the identifier's
document names the address of its entity configuration, and that address
answers.
The requests go to the public API, api.local.oktoid.app, as the root account,
acting for the anchor. On a local stack the root account holds SOLE_ADMIN,
which includes the four identifier permissions: identifier.create,
identifier.read, identifier.publish and identifier.update. In a
deployment, an organization's own administrator holds them through
ENTITY_ADMIN, and creates the organization's identifier acting for that
organization. Bundles
lists what each bundle holds.
A 401 on any step means the access token has expired; it lasts five minutes.
Sign in again as the tutorial before this one shows, and repeat the request.
1. Create the identifier
IDENT=$(curl -sS --fail-with-body --cacert "$CA" "$API/identifiers" \
-H "Authorization: Bearer $TOKEN" \
-H "Oktoid-Acting-For: $ANCHOR" \
-H "Idempotency-Key: $(key)" \
-H 'Content-Type: application/json' \
-d "{\"organizationId\":\"${ACME:?create the organization first}\"}" \
| field id)
echo "${IDENT:?step 1 made no identifier: read the reply above}"
The API answers 202 with the identifier, and a Location header naming where
to read it: /api/v1/identifiers/<id>. Its status is placing. Its method
is webvh, the default, and its host is acme.identity.local.oktoid.app,
made from the last part of the organization's path. It has no did yet.
The answer is 202 and not 201 because only the record exists so far. The
platform first places the organization on an agent, the service that holds its
keys on a hardware token and signs for it. That happens in the background.
Two choices are made here, and both are permanent for this identifier:
- The method. Leaving
methodout giveswebvh: a signed, append-only log, so a verifier can tell a key rotation from a substitution."method": "web"givesdid:web: one replaceable document with no history and no recovery, for verifiers that resolve nothing else. Adid:webvhidentifier is also served asdid:web(step 5), so most organizations never needweb. A deployment may not permitwebat all. - The host. Leaving
hostout serves the identifier on the organization's own publisher host."host": "id.acme.example"names a host the organization controls instead. Its DNS must point at the platform before you publish. Under the platform's own domain, only a name beneath the organization's own slug is accepted, such asid.acme.local.oktoid.app.
An organization has one identifier. A second request for acme is refused with
409 and "code": "identifier.exists".
2. Wait until it is prepared
Read the identifier until its status is no longer placing:
for i in $(seq 1 30); do
STATUS=$(curl -sS --fail-with-body --cacert "$CA" "$API/identifiers/${IDENT:?run step 1 first}" \
-H "Authorization: Bearer $TOKEN" \
-H "Oktoid-Acting-For: $ANCHOR" \
| field status)
echo "$STATUS"
[ "$STATUS" = placing ] || break
sleep 2
done
It prints placing a few times, then prepared.
Reading is what moves the identifier on, so keep reading. The first read after the organization is placed asks the agent for three keys: the assertion key, which the organization will sign credentials with, and two update keys, one to sign this version of the log and one committed to for the next. A read waits up to two seconds for them and otherwise answers the identifier as it stands; the next read sees them. A client that never reads never gets a prepared identifier.
prepared means the keys exist and the document is rendered. If the loop ends
on placing, the agent has not answered yet; run the loop again. If it prints
failed, the identifier cannot be published: read its failureReason.
3. Read what will be published
PREVIEW=$(curl -sS --fail-with-body --cacert "$CA" "$API/identifiers/$IDENT/preview" \
-H "Authorization: Bearer $TOKEN" \
-H "Oktoid-Acting-For: $ANCHOR")
echo "$PREVIEW" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{const p=JSON.parse(s);console.log("version",p.version);console.log("served at",p.servedAt.join(" "));console.log(JSON.stringify(JSON.parse(p.document),null,2));console.log("did.json:");console.log(JSON.stringify(JSON.parse(p.parallelDocument),null,2))})'
The preview is the exact document that will be signed, as text. The command prints it, then the second document that will be served beside it. The first looks like this:
{
"@context": [
"https://www.w3.org/ns/did/v1",
"https://w3id.org/security/multikey/v1"
],
"alsoKnownAs": [
"https://acme.identity.local.oktoid.app",
"did:web:acme.identity.local.oktoid.app"
],
"assertionMethod": [
"did:webvh:{SCID}:acme.identity.local.oktoid.app#key-1"
],
"authentication": [
"did:webvh:{SCID}:acme.identity.local.oktoid.app#key-1"
],
"id": "did:webvh:{SCID}:acme.identity.local.oktoid.app",
"verificationMethod": [
{
"controller": "did:webvh:{SCID}:acme.identity.local.oktoid.app",
"id": "did:webvh:{SCID}:acme.identity.local.oktoid.app#key-1",
"publicKeyMultibase": "<the assertion key>",
"type": "Multikey"
}
]
}
Read it as the thing you are about to make permanent:
{SCID}is literal. The self-certifying identifier is a hash of the first version of the log, so it cannot exist before that version is signed. Publishing writes it where{SCID}stands and changes nothing else; the platform refuses to publish if anything else differs. The preview says so itself: itsscidisnulland itsscidPlaceholderis{SCID}.- One key. The document names the assertion key and nothing else. It names no service and no other controller. The update keys are not in the document: they sign the log, not credentials.
alsoKnownAsnamesacme's entity identifier and thedid:webform of the same identifier.servedAtlists the two addresses publishing will write:https://acme.identity.local.oktoid.app/.well-known/did.jsonl, the log, and.../.well-known/did.json, thedid:webdocument.
The second document, did.json, is the same document with the DID in its
did:web form, the did:webvh DID added to alsoKnownAs, and the two services
the method implies for every did:webvh identifier, #files and #whois. It
names the same key.
Reading the preview is recorded on the identifier: who read it, when, and the
digest of what they read (previewedBy, previewedAt, previewedDigest).
That record is what step 4 checks.
4. Publish it
DID=$(curl -sS --fail-with-body --cacert "$CA" "$API/identifiers/$IDENT/publish" \
-X POST \
-H "Authorization: Bearer $TOKEN" \
-H "Oktoid-Acting-For: $ANCHOR" \
-H "Idempotency-Key: $(key)" \
-H 'Content-Type: application/json' \
-d '{}' \
| field did)
echo "${DID:?step 4 published nothing: read the reply above}"
The body is an empty JSON object, {}. A request with no body is refused with
400.
The API answers 200 with the identifier. Its status is published, its
version is 1, and it now has a did, did:webvh:<scid>:acme.identity.local.oktoid.app,
which the command prints.
Publishing does this, in this order, and stops at the first thing that fails:
- It checks that the document waiting is the one somebody previewed since it
was rendered. Otherwise the answer is
409with"code": "identifier.not_previewed"; read the preview again (step 3) and repeat. - The agent signs the first version of the log with the update key.
- The platform compares what was signed with what was previewed, and checks the signature and every parameter of the log itself. Any difference refuses the publication, and nothing is written.
- The log is written, then
did.json. - The update key that signed is destroyed. The log has already committed to the key that will sign the next version, and the spent key could sign nothing a resolver would accept.
5. Resolve it with a resolver OktoID did not write
pnpm oktoid resolve-identifier "${DID:?run step 4 first}"
The command runs didwebvh-rs, the Decentralized Identity Foundation's Rust
implementation of the method, in a container of the stack. It shares no code
with the library OktoID signs with. The first run builds its image, which takes
a few minutes.
It fetches the log through the edge, as any outside client does, trusting
nothing but the stack's development root. It recomputes the SCID from the first
version, checks every hash and every signature, and checks that the key that
signed was the one committed to. Then it prints the DID, the versionId of the
last version, its versionTime, and the document, with the real SCID where the
preview showed {SCID}:
did:webvh:<scid>:acme.identity.local.oktoid.app
versionId 1-<hash>
versionTime <when it was signed>
{
...
}
It exits 0 only if the whole log verified. A resolver anywhere that
implements did:webvh 1.0 gets the same answer from the same URL.
The did:web form resolves too:
pnpm oktoid resolve-identifier did:web:acme.identity.local.oktoid.app
It prints the URL it derived from the DID,
https://acme.identity.local.oktoid.app/.well-known/did.json, the content type
application/did+ld+json, and the document. This is a weaker check: a did:web
document has no history and no signature, so there is nothing to verify but its
content.
To see the raw log:
curl -sS --cacert "$CA" https://acme.identity.local.oktoid.app/.well-known/did.jsonl
One line, one version. Its parameters include "portable":true and a
nextKeyHashes entry: the hash of the update key that must sign the next
version.
6. Rotate the key
An update replaces the assertion key and publishes a second version of the log. It goes through the same preview and publish:
curl -sS --fail-with-body --cacert "$CA" "$API/identifiers/$IDENT/update" \
-X POST \
-H "Authorization: Bearer $TOKEN" \
-H "Oktoid-Acting-For: $ANCHOR" \
-H "Idempotency-Key: $(key)" \
-H 'Content-Type: application/json' \
-d '{}' \
| field status
It prints prepared. A new assertion key was generated, and a new update key
to commit to. Version 1 is still served while this one waits.
Now repeat step 3 and step 4. The preview's version is 2, the document
carries the real SCID instead of {SCID}, and the key's id ends in #key-2.
After publishing, the reply's version is 2.
Resolve again:
pnpm oktoid resolve-identifier "$DID"
The answer may still be version 1 for up to five minutes. The edge caches
the log for the time its Cache-Control allows, max-age=300, and a resolver
that asks within that window gets the copy it already served. Version 1 is
still a valid log: it is version 2 without its last line. Wait, and resolve
again; versionId then starts with 2-.
The old assertion key is no longer in the document. It is not destroyed: it
stays on the agent's token, listed on the identifier as retired
(retiredAssertionKeyIds). Anything signed with it before the rotation is
checked against its public half, which stays in version 1 of the log.
When a request is refused
Every refusal is application/problem+json with a code that names it.
| Status | code | What it means | What to do |
|---|---|---|---|
404 | none | The identifier or the organization does not exist, or is outside the organization you act for. The two are answered the same way on purpose | Check the id and Oktoid-Acting-For |
409 | identifier.exists | The organization already has an identifier | Read it: GET /api/v1/identifiers?organization=<id> |
409 | identifier.not_previewed | Publishing without reading the current preview | Step 3, then step 4 |
409 | identifier.not_prepared | Nothing is waiting to be published | Step 2, or step 6 for a new version |
409 | identifier.update_in_progress | An update is already waiting to be previewed and published; one runs at a time | Step 3, then step 4, for the update that is waiting; then start the next |
409 | identifier.host_unverified | A host you named does not resolve to the platform yet | Point its DNS at the platform, then publish again |
409 | identifier.host_taken | On create: the organization's own publisher host already serves another identifier. On publish: another identifier was published on the host you named first, and this identifier is now final and will never be published | On create, name a host on the organization's own domain. On publish, create a new identifier on another host. Do not repeat the request unchanged |
409 | identifier.organization_inactive | The organization is not active | Restore the organization, then create the identifier |
400 | identifier.method_not_permitted | This deployment does not permit the method you asked for | Leave method out |
400 | identifier.host_refused | The host you named may not serve an identifier: it is not a lowercase DNS name (an address, a port, invalid punycode), or it is under the platform's own domain but not beneath the organization's own slug | Leave host out, or name a host on the organization's own domain |
400 | identifier.host_invalid | The organization's slug does not make a valid hostname under the platform's domain, so it has no publisher host | Name a host with host, or rename the organization's slug |
502 | deadline.exceeded | A platform service did not answer in time. The failure is the platform's, not the request's | Retry with backoff and the same Idempotency-Key |
502 | identifier.placement_pending, identifier.agent_unavailable and the other codes of an unavailable service | The agent, or another service the step needs, has not answered. Nothing was published | Wait, and retry with the same Idempotency-Key |
503 | module.unavailable | The service behind the route is not running | Wait, and retry with the same Idempotency-Key |
500 | none | Something the platform did not expect, such as a signed document that differs from the preview. Nothing was published | Report it to the deployment's operator with the reply's correlationId |