Skip to content

Commit

Permalink
feat: upload-api ucanto server id uses did from env.DID (#91)
Browse files Browse the repository at this point in the history
Motivation:
* #90

What
* w3infra upload-api will use env.UPLOAD_API_DID for its ucanto server
id
* UPLOAD_API_DID gets set to did:web:{customDomain} on new sst stack
creation, where customDomain comes from seed.run env.HOSTED_ZONE

Questions:
* I think I did the `Config.Parameter` right using sst for the
upload-api `UPLOAD_API_DID` parameter? but not sure since other configs
just use environment variables without explicitly using
`Config.Parameter`. How do we decide when to use `Config.Parameter` and
when not to?
  • Loading branch information
gobengo committed Dec 13, 2022
1 parent 7a50112 commit 036e837
Show file tree
Hide file tree
Showing 7 changed files with 144 additions and 8 deletions.
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,10 @@ DID of the w3access service.

URL of the w3access service.

#### `UPLOAD_API_DID`

[DID](https://www.w3.org/TR/did-core/) of the upload-api ucanto server. e.g. `did:web:up.web3.storage`. Optional: if omitted, a `did:key` will be derrived from `PRIVATE_KEY`

#### `R2_ACCESS_KEY_ID`

Access key for S3 like cloud object storage to replicate content into.
Expand Down
5 changes: 4 additions & 1 deletion stacks/upload-api-stack.js
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,11 @@ export function UploadApiStack({ stack, app }) {
R2_REGION: process.env.R2_REGION ?? '',
R2_DUDEWHERE_BUCKET_NAME: process.env.R2_DUDEWHERE_BUCKET_NAME ?? '',
R2_ENDPOINT: process.env.R2_ENDPOINT ?? '',
UPLOAD_API_DID: process.env.UPLOAD_API_DID ?? '',
},
bind: [privateKey]
bind: [
privateKey,
]
}
},
routes: {
Expand Down
18 changes: 15 additions & 3 deletions upload-api/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,20 @@
* see: https://docs.sst.dev/advanced/testing#how-sst-bind-works
*/
import * as ed25519 from '@ucanto/principal/ed25519'
import { Config } from '@serverless-stack/node/config/index.js'
import { DID } from '@ucanto/validator'

export function getServiceSigner() {
return ed25519.parse(Config.PRIVATE_KEY)
/**
* Given a config, return a ucanto Signer object representing the service
*
* @param {object} config
* @param {string} [config.UPLOAD_API_DID] - public identifier of the running service. e.g. a did:key or a did:web
* @param {string} config.PRIVATE_KEY - multiformats private key of primary signing key
*/
export function getServiceSigner(config) {
const signer = ed25519.parse(config.PRIVATE_KEY)
const did = config.UPLOAD_API_DID
if (!did) {
return signer
}
return signer.withDID(DID.match({}).from(did))
}
10 changes: 7 additions & 3 deletions upload-api/functions/get.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import * as Sentry from '@sentry/serverless'
import { Config } from '@serverless-stack/node/config/index.js'

import { getServiceSigner } from '../config.js'

Expand All @@ -14,7 +15,9 @@ Sentry.AWSLambda.init({
*/
export async function versionGet (request) {
const { NAME: name , VERSION: version, COMMIT: commit, STAGE: env } = process.env
const did = getServiceSigner().did()
const { UPLOAD_API_DID } = process.env;
const { PRIVATE_KEY } = Config
const did = getServiceSigner({ UPLOAD_API_DID, PRIVATE_KEY }).did()
const repo = 'https://github.com/web3-storage/upload-api'
return {
statusCode: 200,
Expand All @@ -33,8 +36,9 @@ export const version = Sentry.AWSLambda.wrapHandler(versionGet)
* @param {import('aws-lambda').APIGatewayProxyEventV2} request
*/
export async function homeGet (request) {
const { VERSION: version, STAGE: stage } = process.env
const did = getServiceSigner().did()
const { VERSION: version, STAGE: stage, UPLOAD_API_DID } = process.env
const { PRIVATE_KEY } = Config
const did = getServiceSigner({ PRIVATE_KEY, UPLOAD_API_DID }).did()
const repo = 'https://github.com/web3-storage/upload-api'
const env = stage === 'prod' ? '' : `(${stage})`
return {
Expand Down
5 changes: 4 additions & 1 deletion upload-api/functions/ucan-invocation-router.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { createStoreTable } from '../tables/store.js'
import { createUploadTable } from '../tables/upload.js'
import { getServiceSigner } from '../config.js'
import { createUcantoServer } from '../service/index.js'
import { Config } from '@serverless-stack/node/config/index.js'

Sentry.AWSLambda.init({
dsn: process.env.SENTRY_DSN,
Expand Down Expand Up @@ -52,7 +53,9 @@ async function ucanInvocationRouter (request) {
}
}

const serviceSigner = getServiceSigner()
const { UPLOAD_API_DID } = process.env;
const { PRIVATE_KEY } = Config
const serviceSigner = getServiceSigner({ UPLOAD_API_DID, PRIVATE_KEY })
const ucanStoreBucket = createUcanStore(AWS_REGION, ucanBucketName)

const server = await createUcantoServer(serviceSigner, {
Expand Down
48 changes: 48 additions & 0 deletions upload-api/test/config.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { test } from './helpers/context.js'
import * as configModule from '../config.js'

/** keypair that can be used for testing */
const testKeypair = {
private: {
/**
* Private key encoded as multiformats
*/
multiformats:
'MgCYWjE6vp0cn3amPan2xPO+f6EZ3I+KwuN1w2vx57vpJ9O0Bn4ci4jn8itwc121ujm7lDHkCW24LuKfZwIdmsifVysY=',
},
public: {
/**
* Public key encoded as a did:key
*/
did: 'did:key:z6MkqBzPG7oNu7At8fktasQuS7QR7Tj7CujaijPMAgzdmAxD',
},
}

test('upload-api/config getServiceSigner creates a signer using config.{UPLOAD_API_KEY,PRIVATE_KEY}', async (t) => {
const config = {
PRIVATE_KEY: testKeypair.private.multiformats,
UPLOAD_API_DID: 'did:web:exampe.com',
}
const signer = configModule.getServiceSigner(config)
t.assert(signer)
t.is(signer.did().toString(), config.UPLOAD_API_DID)
const { keys } = signer.toArchive()
const didKeys = Object.keys(keys)
t.deepEqual(didKeys, [testKeypair.public.did])
})
test('upload-api/config getServiceSigner errors if config.DID is provided but not a did', (t) => {
t.throws(() => {
configModule.getServiceSigner({
UPLOAD_API_DID: 'not a did',
PRIVATE_KEY: testKeypair.private.multiformats,
})
}, { message: /^Expected a did: but got ".+" instead$/ })
})
test('upload-api/config getServiceSigner infers did from config.PRIVATE_KEY when config.DID is omitted', async (t) => {
const config = {
PRIVATE_KEY: testKeypair.private.multiformats,
}
const signer = configModule.getServiceSigner(config)
t.assert(signer)
t.is(signer.did().toString(), testKeypair.public.did)
})
62 changes: 62 additions & 0 deletions upload-api/test/service/upload.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { uploadTableProps } from '../../tables/index.js'
import { createS3, createBucket, createAccessServer, createDynamodDb, dynamoDBTableConfig } from '../helpers/resources.js'
import { randomCAR } from '../helpers/random.js'
import { getClientConnection, createSpace } from '../helpers/ucanto.js'
import { getServiceSigner } from '../../config.js'

// https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/clients/client-dynamodb/classes/batchwriteitemcommand.html
const BATCH_MAX_SAFE_LIMIT = 25
Expand Down Expand Up @@ -618,6 +619,67 @@ test('upload/list can be paginated with custom size', async (t) => {
}
})

test('can invoke when serviceSigner has a did:web did', async (t) => {
const serviceDid = 'did:web:example.com'
const servicePrivateKey = Signer.format(await Signer.generate())
const servicePrincipal = getServiceSigner({
UPLOAD_API_DID: serviceDid,
PRIVATE_KEY: servicePrivateKey,
})
const connection = await getClientConnection(servicePrincipal, {
...t.context,
...await prepareResources(t.context.dynamoClient, t.context.s3Client),
})

// first try invoking with expected issuer, audience
const alice = await Signer.generate()
const inovocation = await createNoopRemoveInovocation({
issuer: alice,
audience: servicePrincipal
})
const result = await inovocation.execute(connection)
t.falsy(result, 'result is falsy')
t.is(result?.error, undefined, 'result is not a ucanto Failure')
// everything's fine when invocation audience is the expected serviceDid.

// Let's also ensure that invoking with the wrong audience results in an error.
// Specifically, we'll use the wrong audience that corresponds to a servicePrincipal.signer key.
// This might be a common mistake, since its a key that the serviceSigner may sign with,
// but the `signer.did()` does not match, so we'd still expect the server to reject it.
const wrongAudience = Signer.parse(servicePrivateKey)
const resultOfInvocationWithWrongAudience = await (await createNoopRemoveInovocation({
issuer: alice,
audience: wrongAudience,
})).execute(connection)
t.not(resultOfInvocationWithWrongAudience, undefined, 'result is not undefined - it should be an error')
if (resultOfInvocationWithWrongAudience?.error) {
t.is(resultOfInvocationWithWrongAudience.name, 'InvalidAudience', 'result of sending invocation with wrong audience is InvalidAudience')
t.is(/** @type {import('@ucanto/server').InvalidAudience} */ (resultOfInvocationWithWrongAudience).audience?.toString(), serviceDid)
}
})

/**
* Create an invocation that can be used for testing ucanto connections.
*
* @param {object} options
* @param {import('@ucanto/interface').Principal} options.audience
* @param {Signer.EdSigner} options.issuer
*/
async function createNoopRemoveInovocation(options) {
const { proof, spaceDid } = await createSpace(options.issuer)
const car = await randomCAR(128)
// upload/remove is a decent choice for a no-op, as it will respond with a non-error result
// even without setting up any state ahead of time
const invocation = UploadCapabilities.remove.invoke({
issuer: options.issuer,
audience: options.audience,
with: spaceDid,
nb: { root: car.roots[0] },
proofs: [proof]
})
return invocation
}

/**
* @param {import("@aws-sdk/client-dynamodb").DynamoDBClient} dynamoClient
* @param {import("@aws-sdk/client-s3").S3Client} s3Client
Expand Down

0 comments on commit 036e837

Please sign in to comment.