Skip to content

Commit cd7f9db

Browse files
capability plugin (#506): scoped, time-bound, shareable capability URLs
Macaroon-lite tokens: v1.<payload>.<HMAC-SHA256> over {jti, iss, res, modes, iat, exp}, per-plugin secret in pluginDir (0600). Mint requires identity (getAgent); USE requires none — the URL is the credential, scoped to resource + verb + expiry + write-byte ceiling. Issuer-only revocation ledger. no-store/no-referrer on responses. Finding: capabilities over the plugin's OWN resources are fully self-contained; extending grants to arbitrary POD resources is the boundary — a capability must exercise the ISSUER's authority, which the notifications loopback trick can't do (loopback carries the REQUESTER's creds). Needs api.wac.grant(...) or core ?cap= handling. Capability service = plugin; capability authz over pod resources = core. Bonus: fastify maxParamLength(100) 404s long tokens in named params and a plugin can't change server options — route must be a wildcard. 8/8.
1 parent e917ac1 commit cd7f9db

3 files changed

Lines changed: 629 additions & 0 deletions

File tree

capability/README.md

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

Comments
 (0)