Skip to main content

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>
PartWhat 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:

MemberWhat it holds
versionId<n>-<entry hash>: the version number, starting at 1, and the hash of this entry
versionTimeWhen the entry was made, in UTC, to the second
parametersHow 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
stateThe DID document of this version
proofOne signature over the entry

The first entry's parameters are fixed by OktoID and are always these:

ParameterValueMeaning
methoddid:webvh:1.0The version of the method the log follows
scidThe SCIDRepeated here so the resolver can check it
updateKeysOne Ed25519 keyThe key that signed this entry
nextKeyHashesOne hashA commitment to the key that must sign the next entry (see Key rotation)
portabletrueThe identifier may later move to another host and keep its SCID
deactivatedfalseThe 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:

MemberValue
@contextThe DID core context and the Multikey context
idThe did:webvh DID
alsoKnownAsThe organization's entity identifier, https://<slug>.identity.<domain>, and did:web:<host>
verificationMethodOne Multikey key, P-256, with the id <DID>#key-<n> where <n> is the version that introduced it
assertionMethod, authenticationThat 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:

  1. adds the two services the method implies for every did:webvh identifier: {"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"};
  2. replaces did:webvh:<scid>: with did:web: throughout;
  3. adds the full did:webvh DID to alsoKnownAs;
  4. removes duplicates from alsoKnownAs, and the did:web DID 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-age to pass and fetch again.
  • The edge never serves a copy older than max-age. It does not honor stale-while-revalidate or stale-if-error, and it ignores a client's Cache-Control: no-cache, so one client cannot empty the cache for everyone.
  • Honor max-age in 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.