Skip to content

Commit

Permalink
feat: first draft of UCAN bridge
Browse files Browse the repository at this point in the history
To support users in languages that do not have existing UCAN invocation implementations, we are going to launch a bridge that allows them to make simple HTTP requests with JSON bodies that we transform into proper UCAN invocations.

So far this PR has:

1) an untested (but type-checking!) implementation of such a bridge
2) a markdown description of the bridge protocol, intended to be the first draft of an eventual specification

Notable design choices:

1) I chose to include JUST the base64pad-encoded "secret" in the Authorization header to avoid running afoul of maximum header size restrictions that exist in [some HTTP environments](https://stackoverflow.com/questions/686217/maximum-on-http-header-values).
2) The `proof` field of the JSON body is a base64pad encoded "delegation archive" (created with `ucanto`'s `Delegation.archive` function)

TODO
- [ ] factor core bridge logic out to a separate library
- [ ] factor HTTP input wrangling out to a separate function
- [ ] rename `UPLOAD_API_DID` and `ACCESS_SERVICE_URL` environment variables to `W3UP_SERVICE_DID` and `W3UP_SERVICE_URL`
- [ ] add tests
- [ ] expand and formalize bridge specification, move it to the specs repo (?)
  • Loading branch information
travis committed Feb 7, 2024
1 parent 9d9f405 commit ec6bc44
Show file tree
Hide file tree
Showing 4 changed files with 201 additions and 1 deletion.
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 @@ -120,6 +120,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
48 changes: 48 additions & 0 deletions upload-api/BRIDGE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# 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
Authorization: MYmI4NWUwMGFhNzNlZDlkM2Y2NDYxYWEwZjk1NDdjOWY=
{
"ability": "store/add",
"resource": "did:key:z6Mkm5qHN9g9NQSGbBfL7iGp9sexdssioT4CzyVap9ATqGqX",
"inputs": {
"link": "bafybeicxsrpxilwb6bdtq6iztjziosrqts5qq2kgali3xuwgwjjjpx5j24",
"size": 42
},
"proof": "MOqJlcm9vdHOB2CpYJQABcRIg8oX3pzNhQ6omQIViTTLdoga/hH4EFdTlQDRJKzd5LQFndmVyc2lvbgGaAgFxEiCa4m5KeAneTomW0WJzcF3a6Wst3m8oLY4/Q4VZRoOsJqdhc1hE7aEDQNCsmjfCsOrj/m0iVZDjxZxwj66cf9hN5yxTkC/t/4MKqR7hsRQzDXep4O0Js9p3cgSlhAOdkbMarQx+qk0i8QNhdmUwLjkuMWNhdHSBomNjYW5hKmR3aXRoeDhkaWQ6a2V5Ono2TWttNXFITjlnOU5RU0diQmZMN2lHcDlzZXhkc3Npb1Q0Q3p5VmFwOUFUcUdxWGNhdWRYIu0Bu8P7ClUb33SVOyLOPQxUZ5Xe5crrHlKRvQO8uOgxtCpjZXhw9mNpc3NYIu0BYoScyDzOAQt1rUCkSiErSfsJrVf1kwu74l1i3GgbnlpjcHJmgMMDAXESIBulnNb9LPB9Br8qPQbpPi7OENfFXODPt9+62t/tNC9Xp2FzWETtoQNAsmFzSClooNmdbQazHFOZ7zus4WGhxq0G5QSk+RZdGo4NVUg8fuwaR4nxlnYr128AtgrE9c6Onzf+Yu10anbxA2F2ZTAuOS4xY2F0dIKiY2NhbmlzdG9yZS9hZGRkd2l0aHg4ZGlkOmtleTp6Nk1rbTVxSE45ZzlOUVNHYkJmTDdpR3A5c2V4ZHNzaW9UNEN6eVZhcDlBVHFHcViiY2Nhbmp1cGxvYWQvYWRkZHdpdGh4OGRpZDprZXk6ejZNa201cUhOOWc5TlFTR2JCZkw3aUdwOXNleGRzc2lvVDRDenlWYXA5QVRxR3FYY2F1ZFgi7QEYGA5FY50aOrnMapmJCO0DvHovsz4HtRZ9bd7PKJarL2NleHABY2lzc1gi7QG7w/sKVRvfdJU7Is49DFRnld7lyuseUpG9A7y46DG0KmNwcmaC2CpYJQABcRIgmuJuSngJ3k6JltFic3Bd2ulrLd5vKC2OP0OFWUaDrCbYKlglAAFxEiCa4m5KeAneTomW0WJzcF3a6Wst3m8oLY4/Q4VZRoOsJlkBcRIg8oX3pzNhQ6omQIViTTLdoga/hH4EFdTlQDRJKzd5LQGhanVjYW5AMC45LjHYKlglAAFxEiAbpZzW/SzwfQa/Kj0G6T4uzhDXxVzgz7ffutrf7TQvVw=="
}
```

And receive a JSON-encoded UCAN receipt in response.

### Authorization

The `Authorization` header and `proof` field's values can be generated with the `bridge generate-tokens` command of `w3cli`:

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

Authorization header: MYmI4NWUwMGFhNzNlZDlkM2Y2NDYxYWEwZjk1NDdjOWY=

Proof: MOqJlcm9vdHOB2CpYJQABcRIg8oX3pzNhQ6omQIViTTLdoga/hH4EFdTlQDRJKzd5LQFndmVyc2lvbgGaAgFxEiCa4m5KeAneTomW0WJzcF3a6Wst3m8oLY4/Q4VZRoOsJqdhc1hE7aEDQNCsmjfCsOrj/m0iVZDjxZxwj66cf9hN5yxTkC/t/4MKqR7hsRQzDXep4O0Js9p3cgSlhAOdkbMarQx+qk0i8QNhdmUwLjkuMWNhdHSBomNjYW5hKmR3aXRoeDhkaWQ6a2V5Ono2TWttNXFITjlnOU5RU0diQmZMN2lHcDlzZXhkc3Npb1Q0Q3p5VmFwOUFUcUdxWGNhdWRYIu0Bu8P7ClUb33SVOyLOPQxUZ5Xe5crrHlKRvQO8uOgxtCpjZXhw9mNpc3NYIu0BYoScyDzOAQt1rUCkSiErSfsJrVf1kwu74l1i3GgbnlpjcHJmgMMDAXESIBulnNb9LPB9Br8qPQbpPi7OENfFXODPt9+62t/tNC9Xp2FzWETtoQNAsmFzSClooNmdbQazHFOZ7zus4WGhxq0G5QSk+RZdGo4NVUg8fuwaR4nxlnYr128AtgrE9c6Onzf+Yu10anbxA2F2ZTAuOS4xY2F0dIKiY2NhbmlzdG9yZS9hZGRkd2l0aHg4ZGlkOmtleTp6Nk1rbTVxSE45ZzlOUVNHYkJmTDdpR3A5c2V4ZHNzaW9UNEN6eVZhcDlBVHFHcViiY2Nhbmp1cGxvYWQvYWRkZHdpdGh4OGRpZDprZXk6ejZNa201cUhOOWc5TlFTR2JCZkw3aUdwOXNleGRzc2lvVDRDenlWYXA5QVRxR3FYY2F1ZFgi7QEYGA5FY50aOrnMapmJCO0DvHovsz4HtRZ9bd7PKJarL2NleHABY2lzc1gi7QG7w/sKVRvfdJU7Is49DFRnld7lyuseUpG9A7y46DG0KmNwcmaC2CpYJQABcRIgmuJuSngJ3k6JltFic3Bd2ulrLd5vKC2OP0OFWUaDrCbYKlglAAFxEiCa4m5KeAneTomW0WJzcF3a6Wst3m8oLY4/Q4VZRoOsJlkBcRIg8oX3pzNhQ6omQIViTTLdoga/hH4EFdTlQDRJKzd5LQGhanVjYW5AMC45LjHYKlglAAFxEiAbpZzW/SzwfQa/Kj0G6T4uzhDXxVzgz7ffutrf7TQvVw==
```


### Invocation Fields

`ability`, `resource` and `inputs` should be specified according to the capability you wish to invoke.

`ability` should be a 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/

`resource` MUST match the resource passed as the first option to `w3 bridge generate-tokens`.
150 changes: 150 additions & 0 deletions upload-api/functions/bridge.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import * as Sentry from '@sentry/serverless'
import { invoke, DID } from '@ucanto/core'

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

View workflow job for this annotation

GitHub Actions / Test

'/home/runner/work/w3infra/w3infra/node_modules/@ucanto/core/src/lib.js' imported multiple times
import { connect } from '@ucanto/client'
import * as CAR from '@ucanto/transport/car'
import * as HTTP from '@ucanto/transport/http'
import { sha256 } from '@ucanto/core'

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

View workflow job for this annotation

GitHub Actions / Test

'/home/runner/work/w3infra/w3infra/node_modules/@ucanto/core/src/lib.js' imported multiple times
import * as Delegation from '@ucanto/core/delegation'
import { ed25519 } from '@ucanto/principal'
import { base64pad } from 'multiformats/bases/base64'

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

const authorizationHeader = request.headers['authorization']

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

View workflow job for this annotation

GitHub Actions / Test

["authorization"] is better written in dot notation
if (!authorizationHeader) {
return {
statusCode: 401,
body: 'request has no authorization header'
}
}
const secret = base64pad.decode(authorizationHeader)

const body = request.body

if (!body) {
return {
statusCode: 400,
body: 'request has no body'
}
}

const jsonBody = JSON.parse(body)

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

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

if (typeof jsonBody.proof !== 'string') {
return {
statusCode: 400,
body: 'proof must be a base64pad multibase encoding of a Delegation archive'
}
}
const delegationResult = await Delegation.extract(base64pad.decode(jsonBody.proof))
if (delegationResult.error) {
return {
statusCode: 400,
body: 'error'
}
}
const delegation = delegationResult.ok

const invocation = invoke({
issuer: await deriveSigner(secret),
audience: DID.parse(UPLOAD_API_DID),
capability: {
can: ability,
with: resource,
nb: jsonBody.inputs
},
proofs: [delegation]
})
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) {

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

View workflow job for this annotation

GitHub Actions / Test

This `if` statement can be replaced by a ternary expression
return {
statusCode: 200,
body: result.ok
}
} else {
return {
statusCode: 500,
body: Buffer.from(result.error?.message ?? 'no error message received').toString('base64'),
isBase64Encoded: true
}
}
} catch (/** @type {any} */ error) {
return {
statusCode: error.status ?? 500,
body: Buffer.from(error.message).toString('base64'),
isBase64Encoded: true,
}
}
}

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

0 comments on commit ec6bc44

Please sign in to comment.