diff --git a/src/index.test.ts b/src/index.test.ts index 54b5f5e..8ce97b2 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -1,6 +1,14 @@ import { setupServer, SetupServerApi } from "msw/node"; import { http, HttpResponse } from "msw"; -import { CustomDnsResolver, getDocumentStoreRecords, queryDns, parseDocumentStoreResults, getDnsDidRecords } from "."; +import { + aliDnsResolver, + cloudflareDnsResolver, + getDocumentStoreRecords, + getDnsDidRecords, + googleDnsResolver, + parseDocumentStoreResults, + queryDns, +} from "."; import { DnsproveStatusCode } from "./common/error"; describe("getCertStoreRecords", () => { @@ -8,11 +16,11 @@ describe("getCertStoreRecords", () => { type: "openatts", net: "ethereum", netId: "3", - dnssec: true, + dnssec: false, addr: "0x2f60375e8144e16Adf1979936301D8341D58C36C", }; test("it should work", async () => { - const records = await getDocumentStoreRecords("donotuse.openattestation.com"); + const records = await getDocumentStoreRecords("donotuse.trustvc.io"); expect(records).toStrictEqual([sampleDnsTextRecordWithDnssec]); }); @@ -27,14 +35,14 @@ describe("getCertStoreRecords", () => { describe("getDnsDidRecords", () => { test("it should work", async () => { - const records = await getDnsDidRecords("donotuse.openattestation.com"); + const records = await getDnsDidRecords("donotuse.trustvc.io"); expect(records).toStrictEqual([ { type: "openatts", algorithm: "dns-did", publicKey: "did:ethr:0xE712878f6E8d5d4F9e87E10DA604F9cB564C9a89#controller", version: "1.0", - dnssec: true, + dnssec: false, }, ]); }); @@ -177,29 +185,29 @@ describe("queryDns", () => { RA: true, AD: true, CD: false, - Question: [{ name: "donotuse.openattestation.com.", type: 16 }], + Question: [{ name: "donotuse.trustvc.io.", type: 16 }], Answer: [ { - name: "donotuse.openattestation.com.", + name: "donotuse.trustvc.io.", type: 16, TTL: 300, data: "openatts a=dns-did; p=did:ethr:0xE712878f6E8d5d4F9e87E10DA604F9cB564C9a89#controller; v=1.0;", }, { - name: "donotuse.openattestation.com.", + name: "donotuse.trustvc.io.", type: 16, TTL: 300, data: "openatts DO NOT ADD ANY RECORDS BEYOND THIS AS THIS DOMAIN IS USED FOR DNSPROVE NPM LIBRARY INTEGRATION TESTS", }, { - name: "donotuse.openattestation.com.", + name: "donotuse.trustvc.io.", type: 16, TTL: 300, data: "openatts fooooooobarrrrrrrrr this entry exists to ensure validation works", }, { - name: "donotuse.openattestation.com.", + name: "donotuse.trustvc.io.", type: 16, TTL: 300, data: "openatts net=ethereum netId=3 addr=0x2f60375e8144e16Adf1979936301D8341D58C36C", @@ -208,22 +216,7 @@ describe("queryDns", () => { Comment: "Response from 205.251.199.177.", }; - const testDnsResolvers: CustomDnsResolver[] = [ - async (domain) => { - const data = await fetch(`https://dns.google/resolve?name=${domain}&type=TXT`, { - method: "GET", - }); - - return data.json(); - }, - async (domain) => { - const data = await fetch(`https://cloudflare-dns.com/dns-query?name=${domain}&type=TXT`, { - method: "GET", - headers: { accept: "application/dns-json", contentType: "application/json", connection: "keep-alive" }, - }); - return data.json(); - }, - ]; + const testDnsResolvers = [googleDnsResolver, cloudflareDnsResolver, aliDnsResolver]; afterEach(() => { server.close(); @@ -239,7 +232,7 @@ describe("queryDns", () => { server = setupServer(...handlers); server.listen(); - const records = await queryDns("https://donotuse.openattestation.com", testDnsResolvers); + const records = await queryDns("https://donotuse.trustvc.io", testDnsResolvers); const sortedAnswer = records?.Answer.sort((a, b) => a.data.localeCompare(b.data)); expect(sortedAnswer).toMatchObject(sampleResponse.Answer); }); @@ -256,7 +249,28 @@ describe("queryDns", () => { server = setupServer(...handlers); server.listen(); - const records = await queryDns("https://donotuse.openattestation.com", testDnsResolvers); + const records = await queryDns("https://donotuse.trustvc.io", testDnsResolvers); + + const sortedAnswer = records?.Answer.sort((a, b) => a.data.localeCompare(b.data)); + expect(sortedAnswer).toMatchObject(sampleResponse.Answer); + }); + + test("Should fallback to third dns when first and second dns is down", async () => { + const handlers = [ + http.get("https://dns.google/resolve", (_) => { + return new HttpResponse(null, { status: 500 }); + }), + http.get("https://cloudflare-dns.com/dns-query", (_) => { + return new HttpResponse(null, { status: 500 }); + }), + http.get("https://dns.alidns.com/resolve", (_) => { + return HttpResponse.json(sampleResponse); + }), + ]; + server = setupServer(...handlers); + server.listen(); + + const records = await queryDns("https://donotuse.trustvc.io", testDnsResolvers); const sortedAnswer = records?.Answer.sort((a, b) => a.data.localeCompare(b.data)); expect(sortedAnswer).toMatchObject(sampleResponse.Answer); @@ -270,14 +284,16 @@ describe("queryDns", () => { http.get("https://cloudflare-dns.com/dns-query", (_) => { return new HttpResponse(null, { status: 500 }); }), + http.get("https://dns.alidns.com/resolve", (_) => { + return new HttpResponse(null, { status: 500 }); + }), ]; server = setupServer(...handlers); server.listen(); - try { - await queryDns("https://donotuse.openattestation.com", testDnsResolvers); - } catch (e: any) { - expect(e.code).toStrictEqual(DnsproveStatusCode.IDNS_QUERY_ERROR_GENERAL); - } + + await expect(queryDns("https://donotuse.trustvc.io", testDnsResolvers)).rejects.toMatchObject({ + code: DnsproveStatusCode.IDNS_QUERY_ERROR_GENERAL, + }); }); }); @@ -321,6 +337,13 @@ describe("getDocumentStoreRecords for Astron", () => { addr: "0x18bc0127Ae33389cD96593a1a612774fD14c0737", dnssec: false, }, + { + type: "openatts", + net: "ethereum", + netId: "1338", + addr: "0x94FD21A026E29E0686583b8be71Cb28a8ca1A8d4", + dnssec: false, + }, { type: "openatts", net: "ethereum", diff --git a/src/index.ts b/src/index.ts index 7bc7f78..4a21d81 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,6 +2,7 @@ import { OpenAttestationDNSTextRecord, OpenAttestationDNSTextRecordT } from "./r import { OpenAttestationDnsDidRecord, OpenAttestationDnsDidRecordT } from "./records/dnsDid"; import { getLogger } from "./util/logger"; import { CodedError, DnsproveStatusCode } from "./common/error"; +import { aliDnsResolver, cloudflareDnsResolver, googleDnsResolver } from "./util/dns-resolvers"; const { trace } = getLogger("index"); @@ -23,22 +24,7 @@ interface GenericObject { export type CustomDnsResolver = (domain: string) => Promise; -export const defaultDnsResolvers: CustomDnsResolver[] = [ - async (domain) => { - const data = await fetch(`https://dns.google/resolve?name=${domain}&type=TXT`, { - method: "GET", - }); - - return data.json(); - }, - async (domain) => { - const data = await fetch(`https://cloudflare-dns.com/dns-query?name=${domain}&type=TXT`, { - method: "GET", - headers: { accept: "application/dns-json", contentType: "application/json", connection: "keep-alive" }, - }); - return data.json(); - }, -]; +export const defaultDnsResolvers: CustomDnsResolver[] = [googleDnsResolver, cloudflareDnsResolver, aliDnsResolver]; /** * Returns true for strings that are openattestation records @@ -115,8 +101,10 @@ export const parseOpenAttestationRecord = (record: string): GenericObject => { trace(`Parsing record: ${record}`); const keyValuePairs = record.trim().split(" "); // tokenize into key=value elements const recordObject = {} as GenericObject; - // @ts-ignore: we already checked for this token - recordObject.type = keyValuePairs.shift(); + const typeToken = keyValuePairs.shift(); + if (typeToken !== undefined) { + recordObject.type = typeToken; + } keyValuePairs.reduce(addKeyValuePairToObject, recordObject); return recordObject; }; @@ -153,6 +141,7 @@ const parseOpenAttestationRecords = (recordSet: IDNSRecord[] = []): GenericObjec /** * Takes a DNS-TXT Record set and returns openattestation document store records if any * @param recordSet Refer to tests for examples + * @param dnssec Resolver AD (authenticated data) flag; applied as each record's `dnssec` field */ export const parseDocumentStoreResults = ( recordSet: IDNSRecord[] = [], @@ -177,6 +166,7 @@ export const parseDnsDidResults = (recordSet: IDNSRecord[] = [], dnssec: boolean /** * Queries a given domain and parses the results to retrieve openattestation document store records if any * @param domain e.g: "example.openattestation.com" + * @param customDnsResolvers Optional resolver list; built-in HTTP DNS chain is used when omitted * @example * > getDocumentStoreRecords("example.openattestation.com") * > [ { type: 'openatts', @@ -191,7 +181,7 @@ export const getDocumentStoreRecords = async ( ): Promise => { trace(`Received request to resolve ${domain}`); - const dnsResolvers = customDnsResolvers || defaultDnsResolvers; + const dnsResolvers = customDnsResolvers ?? defaultDnsResolvers; const results = await queryDns(domain, dnsResolvers); const answers = results.Answer || []; @@ -207,7 +197,7 @@ export const getDnsDidRecords = async ( ): Promise => { trace(`Received request to resolve ${domain}`); - const dnsResolvers = customDnsResolvers || defaultDnsResolvers; + const dnsResolvers = customDnsResolvers ?? defaultDnsResolvers; const results = await queryDns(domain, dnsResolvers); const answers = results.Answer || []; @@ -218,3 +208,4 @@ export const getDnsDidRecords = async ( }; export { OpenAttestationDNSTextRecord, OpenAttestationDnsDidRecord }; +export * from "./util/dns-resolvers"; diff --git a/src/util/dns-resolvers/ali-dns-resolver.ts b/src/util/dns-resolvers/ali-dns-resolver.ts new file mode 100644 index 0000000..506d75f --- /dev/null +++ b/src/util/dns-resolvers/ali-dns-resolver.ts @@ -0,0 +1,29 @@ +import type { CustomDnsResolver, IDNSQueryResponse } from "../.."; + +/** Ali DNS JSON API uses numeric RRTYPE; 16 = TXT */ +const ALI_DNS_TXT_QUERY_TYPE = "16"; +export const aliDnsResolver: CustomDnsResolver = async (domain) => { + const url = new URL("https://dns.alidns.com/resolve"); + + if (!domain) { + throw new Error("Domain is required"); + } + + url.searchParams.set("name", domain); + url.searchParams.set("type", ALI_DNS_TXT_QUERY_TYPE); + + const res = await fetch(url); + + if (!res.ok) { + throw new Error(`Ali DNS request failed: HTTP ${res.status}`); + } + + let data; + try { + data = await res.json(); + } catch { + throw new Error("Failed to parse DNS response JSON"); + } + + return data as IDNSQueryResponse; +}; diff --git a/src/util/dns-resolvers/cloudflare-dns-resolver.ts b/src/util/dns-resolvers/cloudflare-dns-resolver.ts new file mode 100644 index 0000000..65369a4 --- /dev/null +++ b/src/util/dns-resolvers/cloudflare-dns-resolver.ts @@ -0,0 +1,29 @@ +import type { CustomDnsResolver, IDNSQueryResponse } from "../.."; + +export const cloudflareDnsResolver: CustomDnsResolver = async (domain) => { + const url = new URL("https://cloudflare-dns.com/dns-query"); + + if (!domain) { + throw new Error("Domain is required"); + } + + url.searchParams.set("name", domain); + url.searchParams.set("type", "TXT"); + + const res = await fetch(url, { + headers: { Accept: "application/dns-json" }, + }); + + if (!res.ok) { + throw new Error(`Cloudflare DNS request failed: HTTP ${res.status}`); + } + + let data; + try { + data = await res.json(); + } catch { + throw new Error("Failed to parse DNS response JSON"); + } + + return data as IDNSQueryResponse; +}; diff --git a/src/util/dns-resolvers/dns-resolvers.test.ts b/src/util/dns-resolvers/dns-resolvers.test.ts new file mode 100644 index 0000000..9866c5c --- /dev/null +++ b/src/util/dns-resolvers/dns-resolvers.test.ts @@ -0,0 +1,120 @@ +import { setupServer, SetupServerApi } from "msw/node"; +import { http, HttpResponse } from "msw"; +import { aliDnsResolver } from "./ali-dns-resolver"; +import { cloudflareDnsResolver } from "./cloudflare-dns-resolver"; +import { googleDnsResolver } from "./google-dns-resolver"; + +const emptyDnsJson = { + Status: 0, + TC: false, + RD: true, + RA: true, + AD: false, + CD: false, + Answer: [] as [], +}; + +describe("googleDnsResolver", () => { + let server: SetupServerApi | undefined; + + afterEach(() => { + server?.close(); + }); + + test("requests Google DNS JSON with name, TXT type, and encoded query", async () => { + server = setupServer( + http.get("https://dns.google/resolve", ({ request }) => { + const url = new URL(request.url); + expect(url.searchParams.get("name")).toBe("my domain.test"); + expect(url.searchParams.get("type")).toBe("TXT"); + return HttpResponse.json(emptyDnsJson); + }) + ); + server.listen(); + + const out = await googleDnsResolver("my domain.test"); + expect(out).toMatchObject({ Status: 0, Answer: [] }); + }); + + test("throws when Google DNS returns non-2xx", async () => { + server = setupServer(http.get("https://dns.google/resolve", () => new HttpResponse(null, { status: 503 }))); + server.listen(); + + await expect(googleDnsResolver("my.domain.test")).rejects.toThrow(/HTTP 503/); + }); + + test("throws when domain is empty", async () => { + await expect(googleDnsResolver("")).rejects.toThrow("Domain is required"); + }); +}); + +describe("cloudflareDnsResolver", () => { + let server: SetupServerApi | undefined; + + afterEach(() => { + server?.close(); + }); + + test("requests Cloudflare DNS JSON with name, TXT type, Accept header, and encoded query", async () => { + server = setupServer( + http.get("https://cloudflare-dns.com/dns-query", ({ request }) => { + const url = new URL(request.url); + expect(url.searchParams.get("name")).toBe("cf example.test"); + expect(url.searchParams.get("type")).toBe("TXT"); + expect(request.headers.get("accept")).toBe("application/dns-json"); + return HttpResponse.json(emptyDnsJson); + }) + ); + server.listen(); + + const out = await cloudflareDnsResolver("cf example.test"); + expect(out).toMatchObject({ Status: 0, Answer: [] }); + }); + + test("throws when Cloudflare DNS returns non-2xx", async () => { + server = setupServer( + http.get("https://cloudflare-dns.com/dns-query", () => new HttpResponse(null, { status: 502 })) + ); + server.listen(); + + await expect(cloudflareDnsResolver("cf.example.test")).rejects.toThrow(/HTTP 502/); + }); + + test("throws when domain is empty", async () => { + await expect(cloudflareDnsResolver("")).rejects.toThrow("Domain is required"); + }); +}); + +describe("aliDnsResolver", () => { + let server: SetupServerApi | undefined; + + afterEach(() => { + server?.close(); + }); + + test("requests Ali DNS JSON with name, type 16 (TXT), and encoded query", async () => { + server = setupServer( + http.get("https://dns.alidns.com/resolve", ({ request }) => { + const url = new URL(request.url); + expect(url.searchParams.get("name")).toBe("ali example.test"); + expect(url.searchParams.get("type")).toBe("16"); + return HttpResponse.json(emptyDnsJson); + }) + ); + server.listen(); + + const out = await aliDnsResolver("ali example.test"); + expect(out).toMatchObject({ Status: 0, Answer: [] }); + }); + + test("throws when Ali DNS returns non-2xx", async () => { + server = setupServer(http.get("https://dns.alidns.com/resolve", () => new HttpResponse(null, { status: 503 }))); + server.listen(); + + await expect(aliDnsResolver("ali.example.test")).rejects.toThrow(/HTTP 503/); + }); + + test("throws when domain is empty", async () => { + await expect(aliDnsResolver("")).rejects.toThrow("Domain is required"); + }); +}); diff --git a/src/util/dns-resolvers/google-dns-resolver.ts b/src/util/dns-resolvers/google-dns-resolver.ts new file mode 100644 index 0000000..f55ddfd --- /dev/null +++ b/src/util/dns-resolvers/google-dns-resolver.ts @@ -0,0 +1,27 @@ +import type { CustomDnsResolver, IDNSQueryResponse } from "../.."; + +export const googleDnsResolver: CustomDnsResolver = async (domain) => { + const url = new URL("https://dns.google/resolve"); + + if (!domain) { + throw new Error("Domain is required"); + } + + url.searchParams.set("name", domain); + url.searchParams.set("type", "TXT"); + + const res = await fetch(url); + + if (!res.ok) { + throw new Error(`Google DNS request failed: HTTP ${res.status}`); + } + + let data; + try { + data = await res.json(); + } catch { + throw new Error("Failed to parse DNS response JSON"); + } + + return data as IDNSQueryResponse; +}; diff --git a/src/util/dns-resolvers/index.ts b/src/util/dns-resolvers/index.ts new file mode 100644 index 0000000..c622480 --- /dev/null +++ b/src/util/dns-resolvers/index.ts @@ -0,0 +1,3 @@ +export * from "./google-dns-resolver"; +export * from "./cloudflare-dns-resolver"; +export * from "./ali-dns-resolver";