Skip to content

Commit

Permalink
feat: delegate access to spaces (storacha#293)
Browse files Browse the repository at this point in the history
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 <oli@protocol.ai>
  • Loading branch information
olizilla authored Jan 27, 2023
1 parent 32c61fa commit 441d757
Show file tree
Hide file tree
Showing 8 changed files with 204 additions and 31 deletions.
2 changes: 2 additions & 0 deletions examples/react/w3console/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:^",
Expand Down
33 changes: 19 additions & 14 deletions examples/react/w3console/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand All @@ -79,27 +81,30 @@ function SpaceSection (): JSX.Element {
{(space !== undefined) && (
<div className='flex flex-row items-start gap-2'>
<img title={space.did()} src={`https://www.gravatar.com/avatar/${md5(space.did())}?d=identicon`} className='w-10 hover:saturate-200 saturate-0 invert border-solid border-gray-500 border' />
<div>
<div className='grow'>
<h1 className='text-xl font-semibold leading-5'>{space.name() ?? 'Untitled'}</h1>
<label className='font-mono text-xs text-gray-500'>{space.did()}</label>
</div>
<button className='h-6 w-6 text-gray-500 hover:text-gray-100' onClick={() => setShare(!share)}>
<ShareIcon />
</button>
</div>
)}

</header>
<div className='container mx-auto'>
{registered
? (
<>
<Uploader onUploadComplete={() => { void reload() }} />
<div className='mt-8'>
<UploadsList />
</div>
</>
)
: (
<SpaceRegistrar />
)}
{share && <SpaceShare />}
{registered && !share && (
<>
<Uploader onUploadComplete={() => { void reload() }} />
<div className='mt-8'>
<UploadsList />
</div>
</>
)}
{!registered && (
<SpaceRegistrar />
)}
</div>
</div>
)
Expand Down
88 changes: 88 additions & 0 deletions examples/react/w3console/src/share.tsx
Original file line number Diff line number Diff line change
@@ -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 <h3 className='font-semibold text-xs font-mono uppercase tracking-wider mb-4 text-gray-400'>{props.children}</h3>
}

export async function toCarBlob (delegation: Delegation): Promise<Blob> {
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<void> {
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<HTMLFormElement>): void {
e.preventDefault()
void makeDownloadLink(value)
}

function onChange (e: ChangeEvent<HTMLInputElement>): 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 (
<div className='pt-12'>
<div className=''>
<Header>Share your space</Header>
<p className='mb-4'>Ask your friend for their Decentralized Identifier (DID) and paste it in below</p>
<form onSubmit={(e: React.FormEvent<HTMLFormElement>) => { void onSubmit(e) }}>
<input
className='text-black px-2 py-2 rounded block mb-4 w-full max-w-4xl font-mono text-sm'
type='pattern' pattern='did:.+' placeholder='did:'
value={value}
onChange={onChange}
/>
<a className='w3ui-button text-center block w-52 opacity-30' style={{ opacity: downloadUrl !== '' ? '1' : '0.2' }} href={downloadUrl ?? ''} download={downloadName(downloadUrl !== '', value)}>Download UCAN</a>
</form>
</div>
</div>
)
}
11 changes: 9 additions & 2 deletions packages/keyring-core/src/index.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -108,8 +108,15 @@ export interface KeyringContextActions {
* an audience matching the agent DID.
*/
getProofs: (caps: Capability[]) => Promise<Proof[]>
/**
* 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<Delegation>
}

export type CreateDelegationOptions = Omit<UCANOptions, 'audience'> & { audienceMeta?: AgentMeta }

export interface ServiceConfig {
servicePrincipal?: Principal
connection?: ConnectionView<Service>
Expand Down
3 changes: 2 additions & 1 deletion packages/react-keyring/src/Authenticator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,8 @@ export const AuthenticatorContext = createContext<AuthenticatorContextValue>([
setCurrentSpace: async () => { },
registerSpace: async () => { },
cancelRegisterSpace: () => { },
getProofs: async () => []
getProofs: async () => [],
createDelegation: async () => { throw new Error('missing keyring context provider') }
}
])

Expand Down
22 changes: 18 additions & 4 deletions packages/react-keyring/src/providers/Keyring.tsx
Original file line number Diff line number Diff line change
@@ -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 }

Expand All @@ -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') }
}
]

Expand Down Expand Up @@ -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<Delegation> => {
const agent = await getAgent()
const audienceMeta = options.audienceMeta ?? { name: 'agent', type: 'device' }
return await agent.delegate({
...options,
abilities,
audience,
audienceMeta
})
}

const state = {
space,
spaces,
Expand All @@ -129,7 +142,8 @@ export function KeyringProvider ({ children, servicePrincipal, connection }: Key
registerSpace,
cancelRegisterSpace,
setCurrentSpace,
getProofs
getProofs,
createDelegation
}

return (
Expand Down
22 changes: 18 additions & 4 deletions packages/solid-keyring/src/providers/Keyring.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -30,7 +31,8 @@ export const AuthContext = createContext<KeyringContextValue>([
setCurrentSpace: async () => { },
registerSpace: async () => { },
cancelRegisterSpace: () => { },
getProofs: async () => []
getProofs: async () => [],
createDelegation: async () => { throw new Error('missing keyring context provider') }
}
])

Expand Down Expand Up @@ -121,6 +123,17 @@ export const KeyringProvider: ParentComponent<KeyringProviderProps> = props => {
return agent.proofs(caps)
}

const createDelegation = async (audience: Principal, abilities: Abilities[], options: CreateDelegationOptions): Promise<Delegation> => {
const agent = await getAgent()
const audienceMeta = options.audienceMeta ?? { name: 'agent', type: 'device' }
return await agent.delegate({
...options,
abilities,
audience,
audienceMeta
})
}

const actions = {
loadAgent,
unloadAgent,
Expand All @@ -129,7 +142,8 @@ export const KeyringProvider: ParentComponent<KeyringProviderProps> = props => {
registerSpace,
cancelRegisterSpace,
setCurrentSpace,
getProofs
getProofs,
createDelegation
}

return createComponent(AuthContext.Provider, {
Expand Down
Loading

0 comments on commit 441d757

Please sign in to comment.