An implementation of part of the W3C CCG Wallet Attached Storage specification, built as AWS Lambda functions behind an API Gateway REST API, with objects stored in S3.
Requests are authorized with signed zCap (Authorization Capability) invocations, verified by a single API Gateway Lambda REQUEST authorizer rather than by each handler.
| Method | Path | Function | Source |
|---|---|---|---|
| GET | /space/{space_id} |
SpaceDescriptionGetFn |
src/spaces/description/get |
| GET | /space/{space_id}/collections |
SpaceCollectionsListGetFn |
src/spaces/get |
| GET | /space/{space_id}/{collection_id} |
CollectionsGetFn |
src/collections/get |
Implements http-api-get-space-space_id.
Returns the Space's description document. The handler reads
metadata/description.json from the Space's bucket, then overlays the fields the
server owns, so the response stays spec-shaped even if the stored document is
partial:
{
"id": "<space_id>",
"url": "/space/<space_id>",
"type": ["Space"],
"name": "Example space #1",
"controller": "did:key:z6Mk...",
"createdBy": "did:key:z6Mk...",
"linkset": "/space/<space_id>/linkset"
}name, controller, createdBy and any other authored fields pass through from
the stored document. id, url, type and linkset are always derived from
the request path.
Lists the Collections in a Space, by listing the collections/ prefix with
Delimiter: "/" and reading CommonPrefixes — each sub-folder is one
Collection.
Behaviour depends on the trailing slash:
- With a trailing slash — lists the Collection's member resources, by listing
collections/{collection_id}/and readingContents. Keys named inRESERVED_RESOURCE_IDS(currently the folder marker anddescription.json) are excluded, since they are not members of the Collection. - Without — returns the Collection's own
collections/{collection_id}/description.jsonverbatim.
One S3 bucket per Space, named for the space_id. The bucket is the Space,
so keys are already rooted at the Space and contain no /space/{space_id}
segment.
s3://{space_id}/
├── metadata/
│ └── description.json <- the Space description
└── collections/
└── {collection_id}/
├── description.json <- the Collection description
├── {resource_id} <- a member resource
└── {resource_id}
Every route on WASApi is protected by WASZcapAuthorizer, a Lambda REQUEST
authorizer declared as the API's DefaultAuthorizer. Handlers do no verification
of their own.
- src/authorizer/app.mjs — the authorizer entry point; builds the IAM policy.
- src/authorizer/zcap.mjs —
verifyZcap, which wrapsverifyCapabilityInvocationfrom@interop/http-signature-zcap-verify.
verifyZcap(event) takes the Lambda event as its only argument and derives
everything it needs from it:
verifyCapabilityInvocation argument |
Derived from |
|---|---|
url, expectedTarget |
X-Forwarded-Proto + Host + event.path |
method, expectedAction |
event.httpMethod |
expectedHost |
Host header |
expectedRootCapability |
urn:zcap:root: + URI-encoded target |
headers |
event.headers, with authorization normalized to lowercase |
A REQUEST authorizer event carries headers, path and httpMethod under the
same names a proxy-integration event does, so the event passes straight through.
Header lookups are case-insensitive, because API Gateway preserves whatever
casing the client sent.
On success the authorizer returns an Allow policy scoped to event.methodArn,
plus the invoker on the authorizer context. Handlers can read it from
event.requestContext.authorizer:
const { controller, capability, capabilityAction } = event.requestContext.authorizer;Context values must be scalars — no nested objects or arrays — so the capability is passed as its id string.
On failure the authorizer throws an error whose message is exactly
Unauthorized, which is what API Gateway maps to a 401. Rethrowing the
underlying verification error would surface as a 500 instead, so the real reason
is logged rather than thrown.
Two settings in template.yaml are deliberate and should not be changed without
thought:
ReauthorizeEvery: 0disables the authorizer result cache. Each zCap is signed over its own request, so a cached decision would authorize a different request than the one that was actually verified.AddDefaultAuthorizerToCorsPreflight: false. SAM defaults this totrue, which puts the authorizer in front ofOPTIONS. Preflight carries noAuthorizationheader, so the browser would get a 401 and never reach the real request.
src/
├── authorizer/ zCap REQUEST authorizer (deps bundled, no layer)
│ ├── app.mjs policy construction
│ ├── zcap.mjs verifyZcap / verifyCapabilityInvocation
│ └── package.json @interop/* dependencies
├── spaces/
│ ├── description/get/ GET /space/{space_id}
│ └── get/ GET /space/{space_id}/collections
├── collections/get/ GET /space/{space_id}/{collection_id}
├── resources/put/ PUT handler — NOT currently in template.yaml
└── sharedLayer/ dead code; commented out of template.yaml
events/
├── routes.mjs shared route table + event scaffolding
├── sign.mjs prints a freshly-signed authorizer event to stdout
├── generate.mjs rewrites the static proxy fixtures
├── check.mjs signs fresh + runs the real authorizer (npm test)
└── *.json static, unsigned proxy events
template.yaml all AWS resources
Only WASZcapAuthorizerFn has a Metadata block. It is the one function with
third-party dependencies, so it is the only one that needs bundling:
BuildMethod: esbuildbundles its@interop/*dependencies into a single ~1 MBapp.mjs, since they are not available to the runtime any other way.BannerinjectscreateRequire, which is what lets those bundled CJS dependencies work inside an ESM output.
It also overrides the 3s Globals timeout to 10s, because a cold start does DID
resolution and Ed25519 verification.
The three S3 handlers use SAM's default Node.js builder, which copies the source
as-is. They import nothing but @aws-sdk/client-s3, which
every supported Node.js runtime provides,
so there is nothing to bundle. .mjs files are treated as ESM by Node
regardless of any build setting, and each handler directory carries a
package.json with "type": "module" as well.
- AWS SAM CLI
- Docker (only for
--use-containerbuilds) - Node.js 24 (the Lambda runtime is
nodejs24.x)
sam build
sam deploy --guidedSubsequent deploys are just sam deploy. Endpoint URLs are in the stack outputs.
To build one function at a time:
sam build WASZcapAuthorizerFnsam local start-api
curl http://localhost:3000/space/{space_id}The API runs on port 3000, which is why localhost:3000 shows up as the expected
host in signed test invocations. sam local start-api does invoke the Lambda
authorizer, so local requests need a validly signed zCap too.
A single function can be invoked directly with a test event, which bypasses the authorizer. See Test events below for where those come from:
sam local invoke SpaceDescriptionGetFn --event events/space-description-get.jsonevents/ handles the two event shapes differently, because only one of them
needs a signature.
cd events && npm installWASZcapAuthorizerFn needs a valid signature, so its events are generated per
invocation and never stored. events/sign.mjs prints one to
stdout, and sam local invoke reads an event from stdin with -e -:
node sign.mjs space-description-get | sam local invoke WASZcapAuthorizerFn -e -
node sign.mjs space-description-get --invalid | sam local invoke WASZcapAuthorizerFn -e -Routes: space-description-get, space-collections-list-get,
collection-list-get, collection-description-get. --invalid corrupts the
signature to exercise the 401 path. Diagnostics go to stderr, so the pipe stays
clean.
Signing on demand avoids the trap that static signed fixtures fall into: the
signer sets expires to created + 600 and the signature covers the
(expires) pseudo-header, so a stored signed event stops verifying ten minutes
after it is written. A fresh one is always inside that window.
The signer uses the same testSeed as
src/authorizer/zcap.mjs. That matters: the seed
derives spaceController, the controller of every root capability, so signing
with it makes the invoker the controller and the invocation verifies. A
signature also covers (request-target) and host, so it is bound to one route
and one host — it is not reusable across paths.
| File | Route |
|---|---|
space-description-get.json |
GET /space/{space_id} |
space-collections-list-get.json |
GET /space/{space_id}/collections |
collection-list-get.json |
collection listing (trailing slash) |
collection-description-get.json |
collection description (no trailing slash) |
These are what a route handler sees, for invoking one directly:
sam local invoke SpaceDescriptionGetFn --event events/space-description-get.jsonThey carry no signature. The authorizer has already run by the time a handler is
invoked and no handler reads the Authorization header, so a signature here
would be decoration that goes stale. They do carry a filled-in
requestContext.authorizer block, so handler code that reads the invoker finds
something realistic.
Rewrite them with npm run generate after changing a route or path.
npm testevents/check.mjs signs a fresh invocation for each route and
runs it through the real authorizer in process — no files, no Docker. It asserts
that each returns Allow scoped to the right methodArn, that a tampered
signature throws exactly Unauthorized (the message API Gateway maps to a 401),
and that the controller embedded in the static proxy fixtures still matches the
key the signer derives, so a seed change cannot leave them quietly stale.
The signing key is a throwaway for a seed that is already public in this repo. Never point it at a real deployment.
sam logs -n WASZcapAuthorizerFn --stack-name "YOUR_STACK_NAME" --tailWhen a request 401s, the authorizer's log group has the real verification error; the response body deliberately does not.
sam delete- The dev signing seed is hardcoded.
zcap.mjsderivesspaceController— the DID every root capability is issued to, and therefore the root authority for all Spaces — from a literaltestSeed. This must move to Secrets Manager or SSM before the API is exposed. expectedHostno longer constrains anything. It is derived from the request's ownHostheader, so the comparison is self-satisfying and a spoofedHostpasses. Restoring the guard needs a server-controlled source: an environment variable, orevent.requestContext.domainName.src/resources/putis not deployed. The handler exists and writes tocollections/{collection_id}/{resource_id}, buttemplate.yamldeclares no function or route for it. There is a leftoverResourcesApioutput that advertises a path nothing serves.src/sharedLayeris dead code.verifyZcapmoved into the authorizer and nothing imports the layer any more, soDCCSharedLayeris commented out intemplate.yamlalong with everyLayers:andExternal:reference to it. The source is still on disk and still contains a stale duplicate ofverifyZcap, which will drift from the real one insrc/authorizer/zcap.mjs.- Listings are not paginated.
ListObjectsV2returns at most 1000 keys and the handlers ignoreIsTruncated, so larger Spaces and Collections silently truncate. - Query strings are not covered.
event.pathomits the query string, so a capability signed over a URL with query parameters will not match. - No replay protection. Nothing tracks invocation nonces, so a captured
signed request can be replayed until its
expirespasses — and the verifier only checksexpireswhen the header is present, so an invocation signed without one is replayable indefinitely. /space/{space_id}/linksetis advertised but not implemented. The Space description returns alinksetURL; no route serves it.S3ReadPolicy: BucketName: '*'grants read on every bucket in the account, not just Space buckets.