|
| 1 | +# capability — capability URIs plugin (#506) |
| 2 | + |
| 3 | +Out-of-tree exploration of |
| 4 | +[#506](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/issues/506): |
| 5 | +scoped, time-bound, **shareable** access by URI possession instead of by |
| 6 | +WebID. Not a port of core code — core has no capability layer yet; this is |
| 7 | +the plugin-shaped probe of the design, and of where its boundary with core |
| 8 | +lies (see Findings). |
| 9 | + |
| 10 | +```js |
| 11 | +plugins: [{ module: 'capability/plugin.js', prefix: '/cap', |
| 12 | + config: { |
| 13 | + resources: { 'report.pdf': '…seed content…' }, // optional read-only seeds |
| 14 | + defaultTtl: 3600, // seconds, when a grant omits ttl |
| 15 | + maxTtl: 2592000, // hard ceiling (30 days) |
| 16 | + maxBytes: 1048576, // write-capability body ceiling |
| 17 | + } }] |
| 18 | +``` |
| 19 | + |
| 20 | +## Usage |
| 21 | + |
| 22 | +```bash |
| 23 | +# 1. Mint (must be signed in — any credential scheme the host accepts): |
| 24 | +curl -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ |
| 25 | + -d '{ "resource": "report.pdf", "modes": ["read"], "ttl": 86400 }' \ |
| 26 | + https://pod.example/cap/issue |
| 27 | +# → { "url": "/cap/r/v1.eyJ2IjoxLCJqdGkiOi…", "token": "…", "jti": "…", "exp": … } |
| 28 | + |
| 29 | +# 2. Hand the URL to anyone. Using it needs NO credential — possession is auth: |
| 30 | +curl https://pod.example/cap/r/v1.eyJ2IjoxLCJqdGkiOi… |
| 31 | + |
| 32 | +# 3. Revoke (issuer only), by token or by jti: |
| 33 | +curl -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ |
| 34 | + -d '{ "jti": "…" }' https://pod.example/cap/revoke |
| 35 | +``` |
| 36 | + |
| 37 | +Modes: `read` (GET/HEAD), `write` (PUT/POST — body lands in the plugin's |
| 38 | +store and is visible to later read capabilities: the #506 inbox/drop-box |
| 39 | +case). A grant may carry both. |
| 40 | + |
| 41 | +## Token format (macaroon-lite) |
| 42 | + |
| 43 | +``` |
| 44 | +v1.<base64url(payload JSON)>.<base64url(HMAC-SHA256(secret, "v1." + payloadB64))> |
| 45 | +
|
| 46 | +payload = { |
| 47 | + v: 1, format version |
| 48 | + jti: <128-bit random> unique id — the unit of revocation |
| 49 | + iss: <agent id> verified issuer (WebID / did:nostr) at mint time |
| 50 | + res: <name> canonical resource name, e.g. "report.pdf" |
| 51 | + modes: ["read"], granted verbs |
| 52 | + iat, exp issued-at / expiry, unix seconds |
| 53 | +} |
| 54 | +``` |
| 55 | + |
| 56 | +The token is **self-describing and self-verifying** — no database lookup |
| 57 | +on the read path. The server checks, in order: HMAC signature |
| 58 | +(timing-safe), expiry, revocation list, then that the request method is |
| 59 | +inside `modes`. Everything the capability grants is inside the signed |
| 60 | +payload; tampering with any field kills the signature. |
| 61 | + |
| 62 | +State kept in `api.storage.pluginDir()` (dot-guarded, never served): |
| 63 | + |
| 64 | +- `secret` — 32 random bytes, generated once (mode 0600). Signing key. |
| 65 | +- `revoked.json` — `jti → exp`: revoked ids, kept until the token would |
| 66 | + have expired anyway (replay protection per #506), then pruned at boot. |
| 67 | +- `issued.json` — `jti → { iss, exp }`: convenience index so an issuer can |
| 68 | + revoke by `jti` without having kept the token. Pruned at expiry; revoking |
| 69 | + by full token needs no index at all. |
| 70 | +- `store/` — where write capabilities land and read capabilities look |
| 71 | + first (config seeds are the fallback). |
| 72 | + |
| 73 | +## Security model |
| 74 | + |
| 75 | +- **Bearer semantics**: whoever holds the URL has exactly the granted |
| 76 | + scope until `exp` or revocation — that is the feature. Responses carry |
| 77 | + `Cache-Control: no-store` and `Referrer-Policy: no-referrer` (the #506 |
| 78 | + Referer-leak note); tokens ride the path, so operators should scrub |
| 79 | + `{prefix}/r/` from access logs (a plugin cannot — see Findings). |
| 80 | +- **Unforgeable, not just unguessable**: minting requires the server-side |
| 81 | + 256-bit HMAC secret; the 128-bit random `jti` additionally makes every |
| 82 | + token unique. Signature comparison is `crypto.timingSafeEqual`. |
| 83 | +- **Scope-minimal**: exact resource, explicit verb set, mandatory expiry |
| 84 | + (`maxTtl` ceiling), write-body byte ceiling. No wildcards. |
| 85 | +- **Mint requires identity, use does not**: `POST /cap/issue` goes through |
| 86 | + `api.auth.getAgent` — every credential scheme the host accepts. The |
| 87 | + verified agent id is baked into the token as `iss`, and only that agent |
| 88 | + may revoke it. |
| 89 | +- **Deviation from #506**: the issue signs tokens with the *issuer's* |
| 90 | + Nostr key so third parties could verify provenance. That key machinery |
| 91 | + is `src/auth/nostr-keys.js` — internal, unreachable under the repo rule |
| 92 | + — so this port signs with a per-plugin server secret instead. Same |
| 93 | + bearer semantics; the trade is that only the minting server can verify, |
| 94 | + and `iss` is trusted server attestation rather than issuer signature. |
| 95 | + Key rotation (delete `secret`) invalidates all outstanding capabilities, |
| 96 | + matching the issue's rotation note. |
| 97 | +- **Not implemented from #506** (out of scope for a boundary probe): |
| 98 | + use-count ledgers (`uses: 1`), `nbf`, content-type/byte constraints |
| 99 | + beyond the global write ceiling, attenuated delegation. |
| 100 | + |
| 101 | +## Findings |
| 102 | + |
| 103 | +1. **Capabilities for the plugin's OWN resources need zero seams — the |
| 104 | + headline.** Everything #506's core loop requires is already on the |
| 105 | + plugin api: `api.auth.getAgent` gates minting (any credential scheme), |
| 106 | + `pluginDir()` holds the secret + revocation ledger, and the prefix's |
| 107 | + appPaths exemption (#582) is precisely what lets `GET {prefix}/r/<tok>` |
| 108 | + through with no credential so the capability itself can be the auth. |
| 109 | + Fully self-contained; no internals touched. |
| 110 | +2. **Extending grants to arbitrary POD resources is the boundary — core, |
| 111 | + not plugin.** #506's verification path ends "synthesize a virtual agent |
| 112 | + … WAC check is bypassed — the capability IS the authorization". A |
| 113 | + plugin cannot do that step: it can neither tell WAC to treat a request |
| 114 | + as authorized nor read a WAC-protected resource on a holder's behalf. |
| 115 | + The notifications port's loopback trick doesn't transfer — loopback |
| 116 | + carries the *requester's* credentials, but a capability must exercise |
| 117 | + the *issuer's* authority, and impersonating the issuer would mean the |
| 118 | + plugin holding issuer credentials (unacceptable). The missing seam is a |
| 119 | + WAC integration point — e.g. `api.wac.grant(request, { agent, resource, |
| 120 | + modes })` scoped to one request, or core detecting `?cap=` in its own |
| 121 | + auth middleware. So the split is: capability *service* (mint / verify / |
| 122 | + revoke / token format) fits a plugin; capability *authorization over |
| 123 | + pod resources* is core. This demo therefore governs its own resource |
| 124 | + space (config seeds + `pluginDir()/store`) — which already covers |
| 125 | + #506's drop-box and time-bound-share cases end-to-end. |
| 126 | +3. **`maxParamLength` bites token-in-path designs.** Fastify's default |
| 127 | + named-param limit (100 chars) silently 404s a ~300-char token; a plugin |
| 128 | + cannot change server options, so the route must be a wildcard |
| 129 | + (`{prefix}/r/*`). Worth a line in the plugin docs. |
| 130 | +4. **No issuer-key signing from a plugin.** #506 wants tokens signed with |
| 131 | + the key the issuer's WebID already declares (`src/auth/nostr-keys.js`) |
| 132 | + — internal. A public "sign/verify as this agent's declared key" surface |
| 133 | + would let a plugin issue tokens verifiable by third parties instead of |
| 134 | + only by the minting server. Candidate seam, not a blocker: HMAC covers |
| 135 | + the single-server case fine. |
| 136 | +5. **Log scrubbing needs a host knob.** #506 asks that cap tokens never |
| 137 | + reach access logs. A plugin can set response headers on its own routes |
| 138 | + but cannot redact the host's request logging. (Same family as the |
| 139 | + notifications header-injection finding: response/log hooks.) |
0 commit comments