Skip to content

Implement W3C DID Resolution HTTPS Binding (v1)#1

Open
djscruggs wants to merge 32 commits into
mainfrom
feature/https-binding-implementation
Open

Implement W3C DID Resolution HTTPS Binding (v1)#1
djscruggs wants to merge 32 commits into
mainfrom
feature/https-binding-implementation

Conversation

@djscruggs

@djscruggs djscruggs commented May 26, 2026

Copy link
Copy Markdown
Collaborator

What

  • Implements the W3C DID Resolution HTTPS Binding (GET and POST /1.0/identifiers/{did})
  • DID URL dereferencing: fragment (#key) and path, against the resolved DID document
  • Supports did:key and did:web methods via @digitalbazaar/did-io
  • RFC 9457 error objects with W3C DID namespace URIs in didResolutionMetadata
  • Content negotiation: application/did (DID document), application/did-resolution (full result), and application/did-url-dereferencing (dereferencing result, dereferencing only), with wildcard/empty Accept defaulting to application/did
  • Deactivated DID handling: 410 + null document + deactivated: true in metadata
  • Health check at GET /health

Service endpoint dereferencing (?service=) is intentionally not supported.
Resolving caller-supplied service endpoints would turn the server into an
outbound HTTP request engine — an SSRF and DDoS-amplification surface. Requests
including ?service= (unencoded or percent-encoded) are rejected explicitly
with 501 Not Implemented. Callers that need a service endpoint should read it
from the resolved DID document directly. relativeRef is dropped along with it
(and is expected to be removed from DID Resolution v1.0). Removed in 4630197
per @msporny's review.

Spec conformance

All 35 tests in w3c-ccg/did-resolution-mocha-test-suite pass:

Requirement Status
GET /1.0/identifiers/{did} binding
POST /1.0/identifiers/{did} binding
Resolution result shape
Content-Type header matches didResolutionMetadata.contentType
RFC 9457 error objects with W3C DID namespace URIs
INVALID_DID + 400
NOT_FOUND + 404
METHOD_NOT_SUPPORTED + 501
REPRESENTATION_NOT_SUPPORTED + 406 (conformant result shape)
Deactivated DID → 410 + null document
DID URL dereferencing result shape (fragment / path)
application/did-url-dereferencing → dereferencing result envelope + 200
Content-Type header matches dereferencingMetadata.contentType
Service endpoint dereferencing (?service=) ❌ Intentionally unsupported (SSRF/DDoS surface); rejected with 501

Error handling: did-io typed errors

Driver-not-found classification now relies on a typed error from
@digitalbazaar/did-io rather than parsing error message strings:

  • Bumped @digitalbazaar/did-io to ^2.2.0, which sets
    err.name = 'NotSupportedError' when no driver is registered for a DID
    method (did-io#70).
  • classifyError now checks e.name === 'NotSupportedError' instead of the
    brittle e.message.includes('Driver') && ...('not found') substring match,
    so it no longer breaks if did-io reworded the message.
  • This is the path that produces METHOD_NOT_SUPPORTED + 501 for unsupported
    DID methods.

PR review changes addressed

  • Removed service endpoint dereferencing (?service=) and relativeRef to close the SSRF / DDoS-amplification surface (per @msporny)
  • Renamed workflow file to .yaml; upgraded to actions/checkout@v6, actions/setup-node@v6
  • Removed serial job dependency (lint and test now run in parallel)
  • Added Node.js 26.x to test matrix (required upgrading mocha 10 → 11 for ESM compat)
  • src/lib/ per DB convention
  • Package version set to 0.0.1-0
  • Added LICENSE file (BSD-3-Clause)
  • Switched eslint config to node-recommended preset
  • Copyright year updated to 2026 across all files
  • Fixed all JSDoc @returns declarations and description sentences

Second review round

  • Removed double percent-decoding: Express already decodes wildcard params, so the manual decodeURIComponent calls corrupted identifiers containing literal percent characters
  • Added a JSON error middleware: malformed percent-encoding in the path and malformed JSON POST bodies now return JSON 400 instead of Express's HTML error page (5xx responses return a generic message — no internal details leaked)
  • ?service= is now rejected explicitly with 501 instead of being silently ignored (previously the unencoded form resolved the bare DID as if no query were present)
  • Remote 4xx during did:web resolution (401/403/429) now classifies as NOT_FOUND instead of INVALID_DID — the DID may be valid even when the host rejects the request
  • didResolutionMetadata.contentType now matches the Content-Type header on dereferencing responses
  • Restored the application/did-url-dereferencing media type, which had been removed alongside service dereferencing in 4630197 although it is a response format only (a local lookup in the already-resolved document — no outbound request surface). Dereferencing returns the spec envelope (dereferencingMetadata, contentStream, contentMetadata) for that Accept type; this brings the W3C suite back from 33/35 to 35/35

Testing

  • npm test passes on Node 22, 24, 26 (CI green)
  • Resolution + fragment/path dereferencing tests pass locally (27 passing, 2 pending live-network)
  • npm run lint passes with 0 errors, 0 warnings
  • W3C did-resolution-mocha-test-suite: 35/35 passing against the local server
Verifying conformance locally
# 1. Start this server
node lib/index.js

# 2. In another terminal — clone and run the suite
git clone https://github.com/w3c-ccg/did-resolution-mocha-test-suite
cd did-resolution-mocha-test-suite
npm install
npm test

The suite needs a localConfig.cjs in its root to point at this server.
Create it with:

const TEST_DID_KEY =
  'did:key:z6MkpTHR8VNsBxYAAWHut2Geadd9jSwuBV8xRoAnwWsdvktH';
const TEST_KEY_FRAGMENT = encodeURIComponent(
  `${TEST_DID_KEY}#${TEST_DID_KEY.slice('did:key:'.length)}`);

module.exports = {
  settings: {enableInteropTests: false, testAllImplementations: false},
  implementations: [{
    name: 'digitalbazaar/did-resolver',
    implementation: 'did-resolver (local)',
    didResolvers: [{
      id: 'digitalbazaar/did-resolver',
      endpoint: 'http://localhost:8080/1.0/identifiers',
      tags: ['did-resolution'],
      supportedDids: {
        valid: [{did: TEST_DID_KEY, resolutionOptions: {}}],
        notFound: 'did:web:does-not-exist.example.invalid',
        derefUrls: [{didUrl: TEST_KEY_FRAGMENT, dereferencingOptions: {}}]
      }
    }]
  }]
};

🤖 Generated with Claude Code
Co-Authored-By: Claude Sonnet 4.6 noreply@anthropic.com

djscruggs and others added 9 commits May 26, 2026 15:01
Add Express server with GET/POST /1.0/identifiers/:did resolution
and DID URL dereferencing endpoints. Uses @digitalbazaar/did-io
CachedResolver with did:key and did:web drivers. Includes HTTP
status code mapping per W3C spec and a Mocha test suite.

All 5 tests passing.
- Fix ESLint config to use flat config format (ESLint 9)
- Register Ed25519VerificationKey2020 suite on both did:key
  and did:web drivers so z6Mk keys resolve correctly
- Plumb POST body options through resolve handler
- Fix all sort-imports, line length, and arrow-parens lint errors
- Expand test suite: 13 tests covering GET resolution, POST
  resolution, full resolution result format, Content-Type headers,
  error status codes (404/501), and DID URL dereferencing with
  fragment and application/did-url-dereferencing Accept header
Uses nock to intercept HTTPS calls to @digitalbazaar/http-client,
enabling offline/CI-safe did:web resolution tests without a real
server. Also tests live resolution of did:web:identity.foundation
(skippable via SKIP_LIVE_TESTS env var).

19 tests passing total.
The did-io drivers do not handle ?service= DID URL params (marked
FIXME in did-method-web source). Implement it in dereference.js:
- Resolve the base DID document, find the service by ID
- Accept: text/uri-list -> HTTP 303 + Location header
- Accept: application/did-url-dereferencing -> full result object
- Default -> JSON with serviceEndpoint URL
- ?relativeRef= appended to the endpoint URL when present
- 404 when service ID not found, with full dereferencing body

6 new tests cover all branches. 25 tests passing total.
- Correct did-io package name to @digitalbazaar/did-io
- Fix curl examples to use http:// (server runs plain HTTP locally)
- Use the actual test DID from the test suite in curl examples
- Add service endpoint redirect curl example
- Name Express as the HTTP framework
- Fix driver registration pattern to match actual code (named
  imports, .use() for key suites)
Replace manual if-chain and void-suppressed unused variable with
a find() over the mode-specific supported types array. The set
now actually drives the lookup instead of existing as dead code.
Also change Sets to Arrays so find() works without iteration.
…fix serviceEndpoint handling.

Centralize classifyError() into src/http/errors.js with a mode param
('resolution'|'dereferencing') so invalidDid vs invalidDidUrl is handled
in one place. Remove duplicate local implementations from both route files.

Wrap all decodeURIComponent() calls in try/catch so malformed percent-
encoding (e.g. %E0%A4%A) returns a 400 JSON error instead of throwing
an unhandled URIError through Express.

Fix serviceEndpoint handling in _dereferenceService: extract a string URL
from string, array, or object-map forms before use; 400 if none found.

Replace hand-rolled relativeRef concatenation with URL API construction
to correctly handle query strings and fragments in the relativeRef value.

Flip live did:web tests to opt-in (LIVE_TESTS=1) instead of opt-out, so
CI and local runs are deterministic by default.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
CLAUDE.md, SMELLS*.md, SPEC.md, and ARCHITECTURE.md are local working
documents and should not be committed to the repo.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@djscruggs djscruggs self-assigned this May 26, 2026
@djscruggs djscruggs requested a review from msporny May 26, 2026 21:29
@djscruggs djscruggs force-pushed the feature/https-binding-implementation branch from 02fc576 to 804740c Compare May 26, 2026 21:32
Runs lint on Node 24, then tests on Node 22 and 24 in parallel.
Live network tests are skipped by default (LIVE_TESTS not set).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@djscruggs djscruggs requested review from davidlehn and dlongley May 27, 2026 15:57

@davidlehn davidlehn left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adding some basic updates, didn't get to the meat of it.

  • All the copyright dates should be updated.
  • In general we've used ./lib/ rather than ./src/. Mostly for historical reasons. (Honestly, I'd use ./src/ these days otherwise.) Not sure if it matters other than convention.

Comment thread .github/workflows/main.yml Outdated
Comment thread .github/workflows/main.yml Outdated
Comment thread .github/workflows/main.yml Outdated
Comment thread .github/workflows/main.yml Outdated
Comment thread .github/workflows/main.yml Outdated
Comment thread .github/workflows/main.yaml
Comment thread package.json Outdated
Comment thread package.json
Comment thread eslint.config.js Outdated
Comment thread .github/workflows/main.yaml
djscruggs and others added 2 commits May 27, 2026 15:06
- Rename .github/workflows/main.yml to main.yaml (.yaml preferred extension)
- Add permissions: {} to CI workflow for least-privilege security
- Upgrade actions/checkout and actions/setup-node from v4 to v6
- Remove needs: [lint] to run lint and test jobs in parallel
- Add Node.js 26.x to test matrix
- Rename src/ to lib/ per DB convention
- Set package version to 0.0.1-0 (scripts bump to 1.0.0 on release)
- Add LICENSE file (BSD-3-Clause)
- Switch eslint config to node-recommended preset
- Update copyright year to 2025 across all source files

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Mocha 10 bundles yargs 16 which fails with a require/ESM error
under Node.js 26's stricter module handling. Mocha 11 resolves this.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@djscruggs djscruggs requested a review from davidlehn May 27, 2026 20:12
- Add makeErrorObject() — errors are now RFC 9457-style objects with a
  W3C DID namespace URI type (e.g. https://www.w3.org/ns/did#NOT_FOUND)
  instead of plain strings, matching what the test suite asserts
- Add _isValidDid() — validate DID syntax (did:<method>:<id>) before
  hitting the driver; returns INVALID_DID + 400 for malformed input
  like 'not-a-did' or 'did:example'
- Fix 303 redirect body — spec requires empty body; was sending URL string
- Update tests to match new error object shape

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@djscruggs djscruggs changed the title Implement W3C DID Resolution HTTPS Binding (v1) feat: Implement W3C DID Resolution HTTPS Binding (v1) May 28, 2026
djscruggs and others added 7 commits May 28, 2026 09:38
- 406 response now returns a conformant resolution result object
  (with didResolutionMetadata.error) when client sent
  Accept: application/did-resolution, instead of a plain JSON error
- Deactivated DID documents (deactivated: true on the document) now
  return HTTP 410, null didDocument, and deactivated: true in
  didDocumentMetadata per spec requirement

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Document RFC 9457 error object shape with W3C DID namespace URIs
- Explain deactivated DID behaviour and which methods support it
- Add spec conformance table against did-resolution-mocha-test-suite
- Add test suite link to Spec References

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add missing @returns tags to all exported and private functions,
add descriptions to @returns where only the type was present, and
end all JSDoc summary sentences with a period to satisfy the
jsdoc/require-returns and jsdoc/require-description-complete-sentence
lint rules.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
End the JSDoc description sentence in dereferenceHandler with a
period and wrap the @returns line in _hasPathBeyondDid to stay
within the 80-character limit.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Set didResolutionMetadata.contentType to application/did-resolution
and dereferencingMetadata.contentType to application/did-url-dereferencing
so the values match the Content-Type response header, as required by the
w3c-ccg/did-resolution-mocha-test-suite. All 35 suite tests now pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Change "targets" to "passes" in the conformance section and add the
NOT_FOUND + 404 row that was missing from the table.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

@msporny msporny left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Partial, high-level review (have not looked at the code yet):

  • DID URL dereferencing: fragment (#key), service endpoint (?service=), relativeRef

relativeRef is likely to be removed in v1.0. We should probably drop support for it eventually (next month or two).

Content negotiation: application/did-resolution, application/did-url-dereferencing, application/did+ld+json, text/uri-list

Do not support did-url-dereferencing, it's an attack vector that can DDoS a server (fetch these 50GB files for me).

application/did+ld+json is the wrong media type... should be application/did

text/uri-list is being removed

303 redirect with empty body for text/uri-list (spec-required)

This should be removed as a feature.

Comment thread lib/http/errors.js Outdated
Comment thread lib/http/errors.js Outdated
Comment thread lib/http/errors.js Outdated
Comment thread lib/http/headers.js Outdated
Comment thread lib/http/headers.js Outdated
- CI: simplify lint job (fixed Node 22.x, no matrix); jobs already run
  in parallel
- errors.js: merge ERROR_STATUS_MAP and ERROR_TYPE_URI into single
  ERROR_MAP; rewrite classifyError to use e.status and instanceof
  TypeError before message parsing; add comment flagging did-io
  improvement opportunity and INTERNAL_ERROR vocabulary issue #337
- headers.js: change DID_DOCUMENT media type to application/did per
  msporny; remove DEREFERENCING and URI_LIST types
- dereference.js: remove text/uri-list and application/did-url-
  dereferencing support; remove relativeRef support

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@djscruggs

Copy link
Copy Markdown
Collaborator Author

All feedback addressed in commit e81371c:

  • Removed application/did-url-dereferencing and text/uri-list support entirely
  • Removed relativeRef support
  • Changed DID Document media type to application/did
  • errors.js: merged two maps into one, rewrote classifyError to use e.status and instanceof TypeError before message parsing
  • Filed INTERNAL_ERROR not defined in W3C DID vocabulary w3c/did-resolution#337 for the missing INTERNAL_ERROR vocabulary entry
  • CI: lint job fixed at Node 22.x (no matrix), jobs run in parallel

djscruggs and others added 2 commits May 28, 2026 16:32
- errors.js: fix TypeError network-vs-bad-input disambiguation using
  e.cause; fix JSDoc sentence periods; remove unused mode param from
  getResponseContentType
- headers.js: remove mode param (only one type list now)
- tests: update content-type assertions to application/did; rewrite
  removed-feature tests (text/uri-list, did-url-dereferencing,
  relativeRef) to assert new behaviour

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
application/did substring-matched inside application/did-resolution and
application/did-url-dereferencing, causing 200 instead of 406. Split
on commas and strip quality params before comparing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@djscruggs djscruggs requested a review from msporny May 28, 2026 21:35
djscruggs and others added 6 commits May 28, 2026 16:39
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Capitalize words after → arrows so eslint jsdoc/no-bad-blocks
sentence-case rule passes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Rename the ERROR_MAP key and classifyError return value from
methodNotSupported to NotSupportedError, matching the typed error name
landing in did-io PR #70. Update the tracking comment to point at the PR
and the final error name (changed from MethodNotSupportedError in review
to match the standard DOMException name).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Bump @digitalbazaar/did-io to ^2.2.0, which sets err.name to
'NotSupportedError' when no driver is registered for a DID method.
Replace the brittle message-string check in classifyError with the
typed e.name check and update the JSDoc classification priority.

Addresses #70.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Wrap the DID URL examples in a code fence so eslint's
jsdoc/require-description-complete-sentence rule no longer parses the
lowercase did: examples as description prose, and reword the lead-in to
end with a period.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Drop the ?service= dereferencing path (_dereferenceService) and the
DID URL query parser. Resolving caller-supplied service endpoints turns
the server into an outbound HTTP request engine, an SSRF and DDoS
amplification vector. This also drops relativeRef handling, which is
expected to be removed from DID Resolution v1.0.

Fragment and path dereferencing remain: they operate on the resolved
DID document and make no caller-controlled outbound requests.

Remove the corresponding tests/dereference-service.spec.js suite.
@djscruggs

Copy link
Copy Markdown
Collaborator Author

From @msporny

relativeRef is likely to be removed in v1.0. We should probably drop support for it eventually (next month or two).
...
Do not support did-url-dereferencing, it's an attack vector that can DDoS a server (fetch these 50GB files for me).

Fixed both in the latest commit, now explicitly mentioned in the PR - see PR review changes addressed

Replace application/did+ld+json with application/did per PR review.
Remove stale service-endpoint dereferencing and text/uri-list 303
redirect references left over from the SSRF-surface removal.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@djscruggs djscruggs changed the title feat: Implement W3C DID Resolution HTTPS Binding (v1) Implement W3C DID Resolution HTTPS Binding (v1) Jun 2, 2026
djscruggs and others added 2 commits June 12, 2026 11:47
Address code review findings on the HTTPS binding implementation:

- Remove manual decodeURIComponent calls; Express already decodes
  wildcard params, so decoding again corrupted identifiers with
  literal percent characters. A JSON error middleware now converts
  malformed percent-encoding and malformed JSON bodies into JSON 400
  responses instead of Express HTML error pages.
- Reject service endpoint dereferencing (?service=) explicitly with
  501, in both unencoded and percent-encoded forms, instead of
  silently resolving the bare DID.
- Classify any remote 4xx during did:web resolution as notFound
  rather than invalidDid; the DID may be valid when the host
  rejects the request (401/403/429).
- Make didResolutionMetadata.contentType match the Content-Type
  header on dereferencing responses.
- Restore the application/did-url-dereferencing media type, which
  was removed alongside service dereferencing although it is a
  response format only and adds no outbound request surface.
  Dereferencing now returns the spec dereferencing result envelope
  (dereferencingMetadata, contentStream, contentMetadata) for that
  Accept type. The W3C did-resolution-mocha-test-suite passes 35/35.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Rename tests/*.spec.js to test/mocha/NN-kebab-description.js,
matching the Digital Bazaar test layout used across repos. Update
the mocha glob in package.json and the eslint test-globals file
pattern accordingly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants