diff --git a/packages/w3up-client/package.json b/packages/w3up-client/package.json index 820cce78a..f44ddba7b 100644 --- a/packages/w3up-client/package.json +++ b/packages/w3up-client/package.json @@ -32,6 +32,26 @@ "types": "./dist/src/account.d.ts", "import": "./dist/src/account.js" }, + "./delegation": { + "types": "./dist/src/delegation.d.ts", + "import": "./dist/src/delegation.js" + }, + "./principal": { + "types": "./dist/src/principal/index.d.ts", + "import": "./dist/src/principal/index.js" + }, + "./principal/ed25519": { + "types": "./dist/src/principal/ed25519.d.ts", + "import": "./dist/src/principal/ed25519.js" + }, + "./principal/rsa": { + "types": "./dist/src/principal/rsa.d.ts", + "import": "./dist/src/principal/rsa.js" + }, + "./proof": { + "types": "./dist/src/proof.d.ts", + "import": "./dist/src/proof.js" + }, "./space": { "types": "./dist/src/space.d.ts", "import": "./dist/src/space.js" diff --git a/packages/w3up-client/src/client.js b/packages/w3up-client/src/client.js index f863cf9e0..f716f0a1c 100644 --- a/packages/w3up-client/src/client.js +++ b/packages/w3up-client/src/client.js @@ -13,7 +13,7 @@ import { import { Base } from './base.js' import * as Account from './account.js' import { Space } from './space.js' -import { Delegation as AgentDelegation } from './delegation.js' +import { AgentDelegation } from './delegation.js' import { BlobClient } from './capability/blob.js' import { IndexClient } from './capability/index.js' import { StoreClient } from './capability/store.js' diff --git a/packages/w3up-client/src/delegation.js b/packages/w3up-client/src/delegation.js index 3431cc90d..34976dcc6 100644 --- a/packages/w3up-client/src/delegation.js +++ b/packages/w3up-client/src/delegation.js @@ -1,11 +1,13 @@ -import { Delegation as CoreDelegation } from '@ucanto/core/delegation' +import { Delegation } from '@ucanto/core/delegation' + +export * from '@ucanto/core/delegation' /* c8 ignore start */ /** * @template {import('./types.js').Capabilities} C - * @extends {CoreDelegation} + * @extends {Delegation} */ -export class Delegation extends CoreDelegation { +export class AgentDelegation extends Delegation { /* c8 ignore stop */ /** @type {Record} */ #meta diff --git a/packages/w3up-client/src/principal/ed25519.js b/packages/w3up-client/src/principal/ed25519.js new file mode 100644 index 000000000..7c22cbca9 --- /dev/null +++ b/packages/w3up-client/src/principal/ed25519.js @@ -0,0 +1 @@ +export * from '@ucanto/principal/ed25519' diff --git a/packages/w3up-client/src/principal/index.js b/packages/w3up-client/src/principal/index.js new file mode 100644 index 000000000..b51fd2216 --- /dev/null +++ b/packages/w3up-client/src/principal/index.js @@ -0,0 +1 @@ +export * from '@ucanto/principal' diff --git a/packages/w3up-client/src/principal/rsa.js b/packages/w3up-client/src/principal/rsa.js new file mode 100644 index 000000000..a8b772ce0 --- /dev/null +++ b/packages/w3up-client/src/principal/rsa.js @@ -0,0 +1 @@ +export * from '@ucanto/principal/rsa' diff --git a/packages/w3up-client/src/proof.js b/packages/w3up-client/src/proof.js new file mode 100644 index 000000000..baa3ed5a2 --- /dev/null +++ b/packages/w3up-client/src/proof.js @@ -0,0 +1,54 @@ +import { importDAG, extract } from '@ucanto/core/delegation' +import * as CAR from '@ucanto/transport/car' +import { CarReader } from '@ipld/car' +import * as Link from 'multiformats/link' +import { base64 } from 'multiformats/bases/base64' +import { identity } from 'multiformats/hashes/identity' + +/** + * Parses a base64 encoded CIDv1 CAR of proofs (delegations). + * + * @param {string} str Base64 encoded CAR file. + */ +export const parse = async (str) => { + try { + const cid = Link.parse(str, base64) + if (cid.code !== CAR.codec.code) { + throw new Error(`non CAR codec found: 0x${cid.code.toString(16)}`) + } + if (cid.multihash.code !== identity.code) { + throw new Error( + `non identity multihash: 0x${cid.multihash.code.toString(16)}` + ) + } + + try { + const { ok, error } = await extract(cid.multihash.digest) + if (error) + throw new Error('failed to extract delegation', { cause: error }) + return ok + } catch { + // Before `delegation.archive()` we used `delegation.export()` to create + // a plain CAR file of blocks. + return legacyExtract(cid.multihash.digest) + } + } catch { + // At one point we recommended piping output directly to base64 encoder: + // `w3 delegation create did:key... --can 'store/add' | base64` + return legacyExtract(base64.baseDecode(str)) + } +} + +/** + * Reads a plain CAR file, assuming the last block is the delegation root. + * + * @param {Uint8Array} bytes + */ +const legacyExtract = async (bytes) => { + const blocks = [] + const reader = await CarReader.fromBytes(bytes) + for await (const block of reader.blocks()) { + blocks.push(block) + } + return importDAG(blocks) +} diff --git a/packages/w3up-client/test/proof.test.js b/packages/w3up-client/test/proof.test.js new file mode 100644 index 000000000..b0b181d9c --- /dev/null +++ b/packages/w3up-client/test/proof.test.js @@ -0,0 +1,125 @@ +import * as Test from './test.js' +import * as CAR from '@ucanto/transport/car' +import * as Link from 'multiformats/link' +import { base64 } from 'multiformats/bases/base64' +import { identity } from 'multiformats/hashes/identity' +import { sha256 } from 'multiformats/hashes/sha2' +import { Signer } from '../src/principal/ed25519.js' +import { delegate } from '../src/delegation.js' +import { parse } from '../src/proof.js' +import * as Result from '../src/result.js' + +/** + * @type {Test.Suite} + */ +export const testProof = { + 'should parse a base64 encoded CIDv1 "proof"': async (assert) => { + const alice = await Signer.generate() + const bob = await Signer.generate() + const delegation = await delegate({ + issuer: alice, + audience: bob, + capabilities: [{ can: 'test/thing', with: alice.did() }], + }) + + const bytes = Result.unwrap(await delegation.archive()) + const str = Link.create(CAR.codec.code, identity.digest(bytes)).toString( + base64 + ) + + const proof = await parse(str) + assert.equal(proof.issuer.did(), delegation.issuer.did()) + assert.equal(proof.audience.did(), delegation.audience.did()) + assert.equal(proof.capabilities[0].can, delegation.capabilities[0].can) + assert.equal(proof.capabilities[0].with, delegation.capabilities[0].with) + }, + + 'should fail to parse if CID is not CAR codec': async (assert) => { + const alice = await Signer.generate() + const bob = await Signer.generate() + const delegation = await delegate({ + issuer: alice, + audience: bob, + capabilities: [{ can: 'test/thing', with: alice.did() }], + }) + + const bytes = Result.unwrap(await delegation.archive()) + const str = Link.create(12345, identity.digest(bytes)).toString(base64) + + await assert.rejects(parse(str)) + }, + + 'should fail to parse if multihash is not identity hash': async (assert) => { + const alice = await Signer.generate() + const bob = await Signer.generate() + const delegation = await delegate({ + issuer: alice, + audience: bob, + capabilities: [{ can: 'test/thing', with: alice.did() }], + }) + + const bytes = Result.unwrap(await delegation.archive()) + const str = Link.create( + CAR.codec.code, + await sha256.digest(bytes) + ).toString(base64) + + await assert.rejects(parse(str)) + }, + + 'should parse a base64 encoded CIDv1 "proof" as plain CAR (legacy)': async ( + assert + ) => { + const alice = await Signer.generate() + const bob = await Signer.generate() + const delegation = await delegate({ + issuer: alice, + audience: bob, + capabilities: [{ can: 'test/thing', with: alice.did() }], + }) + + const blocks = new Map() + for (const block of delegation.export()) { + blocks.set(block.cid.toString(), block) + } + + const bytes = CAR.codec.encode({ blocks }) + const str = Link.create(CAR.codec.code, identity.digest(bytes)).toString( + base64 + ) + + const proof = await parse(str) + assert.equal(proof.issuer.did(), delegation.issuer.did()) + assert.equal(proof.audience.did(), delegation.audience.did()) + assert.equal(proof.capabilities[0].can, delegation.capabilities[0].can) + assert.equal(proof.capabilities[0].with, delegation.capabilities[0].with) + }, + + 'should parse a base64 encoded "proof" as plain CAR (legacy)': async ( + assert + ) => { + const alice = await Signer.generate() + const bob = await Signer.generate() + const delegation = await delegate({ + issuer: alice, + audience: bob, + capabilities: [{ can: 'test/thing', with: alice.did() }], + }) + + const blocks = new Map() + for (const block of delegation.export()) { + blocks.set(block.cid.toString(), block) + } + + const bytes = CAR.codec.encode({ blocks }) + const str = base64.baseEncode(bytes) + + const proof = await parse(str) + assert.equal(proof.issuer.did(), delegation.issuer.did()) + assert.equal(proof.audience.did(), delegation.audience.did()) + assert.equal(proof.capabilities[0].can, delegation.capabilities[0].can) + assert.equal(proof.capabilities[0].with, delegation.capabilities[0].with) + }, +} + +Test.test({ Proof: testProof })