Skip to content

Commit

Permalink
feat: revocation handler (#960)
Browse files Browse the repository at this point in the history
Implements `ucan/revoke` capability and a ucanto server hook that
performs the revocation checks.

I ended up changing revocations store interface to avoid serializing /
deserializing UCN CIDs and to layout data in a way that it's would be
more efficient to perform necessary lookups.

☹️ I'm not super happy about the way we have to interface with ucanto
hook and probably it would make sense to change hook API so it's more
like `query` on the revocations store. If we do that we would be able to
uplift logic from `utils/revocation.js` into ucanto and simplify things
here. That said I it's probably better to land thing here first and do
the uplifting if we'll have time to do so.

---------

Co-authored-by: Alan Shaw <alan.shaw@protocol.ai>
  • Loading branch information
Gozala and Alan Shaw authored Oct 10, 2023
1 parent 711e3f7 commit 91f52c6
Show file tree
Hide file tree
Showing 17 changed files with 865 additions and 71 deletions.
6 changes: 6 additions & 0 deletions packages/capabilities/src/console.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import { capability, Schema } from '@ucanto/validator'
import { equalWith } from './utils.js'

export const console = capability({
can: 'console/*',
with: Schema.did(),
derives: equalWith,
})

/**
* Capability that succeeds with the `nb.value` value.
*/
Expand Down
2 changes: 2 additions & 0 deletions packages/capabilities/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import * as RateLimit from './rate-limit.js'
import * as Admin from './admin.js'
import * as Subscription from './subscription.js'
import * as Filecoin from './filecoin.js'
import * as UCAN from './ucan.js'

export {
Access,
Expand All @@ -28,6 +29,7 @@ export {
Subscription,
Filecoin,
Admin,
UCAN,
}

/** @type {import('./types.js').AbilitiesArray} */
Expand Down
34 changes: 34 additions & 0 deletions packages/capabilities/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import * as SubscriptionCaps from './subscription.js'
import * as RateLimitCaps from './rate-limit.js'
import * as FilecoinCaps from './filecoin.js'
import * as AdminCaps from './admin.js'
import * as UCANCaps from './ucan.js'

export type { Unit, PieceLink }

Expand Down Expand Up @@ -213,6 +214,39 @@ export type Store = InferInvokedCapability<typeof store>
export type StoreAdd = InferInvokedCapability<typeof add>
export type StoreRemove = InferInvokedCapability<typeof remove>
export type StoreList = InferInvokedCapability<typeof list>
// UCAN core events

export type UCANRevoke = InferInvokedCapability<typeof UCANCaps.revoke>

/**
* Error is raised when `UCAN` being revoked is not supplied or it's proof chain
* leading to supplied `scope` is not supplied.
*/
export interface UCANNotFound extends Ucanto.Failure {
name: 'UCANNotFound'
}

/**
* Error is raised when `UCAN` being revoked does not have provided `scope` in
* the proof chain.
*/
export interface InvalidRevocationScope extends Ucanto.Failure {
name: 'InvalidRevocationScope'
}

/**
* Error is raised when `UCAN` revocation is issued by unauthorized principal,
* that is `with` field is not an `iss` of the `scope`.
*/
export interface UnauthorizedRevocation extends Ucanto.Failure {
name: 'UnauthorizedRevocation'
}

export type UCANRevokeFailure =
| UCANNotFound
| InvalidRevocationScope
| UnauthorizedRevocation

// Admin
export type Admin = InferInvokedCapability<typeof AdminCaps.admin>
export type AdminUploadInspect = InferInvokedCapability<
Expand Down
53 changes: 38 additions & 15 deletions packages/capabilities/src/ucan.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,12 @@
*/

import { capability, Schema } from '@ucanto/validator'
import { equalWith, checkLink, and } from './utils.js'
import * as API from '@ucanto/interface'
import { equalWith, equal, and, checkLink } from './utils.js'

export const UCANLink = Schema.link({
version: 1,
})
export const UCANLink =
/** @type {Schema.Schema<API.UCANLink, unknown>} */
(Schema.link({ version: 1 }))

/**
* Capability can only be delegated (but not invoked) allowing audience to
Expand All @@ -25,28 +26,50 @@ export const ucan = capability({
* [UCAN Revocation](https://github.com/ucan-wg/spec#66-revocation) that had
* been proposed to a UCAN working group and had a tentative support from
* members.
*
* Capability can be used to revoke `nb.ucan` authorization from all proofs
* chains that lead to the UCAN issued or being delegated to the principal
* identified by the `with` field. Note that revoked UCAN MUST continue to
* be valid in the invocation where proof chain does not lead to the principal
* identified by the `with` field.
*/
export const revoke = capability({
can: 'ucan/revoke',
/**
* With MUST be a DID of the UCAN issuer that is in the proof chain of the
* delegation been revoked.
* DID of the principal authorizing revocation.
*/
with: Schema.did(),
nb: Schema.struct({
/**
* Link of the UCAN been revoked, it MUST be a UCAN be either issued by a
* principal matching `with` field or depend on the delegation issued by
* the principal matching `with` field.
* UCAN being revoked from all proof chains that lead to the UCAN that is
* either issued (iss) by or delegated to (aud) the principal identified
* by the `with` field.
*/
ucan: UCANLink,
/**
* Proof chain illustrating the path from revoked UCAN to the one that is
* either issued (iss) by or delegated to (aud) the principal identified
* by the `with` field.
*
* If the UCAN being revoked is either issued (iss) by or delegated to (aud)
* the principal identified by the `with` field no `proof` is required and
* it can be omitted or set to an empty array.
*
* Alternatively `with` field MAY match the `audience` of the UCAN been revoked,
* which would imply that that delegate is revoking capabilities delegated
* to it. This allows delegate to prove that it is unable to invoke
* delegated capabilities.
* It is RECOMMENDED that `proof` is provided in all other cases otherwise
* it MAY not be possible to verify that revoking principal is a participant
* in the proof chain.
*/
delegation: UCANLink,
proof: UCANLink.array().optional(),
}),
derives: (claim, from) =>
// With field MUST be the same
and(equalWith(claim, from)) ??
checkLink(claim.nb.delegation, from.nb.delegation, 'nb.delegation'),
// UCAN being revoked MUST be the same
and(checkLink(claim.nb.ucan, from.nb.ucan, 'nb.ucan')) ??
// And proof chain MUST be the same
equal(
(claim.nb.proof ?? []).join('/'),
(from.nb.proof ?? []).join('/'),
'nb.proof'
),
})
14 changes: 7 additions & 7 deletions packages/capabilities/test/capabilities/ucan.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ describe('ucan/* capabilities', () => {
audience: service,
with: alice.did(),
nb: {
delegation,
ucan: delegation,
},
})

Expand All @@ -39,7 +39,7 @@ describe('ucan/* capabilities', () => {
audience: service,
with: alice.did(),
nb: {
delegation,
ucan: delegation,
},
proofs: [
await UCAN.revoke.delegate({
Expand Down Expand Up @@ -77,7 +77,7 @@ describe('ucan/* capabilities', () => {
audience: service,
with: alice.did(),
nb: {
delegation: proof.cid,
ucan: proof.cid,
},
proofs: [
await UCAN.revoke.delegate({
Expand All @@ -104,7 +104,7 @@ describe('ucan/* capabilities', () => {
audience: service,
with: alice.did(),
nb: {
delegation,
ucan: delegation,
},
proofs: [
await UCAN.ucan.delegate({
Expand All @@ -131,7 +131,7 @@ describe('ucan/* capabilities', () => {
audience: service,
with: mallory.did(),
nb: {
delegation,
ucan: delegation,
},
proofs: [
await UCAN.ucan.delegate({
Expand All @@ -158,15 +158,15 @@ describe('ucan/* capabilities', () => {
audience: service,
with: alice.did(),
nb: {
delegation,
ucan: delegation,
},
proofs: [
await UCAN.revoke.delegate({
issuer: alice,
with: alice.did(),
audience: bob,
nb: {
delegation: parseLink('bafkqaaa'),
ucan: parseLink('bafkqaaa'),
},
}),
],
Expand Down
7 changes: 5 additions & 2 deletions packages/upload-api/src/lib.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import * as Client from '@ucanto/client'
import * as Types from './types.js'
import * as Legacy from '@ucanto/transport/legacy'
import * as CAR from '@ucanto/transport/car'
import { create as createRevocationChecker } from './utils/revocation.js'
import { createService as createStoreService } from './store.js'
import { createService as createUploadService } from './upload.js'
import { createService as createConsoleService } from './console.js'
Expand All @@ -14,19 +15,20 @@ import { createService as createProviderService } from './provider.js'
import { createService as createSubscriptionService } from './subscription.js'
import { createService as createAdminService } from './admin.js'
import { createService as createRateLimitService } from './rate-limit.js'
import { createService as createUcanService } from './ucan.js'

export * from './types.js'

/**
* @param {Types.UcantoServerContext} options
* @param {Omit<Types.UcantoServerContext, 'validateAuthorization'>} options
*/
export const createServer = ({ id, codec = Legacy.inbound, ...context }) =>
Server.create({
...createRevocationChecker(context),
id,
codec,
service: createService(context),
catch: (error) => context.errorReporter.catch(error),
validateAuthorization: (auth) => context.validateAuthorization(auth),
})

/**
Expand All @@ -45,6 +47,7 @@ export const createService = (context) => ({
store: createStoreService(context),
subscription: createSubscriptionService(context),
upload: createUploadService(context),
ucan: createUcanService(context),
})

/**
Expand Down
20 changes: 18 additions & 2 deletions packages/upload-api/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,13 @@ export type ValidationEmailSend = {
url: string
}

export interface Timestamp {
/**
* Unix timestamp in seconds.
*/
time: number
}

export type SpaceDID = DIDKey
export type ServiceDID = DID<'web'>
export type ServiceSigner = Signer<ServiceDID>
Expand Down Expand Up @@ -100,6 +107,7 @@ import {
ProviderAddFailure,
SpaceInfo,
ProviderDID,
UCANRevoke,
} from '@web3-storage/capabilities/types'
import * as Capabilities from '@web3-storage/capabilities'
import { RevocationsStorage } from './types/revocations'
Expand All @@ -114,7 +122,9 @@ export type {
} from './types/delegations'
export type {
Revocation,
RevocationsStorage,
RevocationQuery,
MatchingRevocations,
RevocationsStorage,
} from './types/revocations'
export type { RateLimitsStorage, RateLimit } from './types/rate-limits'

Expand Down Expand Up @@ -182,6 +192,11 @@ export interface Service {
RateLimitListFailure
>
}

ucan: {
revoke: ServiceMethod<UCANRevoke, Timestamp, Failure>
}

admin: {
store: {
inspect: ServiceMethod<
Expand Down Expand Up @@ -214,7 +229,8 @@ export type StoreServiceContext = SpaceServiceContext & {
}

export type UploadServiceContext = ConsumerServiceContext &
SpaceServiceContext & {
SpaceServiceContext &
RevocationServiceContext & {
signer: EdSigner.Signer
uploadTable: UploadTable
dudewhereBucket: DudewhereBucket
Expand Down
59 changes: 43 additions & 16 deletions packages/upload-api/src/types/revocations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,28 +2,55 @@ import * as Ucanto from '@ucanto/interface'

export interface Revocation {
revoke: Ucanto.UCANLink
scope: Ucanto.UCANLink
cause: Ucanto.UCANLink
scope: Ucanto.DID
cause: Ucanto.UCANLink
}

export interface RevocationsStorage {
/**
* Given a list of delegation CIDs, return a Ucanto Result with
* any revocations in the store whose `revoke` field matches one of
* the given CIDs.
* Given a map of delegations (keyed by delegation CID), return a
* corresponding map of principals (keyed by DID) that revoked them.
*/
getAll: (
query: Revocation['revoke'][]
) => Promise<
Ucanto.Result<Revocation[], Ucanto.Failure>
>
query(
query: RevocationQuery
): Promise<Ucanto.Result<MatchingRevocations, Ucanto.Failure>>

/**
* Add the given revocations to the revocation store.
* Add the given revocations to the revocation store. If there is a revocation
* for given `revoke` with a different `scope` revocation with the given scope
* will be added. If there is a revocation for given `revoke` and `scope` no
* revocation will be added or updated.
*/
addAll: (
revocations: Revocation[]
) => Promise<
Ucanto.Result<Ucanto.Unit, Ucanto.Failure>
>
add: (
revocation: Revocation
) => Promise<Ucanto.Result<Ucanto.Unit, Ucanto.Failure>>

/**
* Creates or updates revocation for given `revoke` by setting `scope` to
* the one passed in the argument. This is intended to compact revocation
* store by dropping all existing revocations for given `revoke` in favor of
* given one. It is supposed to be called when the revocation authority is the
* same as the UCAN issuer, as such a revocation will apply to all possible
* invocations.
*/
reset: (
revocation: Revocation
) => Promise<Ucanto.Result<Ucanto.Unit, Ucanto.Failure>>
}

/**
* A map of revocations for which we want to find corresponding
* revocations in the store.
*/
export type RevocationQuery = Record<
Ucanto.ToString<Ucanto.UCANLink>,
Ucanto.Unit
>

/**
* Map of ucans to map of principals that issued revocations for them.
*/
export type MatchingRevocations = Record<
Ucanto.ToString<Ucanto.UCANLink>,
Record<Ucanto.DID, Ucanto.Unit>
>
11 changes: 11 additions & 0 deletions packages/upload-api/src/ucan.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { ucanRevokeProvider } from './ucan/revoke.js'
import * as API from './types.js'

/**
* @param {API.UploadServiceContext} context
*/
export const createService = (context) => {
return {
revoke: ucanRevokeProvider(context),
}
}
Loading

0 comments on commit 91f52c6

Please sign in to comment.