Skip to content

Commit bb27c25

Browse files
s3 plugin: S3-compatible object-storage gateway over a pod
Path-style API translated to LDP over loopback (forwarded auth, WAC governs): PutObject/GetObject/HeadObject/DeleteObject, ListObjectsV2 (hand-rolled XML, prefix + delimiter/CommonPrefixes), ListBuckets, Create/DeleteBucket, S3 <Error> XML. md5 ETags. Two auth paths: Bearer passthrough AND full SigV4 verification (node:crypto HMAC canonical request, pod token as secret; tampered sig -> SignatureDoesNotMatch). Findings: (1) the loopback bridge generalizes to object storage — 7th witness. (2) SigV4-vs-Bearer tension: a plugin can verify SigV4 itself but can't reuse getAgent for a non-Bearer scheme (candidate: api.authorizeAs(token)). (3) server root isn't agent-writable/JSON-LD, so config.bucketRoot points at the pod. 16/16.
1 parent 1273a42 commit bb27c25

3 files changed

Lines changed: 1073 additions & 0 deletions

File tree

s3/README.md

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
# s3 — an S3 object-storage gateway over a Solid pod
2+
3+
Expose a pod as **S3 object storage**. A path-style AWS-S3 REST subset is
4+
served under the plugin prefix; every request is translated to a Solid/LDP
5+
call against the host itself over **loopback HTTP carrying the caller's own
6+
credentials** — the pattern `notifications/` established and `webdav/`,
7+
`carddav/`, `caldav/`, `rss/`, `sparql/` reuse. The gateway has **no authority
8+
of its own**: WAC governs, because it literally asks the server as the caller.
9+
10+
```js
11+
plugins: [{
12+
id: 's3', module: 's3/plugin.js', prefix: '/s3',
13+
config: {
14+
baseUrl: 'http://localhost:3000', // the server's own origin
15+
loopbackUrl: 'http://127.0.0.1:3000', // where the gateway reaches the host
16+
bucketRoot: '/alice/', // where buckets live (see Findings)
17+
},
18+
}]
19+
```
20+
21+
## The mapping
22+
23+
| S3 concept | Solid concept |
24+
|---|---|
25+
| bucket | a container under `config.bucketRoot` |
26+
| object key | a resource path under that container |
27+
| `PUT /s3/<bucket>/<key>` | loopback `PUT` of the body; ETag = md5(body) |
28+
| `GET /s3/<bucket>/<key>` | loopback `GET`; body + content-type + ETag |
29+
| `HEAD /s3/<bucket>/<key>` | headers only: content-length, ETag, content-type |
30+
| `DELETE /s3/<bucket>/<key>` | loopback `DELETE`; `204` |
31+
| `GET /s3/<bucket>?list-type=2` | ListObjectsV2 XML (bounded recursive container walk) |
32+
| `GET /s3/` | ListBuckets — containers under `bucketRoot` |
33+
| `PUT /s3/<bucket>` | CreateBucket — loopback create container |
34+
| `DELETE /s3/<bucket>` | DeleteBucket |
35+
36+
With `bucketRoot: '/alice/'`, bucket `photos` + key `holiday/beach.jpg`
37+
`/alice/photos/holiday/beach.jpg`.
38+
39+
ETags are the **md5 hex of the object content, quoted** — S3's single-part
40+
convention — computed by the gateway so `PutObject`, `GetObject`, `HeadObject`
41+
and `ListObjectsV2` all agree. Errors are S3 XML
42+
(`<Error><Code>NoSuchKey</Code>…`): `NoSuchKey`/`NoSuchBucket`→404,
43+
`AccessDenied`→403, `SignatureDoesNotMatch`→403.
44+
45+
## Authentication — Bearer passthrough **and** SigV4
46+
47+
S3 authenticates with **AWS Signature V4** (`Authorization: AWS4-HMAC-SHA256
48+
Credential=…, SignedHeaders=…, Signature=…`); real SDKs / aws-cli / rclone
49+
always sign and will not send a bare Bearer. This gateway accepts **both**:
50+
51+
1. **Bearer passthrough**`Authorization: Bearer <pod-token>` is forwarded
52+
verbatim to loopback. Trivial; for `curl` and bespoke clients.
53+
54+
2. **SigV4 verification (the strong path)** — the plugin re-derives the
55+
signature with `node:crypto` HMAC and constant-time-compares it. Because
56+
the plugin has no `accessKeyId → secret` store and cannot reuse core's
57+
`getAgent` for a non-Bearer scheme, the client configures **both**
58+
`aws_access_key_id` **and** `aws_secret_access_key` to the **pod token**.
59+
The plugin reads the pod token from the (cleartext) `Credential`
60+
accessKeyId, verifies the request was signed with that same value as the
61+
secret, and forwards `Bearer <accessKeyId>` to loopback so **WAC still
62+
decides**. This gives real S3-SDK compatibility and request integrity.
63+
64+
The tradeoff: the "secret" travels in the `Credential` field, so it is not
65+
a true secret. Over TLS this is no weaker than sending a Bearer (the
66+
Authorization header is the credential either way), but a production
67+
deployment wanting real SigV4 secrecy needs a proper `accessKeyId → secret`
68+
store the plugin api does not provide — see **Findings**.
69+
70+
### aws-cli
71+
72+
```sh
73+
aws configure set aws_access_key_id "$POD_TOKEN"
74+
aws configure set aws_secret_access_key "$POD_TOKEN"
75+
aws --endpoint-url http://localhost:3000/s3 s3api list-objects-v2 --bucket photos
76+
aws --endpoint-url http://localhost:3000/s3 s3 cp ./file.txt s3://photos/file.txt
77+
```
78+
79+
### rclone
80+
81+
```ini
82+
[pod]
83+
type = s3
84+
provider = Other
85+
access_key_id = <POD_TOKEN>
86+
secret_access_key = <POD_TOKEN>
87+
endpoint = http://localhost:3000/s3
88+
force_path_style = true
89+
```
90+
91+
> **Path-style only.** Virtual-host addressing (`<bucket>.host`) is not
92+
> supported — set `force_path_style`/`--endpoint-url` accordingly. Note that a
93+
> path-prefixed endpoint (`/s3`) is a known friction point for some SDKs that
94+
> assume the endpoint is an origin; pointing the plugin `prefix` at `/` (with
95+
> the operator's `appPaths`) sidesteps it.
96+
97+
## What maps / what doesn't
98+
99+
**Implemented:** PutObject, GetObject, HeadObject, DeleteObject,
100+
ListObjectsV2 (with `prefix` and `delimiter`), ListBuckets, CreateBucket,
101+
DeleteBucket; content-md5 ETags; S3 error XML; SigV4 verification + Bearer.
102+
103+
**Not implemented (deliberate, out of scope):**
104+
105+
- **Multipart upload** (`CreateMultipartUpload`/`UploadPart`/`Complete`) —
106+
needs server-side part staging + assembly; a pod `PUT` is whole-object.
107+
- **Versioning**, object tagging, lifecycle, object lock.
108+
- **ACLs / bucket policy** — access control is WAC on the pod, not S3 ACLs.
109+
`x-amz-acl` is ignored; use the pod's `.acl`.
110+
- **Presigned URLs**, `SelectObjectContent`, torrent, website config.
111+
- **`list-type=1`** (legacy ListObjects) and pagination
112+
(`continuation-token`): the walk returns a single bounded page and sets
113+
`IsTruncated` when it overflows `MaxKeys`; no continuation cursor.
114+
- **Range GET / conditional headers** are not translated (the LDP host
115+
supports them, but the md5-ETag buffering here reads the whole object).
116+
117+
## Findings
118+
119+
The webdav-family **loopback bridge generalizes to object storage.** The same
120+
"translate a foreign protocol to LDP over loopback with forwarded auth,
121+
hand-roll the wire XML" recipe that produced WebDAV/CalDAV/CardDAV produces an
122+
S3 gateway with no new api surface — a seventh witness that the loopback
123+
pattern is the plugin api's load-bearing primitive.
124+
125+
Specific seams this port surfaced:
126+
127+
- **The SigV4-vs-Bearer auth tension is the headline.** A plugin *can* verify
128+
SigV4 itself (`node:crypto` has the HMAC), and this one does — but it
129+
**cannot reuse core's `getAgent`** for a non-Bearer scheme: `getAgent` only
130+
resolves the credential schemes core knows (Bearer/DPoP/nostr). So a plugin
131+
bringing its *own* auth scheme has to both (a) verify it and (b) still
132+
produce a core-recognized credential to reach pod data. Here that forces the
133+
accessKeyId-carries-the-token compromise: the only way the plugin can end up
134+
holding a Bearer to forward is if the token is recoverable from the request,
135+
and SigV4 never transmits the secret — only the `Credential` accessKeyId is
136+
in cleartext. **Candidate seam:** either a pluggable `getAgent` credential
137+
resolver (let a plugin register a scheme verifier), or an
138+
`api.authorizeAs(token)` that mints a loopback-usable credential from a
139+
plugin-verified identity. Until then, "verify SigV4 with the token as the
140+
secret access key" is the strongest honest form, and it works with real S3
141+
SDKs (the test drives aws-style signatures end-to-end, including query
142+
signing and a tampered-signature rejection).
143+
144+
- **"The pod's top-level containers" is not an agent-visible concept.** The
145+
original spec said ListBuckets = the pod's top-level containers. In practice
146+
the server root `/` is **server-owned**: an ordinary agent can neither
147+
`PUT /newbucket/` there (403) nor `GET /` as a JSON-LD listing (it serves
148+
HTML). The writable, enumerable root *for an agent* is its own pod
149+
container. Hence `config.bucketRoot` (default `/`, but set to `/alice/` in
150+
practice): buckets are the pod's sub-containers. This is the honest mapping,
151+
and the gap — no notion of an agent-scoped "storage root" the api could hand
152+
a plugin — is worth recording.
153+
154+
- **`config.baseUrl`/`serverInfo` repetition, again.** Like
155+
`notifications/webdav/rss/sparql`, the plugin must be told its own origin
156+
because there is **no `api.serverInfo`**; it `throw`s in `activate` when
157+
`config.baseUrl` is missing. ~10 plugins now carry the identical stanza —
158+
the strongest quantitative case in the repo for adding `api.serverInfo`.
159+
160+
- **No write-time index (`api.events`) → O(N) list.** ListObjectsV2 is a
161+
read-time bounded recursive crawl (same limitation `sparql/` and `rss/`
162+
hit), and each listed object is additionally fetched to compute its
163+
content-md5 ETag so it matches PutObject. That is O(objects) loopback calls
164+
per list — acceptable at test scale, capped by `WALK_MAX_RESOURCES`, but a
165+
real deployment would want a write-time size/hash index, which needs the
166+
`api.events.onResourceChange` seam that is still absent.
167+
168+
- **Raw-body parser needed for byte-exact objects + payload hashing.** Like
169+
`corsproxy/` and `gitscratch/`, the plugin registers a scoped
170+
`addContentTypeParser('*', { parseAs: 'buffer' })` so object bodies arrive
171+
as raw `Buffer`s — required both for byte-exact `PutObject` and for the
172+
SigV4 `x-amz-content-sha256` payload hash to match. Relates to #583.
173+
174+
## Run
175+
176+
```sh
177+
cd .. && node --test --test-concurrency=1 s3/test.js
178+
```
179+
180+
16 tests, all green: CreateBucket, Put/Get/Head/Delete object (ETag/body/
181+
content-length assertions), ListObjectsV2 (keys + Size + ETag, `prefix`
182+
filter, `delimiter` CommonPrefixes), NoSuchKey on a deleted key, ListBuckets,
183+
AccessDenied (anonymous and bogus-Bearer), and the full SigV4 path
184+
(signed Create/Put/Get/List + a tampered-signature `SignatureDoesNotMatch`).

0 commit comments

Comments
 (0)