Skip to main content

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 method out gives webvh: a signed, append-only log, so a verifier can tell a key rotation from a substitution. "method": "web" gives did:web: one replaceable document with no history and no recovery, for verifiers that resolve nothing else. A did:webvh identifier is also served as did:web (step 5), so most organizations never need web. A deployment may not permit web at all.
  • The host. Leaving host out 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 as id.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: its scid is null and its scidPlaceholder is {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.
  • alsoKnownAs names acme's entity identifier and the did:web form of the same identifier.
  • servedAt lists the two addresses publishing will write: https://acme.identity.local.oktoid.app/.well-known/did.jsonl, the log, and .../.well-known/did.json, the did:web document.

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:

  1. It checks that the document waiting is the one somebody previewed since it was rendered. Otherwise the answer is 409 with "code": "identifier.not_previewed"; read the preview again (step 3) and repeat.
  2. The agent signs the first version of the log with the update key.
  3. 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.
  4. The log is written, then did.json.
  5. 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.

StatuscodeWhat it meansWhat to do
404noneThe identifier or the organization does not exist, or is outside the organization you act for. The two are answered the same way on purposeCheck the id and Oktoid-Acting-For
409identifier.existsThe organization already has an identifierRead it: GET /api/v1/identifiers?organization=<id>
409identifier.not_previewedPublishing without reading the current previewStep 3, then step 4
409identifier.not_preparedNothing is waiting to be publishedStep 2, or step 6 for a new version
409identifier.update_in_progressAn update is already waiting to be previewed and published; one runs at a timeStep 3, then step 4, for the update that is waiting; then start the next
409identifier.host_unverifiedA host you named does not resolve to the platform yetPoint its DNS at the platform, then publish again
409identifier.host_takenOn 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 publishedOn 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
409identifier.organization_inactiveThe organization is not activeRestore the organization, then create the identifier
400identifier.method_not_permittedThis deployment does not permit the method you asked forLeave method out
400identifier.host_refusedThe 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 slugLeave host out, or name a host on the organization's own domain
400identifier.host_invalidThe organization's slug does not make a valid hostname under the platform's domain, so it has no publisher hostName a host with host, or rename the organization's slug
502deadline.exceededA platform service did not answer in time. The failure is the platform's, not the request'sRetry with backoff and the same Idempotency-Key
502identifier.placement_pending, identifier.agent_unavailable and the other codes of an unavailable serviceThe agent, or another service the step needs, has not answered. Nothing was publishedWait, and retry with the same Idempotency-Key
503module.unavailableThe service behind the route is not runningWait, and retry with the same Idempotency-Key
500noneSomething the platform did not expect, such as a signed document that differs from the preview. Nothing was publishedReport it to the deployment's operator with the reply's correlationId