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.
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.
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.
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".
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 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.
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:
keyAgreement is deferred. The spec can derive an X25519
encryption key from the Ed25519 signing key via a birational
Edwards-to-Montgomery curve map. That is curve arithmetic, not byte
encoding, so it waits on a verified X25519 conversion primitive; the
resolver omits keyAgreement rather than guess it.did:key:zQ3s… or a
P-256 did:key:zDna… has the right multibase but the wrong
multicodec, so parse_did_key returns None and didKeyResolve
reports an error rather than mis-resolving it. Widening to those
curves is follow-on work on the same verified-decoder discipline.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.
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.