Skip to content

Commit

Permalink
tests pass on fix for session proofs
Browse files Browse the repository at this point in the history
  • Loading branch information
gobengo committed Oct 30, 2023
1 parent 65245ef commit 38fa0d3
Show file tree
Hide file tree
Showing 3 changed files with 130 additions and 23 deletions.
23 changes: 23 additions & 0 deletions packages/access-client/src/agent-data.js
Original file line number Diff line number Diff line change
Expand Up @@ -189,3 +189,26 @@ export function getSessionProofs(data) {
}
return proofs
}

/**
* Given a UCAN, return whether it is a session proof matching some options
*
* @param {Ucanto.Delegation} ucan
* @param {object} options
* @param {Ucanto.DID} [options.issuer] - issuer of session proof
* @param {import('@ucanto/interface').UCANLink} [options.attestedProof] - CID of proof that the session proof attests to
* @param {boolean} [options.allowExpired]
* @returns {boolean} whether the ucan matches the options
*/
export function matchSessionProof(ucan, options) {
if ( ! isSessionProof(ucan)) { return false; }
const cap = ucan.capabilities[0]
const matchesRequiredIssuer = (options.issuer === undefined) || options.issuer === ucan.issuer.did()
const isExpiredButNotAllowed = ( ! options.allowExpired) && isExpired(ucan)
const matchesRequiredProof = ( ! options.attestedProof) || (options.attestedProof.toString() === cap.nb.proof.toString())
if ( ! isSessionProof(ucan)) { return false; }
if (isExpiredButNotAllowed) { return false; }
if ( ! matchesRequiredIssuer) { return false; }
if ( ! matchesRequiredProof) { return false; }
return true
}
69 changes: 46 additions & 23 deletions packages/access-client/src/agent.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/* eslint-disable no-continue */
/* eslint-disable max-depth */
import * as Client from '@ucanto/client'
// @ts-ignore
Expand All @@ -18,7 +19,7 @@ import {
validate,
canDelegateCapability,
} from './delegations.js'
import { AgentData, getSessionProofs } from './agent-data.js'
import { AgentData, getSessionProofs, matchSessionProof } from './agent-data.js'
import { addProviderAndDelegateToAccount } from './agent-use-cases.js'
import { UCAN } from '@web3-storage/capabilities'

Expand Down Expand Up @@ -168,28 +169,45 @@ export class Agent {
* Query the delegations store for all the delegations matching the capabilities provided.
*
* @param {import('@ucanto/interface').Capability[]} [caps]
* @param {Ucanto.DID} [invocationAudience] - audience of invocation these proofs will be bundled with.
*/
#delegations(caps) {
#delegations(caps, invocationAudience) {
const _caps = new Set(caps)
/** @type {Array<{ delegation: Ucanto.Delegation, meta: import('./types.js').DelegationMeta }>} */
const values = []
for (const [, value] of this.#data.delegations) {
// check expiration
if (
!isExpired(value.delegation) && // check if delegation can be used
!isTooEarly(value.delegation)
) {
// check if we need to filter for caps
if (Array.isArray(caps) && caps.length > 0) {
for (const cap of _caps) {
if (canDelegateCapability(value.delegation, cap)) {
if (isExpired(value.delegation)) { continue; }
if (isTooEarly(value.delegation)) { continue; }

if (Array.isArray(caps) && caps.length > 0) {
// caps param is provided. Ensure that the delegations we're looping over can delegate to these caps
for (const cap of _caps) {
const capProbablyRequiresSessionProof = value.delegation.signature.code === ucanto.Signature.NON_STANDARD
const findSessionProofs = () => [...this.#data.delegations.values()].filter(m => matchSessionProof(m.delegation, {
attestedProof: value.delegation.cid,
issuer: invocationAudience,
}))
if (canDelegateCapability(value.delegation, cap)) {
const noSessionRequired = ! capProbablyRequiresSessionProof
const sessionProofs = capProbablyRequiresSessionProof ? findSessionProofs() : []
const hasRequiredSessionProof = capProbablyRequiresSessionProof && findSessionProofs().length > 0
const proofs =
// eslint-disable-next-line no-nested-ternary
(noSessionRequired)
? [value]
: hasRequiredSessionProof
? [value, ...sessionProofs]
: []

values.push(...proofs)
if (proofs.length) {
_caps.delete(cap)
values.push(value)
}
}
} else {
values.push(value)
}
} else { // no caps param is provided. Caller must want all delegations.
values.push(value)
}
}
return values
Expand Down Expand Up @@ -250,11 +268,11 @@ export class Agent {
* proofs matching the passed capabilities require it.
*
* @param {import('@ucanto/interface').Capability[]} [caps] - Capabilities to filter by. Empty or undefined caps with return all the proofs.
* @param {Ucanto.DID} [invocationAudience] - audience of invocation these proofs will be bundled with.
*/
proofs(caps) {
proofs(caps, invocationAudience) {
const arr = []

for (const { delegation } of this.#delegations(caps)) {
for (const { delegation } of this.#delegations(caps, invocationAudience)) {
if (delegation.audience.did() === this.issuer.did()) {
arr.push(delegation)
}
Expand Down Expand Up @@ -557,6 +575,8 @@ export class Agent {
* @param {import('./types.js').InvokeOptions<A, R, CAP>} options
*/
async invoke(cap, options) {
const audience = options.audience || this.connection.id

const space = options.with || this.currentSpace()
if (!space) {
throw new Error(
Expand All @@ -566,12 +586,15 @@ export class Agent {

const proofs = [
...(options.proofs || []),
...this.proofs([
{
with: space,
can: cap.can,
},
]),
...this.proofs(
[
{
with: space,
can: cap.can,
},
],
audience.did()
),
]

if (proofs.length === 0 && options.with !== this.did()) {
Expand All @@ -581,7 +604,7 @@ export class Agent {
}
const inv = invoke({
...options,
audience: options.audience || this.connection.id,
audience,
// @ts-ignore
capability: cap.create({
with: space,
Expand Down
61 changes: 61 additions & 0 deletions packages/access-client/test/agent.test.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
/* eslint-disable no-unused-vars */
import assert from 'assert'
import * as ucanto from '@ucanto/core'
import { URI } from '@ucanto/validator'
import { Delegation, provide } from '@ucanto/server'
import { Agent, connection } from '../src/agent.js'
import * as Space from '@web3-storage/capabilities/space'
import * as UCAN from '@web3-storage/capabilities/ucan'
import { createServer } from './helpers/utils.js'
import * as fixtures from './helpers/fixtures.js'
import * as ed25519 from '@ucanto/principal/ed25519'
import { Access } from '@web3-storage/capabilities'
import { Absentee } from '@ucanto/principal'
import * as DidMailto from '@web3-storage/did-mailto'

describe('Agent', function () {
it('should return did', async function () {
Expand Down Expand Up @@ -335,4 +341,59 @@ describe('Agent', function () {
`failed to revoke even though proof was passed: ${result4.error?.message}`
)
})

/**
* An agent may manage a bunch of different proofs for the same agent key. e.g. proofs may authorize agent key to access various audiences or sessions on those audiences.
* When one of the proofs is a session proof issued by w3upA or w3upB, the Agent#proofs result should contain proofs appropriate for the session host.
*/
it('should include session proof based on connection', async () => {
// const space = await ed25519.Signer.generate()
const account = DidMailto.fromEmail(`test-${Math.random().toString().slice(2)}@dag.house`)
const serviceA = await ed25519.Signer.generate()
const serviceAWeb = serviceA.withDID('did:web:a.up.web3.storage')
const serviceB = await ed25519.Signer.generate()
const serviceBWeb = serviceB.withDID('did:web:b.up.web3.storage')

const server = createServer()
const agent = await Agent.create(undefined, {
connection: connection({ channel: server }),
})

// the agent has a delegation+sesssion for each service
const services = [serviceAWeb, serviceBWeb]
for (const service of services) {
const delegation = await ucanto.delegate({
issuer: Absentee.from({ id: account }),
audience: agent,
capabilities: [
{
can: 'provider/add',
with: 'ucan:*',
}
]
})
const session = await Access.session.delegate({
issuer: service,
audience: agent,
with: service.did(),
nb: { proof: delegation.cid },
})
agent.addProof(delegation)
agent.addProof(session)
}

// now let's say we want to send provider/add invocation to serviceB
const desiredInvocationAudience = serviceAWeb
const proofsA = agent.proofs(
[
{
can: 'provider/add',
with: account
}
],
desiredInvocationAudience.did(),
)
assert.ok(proofsA)
assert.equal(proofsA[1].issuer.did(), desiredInvocationAudience.did())
})
})

0 comments on commit 38fa0d3

Please sign in to comment.