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: content serve authorization (#1590) + set default gateway #99

Merged
merged 5 commits into from
Dec 19, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
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
24 changes: 14 additions & 10 deletions packages/capabilities/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,16 +132,6 @@ export type UsageReport = InferInvokedCapability<typeof UsageCaps.report>
export type UsageReportSuccess = Record<ProviderDID, UsageData>
export type UsageReportFailure = Ucanto.Failure

export type EgressRecord = InferInvokedCapability<typeof SpaceCaps.egressRecord>
export type EgressRecordSuccess = {
space: SpaceDID
resource: UnknownLink
bytes: number
servedAt: ISO8601Date
cause: UnknownLink
}
export type EgressRecordFailure = ConsumerNotFound | Ucanto.Failure

export interface UsageData {
/** Provider the report concerns, e.g. `did:web:storacha.network` */
provider: ProviderDID
Expand Down Expand Up @@ -285,6 +275,18 @@ export type RateLimitListFailure = Ucanto.Failure
// Space
export type Space = InferInvokedCapability<typeof SpaceCaps.space>
export type SpaceInfo = InferInvokedCapability<typeof SpaceCaps.info>
export type SpaceContentServe = InferInvokedCapability<
typeof SpaceCaps.contentServe
>
export type EgressRecord = InferInvokedCapability<typeof SpaceCaps.egressRecord>
export type EgressRecordSuccess = {
space: SpaceDID
resource: UnknownLink
bytes: number
servedAt: ISO8601Date
cause: UnknownLink
}
export type EgressRecordFailure = ConsumerNotFound | Ucanto.Failure

// filecoin
export interface DealMetadata {
Expand Down Expand Up @@ -901,6 +903,8 @@ export type ServiceAbilityArray = [
ProviderAdd['can'],
Space['can'],
SpaceInfo['can'],
SpaceContentServe['can'],
EgressRecord['can'],
Upload['can'],
UploadAdd['can'],
UploadGet['can'],
Expand Down
28 changes: 26 additions & 2 deletions packages/cli/space.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import * as W3Space from '@storacha/client/space'
import * as W3Account from '@storacha/client/account'
import * as UcantoClient from '@ucanto/client'
import { HTTP } from '@ucanto/transport'
import * as CAR from '@ucanto/transport/car'
import { getClient } from './lib.js'
import process from 'node:process'
import * as DIDMailto from '@storacha/did-mailto'
Expand All @@ -17,15 +20,36 @@ import * as Result from '@storacha/client/result'
* @property {false} [caution]
* @property {DIDMailto.EmailAddress|false} [customer]
* @property {string|false} [account]
* @property {Array<{id: import('@ucanto/interface').DID, serviceEndpoint: string}>} [authorizeGatewayServices] - The DID Key or DID Web and URL of the Gateway to authorize to serve content from the created space.
* @property {boolean} [skipGatewayAuthorization] - Whether to skip the Gateway authorization. It means that the content of the space will not be served by any Gateway.
*
* @param {string|undefined} name
* @param {CreateOptions} options
*/
export const create = async (name, options) => {
const client = await getClient()
const spaces = client.spaces()

const space = await client.createSpace(await chooseName(name ?? '', spaces))

let space
if (options.skipGatewayAuthorization === true) {
space = await client.createSpace(await chooseName(name ?? '', spaces), {
skipGatewayAuthorization: true,
})
} else {
const gateways = options.authorizeGatewayServices ?? []
fforbeck marked this conversation as resolved.
Show resolved Hide resolved
const connections = gateways.map(({ id, serviceEndpoint }) =>
UcantoClient.connect({
id: {
did: () => id,
},
codec: CAR.outbound,
channel: HTTP.open({ url: new URL(serviceEndpoint) }),
})
)
space = await client.createSpace(await chooseName(name ?? '', spaces), {
authorizeGatewayServices: connections,
})
}

// Unless use opted-out from paper key recovery, we go through the flow
if (options.recovery !== false) {
Expand Down
7 changes: 5 additions & 2 deletions packages/filecoin-api/src/aggregator/events.js
Original file line number Diff line number Diff line change
Expand Up @@ -229,8 +229,11 @@ export const handleAggregateInsertToPieceAcceptQueue = async (
// TODO: Batch per a maximum to queue
const results = await map(
pieces,
/** @returns {Promise<import('@ucanto/interface').Result<import('@ucanto/interface').Unit, RangeError|import('../types.js').QueueAddError>>} */
async (piece) => {
/**
* @param piece
* @returns {Promise<import('@ucanto/interface').Result<import('@ucanto/interface').Unit, RangeError|import('../types.js').QueueAddError>>}
*/
async piece => {
const inclusionProof = aggregateBuilder.resolveProof(piece.link)
if (inclusionProof.error) return inclusionProof

Expand Down
2 changes: 2 additions & 0 deletions packages/upload-api/src/access/claim.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,15 @@ export const provide = (ctx) =>

/**
* Checks if the given Principal is an Account.
*
* @param {API.Principal} principal
* @returns {principal is API.Principal<API.DID<'mailto'>>}
*/
const isAccount = (principal) => principal.did().startsWith('did:mailto:')

/**
* Returns true when the delegation has a `ucan:*` capability.
*
* @param {API.Delegation} delegation
* @returns boolean
*/
Expand Down
2 changes: 1 addition & 1 deletion packages/upload-api/src/lib.js
Original file line number Diff line number Diff line change
Expand Up @@ -145,8 +145,8 @@ export const execute = async (agent, input) => {
* a receipt it will return receipt without running invocation.
*
* @template {Record<string, any>} S
* @param {Types.Invocation} invocation
* @param {Agent<S>} agent
* @param {Types.Invocation} invocation
*/
export const run = async (agent, invocation) => {
const cached = await agent.context.agentStore.receipts.get(invocation.link())
Expand Down
2 changes: 2 additions & 0 deletions packages/upload-api/test/helpers/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,13 @@ export const mallory = ed25519.parse(
'MgCYtH0AvYxiQwBG6+ZXcwlXywq9tI50G2mCAUJbwrrahkO0B0elFYkl3Ulf3Q3A/EvcVY0utb4etiSE8e6pi4H0FEmU='
)

/** did:key:z6MkrZ1r5XBFZjBU34qyD8fueMbMRkKw17BZaq2ivKFjnz2z */
export const w3Signer = ed25519.parse(
'MgCYKXoHVy7Vk4/QjcEGi+MCqjntUiasxXJ8uJKY0qh11e+0Bs8WsdqGK7xothgrDzzWD0ME7ynPjz2okXDh8537lId8='
)
export const w3 = w3Signer.withDID('did:web:test.web3.storage')

/** did:key:z6MkuKJgV8DKxiAF5oaUcT8ckg8kZUoBe6yavSLnHn5ZgyAP */
export const gatewaySigner = ed25519.parse(
'MgCaNpGXCEX0+BxxE4SjSStrxU9Ru/Im+HGNQ/JJx3lDoI+0B3NWjWW3G8OzjbazZjanjM3kgfcZbvpyxv20jHtmcTtg='
)
Expand Down
118 changes: 109 additions & 9 deletions packages/w3up-client/src/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@ import {
Receipt,
} from '@storacha/upload-client'
import {
Access as AccessCapabilities,
SpaceBlob as BlobCapabilities,
SpaceIndex as IndexCapabilities,
Upload as UploadCapabilities,
Filecoin as FilecoinCapabilities,
Space as SpaceCapabilities,
} from '@storacha/capabilities'
import * as DIDMailto from '@storacha/did-mailto'
import { Base } from './base.js'
Expand Down Expand Up @@ -243,19 +245,27 @@ export class Client extends Base {
}

/**
* Create a new space with a given name.
* Creates a new space with a given name.
* If an account is not provided, the space is created without any delegation and is not saved, hence it is a temporary space.
* When an account is provided in the options argument, then it creates a delegated recovery account
* by provisioning the space, saving it and then delegating access to the recovery account.
* In addition, it authorizes the listed Gateway Services to serve content from the created space.
* It is done by delegating the `space/content/serve/*` capability to the Gateway Service.
* User can skip the Gateway authorization by setting the `skipGatewayAuthorization` option to `true`.
*
* @typedef {object} CreateOptions
* @property {Account.Account} [account]
* @typedef {import('./types.js').ConnectionView<import('./types.js').ContentServeService>} ConnectionView
*
* @param {string} name
* @param {CreateOptions} options
* @typedef {object} SpaceCreateOptions
* @property {Account.Account} [account] - The account configured as the recovery account for the space.
* @property {Array<ConnectionView>} [authorizeGatewayServices] - The DID Key or DID Web of the Gateway to authorize to serve content from the created space.
* @property {boolean} [skipGatewayAuthorization] - Whether to skip the Gateway authorization. It means that the content of the space will not be served by any Gateway.
*
* @param {string} name - The name of the space to create.
* @param {SpaceCreateOptions} options - Options for the space creation.
* @returns {Promise<import("./space.js").OwnedSpace>} The created space owned by the agent.
*/
async createSpace(name, options = {}) {
// Save the space to authorize the client to use the space
const space = await this._agent.createSpace(name)

const account = options.account
Expand All @@ -276,18 +286,35 @@ export class Client extends Base {
const recovery = await space.createRecovery(account.did())

// Delegate space access to the recovery
const result = await this.capability.access.delegate({
const delegationResult = await this.capability.access.delegate({
space: space.did(),
delegations: [recovery],
})

if (result.error) {
if (delegationResult.error) {
throw new Error(
`failed to authorize recovery account: ${delegationResult.error.message}`,
{ cause: delegationResult.error }
)
}
}

// Authorize the listed Gateway Services to serve content from the created space
if (options.skipGatewayAuthorization !== true) {
if (
!options.authorizeGatewayServices ||
options.authorizeGatewayServices.length === 0
) {
throw new Error(
`failed to authorize recovery account: ${result.error.message}`,
{ cause: result.error }
'failed to authorize Gateway Services: missing <authorizeGatewayServices> option'
)
}

for (const serviceConnection of options.authorizeGatewayServices) {
await authorizeContentServe(this, space, serviceConnection)
}
}

return space
}

Expand Down Expand Up @@ -507,3 +534,76 @@ export class Client extends Base {
await this.capability.upload.remove(contentCID)
}
}

/**
* Authorizes an audience to serve content from the provided space and record egress events.
* It also publishes the delegation to the content serve service.
* Delegates the following capabilities to the audience:
* - `space/content/serve/*`
*
* @param {Client} client - The w3up client instance.
* @param {import('./types.js').OwnedSpace} space - The space to authorize the audience for.
* @param {import('./types.js').ConnectionView<import('./types.js').ContentServeService>} connection - The connection to the Content Serve Service that will handle, validate, and store the access/delegate UCAN invocation.
* @param {object} [options] - Options for the content serve authorization invocation.
* @param {`did:${string}:${string}`} [options.audience] - The Web DID of the audience (gateway or peer) to authorize.
* @param {number} [options.expiration] - The time at which the delegation expires in seconds from unix epoch.
*/
export const authorizeContentServe = async (
client,
space,
connection,
options = {}
) => {
const currentSpace = client.currentSpace()
try {
// Set the current space to the space we are authorizing the gateway for, otherwise the delegation will fail
await client.setCurrentSpace(space.did())

/** @type {import('@ucanto/client').Principal<`did:${string}:${string}`>} */
const audience = {
did: () => options.audience ?? connection.id.did(),
}

// Grant the audience the ability to serve content from the space, it includes existing proofs automatically
const delegation = await client.createDelegation(
audience,
[SpaceCapabilities.contentServe.can],
{
expiration: options.expiration ?? Infinity,
}
)

// Publish the delegation to the content serve service
const accessProofs = client.proofs([
{ can: AccessCapabilities.access.can, with: space.did() },
])
const verificationResult = await AccessCapabilities.delegate
.invoke({
issuer: client.agent.issuer,
audience,
with: space.did(),
proofs: [...accessProofs, delegation],
nb: {
delegations: {
[delegation.cid.toString()]: delegation.cid,
},
},
})
.execute(connection)

/* c8 ignore next 8 - can't mock this error */
if (verificationResult.out.error) {
throw new Error(
`failed to publish delegation for audience ${options.audience}: ${verificationResult.out.error.message}`,
{
cause: verificationResult.out.error,
}
)
}
return { ok: { ...verificationResult.out.ok, delegation } }
} finally {
if (currentSpace) {
await client.setCurrentSpace(currentSpace.did())
}
}
}
1 change: 1 addition & 0 deletions packages/w3up-client/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { Client } from './client.js'
export * as Result from './result.js'
export * as Account from './account.js'
export * from './ability.js'
export { authorizeContentServe } from './client.js'

/**
* Create a new w3up client.
Expand Down
13 changes: 13 additions & 0 deletions packages/w3up-client/src/types.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { type Driver } from '@storacha/access/drivers/types'
import {
AccessDelegate,
AccessDelegateFailure,
AccessDelegateSuccess,
type Service as AccessService,
type AgentDataExport,
} from '@storacha/access/types'
Expand All @@ -11,6 +14,7 @@ import type {
Ability,
Resource,
Unit,
ServiceMethod,
} from '@ucanto/interface'
import { type Client } from './client.js'
import { StorefrontService } from '@storacha/filecoin-client/storefront'
Expand All @@ -36,6 +40,15 @@ export interface ServiceConf {
filecoin: ConnectionView<StorefrontService>
}

export interface ContentServeService {
access: {
delegate: ServiceMethod<
AccessDelegate,
AccessDelegateSuccess,
AccessDelegateFailure
>
}
}
export interface ClientFactoryOptions {
/**
* A storage driver that persists exported agent data.
Expand Down
Loading
Loading