From 8f745ad7f07de9193c2d42d47c277b43900f785a Mon Sep 17 00:00:00 2001 From: Jayden Date: Wed, 21 Aug 2024 11:19:44 +0800 Subject: [PATCH 1/3] Bug fixes Fixed a bug in retrieving the DKIM public key: For some emails, the DKIM public key is not stored TXT record directly in `selector._domainkey.domain`, but instead points to a CNAME. For example, when retrieving the DKIM public key for the following domain: `protonmail._domainkey.proton.me`, we first need to resolve the CNAME for this domain: `protonmail.domainkey.drfeyjwh4gwlal4e2rhajsytrp6auv2nhenecpzigu7muak6lw6ya.domains.proton.ch`. However, using the "dns" npm package, the CNAME cannot be resolved. With this update, all DNS resolutions will be performed using google DNS-over-HTTPS (DoH). --- packages/helpers/src/lib/mailauth/tools.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/helpers/src/lib/mailauth/tools.ts b/packages/helpers/src/lib/mailauth/tools.ts index deadfccbe..09c50f482 100644 --- a/packages/helpers/src/lib/mailauth/tools.ts +++ b/packages/helpers/src/lib/mailauth/tools.ts @@ -301,11 +301,7 @@ export const getPublicKey = async ( resolver: (...args: [name: string, type: string]) => Promise ) => { minBitLength = minBitLength || 1024; - if (!IS_BROWSER) { - resolver = resolver || require("dns").promises.resolve; - } else { - resolver = resolveDNSHTTP; - } + resolver = resolveDNSHTTP; let list = await resolver(name, "TXT"); let rr = From c8a4bcfe66ad790b5ee0c3c3ca1876965746a69c Mon Sep 17 00:00:00 2001 From: Jayden Date: Thu, 22 Aug 2024 00:26:18 +0800 Subject: [PATCH 2/3] Using both Google and Cloudflare --- packages/helpers/src/lib/mailauth/DoH.ts | 97 ++++++++++++++++++++++ packages/helpers/src/lib/mailauth/tools.ts | 26 +++--- 2 files changed, 112 insertions(+), 11 deletions(-) create mode 100644 packages/helpers/src/lib/mailauth/DoH.ts diff --git a/packages/helpers/src/lib/mailauth/DoH.ts b/packages/helpers/src/lib/mailauth/DoH.ts new file mode 100644 index 000000000..8fe18d99a --- /dev/null +++ b/packages/helpers/src/lib/mailauth/DoH.ts @@ -0,0 +1,97 @@ +// DoH servers list +export enum DoHServer { + // Google Public DNS + Google = "https://dns.google/resolve", + // Cloudflare DNS + Cloudflare = "https://cloudflare-dns.com/dns-query", +} + +/** + * DNS over HTTPS (DoH) resolver + * + * @export + * @class DoH + */ +export class DoH { + + // DNS response codes + static DoHStatusNoError = 0; + // DNS RR types + static DoHTypeTXT = 16; + + /** + * Resolve DKIM public key from DNS + * + * @static + * @param {string} name DKIM record name (e.g. 20230601._domainkey.gmail.com) + * @param {string} DNSServer DNS over HTTPS API URL + * @return {*} {(Promise)} DKIM public key or null if not found + * @memberof DoH + */ + public static async resolveDKIMPublicKey(name: string, DNSServer: string): Promise { + if (!DNSServer.startsWith('https://')) { + DNSServer = 'https://' + DNSServer; + } + if (DNSServer.endsWith('/')) { + DNSServer = DNSServer.slice(0, -1); + } + const resp = await fetch( + DNSServer + "?" + + new URLSearchParams({ + name: name, + // DKIM public key record type is TXT + type: DoH.DoHTypeTXT.toString(), + }), + { + headers: { + "accept": "application/dns-json", + } + } + ); + if (resp.status === 200) { + const out = await resp.json(); + if (typeof out === 'object' && out !== null && 'Status' in out && 'Answer' in out) { + const resp = out as DoHResponse; + if (resp.Status === DoH.DoHStatusNoError && resp.Answer.length > 0) { + for (const ans of resp.Answer) { + if (ans.type === DoH.DoHTypeTXT) { + let DKIMRecord = ans.data; + /* + Remove all double quotes + Some DNS providers wrap TXT records in double quotes, + and others like Cloudflare may include them. According to + TXT (potentially multi-line) and DKIM (Base64 data) standards, + we can directly remove all double quotes from the DKIM public key. + */ + DKIMRecord = DKIMRecord.replace(/"/g, ''); + return DKIMRecord; + } + } + } + } + } + return null; + } +} + +interface DoHResponse { + Status: number; // NOERROR - Standard DNS response code (32 bit integer). + TC: boolean; // Whether the response is truncated + AD: boolean; // Whether all response data was validated with DNSSEC + CD: boolean; // Whether the client asked to disable DNSSEC + Question: Question[]; + Answer: Answer[]; + Comment: string; +} + +interface Question { + name: string; // FQDN with trailing dot + type: number; // A - Standard DNS RR type. 5:CNAME, 16:TXT +} + +interface Answer { + name: string; // Always matches name in the Question section + type: number; // A - Standard DNS RR type. 5:CNAME, 16:TXT + TTL: number; // Record's time-to-live in seconds + data: string; // Record data +} \ No newline at end of file diff --git a/packages/helpers/src/lib/mailauth/tools.ts b/packages/helpers/src/lib/mailauth/tools.ts index 09c50f482..7d978deca 100644 --- a/packages/helpers/src/lib/mailauth/tools.ts +++ b/packages/helpers/src/lib/mailauth/tools.ts @@ -9,6 +9,7 @@ import crypto, { KeyObject } from "crypto"; import parseDkimHeaders from "./parse-dkim-headers"; import { DkimVerifier } from "./dkim-verifier"; import type { Parsed, SignatureType } from "./dkim-verifier"; +import { DoH, DoHServer } from './DoH'; const IS_BROWSER = typeof window !== "undefined"; @@ -247,16 +248,17 @@ export const formatSignatureHeaderLine = ( }; async function resolveDNSHTTP(name: string, type: string) { - const resp = await fetch( - "https://dns.google/resolve?" + - new URLSearchParams({ - name: name, - type: type, - }) - ); - const out = await resp.json(); - // For some DNS, the Answer response here contains more than 1 element in the array. The last element is the one containing the public key - return [out.Answer[out.Answer.length - 1].data]; + if (type !== "TXT") { + throw new Error("DKIM record type is TXT"); + } + const DKIMRecord = await DoH.resolveDKIMPublicKey(name, DoHServer.Google); + if (!DKIMRecord) { + throw new CustomError("No DKIM record found", "ENODATA"); + } + if (DKIMRecord !== await DoH.resolveDKIMPublicKey(name, DoHServer.Cloudflare)) { + console.error("DKIM record mismatch!"); + } + return [DKIMRecord]; } // from https://developers.google.com/web/updates/2012/06/How-to-convert-ArrayBuffer-to-and-from-String @@ -301,7 +303,9 @@ export const getPublicKey = async ( resolver: (...args: [name: string, type: string]) => Promise ) => { minBitLength = minBitLength || 1024; - resolver = resolveDNSHTTP; + if (!resolver) { + resolver = resolveDNSHTTP; + } let list = await resolver(name, "TXT"); let rr = From 11c846d021e1cadf86c7b34e8a0526df90324c5a Mon Sep 17 00:00:00 2001 From: Jayden Date: Thu, 22 Aug 2024 21:31:59 +0800 Subject: [PATCH 3/3] Apply suggestions from code review Co-authored-by: saleel --- packages/helpers/src/lib/mailauth/tools.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/helpers/src/lib/mailauth/tools.ts b/packages/helpers/src/lib/mailauth/tools.ts index 7d978deca..0ae22fe08 100644 --- a/packages/helpers/src/lib/mailauth/tools.ts +++ b/packages/helpers/src/lib/mailauth/tools.ts @@ -249,7 +249,7 @@ export const formatSignatureHeaderLine = ( async function resolveDNSHTTP(name: string, type: string) { if (type !== "TXT") { - throw new Error("DKIM record type is TXT"); + throw new Error("DKIM record type is not TXT"); } const DKIMRecord = await DoH.resolveDKIMPublicKey(name, DoHServer.Google); if (!DKIMRecord) {