This page is fully self-contained: no external network request is possible (see the Content-Security-Policy above). A live mode twin of this same post can load map tiles and remote SPARQL endpoints.

Every identifier the series has used so far named something inside a graph: an IRI for a person, a class, a shape. A Decentralized Identifier (DID) names a subject and comes with a method for looking up a small controlled document — the DID Document — that says how to authenticate as, or verify signatures from, that subject. Most DID methods resolve by talking to a ledger, a registry, or an HTTP endpoint. did:key is the one that resolves to nothing but itself: a did:key:z6Mk… identifier is a public key, encoded, and "resolving" it is a total function from the identifier string to the DID Document. No network, no registry, no clock — the same character as every other computation in this series.

That makes did:key a natural fit for a verified engine. Factoidal's resolver is DID.Key.fst, a pure Tot F* module that reuses the same verified multibase/multicodec codec the Verifiable-Credentials Multikey path uses. This post resolves a real spec vector in your browser and renders the DID Document it produces.

What a did:key identifier encodes#

Strip the did:key: prefix and you are left with a multibase string — here it always starts with z, meaning base58btc. Decode that and the first bytes are a multicodec prefix naming the key type; for an Ed25519 public key the prefix is the two bytes 0xed 0x01. The remaining 32 bytes are the raw Ed25519 public key. So the whole identifier is a self-describing envelope:

did:key: │ z          │ ed01        │ <32 raw bytes>
         │ base58btc  │ Ed25519     │ the public key
         │ (multibase)│ (multicodec)│

Nothing is looked up. The resolver decodes the multibase, checks the multicodec is Ed25519, and emits the DID Document directly from the key. The document is a small RDF graph: it declares one Ed25519VerificationKey2020 verification method, gives its publicKeyMultibase (the same z6Mk… fragment, verbatim), names the DID as its controller, and registers that one method for authentication, assertionMethod, capabilityInvocation, and capabilityDelegation.

Resolving a spec vector#

The identifier below is the canonical "Create" example from the W3C-CCG did:key spec (the Ed25519VerificationKey2020 revision). It is named once, below, and every cell that resolves it references DID by name:

DID = "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK"

Resolving it needs no argument beyond the string itself. The resolve and parse are also named once — didDataset — computed a single time and reused by every cell below:

didDataset = (async () => {
  const res = await Factoidal.didKeyResolve(DID);
  return fn.parse(res.nquads, { format: "ntriples" });
})()
return didDataset.size;

Eight triples come back — the whole DID Document — computed from the identifier alone. There was no fetch: Factoidal.didKeyResolve calls straight into the F*-extracted DID_Key.did_key_document, and the only thing the browser-side code does with the result is parse the N-Triples it returns into a Dataset.

The DID Document as triples#

Here is the document in full. Flip the cell's Output toggle to Table to read it as an s/p/o grid:

return pretty(didDataset);

The subject of the first row is the DID itself; its object is the verification method, whose IRI is the DID with the multibase fragment appended (…#z6Mk…). The four …Method predicates (authenticationMethod, assertionMethod, capabilityInvocationMethod, capabilityDelegationMethod) all point back at that one method — a did:key document authorises its single key for every purpose. The predicate IRIs are the exact @id mappings from the DID and ed25519-2020 JSON-LD context documents, pinned by hand in the F* module rather than re-derived, so the resolver never "validates itself".

Reading the verification method with SPARQL#

Because the document is ordinary RDF, the verification method's facts are an ordinary SPARQL query — the key type, its controller, and the publicKeyMultibase value that carries the actual key material:

const rows = await fn.query(didDataset, `# Retrieve the verification method's type, controller, and
  # public-key value for this DID.
  PREFIX sec: <https://w3id.org/security#>
  SELECT ?type ?controller ?key WHERE {
    <${DID}> sec:verificationMethod ?vm .
    ?vm a ?type ;
        sec:controller ?controller ;
        sec:publicKeyMultibase ?key .
  }`);
return pretty(rows);

One row: the type is Ed25519VerificationKey2020, the controller is the DID, and ?key is the z6Mk… multibase string typed as a https://w3id.org/security#multibase literal — not a plain xsd:string, which is the detail that lets a consumer tell a key literal apart from an incidental string.

The document as a node-graph#

The DID Document is a small star: the DID at the centre, one verification method it points to five ways, and the method's three facts hanging off it. This cell derives the nodes and edges straight from the resolved dataset and draws them with Observable Plot — an arrow per triple, labelled with the (shortened) predicate:

// Shorten a term to a readable node label.
const label = (t) => {
  if (t.termType === "Literal") return '"' + t.value.slice(0, 8) + '…"';
  const v = t.value;
  const cut = Math.max(v.lastIndexOf("#"), v.lastIndexOf("/"));
  const tail = cut >= 0 ? v.slice(cut + 1) : v;
  return tail.length > 14 ? tail.slice(0, 6) + "…" + tail.slice(-4) : tail;
};
const predName = (p) => {
  const cut = Math.max(p.lastIndexOf("#"), p.lastIndexOf("/"));
  return cut >= 0 ? p.slice(cut + 1) : p;
};

// Unique nodes, in first-seen order, each placed around a circle.
const seen = new Map();
const addNode = (t) => {
  const key = t.termType + "�" + t.value;
  if (!seen.has(key)) seen.set(key, { key, name: label(t), i: seen.size });
  return seen.get(key);
};
const links = [];
for (const q of didDataset) {
  const s = addNode(q.subject), o = addNode(q.object);
  links.push({ sName: s.name, tName: o.name, si: s.i, ti: o.i, pred: predName(q.predicate.value) });
}
const nodes = [...seen.values()];
const n = nodes.length;
const pos = (i) => {
  const a = (2 * Math.PI * i) / n - Math.PI / 2;
  return [Math.cos(a), Math.sin(a)];
};
nodes.forEach((nd) => { const [x, y] = pos(nd.i); nd.x = x; nd.y = y; });
const edges = links.map((l) => {
  const [x1, y1] = pos(l.si), [x2, y2] = pos(l.ti);
  return { ...l, x1, y1, x2, y2 };
});

// Parallel edges between the same node pair share a midpoint, so their
// predicate labels would stack. Fan each group out along the edge's
// perpendicular so every label sits clear of the others.
const groups = new Map();
for (const e of edges) {
  const k = Math.min(e.si, e.ti) + "-" + Math.max(e.si, e.ti);
  (groups.get(k) || groups.set(k, []).get(k)).push(e);
}
for (const g of groups.values()) {
  g.forEach((e, j) => {
    const dx = e.x2 - e.x1, dy = e.y2 - e.y1;
    const len = Math.hypot(dx, dy) || 1;
    const spread = (j - (g.length - 1) / 2) * 0.24;
    e.mx = (e.x1 + e.x2) / 2 + (-dy / len) * spread;
    e.my = (e.y1 + e.y2) / 2 + (dx / len) * spread;
  });
}

// Node labels sit just outside the ring, radially, so they never land
// on the dots or the edges.
nodes.forEach((nd) => {
  const a = (2 * Math.PI * nd.i) / n - Math.PI / 2;
  nd.lx = nd.x + Math.cos(a) * 0.26;
  nd.ly = nd.y + Math.sin(a) * 0.26;
});

return Plot.plot({
  width: 680,
  height: 520,
  margin: 80,
  axis: null,
  x: { domain: [-1.7, 1.7] },
  y: { domain: [-1.7, 1.7] },
  marks: [
    Plot.arrow(edges, { x1: "x1", y1: "y1", x2: "x2", y2: "y2", bend: true, opacity: 0.35, inset: 24 }),
    Plot.text(edges, { x: "mx", y: "my", text: "pred", fontSize: 9, fill: "currentColor" }),
    Plot.dot(nodes, { x: "x", y: "y", r: 7, fill: "currentColor" }),
    Plot.text(nodes, { x: "lx", y: "ly", text: "name", fontSize: 11, fontWeight: "bold", fill: "currentColor" }),
  ],
});

Four nodes (the DID, its verification method, the Ed25519VerificationKey2020 type, and the key literal) and eight edges — every arrow is one triple in the document above, and every one of them was computed in your browser from the identifier string with no network call anywhere.

Scope: Ed25519 now, more later#

Factoidal's did:key support is Ed25519-only in this slice — the z6Mk… prefix, multicodec 0xed01. Two boundaries are deliberate and named in the module:

The resolver is exercised by the did-runner vector suite (tests/did/), which resolves the spec's canonical and test-vector z6Mk… identifiers and checks each against its hand-transcribed expected DID Document, plus the rejection paths above.

What's next#

did:key is the on-ramp to the wider Verifiable-Credentials story the VC and CSVW post began: a credential's issuer is often a did:key, and verifying its proof needs exactly the publicKeyMultibase this document exposes. The cryptographic proof-check itself waits on the verified-hash/signature work the F* story post describes.

Every live cell above is pinned in tests/hub/post23_test.mjs — the exact same source, executed against the real npm/factoidal typed API instead of the in-browser fn adapter.