|
| 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