diff --git a/src/libs/abstraction/index.ts b/src/libs/abstraction/index.ts index 8902d00bb..fd6052f83 100644 --- a/src/libs/abstraction/index.ts +++ b/src/libs/abstraction/index.ts @@ -1,6 +1,7 @@ import { GithubProofParser } from "./web2/github" import { TwitterProofParser } from "./web2/twitter" import { DiscordProofParser } from "./web2/discord" +import { DomainProofParser, DOMAIN_PROOF_PATH } from "./web2/domain" import { type Web2ProofParser } from "./web2/parsers" import { Web2CoreTargetIdentityPayload } from "@kynesyslabs/demosdk/abstraction" import { hexToUint8Array, ucrypto } from "@kynesyslabs/demosdk/encryption" @@ -180,6 +181,7 @@ export async function verifyWeb2Proof( | typeof TwitterProofParser | typeof GithubProofParser | typeof DiscordProofParser + | typeof DomainProofParser switch (payload.context) { case "twitter": @@ -194,6 +196,43 @@ export async function verifyWeb2Proof( case "discord": parser = DiscordProofParser break + case "domain": { + // The proof must be the well-known file ON the claimed domain. + // Binding the proof URL's host to the claimed hostname is what stops + // a sender from pointing at someone else's (their own) valid proof + // while claiming an unrelated domain. + let proofUrl: URL + try { + proofUrl = new URL(payload.proof as string) + } catch { + return { + success: false, + message: "Invalid domain proof URL", + } + } + if (proofUrl.protocol !== "https:") { + return { + success: false, + message: "Domain proof URL must use https", + } + } + if (proofUrl.pathname !== DOMAIN_PROOF_PATH) { + return { + success: false, + message: `Domain proof must be hosted at ${DOMAIN_PROOF_PATH}`, + } + } + // proofUrl.hostname is already lower-cased by the URL parser; + // normalise the client-supplied username so casing never mismatches. + if (proofUrl.hostname !== payload.username?.toLowerCase()) { + return { + success: false, + message: "Proof host does not match the claimed domain", + } + } + parser = DomainProofParser + break + } default: return { success: false, diff --git a/src/libs/abstraction/web2/domain.ts b/src/libs/abstraction/web2/domain.ts new file mode 100644 index 000000000..053edcab6 --- /dev/null +++ b/src/libs/abstraction/web2/domain.ts @@ -0,0 +1,179 @@ +import axios from "axios" +import https from "https" +import dns from "dns" +import ipaddr from "ipaddr.js" +import { Web2ProofParser } from "./parsers" +import { SigningAlgorithm } from "@kynesyslabs/demosdk/types" +import SharedState from "@/utilities/sharedState" +import log from "src/utilities/logger" + +/** The well-known path a domain owner hosts to prove control. */ +export const DOMAIN_PROOF_PATH = "/.well-known/demos-cci.txt" + +/** Max bytes we read from a well-known file — the proof payload is tiny. */ +const MAX_PROOF_BYTES = 4096 + +/** Non-public ranges tolerated only outside production (for local testing). */ +const DEV_ALLOWED_RANGES = new Set(["loopback", "private", "uniqueLocal"]) + +/** + * SSRF guard for the attacker-controlled proof URL. + * + * The proof URL is supplied by the caller, so without this a request could be + * pointed at internal services or the cloud-metadata endpoint + * (e.g. 169.254.169.254) and the response read back. We resolve every A/AAAA + * record and reject non-public targets: + * + * - link-local (incl. metadata), multicast, broadcast, reserved and CGNAT are + * blocked in ALL environments; + * - loopback / private / unique-local are blocked in production but allowed + * in dev, so local hosts (e.g. localhost) stay testable. + * + * @param allowLocal Permit loopback/private/unique-local (true outside prod). + */ +async function resolveAndValidateHost( + hostname: string, + allowLocal: boolean, +): Promise<{ address: string; family: number }> { + let resolved: { address: string; family: number }[] + try { + resolved = await dns.promises.lookup(hostname, { all: true }) + } catch { + throw new Error(`Could not resolve host: ${hostname}`) + } + if (resolved.length === 0) { + throw new Error(`Could not resolve host: ${hostname}`) + } + + for (const { address } of resolved) { + let addr = ipaddr.parse(address) + // Classify the embedded v4 for IPv4-mapped IPv6 (::ffff:a.b.c.d). + if ( + addr.kind() === "ipv6" && + (addr as ipaddr.IPv6).isIPv4MappedAddress() + ) { + addr = (addr as ipaddr.IPv6).toIPv4Address() + } + const range = addr.range() + if (range === "unicast") continue + if (allowLocal && DEV_ALLOWED_RANGES.has(range)) continue + throw new Error( + `Refusing to fetch from non-public address: ${hostname} (${address}, ${range})`, + ) + } + + // Return the first validated address so the caller can pin the socket to it + // (prevents DNS-rebinding between this check and connect time). + return resolved[0] +} + +/** + * Fetch a domain ownership proof file over HTTPS. + * + * The TLS certificate, validated during the handshake, binds the response to + * the requested hostname — so a successful fetch is itself proof that the + * content was served from a host presenting a valid cert for that domain. + * + * In production, certificate validation and the SSRF public-address check are + * enforced; both are relaxed in dev so local self-signed hosts (e.g. localhost) + * can be tested. + * + * @param url Full proof URL, e.g. https://example.com/.well-known/demos-cci.txt + * @returns The trimmed file body and the verified hostname. + */ +export async function fetchDomainProof( + url: string, +): Promise<{ hostname: string; body: string }> { + const parsed = new URL(url) + if (parsed.protocol !== "https:") { + throw new Error("Domain proof URL must use https") + } + + const isProd = SharedState.getInstance().PROD + + // SSRF guard: block internal / metadata targets. Loopback/private hosts are + // permitted only in dev so local test servers (localhost) remain reachable. + const pinned = await resolveAndValidateHost(parsed.hostname, !isProd) + + // Pin the socket to the validated IP so DNS cannot rebind to an internal + // address between the check above and connect time. The URL still carries + // the original hostname, so the Host header and TLS SNI / cert validation + // are unchanged; only the address the socket dials is forced. + const agent = new https.Agent({ + rejectUnauthorized: isProd, + lookup: ( + _hostname: string, + options: { all?: boolean }, + callback: (...args: any[]) => void, + ) => { + if (options && options.all) { + callback(null, [ + { address: pinned.address, family: pinned.family }, + ]) + } else { + callback(null, pinned.address, pinned.family) + } + }, + } as https.AgentOptions) + + const response = await axios.get(url, { + httpsAgent: agent, + responseType: "text", + maxContentLength: MAX_PROOF_BYTES, + maxRedirects: 0, + timeout: 10_000, + // The proof file is plain text; never follow it as JSON. + transformResponse: r => r, + headers: { Accept: "text/plain" }, + }) + + const body = + typeof response.data === "string" + ? response.data.trim() + : String(response.data).trim() + + return { hostname: parsed.hostname, body } +} + +export class DomainProofParser extends Web2ProofParser { + private static instance: DomainProofParser + + constructor() { + super() + } + + async readData( + proofUrl: string, + ): Promise<{ message: string; type: SigningAlgorithm; signature: string }> { + this.verifyProofFormat(proofUrl, "domain") + + let body: string + try { + ;({ body } = await fetchDomainProof(proofUrl)) + } catch (error) { + const errorMsg = + error instanceof Error ? error.message : String(error) + // Full detail (resolved IP / range from the SSRF guard) stays in the + // server log; the thrown message is static so verifyWeb2Proof cannot + // leak internal network info back to the caller. + log.error( + `[DOMAIN] Failed to fetch proof for ${proofUrl}: ${errorMsg}`, + ) + throw new Error("Failed to fetch domain proof") + } + + const payload = this.parsePayload(body) + if (!payload) { + throw new Error("Invalid domain proof format") + } + + return payload + } + + static async getInstance() { + if (!this.instance) { + this.instance = new this() + } + return this.instance + } +} diff --git a/src/libs/abstraction/web2/parsers.ts b/src/libs/abstraction/web2/parsers.ts index 134926acc..d1bdcd08a 100644 --- a/src/libs/abstraction/web2/parsers.ts +++ b/src/libs/abstraction/web2/parsers.ts @@ -15,6 +15,9 @@ export abstract class Web2ProofParser { "https://canary.discord.com/channels", "https://discordapp.com/channels", ], + // Only enforces the scheme here; the hostname<->claimed-domain binding + // and the exact DOMAIN_PROOF_PATH are validated upstream in verifyWeb2Proof. + domain: ["https://"], } constructor() {} diff --git a/src/libs/network/handlers/identityHandlers.ts b/src/libs/network/handlers/identityHandlers.ts index 02dbcbee8..a77b60db4 100644 --- a/src/libs/network/handlers/identityHandlers.ts +++ b/src/libs/network/handlers/identityHandlers.ts @@ -1,5 +1,6 @@ import { Twitter } from "../../identity/tools/twitter" import { Discord } from "../../identity/tools/discord" +import { fetchDomainProof, DOMAIN_PROOF_PATH } from "../../abstraction/web2/domain" import { UDIdentityManager } from "../../blockchain/gcr/gcr_routines/udIdentityManager" import ensureGCRForUser from "../../blockchain/gcr/gcr_routines/ensureGCRForUser" import type { Tweet } from "@kynesyslabs/demosdk/types" @@ -159,6 +160,55 @@ export const identityHandlers: Record = { return response }, + getDomainProof: async (data, response) => { + if (!data.url) { + response.result = 400 + response.response = { success: false, error: "No url specified" } + return response + } + + let parsed: URL + try { + parsed = new URL(data.url) + } catch { + response.result = 400 + response.response = { success: false, error: "Invalid url" } + return response + } + + if (parsed.protocol !== "https:") { + response.result = 400 + response.response = { + success: false, + error: "Proof URL must use https", + } + return response + } + + if (parsed.pathname !== DOMAIN_PROOF_PATH) { + response.result = 400 + response.response = { + success: false, + error: `Proof must be hosted at ${DOMAIN_PROOF_PATH}`, + } + return response + } + + try { + const { hostname, body } = await fetchDomainProof(data.url) + response.result = 200 + response.response = { success: true, hostname, body } + } catch (error) { + log.error("[getDomainProof] failed to fetch domain proof", error) + response.result = 400 + response.response = { + success: false, + error: "Failed to fetch domain proof", + } + } + return response + }, + resolveUdDomain: async (data, response) => { try { const res = await UDIdentityManager.resolveUDDomain(data.domain) diff --git a/src/libs/omniprotocol/protocol/opcodes.ts b/src/libs/omniprotocol/protocol/opcodes.ts index caa585e4a..41b6d546d 100644 --- a/src/libs/omniprotocol/protocol/opcodes.ts +++ b/src/libs/omniprotocol/protocol/opcodes.ts @@ -62,6 +62,7 @@ export enum OmniOpcode { WEB2_PROXY_REQUEST = 0x52, GET_TWEET = 0x53, GET_DISCORD_MESSAGE = 0x54, + GET_DOMAIN_PROOF = 0x55, // 0x6X Admin Operations ADMIN_RATE_LIMIT_UNBLOCK = 0x60, diff --git a/src/libs/omniprotocol/protocol/registry.ts b/src/libs/omniprotocol/protocol/registry.ts index 328ab7a99..26067d779 100644 --- a/src/libs/omniprotocol/protocol/registry.ts +++ b/src/libs/omniprotocol/protocol/registry.ts @@ -402,6 +402,12 @@ const DESCRIPTORS: HandlerDescriptor[] = [ authRequired: false, handler: createHttpFallbackHandler(), }, + { + opcode: OmniOpcode.GET_DOMAIN_PROOF, + name: "getDomainProof", + authRequired: false, + handler: createHttpFallbackHandler(), + }, // 0x6X Admin {