Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
1 change: 1 addition & 0 deletions packages/access-client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@
"@storacha/capabilities": "workspace:^",
"@storacha/did-mailto": "workspace:^",
"@storacha/one-webcrypto": "catalog:",
"@storacha/upload-api": "workspace:^",
"@ucanto/client": "catalog:",
"@ucanto/core": "catalog:",
"@ucanto/interface": "catalog:",
Expand Down
97 changes: 36 additions & 61 deletions packages/access-client/src/agent.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import * as Capabilities from '@storacha/capabilities/space'
import { attest } from '@storacha/capabilities/ucan'
import * as Access from './access.js'
import * as Space from './space.js'
import { validateAuthorization } from '@storacha/upload-api/utils/revocation'

import {
invoke,
Expand Down Expand Up @@ -106,6 +107,7 @@ export class Agent {
url: this.url,
})
this.#data = data
this.revocationsStorage = options.revocationsStorage
agentToData.set(this, this.#data)
}

Expand Down Expand Up @@ -621,82 +623,55 @@ export class Agent {

return /** @type {import('./types.js').SpaceInfoResult} */ (inv.out.ok)
}
}

/**
* Given a list of delegations, add to agent data spaces list.
*
* @deprecated - trying to remove explicit space tracking from Agent/AgentData
* in favor of functions that derive the space set from access.delegations
* Fetch revocations for specific UCANs.
*
* @template {Record<string, any>} [S=Service]
* @param {Agent<S>} agent
* @param {API.Delegation[]} delegations
* @param {API.UCANLink[]} ucanCIDs - Array of UCAN CIDs to check for revocations
* @param {object} [options]
* @param {API.Delegation[]} [options.proofs] - Additional proofs that might be needed for validation
* @returns {Promise<API.Result<API.MatchingRevocations, API.Failure>>}
*/
export async function addSpacesFromDelegations(agent, delegations) {
const data = agentToData.get(agent)
if (!data) {
throw Object.assign(new Error(`cannot determine AgentData for Agent`), {
agent: agent,
})
async getRevocations(ucanCIDs, options = {}) {
if (!this.revocationsStorage) {
return { error: new Error('No revocations storage configured') }
}

// spaces we find along the way.
const spaces = new Map()
// only consider ucans with this agent as the audience
const ours = delegations.filter((x) => x.audience.did() === agent.did())
// space names are stored as facts in proofs in the special `ucan:*` delegation from email to agent.
const ucanStars = ours.filter(
(x) => x.capabilities[0].can === '*' && x.capabilities[0].with === 'ucan:*'
)
for (const delegation of ucanStars) {
for (const proof of delegation.proofs) {
if (
!isDelegation(proof) ||
!proof.capabilities[0].with.startsWith('did:key')
) {
continue
}
const space = Space.fromDelegation(proof)
spaces.set(space.did(), space.meta)
}
const query = {}
for (const cid of ucanCIDs) {
query[cid.toString()] = {}
}

// Find any other spaces the user may have access to
for (const delegation of ours) {
// TODO: we need a more robust way to determine which spaces a user has access to
// it may or may not involve look at delegations
const allows = ucanto.Delegation.allows(delegation)
for (const [resource, value] of Object.entries(allows)) {
// If we discovered a delegation to any DID, we add it to the spaces list.
if (resource.startsWith('did:key') && Object.keys(value).length > 0) {
if (!spaces.has(resource)) {
spaces.set(resource, {})
}
// Get revocations from the storage
const result = await this.revocationsStorage.query(query)
if (result.error) {
return result
}

// If we have proofs, validate them against the revocations
if (options.proofs?.length) {
for (const proof of options.proofs) {
const auth = { delegation: proof }
const validationResult = await validateAuthorization({ revocationsStorage: this.revocationsStorage }, auth)
if (validationResult.error) {
return { error: validationResult.error }
}
}
}

for (const [did, meta] of spaces) {
await data.addSpace(did, meta)
}
return result
}

/**
* Stores given delegations in the agent's data store and adds discovered spaces
* to the agent's space list.
*
* @param {Agent} agent
* @param {object} authorization
* @param {API.Delegation[]} authorization.proofs
* @returns {Promise<API.Result<API.Unit, Error>>}
* Validate that a delegation has not been revoked.
*
* @param {API.Delegation} delegation - The delegation to validate
* @returns {Promise<API.Result<{}, API.Failure>>}
*/
export const importAuthorization = async (agent, { proofs }) => {
try {
await agent.addProofs(proofs)
await addSpacesFromDelegations(agent, proofs)
return { ok: {} }
} catch (error) {
return /** @type {{error:Error}} */ ({ error })
async validateDelegation(delegation) {
if (!this.revocationsStorage) {
return { error: new Error('No revocations storage configured') }
}

return await validateAuthorization({ revocationsStorage: this.revocationsStorage }, { delegation })
}
67 changes: 64 additions & 3 deletions packages/access-client/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,12 @@ import type {
SigAlg,
Caveats,
Unit,
Result,
UCANLink,
} from '@ucanto/interface'

import type { CIDString } from '@ucanto/core'

export type { UTCUnixTimestamp } from '@ipld/dag-ucan'

import type {
Expand Down Expand Up @@ -173,6 +177,58 @@ export interface AgentDataModel {
/** @deprecated */
spaces: Map<DID, SpaceMeta>
delegations: Map<CIDString, { meta: DelegationMeta; delegation: Delegation }>
/** Storage for UCAN revocations */
revocationsStorage?: RevocationsStorage
}

export interface RevocationsStorage {
/**
* Given a map of delegations (keyed by delegation CID), return a
* corresponding map of principals (keyed by DID) that revoked them.
*/
query(
query: RevocationQuery
): Promise<Result<MatchingRevocations, Failure>>

/**
* 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.
*/
add: (
revocation: Revocation
) => Promise<Result<Unit, 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<Result<Unit, Failure>>
}

export interface RevocationQuery {
[key: string]: Record<string, unknown>
}

export interface MatchingRevocations {
[key: string]: {
[key: string]: {
cause: UCANLink
}
}
}

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

/**
Expand Down Expand Up @@ -271,10 +327,15 @@ export interface SpaceMeta {
*/

// eslint-disable-next-line @typescript-eslint/no-explicit-any
export interface AgentOptions<S extends Record<string, any>> {
url?: URL
export interface AgentOptions<S extends Record<string, any> = Service> {
/** Service connection to use */
connection?: ConnectionView<S>
servicePrincipal?: Principal
/** Service principal to use */
servicePrincipal?: Principal<DID<'web'>>
/** Service URL to use */
url?: URL
/** Storage for UCAN revocations */
revocationsStorage?: RevocationsStorage
}

export interface AgentDataOptions {
Expand Down