Covers client/src/did.js, the DID panes on VC issuance step 1, did-tools.html, and the VC Tools partial. The mock issuer’s own DID documents are described in docs/mock-sts.md.
A credential may name its issuer by DID instead of by URL, and the two formats stand differently, which is the first thing to know before changing any of this. ldp_vc is DID-native: VC Data Model 2.0 and Data Integrity assume it. dc+sd-jwt is not — draft-ietf-oauth-sd-jwt-vc says in as many words that “a DID-based mechanism is not explicitly provided herein but still possible via profile/extension”, and defines only /.well-known/jwt-vc-issuer and inline x509. So for SD-JWT VC this is an extension and is labelled as one everywhere it appears, and the DID identifies the issuer only: holder binding stays cnf.jwk, because a DID there would be nobody’s convention and walt.id already behaves this way. (Unrelated trap: RFC 9101 is JWT-Secured Authorization Request, nothing to do with DIDs. It is already used here for the OID4VP request-by-reference flow; do not cite it for DID work.)
The mock issuer offers two credential configurations whose credentials name the issuer by did:web — IdentityCredentialDid (dc+sd-jwt) and IdentityCredentialLdpVcDid (ldp_vc) — beside their plain siblings, which keep the https identifier. That pairing is deliberate and is the reason it is not a server-wide switch: with a switch, a run exercises the DID route or the specification’s own route but never both, and for dc+sd-jwt the route the spec actually defines is the one that must go on being tested. The two entries are cloned from their siblings in vciMetadata() rather than written out again, so a claim or proof type added to one cannot go missing from the other, and tests/vc_did.js asserts they differ in nothing but scope, display name and the identifier advertised. The startup flags OID4VCI_SD_JWT_ISSUER_DID / OID4VCI_LDP_VC_ISSUER_DID still exist and switch the plain configurations over — what a deployment that had gone to DIDs throughout would look like — and stay off by default. issuerDidFor(configId, req) is the single place that decides, so the metadata and the credential cannot disagree about who issued it.
Three documents make the DID discoverable rather than merely asserted, and they answer different questions:
| Document | Member | Answers |
|---|---|---|
/.well-known/openid-credential-issuer |
issuer_did, and issuer_identifier per configuration |
which DID this issuer answers to, and which identifier this configuration’s credentials will carry. Both are extensions — OID4VCI registers neither |
/.well-known/jwt-vc-issuer |
issuer_did beside jwks_uri |
the same DID, named from SD-JWT VC’s own key-resolution document. Its issuer stays the https identifier: the spec has a verifier insert the well-known path into the credential’s iss and require that this document’s issuer equals what it started from, and a DID cannot be the subject of that rule — which is precisely why the DID route is an extension |
/.well-known/did-configuration.json |
DIF Well Known DID Configuration | why the DID should be believed to be the same entity as the origin. The only one of the three that is a real spec and is checkable |
That last one is the point of the exercise, and the reason is worth keeping: for did:web the other documents only look like an answer. Resolving did:web:example.com means fetching example.com, so reading a DID document off that origin to decide whether the DID belongs to it is circular. The Domain Linkage Credential is not — the DID signs, with its own key, a credential naming the origin, and a verifier resolves the DID independently, checks the signature against the keys it authorises to assert, and requires that credentialSubject.origin is the origin the document came from. verifyOriginLinkage() additionally insists the linkage is for the DID asked about: an origin that links its own DID has not vouched for anybody else’s, and without that check “linked” would be a property of the file existing rather than of what it says.
The JWT form is served rather than the Linked Data Proof form (the spec allows either): this issuer signs RS256 JWTs everywhere else, so the same key and JWKS verify it, where the LD form would need JsonWebSignature2020 over URDNA2015 canonicalization for no additional teaching. Two details of that form are what a JWT library gets wrong for you, and both produce a document that looks perfectly right: the header MUST NOT carry typ (jsonwebtoken adds typ: "JWT" unless the header override sets it undefined), and the payload permits no member beyond iss/sub/nbf/exp/vc (it adds iat unless told noTimestamp). An LD-proof entry that arrives is reported as unverifiable here, not invalid — it is somebody else’s conforming document.
Three places in the client had to change for any of this to do anything, and the first two were the plumbing being connected rather than new features:
did:jwk issuer only. resolveIssuerJwks() called sd_jwt_vc.js’s jwkFromDid(), which handles that one method because the identifier is the key — so a did:web or did:key iss fell straight past it into the /.well-known/jwt-vc-issuer chain, a URL that cannot be built from a DID, and then verified off the last-resort fallback by luck or not at all. It now goes through did.js’s resolve() and assertionJwks(), keeping the well-known and credential_issuer fallbacks (which is what keeps walt.id working).ldp_vc proof’s verificationMethod with fetch(). That works for the https URL these credentials used to carry and cannot work for a DID URL, so a DID-issued ldp_vc was unpresentable — and the symptom was the issuer’s key reported unreachable, which reads as a broken issuer rather than a wallet that cannot follow a DID. resolveVerificationMethod() handles both forms, and deliberately has no fallback to “the first key in the document”: the proof named one method, and this issuer’s document publishes an RSA signing key before the BBS key, so a fallback would silently hand back the wrong one.did.js was already verified against the did:key spec’s own vectors when it was written, and tests/did_document.js still found a real bug on its first run: P-384’s and P-521’s field primes and b coefficients were nine digits short, having been written as decimal strings split across two literals with a +. A truncated 116-digit decimal is invisible, and the failure it produced was a refusal rather than a wrong answer — with the wrong field no x has a square root, so every P-384 did:key was rejected as “not on the curve”. P-256 was correct, and P-256 is the only curve anything here uses in anger, which is exactly why nothing had noticed. They are now hex, on one line each however long the line, because that is how FIPS 186-4 and SEC 2 publish them and a digit can be checked by eye. Two other constants in the same file are the same class of hazard and are pinned by that test: multicodec is an unsigned varint, so P-256’s 0x1200 is the bytes 0x80 0x24 (which is why every P-256 did:key begins zDn, and why writing it 0x12 0x00 yields DIDs that decode here and nowhere else), and did:key carries a compressed EC point, so recovering y is a modular square root whose wrong root gives the other, equally well-formed point on the curve.
A third metadata pane beside the credential issuer’s and the authorization server’s, for /.well-known/did-configuration.json (DIF Well Known DID Configuration) — retrieved by URL or uploaded from a file, tabulated, checked, and pushed into its own section of Configuration Parameters. Deliberately the same controls as the AS pane, because it is the same job on a third document.
It differs in one way that is not cosmetic: the other two panes can only check a document’s shape, and this one can also check whether it is true. So it carries both, and they answer different questions:
validateDidConfiguration() in metadata_schema.js, rules transcribed from the specification with a citation each. Two of them are what a JWT library breaks for you: the JWT-form header MUST NOT carry typ, and the payload permits nothing beyond iss/sub/nbf/exp/vc — so typ: "JWT" and iat are the likeliest way to publish a document that reads correctly and is refused. The resource itself permits no members beyond @context and linked_dids, and the credential none beyond its list.did.js’s verifyDomainLinkage() per entry: resolve the DID, verify the signature against the keys it authorises to assert, and check the origin it names is the origin it was fetched from. Checked against the URL’s origin, not the one the credential states — comparing the credential with itself would pass for any document.The case that proves they are not the same check: the STS’s own document, retrieved and then verified against a different origin, stays “satisfies every rule” and becomes NOT LINKED. didConfigurationPane() in tests/sd_jwt_vc_issuance.js asserts exactly that, and tests/metadata_schema_validation.js covers 25 schema rules, each with the severity the specification implies (a missing expirationDate and an origin carrying a path are warnings; the rest are errors).
The Configuration Parameters section lists the resource’s two members plus derived facts — the linked DID, the origin, which of the two credential forms it is in, the verification method, and the validity window. Those are the facts a reader came for and every one of them is buried inside a JWT that a two-member table would show as one unreadable line; didConfigurationDetails() in did.js does the deriving, so this pane and DID Tools cannot disagree about what a linkage says. It reads the first credential and the pane says how many were found, because an origin may link several and a two-column table cannot show a set.
Two traps in testing this pane, both of which cost a run. The Configuration Parameters section’s group headings are div.vc-config-group + h4, not tr.vc-group-heading — the pane is laid out in three CSS columns, so a group has to be able to break across them (see Step 1 is laid out for one screen in docs/sd-jwt-vc-issuance.md). stepOne() in tests/sd_jwt_vc_issuance.js counted only the older shape and reported “0 groups” about a pane with seven; it now counts both, since metadata_client.groupRow() still emits the tr form for oauth2_oidc_1.html. And the wrong-origin negative uses an http:// origin deliberately: this pane derives the scheme for resolving the linked did:web from the origin it is checking against, so an https origin makes the DID resolve over https to a plain-HTTP STS, fail with ERR_SSL_PROTOCOL_ERROR, and be counted by severeErrors() as a browser error that fails the whole run. The scheme is not what that case tests; the origin mismatch is.
Two smaller decisions: the URL is offered, not demanded — the resource’s path is fixed by the specification, so the only unknown is the origin, which the credential issuer metadata has just supplied; and on reload the pane restores its table and its schema verdict but not its linkage verdict, because the latter depends on resolving a DID over the network and a remembered “LINKED” would be a claim about a fetch that may no longer be true.
did-tools.html is a general-purpose DID verifier: resolve a DID (did:jwk and did:key decode locally, did:web is fetched), retrieve a document by URL, upload one, check that a key it publishes really signed the credential in the browser, and check a domain linkage against any origin. It is the same three panes as VC Issuance step 1’s Issuer DID Document pane, with the “Issuer” wording dropped and the document’s members promoted to a pane of their own, plus the expand/collapse-all toggle every other page has.
It is a copy, not a move, and the two must not tread on each other. Step 1 keeps its own pane, because there a resolved document is the issuance workflow’s configuration — it populates the did_* Configuration Parameters, which is what the credential request then uses. did-tools.html is independent of all of that. They share element ids (same markup) but no storage key: everything the tools page writes is prefixed didtools_. Without that, resolving a DID on the tools page would silently rewrite the issuance configuration and Clear there would wipe it, from a page opened only to look something up.
Two traps in that arrangement, both of which bit while it was being built:
did_identifier was both). Namespacing the key broke every val()/setVal() in the copied code, and the failure is silent in the worst way — the field reads back empty and the page says “that is not a DID” about a DID you can see in the box. did_tools.js therefore keeps *_FIELD constants for ids and *_KEY for storage, and never passes one where the other belongs.credential_issuer. Both are reads; it writes none of the workflow’s keys.For testing, the STS mints DIDs on demand: GET /did/generate?method=jwk|web (non-spec, and listed as such on /admin/sts-metadata) returns a DID, its document, and a credential signed by the key that DID publishes. That last part is the point — a DID with nothing signed by it can only be parsed, not verified, so a test given one could assert nothing about the checks that matter. jwk generates a fresh P-256 key per request and signs ES256; web returns this service’s own did:web and signs RS256 with the key its document publishes. did:key is deliberately absent: encoding one needs multicodec varints and base58btc, which live in client/src/did.js, and a second implementation in the STS would be free to agree only with itself.
Every page of both workflows carries a VC Tools pane at the bottom, from partials/vc_tools.html — one copy for nine pages — with the link to DID Tools. Its fieldset is vc_tools_fieldset, which each page adds to its own collapse-all list so the toggle at the top covers it; the two chooser pages (vc-issuance-0, vc-presentation-0) have no such toggle and rely on the pane’s own clickable legend.