diff --git a/build.js b/build.js index 761ed0fa..fb0f7a9c 100644 --- a/build.js +++ b/build.js @@ -432,7 +432,8 @@ export const buildOptions = { 'process.env.NODE_ENV': `"${process.env.NODE_ENV ?? 'production'}"`, 'process.env.APP_NAME': `'${pkg.name}'`, 'process.env.APP_VERSION': `'${pkg.version}'`, - 'process.env.GIT_REVISION': `'${rev}'` + 'process.env.GIT_REVISION': `'${rev}'`, + 'process.env.DEBUG': 'undefined' }, entryPoints: [ 'src/index.tsx', diff --git a/debug-ens.ts b/debug-ens.ts new file mode 100644 index 00000000..9c7158f6 --- /dev/null +++ b/debug-ens.ts @@ -0,0 +1,39 @@ +/* eslint-disable no-console */ +import { resolveEthDnsLink } from './src/sw/lib/ens-resolver.ts' + +async function main (): Promise { + const name = process.argv[2] ?? 'vitalik.eth' + + const startedAt = Date.now() + + console.log(`[debug:ens] resolving ${name}`) + + try { + const result = await resolveEthDnsLink(name) + const elapsedMs = Date.now() - startedAt + + console.log('[debug:ens] success') + console.log(`[debug:ens] dnsLinkPath=${result.dnsLinkPath}`) + console.log(`[debug:ens] blockNumber=${result.blockNumber}`) + console.log(`[debug:ens] blockHash=${result.blockHash}`) + console.log(`[debug:ens] elapsedMs=${elapsedMs}`) + } catch (err) { + const elapsedMs = Date.now() - startedAt + console.error('[debug:ens] failure') + console.error(`[debug:ens] elapsedMs=${elapsedMs}`) + + if (err instanceof Error) { + console.error(`[debug:ens] name=${err.name}`) + console.error(`[debug:ens] message=${err.message}`) + if (err.stack != null) { + console.error(err.stack) + } + } else { + console.error(String(err)) + } + + process.exitCode = 1 + } +} + +await main() diff --git a/package.json b/package.json index ccbada7a..97b8c2a7 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "build": "node --experimental-strip-types build.js && go build -o service-worker-gateway main.go", "build:test": "cross-env NODE_ENV=test npm run build", "start": "cross-env NODE_ENV=development node --experimental-strip-types ./serve.ts --load-fixtures --watch", + "debug:ens": "cross-env NODE_ENV=development node --experimental-strip-types ./debug-ens.ts", "test": "cross-env NODE_ENV=test aegir test -- --experimental-strip-types", "test:chrome": "npm run build:test && playwright test -c playwright.config.js --project chromium", "test:firefox": "npm run build:test && playwright test -c playwright.config.js --project firefox no-service-worker", @@ -41,11 +42,13 @@ "@chainsafe/is-ip": "^2.1.0", "@chainsafe/libp2p-noise": "^17.0.0", "@chainsafe/libp2p-yamux": "^8.0.1", + "@ensdomains/ensjs": "^4.2.2", "@helia/block-brokers": "^5.2.1", "@helia/delegated-routing-v1-http-api-client": "^6.0.1", "@helia/routers": "^5.1.0", "@helia/utils": "^2.5.0", "@helia/verified-fetch": "^7.2.11", + "@ipshipyard/verified-eth-provider": "github:ipshipyard/verified-eth-provider#f12ce4f877467df59bb3348d27e5c14b07c7f7c9", "@ipld/dag-cbor": "^9.2.5", "@ipld/dag-json": "^10.2.5", "@ipld/dag-pb": "^4.1.5", @@ -76,6 +79,7 @@ "react-router-dom": "^7.9.5", "tachyons": "^4.12.0", "uint8arrays": "^5.1.0", + "viem": "^2.48.4", "weald": "^1.1.1" }, "devDependencies": { diff --git a/src/config/index-development.ts b/src/config/index-development.ts index 34555240..9cf23980 100644 --- a/src/config/index-development.ts +++ b/src/config/index-development.ts @@ -21,5 +21,13 @@ export const config: Config = { }, fetchTimeout: 30_000, serviceWorkerTTL: 86_400_000, - debug: '*,*:trace' + debug: '*,*:trace', + ens: { + primaryRpc: 'https://ethereum.publicnode.com', + witnessRpcs: [ + 'https://eth-mainnet.public.blastapi.io', + 'https://mainnet.gateway.tenderly.co' + ], + maxSafeBlockAgeMs: 20 * 60 * 1_000 + } } diff --git a/src/config/index-test.ts b/src/config/index-test.ts index e1012e53..496d0187 100644 --- a/src/config/index-test.ts +++ b/src/config/index-test.ts @@ -15,5 +15,14 @@ export const config: Config = { }, fetchTimeout: 1_000, serviceWorkerTTL: 86_400_000, - debug: '*,*:trace' + debug: '*,*:trace', + ens: { + // Pointed at the local test fixture server + primaryRpc: 'http://127.0.0.1:3336', + witnessRpcs: [ + 'http://127.0.0.1:3337', + 'http://127.0.0.1:3338' + ], + maxSafeBlockAgeMs: 60 * 60 * 24 * 365 * 1_000 // never stale in tests + } } diff --git a/src/config/index.ts b/src/config/index.ts index 5e4eacfd..dcf9c71c 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -1,3 +1,21 @@ +export interface EnsConfig { + /** + * Primary Ethereum RPC endpoint. Must support eth_getProof and + * eth_getBlockByNumber. + */ + primaryRpc: string + /** + * Two witness Ethereum RPC endpoints used to independently verify the safe + * block returned by the primary. Only eth_getBlockByNumber is required. + */ + witnessRpcs: [string, string] + /** + * Maximum age in milliseconds for the safe block timestamp before we + * consider it stale and reject the lookup. + */ + maxSafeBlockAgeMs: number +} + export interface Config { gateways: string[] routers: string[] @@ -5,6 +23,7 @@ export interface Config { fetchTimeout: number serviceWorkerTTL: number debug: string + ens: EnsConfig } /** @@ -22,5 +41,16 @@ export const config: Config = { }, fetchTimeout: 30_000, serviceWorkerTTL: 86_400_000, - debug: globalThis?.location?.hostname?.search(/localhost|inbrowser\.dev|127\.0\.0\.1/) === -1 ? '' : '*,*:trace' + debug: globalThis?.location?.hostname?.search(/localhost|inbrowser\.dev|127\.0\.0\.1/) === -1 ? '' : '*,*:trace', + ens: { + // Primary endpoint for safe block and proof-backed ENS reads + primaryRpc: 'https://ethereum.publicnode.com', + // Two independent witness providers for block-hash verification + witnessRpcs: [ + 'https://eth-mainnet.public.blastapi.io', + 'https://mainnet.gateway.tenderly.co' + ], + // Reject safe-head older than 5 minutes + maxSafeBlockAgeMs: 20 * 60 * 1_000 + } } diff --git a/src/sw/lib/ens-resolver.ts b/src/sw/lib/ens-resolver.ts new file mode 100644 index 00000000..3d3d583a --- /dev/null +++ b/src/sw/lib/ens-resolver.ts @@ -0,0 +1,241 @@ +/** + * Verifiable ENS resolution for .eth names. + * + * Flow: + * 1. Fetch the `safe` block header from the primary Ethereum RPC. + * 2. Fetch the same block number from two independent witness RPCs. + * 3. Verify all three agree on the same block hash and the block is recent. + * 4. Build a viem client backed by a custom provider pinned to that safe block. + * 5. Delegate ENS resolver/contenthash reads to viem actions. + * + * viem is lazy-imported so ENS/Ethereum code is never loaded for non-.eth + * requests. + */ + +import { + createQuorumTrustedBlockSelector, + createVerifiedTransport +} from '@ipshipyard/verified-eth-provider' +import { config } from '../../config/index.ts' +import { getSwLogger } from '../../lib/logger.ts' +import type { + TrustedBlock, + TrustedBlockProvider, + VerifiedTransport +} from '@ipshipyard/verified-eth-provider' +import type { DNSResponse, QueryOptions, RecordType } from '@multiformats/dns' +import type { DNSResolver } from '@multiformats/dns/resolvers' + +const log = getSwLogger('ens-resolver') +const ENS_DNSLINK_TTL_SECONDS = 60 + +interface EnsJsTools { + normalize (name: string): string + createEnsPublicClient (opts: any): any + addEnsContracts (chain: any): any + mainnet: any + getContentHashRecord (client: any, opts: { name: string, gatewayUrls?: string[], strict?: boolean }): Promise +} + +let ensToolsCache: EnsJsTools | null = null +let trustedBlockProvider: TrustedBlockProvider | null = null + +function getTrustedBlockProvider (): TrustedBlockProvider { + if (trustedBlockProvider == null) { + trustedBlockProvider = createQuorumTrustedBlockSelector(config.ens, { + log: getSwLogger('verified-eth-rpc-provider') + }) + } + + return trustedBlockProvider +} + +async function loadEnsTools (): Promise { + if (ensToolsCache != null) { + return ensToolsCache + } + + const [ + { normalize }, + { createEnsPublicClient, addEnsContracts }, + { getContentHashRecord }, + { mainnet } + ] = await Promise.all([ + import('viem/ens'), + import('@ensdomains/ensjs'), + import('@ensdomains/ensjs/public'), + import('viem/chains') + ]) + + const tools: EnsJsTools = { + normalize, + createEnsPublicClient, + addEnsContracts, + mainnet, + getContentHashRecord + } + + ensToolsCache = tools + + return tools +} + +async function resolveDnsLinkWithEnsJs ( + ensTools: EnsJsTools, + normalizedName: string, + transport: VerifiedTransport, + signal?: AbortSignal +): Promise<{ protocolType: string, decoded: string } | null> { + void signal + + const client = ensTools.createEnsPublicClient({ + chain: ensTools.addEnsContracts(ensTools.mainnet), + transport + }) + + return ensTools.getContentHashRecord(client, { + name: normalizedName, + strict: false + }) as Promise<{ protocolType: string, decoded: string } | null> +} + +function contenthashToDnsLinkPath (protocolType: string, decoded: string): string { + switch (protocolType) { + case 'ipfs': + return `/ipfs/${decoded}` + case 'ipns': + return `/ipns/${decoded}` + default: + throw new EnsUnsupportedContenthashError( + `Unsupported ENS contenthash protocol "${protocolType}" for this name` + ) + } +} + +export interface EnsDnsLinkResult { + dnsLinkPath: string + blockNumber: string + blockHash: string +} + +export async function resolveEthDnsLink (name: string, signal?: AbortSignal): Promise { + log('resolving ENS name via ensjs-backed provider: %s', name) + + const trustedBlockProvider = getTrustedBlockProvider() + const [ensTools, transport] = await Promise.all([ + loadEnsTools(), + createVerifiedTransport({ + rpcUrl: config.ens.primaryRpc, + trustedBlock: async () => trustedBlockProvider(signal) + }, { + log: getSwLogger('verified-eth-rpc-provider') + }) + ]) + void transport.prewarmVerificationDependencies().catch(err => { + log('prewarm of ENS verification dependencies failed: %s', err instanceof Error ? err.message : String(err)) + }) + + const safeBlock: TrustedBlock = transport.trustedBlock + + let normalizedName: string + try { + normalizedName = ensTools.normalize(name) + } catch (err: any) { + throw new EnsResolutionError(`Invalid ENS name "${name}": ${err.message}`) + } + + const contentHash = await resolveDnsLinkWithEnsJs( + ensTools, + normalizedName, + transport, + signal + ) + + if (contentHash == null || contentHash.protocolType == null || contentHash.decoded == null || contentHash.decoded === '') { + throw new EnsNoContenthashError(`ENS name "${name}" has no contenthash record`) + } + + const dnsLinkPath = contenthashToDnsLinkPath(contentHash.protocolType, contentHash.decoded) + + log('ENS contenthash for %s: protocol=%s decoded=%s block=%s', name, contentHash.protocolType, contentHash.decoded, safeBlock.number) + + return { + dnsLinkPath, + blockNumber: safeBlock.number, + blockHash: safeBlock.hash + } +} + +function stripDnsLinkPrefix (domain: string): string { + return domain.startsWith('_dnslink.') ? domain.slice('_dnslink.'.length) : domain +} + +function buildDnsResponse (domain: string, recordData: string, type: RecordType): DNSResponse { + return { + Status: 0, + TC: false, + RD: true, + RA: true, + AD: false, + CD: false, + Question: [{ name: domain, type }], + Answer: [{ + name: domain, + type, + TTL: ENS_DNSLINK_TTL_SECONDS, + data: recordData + }] + } +} + +export function createEnsDnsResolver (): DNSResolver { + return async (domain: string, options?: QueryOptions): Promise => { + const normalizedDomain = stripDnsLinkPrefix(domain) + + if (!normalizedDomain.endsWith('.eth')) { + const err = new Error(`ENS DNS resolver cannot resolve non-.eth domain: ${domain}`) as Error & { code?: string } + err.code = 'ENOTFOUND' + throw err + } + + let requestedTypes: RecordType[] = [] + if (options?.types != null) { + requestedTypes = Array.isArray(options.types) + ? options.types + : [options.types] + } + + if (requestedTypes.length > 0 && !requestedTypes.includes(16 as RecordType)) { + const type = requestedTypes[0] + return buildDnsResponse(domain, '', type) + } + + const result = await resolveEthDnsLink(normalizedDomain, options?.signal) + const recordData = `dnslink=${result.dnsLinkPath}` + + log('synthetic DNSLink TXT for %s: %s (block %s)', normalizedDomain, recordData, result.blockNumber) + + return buildDnsResponse(domain, recordData, 16 as RecordType) + } +} + +export class EnsResolutionError extends Error { + constructor (message: string, options?: ErrorOptions) { + super(message, options) + this.name = 'EnsResolutionError' + } +} + +export class EnsNoContenthashError extends EnsResolutionError { + constructor (message: string) { + super(message) + this.name = 'EnsNoContenthashError' + } +} + +export class EnsUnsupportedContenthashError extends EnsResolutionError { + constructor (message: string) { + super(message) + this.name = 'EnsUnsupportedContenthashError' + } +} diff --git a/src/sw/lib/verified-fetch.ts b/src/sw/lib/verified-fetch.ts index 1246641f..195b5188 100644 --- a/src/sw/lib/verified-fetch.ts +++ b/src/sw/lib/verified-fetch.ts @@ -23,6 +23,7 @@ import { sha1 } from 'multiformats/hashes/sha1' import { config } from '../../config/index.ts' import { collectingLogger } from '../../lib/collecting-logger.ts' import { blake3 } from './blake3.ts' +import { createEnsDnsResolver } from './ens-resolver.ts' import type { VerifiedFetch } from '@helia/verified-fetch' import type { Libp2pOptions } from 'libp2p' @@ -82,6 +83,8 @@ export async function updateVerifiedFetch (): Promise { const resolvers: Record = {} + resolvers.eth = createEnsDnsResolver() + for (const [key, resolver] of Object.entries(config.dnsResolvers)) { resolvers[key] = Array.isArray(resolver) ? resolver.map(r => dnsJsonOverHttps(r)) : dnsJsonOverHttps(resolver) } diff --git a/test/lib/parse-request.spec.ts b/test/lib/parse-request.spec.ts index f4e22150..a1f196ed 100644 --- a/test/lib/parse-request.spec.ts +++ b/test/lib/parse-request.spec.ts @@ -156,6 +156,20 @@ describe('parse-request', () => { nativeURL: new URL(`ipns://${domain}${path}`) }) }) + + it('should parse .eth DNSLink as DNSLink protocol', async () => { + const domain = 'vitalik.eth' + const path = '/about' + + expect(parseRequest(new URL(`http://${dnsLinkLabelEncoder(domain)}.ipns.localhost:3000${path}`), new URL('http://localhost:3000'))).to.deep.equal({ + protocol: 'dnslink', + type: 'subdomain', + domain, + subdomainURL: new URL(`http://${dnsLinkLabelEncoder(domain)}.ipns.localhost:3000${path}`), + pathURL: new URL(`http://localhost:3000/ipns/${domain}${path}`), + nativeURL: new URL(`ipns://${domain}${path}`) + }) + }) }) describe('path gateway requests', () => { diff --git a/test/sw/lib/ens-resolver.spec.ts b/test/sw/lib/ens-resolver.spec.ts new file mode 100644 index 00000000..43d326bd --- /dev/null +++ b/test/sw/lib/ens-resolver.spec.ts @@ -0,0 +1,20 @@ +import { RecordType } from '@multiformats/dns' +import { expect } from 'aegir/chai' +import { createEnsDnsResolver } from '../../../src/sw/lib/ens-resolver.ts' + +describe('/sw/lib/ens-resolver.js', () => { + it('should synthesize a DNSLink TXT answer for .eth domains', async function () { + this.timeout(30_000) + + const resolver = createEnsDnsResolver() + const result = await resolver('_dnslink.vitalik.eth', { + types: [RecordType.TXT] + }) + + expect(result.Status).to.equal(0) + expect(result.Answer).to.have.lengthOf(1) + expect(result.Answer[0].name).to.equal('_dnslink.vitalik.eth') + expect(result.Answer[0].type).to.equal(RecordType.TXT) + expect(result.Answer[0].data).to.match(/^dnslink=\/ip(fs|ns)\//) + }) +}) diff --git a/tsconfig.json b/tsconfig.json index ee8704f1..8cfb98de 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -12,6 +12,7 @@ }, "include": [ "src", + "packages/gateway-url-helper/src", "types", "test", "test-conformance", @@ -19,6 +20,7 @@ "test-e2e", "playwright.config.js", "serve.ts", + "debug-ens.ts", "cloudflare/snippets/02_shared_sw_installer_cache.js", "cloudflare/snippets/01_path_gateway_to_subdomain.js" ]