Skip to content

Commit 1273a42

Browse files
didweb plugin: did:web DID-document resolver for pods
Root DID (did:web:<host> at /.well-known/did.json) and pathed per-pod DID (did:web:<host>:<user> at /<user>/did.json). VM derived from the pod's public profile-card key (secp256k1 Multikey, shared subject with the Nostr identity) or a minted+persisted Ed25519 keypair for keyless pods. service: SolidPod, LinkedDomains, optional ActivityPubActor. Inline base58btc encoder (z6Mk/zQ3s self-checked). Findings: (1) 3rd .well-known-by-luck doc (nip05, webfinger, didweb). (2) sharpens api.reservePath: the pathed /<user>/did.json lives INSIDE the pod's WAC namespace and can't be exempted at all — appPaths can't match a parameterized route, and unlike API shims did:web can't escape via a fake root. Strongest case yet for a PARAMETERIZED api.reservePath(). 6/6.
1 parent 03bfc83 commit 1273a42

3 files changed

Lines changed: 721 additions & 0 deletions

File tree

didweb/README.md

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
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

Comments
 (0)