Skip to content

Commit

Permalink
Merge pull request #227 from zkemail/feat/archiver-dkim
Browse files Browse the repository at this point in the history
Add an option to resolve DNS from ZKEmail Archive when HTTP DNS fails
  • Loading branch information
Divide-By-0 authored Oct 12, 2024
2 parents a6c3ecc + 6cc8b8a commit 6e08c67
Show file tree
Hide file tree
Showing 6 changed files with 265 additions and 120 deletions.
30 changes: 30 additions & 0 deletions packages/helpers/src/dkim/dns-archive.ts
Original file line number Diff line number Diff line change
@@ -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];
}
131 changes: 131 additions & 0 deletions packages/helpers/src/dkim/dns-over-http.ts
Original file line number Diff line number Diff line change
@@ -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<string | null>)} DKIM public key or null if not found
* @memberof DoH
*/
public static async resolveDKIMPublicKey(
name: string,
DNSServer: string
): Promise<string | null> {
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];
}
35 changes: 31 additions & 4 deletions packages/helpers/src/dkim/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<DKIMVerificationResult> {
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,
}))),
Expand Down Expand Up @@ -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;
Expand Down
97 changes: 0 additions & 97 deletions packages/helpers/src/lib/mailauth/DoH.ts

This file was deleted.

18 changes: 0 additions & 18 deletions packages/helpers/src/lib/mailauth/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -303,9 +288,6 @@ export const getPublicKey = async (
resolver: (...args: [name: string, type: string]) => Promise<any>
) => {
minBitLength = minBitLength || 1024;
if (!resolver) {
resolver = resolveDNSHTTP;
}

let list = await resolver(name, "TXT");
let rr =
Expand Down
Loading

0 comments on commit 6e08c67

Please sign in to comment.