From 21639a68828ef8ed7041852f8c5252bff1d5747e Mon Sep 17 00:00:00 2001 From: bars26 Date: Mon, 21 Sep 2026 22:14:33 +0300 Subject: [PATCH] fix(did): default getDidResolver() to https-only did:web/did:jwks resolution getDidResolver() defaulted webOptions.allowedHttpHosts to ["localhost", "127.0.0.1", "0.0.0.0"] when called without webOptions, so a default verifier sent plain http:// requests to its own loopback while resolving an attacker-chosen did:web (e.g. did:web:127.0.0.1%3A6379), before any signature check could reject the token. This contradicted the did:web resolver's own documented default of [] and only applied when webOptions was omitted entirely. Default to no plain-http hosts, matching the resolver. Callers that need plain http for local development opt in with getDidResolver({ webOptions: { allowedHttpHosts: [...] } }); the identity-a2a demo, which fetches a localhost did:web, does so, and the local-did-host example docs now show the opt-in. Fixes #223. Co-Authored-By: Claude Sonnet 5 --- .changeset/https-only-did-resolver-default.md | 22 ++++++ demos/identity-a2a/src/bank-client-agent.ts | 5 +- docs/demos/example-local-did-host.mdx | 12 +++ examples/local-did-host/README.md | 12 +++ packages/did/README.md | 3 + .../did-resolvers/get-did-resolver.test.ts | 75 +++++++++++++++++++ .../did/src/did-resolvers/get-did-resolver.ts | 10 ++- 7 files changed, 134 insertions(+), 5 deletions(-) create mode 100644 .changeset/https-only-did-resolver-default.md diff --git a/.changeset/https-only-did-resolver-default.md b/.changeset/https-only-did-resolver-default.md new file mode 100644 index 00000000..e9d87ac0 --- /dev/null +++ b/.changeset/https-only-did-resolver-default.md @@ -0,0 +1,22 @@ +--- +"@agentcommercekit/did": minor +--- + +`getDidResolver()` now fetches did:web and did:jwks documents over `https` only +by default + +When called without `webOptions`, `getDidResolver()` defaulted +`allowedHttpHosts` to `["localhost", "127.0.0.1", "0.0.0.0"]`, so a default +verifier sent plain `http://` requests to its own loopback while resolving an +attacker-chosen DID, for example `did:web:127.0.0.1%3A6379`, before any +signature check could reject the token. That contradicted the did:web +resolver's own documented `allowedHttpHosts` default of `[]`, and it only +applied when `webOptions` was omitted altogether (passing any other +`webOptions` already meant no plain-http hosts). + +The default is now `[]`, matching the resolver. To resolve over plain `http` +for local development, opt in explicitly: + +```ts +getDidResolver({ webOptions: { allowedHttpHosts: ["localhost"] } }) +``` diff --git a/demos/identity-a2a/src/bank-client-agent.ts b/demos/identity-a2a/src/bank-client-agent.ts index 54bb45f1..6fe664ad 100644 --- a/demos/identity-a2a/src/bank-client-agent.ts +++ b/demos/identity-a2a/src/bank-client-agent.ts @@ -382,7 +382,10 @@ class BankClientAgent extends Agent { try { logger.log("🔍 Resolving bank teller DID document...") - const resolver = getDidResolver() + // The bank teller runs locally over plain http, so opt in explicitly + const resolver = getDidResolver({ + webOptions: { allowedHttpHosts: ["localhost"] }, + }) const didResult = await resolveDid(serverDid, resolver) const didDocument = didResult.didDocument diff --git a/docs/demos/example-local-did-host.mdx b/docs/demos/example-local-did-host.mdx index 593048ac..0ee394ef 100644 --- a/docs/demos/example-local-did-host.mdx +++ b/docs/demos/example-local-did-host.mdx @@ -40,6 +40,18 @@ Default served identities: | `agent` | did\:web:0.0.0.0%3A3458\:agent | [http://0.0.0.0:3458/agent/.well-known/did.json](http://0.0.0.0:3458/agent/.well-known/did.json) | | `controller` | did\:web:0.0.0.0%3A3458\:controller | [http://0.0.0.0:3458/controller/.well-known/did.json](http://0.0.0.0:3458/controller/.well-known/did.json) | +## Resolving these DIDs + +These DIDs are served over plain `http`, and `getDidResolver()` fetches +`did:web` documents over `https` only by default. To resolve them locally, opt in +to the host explicitly: + +```ts +const resolver = getDidResolver({ + webOptions: { allowedHttpHosts: ["0.0.0.0"] }, +}) +``` + ## How `did:web` Resolution Works Resolving a `did:web` DID involves fetching `.well-known/did.json` from the indicated domain or subpath. Examples: diff --git a/examples/local-did-host/README.md b/examples/local-did-host/README.md index 8fa10841..b4f03a3d 100644 --- a/examples/local-did-host/README.md +++ b/examples/local-did-host/README.md @@ -35,6 +35,18 @@ By default, the following identities are included. | `agent` | did:web:0.0.0.0%3A3458:agent | | | `controller` | did:web:0.0.0.0%3A3458:controller | | +## Resolving these DIDs + +These DIDs are served over plain `http`, and `getDidResolver()` fetches +`did:web` documents over `https` only by default. To resolve them locally, opt in +to the host explicitly: + +```ts +const resolver = getDidResolver({ + webOptions: { allowedHttpHosts: ["0.0.0.0"] }, +}) +``` + ## References - The `did-web` spec: diff --git a/packages/did/README.md b/packages/did/README.md index aab78399..02f1ee1b 100644 --- a/packages/did/README.md +++ b/packages/did/README.md @@ -107,6 +107,9 @@ const { did, didDocument } = createDidWebDocumentFromKeypair({ ### Resolution - `getDidResolver(options?: GetDidResolverOptions): DidResolver` - Create a resolver supporting multiple DID methods + - did:web and did:jwks documents are fetched over `https` only. To resolve + over plain `http` (for example against a local development server), opt in + with `getDidResolver({ webOptions: { allowedHttpHosts: ["localhost"] } })` - `resolveDid(didUri: string, resolver: Resolvable): Promise` - Resolve a DID to its document - `resolveDidWithController(didUri: string, resolver: Resolvable): Promise` - Resolve a DID and its controller diff --git a/packages/did/src/did-resolvers/get-did-resolver.test.ts b/packages/did/src/did-resolvers/get-did-resolver.test.ts index c5395e78..2ffadad1 100644 --- a/packages/did/src/did-resolvers/get-did-resolver.test.ts +++ b/packages/did/src/did-resolvers/get-did-resolver.test.ts @@ -3,7 +3,82 @@ import { describe, expect, it, vi } from "vitest" import type { FetchLike } from "../types" import { getDidResolver } from "./get-did-resolver" +const notFound = () => + vi.fn().mockResolvedValue(new Response(null, { status: 404 })) + describe("getDidResolver", () => { + describe("plain http policy", () => { + it("resolves did:web over https only by default, even for loopback hosts", async () => { + const mockFetch = notFound() + const resolver = getDidResolver({ webOptions: { fetch: mockFetch } }) + + await resolver.resolve("did:web:localhost%3A8787") + await resolver.resolve("did:web:127.0.0.1%3A6379") + + expect(mockFetch).toHaveBeenCalledTimes(2) + expect(mockFetch).toHaveBeenNthCalledWith( + 1, + "https://localhost:8787/.well-known/did.json", + expect.anything(), + ) + expect(mockFetch).toHaveBeenNthCalledWith( + 2, + "https://127.0.0.1:6379/.well-known/did.json", + expect.anything(), + ) + }) + + it("resolves did:web over https when webOptions is omitted entirely", async () => { + const mockFetch = notFound() + vi.stubGlobal("fetch", mockFetch) + + try { + await getDidResolver().resolve("did:web:127.0.0.1%3A6379") + + expect(mockFetch).toHaveBeenCalledWith( + "https://127.0.0.1:6379/.well-known/did.json", + expect.anything(), + ) + expect(mockFetch).not.toHaveBeenCalledWith( + expect.stringMatching(/^http:/), + expect.anything(), + ) + } finally { + vi.unstubAllGlobals() + } + }) + + it("resolves did:jwks over https only by default, even for loopback hosts", async () => { + const mockFetch = notFound() + const resolver = getDidResolver({ webOptions: { fetch: mockFetch } }) + + await resolver.resolve("did:jwks:localhost%3A3000") + + expect(mockFetch).toHaveBeenCalledWith( + "https://localhost:3000/.well-known/jwks.json", + expect.anything(), + ) + expect(mockFetch).not.toHaveBeenCalledWith( + expect.stringMatching(/^http:/), + expect.anything(), + ) + }) + + it("uses plain http for did:web when the host is explicitly allowed", async () => { + const mockFetch = notFound() + const resolver = getDidResolver({ + webOptions: { fetch: mockFetch, allowedHttpHosts: ["localhost"] }, + }) + + await resolver.resolve("did:web:localhost%3A8787") + + expect(mockFetch).toHaveBeenCalledWith( + "http://localhost:8787/.well-known/did.json", + expect.anything(), + ) + }) + }) + describe("did:jwks redirect policy", () => { it("refuses redirects by default when resolving did:jwks", async () => { const mockFetch = vi diff --git a/packages/did/src/did-resolvers/get-did-resolver.ts b/packages/did/src/did-resolvers/get-did-resolver.ts index f793c8a7..30d071c9 100644 --- a/packages/did/src/did-resolvers/get-did-resolver.ts +++ b/packages/did/src/did-resolvers/get-did-resolver.ts @@ -11,7 +11,11 @@ import { interface GetDidResolverOptions extends ResolverOptions { /** - * The options for the did:web resolver + * The options for the did:web and did:jwks resolvers. + * + * By default only `https` is used. To resolve a DID over plain `http` (for + * example against a local development server), opt in explicitly with + * `allowedHttpHosts: ["localhost"]`. */ webOptions?: DidWebResolverOptions } @@ -23,9 +27,7 @@ interface GetDidResolverOptions extends ResolverOptions { * @returns A new {@link DidResolver} instance */ export function getDidResolver({ - webOptions = { - allowedHttpHosts: ["localhost", "127.0.0.1", "0.0.0.0"], - }, + webOptions = {}, ...options }: GetDidResolverOptions = {}): DidResolver { const webFetch = webOptions.fetch ?? globalThis.fetch