How a did:webvh log is resolved
An organization's decentralized identifier in OktoID is a did:webvh DID by
default. Resolving it means fetching one file, the organization's log, and
checking every entry in it from the first to the last. The DID document is the
last entry's content. Nothing about the result depends on trusting OktoID, the
web server or the certificate: a log that was altered anywhere fails the checks.
OktoID implements version 1.0 of the
did:webvh method. A resolver
that implements that version resolves an OktoID identifier without knowing
anything about OktoID. This page explains what such a resolver does with what
OktoID serves, so that you can decide what a resolved document proves.
The identifier and where its log is
An OktoID identifier has this form:
did:webvh:<scid>:<host>
| Part | What it is |
|---|---|
<scid> | The self-certifying identifier: a hash of the log's first entry. It never changes for the life of the identifier |
<host> | The host the log is served on. By default it is the organization's own publisher host, <slug>.identity.<domain>, such as acme.identity.local.oktoid.app. An organization may instead name a host of its own when the identifier is created |
The identifier never has a path or a port. The method turns it into one URL:
https://<host>/.well-known/did.jsonl
The file is served with Content-Type: text/jsonl. A host that has no
published identifier answers 404.
The log
The log is JSON Lines: one JSON object per line, one line per version, oldest first. OktoID stores each line as the exact bytes that were signed and serves the lines joined by a newline. Nothing re-serializes them on the way to you, so the bytes you fetch are the bytes the signature covers.
Each entry has exactly five members:
| Member | What it holds |
|---|---|
versionId | <n>-<entry hash>: the version number, starting at 1, and the hash of this entry |
versionTime | When the entry was made, in UTC, to the second |
parameters | How the log is governed from this entry on: which key may sign, which key must sign next, and in the first entry the method's settings |
state | The DID document of this version |
proof | One signature over the entry |
The first entry's parameters are fixed by OktoID and are always these:
| Parameter | Value | Meaning |
|---|---|---|
method | did:webvh:1.0 | The version of the method the log follows |
scid | The SCID | Repeated here so the resolver can check it |
updateKeys | One Ed25519 key | The key that signed this entry |
nextKeyHashes | One hash | A commitment to the key that must sign the next entry (see Key rotation) |
portable | true | The identifier may later move to another host and keep its SCID |
deactivated | false | The identifier is active |
watchers | [] | No watchers |
witness | {} | No witnesses: each entry is authorized by the update key alone |
Every later entry's parameters holds updateKeys and nextKeyHashes and
nothing else. Every other parameter keeps the value the first entry gave it.
What the resolver checks
A resolver walks the log from the first line to the last and stops at the first check that fails. For each entry it confirms the following.
The SCID. For the first entry, the resolver writes the literal text {SCID}
everywhere the SCID appears, hashes the result, and compares the hash with the
SCID in the DID. The SCID is therefore a hash of the first entry, which is why it
cannot be known before that entry exists. A log whose first entry was changed in
any way yields a different SCID and does not resolve to the DID you asked for.
The hash chain. Each entry's hash is computed with its own versionId
replaced by the previous entry's versionId, or by the SCID for the first
entry, and must equal the hash in its versionId. Each entry is therefore bound
to every entry before it: editing or reordering an entry, or removing one from
the middle, breaks the chain. Removing entries from the end does not. A
shortened log still verifies and resolves to an earlier version, so compare the
versionId you resolve with the newest one you have seen. Each hash is SHA-256 over the entry's
JSON Canonicalization Scheme form,
written as a base58btc multihash.
The version and the time. Version numbers rise by exactly one, and each
versionTime is later than the one before. A resolver may refuse an entry dated
more than a few minutes in the future; OktoID's signing agent refuses to date an
entry more than a minute ahead of its own clock, so it never produces one.
The proof. Each entry carries one DataIntegrityProof with the
eddsa-jcs-2022 cryptosuite and the purpose assertionMethod. Its
verificationMethod is did:key:<update key>#<update key>. The signature must
verify over the entry, and the key must be one the log authorizes at that point
(next section).
The document. The state of the last entry is the resolved DID document,
and its id must be the DID you asked for.
The resolver's answer also carries the last entry's versionId and
versionTime, which say which version you resolved and when it was made.
Key rotation and nextKeyHashes
The key that signs an entry is not the key the organization signs credentials with. It is an update key, used only to sign the log.
OktoID turns on the method's pre-rotation from the first entry. Each entry
names in updateKeys the key that signed it, and commits in nextKeyHashes to
the hash of the key that must sign the entry after it. The hash is the base58btc
multihash of the next key's public Multikey value, so the next key itself is
not revealed until it is used. While pre-rotation is on, the resolver checks
each entry against two things:
- its proof verifies under a key in its own
updateKeys; - the hash of that key is in the previous entry's
nextKeyHashes.
So every entry is signed by a key the entry before it committed to, and commits to a new one. OktoID rotates the update key on every entry: the method allows nothing else while pre-rotation is on. Once an entry is published, the key that signed it can sign nothing the method would accept, and OktoID destroys it.
What this gives a verifier: someone who steals the current update key cannot add an entry, because the next entry must be signed by a key whose hash is already published and which has never been used. What it costs the organization: if the key that was committed to is lost, no further entry can be made and the identifier cannot change again. The keys live on the deployment's hardware security module (a software token on a local stack), which is what makes that loss unlikely; the method's provision for spare committed keys is not used in this release.
The DID document
OktoID renders each version's document from a fixed list of members, and an administrator previews the exact text before it is signed. The document holds only:
| Member | Value |
|---|---|
@context | The DID core context and the Multikey context |
id | The did:webvh DID |
alsoKnownAs | The organization's entity identifier, https://<slug>.identity.<domain>, and did:web:<host> |
verificationMethod | One Multikey key, P-256, with the id <DID>#key-<n> where <n> is the version that introduced it |
assertionMethod, authentication | That key's id |
There is no service, no controller member at the top level, and no
keyAgreement, capabilityInvocation or capabilityDelegation. The assertion
key is the one the organization signs credentials with; it is never an update
key.
The entity identifier in alsoKnownAs is the address at which the organization
publishes its OpenID Federation entity configuration, once it has been admitted
to the trust tree. Read the trust tree shows
how to check that address. The link in the other direction, from the entity
configuration to the DID, is not published in this release.
When the organization rotates its assertion key, a new entry carries a document
with a new key, numbered for the new version. The old key leaves the current
document but stays in the earlier entries, and the method lets a resolver
resolve an earlier version by its versionId or versionTime. That is how
something signed before the rotation can still be checked.
The parallel did:web document
Every did:webvh identifier also answers as did:web:<host>, for verifiers
that resolve only did:web. Beside the log, the same host serves:
https://<host>/.well-known/did.json
with Content-Type: application/did+ld+json. The method's section
Publishing a Parallel did:web DID defines this document, and OktoID follows
its text. Starting from the last entry's document, it:
- adds the two services the method implies for every
did:webvhidentifier:{"id":"#files","type":"relativeRef","serviceEndpoint":"https://<host>/"}and{"@context":"https://identity.foundation/linked-vp/contexts/v1","id":"#whois","type":"LinkedVerifiablePresentation","serviceEndpoint":"https://<host>/whois.vp"}; - replaces
did:webvh:<scid>:withdid:web:throughout; - adds the full
did:webvhDID toalsoKnownAs; - removes duplicates from
alsoKnownAs, and thedid:webDID itself.
So the parallel document names exactly the same key as the log's last entry. Its
alsoKnownAs holds the entity identifier and the did:webvh DID, and the
did:webvh document's alsoKnownAs holds did:web:<host>: each points at the
other.
Three details of the result follow the specification's text and may differ from
what a library produces if you derive the document yourself. The service ids are
the relative #files and #whois, not absolute DID URLs. The #files endpoint
ends in a slash. There is no top-level controller. If you compare the served
document with one you derived, compare the keys and the names, not the bytes.
The two services describe where the method would find more of the
organization's files. OktoID does not serve anything at either endpoint in this
release, so following #files or #whois answers 404.
What the parallel document cannot tell you. It is a single file with no
history and no signature. A verifier that resolves did:web learns which key the
host serves now, and cannot tell a rotation from a substitution or detect that
the host's owner changed. The log is what proves continuity. Where you can
resolve did:webvh, resolve the log.
OktoID writes did.json only after the log that holds the same document is
served, and it derives the document twice, once in the signing agent with the
method library and once in the identifier service with code that shares nothing
with that library. A difference refuses the publication. It cannot publish a key
the log does not.
An organization may instead choose did:web as its only method. Such an
identifier has no log: did.json is all there is, and it is replaced when the
key changes. The identifier's method reads web and its history reads
false.
Caching
Both files are served with Cache-Control: public, max-age=300, and the edge
that serves them keeps a copy for the same five minutes.
- After a new version is published, a resolver may go on receiving the
previous log for up to five minutes. The previous log is still valid: it is
the new one without its last line. If you need the newest version, wait for
max-ageto pass and fetch again. - The edge never serves a copy older than
max-age. It does not honorstale-while-revalidateorstale-if-error, and it ignores a client'sCache-Control: no-cache, so one client cannot empty the cache for everyone. - Honor
max-agein your own cache. Do not hold a log longer than that if you need to see key rotations promptly.
Checking an identifier yourself
Any resolver that implements did:webvh 1.0 can check an OktoID identifier,
and checking with one that OktoID did not write is the point. OktoID's own
signing code uses didwebvh-ts. OktoID checks what it publishes with
didwebvh-rs, the Decentralized Identity Foundation's Rust implementation, which
shares no code with it.
On a local stack, the repository wraps that resolver in a container:
pnpm oktoid resolve-identifier did:webvh:<scid>:acme.identity.local.oktoid.app
The container fetches the log through the stack's edge like any outside client,
trusting only the stack's development root, and checks everything in
What the resolver checks. It prints the DID, the
versionId, the versionTime and the document, and exits 0 only if the whole
log verified. --json prints the resolver's whole answer, including the
did:web document the library derives. A did:web DID given to the same
command is resolved without the container: the command derives the URL from the
DID and fetches did.json, which is a weaker check because there is nothing to
verify but the document's own content.
The same container checks a log saved to a file, with no network at all:
docker build -t oktoid/resolver:local infra/docker/resolver
docker run --rm --network none -v ./did.jsonl:/in.jsonl:ro oktoid/resolver:local \
--log /in.jsonl did:webvh:<scid>:<host>
It exits 0 if the log resolved, 1 if it did not (with the library's own
reason), and 2 if the command was not understood.
The governance tutorial Create an identifier creates, publishes and resolves an identifier on a local stack from start to finish.
Not in this release
- Witnesses and watchers. Neither is configured. An entry is authorized by its update key alone.
- Deactivation. No operation sets
deactivated. - Moving to another host. The log is portable, so a move would keep the SCID, but no operation performs one.
- DID URL paths and
/whois. Nothing is served under the implied services' endpoints. - More than one identifier per organization. An organization has one, of either method.