|
| 1 | +# didweb — did:web DID document resolver plugin |
| 2 | + |
| 3 | +Out-of-tree implementation of the [did:web](https://w3c-ccg.github.io/did-method-web/) |
| 4 | +DID method for JSS pods. Serves the two DID documents that any DID resolver — |
| 5 | +the DIF [universal-resolver](https://dev.uniresolver.io/), `did-resolver` + |
| 6 | +[`web-did-resolver`](https://github.com/decentralized-identity/web-did-resolver), |
| 7 | +Veramo, etc. — fetches to resolve a `did:web`: |
| 8 | + |
| 9 | +| DID | resolves to (HTTPS GET) | |
| 10 | +|---|---| |
| 11 | +| `did:web:<host>` | `https://<host>/.well-known/did.json` | |
| 12 | +| `did:web:<host>:<user>` | `https://<host>/<user>/did.json` | |
| 13 | + |
| 14 | +The port, if present, is percent-encoded into the host: `localhost:3000` → |
| 15 | +`did:web:localhost%3A3000`. |
| 16 | + |
| 17 | +```js |
| 18 | +plugins: [{ module: 'didweb/plugin.js', prefix: '/didweb', |
| 19 | + config: { |
| 20 | + podsRoot: './data', // pod dirs to scan (finding 3) |
| 21 | + baseUrl: 'https://pod.example', // host for the did:web id (required) |
| 22 | + actorPathTemplate: '/ap/<user>/actor', // optional → ActivityPubActor service |
| 23 | + serviceEndpoints: [ /* extra service entries, appended verbatim */ ], |
| 24 | + } }] |
| 25 | +``` |
| 26 | + |
| 27 | +## The DID document |
| 28 | + |
| 29 | +```json |
| 30 | +{ |
| 31 | + "@context": ["https://www.w3.org/ns/did/v1", "https://w3id.org/security/multikey/v1"], |
| 32 | + "id": "did:web:pod.example:alice", |
| 33 | + "alsoKnownAs": ["https://pod.example/alice/profile/card#me"], |
| 34 | + "verificationMethod": [ |
| 35 | + { "id": "did:web:pod.example:alice#owner-key", "type": "Multikey", |
| 36 | + "controller": "did:web:pod.example:alice", |
| 37 | + "publicKeyMultibase": "zQ3sh…" } |
| 38 | + ], |
| 39 | + "authentication": ["did:web:pod.example:alice#owner-key"], |
| 40 | + "assertionMethod": ["did:web:pod.example:alice#owner-key"], |
| 41 | + "service": [ |
| 42 | + { "id": "…#pod", "type": "SolidPod", "serviceEndpoint": "https://pod.example/alice/" }, |
| 43 | + { "id": "…#linked-domain", "type": "LinkedDomains", "serviceEndpoint": "https://pod.example" } |
| 44 | + ] |
| 45 | +} |
| 46 | +``` |
| 47 | + |
| 48 | +### Verification-method derivation (ties to nostr / AP identity) |
| 49 | + |
| 50 | +The key material is read from the pod's **public** WebID profile card |
| 51 | +(`<pod>/profile/card.jsonld`) exactly as `nip05/` and `webfinger/` read it — |
| 52 | +the provisioned Nostr owner key (#437/#443) in the card's `verificationMethod`, |
| 53 | +in either `publicKeyMultibase` (f-form `f` + multicodec `e701` + parity + x) |
| 54 | +or `publicKeyJwk` (EC secp256k1) form. Every candidate is checked **on-curve** |
| 55 | +with `@noble/curves` before it is published, then re-encoded as a base58btc |
| 56 | +**secp256k1 Multikey** (`zQ3s…`). Because it is the *same* key the pod signs |
| 57 | +Nostr events / did:nostr with, the `did:web` document and the pod's Nostr |
| 58 | +identity resolve to one subject; `alsoKnownAs` binds the same subject to the |
| 59 | +pod's WebID, and the `SolidPod` service (plus an optional `ActivityPubActor` |
| 60 | +service from `actorPathTemplate`) links the DID to the pod's data plane and its |
| 61 | +fediverse actor. |
| 62 | + |
| 63 | +If a pod carries **no** key, the plugin **mints and persists an Ed25519 |
| 64 | +keypair** in `api.storage.pluginDir()` (`node:crypto`, private key never served) |
| 65 | +and publishes it as an Ed25519 Multikey (`z6Mk…`) — so every resolvable DID |
| 66 | +always has at least one verification method and a usable `authentication` |
| 67 | +reference. The root/server DID `did:web:<host>` is served from the single-user |
| 68 | +pod card at `podsRoot` if present, else from a minted server key. |
| 69 | + |
| 70 | +- Served at **both** `/.well-known/did.json` (attempted; finding 1) and the |
| 71 | + contract-safe `<prefix>/did.json`; pathed DIDs at **both** `/<user>/did.json` |
| 72 | + (attempted, WAC-governed; finding 2) and `<prefix>/<user>/did.json`. |
| 73 | +- `Access-Control-Allow-Origin: *` so any resolver/browser can fetch. Content |
| 74 | + type `application/did+json`. Pods scanned fresh per request. Unknown user → |
| 75 | + 404 (on the WAC-exempt prefix mount). |
| 76 | + |
| 77 | +## Findings |
| 78 | + |
| 79 | +1. **The root DID lives at a well-known path — servable only by accident.** |
| 80 | + `/.well-known/did.json` lies outside any prefix a plugin can mount on, yet |
| 81 | + `api.fastify.get('/.well-known/did.json', h)` works, served *unauthenticated* |
| 82 | + (the test proves an anonymous 200 against a server whose default is not |
| 83 | + public-read). This is the **third** `.well-known`-by-luck document in the |
| 84 | + repo, after `nip05/` (`/.well-known/nostr.json`) and `webfinger/` |
| 85 | + (`/.well-known/webfinger`) — three plugins now depending on the identical |
| 86 | + chain of core internals, none of it in the plugin contract: the loader |
| 87 | + hands over the real scoped Fastify instance and doesn't confine routes to |
| 88 | + `prefix`; an exact path outranks core's LDP `GET /*` wildcard; and core's |
| 89 | + auth preHandler blanket-exempts `/.well-known/*`. The registration is |
| 90 | + wrapped in try/catch and degrades to the prefix mount. Same candidate seam |
| 91 | + the other two name: a declared `wellKnown: [...]` / reserved-path api so the |
| 92 | + loader reserves the route, reports conflicts, and extends the WAC exemption |
| 93 | + deliberately instead of by coincidence. |
| 94 | + |
| 95 | +2. **The pathed DID re-hits activitypub's namespace-interleaving wall.** |
| 96 | + did:web's pathed form `GET /<user>/did.json` lands *inside* the pod's own |
| 97 | + `/<user>/` LDP namespace — the exact collision `activitypub/` hit putting |
| 98 | + `/<user>/inbox` and `/<user>/outbox` on top of the pod. Unlike the |
| 99 | + well-known root, this path is **WAC-governed**: core's auth preHandler skips |
| 100 | + only `/.well-known/*`, `appPaths` prefixes, and the plugin's own `prefix` |
| 101 | + (`server.js`), so an anonymous `GET /alice/did.json` is denied by default. |
| 102 | + The root `/.acl` JSS seeds is public-read on the container **with no |
| 103 | + `acl:default`**, so a child `did.json` is not inherited-public either — the |
| 104 | + test must write an explicit public-read `/alice/did.json.acl` for the |
| 105 | + absolute did:web location to resolve. **Consequence:** did:web pathed |
| 106 | + resolution works only where the pod owner grants public Read at that path; |
| 107 | + the operator cannot fix it globally the way an API shim does with |
| 108 | + `appPaths`, because the path is *parameterized* (`/:user/did.json`) and |
| 109 | + `appPaths` matches only fixed prefixes. This strengthens the case for |
| 110 | + `api.reservePath()` (#582) to cover *parameterized* public routes that |
| 111 | + interleave with pod namespaces, not just fixed app roots — a requirement |
| 112 | + `activitypub/`, `mastodon/`, and `bluesky/` only approximated by inventing a |
| 113 | + single fake root (`/ap`, `/api`, `/xrpc`). did:web has no such escape: its |
| 114 | + URLs are fixed by the method spec. The plugin therefore also serves every |
| 115 | + DID under the always-safe `<prefix>/<user>/did.json`, and the test asserts |
| 116 | + that mount is byte-identical. |
| 117 | + |
| 118 | +3. **`config.podsRoot` + `config.baseUrl` repetition, again** (see `webfinger/`, |
| 119 | + `nip05/`, `notifications/`). A plugin can learn neither the data root nor |
| 120 | + its own origin, so both are repeated in config and can be pointed at the |
| 121 | + wrong place — here the failure mode is that the resolver *confidently mints |
| 122 | + a valid-looking DID* off a stale/empty pod or a mismatched host, which a |
| 123 | + resolver then caches. `api.storage.serverRoot` (read-only) and |
| 124 | + `api.serverInfo` remain the candidate seams. Subdomain-mode did:web |
| 125 | + (`did:web:alice.pod.example` → `https://alice.pod.example/.well-known/did.json`) |
| 126 | + is out of reach for the same reason `nip05/` couldn't do per-host filtering: |
| 127 | + a plugin can read `request.headers.host` but has no way to learn the base |
| 128 | + domain or the pod↔host mapping, so this port ships path-mode only. |
| 129 | + |
| 130 | +4. **Key-codec helpers had to be vendored** (same wall `nip05/`, `relay/nip01` |
| 131 | + document). Decoding the card's f-form/JWK secp256k1 key lives in core's |
| 132 | + internal `src/auth/nostr-keys.js`; ~40 lines were re-implemented here |
| 133 | + against `@noble/curves`, plus a small base58btc encoder (Multikey has no |
| 134 | + `node:` primitive). The base58 output is self-checking: Ed25519 keys encode |
| 135 | + to the canonical `z6Mk…` prefix and secp256k1 to `zQ3s…`, which the tests |
| 136 | + pin. If the card format evolves (new multicodec, base58 `z`-form keys) this |
| 137 | + plugin silently drops those keys and falls back to a minted Ed25519 key. |
| 138 | + Candidate: publish the key-codec helpers (and a Multikey encoder) on the |
| 139 | + documented surface (`auth.js` or a `keys.js`). |
0 commit comments