From 136a8c897d400bad8e2068a910e8246ebecc2cfc Mon Sep 17 00:00:00 2001 From: Saleel Date: Fri, 11 Oct 2024 00:59:50 +0530 Subject: [PATCH 1/7] feat: enable dns archiver flag in tryVerifyDKIM --- packages/helpers/src/dkim/index.ts | 88 ++++++++++++++++++++++-------- 1 file changed, 66 insertions(+), 22 deletions(-) diff --git a/packages/helpers/src/dkim/index.ts b/packages/helpers/src/dkim/index.ts index 4bd714a17..8ee07b36f 100644 --- a/packages/helpers/src/dkim/index.ts +++ b/packages/helpers/src/dkim/index.ts @@ -1,7 +1,7 @@ -import { pki } from 'node-forge'; -import { DkimVerifier } from '../lib/mailauth/dkim-verifier'; -import { writeToStream } from '../lib/mailauth/tools'; -import sanitizers from './sanitizers'; +import { pki } from "node-forge"; +import { DkimVerifier } from "../lib/mailauth/dkim-verifier"; +import { writeToStream } from "../lib/mailauth/tools"; +import sanitizers from "./sanitizers"; // `./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,32 +26,36 @@ 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 enableZKEmailDNSArchiver If provided, this public (modulus as bigint) key will be used instead of the one in the email * @returns */ export async function verifyDKIMSignature( email: Buffer | string, - domain: string = '', + domain: string = "", enableSanitization: boolean = true, + enableZKEmailDNSArchiver: bigint | null = null ): Promise { const emailStr = email.toString(); - let dkimResult = await tryVerifyDKIM(email, domain); + let dkimResult = await tryVerifyDKIM(email, domain, enableZKEmailDNSArchiver); // If DKIM verification fails, try again after sanitizing email let appliedSanitization; - if (dkimResult.status.comment === 'bad signature' && enableSanitization) { + if (dkimResult.status.comment === "bad signature" && enableSanitization) { const results = await Promise.all( - sanitizers.map((sanitize) => tryVerifyDKIM(sanitize(emailStr), domain).then((result) => ({ - result, - sanitizer: sanitize.name, - }))), + sanitizers.map((sanitize) => + tryVerifyDKIM(sanitize(emailStr), domain).then((result) => ({ + result, + sanitizer: sanitize.name, + })) + ) ); - const passed = results.find((r) => r.result.status.result === 'pass'); + const passed = results.find((r) => r.result.status.result === "pass"); if (passed) { console.log( - `DKIM: Verification passed after applying sanitization "${passed.sanitizer}"`, + `DKIM: Verification passed after applying sanitization "${passed.sanitizer}"` ); dkimResult = passed.result; appliedSanitization = passed.sanitizer; @@ -68,16 +72,16 @@ export async function verifyDKIMSignature( bodyHash, } = dkimResult; - if (result !== 'pass') { + if (result !== "pass") { throw new Error( - `DKIM signature verification failed for domain ${signingDomain}. Reason: ${comment}`, + `DKIM signature verification failed for domain ${signingDomain}. Reason: ${comment}` ); } const pubKeyData = pki.publicKeyFromPem(publicKey.toString()); return { - signature: BigInt(`0x${Buffer.from(signature, 'base64').toString('hex')}`), + signature: BigInt(`0x${Buffer.from(signature, "base64").toString("hex")}`), headers: status.signedHeaders, body, bodyHash, @@ -91,28 +95,68 @@ export async function verifyDKIMSignature( }; } -async function tryVerifyDKIM(email: Buffer | string, domain: string = '') { - const dkimVerifier = new DkimVerifier({}); +async function tryVerifyDKIM( + email: Buffer | string, + domain: string = "", + enableZKEmailDNSArchiver: bigint | null = null +) { + const dkimVerifier = new DkimVerifier({ + ...(enableZKEmailDNSArchiver && { + resolver: async (name: string, type: string) => { + if (type !== "TXT") { + throw new Error( + `ZK Email Archive only supports TXT records - got ${type}` + ); + } + const ZKEMAIL_DNS_ARCHIVER_API = "https://archive.prove.email/api/key"; + + // 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 Error( + `DKIM record not found for domain ${domain} and selector ${selector} in ZK Email Archive.` + ); + } + + console.log("dkimRecord", dkimRecord.value); + + return [dkimRecord.value]; + }, + }), + }); + await writeToStream(dkimVerifier, email as any); let domainToVerifyDKIM = domain; if (!domainToVerifyDKIM) { if (dkimVerifier.headerFrom.length > 1) { throw new Error( - 'Multiple From header in email and domain for verification not specified', + "Multiple From header in email and domain for verification not specified" ); } - domainToVerifyDKIM = dkimVerifier.headerFrom[0].split('@')[1]; + domainToVerifyDKIM = dkimVerifier.headerFrom[0].split("@")[1]; } const dkimResult = dkimVerifier.results.find( - (d: any) => d.signingDomain === domainToVerifyDKIM, + (d: any) => d.signingDomain === domainToVerifyDKIM ); if (!dkimResult) { throw new Error( - `DKIM signature not found for domain ${domainToVerifyDKIM}`, + `DKIM signature not found for domain ${domainToVerifyDKIM}` ); } From ec8bfd39712e2cb016bd2549df77292fbd1968f6 Mon Sep 17 00:00:00 2001 From: Saleel Date: Fri, 11 Oct 2024 01:15:45 +0530 Subject: [PATCH 2/7] chore: refactor DoH resolver --- packages/helpers/src/dkim/DoH.ts | 129 +++++++++++++++++++++ packages/helpers/src/lib/mailauth/DoH.ts | 97 ---------------- packages/helpers/src/lib/mailauth/tools.ts | 17 --- 3 files changed, 129 insertions(+), 114 deletions(-) create mode 100644 packages/helpers/src/dkim/DoH.ts delete mode 100644 packages/helpers/src/lib/mailauth/DoH.ts diff --git a/packages/helpers/src/dkim/DoH.ts b/packages/helpers/src/dkim/DoH.ts new file mode 100644 index 000000000..5bb08e683 --- /dev/null +++ b/packages/helpers/src/dkim/DoH.ts @@ -0,0 +1,129 @@ +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); + if (!cloudflareResult) { + throw new CustomError("No DKIM record found in Cloudflare", "ENODATA"); + } + + // Log an error if there is a mismatch in the result + if (googleResult !== cloudflareResult) { + console.error("DKIM record mismatch!"); + } + + return [googleResult]; +} 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..42ec764ed 100644 --- a/packages/helpers/src/lib/mailauth/tools.ts +++ b/packages/helpers/src/lib/mailauth/tools.ts @@ -247,20 +247,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 +289,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 = From 5345da8c64bcd8631249e99228d2fb0a48af3c91 Mon Sep 17 00:00:00 2001 From: Saleel Date: Fri, 11 Oct 2024 01:48:22 +0530 Subject: [PATCH 3/7] chore: update dns over https --- .../helpers/src/dkim/{DoH.ts => dns-over-http.ts} | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) rename packages/helpers/src/dkim/{DoH.ts => dns-over-http.ts} (93%) diff --git a/packages/helpers/src/dkim/DoH.ts b/packages/helpers/src/dkim/dns-over-http.ts similarity index 93% rename from packages/helpers/src/dkim/DoH.ts rename to packages/helpers/src/dkim/dns-over-http.ts index 5bb08e683..ad09aa314 100644 --- a/packages/helpers/src/dkim/DoH.ts +++ b/packages/helpers/src/dkim/dns-over-http.ts @@ -115,14 +115,16 @@ export async function resolveDNSHTTP(name: string, type: string) { throw new CustomError("No DKIM record found in Google", "ENODATA"); } - const cloudflareResult = await DoH.resolveDKIMPublicKey(name, DoHServer.Cloudflare); - if (!cloudflareResult) { - throw new CustomError("No DKIM record found in Cloudflare", "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!"); + console.error( + "DKIM record mismatch between Google and Cloudflare! Using Google result." + ); } return [googleResult]; From 8c63422af18a320a7a3b69a662fd2a4b6e7c3500 Mon Sep 17 00:00:00 2001 From: Saleel Date: Fri, 11 Oct 2024 01:48:42 +0530 Subject: [PATCH 4/7] feat: add fallback to archive on https failure --- packages/helpers/src/dkim/dns-archive.ts | 30 ++++++++++ packages/helpers/src/dkim/index.ts | 64 ++++++++-------------- packages/helpers/src/lib/mailauth/tools.ts | 1 - 3 files changed, 54 insertions(+), 41 deletions(-) create mode 100644 packages/helpers/src/dkim/dns-archive.ts 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/index.ts b/packages/helpers/src/dkim/index.ts index 8ee07b36f..baa63ba5e 100644 --- a/packages/helpers/src/dkim/index.ts +++ b/packages/helpers/src/dkim/index.ts @@ -1,7 +1,9 @@ import { pki } from "node-forge"; import { DkimVerifier } from "../lib/mailauth/dkim-verifier"; -import { writeToStream } from "../lib/mailauth/tools"; +import { CustomError, writeToStream } from "../lib/mailauth/tools"; import sanitizers from "./sanitizers"; +import { DoH, DoHServer, 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,25 +28,26 @@ 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 enableZKEmailDNSArchiver If provided, this public (modulus as bigint) key will be used instead of the one in the email + * @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, - enableZKEmailDNSArchiver: bigint | null = null + fallbackToZKEmailDNSArchive: boolean = false ): Promise { const emailStr = email.toString(); - let dkimResult = await tryVerifyDKIM(email, domain, enableZKEmailDNSArchiver); + 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) => ({ + tryVerifyDKIM(sanitize(emailStr), domain, fallbackToZKEmailDNSArchive).then((result) => ({ result, sanitizer: sanitize.name, })) @@ -95,46 +98,27 @@ export async function verifyDKIMSignature( }; } + async function tryVerifyDKIM( email: Buffer | string, domain: string = "", - enableZKEmailDNSArchiver: bigint | null = null + 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"); + return resolveDNSFromZKEmailArchive(name, type); + } + throw e; + } + }; + const dkimVerifier = new DkimVerifier({ - ...(enableZKEmailDNSArchiver && { - resolver: async (name: string, type: string) => { - if (type !== "TXT") { - throw new Error( - `ZK Email Archive only supports TXT records - got ${type}` - ); - } - const ZKEMAIL_DNS_ARCHIVER_API = "https://archive.prove.email/api/key"; - - // 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 Error( - `DKIM record not found for domain ${domain} and selector ${selector} in ZK Email Archive.` - ); - } - - console.log("dkimRecord", dkimRecord.value); - - return [dkimRecord.value]; - }, - }), + resolver, }); await writeToStream(dkimVerifier, email as any); diff --git a/packages/helpers/src/lib/mailauth/tools.ts b/packages/helpers/src/lib/mailauth/tools.ts index 42ec764ed..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"; From 4e692fffb77245f5d570b31486c6efa9a7694599 Mon Sep 17 00:00:00 2001 From: Saleel Date: Fri, 11 Oct 2024 01:49:18 +0530 Subject: [PATCH 5/7] chore: add tests for fallbackToZKEmailDNSArchive --- packages/helpers/src/dkim/index.ts | 47 ++++++----- packages/helpers/tests/dkim.test.ts | 122 ++++++++++++++++++++++------ 2 files changed, 118 insertions(+), 51 deletions(-) diff --git a/packages/helpers/src/dkim/index.ts b/packages/helpers/src/dkim/index.ts index baa63ba5e..6fdfed900 100644 --- a/packages/helpers/src/dkim/index.ts +++ b/packages/helpers/src/dkim/index.ts @@ -1,9 +1,9 @@ -import { pki } from "node-forge"; -import { DkimVerifier } from "../lib/mailauth/dkim-verifier"; -import { CustomError, writeToStream } from "../lib/mailauth/tools"; -import sanitizers from "./sanitizers"; +import { pki } from 'node-forge'; +import { DkimVerifier } from '../lib/mailauth/dkim-verifier'; +import { CustomError, writeToStream } from '../lib/mailauth/tools'; +import sanitizers from './sanitizers'; import { DoH, DoHServer, resolveDNSHTTP } from './dns-over-http'; -import { resolveDNSFromZKEmailArchive } from "./dns-archive"; +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 @@ -34,7 +34,7 @@ export interface DKIMVerificationResult { */ export async function verifyDKIMSignature( email: Buffer | string, - domain: string = "", + domain: string = '', enableSanitization: boolean = true, fallbackToZKEmailDNSArchive: boolean = false ): Promise { @@ -44,17 +44,15 @@ export async function verifyDKIMSignature( // If DKIM verification fails, try again after sanitizing email let appliedSanitization; - if (dkimResult.status.comment === "bad signature" && enableSanitization) { + if (dkimResult.status.comment === 'bad signature' && enableSanitization) { const results = await Promise.all( - sanitizers.map((sanitize) => - tryVerifyDKIM(sanitize(emailStr), domain, fallbackToZKEmailDNSArchive).then((result) => ({ - result, - sanitizer: sanitize.name, - })) - ) + sanitizers.map((sanitize) => tryVerifyDKIM(sanitize(emailStr), domain, fallbackToZKEmailDNSArchive).then((result) => ({ + result, + sanitizer: sanitize.name, + }))), ); - const passed = results.find((r) => r.result.status.result === "pass"); + const passed = results.find((r) => r.result.status.result === 'pass'); if (passed) { console.log( @@ -75,16 +73,16 @@ export async function verifyDKIMSignature( bodyHash, } = dkimResult; - if (result !== "pass") { + if (result !== 'pass') { throw new Error( - `DKIM signature verification failed for domain ${signingDomain}. Reason: ${comment}` + `DKIM signature verification failed for domain ${signingDomain}. Reason: ${comment}`, ); } const pubKeyData = pki.publicKeyFromPem(publicKey.toString()); return { - signature: BigInt(`0x${Buffer.from(signature, "base64").toString("hex")}`), + signature: BigInt(`0x${Buffer.from(signature, 'base64').toString('hex')}`), headers: status.signedHeaders, body, bodyHash, @@ -101,7 +99,7 @@ export async function verifyDKIMSignature( async function tryVerifyDKIM( email: Buffer | string, - domain: string = "", + domain: string = '', fallbackToZKEmailDNSArchive: boolean ) { const resolver = async (name: string, type: string) => { @@ -110,8 +108,9 @@ async function tryVerifyDKIM( return result; } catch (e) { if (fallbackToZKEmailDNSArchive) { - console.log("DNS over HTTP failed, falling back to ZK Email Archive"); - return resolveDNSFromZKEmailArchive(name, type); + console.log('DNS over HTTP failed, falling back to ZK Email Archive'); + const result = await resolveDNSFromZKEmailArchive(name, type); + return result; } throw e; } @@ -127,20 +126,20 @@ async function tryVerifyDKIM( if (!domainToVerifyDKIM) { if (dkimVerifier.headerFrom.length > 1) { throw new Error( - "Multiple From header in email and domain for verification not specified" + 'Multiple From header in email and domain for verification not specified', ); } - domainToVerifyDKIM = dkimVerifier.headerFrom[0].split("@")[1]; + domainToVerifyDKIM = dkimVerifier.headerFrom[0].split('@')[1]; } const dkimResult = dkimVerifier.results.find( - (d: any) => d.signingDomain === domainToVerifyDKIM + (d: any) => d.signingDomain === domainToVerifyDKIM, ); if (!dkimResult) { throw new Error( - `DKIM signature not found for domain ${domainToVerifyDKIM}` + `DKIM signature not found for domain ${domainToVerifyDKIM}`, ); } diff --git a/packages/helpers/tests/dkim.test.ts b/packages/helpers/tests/dkim.test.ts index 54dd8ea42..aa7b5533a 100644 --- a/packages/helpers/tests/dkim.test.ts +++ b/packages/helpers/tests/dkim.test.ts @@ -1,24 +1,26 @@ -import fs from 'fs'; -import path from 'path'; -import { verifyDKIMSignature } from '../src/dkim'; +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); -describe('DKIM signature verification', () => { - it('should pass for valid email', async () => { +describe("DKIM signature verification", () => { + it("should pass for valid email", async () => { const email = fs.readFileSync( - path.join(__dirname, 'test-data/email-good.eml'), + path.join(__dirname, "test-data/email-good.eml") ); const result = await verifyDKIMSignature(email); - expect(result.signingDomain).toBe('icloud.com'); + expect(result.signingDomain).toBe("icloud.com"); expect(result.appliedSanitization).toBeFalsy(); }); - it('should fail for invalid selector', async () => { + it("should fail for invalid selector", async () => { const email = fs.readFileSync( - path.join(__dirname, 'test-data/email-invalid-selector.eml'), + path.join(__dirname, "test-data/email-invalid-selector.eml") ); expect.assertions(1); @@ -27,14 +29,14 @@ describe('DKIM signature verification', () => { await verifyDKIMSignature(email); } catch (e) { expect(e.message).toBe( - 'DKIM signature verification failed for domain icloud.com. Reason: no key', + "DKIM signature verification failed for domain icloud.com. Reason: no key" ); } }); - it('should fail for tampered body', async () => { + it("should fail for tampered body", async () => { const email = fs.readFileSync( - path.join(__dirname, 'test-data/email-body-tampered.eml'), + path.join(__dirname, "test-data/email-body-tampered.eml") ); expect.assertions(1); @@ -43,15 +45,15 @@ describe('DKIM signature verification', () => { await verifyDKIMSignature(email); } catch (e) { expect(e.message).toBe( - 'DKIM signature verification failed for domain icloud.com. Reason: body hash did not verify', + "DKIM signature verification failed for domain icloud.com. Reason: body hash did not verify" ); } }); - it('should fail for when DKIM signature is not present for domain', async () => { + it("should fail for when DKIM signature is not present for domain", async () => { // In this email From address is user@gmail.com, but the DKIM signature is only for icloud.com const email = fs.readFileSync( - path.join(__dirname, 'test-data/email-invalid-domain.eml'), + path.join(__dirname, "test-data/email-invalid-domain.eml") ); expect.assertions(1); @@ -59,16 +61,14 @@ describe('DKIM signature verification', () => { try { await verifyDKIMSignature(email); } catch (e) { - expect(e.message).toBe( - 'DKIM signature not found for domain gmail.com', - ); + expect(e.message).toBe("DKIM signature not found for domain gmail.com"); } }); - it('should be able to override domain', async () => { + it("should be able to override domain", async () => { // From address domain is icloud.com const email = fs.readFileSync( - path.join(__dirname, 'test-data/email-different-domain.eml'), + path.join(__dirname, "test-data/email-different-domain.eml") ); // Should pass with default domain @@ -79,26 +79,94 @@ describe('DKIM signature verification', () => { // different from From domain and the below check pass. expect.assertions(1); try { - await verifyDKIMSignature(email, 'domain.com'); + await verifyDKIMSignature(email, "domain.com"); + } catch (e) { + expect(e.message).toBe("DKIM signature not found for domain domain.com"); + } + }); + + 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 not found for domain domain.com', + "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 () => { +describe("DKIM with sanitization", () => { + it("should pass after removing label from Subject", async () => { const email = fs.readFileSync( - path.join(__dirname, 'test-data/email-good.eml'), + path.join(__dirname, "test-data/email-good.eml") ); // Add a label to the subject - const tamperedEmail = email.toString().replace('Subject: ', 'Subject: [EmailListABC]'); + const tamperedEmail = email + .toString() + .replace("Subject: ", "Subject: [EmailListABC]"); const result = await verifyDKIMSignature(tamperedEmail); - expect(result.appliedSanitization).toBe('removeLabels'); + expect(result.appliedSanitization).toBe("removeLabels"); }); }); From c8e98afb5f126cc4cb4f40e77406c667f44cd865 Mon Sep 17 00:00:00 2001 From: Saleel Date: Fri, 11 Oct 2024 02:02:51 +0530 Subject: [PATCH 6/7] chore: refactor fallback tests --- packages/helpers/src/dkim/index.ts | 2 +- packages/helpers/tests/dkim.test.ts | 178 ++++++++++++++-------------- 2 files changed, 92 insertions(+), 88 deletions(-) diff --git a/packages/helpers/src/dkim/index.ts b/packages/helpers/src/dkim/index.ts index 6fdfed900..58977865b 100644 --- a/packages/helpers/src/dkim/index.ts +++ b/packages/helpers/src/dkim/index.ts @@ -56,7 +56,7 @@ export async function verifyDKIMSignature( if (passed) { console.log( - `DKIM: Verification passed after applying sanitization "${passed.sanitizer}"` + `DKIM: Verification passed after applying sanitization "${passed.sanitizer}"`, ); dkimResult = passed.result; appliedSanitization = passed.sanitizer; diff --git a/packages/helpers/tests/dkim.test.ts b/packages/helpers/tests/dkim.test.ts index aa7b5533a..3d9ad1c62 100644 --- a/packages/helpers/tests/dkim.test.ts +++ b/packages/helpers/tests/dkim.test.ts @@ -1,26 +1,26 @@ -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"; +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); -describe("DKIM signature verification", () => { - it("should pass for valid email", async () => { +describe('DKIM signature verification', () => { + it('should pass for valid email', async () => { const email = fs.readFileSync( - path.join(__dirname, "test-data/email-good.eml") + path.join(__dirname, 'test-data/email-good.eml'), ); const result = await verifyDKIMSignature(email); - expect(result.signingDomain).toBe("icloud.com"); + expect(result.signingDomain).toBe('icloud.com'); expect(result.appliedSanitization).toBeFalsy(); }); - it("should fail for invalid selector", async () => { + it('should fail for invalid selector', async () => { const email = fs.readFileSync( - path.join(__dirname, "test-data/email-invalid-selector.eml") + path.join(__dirname, 'test-data/email-invalid-selector.eml'), ); expect.assertions(1); @@ -29,14 +29,14 @@ describe("DKIM signature verification", () => { await verifyDKIMSignature(email); } catch (e) { expect(e.message).toBe( - "DKIM signature verification failed for domain icloud.com. Reason: no key" + 'DKIM signature verification failed for domain icloud.com. Reason: no key', ); } }); - it("should fail for tampered body", async () => { + it('should fail for tampered body', async () => { const email = fs.readFileSync( - path.join(__dirname, "test-data/email-body-tampered.eml") + path.join(__dirname, 'test-data/email-body-tampered.eml'), ); expect.assertions(1); @@ -45,15 +45,15 @@ describe("DKIM signature verification", () => { await verifyDKIMSignature(email); } catch (e) { expect(e.message).toBe( - "DKIM signature verification failed for domain icloud.com. Reason: body hash did not verify" + 'DKIM signature verification failed for domain icloud.com. Reason: body hash did not verify', ); } }); - it("should fail for when DKIM signature is not present for domain", async () => { + it('should fail for when DKIM signature is not present for domain', async () => { // In this email From address is user@gmail.com, but the DKIM signature is only for icloud.com const email = fs.readFileSync( - path.join(__dirname, "test-data/email-invalid-domain.eml") + path.join(__dirname, 'test-data/email-invalid-domain.eml'), ); expect.assertions(1); @@ -61,14 +61,16 @@ describe("DKIM signature verification", () => { try { await verifyDKIMSignature(email); } catch (e) { - expect(e.message).toBe("DKIM signature not found for domain gmail.com"); + expect(e.message).toBe( + 'DKIM signature not found for domain gmail.com', + ); } }); - it("should be able to override domain", async () => { + it('should be able to override domain', async () => { // From address domain is icloud.com const email = fs.readFileSync( - path.join(__dirname, "test-data/email-different-domain.eml") + path.join(__dirname, 'test-data/email-different-domain.eml'), ); // Should pass with default domain @@ -79,94 +81,96 @@ describe("DKIM signature verification", () => { // different from From domain and the below check pass. expect.assertions(1); try { - await verifyDKIMSignature(email, "domain.com"); + await verifyDKIMSignature(email, 'domain.com'); } catch (e) { - expect(e.message).toBe("DKIM signature not found for domain domain.com"); + expect(e.message).toBe( + 'DKIM signature not found for domain domain.com', + ); } }); +}); - 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" - ); +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") + ); - mockResolveDNSHTTP.mockRestore(); - }); + // Mock resolveDNSHTTP to throw an error just for this test + const mockResolveDNSHTTP = jest + .spyOn(dnsOverHttp, "resolveDNSHTTP") + .mockRejectedValue(new Error("Failed due to mock")); - 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") - ); + const consoleSpy = jest.spyOn(console, "log"); + await verifyDKIMSignature(email, "icloud.com", true, true); - // Mock resolveDNSHTTP to throw an error just for this test - const mockResolveDNSHTTP = jest - .spyOn(dnsOverHttp, "resolveDNSHTTP") - .mockRejectedValue(new Error("Failed due to mock")); + // 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" + ); - 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(); - }); + 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") +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(); +}); - 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" - ); - } +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(); - }); + mockResolveDNSHTTP.mockRestore(); + mockResolveDNSFromZKEmailArchive.mockRestore(); }); -describe("DKIM with sanitization", () => { - it("should pass after removing label from Subject", async () => { +describe('DKIM with sanitization', () => { + it('should pass after removing label from Subject', async () => { const email = fs.readFileSync( - path.join(__dirname, "test-data/email-good.eml") + path.join(__dirname, 'test-data/email-good.eml'), ); // Add a label to the subject - const tamperedEmail = email - .toString() - .replace("Subject: ", "Subject: [EmailListABC]"); + const tamperedEmail = email.toString().replace('Subject: ', 'Subject: [EmailListABC]'); const result = await verifyDKIMSignature(tamperedEmail); - expect(result.appliedSanitization).toBe("removeLabels"); + expect(result.appliedSanitization).toBe('removeLabels'); }); -}); +}); \ No newline at end of file From 6cc8b8abd7fc3a7ce602be5d66e01c5af8486677 Mon Sep 17 00:00:00 2001 From: Saleel Date: Fri, 11 Oct 2024 02:03:55 +0530 Subject: [PATCH 7/7] chore: remove unused imports --- packages/helpers/src/dkim/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/helpers/src/dkim/index.ts b/packages/helpers/src/dkim/index.ts index 58977865b..37f859fa6 100644 --- a/packages/helpers/src/dkim/index.ts +++ b/packages/helpers/src/dkim/index.ts @@ -1,8 +1,8 @@ import { pki } from 'node-forge'; import { DkimVerifier } from '../lib/mailauth/dkim-verifier'; -import { CustomError, writeToStream } from '../lib/mailauth/tools'; +import { writeToStream } from '../lib/mailauth/tools'; import sanitizers from './sanitizers'; -import { DoH, DoHServer, resolveDNSHTTP } from './dns-over-http'; +import { resolveDNSHTTP } from './dns-over-http'; import { resolveDNSFromZKEmailArchive } from './dns-archive'; // `./mailauth` is modified version of https://github.com/postalsys/mailauth