From 441d7573f628e358aa76fe8313f01e209c42c68f Mon Sep 17 00:00:00 2001 From: Oli Evans Date: Fri, 27 Jan 2023 11:09:14 +0000 Subject: [PATCH] feat: delegate access to spaces (#293) Allow user to delegate access to a space. Adds createDelegation to keyring-core and chases it through {react,solid}-keyring https://user-images.githubusercontent.com/58871/214885759-2bcf7167-7001-4ffd-b5f4-a0d4b51a9355.mov To keep this PR reviewable, importing delegations will be added in a subsequent PR. License: MIT Signed-off-by: Oli Evans --- examples/react/w3console/package.json | 2 + examples/react/w3console/src/app.tsx | 33 ++++--- examples/react/w3console/src/share.tsx | 88 +++++++++++++++++++ packages/keyring-core/src/index.ts | 11 ++- packages/react-keyring/src/Authenticator.tsx | 3 +- .../react-keyring/src/providers/Keyring.tsx | 22 ++++- .../solid-keyring/src/providers/Keyring.ts | 22 ++++- pnpm-lock.yaml | 54 ++++++++++-- 8 files changed, 204 insertions(+), 31 deletions(-) create mode 100644 examples/react/w3console/src/share.tsx diff --git a/examples/react/w3console/package.json b/examples/react/w3console/package.json index c4bd8a292..b620e64b2 100644 --- a/examples/react/w3console/package.json +++ b/examples/react/w3console/package.json @@ -11,6 +11,8 @@ }, "dependencies": { "@heroicons/react": "^2.0.13", + "@ipld/car": "^5.0.3", + "@ipld/dag-ucan": "^3.2.0", "@w3ui/keyring-core": "workspace:^", "@w3ui/react": "workspace:^", "@w3ui/react-keyring": "workspace:^", diff --git a/examples/react/w3console/src/app.tsx b/examples/react/w3console/src/app.tsx index e4d2a2b1b..70d9e8151 100644 --- a/examples/react/w3console/src/app.tsx +++ b/examples/react/w3console/src/app.tsx @@ -5,9 +5,10 @@ import { useEffect, useState } from 'react' import { Authenticator, Uploader, UploadsList, W3APIProvider, SpaceFinder } from '@w3ui/react' import { useKeyring } from '@w3ui/react-keyring' import { useUploadsList } from '@w3ui/react-uploads-list' -import { ArrowPathIcon } from '@heroicons/react/20/solid' +import { ArrowPathIcon, ShareIcon } from '@heroicons/react/20/solid' import md5 from 'blueimp-md5' import '@w3ui/react/src/styles/uploader.css' +import { SpaceShare } from './share' function SpaceRegistrar (): JSX.Element { const [, { registerSpace }] = useKeyring() @@ -64,6 +65,7 @@ function SpaceRegistrar (): JSX.Element { } function SpaceSection (): JSX.Element { + const [share, setShare] = useState(false) const [{ space }] = useKeyring() const [, { reload }] = useUploadsList() // reload the uploads list when the space changes @@ -79,27 +81,30 @@ function SpaceSection (): JSX.Element { {(space !== undefined) && (
-
+

{space.name() ?? 'Untitled'}

+
)}
- {registered - ? ( - <> - { void reload() }} /> -
- -
- - ) - : ( - - )} + {share && } + {registered && !share && ( + <> + { void reload() }} /> +
+ +
+ + )} + {!registered && ( + + )}
) diff --git a/examples/react/w3console/src/share.tsx b/examples/react/w3console/src/share.tsx new file mode 100644 index 000000000..83330e7e5 --- /dev/null +++ b/examples/react/w3console/src/share.tsx @@ -0,0 +1,88 @@ +import { ChangeEvent, useState } from 'react' +import { useKeyring } from '@w3ui/react-keyring' +import * as DID from '@ipld/dag-ucan/did' +import { CarWriter } from '@ipld/car/writer' +import type { PropsWithChildren } from 'react' +import type { Delegation } from '@ucanto/interface' + +function Header (props: PropsWithChildren): JSX.Element { + return

{props.children}

+} + +export async function toCarBlob (delegation: Delegation): Promise { + const { writer, out } = CarWriter.create() + for (const block of delegation.export()) { + // @ts-expect-error + void writer.put(block) + } + void writer.close() + + const carParts = [] + for await (const chunk of out) { + carParts.push(chunk) + } + const car = new Blob(carParts, { + type: 'application/vnd.ipld.car' + }) + return car +} + +export function SpaceShare (): JSX.Element { + const [, { createDelegation }] = useKeyring() + const [value, setValue] = useState('') + const [downloadUrl, setDownloadUrl] = useState('') + + async function makeDownloadLink (input: string): Promise { + let audience + try { + audience = DID.parse(input.trim()) + } catch (err) { + setDownloadUrl('') + return + } + + try { + const delegation = await createDelegation(audience, ['*'], { expiration: Infinity }) + const blob = await toCarBlob(delegation) + const url = URL.createObjectURL(blob) + setDownloadUrl(url) + } catch (err) { + throw new Error('failed to register', { cause: err }) + } + } + + function onSubmit (e: React.FormEvent): void { + e.preventDefault() + void makeDownloadLink(value) + } + + function onChange (e: ChangeEvent): void { + const input = e.target.value + void makeDownloadLink(input) + setValue(input) + } + + function downloadName (ready: boolean, inputDid: string): string { + if (!ready || inputDid === '') return '' + const [, method = '', id = ''] = inputDid.split(':') + return `did-${method}-${id?.substring(0, 10)}.ucan` + } + + return ( +
+
+
Share your space
+

Ask your friend for their Decentralized Identifier (DID) and paste it in below

+
) => { void onSubmit(e) }}> + + Download UCAN +
+
+
+ ) +} diff --git a/packages/keyring-core/src/index.ts b/packages/keyring-core/src/index.ts index 22d5e0a25..f7f27de88 100644 --- a/packages/keyring-core/src/index.ts +++ b/packages/keyring-core/src/index.ts @@ -1,7 +1,7 @@ import { Agent } from '@web3-storage/access/agent' import { StoreIndexedDB } from '@web3-storage/access/stores/store-indexeddb' -import type { Service } from '@web3-storage/access/types' -import type { Capability, DID, Proof, Signer, ConnectionView, Principal } from '@ucanto/interface' +import type { Abilities, AgentMeta, Service } from '@web3-storage/access/types' +import type { Capability, DID, Proof, Signer, ConnectionView, Principal, Delegation, UCANOptions } from '@ucanto/interface' import * as RSASigner from '@ucanto/principal/rsa' const DB_NAME = 'w3ui' @@ -108,8 +108,15 @@ export interface KeyringContextActions { * an audience matching the agent DID. */ getProofs: (caps: Capability[]) => Promise + /** + * Create a delegation to the passed audience for the given abilities with + * the _current_ space as the resource. + */ + createDelegation: (audience: Principal, abilities: Abilities[], options: CreateDelegationOptions) => Promise } +export type CreateDelegationOptions = Omit & { audienceMeta?: AgentMeta } + export interface ServiceConfig { servicePrincipal?: Principal connection?: ConnectionView diff --git a/packages/react-keyring/src/Authenticator.tsx b/packages/react-keyring/src/Authenticator.tsx index fb35c0e3b..1b73304f1 100644 --- a/packages/react-keyring/src/Authenticator.tsx +++ b/packages/react-keyring/src/Authenticator.tsx @@ -47,7 +47,8 @@ export const AuthenticatorContext = createContext([ setCurrentSpace: async () => { }, registerSpace: async () => { }, cancelRegisterSpace: () => { }, - getProofs: async () => [] + getProofs: async () => [], + createDelegation: async () => { throw new Error('missing keyring context provider') } } ]) diff --git a/packages/react-keyring/src/providers/Keyring.tsx b/packages/react-keyring/src/providers/Keyring.tsx index 81681c820..de700cf41 100644 --- a/packages/react-keyring/src/providers/Keyring.tsx +++ b/packages/react-keyring/src/providers/Keyring.tsx @@ -1,8 +1,9 @@ import React, { createContext, useState, useContext } from 'react' -import { createAgent, Space, getCurrentSpace, getSpaces } from '@w3ui/keyring-core' +import { createAgent, Space, getCurrentSpace, getSpaces, CreateDelegationOptions } from '@w3ui/keyring-core' import type { KeyringContextState, KeyringContextActions, ServiceConfig } from '@w3ui/keyring-core' import type { Agent } from '@web3-storage/access' -import type { Capability, DID, Proof, Signer } from '@ucanto/interface' +import type { Abilities } from '@web3-storage/access/types' +import type { Capability, Delegation, DID, Principal, Proof, Signer } from '@ucanto/interface' export { KeyringContextState, KeyringContextActions } @@ -25,7 +26,8 @@ export const keyringContextDefaultValue: KeyringContextValue = [ setCurrentSpace: async () => { }, registerSpace: async () => { }, cancelRegisterSpace: () => { }, - getProofs: async () => [] + getProofs: async () => [], + createDelegation: async () => { throw new Error('missing keyring context provider') } } ] @@ -116,6 +118,17 @@ export function KeyringProvider ({ children, servicePrincipal, connection }: Key return agent.proofs(caps) } + const createDelegation = async (audience: Principal, abilities: Abilities[], options: CreateDelegationOptions): Promise => { + const agent = await getAgent() + const audienceMeta = options.audienceMeta ?? { name: 'agent', type: 'device' } + return await agent.delegate({ + ...options, + abilities, + audience, + audienceMeta + }) + } + const state = { space, spaces, @@ -129,7 +142,8 @@ export function KeyringProvider ({ children, servicePrincipal, connection }: Key registerSpace, cancelRegisterSpace, setCurrentSpace, - getProofs + getProofs, + createDelegation } return ( diff --git a/packages/solid-keyring/src/providers/Keyring.ts b/packages/solid-keyring/src/providers/Keyring.ts index b9942a861..86beeffd3 100644 --- a/packages/solid-keyring/src/providers/Keyring.ts +++ b/packages/solid-keyring/src/providers/Keyring.ts @@ -1,7 +1,8 @@ import type { ParentComponent } from 'solid-js' -import type { KeyringContextState, KeyringContextActions, ServiceConfig } from '@w3ui/keyring-core' +import type { KeyringContextState, KeyringContextActions, ServiceConfig, CreateDelegationOptions } from '@w3ui/keyring-core' import type { Agent } from '@web3-storage/access' -import type { Delegation, Capability, DID } from '@ucanto/interface' +import type { Abilities } from '@web3-storage/access/types' +import type { Delegation, Capability, DID, Principal } from '@ucanto/interface' import { createContext, useContext, createSignal, createComponent } from 'solid-js' import { createStore } from 'solid-js/store' @@ -30,7 +31,8 @@ export const AuthContext = createContext([ setCurrentSpace: async () => { }, registerSpace: async () => { }, cancelRegisterSpace: () => { }, - getProofs: async () => [] + getProofs: async () => [], + createDelegation: async () => { throw new Error('missing keyring context provider') } } ]) @@ -121,6 +123,17 @@ export const KeyringProvider: ParentComponent = props => { return agent.proofs(caps) } + const createDelegation = async (audience: Principal, abilities: Abilities[], options: CreateDelegationOptions): Promise => { + const agent = await getAgent() + const audienceMeta = options.audienceMeta ?? { name: 'agent', type: 'device' } + return await agent.delegate({ + ...options, + abilities, + audience, + audienceMeta + }) + } + const actions = { loadAgent, unloadAgent, @@ -129,7 +142,8 @@ export const KeyringProvider: ParentComponent = props => { registerSpace, cancelRegisterSpace, setCurrentSpace, - getProofs + getProofs, + createDelegation } return createComponent(AuthContext.Provider, { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 62f6ab921..a4cd790a6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -235,6 +235,8 @@ importers: examples/react/w3console: specifiers: '@heroicons/react': ^2.0.13 + '@ipld/car': ^5.0.3 + '@ipld/dag-ucan': ^3.2.0 '@preact/preset-vite': ^2.4.0 '@types/blueimp-md5': ^2.18.0 '@w3ui/keyring-core': workspace:^ @@ -250,6 +252,8 @@ importers: vite: ^4.0.0 dependencies: '@heroicons/react': 2.0.13 + '@ipld/car': 5.0.3 + '@ipld/dag-ucan': 3.2.0 '@w3ui/keyring-core': link:../../../packages/keyring-core '@w3ui/react': link:../../../packages/react '@w3ui/react-keyring': link:../../../packages/react-keyring @@ -3308,6 +3312,15 @@ packages: multiformats: 10.0.3 varint: 6.0.0 + /@ipld/car/5.0.3: + resolution: {integrity: sha512-omPSY65OSVmlFGJDn2xbd75o71GNHmgP5u2dQ5fITc0X/QqJZVfZi95NCs8oa1wWhjkaK3RTswRSg2iNqFUSAg==} + engines: {node: '>=16.0.0', npm: '>=7.0.0'} + dependencies: + '@ipld/dag-cbor': 9.0.0 + cborg: 1.10.0 + multiformats: 11.0.1 + varint: 6.0.0 + /@ipld/dag-cbor/8.0.0: resolution: {integrity: sha512-VfedC21yAD/ZIahcrHTeMcc17kEVRlCmHQl0JY9/Rwbd102v0QcuXtBN8KGH8alNO82S89+H6MM/hxP85P4Veg==} engines: {node: '>=16.0.0', npm: '>=7.0.0'} @@ -3315,6 +3328,20 @@ packages: cborg: 1.9.6 multiformats: 10.0.3 + /@ipld/dag-cbor/9.0.0: + resolution: {integrity: sha512-zdsiSiYDEOIDW7mmWOYWC9gukjXO+F8wqxz/LfN7iSwTfIyipC8+UQrCbPupFMRb/33XQTZk8yl3My8vUQBRoA==} + engines: {node: '>=16.0.0', npm: '>=7.0.0'} + dependencies: + cborg: 1.10.0 + multiformats: 11.0.1 + + /@ipld/dag-json/10.0.0: + resolution: {integrity: sha512-u/PfR2sT9AiZZDUl1VNspx3OP13zuvBXAd3sKiURlSOoWfoLigxTCs+sXeaXA0hoXU7u1M2DECMt4LCUHuApSA==} + engines: {node: '>=16.0.0', npm: '>=7.0.0'} + dependencies: + cborg: 1.10.0 + multiformats: 11.0.1 + /@ipld/dag-json/9.0.1: resolution: {integrity: sha512-dL5Xhrk0XXoq3lSsY2LNNraH2Nxx4nlgQwSarl2J3oir2jBDQEiBDW8bjgr30ni8/epdWDhXm5mdxat8dFWwGQ==} engines: {node: '>=16.0.0', npm: '>=7.0.0'} @@ -3335,6 +3362,13 @@ packages: '@ipld/dag-json': 9.0.1 multiformats: 10.0.3 + /@ipld/dag-ucan/3.2.0: + resolution: {integrity: sha512-CTClaGx4F3iEMJgYaYVOVBEvtNXzPc77Mi6p3vBtylSzDWhbf1Gou9ij7PlblOqWKA1H7XI8fp6yweTb6iXNKQ==} + dependencies: + '@ipld/dag-cbor': 9.0.0 + '@ipld/dag-json': 10.0.0 + multiformats: 11.0.1 + /@ipld/unixfs/2.0.0: resolution: {integrity: sha512-Li6ObZWlnQPM8R1O6mjUWQWlxjf+4yjZDERZIvNILOXeTvF0G36WFIdr3c2s9M6Aiez8gCMzodNnJLRXzXnJ0Q==} dependencies: @@ -5262,22 +5296,22 @@ packages: /@ucanto/core/4.0.3: resolution: {integrity: sha512-5Uc6vdmKZzlA9NFvAN6BC1Tp1Npz0sepp2up1ZWU4BqArQ0w4U0YMtL9KPdBnL3TDAyDNgS9PgK+vHpjcSoeiQ==} dependencies: - '@ipld/car': 5.0.1 + '@ipld/car': 5.0.3 '@ipld/dag-cbor': 8.0.0 - '@ipld/dag-ucan': 3.1.1 + '@ipld/dag-ucan': 3.2.0 '@ucanto/interface': 4.0.3 multiformats: 10.0.3 /@ucanto/interface/4.0.3: resolution: {integrity: sha512-ip1ZziMUhi9nFm9jPLEDLs8zX4HleYsuHHITH5w8GjST7chbRz1LBSq43A3nMUgea17cuIp+rr7i4QcOSFgXHw==} dependencies: - '@ipld/dag-ucan': 3.1.1 + '@ipld/dag-ucan': 3.2.0 multiformats: 10.0.3 /@ucanto/principal/4.0.3: resolution: {integrity: sha512-mR9BTkXWDDSFDCf5gminNeDte/jwurohjFJE8oVfGfUnkzSjYwfm4h5GQ47qeze6xgm17SS5pQwipSvCGHfvkg==} dependencies: - '@ipld/dag-ucan': 3.1.1 + '@ipld/dag-ucan': 3.2.0 '@noble/ed25519': 1.7.1 '@ucanto/interface': 4.0.3 multiformats: 10.0.3 @@ -5294,7 +5328,7 @@ packages: /@ucanto/transport/4.0.3: resolution: {integrity: sha512-yrJoqoxmMCpPElR+iEb2AKIjUEmM+JGCcM1TZLXVbMlzaAt6ndYDMPajfnh3PBQMk7edIodZi+UxCLKvc8yelg==} dependencies: - '@ipld/car': 5.0.1 + '@ipld/car': 5.0.3 '@ipld/dag-cbor': 8.0.0 '@ucanto/core': 4.0.3 '@ucanto/interface': 4.0.3 @@ -5303,7 +5337,7 @@ packages: /@ucanto/validator/4.0.3: resolution: {integrity: sha512-GLsOIq4R7ixu4D1NMNEJZhOelLPIpd/qtTyOjpxqrrSfsDfOoCsHkSxBy0gTwS/4ZIFMM5sa2LBPJw+ZXobgzw==} dependencies: - '@ipld/car': 5.0.1 + '@ipld/car': 5.0.3 '@ipld/dag-cbor': 8.0.0 '@ucanto/core': 4.0.3 '@ucanto/interface': 4.0.3 @@ -6323,6 +6357,10 @@ packages: resolution: {integrity: sha512-1MgUzEkoMO6gKfXflStpYgZDlFM7M/ck/bgfVCACO5vnAf0fXoNVHdWtqGU+MYca+4bL9Z5bpOVmR33cWW9G2A==} dev: true + /cborg/1.10.0: + resolution: {integrity: sha512-/eM0JCaL99HDHxjySNQJLaolZFVdl6VA0/hEKIoiQPcQzE5LrG5QHdml0HaBt31brgB9dNe1zMr3f8IVrpotRQ==} + hasBin: true + /cborg/1.9.6: resolution: {integrity: sha512-XmiD+NWTk9xg31d8MdXgW46bSZd95ELllxjbjdWGyHAtpTw+cf8iG3NibWgTWRnfWfxtcihVa5Pm0gchHiO3JQ==} hasBin: true @@ -9947,6 +9985,10 @@ packages: resolution: {integrity: sha512-K2yGSmstS/oEmYiEIieHb53jJCaqp4ERPDQAYrm5sV3UUrVDZeshJQCK6GHAKyIGufU1vAcbS0PdAAZmC7Tzcw==} engines: {node: '>=16.0.0', npm: '>=7.0.0'} + /multiformats/11.0.1: + resolution: {integrity: sha512-atWruyH34YiknSdL5yeIir00EDlJRpHzELYQxG7Iy29eCyL+VrZHpPrX5yqlik3jnuqpLpRKVZ0SGVb9UzKaSA==} + engines: {node: '>=16.0.0', npm: '>=7.0.0'} + /multiformats/9.9.0: resolution: {integrity: sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==} dev: false