Skip to content

Commit

Permalink
fix: content claims derives (#74)
Browse files Browse the repository at this point in the history
The default derives function has very basic loose equality checks on
caveat fields. Turns out this doesn't allow the service to be invoked
for most of the defined capabilities when the issuer has been delegated
a capability (i.e. when not using the service key to self sign the
invocation). When using a delegated capability the derives function is
called to figure out if you have violated any constraints.

Luckily we didn't expose this publically and we have been using the
service key to sign invocations so this hasn't come up yet.
  • Loading branch information
alanshaw authored Nov 4, 2024
1 parent 9c601f2 commit 6791016
Show file tree
Hide file tree
Showing 5 changed files with 231 additions and 11 deletions.
9 changes: 5 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
"constructs": "10.1.156",
"sst": "^2.8.28",
"standard": "^17.0.0",
"typescript": "^5.1.6"
"typescript": "^5.6.3"
},
"workspaces": [
"packages/*"
Expand Down
29 changes: 25 additions & 4 deletions packages/core/src/capability/assert.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { capability, URI, Schema } from '@ucanto/server'
import { capability, URI, Schema, ok } from '@ucanto/server'
import { and, equal, equalLinkOrDigestContent, equalWith } from './utils.js'

const linkOrDigest = () => Schema.link().or(Schema.struct({ digest: Schema.bytes() }))

Expand All @@ -21,7 +22,15 @@ export const location = capability({
offset: Schema.integer(),
length: Schema.integer().optional()
}).optional()
})
}),
derives: (claimed, delegated) => (
and(equalWith(claimed, delegated)) ||
and(equalLinkOrDigestContent(claimed, delegated)) ||
and(equal(claimed.nb.location, delegated.nb.location, 'location')) ||
and(equal(claimed.nb.range?.offset, delegated.nb.range?.offset, 'offset')) ||
and(equal(claimed.nb.range?.length, delegated.nb.range?.length, 'length')) ||
ok({})
)
})

/**
Expand Down Expand Up @@ -55,7 +64,13 @@ export const index = capability({
* @see https://github.com/w3s-project/specs/blob/main/w3-index.md
*/
index: Schema.link({ version: 1 })
})
}),
derives: (claimed, delegated) => (
and(equalWith(claimed, delegated)) ||
and(equal(claimed.nb.content, delegated.nb.content, 'content')) ||
and(equal(claimed.nb.index, delegated.nb.index, 'index')) ||
ok({})
)
})

/**
Expand Down Expand Up @@ -105,5 +120,11 @@ export const equals = capability({
nb: Schema.struct({
content: linkOrDigest(),
equals: Schema.link()
})
}),
derives: (claimed, delegated) => (
and(equalWith(claimed, delegated)) ||
and(equalLinkOrDigestContent(claimed, delegated)) ||
and(equal(claimed.nb.equals, delegated.nb.equals, 'equals')) ||
ok({})
)
})
70 changes: 70 additions & 0 deletions packages/core/src/capability/utils.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { fail, ok } from '@ucanto/validator'
import * as Bytes from 'multiformats/bytes'
import { base58btc } from 'multiformats/bases/base58'

/** @import * as API from '@ucanto/interface' */

/**
* Checks that `with` on claimed capability is the same as `with`
* in delegated capability, or starts with the same string if the delegated
* capability is a wildcard. Note this will ignore `can` field.
*
* @param {API.ParsedCapability} claimed
* @param {API.ParsedCapability} delegated
*/
export const equalWith = (claimed, delegated) => {
if (delegated.with.endsWith('*')) {
if (!claimed.with.startsWith(delegated.with.slice(0, -1))) {
return fail(`Resource ${claimed.with} does not match delegated ${delegated.with}`)
}
}
if (claimed.with !== delegated.with) {
return fail(`Can not derive ${claimed.can} with ${claimed.with} from ${delegated.with}`)
}
return ok({})
}

/**
* @param {unknown} claimed
* @param {unknown} delegated
* @param {string} constraint
*/
export const equal = (claimed, delegated, constraint) => {
if (String(claimed) !== String(delegated)) {
return fail(`${constraint}: ${claimed} violates ${delegated}`)
}
return ok({})
}

/** @param {import('multiformats').Link<unknown, number, number, 0|1>|{digest: Uint8Array}} linkOrDigest */
const toDigestBytes = (linkOrDigest) =>
'multihash' in linkOrDigest
? linkOrDigest.multihash.bytes
: linkOrDigest.digest

/**
* @template {API.ParsedCapability<API.Ability, API.URI, { content?: API.UnknownLink | { digest: Uint8Array } }>} T
* @param {T} claimed
* @param {T} delegated
* @returns {API.Result<{}, API.Failure>}
*/
export const equalLinkOrDigestContent = (claimed, delegated) => {
if (delegated.nb.content) {
const delegatedBytes = toDigestBytes(delegated.nb.content)
if (!claimed.nb.content) {
return fail(`content: undefined violates ${base58btc.encode(delegatedBytes)}`)
}
const claimedBytes = toDigestBytes(claimed.nb.content)
if (!Bytes.equals(claimedBytes, delegatedBytes)) {
return fail(`content: ${base58btc.encode(claimedBytes)} violates ${base58btc.encode(delegatedBytes)}`)
}
}
return ok({})
}

/**
* @template T
* @param {API.Result<T , API.Failure>} result
* @returns {{error: API.Failure, ok?:undefined}|undefined}
*/
export const and = (result) => (result.error ? result : undefined)
132 changes: 130 additions & 2 deletions packages/core/test/server.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { connect } from '@ucanto/client'
import { mock } from 'node:test'
import * as Block from 'multiformats/block'
import { sha256, sha512 } from 'multiformats/hashes/sha2'
import { base58btc } from 'multiformats/bases/base58'
import * as Bytes from 'multiformats/bytes'
import * as Link from 'multiformats/link'
import * as dagCBOR from '@ipld/dag-cbor'
Expand Down Expand Up @@ -124,11 +125,18 @@ export const test = {

'should claim location': async (/** @type {import('entail').assert} */ assert) => {
const { claimStore, signer, server } = await beforeEach()
const alice = await ed25519.generate()

const content = await Block.encode({ value: 'find me', hasher: sha256, codec: dagCBOR })
const car = CAR.codec.encode({ roots: [content] })
const carBlock = await Block.encode({ value: car, hasher: sha256, codec: CAR.codec })

const proof = await Assert.location.delegate({
issuer: signer,
audience: alice,
with: signer.did()
})

const connection = connect({
id: signer,
codec: CAR.outbound,
Expand All @@ -137,13 +145,14 @@ export const test = {

const result = await Assert.location
.invoke({
issuer: signer,
issuer: alice,
audience: signer,
with: signer.did(),
nb: {
content: { digest: carBlock.cid.multihash.bytes },
location: ['http://localhost:3000/']
}
},
proofs: [proof]
})
.execute(connection)

Expand All @@ -164,5 +173,124 @@ export const test = {

assert.ok(cap)
assert.equal(cap.nb.location.toString(), 'http://localhost:3000/')
},

'should not authorize resource (with) constraint violation': async (/** @type {import('entail').assert} */ assert) => {
const { signer, server } = await beforeEach()
const alice = await ed25519.generate()
const bob = await ed25519.generate()
const content = await Block.encode({ value: 'find me', hasher: sha256, codec: dagCBOR })

const proof = await Assert.location.delegate({
issuer: signer,
audience: alice,
with: signer.did()
})

const connection = connect({
id: signer,
codec: CAR.outbound,
channel: server
})

const result = await Assert.location
.invoke({
issuer: alice,
audience: signer,
with: bob.did(),
nb: {
content: content.cid,
location: ['http://localhost:3000/']
},
proofs: [proof]
})
.execute(connection)

assert.ok(result.out.error)
assert.ok(result.out.error.message.includes(`Can not derive ${Assert.location.can} with ${bob.did()} from ${signer.did()}`))
},

'should not authorize location caveats content constraint violation': async (/** @type {import('entail').assert} */ assert) => {
const { signer, server } = await beforeEach()
const alice = await ed25519.generate()

const content = await Block.encode({ value: 'find me', hasher: sha256, codec: dagCBOR })
const car = CAR.codec.encode({ roots: [content] })
const carBlock = await Block.encode({ value: car, hasher: sha256, codec: CAR.codec })

const proof = await Assert.location.delegate({
issuer: signer,
audience: alice,
with: signer.did(),
nb: {
content: { digest: carBlock.cid.multihash.bytes }
}
})

const connection = connect({
id: signer,
codec: CAR.outbound,
channel: server
})

const result = await Assert.location
.invoke({
issuer: alice,
audience: signer,
with: signer.did(),
nb: {
content: content.cid,
location: ['http://localhost:3000/']
},
proofs: [proof]
})
.execute(connection)

assert.ok(result.out.error)
assert.ok(result.out.error.message.includes(`Constraint violation: content: ${base58btc.encode(content.cid.multihash.bytes)} violates ${base58btc.encode(carBlock.cid.multihash.bytes)}`))
},

'should not authorize index caveats index constraint violation': async (/** @type {import('entail').assert} */ assert) => {
const { signer, server } = await beforeEach()
const alice = await ed25519.generate()

const content = await Block.encode({ value: 'find me', hasher: sha256, codec: dagCBOR })
const car = CAR.codec.encode({ roots: [content] })

const index = await BlobIndexUtil.fromShardArchives(content.cid, [car])
const indexBytes = Result.unwrap(await index.archive())
const indexLink = Link.create(CAR.codec.code, await sha256.digest(indexBytes))

const proof = await Assert.index.delegate({
issuer: signer,
audience: alice,
with: signer.did(),
nb: {
content: content.cid,
index: indexLink
}
})

const connection = connect({
id: signer,
codec: CAR.outbound,
channel: server
})

const result = await Assert.index
.invoke({
issuer: alice,
audience: signer,
with: signer.did(),
nb: {
content: content.cid,
index: content.cid
},
proofs: [proof]
})
.execute(connection)

assert.ok(result.out.error)
assert.ok(result.out.error.message.includes(`Constraint violation: index: ${content.cid} violates ${indexLink}`))
}
}

0 comments on commit 6791016

Please sign in to comment.