Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .changeset/https-only-did-resolver-default.md
Original file line number Diff line number Diff line change
@@ -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"] } })
```
5 changes: 4 additions & 1 deletion demos/identity-a2a/src/bank-client-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
12 changes: 12 additions & 0 deletions docs/demos/example-local-did-host.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
12 changes: 12 additions & 0 deletions examples/local-did-host/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,18 @@ By default, the following identities are included.
| `agent` | did:web:0.0.0.0%3A3458:agent | <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> |

## 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: <https://w3c-ccg.github.io/did-method-web/>
Expand Down
3 changes: 3 additions & 0 deletions packages/did/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<DidUriWithDocument>` - Resolve a DID to its document
- `resolveDidWithController(didUri: string, resolver: Resolvable): Promise<DidUriWithControlledDidDocument>` - Resolve a DID and its controller

Expand Down
75 changes: 75 additions & 0 deletions packages/did/src/did-resolvers/get-did-resolver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<FetchLike>().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
Expand Down
10 changes: 6 additions & 4 deletions packages/did/src/did-resolvers/get-did-resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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
Expand Down