diff --git a/packages/helpers/src/dkim/dns-archive.ts b/packages/helpers/src/dkim/dns-archive.ts new file mode 100644 index 000000000..0eb84c600 --- /dev/null +++ b/packages/helpers/src/dkim/dns-archive.ts @@ -0,0 +1,30 @@ +import { CustomError } from "../lib/mailauth/tools"; + +const ZKEMAIL_DNS_ARCHIVER_API = "https://archive.prove.email/api/key"; + +export async function resolveDNSFromZKEmailArchive(name: string, type: string) { + if (type !== "TXT") { + throw new Error(`ZK Email Archive only supports TXT records - got ${type}`); + } + + // Get domain from full dns record name - $selector._domainkey.$domain.com + const domain = name.split(".").slice(-2).join("."); + const selector = name.split(".")[0]; + + const queryUrl = new URL(ZKEMAIL_DNS_ARCHIVER_API); + queryUrl.searchParams.set("domain", domain); + + const resp = await fetch(queryUrl); + const data = await resp.json(); + + const dkimRecord = data.find((record: any) => record.selector === selector); + + if (!dkimRecord) { + throw new CustomError( + `DKIM record not found for domain ${domain} and selector ${selector} in ZK Email Archive.`, + "ENODATA" + ); + } + + return [dkimRecord.value]; +} diff --git a/packages/helpers/src/dkim/dns-over-http.ts b/packages/helpers/src/dkim/dns-over-http.ts new file mode 100644 index 000000000..ad09aa314 --- /dev/null +++ b/packages/helpers/src/dkim/dns-over-http.ts @@ -0,0 +1,131 @@ +import { CustomError } from "../lib/mailauth/tools"; + +// 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 +} + +export async function resolveDNSHTTP(name: string, type: string) { + if (type !== "TXT") { + throw new Error(`DNS over HTTP: Only type TXT is supported, got ${type}`); + } + const googleResult = await DoH.resolveDKIMPublicKey(name, DoHServer.Google); + if (!googleResult) { + throw new CustomError("No DKIM record found in Google", "ENODATA"); + } + + const cloudflareResult = await DoH.resolveDKIMPublicKey( + name, + DoHServer.Cloudflare + ); + + // Log an error if there is a mismatch in the result + if (googleResult !== cloudflareResult) { + console.error( + "DKIM record mismatch between Google and Cloudflare! Using Google result." + ); + } + + return [googleResult]; +} diff --git a/packages/helpers/src/dkim/index.ts b/packages/helpers/src/dkim/index.ts index 4bd714a17..37f859fa6 100644 --- a/packages/helpers/src/dkim/index.ts +++ b/packages/helpers/src/dkim/index.ts @@ -2,6 +2,8 @@ import { pki } from 'node-forge'; import { DkimVerifier } from '../lib/mailauth/dkim-verifier'; import { writeToStream } from '../lib/mailauth/tools'; import sanitizers from './sanitizers'; +import { resolveDNSHTTP } from './dns-over-http'; +import { resolveDNSFromZKEmailArchive } from './dns-archive'; // `./mailauth` is modified version of https://github.com/postalsys/mailauth // Main modification are including emailHeaders in the DKIM result, making it work in the browser, add types @@ -26,22 +28,25 @@ export interface DKIMVerificationResult { * @param email Entire email data as a string or buffer * @param domain Domain to verify DKIM signature for. If not provided, the domain is extracted from the `From` header * @param enableSanitization If true, email will be applied with various sanitization to try and pass DKIM verification + * @param fallbackToZKEmailDNSArchive If true, ZK Email DNS Archive (https://archive.prove.email/api-explorer) will + * be used to resolve DKIM public keys if we cannot resolve from HTTP DNS * @returns */ export async function verifyDKIMSignature( email: Buffer | string, domain: string = '', enableSanitization: boolean = true, + fallbackToZKEmailDNSArchive: boolean = false ): Promise { const emailStr = email.toString(); - let dkimResult = await tryVerifyDKIM(email, domain); + let dkimResult = await tryVerifyDKIM(email, domain, fallbackToZKEmailDNSArchive); // If DKIM verification fails, try again after sanitizing email let appliedSanitization; if (dkimResult.status.comment === 'bad signature' && enableSanitization) { const results = await Promise.all( - sanitizers.map((sanitize) => tryVerifyDKIM(sanitize(emailStr), domain).then((result) => ({ + sanitizers.map((sanitize) => tryVerifyDKIM(sanitize(emailStr), domain, fallbackToZKEmailDNSArchive).then((result) => ({ result, sanitizer: sanitize.name, }))), @@ -91,8 +96,30 @@ export async function verifyDKIMSignature( }; } -async function tryVerifyDKIM(email: Buffer | string, domain: string = '') { - const dkimVerifier = new DkimVerifier({}); + +async function tryVerifyDKIM( + email: Buffer | string, + domain: string = '', + fallbackToZKEmailDNSArchive: boolean +) { + const resolver = async (name: string, type: string) => { + try { + const result = await resolveDNSHTTP(name, type); + return result; + } catch (e) { + if (fallbackToZKEmailDNSArchive) { + console.log('DNS over HTTP failed, falling back to ZK Email Archive'); + const result = await resolveDNSFromZKEmailArchive(name, type); + return result; + } + throw e; + } + }; + + const dkimVerifier = new DkimVerifier({ + resolver, + }); + await writeToStream(dkimVerifier, email as any); let domainToVerifyDKIM = domain; diff --git a/packages/helpers/src/lib/mailauth/DoH.ts b/packages/helpers/src/lib/mailauth/DoH.ts deleted file mode 100644 index 8fe18d99a..000000000 --- a/packages/helpers/src/lib/mailauth/DoH.ts +++ /dev/null @@ -1,97 +0,0 @@ -// 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 0ae22fe08..cf3beaeac 100644 --- a/packages/helpers/src/lib/mailauth/tools.ts +++ b/packages/helpers/src/lib/mailauth/tools.ts @@ -9,7 +9,6 @@ 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,20 +246,6 @@ export const formatSignatureHeaderLine = ( return header; }; -async function resolveDNSHTTP(name: string, type: string) { - if (type !== "TXT") { - throw new Error("DKIM record type is not 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 function str2ab(str: string) { const buf = new ArrayBuffer(str.length); @@ -303,9 +288,6 @@ export const getPublicKey = async ( resolver: (...args: [name: string, type: string]) => Promise ) => { minBitLength = minBitLength || 1024; - if (!resolver) { - resolver = resolveDNSHTTP; - } let list = await resolver(name, "TXT"); let rr = diff --git a/packages/helpers/tests/dkim.test.ts b/packages/helpers/tests/dkim.test.ts index 54dd8ea42..3d9ad1c62 100644 --- a/packages/helpers/tests/dkim.test.ts +++ b/packages/helpers/tests/dkim.test.ts @@ -1,6 +1,8 @@ import fs from 'fs'; import path from 'path'; import { verifyDKIMSignature } from '../src/dkim'; +import * as dnsOverHttp from '../src/dkim/dns-over-http'; +import * as dnsArchive from '../src/dkim/dns-archive'; jest.setTimeout(10000); @@ -88,6 +90,76 @@ describe('DKIM signature verification', () => { }); }); + + +it("should fallback to ZK Email Archive if DNS over HTTP fails", async () => { + const email = fs.readFileSync( + path.join(__dirname, "test-data/email-good.eml") + ); + + // Mock resolveDNSHTTP to throw an error just for this test + const mockResolveDNSHTTP = jest + .spyOn(dnsOverHttp, "resolveDNSHTTP") + .mockRejectedValue(new Error("Failed due to mock")); + + const consoleSpy = jest.spyOn(console, "log"); + await verifyDKIMSignature(email, "icloud.com", true, true); + + // Check if the error was logged to ensure fallback to ZK Email Archive happened + expect(consoleSpy).toHaveBeenCalledWith( + "DNS over HTTP failed, falling back to ZK Email Archive" + ); + + mockResolveDNSHTTP.mockRestore(); +}); + +it("should fail on DNS over HTTP failure if fallback is not enabled", async () => { + const email = fs.readFileSync( + path.join(__dirname, "test-data/email-good.eml") + ); + + // Mock resolveDNSHTTP to throw an error just for this test + const mockResolveDNSHTTP = jest + .spyOn(dnsOverHttp, "resolveDNSHTTP") + .mockRejectedValue(new Error("Failed due to mock")); + + expect.assertions(1); + try { + await verifyDKIMSignature(email, "icloud.com", true, false); + } catch (e) { + expect(e.message).toBe( + "DKIM signature verification failed for domain icloud.com. Reason: DNS failure: Failed due to mock" + ); + } + mockResolveDNSHTTP.mockRestore(); +}); + +it("should fail if both DNS over HTTP and ZK Email Archive fail", async () => { + const email = fs.readFileSync( + path.join(__dirname, "test-data/email-good.eml") + ); + + const mockResolveDNSHTTP = jest + .spyOn(dnsOverHttp, "resolveDNSHTTP") + .mockRejectedValue(new Error("Failed due to mock")); + + const mockResolveDNSFromZKEmailArchive = jest + .spyOn(dnsArchive, "resolveDNSFromZKEmailArchive") + .mockRejectedValue(new Error("Failed due to mock")); + + expect.assertions(1); + try { + await verifyDKIMSignature(email, "icloud.com", true, false); + } catch (e) { + expect(e.message).toBe( + "DKIM signature verification failed for domain icloud.com. Reason: DNS failure: Failed due to mock" + ); + } + + mockResolveDNSHTTP.mockRestore(); + mockResolveDNSFromZKEmailArchive.mockRestore(); +}); + describe('DKIM with sanitization', () => { it('should pass after removing label from Subject', async () => { const email = fs.readFileSync( @@ -101,4 +173,4 @@ describe('DKIM with sanitization', () => { expect(result.appliedSanitization).toBe('removeLabels'); }); -}); +}); \ No newline at end of file