Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: HTTP to UCAN bridge #325

Merged
merged 45 commits into from
Mar 5, 2024
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
5ebd1ea
feat: first draft of UCAN bridge
travis Feb 7, 2024
b53de7c
fix: return JSON properly
travis Feb 9, 2024
4185a6d
feat: update spec
travis Feb 12, 2024
bbfcb35
fix: update to latest invocation spec names
travis Feb 12, 2024
02440e0
feat: update to latest designs
travis Feb 16, 2024
babea35
feat: a bunch of additions from PRs and other discussions
travis Feb 21, 2024
0a0f7d3
fix: support two more content types
travis Feb 22, 2024
fa74f9c
feat: update to latest request and response format
travis Feb 23, 2024
73447b7
fix: a couple tweaks
travis Feb 26, 2024
f86fb98
fix: lint
travis Feb 26, 2024
e73ce81
fix: update bridge spec to latest design
travis Feb 26, 2024
e45b667
fix: one more tweak to spec
travis Feb 26, 2024
bc2126c
fix: TODOs, error messages
travis Feb 26, 2024
40536e9
fix: move spec to specs repo
travis Feb 26, 2024
9dbd9d7
fix: factor env variables out of runtime
travis Mar 1, 2024
8e783a4
feat: add integration test for bridge
travis Mar 1, 2024
1acc53e
chore: lint
travis Mar 1, 2024
5d40271
fix: remove unused parameter
travis Mar 1, 2024
b4bac79
feat: updates from PR review
travis Mar 1, 2024
e1f020c
fix: integration test
travis Mar 1, 2024
aadb19c
fix: add some debug lines for the CI integration tests
travis Mar 1, 2024
5c69e18
fix: remove stray console.logs
travis Mar 1, 2024
1170dde
fix: import ReadableStream from polyfill
travis Mar 1, 2024
e9389b4
chore: more integration test debugging
travis Mar 1, 2024
78fd9a2
chore: more debugging and a hail mary wait
travis Mar 2, 2024
c9c0864
fix: better upload wait function
travis Mar 2, 2024
82fd186
fix: lint
travis Mar 2, 2024
1171b8a
chore: more debugging!
travis Mar 2, 2024
0904d3a
chore: more debugging
travis Mar 2, 2024
6cf3325
chore: keep debugging
travis Mar 2, 2024
d8af685
chore: more debugging
travis Mar 2, 2024
60ba516
chore: more debugging!
travis Mar 2, 2024
9065b95
chore: debug
travis Mar 2, 2024
9a349af
chore: debug
travis Mar 2, 2024
c43f4d6
chore: debug
travis Mar 2, 2024
beeadce
chore: debugging
travis Mar 2, 2024
46011cc
chore: debug
travis Mar 2, 2024
cb95b05
chore: more loggin
travis Mar 4, 2024
71d33eb
fix: fix service URLs
travis Mar 4, 2024
14e82d3
fix: no longer need to rewrite auth links!
travis Mar 4, 2024
0fcbf83
Merge branch 'main' into feat/ucan-bridge
travis Mar 4, 2024
91fa5d0
chore: cleanup
travis Mar 4, 2024
3dffb9c
fix: lint and types
travis Mar 4, 2024
c55b512
fix: re-upgrade w3up client and fix a CID comparison issue
travis Mar 4, 2024
0f521dc
fix: one last round of feedback
travis Mar 5, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions stacks/upload-api-stack.js
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ export function UploadApiStack({ stack, app }) {
routes: {
'POST /': 'functions/ucan-invocation-router.handler',
'POST /ucan': 'functions/ucan.handler',
'POST /bridge': 'functions/bridge.handler',
'GET /': 'functions/get.home',
'GET /validate-email': 'functions/validate-email.preValidateEmail',
'POST /validate-email': 'functions/validate-email.validateEmail',
Expand Down
66 changes: 66 additions & 0 deletions upload-api/BRIDGE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# HTTP-UCAN Bridge

## Summary

We have implemented a "bridge" that allows w3up users to interact with the service
without implementing the UCAN invocation wire protocols.

A user can submit an HTTP request like (simplified for clarity):

```
POST /bridge
X-Auth-Secret: NGY2YTQ1YjYwNWFiYWU2YWNmYmY4NWFhODc4YjEwYzQ=
Authorization: Bearer eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCIsInVjdiI6IjAuOS4xIn0.eyJhdHQiOlt7ImNhbiI6InVwbG9hZC9saXN0Iiwid2l0aCI6ImRpZDprZXk6ejZNa3JUblpIRU1aQnYzMjRIMlV5N2N1cjZIR29weXRuZkc4V3RBbzEyTFByQjk0In1dLCJhdWQiOiJkaWQ6a2V5Ono2TWtyczNVbkRZVndVZ2FDWDl5OGdVeGY0c2VKblFxSGE5OWltQkhLa2hiekV5dSIsImV4cCI6MTcwNzUyMzIzNCwiaXNzIjoiZGlkOmtleTp6Nk1ralJ4QmkycDdHelRrTFFRSE5RNGZIY1ExWHQzaVBKVVpxRGVKMnd3UTRlVVUiLCJwcmYiOlsiYmFmeXJlaWQ2dXNwNnZncmprNjRuNXZ6ZGlkZ2gyeW9mbHA0NnRwcmZvdnFwdHozM283eTRvcmxyM3EiXX0.VH09YeLZjT28QpipB4kDRHWdnHq08GiwjlCIaxD2z8XXr5-WC2eR39scKYC8_kAxiRc5EdJ8Vj25hwld2eTyBw
Content-Type: application/json

{
"call": "store/add",
"on": "did:key:z6Mkm5qHN9g9NQSGbBfL7iGp9sexdssioT4CzyVap9ATqGqX",
travis marked this conversation as resolved.
Show resolved Hide resolved
"inputs": {
travis marked this conversation as resolved.
Show resolved Hide resolved
"link": "bafybeicxsrpxilwb6bdtq6iztjziosrqts5qq2kgali3xuwgwjjjpx5j24",
"size": 42
}
}
travis marked this conversation as resolved.
Show resolved Hide resolved
```

And receive a JSON-encoded UCAN receipt in response.
travis marked this conversation as resolved.
Show resolved Hide resolved

### Authorization

The `X-Auth-Secret` and `Authorization` header values can be generated with the `bridge generate-tokens` command of `w3cli`:

```sh
$ w3 bridge generate-tokens did:key:z6Mkm5qHN9g9NQSGbBfL7iGp9sexdssioT4CzyVap9ATqGqX --expiration 1707264563641

X-Auth-Secret header: NGY2YTQ1YjYwNWFiYWU2YWNmYmY4NWFhODc4YjEwYzQ=

Authorization header: Bearer eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCIsInVjdiI6IjAuOS4xIn0.eyJhdHQiOlt7ImNhbiI6InVwbG9hZC9saXN0Iiwid2l0aCI6ImRpZDprZXk6ejZNa3JUblpIRU1aQnYzMjRIMlV5N2N1cjZIR29weXRuZkc4V3RBbzEyTFByQjk0In1dLCJhdWQiOiJkaWQ6a2V5Ono2TWtyczNVbkRZVndVZ2FDWDl5OGdVeGY0c2VKblFxSGE5OWltQkhLa2hiekV5dSIsImV4cCI6MTcwNzUyMzIzNCwiaXNzIjoiZGlkOmtleTp6Nk1ralJ4QmkycDdHelRrTFFRSE5RNGZIY1ExWHQzaVBKVVpxRGVKMnd3UTRlVVUiLCJwcmYiOlsiYmFmeXJlaWQ2dXNwNnZncmprNjRuNXZ6ZGlkZ2gyeW9mbHA0NnRwcmZvdnFwdHozM283eTRvcmxyM3EiXX0.VH09YeLZjT28QpipB4kDRHWdnHq08GiwjlCIaxD2z8XXr5-WC2eR39scKYC8_kAxiRc5EdJ8Vj25hwld2eTyBw
```

`X-Auth-Secret` is a base64pad-encoded Uint8Array of arbitrary length that will be used to derive an ed25519 principal as follows:

```typescript

import { sha256 } from '@ucanto/core'
import { ed25519 } from '@ucanto/principal'

async function deriveSigner(secret: Uint8Array): Promise<ed25519.EdSigner> {
const { digest } = await sha256.digest(secret)
return ed25519.Signer.derive(digest)
}
```

`Authorization` is a JWT [Bearer token](https://datatracker.ietf.org/doc/html/rfc6750) representing a UCAN delegation as described by
the [`ucan-http-bearer-token`](https:// github.com/ucan-wg/ucan-http-bearer-token?tab=readme-ov-file#ucan-as-bearer-token-specification-v030) specification.
It should grant the principal identified by `X-Auth-Secret` appropriate capabilities
on the resource identified in the JSON body of the HTTP request.

### Invocation Fields

`call`, `on` and `inputs` should be specified according to the capability you wish to invoke.

`call` should be an "ability" string like `store/add` or `upload/add` and must be included in the set of abilities passed to the `--can` option of `w3 bridge generate-tokens`. By default, `--can` is set to `['upload/add', 'store/add']`.

Information about possible `inputs` for a particular ability can be found in https://github.com/web3-storage/specs/

`on` MUST match the resource passed as the first option to `w3 bridge generate-tokens`.
156 changes: 156 additions & 0 deletions upload-api/functions/bridge.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import * as Sentry from '@sentry/serverless'
import { invoke, DID } from '@ucanto/core'
import { connect } from '@ucanto/client'
import * as CAR from '@ucanto/transport/car'
import * as HTTP from '@ucanto/transport/http'
import { sha256 } from '@ucanto/core'
import { ed25519 } from '@ucanto/principal'
import { base64pad } from 'multiformats/bases/base64'
import * as UCAN from "@ipld/dag-ucan"

Sentry.AWSLambda.init({
environment: process.env.SST_STAGE,
dsn: process.env.SENTRY_DSN,
tracesSampleRate: 1.0,
})

/**
*
* @param {Uint8Array} secret
* @returns
*/
async function deriveSigner(secret) {
const { digest } = await sha256.digest(secret)
return await ed25519.Signer.derive(digest)
}

/**
* AWS HTTP Gateway handler for POST / with ucan invocation router.
*
* We provide responses in Payload format v2.0
* see: https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-develop-integrations-lambda.html#http-api-develop-integrations-lambda.proxy-format
*
* @param {import('aws-lambda').APIGatewayProxyEventV2} request
*/
async function handlerFn(request) {
try {
const { UPLOAD_API_DID, ACCESS_SERVICE_URL } = process.env

if (!UPLOAD_API_DID) {
return {
statusCode: 500,
body: 'UPLOAD_API_DID is not set'
}
}

if (!ACCESS_SERVICE_URL) {
return {
statusCode: 500,
body: 'ACCESS_SERVICE_URL is not set'
}
}

// parse headers
const authSecretHeader = request.headers['x-auth-secret']
if (!authSecretHeader) {
return {
statusCode: 401,
body: 'request has no x-auth-secret header'
}
}
const secret = base64pad.baseDecode(authSecretHeader)

const authorizationHeader = request.headers['authorization']
if (!authorizationHeader) {
return {
statusCode: 401,
body: 'request has no authorization header'
}
}
if (!authorizationHeader.startsWith('Bearer ')) {
return {
statusCode: 401,
body: 'authorization header must use the Bearer directive'
}
}
const jwt = authorizationHeader.replace('Bearer ', '')
travis marked this conversation as resolved.
Show resolved Hide resolved
const delegation = UCAN.parse(jwt)
travis marked this conversation as resolved.
Show resolved Hide resolved

// parse body
travis marked this conversation as resolved.
Show resolved Hide resolved
const body = request.body
if (!body) {
return {
statusCode: 400,
body: 'request has no body'
}
}

const jsonBody = JSON.parse(body)

if (
(typeof jsonBody.call !== 'string') ||
(jsonBody.call.split('/').length < 2)
) {
return {
statusCode: 400,
body: 'call must be /-separated string like "store/add" or "admin/store/info"'
}
}
const ability = /** @type {import('@ucanto/interface').Ability} */(jsonBody.call)

if (
(typeof jsonBody.on !== 'string') ||
(jsonBody.on.split(':').length < 2)
) {
return {
statusCode: 400,
body: 'on must be a URI'
}
}
const resource = /** @type {import('@ucanto/interface').Resource} */(jsonBody.on)
travis marked this conversation as resolved.
Show resolved Hide resolved

const invocation = invoke({
issuer: await deriveSigner(secret),
audience: DID.parse(UPLOAD_API_DID),
capability: {
can: ability,
with: resource,
nb: jsonBody.inputs
},
proofs: [delegation]

Check failure on line 120 in upload-api/functions/bridge.js

View workflow job for this annotation

GitHub Actions / Test

Type 'View<Capabilities>' is not assignable to type 'Proof<Capabilities>'.
})
const receipt = await invocation.execute(connect({
id: DID.parse(UPLOAD_API_DID),
codec: CAR.outbound,
channel: HTTP.open({
url: new URL(ACCESS_SERVICE_URL),
method: 'POST'
})
}))
const result = receipt.out
if (result.ok) {
return {
statusCode: 200,
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(result.ok)
travis marked this conversation as resolved.
Show resolved Hide resolved
}
} else {
return {
statusCode: 500,
body: Buffer.from(result.error?.message ?? 'no error message received').toString('base64'),
isBase64Encoded: true
}
}
} catch (/** @type {any} */ error) {
console.error(error)
return {
statusCode: error.status ?? 500,
body: Buffer.from(error.message).toString('base64'),
isBase64Encoded: true,
}
}
}

export const handler = Sentry.AWSLambda.wrapHandler(handlerFn)
Loading