Skip to content

Commit

Permalink
feat: Generate Space proofs on the fly, on access/claim (storacha#1555
Browse files Browse the repository at this point in the history
)

The bulk of the work for
storacha/project-tracking#125.

This together with [`w3ui` calling `access/claim` on page
load](storacha/w3ui#644) will make new spaces
appear on the page without logging out.

---

## The Strategy

* On `access/confirm`, where we previously created a `*`/`ucan:*`
delegation with proofs for each space, we now create one with no proofs.
This delegation technically provides no capability, but serves as a
signal in `access/claim`.
* During `access/claim`, we look for the Agent's `*`/`ucan:*`
delegations in the store, and translate them on the fly into ones
containing proofs for the relevant spaces. This is legitimate, because
`*`/`ucan:*` means the Agent has been granted access to anything the
Account can do, it just doesn't have the proofs that Account can do
anything yet. We're just putting 2 and 2 together.

## Remaining

* [x] We don't validate the `*`/`ucan:*` delegation before recreating
it. If `access/confirm` doesn't create an attestation, `access/claim`
will still happily make one for its new delegation. That's a (minor?)
security issue. If the delegation is in our store, we can probably trust
it, but better to care about the attestation.
* [x] Tests for all of this.

---------

Co-authored-by: Alan Shaw <[email protected]>
  • Loading branch information
Peeja and alanshaw authored Oct 7, 2024
1 parent 25e35e3 commit 9e2b1d4
Show file tree
Hide file tree
Showing 7 changed files with 340 additions and 83 deletions.
1 change: 0 additions & 1 deletion packages/access-client/src/agent-use-cases.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,6 @@ export async function requestAccess(access, account, capabilities) {
* @param {object} opts
* @param {string} [opts.nonce] - nonce to use for the claim
* @param {boolean} [opts.addProofs] - whether to addProof to access agent
* @returns
*/
export async function claimAccess(
access,
Expand Down
4 changes: 2 additions & 2 deletions packages/capabilities/src/top.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
* @module
*/

import { capability, URI } from '@ucanto/validator'
import { capability, Schema } from '@ucanto/validator'
import { equalWith } from './utils.js'

/**
Expand All @@ -20,6 +20,6 @@ import { equalWith } from './utils.js'
*/
export const top = capability({
can: '*',
with: URI.match({ protocol: 'did:' }),
with: Schema.or(Schema.did(), Schema.literal('ucan:*')),
derives: equalWith,
})
143 changes: 135 additions & 8 deletions packages/upload-api/src/access/claim.js
Original file line number Diff line number Diff line change
@@ -1,37 +1,164 @@
import * as Server from '@ucanto/server'
import * as Access from '@web3-storage/capabilities/access'
import * as UCAN from '@ipld/dag-ucan'
import * as API from '../types.js'
import * as delegationsResponse from '../utils/delegations-response.js'
import { createSessionProofs } from './confirm.js'

/**
* @param {API.AccessClaimContext} ctx
*/
export const provide = (ctx) =>
Server.provide(Access.claim, (input) => claim(input, 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
*/
const isUCANStar = (delegation) =>
delegation.capabilities.some((capability) => capability.with === 'ucan:*')

/**
* Returns true when the capability is a `ucan/attest` capability for the given
* signer.
*
* @param {API.Capability} capability
* @returns {capability is API.UCANAttest}
*/
const isUCANAttest = (capability) => capability.can === 'ucan/attest'

/**
* @param {API.Input<Access.claim>} input
* @param {API.AccessClaimContext} ctx
* @returns {Promise<API.Result<API.AccessClaimSuccess, API.AccessClaimFailure>>}
*/
export const claim = async (
{ invocation },
{ delegationsStorage: delegations }
) => {
export const claim = async ({ invocation }, { delegationsStorage, signer }) => {
const claimedAudience = invocation.capabilities[0].with
const claimedResult = await delegations.find({ audience: claimedAudience })
if (claimedResult.error) {
const storedDelegationsResult = await delegationsStorage.find({
audience: claimedAudience,
})

if (storedDelegationsResult.error) {
return {
error: {
name: 'AccessClaimFailure',
message: 'error finding delegations',
cause: claimedResult.error,
cause: storedDelegationsResult.error,
},
}
}

const delegationsToReturnByCid = Object.fromEntries(
storedDelegationsResult.ok.map((delegation) => [delegation.cid, delegation])
)

// Find any attested ucan:* delegations and replace them with fresh ones.
for (const delegation of storedDelegationsResult.ok) {
// Ignore delegations that aren't attestations, and ours.
const attestCap = delegation.capabilities.find(isUCANAttest)
if (!(attestCap && attestCap.with === signer.did())) continue

// Ignore invalid attestations.
const valid =
(await UCAN.verifySignature(delegation.data, signer)) &&
!UCAN.isTooEarly(delegation.data) &&
!UCAN.isExpired(delegation.data)
if (!valid) continue

// Ignore attestations of delegations we don't have.
const attestedCid = attestCap.nb.proof
const attestedDelegation = delegationsToReturnByCid[attestedCid.toString()]
if (!(attestedDelegation && isUCANStar(attestedDelegation))) continue

// Create new session proofs for the attested delegation.
const sessionProofsResult = await createSessionProofsForLogin(
attestedDelegation,
delegationsStorage,
signer
)

// If something went wrong, bail on the entire invocation with the error.
// NB: This breaks out of the loop, because if this fails at all, we don't
// need to keep looking.
if (sessionProofsResult.error) {
return {
error: {
name: 'AccessClaimFailure',
message: 'error creating session proofs',
cause: sessionProofsResult.error,
},
}
}

// Delete the ones we're replacing...
delete delegationsToReturnByCid[delegation.cid.toString()]
delete delegationsToReturnByCid[attestedCid.toString()]

// ...and add the new ones.
for (const proof of sessionProofsResult.ok) {
delegationsToReturnByCid[proof.cid.toString()] = proof
}
}

return {
ok: {
delegations: delegationsResponse.encode(claimedResult.ok),
delegations: delegationsResponse.encode(
Object.values(delegationsToReturnByCid)
),
},
}
}

/**
* @param {API.Delegation} loginDelegation
* @param {API.DelegationsStorage} delegationsStorage
* @param {API.Signer} signer
* @returns {Promise<API.Result<API.Delegation[], API.AccessClaimFailure>>}
*/
async function createSessionProofsForLogin(
loginDelegation,
delegationsStorage,
signer
) {
// These should always be Accounts (did:mailto:), but if one's not, skip it.
if (!isAccount(loginDelegation.issuer)) return { ok: [] }

const accountDelegationsResult = await delegationsStorage.find({
audience: loginDelegation.issuer.did(),
})

if (accountDelegationsResult.error) {
return {
error: {
name: 'AccessClaimFailure',
message: 'error finding delegations',
cause: accountDelegationsResult.error,
},
}
}

return {
ok: await createSessionProofs({
service: signer,
account: loginDelegation.issuer,
agent: loginDelegation.audience,
facts: loginDelegation.facts,
capabilities: loginDelegation.capabilities,
// We include all the delegations to the account so that the agent will
// have delegation chains to all the delegated resources.
// We should actually filter out only delegations that support delegated
// capabilities, but for now we just include all of them since we only
// implement sudo access anyway.
delegationProofs: accountDelegationsResult.ok,
expiration: Infinity,
}),
}
}
11 changes: 4 additions & 7 deletions packages/upload-api/src/access/confirm.js
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ export async function confirm({ capability, invocation }, ctx) {
return delegationsResult
}

// Create session proofs, but containing no Space proofs. We'll store these,
// and generate the Space proofs on access/claim.
const [delegation, attestation] = await createSessionProofs({
service: ctx.signer,
account,
Expand All @@ -72,16 +74,11 @@ export async function confirm({ capability, invocation }, ctx) {
},
],
capabilities,
// We include all the delegations to the account so that the agent will
// have delegation chains to all the delegated resources.
// We should actually filter out only delegations that support delegated
// capabilities, but for now we just include all of them since we only
// implement sudo access anyway.
delegationProofs: delegationsResult.ok,
delegationProofs: [],
expiration: Infinity,
})

// Store the delegations so that they can be pulled with access/claim.
// Store the delegations so that they can be pulled during access/claim.
// Since there is no invocation that contains these delegations, don't pass
// a `cause` parameter.
// TODO: we should invoke access/delegate here rather than interacting with
Expand Down
13 changes: 6 additions & 7 deletions packages/upload-api/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@ import type {
} from '@ucanto/interface'
import type { ProviderInput, ConnectionView } from '@ucanto/server'

import { Signer as EdSigner } from '@ucanto/principal/ed25519'
import { StorefrontService } from '@web3-storage/filecoin-api/types'
import { ServiceContext as FilecoinServiceContext } from '@web3-storage/filecoin-api/storefront/api'
import { DelegationsStorage as Delegations } from './types/delegations.js'
Expand Down Expand Up @@ -398,34 +397,34 @@ export type UploadServiceContext = ConsumerServiceContext &
SpaceServiceContext &
RevocationServiceContext &
ConcludeServiceContext & {
signer: EdSigner.Signer
signer: Signer
uploadTable: UploadTable
}

export interface AccessClaimContext {
signer: Signer
delegationsStorage: Delegations
}

export interface AccessServiceContext extends AccessClaimContext, AgentContext {
signer: EdSigner.Signer
email: Email
url: URL
provisionsStorage: Provisions
rateLimitsStorage: RateLimits
}

export interface ConsumerServiceContext {
signer: EdSigner.Signer
signer: Signer
provisionsStorage: Provisions
}

export interface CustomerServiceContext {
signer: EdSigner.Signer
signer: Signer
provisionsStorage: Provisions
}

export interface AdminServiceContext {
signer: EdSigner.Signer
signer: Signer
uploadTable: UploadTable
storeTable: StoreTable
}
Expand All @@ -446,7 +445,7 @@ export interface ProviderServiceContext {
}

export interface SubscriptionServiceContext {
signer: EdSigner.Signer
signer: Signer
provisionsStorage: Provisions
subscriptionsStorage: SubscriptionsStorage
}
Expand Down
Loading

0 comments on commit 9e2b1d4

Please sign in to comment.