diff --git a/.env.example b/.env.example index 79b7ff88..fa329d7d 100644 --- a/.env.example +++ b/.env.example @@ -5,4 +5,3 @@ VITE_GA_MEASUREMENT_ID= VITE_POSTHOG_HOST= VITE_POSTHOG_KEY= VITE_TEMPO_ENV= # testnet|devnet|localnet -VITE_WEBAUTHN_AUTH_URL= # e.g. https://wallet.tempo.xyz/api/webauthn diff --git a/env.d.ts b/env.d.ts index e592b1ba..5a86c157 100644 --- a/env.d.ts +++ b/env.d.ts @@ -5,7 +5,6 @@ interface EnvironmentVariables { readonly VITE_POSTHOG_KEY: string readonly VITE_POSTHOG_HOST: string readonly VITE_TEMPO_ENV: 'localnet' | 'devnet' | 'moderato' - readonly VITE_WEBAUTHN_AUTH_URL?: string readonly INDEXSUPPLY_API_KEY: string readonly SLACK_FEEDBACK_WEBHOOK: string diff --git a/package.json b/package.json index cbf03bd9..bd99d03f 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "vocs": "https://pkg.pr.new/wevm/vocs@2fb25c2", "wagmi": "0.0.0-canary-20260421205751", "waku": "1.0.0-alpha.4", + "webauthx": "~0.1.1", "zod": "^4.3.6" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3fbeca99..b6eb114c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -105,6 +105,9 @@ importers: waku: specifier: 1.0.0-alpha.4 version: 1.0.0-alpha.4(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(react-dom@19.2.5(react@19.2.5))(react-server-dom-webpack@19.2.3(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(webpack@5.104.1))(react@19.2.5)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.3) + webauthx: + specifier: ~0.1.1 + version: 0.1.1(typescript@5.9.3)(zod@4.3.6) zod: specifier: ^4.3.6 version: 4.3.6 diff --git a/src/lib/webAuthnCeremony.ts b/src/lib/webAuthnCeremony.ts new file mode 100644 index 00000000..21a814b1 --- /dev/null +++ b/src/lib/webAuthnCeremony.ts @@ -0,0 +1,89 @@ +import { WebAuthnCeremony } from 'accounts' +import { Bytes } from 'ox' +import { Authentication, Registration } from 'webauthx/server' + +/** + * Custom {@link WebAuthnCeremony.WebAuthnCeremony} that wraps the + * [`keys.tempo.xyz`](https://github.com/tempoxyz/tempo-apps/tree/main/apps/key-manager) + * key-manager service. + * + * The service is a thin challenge issuer + credential public-key store + * (3 endpoints). Unlike `WebAuthnCeremony.server({ url })`, this ceremony + * builds the `PublicKeyCredentialCreationOptions` / + * `PublicKeyCredentialRequestOptions` client-side using `webauthx/server`, + * sources challenges from `GET /challenge`, persists the registered + * credential's public key via `POST /:id`, and retrieves it later via + * `GET /:id`. + */ +export function keys(options: keys.Options = {}): WebAuthnCeremony.WebAuthnCeremony { + const { url = 'https://keys.tempo.xyz', rpId: rpIdOption } = options + + async function getChallenge() { + const response = await fetch(`${url}/challenge`) + if (!response.ok) throw new Error('Failed to get challenge') + return (await response.json()) as { + challenge: `0x${string}` + rp?: { id: string; name: string } + } + } + + function resolveRpId(rp: { id: string; name: string } | undefined) { + return ( + rpIdOption ?? rp?.id ?? (typeof location !== 'undefined' ? location.hostname : 'localhost') + ) + } + + return WebAuthnCeremony.from({ + async getRegistrationOptions(parameters) { + const { excludeCredentialIds, name, userId } = parameters + const { challenge, rp: rpFromServer } = await getChallenge() + const rpId = resolveRpId(rpFromServer) + const { options: opts } = Registration.getOptions({ + challenge, + excludeCredentialIds, + name, + rp: { id: rpId, name: rpFromServer?.name ?? rpId }, + user: userId ? { id: Bytes.fromString(userId), name } : undefined, + }) + return { options: opts } + }, + async verifyRegistration(credential) { + const publicKey = credential.publicKey + const response = await fetch(`${url}/${credential.id}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ credential, publicKey }), + }) + if (!response.ok) { + const { error } = (await response.json().catch(() => ({}))) as { error?: string } + throw new Error(error ?? 'Failed to register credential') + } + return { credentialId: credential.id, publicKey } + }, + async getAuthenticationOptions(parameters = {}) { + const { allowCredentialIds, challenge, credentialId } = parameters + const resolvedChallenge = challenge ?? (await getChallenge()).challenge + const { options: opts } = Authentication.getOptions({ + challenge: resolvedChallenge, + credentialId: allowCredentialIds ? [...allowCredentialIds] : credentialId, + rpId: resolveRpId(undefined), + }) + return { options: opts } + }, + async verifyAuthentication(response) { + const res = await fetch(`${url}/${response.id}`) + if (!res.ok) throw new Error(`Unknown credential: ${response.id}`) + const { publicKey } = (await res.json()) as { publicKey: `0x${string}` } + return { credentialId: response.id, publicKey } + }, + }) +} + +export namespace keys { + export type Options = { + /** Base URL of the key-manager service. @default `"https://keys.tempo.xyz"` */ + url?: string | undefined + /** Override Relying Party ID. @default rp returned by `GET /challenge`, falling back to `location.hostname`. */ + rpId?: string | undefined + } +} diff --git a/src/wagmi.config.ts b/src/wagmi.config.ts index d36f7d6c..a64b99a8 100644 --- a/src/wagmi.config.ts +++ b/src/wagmi.config.ts @@ -15,6 +15,7 @@ import { } from 'wagmi' import { tempoWallet, webAuthn } from 'wagmi/tempo' import { alphaUsd, betaUsd, pathUsd, thetaUsd } from './components/guides/tokens' +import * as WebAuthnCeremony from './lib/webAuthnCeremony.ts' import { feeToken, moderatoZones } from './lib/private-zones.ts' const chain = @@ -67,11 +68,7 @@ export function getConfig(options: getConfig.Options = {}) { url: 'https://sponsor.moderato.tempo.xyz', }, }), - webAuthn( - import.meta.env.VITE_WEBAUTHN_AUTH_URL - ? { authUrl: import.meta.env.VITE_WEBAUTHN_AUTH_URL } - : undefined, - ), + webAuthn({ ceremony: WebAuthnCeremony.keys() }), ]), ], multiInjectedProviderDiscovery,