Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat!: World ID Bridge #127

Closed
wants to merge 18 commits into from
6 changes: 5 additions & 1 deletion idkit/pnpm-lock.yaml

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

11 changes: 4 additions & 7 deletions idkit/src/components/IDKitWidget/States/WorldID/QRState.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,7 @@ import LoadingIcon from '@/components/Icons/LoadingIcon'
import WorldcoinIcon from '@/components/Icons/WorldcoinIcon'

type Props = {
qrData: {
default: string
mobile: string
} | null
qrData: string | null
showQR: boolean
setShowQR: (show: boolean | ((state: boolean) => boolean)) => void
}
Expand All @@ -23,7 +20,7 @@ const QRState: FC<Props> = ({ qrData, showQR, setShowQR }) => {
const [copiedLink, setCopiedLink] = useState(false)

const copyLink = useCallback(() => {
copy(qrData?.default ?? '')
copy(qrData ?? '')

setCopiedLink(true)
setTimeout(() => setCopiedLink(false), 2000)
Expand Down Expand Up @@ -59,7 +56,7 @@ const QRState: FC<Props> = ({ qrData, showQR, setShowQR }) => {
{qrData ? (
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
<div onClick={copyLink} className="cursor-pointer">
<Qrcode data={qrData.default} size={244} />
<Qrcode data={qrData} size={244} />
</div>
) : (
<div className="flex h-[244px] w-[244px] items-center justify-center">
Expand All @@ -72,11 +69,11 @@ const QRState: FC<Props> = ({ qrData, showQR, setShowQR }) => {
)}
<div className="space-y-4">
<motion.a
href={qrData ?? ''}
whileTap={{ scale: 0.95 }}
whileHover={{ scale: 1.05 }}
transition={{ layout: { duration: 0.15 } }}
layoutId={media == 'desktop' ? undefined : 'worldid-button'}
href={qrData?.mobile}
className={classNames(
'flex w-full space-x-2 items-center px-4 py-4 border border-transparent font-medium rounded-2xl shadow-sm',
'bg-0d151d dark:bg-white text-white dark:text-0d151d md:hidden'
Expand Down
33 changes: 12 additions & 21 deletions idkit/src/components/IDKitWidget/States/WorldIDState.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,25 +7,25 @@ import { shallow } from 'zustand/shallow'
import { useEffect, useState } from 'react'
import type { IDKitStore } from '@/store/idkit'
import AboutWorldID from '@/components/AboutWorldID'
import useAppConnection from '@/services/walletconnect'
pdtfh marked this conversation as resolved.
Show resolved Hide resolved
import { useWorldBridge } from '@/services/wld-bridge'
import LoadingIcon from '@/components/Icons/LoadingIcon'
import WorldcoinIcon from '@/components/Icons/WorldcoinIcon'
import { AppErrorCodes, VerificationState } from '@/types/app'
import { AppErrorCodes, VerificationState } from '@/types/bridge'
import DevicePhoneMobileIcon from '@/components/Icons/DevicePhoneMobileIcon'

const getOptions = (store: IDKitStore) => ({
signal: store.signal,
app_id: store.app_id,
action: store.action,
setStage: store.setStage,
bridgeUrl: store.bridgeUrl,
handleVerify: store.handleVerify,
setErrorState: store.setErrorState,
showAbout: store.methods.length == 1,
credential_types: store.credential_types,
isExperimental: store.methods.length > 0,
hasPhone: store.methods.includes('phone'),
action_description: store.action_description,
walletConnectProjectId: store.walletConnectProjectId,
usePhone: () => store.setStage(IDKITStage.ENTER_PHONE),
})

Expand All @@ -40,20 +40,21 @@ const WorldIDState = () => {
usePhone,
handleVerify,
isExperimental,
bridgeUrl,
action_description,
credential_types,
hasPhone,
walletConnectProjectId,
setErrorState,
} = useIDKitStore(getOptions, shallow)

const { result, qrData, verificationState, reset, errorCode } = useAppConnection(
const { connectorURI, reset, errorCode, result, verificationState } = useWorldBridge(
app_id,
action,
signal,
bridgeUrl,
credential_types,
action_description,
walletConnectProjectId
action_description
)

useEffect(() => reset, [reset])
Expand All @@ -80,28 +81,18 @@ const WorldIDState = () => {
<WorldcoinIcon className="text-0d151d h-8 dark:text-white" />
</div>
<p className="font-sora text-center text-2xl font-semibold text-gray-900 dark:text-white">
{/* eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing */}
{verificationState === VerificationState.AwaitingVerification
? __('Confirm in World App')
: __('Continue with Worldcoin')}
{__('Continue with Worldcoin')}
</p>
{verificationState === VerificationState.AwaitingVerification && (
<p className="text-70868f dark:text-9eafc0 mt-3 text-center md:mt-2">
Please confirm the request in your app to continue.
</p>
)}
</div>
{verificationState === VerificationState.AwaitingVerification ? (
{verificationState === VerificationState.PreparingClient ? (
<div className="flex items-center justify-center">
<LoadingIcon className="h-20 w-20" />
</div>
) : (
<QRState showQR={showQR} setShowQR={setShowQR} qrData={qrData} />
<QRState showQR={showQR} setShowQR={setShowQR} qrData={connectorURI} />
)}
{(media == 'desktop' || !showQR) &&
(verificationState === VerificationState.AwaitingConnection ||
verificationState === VerificationState.LoadingWidget) && <AboutWorldID />}
{hasPhone && verificationState == VerificationState.AwaitingConnection && (
{(media == 'desktop' || !showQR) && <AboutWorldID />}
{hasPhone && (
<div className="hidden space-y-3 md:block">
<div className="flex items-center justify-between space-x-6">
<div className="bg-f2f5f9 dark:bg-29343f h-px flex-1" />
Expand Down
30 changes: 30 additions & 0 deletions idkit/src/lib/crypto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { encodeKey } from './hashing'

const encoder = new TextEncoder()
const decoder = new TextDecoder()

export const generateKey = (): Promise<CryptoKeyPair> => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@recmo 👀

return window.crypto.subtle.generateKey(
{ name: 'RSA-OAEP', modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]), hash: 'SHA-256' },
true,
['encrypt', 'decrypt']
)
}

export const exportKey = async (key: CryptoKey): Promise<string> => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: make it explicit this is used to export the private key?

const jwk = await window.crypto.subtle.exportKey('jwk', key)

return Buffer.from(JSON.stringify(jwk)).toString('base64')
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this the simplest encoding?

}

export const getRequestId = async (key: CryptoKey): Promise<`0x${string}`> => {
return encodeKey(await window.crypto.subtle.encrypt({ name: 'RSA-OAEP' }, key, encoder.encode('world-id-v1')))
}

export const encryptRequest = async (key: CryptoKey, request: string): Promise<ArrayBuffer> => {
return window.crypto.subtle.encrypt({ name: 'RSA-OAEP' }, key, encoder.encode(request))
}

export const decryptResponse = async (key: CryptoKey, response: ArrayBuffer): Promise<string> => {
return decoder.decode(await window.crypto.subtle.decrypt({ name: 'RSA-OAEP' }, key, response))
}
6 changes: 5 additions & 1 deletion idkit/src/lib/hashing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { arrayify, concat, hexlify, isBytesLike } from '@ethersproject/bytes'

export interface HashFunctionOutput {
hash: BigInt
digest: string
digest: `0x${string}`
}

/**
Expand Down Expand Up @@ -110,3 +110,7 @@ export const encodeAction = (action: IDKitConfig['action']): string => {

return action.types.map((type, index) => `${type}(${action.values[index]})`).join(',')
}

export const encodeKey = (key: ArrayBuffer): `0x${string}` => {
return `0x${[...new Uint8Array(key)].map(b => b.toString(16).padStart(2, '0')).join('')}`
}
155 changes: 155 additions & 0 deletions idkit/src/services/wld-bridge.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import { create } from 'zustand'
import type { ISuccessResult } from '..'
import { useEffect, useRef } from 'react'
import type { IDKitConfig } from '@/types/config'
import { VerificationState } from '@/types/bridge'
import type { AppErrorCodes } from '@/types/bridge'
import { encodeAction, generateSignal } from '@/lib/hashing'
import { decryptResponse, encryptRequest, exportKey, generateKey, getRequestId } from '@/lib/crypto'

const DEFAULT_BRIDGE_URL = 'https://bridge.id.worldcoin.org/'

type WorldBridgeStore = {
bridgeUrl: string
key: CryptoKeyPair | null
connectorURI: string | null
result: ISuccessResult | null
requestId: `0x${string}` | null
errorCode: AppErrorCodes | null
verificationState: VerificationState

createClient: (
app_id: IDKitConfig['app_id'],
action: IDKitConfig['action'],
signal?: IDKitConfig['signal'],
bridgeUrl?: IDKitConfig['bridgeUrl'],
credential_types?: IDKitConfig['credential_types'],
action_description?: IDKitConfig['action_description']
) => Promise<void>

pollForUpdates: () => Promise<void>

reset: () => void
}

const useWorldBridgeStore = create<WorldBridgeStore>((set, get) => ({
key: null,
result: null,
errorCode: null,
requestId: null,
connectorURI: null,
bridgeUrl: DEFAULT_BRIDGE_URL,
verificationState: VerificationState.PreparingClient,

createClient: async (
app_id: IDKitConfig['app_id'],
action: IDKitConfig['action'],
signal?: IDKitConfig['signal'],
bridgeUrl?: IDKitConfig['bridgeUrl'],
credential_types?: IDKitConfig['credential_types'],
action_description?: IDKitConfig['action_description']
) => {
const key = await generateKey()
const requestId = await getRequestId(key.publicKey)

const res = await fetch(`${bridgeUrl ?? DEFAULT_BRIDGE_URL}/request/${requestId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/octet-stream',
},
body: await encryptRequest(
key.publicKey,
JSON.stringify({
app_id,
credential_types,
action_description,
action: encodeAction(action),
signal: generateSignal(signal).digest,
})
),
})

if (!res.ok) {
set({ verificationState: VerificationState.Failed })
throw new Error('Failed to create client')
}

set({
key,
requestId,
bridgeUrl: bridgeUrl ?? DEFAULT_BRIDGE_URL,
verificationState: VerificationState.PollingForUpdates,
connectorURI: `https://worldcoin.org/verify?t=wld&k=${await exportKey(key.privateKey)}${
bridgeUrl ? `&b=${encodeURIComponent(bridgeUrl)}` : ''
}`,
})
},

pollForUpdates: async () => {
const key = get().key
if (!key) throw new Error('No keypair found. Please call `createClient` first.')

const res = await fetch(`${get().bridgeUrl}/response/${get().requestId}`)

if (res.status == 500) {
return set({ verificationState: VerificationState.Failed })
}

if (!res.ok) return

const result = JSON.parse(await decryptResponse(key.privateKey, await res.arrayBuffer())) as
| ISuccessResult
| { error_code: AppErrorCodes }

if ('error_code' in result) {
return set({
errorCode: result.error_code,
verificationState: VerificationState.Failed,
})
}

set({ result, verificationState: VerificationState.Confirmed, key: null, connectorURI: null, requestId: null })
},

reset: () => {
set({ requestId: null, key: null, connectorURI: null, verificationState: VerificationState.PreparingClient })
},
}))

type UseAppBridgeResponse = {
reset: () => void
connectorURI: string | null
result: ISuccessResult | null
errorCode: AppErrorCodes | null
verificationState: VerificationState
}

export const useWorldBridge = (
app_id: IDKitConfig['app_id'],
action: IDKitConfig['action'],
signal?: IDKitConfig['signal'],
bridgeUrl?: IDKitConfig['bridgeUrl'],
credential_types?: IDKitConfig['credential_types'],
action_description?: IDKitConfig['action_description']
): UseAppBridgeResponse => {
const { reset, result, connectorURI, createClient, pollForUpdates, verificationState, errorCode } =
useWorldBridgeStore()
const ref_credential_types = useRef(credential_types)

useEffect(() => {
if (!app_id) return
if (!connectorURI) {
void createClient(app_id, action, signal, bridgeUrl, ref_credential_types.current, action_description)
}
}, [app_id, action, signal, action_description, createClient, ref_credential_types, bridgeUrl, connectorURI])

useEffect(() => {
if (!connectorURI || result || errorCode) return

const interval = setInterval(() => void pollForUpdates(), 5000)
pdtfh marked this conversation as resolved.
Show resolved Hide resolved

return () => clearInterval(interval)
}, [connectorURI, pollForUpdates, errorCode, result])

return { connectorURI, reset, result, verificationState, errorCode }
}
8 changes: 4 additions & 4 deletions idkit/src/store/idkit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ export type IDKitStore = {
app_id: IDKitConfig['app_id']
action: IDKitConfig['action']
signal: IDKitConfig['signal']
bridgeUrl?: IDKitConfig['bridgeUrl']
action_description?: IDKitConfig['action_description']
walletConnectProjectId?: IDKitConfig['walletConnectProjectId']
credential_types?: IDKitConfig['credential_types']
phoneNumber: string // EXPERIMENTAL

Expand Down Expand Up @@ -63,7 +63,7 @@ const useIDKitStore = create<IDKitStore>()((set, get) => ({
phoneNumber: '', // EXPERIMENTAL
methods: [],
action_description: '',
walletConnectProjectId: '',
bridgeUrl: '',
credential_types: [],

open: false,
Expand Down Expand Up @@ -143,7 +143,7 @@ const useIDKitStore = create<IDKitStore>()((set, get) => ({
credential_types,
action_description,
experimental_methods,
walletConnectProjectId,
bridgeUrl,
autoClose,
theme,
}: Config,
Expand All @@ -161,7 +161,7 @@ const useIDKitStore = create<IDKitStore>()((set, get) => ({
action,
app_id,
autoClose,
walletConnectProjectId,
bridgeUrl,
credential_types: sanitized_credential_types,
action_description,
methods: experimental_methods ?? store.methods,
Expand Down
Loading