From 4e9d589597fa0dd3f8f195d14819a1655eac3235 Mon Sep 17 00:00:00 2001 From: Saleel Date: Thu, 17 Oct 2024 11:49:17 +0100 Subject: [PATCH] chore: apply prettier --- packages/helpers/package.json | 2 +- packages/helpers/src/binary-format.ts | 8 +- packages/helpers/src/chunked-zkey.ts | 52 +- packages/helpers/src/dkim/dns-over-http.ts | 32 +- packages/helpers/src/dkim/index.ts | 30 +- packages/helpers/src/dkim/sanitizers.ts | 12 +- packages/helpers/src/input-generators.ts | 30 +- packages/helpers/src/lib/fast-sha256.ts | 40 +- .../helpers/src/lib/mailauth/body/index.ts | 18 +- .../helpers/src/lib/mailauth/body/relaxed.ts | 416 ++++++------- .../helpers/src/lib/mailauth/body/simple.ts | 164 ++--- .../helpers/src/lib/mailauth/dkim-verifier.ts | 196 +++--- .../helpers/src/lib/mailauth/header/index.ts | 31 +- .../src/lib/mailauth/header/relaxed.ts | 117 ++-- .../helpers/src/lib/mailauth/header/simple.ts | 120 ++-- .../src/lib/mailauth/message-parser.ts | 236 ++++---- .../src/lib/mailauth/parse-dkim-headers.ts | 569 +++++++++--------- packages/helpers/src/lib/mailauth/tools.ts | 264 ++++---- packages/helpers/src/sha-utils.ts | 17 +- packages/helpers/tests/dkim.test.ts | 92 +-- .../helpers/tests/input-generators.test.ts | 46 +- 21 files changed, 1217 insertions(+), 1275 deletions(-) diff --git a/packages/helpers/package.json b/packages/helpers/package.json index 4788d6d6a..c3655d9e9 100644 --- a/packages/helpers/package.json +++ b/packages/helpers/package.json @@ -6,7 +6,7 @@ "scripts": { "build": "tsc", "test": "jest tests/**/*.test.ts", - "lint": "eslint .", + "lint": "prettier --write src/**/*.ts tests/**/*.ts && eslint .", "prepublish": "yarn lint && yarn build", "publish": "yarn npm publish --access=public" }, diff --git a/packages/helpers/src/binary-format.ts b/packages/helpers/src/binary-format.ts index a549d8654..a1925b7d3 100644 --- a/packages/helpers/src/binary-format.ts +++ b/packages/helpers/src/binary-format.ts @@ -190,14 +190,18 @@ export function packedNBytesToString(packedBytes: bigint[], n: number = 31): str } export function packBytesIntoNBytes(messagePaddedRaw: Uint8Array | string, n = 7): Array { - const messagePadded: Uint8Array = typeof messagePaddedRaw === 'string' ? stringToBytes(messagePaddedRaw) : messagePaddedRaw; + const messagePadded: Uint8Array = + typeof messagePaddedRaw === 'string' ? stringToBytes(messagePaddedRaw) : messagePaddedRaw; const output: Array = []; for (let i = 0; i < messagePadded.length; i++) { if (i % n === 0) { output.push(0n); } const j = (i / n) | 0; - console.assert(j === output.length - 1, 'Not editing the index of the last element -- packing loop invariants bug!'); + console.assert( + j === output.length - 1, + 'Not editing the index of the last element -- packing loop invariants bug!', + ); output[j] += BigInt(messagePadded[i]) << BigInt((i % n) * 8); } return output; diff --git a/packages/helpers/src/chunked-zkey.ts b/packages/helpers/src/chunked-zkey.ts index 6d0053a06..09b540d64 100644 --- a/packages/helpers/src/chunked-zkey.ts +++ b/packages/helpers/src/chunked-zkey.ts @@ -10,9 +10,7 @@ const zkeySuffix = ['b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k']; // uncompresses single .gz file. // returns the contents as an ArrayBuffer -export const uncompressGz = async ( - arrayBuffer: ArrayBuffer, -): Promise => { +export const uncompressGz = async (arrayBuffer: ArrayBuffer): Promise => { const output = pako.ungzip(arrayBuffer); const buff = output.buffer; return buff; @@ -31,19 +29,13 @@ async function downloadWithRetries(link: string, downloadAttempts: number) { return response; } } - throw new Error( - `Error downloading ${link} after ${downloadAttempts} retries`, - ); + throw new Error(`Error downloading ${link} after ${downloadAttempts} retries`); } // GET the compressed file from the remote server, then store it with localforage // Note that it must be stored as an uncompressed ArrayBuffer // and named such that filename===`${name}.zkey${a}` in order for it to be found by snarkjs. -export async function downloadFromFilename( - baseUrl: string, - filename: string, - compressed = false, -) { +export async function downloadFromFilename(baseUrl: string, filename: string, compressed = false) { const link = baseUrl + filename; const zkeyResp = await downloadWithRetries(link, 3); @@ -64,28 +56,18 @@ export async function downloadFromFilename( console.log(`Storage of ${filename} successful!`); } -export async function downloadProofFiles( - baseUrl: string, - circuitName: string, - onFileDownloaded: () => void, -) { +export async function downloadProofFiles(baseUrl: string, circuitName: string, onFileDownloaded: () => void) { const filePromises = []; for (const c of zkeySuffix) { const targzFilename = `${circuitName}.zkey${c}${zkeyExtension}`; // const itemCompressed = await localforage.getItem(targzFilename); const item = await localforage.getItem(`${circuitName}.zkey${c}`); if (item) { - console.log( - `${circuitName}.zkey${c}${ - item ? '' : zkeyExtension - } already found in localforage!`, - ); + console.log(`${circuitName}.zkey${c}${item ? '' : zkeyExtension} already found in localforage!`); onFileDownloaded(); continue; } - filePromises.push( - downloadFromFilename(baseUrl, targzFilename, true).then(() => onFileDownloaded()), - ); + filePromises.push(downloadFromFilename(baseUrl, targzFilename, true).then(() => onFileDownloaded())); } console.log(filePromises); await Promise.all(filePromises); @@ -116,11 +98,7 @@ export async function verifyProof(proof: any, publicSignals: any, baseUrl: strin const vkey = await response.json(); console.log('vkey', vkey); - const proofVerified = await snarkjs.groth16.verify( - vkey, - publicSignals, - proof, - ); + const proofVerified = await snarkjs.groth16.verify(vkey, publicSignals, proof); console.log('proofV', proofVerified); return proofVerified; @@ -143,24 +121,16 @@ function bigIntToArray(n: number, k: number, x: bigint) { // taken from generation code in dizkus-circuits tests function pubkeyToXYArrays(pk: string) { - const XArr = bigIntToArray(64, 4, BigInt(`0x${pk.slice(4, 4 + 64)}`)).map( - (el) => el.toString(), - ); - const YArr = bigIntToArray(64, 4, BigInt(`0x${pk.slice(68, 68 + 64)}`)).map( - (el) => el.toString(), - ); + const XArr = bigIntToArray(64, 4, BigInt(`0x${pk.slice(4, 4 + 64)}`)).map((el) => el.toString()); + const YArr = bigIntToArray(64, 4, BigInt(`0x${pk.slice(68, 68 + 64)}`)).map((el) => el.toString()); return [XArr, YArr]; } // taken from generation code in dizkus-circuits tests function sigToRSArrays(sig: string) { - const rArr = bigIntToArray(64, 4, BigInt(`0x${sig.slice(2, 2 + 64)}`)).map( - (el) => el.toString(), - ); - const sArr = bigIntToArray(64, 4, BigInt(`0x${sig.slice(66, 66 + 64)}`)).map( - (el) => el.toString(), - ); + const rArr = bigIntToArray(64, 4, BigInt(`0x${sig.slice(2, 2 + 64)}`)).map((el) => el.toString()); + const sArr = bigIntToArray(64, 4, BigInt(`0x${sig.slice(66, 66 + 64)}`)).map((el) => el.toString()); return [rArr, sArr]; } diff --git a/packages/helpers/src/dkim/dns-over-http.ts b/packages/helpers/src/dkim/dns-over-http.ts index 76289d7d7..0d8ba1659 100644 --- a/packages/helpers/src/dkim/dns-over-http.ts +++ b/packages/helpers/src/dkim/dns-over-http.ts @@ -30,10 +30,7 @@ export class DoH { * @return {*} {(Promise)} DKIM public key or null if not found * @memberof DoH */ - public static async resolveDKIMPublicKey( - name: string, - dnsServerURL: string - ): Promise { + public static async resolveDKIMPublicKey(name: string, dnsServerURL: string): Promise { let cleanURL = dnsServerURL; if (!cleanURL.startsWith('https://')) { cleanURL = `https://${cleanURL}`; @@ -47,20 +44,14 @@ export class DoH { queryUrl.searchParams.set('type', DoH.DoHTypeTXT.toString()); const resp = await fetch(queryUrl, { - headers: { - accept: 'application/dns-json', - }, - } - ); + 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 - ) { + if (typeof out === 'object' && out !== null && 'Status' in out && 'Answer' in out) { const result = out as DoHResponse; if (result.Status === DoH.DoHStatusNoError && result.Answer.length > 0) { for (const ans of result.Answer) { @@ -73,7 +64,7 @@ export class DoH { 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, ""); + dkimRecord = dkimRecord.replace(/"/g, ''); return dkimRecord; } } @@ -124,16 +115,11 @@ export async function resolveDNSHTTP(name: string, type: string) { } } - const cloudflareResult = await DoH.resolveDKIMPublicKey( - name, - DoHServer.Cloudflare - ); + 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.' - ); + 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 8d6ae3bd9..be553362c 100644 --- a/packages/helpers/src/dkim/index.ts +++ b/packages/helpers/src/dkim/index.ts @@ -46,18 +46,18 @@ export async function verifyDKIMSignature( let appliedSanitization; 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'); if (passed) { - console.log( - `DKIM: Verification passed after applying sanitization "${passed.sanitizer}"`, - ); + console.log(`DKIM: Verification passed after applying sanitization "${passed.sanitizer}"`); dkimResult = passed.result; appliedSanitization = passed.sanitizer; } @@ -74,9 +74,7 @@ export async function verifyDKIMSignature( } = dkimResult; if (result !== 'pass') { - throw new Error( - `DKIM signature verification failed for domain ${signingDomain}. Reason: ${comment}`, - ); + throw new Error(`DKIM signature verification failed for domain ${signingDomain}. Reason: ${comment}`); } const pubKeyData = pki.publicKeyFromPem(publicKey.toString()); @@ -124,22 +122,16 @@ async function tryVerifyDKIM( let domainToVerifyDKIM = domain; if (!domainToVerifyDKIM) { if (dkimVerifier.headerFrom.length > 1) { - throw new Error( - 'Multiple From header in email and domain for verification not specified', - ); + throw new Error('Multiple From header in email and domain for verification not specified'); } domainToVerifyDKIM = dkimVerifier.headerFrom[0].split('@')[1]; } - const dkimResult = dkimVerifier.results.find( - (d: any) => d.signingDomain === domainToVerifyDKIM, - ); + const dkimResult = dkimVerifier.results.find((d: any) => d.signingDomain === domainToVerifyDKIM); if (!dkimResult) { - throw new Error( - `DKIM signature not found for domain ${domainToVerifyDKIM}`, - ); + throw new Error(`DKIM signature not found for domain ${domainToVerifyDKIM}`); } dkimResult.headers = dkimVerifier.headers; diff --git a/packages/helpers/src/dkim/sanitizers.ts b/packages/helpers/src/dkim/sanitizers.ts index 652f2adea..97a42dac8 100644 --- a/packages/helpers/src/dkim/sanitizers.ts +++ b/packages/helpers/src/dkim/sanitizers.ts @@ -19,10 +19,7 @@ function revertGoogleMessageId(email: string): string { return email; } - const googleReplacedMessageId = getHeaderValue( - email, - 'X-Google-Original-Message-ID', - ); + const googleReplacedMessageId = getHeaderValue(email, 'X-Google-Original-Message-ID'); if (googleReplacedMessageId) { return setHeaderValue(email, 'Message-ID', googleReplacedMessageId); @@ -65,11 +62,6 @@ function sanitizeTabs(email: string): string { return email.replace('=09', '\t'); } -const sanitizers = [ - revertGoogleMessageId, - removeLabels, - insert13Before10, - sanitizeTabs, -]; +const sanitizers = [revertGoogleMessageId, removeLabels, insert13Before10, sanitizeTabs]; export default sanitizers; diff --git a/packages/helpers/src/input-generators.ts b/packages/helpers/src/input-generators.ts index b35efe0cc..5bab624f1 100644 --- a/packages/helpers/src/input-generators.ts +++ b/packages/helpers/src/input-generators.ts @@ -40,10 +40,10 @@ function removeSoftLineBreaks(body: string[]): string[] { let i = 0; while (i < body.length) { if ( - i + 2 < body.length - && body[i] === '61' // '=' character - && body[i + 1] === '13' // '\r' character - && body[i + 2] === '10' + i + 2 < body.length && + body[i] === '61' && // '=' character + body[i + 1] === '13' && // '\r' character + body[i + 2] === '10' ) { // '\n' character // Skip the soft line break sequence @@ -94,15 +94,10 @@ export function generateEmailVerifierInputsFromDKIMResult( dkimResult: DKIMVerificationResult, params: InputGenerationArgs = {}, ): CircuitInput { - const { - headers, body, bodyHash, publicKey, signature, - } = dkimResult; + const { headers, body, bodyHash, publicKey, signature } = dkimResult; // SHA add padding - const [messagePadded, messagePaddedLen] = sha256Pad( - headers, - params.maxHeadersLength || MAX_HEADER_PADDED_BYTES, - ); + const [messagePadded, messagePaddedLen] = sha256Pad(headers, params.maxHeadersLength || MAX_HEADER_PADDED_BYTES); const circuitInputs: CircuitInput = { emailHeader: Uint8ArrayToCharArray(messagePadded), // Packed into 1 byte signals @@ -117,9 +112,7 @@ export function generateEmailVerifierInputsFromDKIMResult( if (!params.ignoreBodyHashCheck) { if (!body || !bodyHash) { - throw new Error( - 'body and bodyHash are required when ignoreBodyHashCheck is false', - ); + throw new Error('body and bodyHash are required when ignoreBodyHashCheck is false'); } const bodyHashIndex = headers.toString().indexOf(bodyHash); @@ -128,10 +121,7 @@ export function generateEmailVerifierInputsFromDKIMResult( // 65 comes from the 64 at the end and the 1 bit in the start, then 63 comes from the formula to round it up to the nearest 64. // see sha256algorithm.com for a more full explanation of padding length const bodySHALength = Math.floor((body.length + 63 + 65) / 64) * 64; - const [bodyPadded, bodyPaddedLen] = sha256Pad( - body, - Math.max(maxBodyLength, bodySHALength), - ); + const [bodyPadded, bodyPaddedLen] = sha256Pad(body, Math.max(maxBodyLength, bodySHALength)); const { precomputedSha, bodyRemaining, bodyRemainingLength } = generatePartialSHA({ body: bodyPadded, @@ -146,9 +136,7 @@ export function generateEmailVerifierInputsFromDKIMResult( circuitInputs.emailBody = Uint8ArrayToCharArray(bodyRemaining); if (params.removeSoftLineBreaks) { - circuitInputs.decodedEmailBodyIn = removeSoftLineBreaks( - circuitInputs.emailBody, - ); + circuitInputs.decodedEmailBodyIn = removeSoftLineBreaks(circuitInputs.emailBody); } if (params.enableBodyMasking) { diff --git a/packages/helpers/src/lib/fast-sha256.ts b/packages/helpers/src/lib/fast-sha256.ts index a3025d09f..4db4d9bc8 100644 --- a/packages/helpers/src/lib/fast-sha256.ts +++ b/packages/helpers/src/lib/fast-sha256.ts @@ -23,15 +23,30 @@ export const blockSize: number = 64; // SHA-256 constants const K = new Uint32Array([ - 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, - 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, - 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b, - 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, - 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2, + 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, 0xd807aa98, + 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, + 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, 0x983e5152, 0xa831c66d, 0xb00327c8, + 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, + 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, + 0xd6990624, 0xf40e3585, 0x106aa070, 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, + 0x5b9cca4f, 0x682e6ff3, 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, + 0xc67178f2, ]); function hashBlocks(w: Int32Array, v: Int32Array, p: Uint8Array, pos: number, len: number): number { - let a: number, b: number, c: number, d: number, e: number, f: number, g: number, h: number, u: number, i: number, j: number, t1: number, t2: number; + let a: number, + b: number, + c: number, + d: number, + e: number, + f: number, + g: number, + h: number, + u: number, + i: number, + j: number, + t1: number, + t2: number; while (len >= 64) { a = v[0]; b = v[1]; @@ -59,9 +74,16 @@ function hashBlocks(w: Int32Array, v: Int32Array, p: Uint8Array, pos: number, le for (i = 0; i < 64; i++) { t1 = - ((((((e >>> 6) | (e << (32 - 6))) ^ ((e >>> 11) | (e << (32 - 11))) ^ ((e >>> 25) | (e << (32 - 25)))) + ((e & f) ^ (~e & g))) | 0) + ((h + ((K[i] + w[i]) | 0)) | 0)) | 0; + ((((((e >>> 6) | (e << (32 - 6))) ^ ((e >>> 11) | (e << (32 - 11))) ^ ((e >>> 25) | (e << (32 - 25)))) + + ((e & f) ^ (~e & g))) | + 0) + + ((h + ((K[i] + w[i]) | 0)) | 0)) | + 0; - t2 = ((((a >>> 2) | (a << (32 - 2))) ^ ((a >>> 13) | (a << (32 - 13))) ^ ((a >>> 22) | (a << (32 - 22)))) + ((a & b) ^ (a & c) ^ (b & c))) | 0; + t2 = + ((((a >>> 2) | (a << (32 - 2))) ^ ((a >>> 13) | (a << (32 - 13))) ^ ((a >>> 22) | (a << (32 - 22)))) + + ((a & b) ^ (a & c) ^ (b & c))) | + 0; h = g; g = f; @@ -358,7 +380,7 @@ function fillBuffer(buffer: Uint8Array, hmac: HMAC, info: Uint8Array | undefined const num = counter[0]; if (num === 0) { - throw new Error("hkdf: cannot expand more"); + throw new Error('hkdf: cannot expand more'); } // Prepare HMAC instance for new data with old key. diff --git a/packages/helpers/src/lib/mailauth/body/index.ts b/packages/helpers/src/lib/mailauth/body/index.ts index 5291ab029..cda10cad0 100644 --- a/packages/helpers/src/lib/mailauth/body/index.ts +++ b/packages/helpers/src/lib/mailauth/body/index.ts @@ -2,13 +2,13 @@ import { SimpleHash } from './simple'; import { RelaxedHash } from './relaxed'; export const dkimBody = (canonicalization: any, ...options: [string, number]) => { - canonicalization = (canonicalization ?? 'simple/simple').toString().split('/').pop()?.toLowerCase().trim(); - switch (canonicalization) { - case 'simple': - return new SimpleHash(...options); - case 'relaxed': - return new RelaxedHash(...options); - default: - throw new Error('Unknown body canonicalization'); - } + canonicalization = (canonicalization ?? 'simple/simple').toString().split('/').pop()?.toLowerCase().trim(); + switch (canonicalization) { + case 'simple': + return new SimpleHash(...options); + case 'relaxed': + return new RelaxedHash(...options); + default: + throw new Error('Unknown body canonicalization'); + } }; diff --git a/packages/helpers/src/lib/mailauth/body/relaxed.ts b/packages/helpers/src/lib/mailauth/body/relaxed.ts index a3a675fb4..b6aa13191 100644 --- a/packages/helpers/src/lib/mailauth/body/relaxed.ts +++ b/packages/helpers/src/lib/mailauth/body/relaxed.ts @@ -12,265 +12,265 @@ const CHAR_TAB = 0x09; * @class */ export class RelaxedHash { - byteLength: number; - bodyHashedBytes: number; - private remainder: Buffer | boolean; - private bodyHash: crypto.Hash; - private maxBodyLength: number; - private maxSizeReached: boolean; - private emptyLinesQueue: Array; - private fullBody: Buffer; + byteLength: number; + bodyHashedBytes: number; + private remainder: Buffer | boolean; + private bodyHash: crypto.Hash; + private maxBodyLength: number; + private maxSizeReached: boolean; + private emptyLinesQueue: Array; + private fullBody: Buffer; - /** - * @param {String} [algorithm] Hashing algo, either "sha1" or "sha256" - * @param {Number} [maxBodyLength] Allowed body length count, the value from the l= parameter - */ - constructor(algorithm: string, maxBodyLength: number) { - algorithm = algorithm?.split('-')?.pop()?.toLowerCase() || 'sha256'; + /** + * @param {String} [algorithm] Hashing algo, either "sha1" or "sha256" + * @param {Number} [maxBodyLength] Allowed body length count, the value from the l= parameter + */ + constructor(algorithm: string, maxBodyLength: number) { + algorithm = algorithm?.split('-')?.pop()?.toLowerCase() || 'sha256'; - this.bodyHash = crypto.createHash(algorithm); + this.bodyHash = crypto.createHash(algorithm); - this.remainder = false; - this.byteLength = 0; + this.remainder = false; + this.byteLength = 0; - this.bodyHashedBytes = 0; - this.maxBodyLength = maxBodyLength; + this.bodyHashedBytes = 0; + this.maxBodyLength = maxBodyLength; - this.maxSizeReached = false; + this.maxSizeReached = false; - this.emptyLinesQueue = []; + this.emptyLinesQueue = []; - this.fullBody = Buffer.alloc(0); - } + this.fullBody = Buffer.alloc(0); + } - private updateBodyHash(chunk: Buffer) { - if (this.maxSizeReached) { - return; - } + private updateBodyHash(chunk: Buffer) { + if (this.maxSizeReached) { + return; + } - // the following is needed for the l= option - if ( - typeof this.maxBodyLength === 'number' && - !isNaN(this.maxBodyLength) && - this.maxBodyLength >= 0 && - this.bodyHashedBytes + chunk.length > this.maxBodyLength - ) { - this.maxSizeReached = true; - if (this.bodyHashedBytes >= this.maxBodyLength) { - // nothing to do here, skip entire chunk - return; - } + // the following is needed for the l= option + if ( + typeof this.maxBodyLength === 'number' && + !isNaN(this.maxBodyLength) && + this.maxBodyLength >= 0 && + this.bodyHashedBytes + chunk.length > this.maxBodyLength + ) { + this.maxSizeReached = true; + if (this.bodyHashedBytes >= this.maxBodyLength) { + // nothing to do here, skip entire chunk + return; + } + + // only use allowed size of bytes + chunk = chunk.subarray(0, this.maxBodyLength - this.bodyHashedBytes); + } - // only use allowed size of bytes - chunk = chunk.subarray(0, this.maxBodyLength - this.bodyHashedBytes); - } + this.bodyHashedBytes += chunk.length; + this.bodyHash.update(chunk); + this.fullBody = Buffer.concat([this.fullBody, Buffer.from(chunk)]); - this.bodyHashedBytes += chunk.length; - this.bodyHash.update(chunk); - this.fullBody = Buffer.concat([this.fullBody, Buffer.from(chunk)]); + //process.stdout.write(chunk); + } - //process.stdout.write(chunk); + private drainPendingEmptyLines() { + if (this.emptyLinesQueue.length) { + for (let emptyLine of this.emptyLinesQueue) { + this.updateBodyHash(emptyLine); + } + this.emptyLinesQueue = []; } + } - private drainPendingEmptyLines() { - if (this.emptyLinesQueue.length) { - for (let emptyLine of this.emptyLinesQueue) { - this.updateBodyHash(emptyLine); - } - this.emptyLinesQueue = []; - } + private pushBodyHash(chunk: Buffer) { + if (!chunk || !chunk.length) { + return; } - private pushBodyHash(chunk: Buffer) { - if (!chunk || !chunk.length) { - return; - } - - // remove line endings - let foundNonLn = false; - - // buffer line endings and empty lines - for (let i = chunk.length - 1; i >= 0; i--) { - if (chunk[i] !== CHAR_LF && chunk[i] !== CHAR_CR) { - this.drainPendingEmptyLines(); - if (i < chunk.length - 1) { - this.emptyLinesQueue.push(chunk.subarray(i + 1)); - chunk = chunk.subarray(0, i + 1); - } - foundNonLn = true; - break; - } - } + // remove line endings + let foundNonLn = false; - if (!foundNonLn) { - this.emptyLinesQueue.push(chunk); - return; + // buffer line endings and empty lines + for (let i = chunk.length - 1; i >= 0; i--) { + if (chunk[i] !== CHAR_LF && chunk[i] !== CHAR_CR) { + this.drainPendingEmptyLines(); + if (i < chunk.length - 1) { + this.emptyLinesQueue.push(chunk.subarray(i + 1)); + chunk = chunk.subarray(0, i + 1); } - - this.updateBodyHash(chunk); + foundNonLn = true; + break; + } } - fixLineBuffer(line: Buffer) { - let resultLine = []; + if (!foundNonLn) { + this.emptyLinesQueue.push(chunk); + return; + } - let nonWspFound = false; - let prevWsp = false; + this.updateBodyHash(chunk); + } - for (let i = line.length - 1; i >= 0; i--) { - if (line[i] === CHAR_LF) { - resultLine.unshift(line[i]); - if (i === 0 || line[i - 1] !== CHAR_CR) { - // add missing carriage return - resultLine.unshift(CHAR_CR); - } - continue; - } + fixLineBuffer(line: Buffer) { + let resultLine = []; - if (line[i] === CHAR_CR) { - resultLine.unshift(line[i]); - continue; - } + let nonWspFound = false; + let prevWsp = false; - if (line[i] === CHAR_SPACE || line[i] === CHAR_TAB) { - if (nonWspFound) { - prevWsp = true; - } - continue; - } + for (let i = line.length - 1; i >= 0; i--) { + if (line[i] === CHAR_LF) { + resultLine.unshift(line[i]); + if (i === 0 || line[i - 1] !== CHAR_CR) { + // add missing carriage return + resultLine.unshift(CHAR_CR); + } + continue; + } - if (prevWsp) { - resultLine.unshift(CHAR_SPACE); - prevWsp = false; - } + if (line[i] === CHAR_CR) { + resultLine.unshift(line[i]); + continue; + } - nonWspFound = true; - resultLine.unshift(line[i]); + if (line[i] === CHAR_SPACE || line[i] === CHAR_TAB) { + if (nonWspFound) { + prevWsp = true; } + continue; + } - if (prevWsp && nonWspFound) { - resultLine.unshift(CHAR_SPACE); - } + if (prevWsp) { + resultLine.unshift(CHAR_SPACE); + prevWsp = false; + } - return Buffer.from(resultLine); + nonWspFound = true; + resultLine.unshift(line[i]); } - update(chunk: Buffer | null, final: boolean) { - this.byteLength += (chunk && chunk.length) || 0; - if (this.maxSizeReached) { - return; - } + if (prevWsp && nonWspFound) { + resultLine.unshift(CHAR_SPACE); + } - // Canonicalize content by applying a and b in order: - // a.1. Ignore all whitespace at the end of lines. - // a.2. Reduce all sequences of WSP within a line to a single SP character. + return Buffer.from(resultLine); + } - // b.1. Ignore all empty lines at the end of the message body. - // b.2. If the body is non-empty but does not end with a CRLF, a CRLF is added. + update(chunk: Buffer | null, final: boolean) { + this.byteLength += (chunk && chunk.length) || 0; + if (this.maxSizeReached) { + return; + } - let lineEndPos = -1; - let lineNeedsFixing = false; - let cursorPos = 0; + // Canonicalize content by applying a and b in order: + // a.1. Ignore all whitespace at the end of lines. + // a.2. Reduce all sequences of WSP within a line to a single SP character. + + // b.1. Ignore all empty lines at the end of the message body. + // b.2. If the body is non-empty but does not end with a CRLF, a CRLF is added. + + let lineEndPos = -1; + let lineNeedsFixing = false; + let cursorPos = 0; + + if (this.remainder && this.remainder instanceof Buffer && this.remainder.length) { + if (chunk) { + // concatting chunks might be bad for performance :S + chunk = Buffer.concat([this.remainder, chunk]); + } else { + chunk = this.remainder; + } + this.remainder = false; + } - if (this.remainder && this.remainder instanceof Buffer && this.remainder.length) { - if (chunk) { - // concatting chunks might be bad for performance :S - chunk = Buffer.concat([this.remainder, chunk]); - } else { - chunk = this.remainder; + if (chunk && chunk.length) { + for (let pos = 0; pos < chunk.length; pos++) { + switch (chunk[pos]) { + case CHAR_LF: + if ( + !lineNeedsFixing && + // previous character is not + ((pos >= 1 && chunk[pos - 1] !== CHAR_CR) || + // LF is the first byte on the line + pos === 0 || + // there's a space before line break + (pos >= 2 && chunk[pos - 1] === CHAR_CR && chunk[pos - 2] === CHAR_SPACE)) + ) { + lineNeedsFixing = true; } - this.remainder = false; - } - if (chunk && chunk.length) { - for (let pos = 0; pos < chunk.length; pos++) { - switch (chunk[pos]) { - case CHAR_LF: - if ( - !lineNeedsFixing && - // previous character is not - ((pos >= 1 && chunk[pos - 1] !== CHAR_CR) || - // LF is the first byte on the line - pos === 0 || - // there's a space before line break - (pos >= 2 && chunk[pos - 1] === CHAR_CR && chunk[pos - 2] === CHAR_SPACE)) - ) { - lineNeedsFixing = true; - } - - // line break - if (lineNeedsFixing) { - // emit pending bytes up to the last line break before current line - if (lineEndPos >= 0 && lineEndPos >= cursorPos) { - let chunkPart = chunk.subarray(cursorPos, lineEndPos + 1); - this.pushBodyHash(chunkPart); - } - - let line = chunk.subarray(lineEndPos + 1, pos + 1); - this.pushBodyHash(this.fixLineBuffer(line)); - - lineNeedsFixing = false; - - // move cursor to the start of next line - cursorPos = pos + 1; - } - - lineEndPos = pos; - - break; - - case CHAR_SPACE: - if (!lineNeedsFixing && pos && chunk[pos - 1] === CHAR_SPACE) { - lineNeedsFixing = true; - } - break; - - case CHAR_TAB: - // non-space WSP always needs replacing - lineNeedsFixing = true; - break; - - default: - } - } - } + // line break + if (lineNeedsFixing) { + // emit pending bytes up to the last line break before current line + if (lineEndPos >= 0 && lineEndPos >= cursorPos) { + let chunkPart = chunk.subarray(cursorPos, lineEndPos + 1); + this.pushBodyHash(chunkPart); + } - if (chunk && cursorPos < chunk.length && cursorPos !== lineEndPos) { - // emit data from chunk + let line = chunk.subarray(lineEndPos + 1, pos + 1); + this.pushBodyHash(this.fixLineBuffer(line)); - let chunkPart = chunk.subarray(cursorPos, lineEndPos + 1); + lineNeedsFixing = false; - if (chunkPart.length) { - this.pushBodyHash(lineNeedsFixing ? this.fixLineBuffer(chunkPart) : chunkPart); - lineNeedsFixing = false; + // move cursor to the start of next line + cursorPos = pos + 1; } - cursorPos = lineEndPos + 1; - } + lineEndPos = pos; - if (chunk && !final && cursorPos < chunk.length) { - this.remainder = chunk.subarray(cursorPos); - } + break; - if (final) { - let chunkPart = (cursorPos && chunk && chunk.subarray(cursorPos)) || chunk; - if (chunkPart && chunkPart.length) { - this.pushBodyHash(lineNeedsFixing ? this.fixLineBuffer(chunkPart) : chunkPart); - lineNeedsFixing = false; + case CHAR_SPACE: + if (!lineNeedsFixing && pos && chunk[pos - 1] === CHAR_SPACE) { + lineNeedsFixing = true; } + break; - if (this.bodyHashedBytes) { - // terminating line break for non-empty messages - this.updateBodyHash(Buffer.from([CHAR_CR, CHAR_LF])); - } + case CHAR_TAB: + // non-space WSP always needs replacing + lineNeedsFixing = true; + break; + + default: } + } } - digest(encoding: crypto.BinaryToTextEncoding) { - this.update(null, true); + if (chunk && cursorPos < chunk.length && cursorPos !== lineEndPos) { + // emit data from chunk + + let chunkPart = chunk.subarray(cursorPos, lineEndPos + 1); + + if (chunkPart.length) { + this.pushBodyHash(lineNeedsFixing ? this.fixLineBuffer(chunkPart) : chunkPart); + lineNeedsFixing = false; + } + + cursorPos = lineEndPos + 1; + } - // finalize - return this.bodyHash.digest(encoding); + if (chunk && !final && cursorPos < chunk.length) { + this.remainder = chunk.subarray(cursorPos); } + + if (final) { + let chunkPart = (cursorPos && chunk && chunk.subarray(cursorPos)) || chunk; + if (chunkPart && chunkPart.length) { + this.pushBodyHash(lineNeedsFixing ? this.fixLineBuffer(chunkPart) : chunkPart); + lineNeedsFixing = false; + } + + if (this.bodyHashedBytes) { + // terminating line break for non-empty messages + this.updateBodyHash(Buffer.from([CHAR_CR, CHAR_LF])); + } + } + } + + digest(encoding: crypto.BinaryToTextEncoding) { + this.update(null, true); + + // finalize + return this.bodyHash.digest(encoding); + } } /* diff --git a/packages/helpers/src/lib/mailauth/body/simple.ts b/packages/helpers/src/lib/mailauth/body/simple.ts index db56c4f26..899778e68 100644 --- a/packages/helpers/src/lib/mailauth/body/simple.ts +++ b/packages/helpers/src/lib/mailauth/body/simple.ts @@ -7,101 +7,101 @@ import * as crypto from 'crypto'; * @class */ export class SimpleHash { - byteLength: number; - bodyHashedBytes: number; - private remainder: Buffer[]; - private bodyHash: crypto.Hash; - private maxBodyLength: number; - private fullBody: Buffer; - private lastNewline: boolean; - /** - * @param {String} [algorithm] Hashing algo, either "sha1" or "sha256" - * @param {Number} [maxBodyLength] Allowed body length count, the value from the l= parameter - */ - constructor(algorithm: string, maxBodyLength: number) { - algorithm = algorithm?.split('-')?.pop() || 'sha256'; - this.bodyHash = crypto.createHash(algorithm); + byteLength: number; + bodyHashedBytes: number; + private remainder: Buffer[]; + private bodyHash: crypto.Hash; + private maxBodyLength: number; + private fullBody: Buffer; + private lastNewline: boolean; + /** + * @param {String} [algorithm] Hashing algo, either "sha1" or "sha256" + * @param {Number} [maxBodyLength] Allowed body length count, the value from the l= parameter + */ + constructor(algorithm: string, maxBodyLength: number) { + algorithm = algorithm?.split('-')?.pop() || 'sha256'; + this.bodyHash = crypto.createHash(algorithm); - this.remainder = []; - this.byteLength = 0; + this.remainder = []; + this.byteLength = 0; - this.bodyHashedBytes = 0; - this.maxBodyLength = maxBodyLength; + this.bodyHashedBytes = 0; + this.maxBodyLength = maxBodyLength; - this.lastNewline = false; + this.lastNewline = false; - this.fullBody = Buffer.alloc(0); - } - - private updateBodyHash(chunk: Buffer) { - // the following is needed for l= option - if ( - typeof this.maxBodyLength === 'number' && - !isNaN(this.maxBodyLength) && - this.maxBodyLength >= 0 && - this.bodyHashedBytes + chunk.length > this.maxBodyLength - ) { - if (this.bodyHashedBytes >= this.maxBodyLength) { - // nothing to do here, skip entire chunk - return; - } - // only use allowed size of bytes - chunk = chunk.subarray(0, this.maxBodyLength - this.bodyHashedBytes); - } + this.fullBody = Buffer.alloc(0); + } - this.bodyHashedBytes += chunk.length; - this.bodyHash.update(chunk); - this.fullBody = Buffer.concat([this.fullBody, chunk]); - - //process.stdout.write(chunk); + private updateBodyHash(chunk: Buffer) { + // the following is needed for l= option + if ( + typeof this.maxBodyLength === 'number' && + !isNaN(this.maxBodyLength) && + this.maxBodyLength >= 0 && + this.bodyHashedBytes + chunk.length > this.maxBodyLength + ) { + if (this.bodyHashedBytes >= this.maxBodyLength) { + // nothing to do here, skip entire chunk + return; + } + // only use allowed size of bytes + chunk = chunk.subarray(0, this.maxBodyLength - this.bodyHashedBytes); } - update(chunk: Buffer) { - if (this.remainder.length) { - // see if we can release the last remainder - for (let i = 0; i < chunk.length; i++) { - let c = chunk[i]; - if (c !== 0x0a && c !== 0x0d) { - // found non-line terminator byte, can release previous chunk - for (let remainderChunk of this.remainder) { - this.updateBodyHash(remainderChunk); - } - this.remainder = []; - } - } - } + this.bodyHashedBytes += chunk.length; + this.bodyHash.update(chunk); + this.fullBody = Buffer.concat([this.fullBody, chunk]); - // find line terminators from the end of chunk - let matchStart: boolean | number = false; - for (let i = chunk.length - 1; i >= 0; i--) { - let c = chunk[i]; - if (c === 0x0a || c === 0x0d) { - // stop looking - matchStart = i; - } else { - break; - } - } + //process.stdout.write(chunk); + } - if (matchStart === 0) { - // nothing but newlines in this chunk - this.remainder.push(chunk); - return; - } else if (matchStart !== false) { - this.remainder.push(chunk.subarray(matchStart)); - chunk = chunk.subarray(0, matchStart); + update(chunk: Buffer) { + if (this.remainder.length) { + // see if we can release the last remainder + for (let i = 0; i < chunk.length; i++) { + let c = chunk[i]; + if (c !== 0x0a && c !== 0x0d) { + // found non-line terminator byte, can release previous chunk + for (let remainderChunk of this.remainder) { + this.updateBodyHash(remainderChunk); + } + this.remainder = []; } + } + } - this.updateBodyHash(chunk); - this.lastNewline = chunk[chunk.length - 1] === 0x0a; + // find line terminators from the end of chunk + let matchStart: boolean | number = false; + for (let i = chunk.length - 1; i >= 0; i--) { + let c = chunk[i]; + if (c === 0x0a || c === 0x0d) { + // stop looking + matchStart = i; + } else { + break; + } } - digest(encoding: crypto.BinaryToTextEncoding) { - if (!this.lastNewline || !this.bodyHashedBytes) { - // emit empty line buffer to keep the stream flowing - this.updateBodyHash(Buffer.from('\r\n')); - } + if (matchStart === 0) { + // nothing but newlines in this chunk + this.remainder.push(chunk); + return; + } else if (matchStart !== false) { + this.remainder.push(chunk.subarray(matchStart)); + chunk = chunk.subarray(0, matchStart); + } - return this.bodyHash.digest(encoding); + this.updateBodyHash(chunk); + this.lastNewline = chunk[chunk.length - 1] === 0x0a; + } + + digest(encoding: crypto.BinaryToTextEncoding) { + if (!this.lastNewline || !this.bodyHashedBytes) { + // emit empty line buffer to keep the stream flowing + this.updateBodyHash(Buffer.from('\r\n')); } + + return this.bodyHash.digest(encoding); + } } diff --git a/packages/helpers/src/lib/mailauth/dkim-verifier.ts b/packages/helpers/src/lib/mailauth/dkim-verifier.ts index 0baf0b364..779aec636 100644 --- a/packages/helpers/src/lib/mailauth/dkim-verifier.ts +++ b/packages/helpers/src/lib/mailauth/dkim-verifier.ts @@ -1,19 +1,25 @@ -const IS_BROWSER = typeof window !== "undefined"; +const IS_BROWSER = typeof window !== 'undefined'; // @ts-ignore -import addressparser from "addressparser"; -import * as crypto from "crypto"; -import { getSigningHeaderLines, getPublicKey, parseDkimHeaders, formatAuthHeaderRow, getAlignment, parseHeaders } from "./tools"; -import { MessageParser } from "./message-parser"; -import { dkimBody } from "./body"; -import { generateCanonicalizedHeader } from "./header"; - - -export type SignatureType = "DKIM" | "ARC" | "AS"; +import addressparser from 'addressparser'; +import * as crypto from 'crypto'; +import { + getSigningHeaderLines, + getPublicKey, + parseDkimHeaders, + formatAuthHeaderRow, + getAlignment, + parseHeaders, +} from './tools'; +import { MessageParser } from './message-parser'; +import { dkimBody } from './body'; +import { generateCanonicalizedHeader } from './header'; + +export type SignatureType = 'DKIM' | 'ARC' | 'AS'; export type ParsedHeaders = ReturnType; -export type Parsed = ParsedHeaders["parsed"][0]; +export type Parsed = ParsedHeaders['parsed'][0]; export type ParseDkimHeaders = ReturnType; @@ -32,7 +38,6 @@ export interface Options { bodyHashedBytes?: string; } - export class DkimVerifier extends MessageParser { envelopeFrom: string | boolean; headerFrom: string[]; @@ -43,7 +48,7 @@ export class DkimVerifier extends MessageParser { private signatureHeaders: ParseDkimHeaders[] & { [key: string]: any }[]; private bodyHashes: Map; private arc: { chain: false }; - private seal: { bodyHash: string; }; + private seal: { bodyHash: string }; private sealBodyHashKey: string = ''; constructor(options: Record) { super(); @@ -68,8 +73,8 @@ export class DkimVerifier extends MessageParser { if (this.seal) { // calculate body hash for the seal - let bodyCanon = "relaxed"; - let hashAlgo = "sha256"; + let bodyCanon = 'relaxed'; + let hashAlgo = 'sha256'; this.sealBodyHashKey = `${bodyCanon}:${hashAlgo}:`; this.bodyHashes.set(this.sealBodyHashKey, dkimBody(bodyCanon, hashAlgo, 0)); } @@ -79,17 +84,17 @@ export class DkimVerifier extends MessageParser { this.headers = headers; this.signatureHeaders = headers.parsed - .filter((h) => h.key === "dkim-signature") + .filter((h) => h.key === 'dkim-signature') .map((h) => { const value: ParseDkimHeaders & { [key: string]: any } = parseDkimHeaders(h.line); - value.type = "DKIM"; + value.type = 'DKIM'; return value; }); - let fromHeaders = headers?.parsed?.filter((h) => h.key === "from"); + let fromHeaders = headers?.parsed?.filter((h) => h.key === 'from'); for (const fromHeader of fromHeaders) { let fromHeaderString = fromHeader.line.toString(); - let splitterPos = fromHeaderString.indexOf(":"); + let splitterPos = fromHeaderString.indexOf(':'); if (splitterPos >= 0) { fromHeaderString = fromHeaderString.substr(splitterPos + 1); } @@ -105,10 +110,10 @@ export class DkimVerifier extends MessageParser { let returnPath = addressparser(this.options.sender); this.envelopeFrom = returnPath.length && returnPath[0].address ? returnPath[0].address : false; } else { - let returnPathHeader = headers.parsed.filter((h) => h.key === "return-path").pop(); + let returnPathHeader = headers.parsed.filter((h) => h.key === 'return-path').pop(); if (returnPathHeader) { let returnPathHeaderString = returnPathHeader.line.toString(); - let splitterPos = returnPathHeaderString.indexOf(":"); + let splitterPos = returnPathHeaderString.indexOf(':'); if (splitterPos >= 0) { returnPathHeaderString = returnPathHeaderString.substr(splitterPos + 1); } @@ -118,24 +123,28 @@ export class DkimVerifier extends MessageParser { } for (let signatureHeader of this.signatureHeaders) { - signatureHeader.algorithm = signatureHeader.parsed?.a?.value || ""; - signatureHeader.signAlgo = signatureHeader.algorithm.split("-").shift().toLowerCase().trim(); - signatureHeader.hashAlgo = signatureHeader.algorithm.split("-").pop().toLowerCase().trim(); + signatureHeader.algorithm = signatureHeader.parsed?.a?.value || ''; + signatureHeader.signAlgo = signatureHeader.algorithm.split('-').shift().toLowerCase().trim(); + signatureHeader.hashAlgo = signatureHeader.algorithm.split('-').pop().toLowerCase().trim(); - signatureHeader.canonicalization = signatureHeader.parsed?.c?.value || ""; - signatureHeader.headerCanon = signatureHeader.canonicalization.split("/").shift().toLowerCase().trim() || "simple"; + signatureHeader.canonicalization = signatureHeader.parsed?.c?.value || ''; + signatureHeader.headerCanon = + signatureHeader.canonicalization.split('/').shift().toLowerCase().trim() || 'simple'; // if body canonicalization is not set, then defaults to 'simple' - signatureHeader.bodyCanon = (signatureHeader.canonicalization.split("/")[1] || "simple").toLowerCase().trim(); + signatureHeader.bodyCanon = (signatureHeader.canonicalization.split('/')[1] || 'simple').toLowerCase().trim(); - signatureHeader.signingDomain = signatureHeader.parsed?.d?.value || ""; - signatureHeader.selector = signatureHeader.parsed?.s?.value || ""; + signatureHeader.signingDomain = signatureHeader.parsed?.d?.value || ''; + signatureHeader.selector = signatureHeader.parsed?.s?.value || ''; - signatureHeader.maxBodyLength = signatureHeader.parsed?.l?.value && !isNaN(signatureHeader.parsed?.l?.value) ? signatureHeader.parsed?.l?.value : ""; + signatureHeader.maxBodyLength = + signatureHeader.parsed?.l?.value && !isNaN(signatureHeader.parsed?.l?.value) + ? signatureHeader.parsed?.l?.value + : ''; - const validSignAlgo = ["rsa", "ed25519"]; - const validHeaderAlgo = signatureHeader.type === "DKIM" ? ["sha256", "sha1"] : ["sha256"]; - const validHeaderCanon = signatureHeader.type !== "AS" ? ["relaxed", "simple"] : ["relaxed"]; - const validBodyCanon = signatureHeader.type !== "AS" ? ["relaxed", "simple"] : ["relaxed"]; + const validSignAlgo = ['rsa', 'ed25519']; + const validHeaderAlgo = signatureHeader.type === 'DKIM' ? ['sha256', 'sha1'] : ['sha256']; + const validHeaderCanon = signatureHeader.type !== 'AS' ? ['relaxed', 'simple'] : ['relaxed']; + const validBodyCanon = signatureHeader.type !== 'AS' ? ['relaxed', 'simple'] : ['relaxed']; if ( !validSignAlgo.includes(signatureHeader.signAlgo) || @@ -149,9 +158,16 @@ export class DkimVerifier extends MessageParser { continue; } - signatureHeader.bodyHashKey = [signatureHeader.bodyCanon, signatureHeader.hashAlgo, signatureHeader.maxBodyLength].join(":"); + signatureHeader.bodyHashKey = [ + signatureHeader.bodyCanon, + signatureHeader.hashAlgo, + signatureHeader.maxBodyLength, + ].join(':'); if (!this.bodyHashes.has(signatureHeader.bodyHashKey)) { - this.bodyHashes.set(signatureHeader.bodyHashKey, dkimBody(signatureHeader.bodyCanon, signatureHeader.hashAlgo, signatureHeader.maxBodyLength)); + this.bodyHashes.set( + signatureHeader.bodyHashKey, + dkimBody(signatureHeader.bodyCanon, signatureHeader.hashAlgo, signatureHeader.maxBodyLength), + ); } } } @@ -170,7 +186,7 @@ export class DkimVerifier extends MessageParser { // convert bodyHashes from hash objects to base64 strings for (let [key, bodyHash] of this.bodyHashes.entries()) { - this.bodyHashes.get(key).hash = bodyHash.digest("base64"); + this.bodyHashes.get(key).hash = bodyHash.digest('base64'); } for (let signatureHeader of this.signatureHeaders) { @@ -179,12 +195,21 @@ export class DkimVerifier extends MessageParser { continue; } - let signingHeaderLines = getSigningHeaderLines((this.headers as { parsed: { key: string | null; casedKey: string | undefined; line: Buffer; }[]; original: Buffer; }).parsed, signatureHeader.parsed?.h?.value, true); + let signingHeaderLines = getSigningHeaderLines( + ( + this.headers as { + parsed: { key: string | null; casedKey: string | undefined; line: Buffer }[]; + original: Buffer; + } + ).parsed, + signatureHeader.parsed?.h?.value, + true, + ); let { canonicalizedHeader } = generateCanonicalizedHeader(signatureHeader.type, signingHeaderLines as any, { signatureHeaderLine: signatureHeader.original as string, canonicalization: signatureHeader.canonicalization, - instance: ["ARC", "AS"].includes(signatureHeader.type) ? signatureHeader.parsed?.i?.value : false, + instance: ['ARC', 'AS'].includes(signatureHeader.type) ? signatureHeader.parsed?.i?.value : false, }); let signingHeaders = { @@ -194,7 +219,7 @@ export class DkimVerifier extends MessageParser { let publicKey, rr, modulusLength; let status: { [key: string]: any } = { - result: "neutral", + result: 'neutral', comment: false, // ptype properties header: { @@ -209,18 +234,25 @@ export class DkimVerifier extends MessageParser { }, }; - if (signatureHeader.type === "DKIM" && this.headerFrom?.length) { - status.aligned = this.headerFrom?.length ? getAlignment(this.headerFrom[0] ?? ''.split("@")?.pop(), [signatureHeader.signingDomain]) : false; + if (signatureHeader.type === 'DKIM' && this.headerFrom?.length) { + status.aligned = this.headerFrom?.length + ? getAlignment(this.headerFrom[0] ?? ''.split('@')?.pop(), [signatureHeader.signingDomain]) + : false; } let bodyHashObj = this.bodyHashes.get(signatureHeader.bodyHashKey); let bodyHash = bodyHashObj?.hash; if (signatureHeader.parsed?.bh?.value !== bodyHash) { - status.result = "neutral"; + status.result = 'neutral'; status.comment = `body hash did not verify`; } else { try { - let res = await getPublicKey(signatureHeader.type, `${signatureHeader.selector}._domainkey.${signatureHeader.signingDomain}`, this.minBitLength, this.resolver); + let res = await getPublicKey( + signatureHeader.type, + `${signatureHeader.selector}._domainkey.${signatureHeader.signingDomain}`, + this.minBitLength, + this.resolver, + ); publicKey = res?.publicKey; rr = res?.rr; @@ -230,26 +262,29 @@ export class DkimVerifier extends MessageParser { let ver_result = false; if (!IS_BROWSER) { ver_result = crypto.verify( - signatureHeader.signAlgo === "rsa" ? signatureHeader.algorithm : null, + signatureHeader.signAlgo === 'rsa' ? signatureHeader.algorithm : null, canonicalizedHeader, publicKey, - Buffer.from(signatureHeader.parsed?.b?.value, "base64") + Buffer.from(signatureHeader.parsed?.b?.value, 'base64'), ); } else { - let ver = crypto.createVerify("RSA-SHA256"); + let ver = crypto.createVerify('RSA-SHA256'); ver.update(canonicalizedHeader); - ver_result = ver.verify({ key: publicKey.toString(), format: "pem" }, Buffer.from(signatureHeader.parsed?.b?.value, "base64")); + ver_result = ver.verify( + { key: publicKey.toString(), format: 'pem' }, + Buffer.from(signatureHeader.parsed?.b?.value, 'base64'), + ); } status.signedHeaders = canonicalizedHeader; - status.result = ver_result ? "pass" : "fail"; + status.result = ver_result ? 'pass' : 'fail'; - if (status?.result === "fail") { - status.comment = "bad signature"; + if (status?.result === 'fail') { + status.comment = 'bad signature'; } } catch (err: any) { status.comment = err.message; - status.result = "neutral"; + status.result = 'neutral'; } } catch (err: any) { if (err.rr) { @@ -257,37 +292,37 @@ export class DkimVerifier extends MessageParser { } switch (err.code) { - case "ENOTFOUND": - case "ENODATA": - status.result = "neutral"; + case 'ENOTFOUND': + case 'ENODATA': + status.result = 'neutral'; status.comment = `no key`; break; - case "EINVALIDVER": - status.result = "neutral"; + case 'EINVALIDVER': + status.result = 'neutral'; status.comment = `unknown key version`; break; - case "EINVALIDTYPE": - status.result = "neutral"; + case 'EINVALIDTYPE': + status.result = 'neutral'; status.comment = `unknown key type`; break; - case "EINVALIDVAL": - status.result = "neutral"; + case 'EINVALIDVAL': + status.result = 'neutral'; status.comment = `invalid public key`; break; - case "ESHORTKEY": - status.result = "policy"; + case 'ESHORTKEY': + status.result = 'policy'; if (!status.policy) { status.policy = {}; } - status.policy["dkim-rules"] = `weak-key`; + status.policy['dkim-rules'] = `weak-key`; break; default: - status.result = "temperror"; + status.result = 'temperror'; status.comment = `DNS failure: ${err.code || err.message}`; } } @@ -295,8 +330,11 @@ export class DkimVerifier extends MessageParser { signatureHeader.bodyHashedBytes = this.bodyHashes.get(signatureHeader.bodyHashKey)?.bodyHashedBytes; - if (typeof signatureHeader.maxBodyLength === "number" && signatureHeader.maxBodyLength !== signatureHeader.bodyHashedBytes) { - status.result = "fail"; + if ( + typeof signatureHeader.maxBodyLength === 'number' && + signatureHeader.maxBodyLength !== signatureHeader.bodyHashedBytes + ) { + status.result = 'fail'; status.comment = `invalid body length ${signatureHeader.bodyHashedBytes}`; } @@ -313,11 +351,11 @@ export class DkimVerifier extends MessageParser { status, }; - if (typeof signatureHeader.bodyHashedBytes === "number") { + if (typeof signatureHeader.bodyHashedBytes === 'number') { result.canonBodyLength = signatureHeader.bodyHashedBytes; } - if (typeof signatureHeader.maxBodyLength === "number") { + if (typeof signatureHeader.maxBodyLength === 'number') { result.bodyLengthCount = signatureHeader.maxBodyLength; } @@ -333,15 +371,15 @@ export class DkimVerifier extends MessageParser { result.rr = rr; } - if (typeof result.status.comment === "boolean") { + if (typeof result.status.comment === 'boolean') { delete result.status.comment; } switch (signatureHeader.type) { - case "ARC": - throw Error("ARC not possible"); + case 'ARC': + throw Error('ARC not possible'); break; - case "DKIM": + case 'DKIM': default: this.results.push(result); break; @@ -351,18 +389,22 @@ export class DkimVerifier extends MessageParser { if (!this.results.length) { this.results.push({ status: { - result: "none", - comment: "message not signed", + result: 'none', + comment: 'message not signed', }, }); } this.results.forEach((result) => { - result.info = formatAuthHeaderRow("dkim", result.status); + result.info = formatAuthHeaderRow('dkim', result.status); }); } - if (this.seal && this.bodyHashes.has(this.sealBodyHashKey) && typeof this.bodyHashes.get(this.sealBodyHashKey)?.hash === "string") { + if ( + this.seal && + this.bodyHashes.has(this.sealBodyHashKey) && + typeof this.bodyHashes.get(this.sealBodyHashKey)?.hash === 'string' + ) { this.seal.bodyHash = this.bodyHashes.get(this.sealBodyHashKey).hash; } } diff --git a/packages/helpers/src/lib/mailauth/header/index.ts b/packages/helpers/src/lib/mailauth/header/index.ts index ce07bf616..7f01c9a66 100644 --- a/packages/helpers/src/lib/mailauth/header/index.ts +++ b/packages/helpers/src/lib/mailauth/header/index.ts @@ -2,15 +2,24 @@ import { Options, SignatureType, SigningHeaderLines } from '../dkim-verifier'; import { relaxedHeaders } from './relaxed'; import { simpleHeaders } from './simple'; -export const generateCanonicalizedHeader = (type: SignatureType, signingHeaderLines: SigningHeaderLines, options: Options) => { - options = options || {}; - let canonicalization = (options.canonicalization || 'simple/simple').toString()?.split('/')?.shift()?.toLowerCase().trim(); - switch (canonicalization) { - case 'simple': - return simpleHeaders(type, signingHeaderLines, options); - case 'relaxed': - return relaxedHeaders(type, signingHeaderLines, options); - default: - throw new Error('Unknown header canonicalization'); - } +export const generateCanonicalizedHeader = ( + type: SignatureType, + signingHeaderLines: SigningHeaderLines, + options: Options, +) => { + options = options || {}; + let canonicalization = (options.canonicalization || 'simple/simple') + .toString() + ?.split('/') + ?.shift() + ?.toLowerCase() + .trim(); + switch (canonicalization) { + case 'simple': + return simpleHeaders(type, signingHeaderLines, options); + case 'relaxed': + return relaxedHeaders(type, signingHeaderLines, options); + default: + throw new Error('Unknown header canonicalization'); + } }; diff --git a/packages/helpers/src/lib/mailauth/header/relaxed.ts b/packages/helpers/src/lib/mailauth/header/relaxed.ts index e4770e5bb..faefa193f 100644 --- a/packages/helpers/src/lib/mailauth/header/relaxed.ts +++ b/packages/helpers/src/lib/mailauth/header/relaxed.ts @@ -3,68 +3,79 @@ import { formatSignatureHeaderLine, formatRelaxedLine } from '../tools'; // generate headers for signing export const relaxedHeaders = (type: SignatureType, signingHeaderLines: SigningHeaderLines, options: Options) => { - let { signatureHeaderLine, signingDomain, selector, algorithm, canonicalization, bodyHash, signTime, signature, instance, bodyHashedBytes } = options || {}; - let chunks = []; + let { + signatureHeaderLine, + signingDomain, + selector, + algorithm, + canonicalization, + bodyHash, + signTime, + signature, + instance, + bodyHashedBytes, + } = options || {}; + let chunks = []; - for (let signedHeaderLine of signingHeaderLines.headers) { - chunks.push(formatRelaxedLine(signedHeaderLine.line, '\r\n')); - } - - let opts: boolean | Record = false; + for (let signedHeaderLine of signingHeaderLines.headers) { + chunks.push(formatRelaxedLine(signedHeaderLine.line, '\r\n')); + } - if (!signatureHeaderLine) { - opts = { - a: algorithm, - c: canonicalization, - s: selector, - d: signingDomain, - h: signingHeaderLines.keys, - bh: bodyHash - }; + let opts: boolean | Record = false; - if (typeof bodyHashedBytes === 'number') { - opts.l = bodyHashedBytes; - } + if (!signatureHeaderLine) { + opts = { + a: algorithm, + c: canonicalization, + s: selector, + d: signingDomain, + h: signingHeaderLines.keys, + bh: bodyHash, + }; - if (instance) { - // ARC only - opts.i = instance; - } + if (typeof bodyHashedBytes === 'number') { + opts.l = bodyHashedBytes; + } - if (signTime) { - if (typeof signTime === 'string' || typeof signTime === 'number') { - signTime = new Date(signTime); - } + if (instance) { + // ARC only + opts.i = instance; + } - if (Object.prototype.toString.call(signTime) === '[object Date]' && signTime.toString() !== 'Invalid Date') { - // we need a unix timestamp value - signTime = Math.round(signTime.getTime() / 1000); - opts.t = signTime; - } - } + if (signTime) { + if (typeof signTime === 'string' || typeof signTime === 'number') { + signTime = new Date(signTime); + } - signatureHeaderLine = formatSignatureHeaderLine( - type, - Object.assign( - { - // make sure that b= always has a value, otherwise folding would be different - b: signature || 'a'.repeat(73) - }, - opts - ) as Record, - true - ); + if (Object.prototype.toString.call(signTime) === '[object Date]' && signTime.toString() !== 'Invalid Date') { + // we need a unix timestamp value + signTime = Math.round(signTime.getTime() / 1000); + opts.t = signTime; + } } - chunks.push( - Buffer.from( - formatRelaxedLine(signatureHeaderLine) - .toString('binary') - // remove value from b= key - .replace(/([;:\s]+b=)[^;]+/, '$1'), - 'binary' - ) + signatureHeaderLine = formatSignatureHeaderLine( + type, + Object.assign( + { + // make sure that b= always has a value, otherwise folding would be different + b: signature || 'a'.repeat(73), + }, + opts, + ) as Record, + true, ); + } + + chunks.push( + Buffer.from( + formatRelaxedLine(signatureHeaderLine) + .toString('binary') + // remove value from b= key + .replace(/([;:\s]+b=)[^;]+/, '$1'), + 'binary', + ), + ); - return { canonicalizedHeader: Buffer.concat(chunks), signatureHeaderLine, dkimHeaderOpts: opts }; + return { canonicalizedHeader: Buffer.concat(chunks), signatureHeaderLine, dkimHeaderOpts: opts }; }; diff --git a/packages/helpers/src/lib/mailauth/header/simple.ts b/packages/helpers/src/lib/mailauth/header/simple.ts index d8e28503f..3fdacb119 100644 --- a/packages/helpers/src/lib/mailauth/header/simple.ts +++ b/packages/helpers/src/lib/mailauth/header/simple.ts @@ -1,72 +1,84 @@ import type { Options, SignatureType, SigningHeaderLines } from '../dkim-verifier'; import { formatSignatureHeaderLine } from '../tools'; -const formatSimpleLine = (line: Buffer | string, suffix?: string) => Buffer.from(line.toString('binary') + (suffix ? suffix : ''), 'binary'); +const formatSimpleLine = (line: Buffer | string, suffix?: string) => + Buffer.from(line.toString('binary') + (suffix ? suffix : ''), 'binary'); // generate headers for signing export const simpleHeaders = (type: SignatureType, signingHeaderLines: SigningHeaderLines, options: Options) => { - let { signatureHeaderLine, signingDomain, selector, algorithm, canonicalization, bodyHash, signTime, signature, instance, bodyHashedBytes } = options || {}; - let chunks = []; + let { + signatureHeaderLine, + signingDomain, + selector, + algorithm, + canonicalization, + bodyHash, + signTime, + signature, + instance, + bodyHashedBytes, + } = options || {}; + let chunks = []; - for (let signedHeaderLine of signingHeaderLines.headers) { - chunks.push(formatSimpleLine(signedHeaderLine.line, '\r\n')); - } - - let opts: boolean | Record = false; + for (let signedHeaderLine of signingHeaderLines.headers) { + chunks.push(formatSimpleLine(signedHeaderLine.line, '\r\n')); + } - if (!signatureHeaderLine) { - opts = { - a: algorithm, - c: canonicalization, - s: selector, - d: signingDomain, - h: signingHeaderLines.keys, - bh: bodyHash - }; + let opts: boolean | Record = false; - if (typeof bodyHashedBytes === 'number') { - opts.l = bodyHashedBytes; - } + if (!signatureHeaderLine) { + opts = { + a: algorithm, + c: canonicalization, + s: selector, + d: signingDomain, + h: signingHeaderLines.keys, + bh: bodyHash, + }; - if (instance) { - // ARC only (should never happen thoug as simple algo is not allowed) - opts.i = instance; - } + if (typeof bodyHashedBytes === 'number') { + opts.l = bodyHashedBytes; + } - if (signTime) { - if (typeof signTime === 'string' || typeof signTime === 'number') { - signTime = new Date(signTime); - } + if (instance) { + // ARC only (should never happen thoug as simple algo is not allowed) + opts.i = instance; + } - if (Object.prototype.toString.call(signTime) === '[object Date]' && signTime.toString() !== 'Invalid Date') { - // we need a unix timestamp value - signTime = Math.round(signTime.getTime() / 1000); - opts.t = signTime; - } - } + if (signTime) { + if (typeof signTime === 'string' || typeof signTime === 'number') { + signTime = new Date(signTime); + } - signatureHeaderLine = formatSignatureHeaderLine( - type, - Object.assign( - { - // make sure that b= has a value, otherwise folding would be different - b: signature || 'a'.repeat(73) - }, - opts - ) as Record, - true - ); + if (Object.prototype.toString.call(signTime) === '[object Date]' && signTime.toString() !== 'Invalid Date') { + // we need a unix timestamp value + signTime = Math.round(signTime.getTime() / 1000); + opts.t = signTime; + } } - chunks.push( - Buffer.from( - formatSimpleLine(signatureHeaderLine) - .toString('binary') - // remove value from b= key - .replace(/([;:\s]+b=)[^;]+/, '$1'), - 'binary' - ) + signatureHeaderLine = formatSignatureHeaderLine( + type, + Object.assign( + { + // make sure that b= has a value, otherwise folding would be different + b: signature || 'a'.repeat(73), + }, + opts, + ) as Record, + true, ); + } + + chunks.push( + Buffer.from( + formatSimpleLine(signatureHeaderLine) + .toString('binary') + // remove value from b= key + .replace(/([;:\s]+b=)[^;]+/, '$1'), + 'binary', + ), + ); - return { canonicalizedHeader: Buffer.concat(chunks), signatureHeaderLine, dkimHeaderOpts: opts }; + return { canonicalizedHeader: Buffer.concat(chunks), signatureHeaderLine, dkimHeaderOpts: opts }; }; diff --git a/packages/helpers/src/lib/mailauth/message-parser.ts b/packages/helpers/src/lib/mailauth/message-parser.ts index 9f70cbcfc..8f02756d2 100644 --- a/packages/helpers/src/lib/mailauth/message-parser.ts +++ b/packages/helpers/src/lib/mailauth/message-parser.ts @@ -1,7 +1,7 @@ // Calculates relaxed body hash for a message body stream import { Writable, WritableOptions } from 'stream'; import { parseHeaders } from './tools'; -import type { ParsedHeaders } from "./dkim-verifier"; +import type { ParsedHeaders } from './dkim-verifier'; /** * Class for separating header from body @@ -10,143 +10,143 @@ import type { ParsedHeaders } from "./dkim-verifier"; * @extends Writable */ export class MessageParser extends Writable { - byteLength: number; - headers: ParsedHeaders | boolean; - private state: string; - private stateBytes: unknown[]; - private headerChunks: Buffer[]; - private lastByte: number = 0; - constructor(options?: WritableOptions) { - super(options); - - this.byteLength = 0; - - this.state = 'header'; - this.stateBytes = []; - - this.headers = false; - this.headerChunks = []; + byteLength: number; + headers: ParsedHeaders | boolean; + private state: string; + private stateBytes: unknown[]; + private headerChunks: Buffer[]; + private lastByte: number = 0; + constructor(options?: WritableOptions) { + super(options); + + this.byteLength = 0; + + this.state = 'header'; + this.stateBytes = []; + + this.headers = false; + this.headerChunks = []; + } + + async nextChunk(...args: any) { + // Override in child class + } + + async finalChunk(...args: any) { + // Override in child class + } + + async messageHeaders(headers: ParsedHeaders) { + // Override in child class + } + + async processChunk(chunk: Buffer) { + if (!chunk || !chunk.length) { + return; } - async nextChunk(...args: any) { - // Override in child class - } - - async finalChunk(...args: any) { - // Override in child class - } - - async messageHeaders(headers: ParsedHeaders) { - // Override in child class - } - - async processChunk(chunk: Buffer) { - if (!chunk || !chunk.length) { - return; + if (this.state === 'header') { + // wait until we have found body part + for (let i = 0; i < chunk.length; i++) { + let c = chunk[i]; + this.stateBytes.push(c); + if (this.stateBytes.length > 4) { + this.stateBytes = this.stateBytes.slice(-4); } - if (this.state === 'header') { - // wait until we have found body part - for (let i = 0; i < chunk.length; i++) { - let c = chunk[i]; - this.stateBytes.push(c); - if (this.stateBytes.length > 4) { - this.stateBytes = this.stateBytes.slice(-4); - } - - let b0 = this.stateBytes[this.stateBytes.length - 1]; - let b1 = this.stateBytes.length > 1 && this.stateBytes[this.stateBytes.length - 2]; - let b2 = this.stateBytes.length > 2 && this.stateBytes[this.stateBytes.length - 3]; - - if (b0 === 0x0a && (b1 === 0x0a || (b1 === 0x0d && b2 === 0x0a))) { - // found header ending - this.state = 'body'; - if (i === chunk.length - 1) { - //end of chunk - this.headerChunks.push(chunk); - this.headers = parseHeaders(Buffer.concat(this.headerChunks)); - await this.messageHeaders(this.headers); - return; - } - this.headerChunks.push(chunk.subarray(0, i + 1)); - this.headers = parseHeaders(Buffer.concat(this.headerChunks)); - await this.messageHeaders(this.headers); - chunk = chunk.subarray(i + 1); - break; - } - } - } + let b0 = this.stateBytes[this.stateBytes.length - 1]; + let b1 = this.stateBytes.length > 1 && this.stateBytes[this.stateBytes.length - 2]; + let b2 = this.stateBytes.length > 2 && this.stateBytes[this.stateBytes.length - 3]; - if (this.state !== 'body') { + if (b0 === 0x0a && (b1 === 0x0a || (b1 === 0x0d && b2 === 0x0a))) { + // found header ending + this.state = 'body'; + if (i === chunk.length - 1) { + //end of chunk this.headerChunks.push(chunk); + this.headers = parseHeaders(Buffer.concat(this.headerChunks)); + await this.messageHeaders(this.headers); return; + } + this.headerChunks.push(chunk.subarray(0, i + 1)); + this.headers = parseHeaders(Buffer.concat(this.headerChunks)); + await this.messageHeaders(this.headers); + chunk = chunk.subarray(i + 1); + break; } - - await this.nextChunk(chunk); + } } - *ensureLinebreaks(input: Buffer) { - let pos = 0; - for (let i = 0; i < input.length; i++) { - let c = input[i]; - if (c !== 0x0a) { - this.lastByte = c; - } else if (this.lastByte !== 0x0d) { - // emit line break - let buf; - if (i === 0 || pos === i) { - buf = Buffer.from('\r\n'); - } else { - buf = Buffer.concat([input.subarray(pos, i), Buffer.from('\r\n')]); - } - yield buf; - - pos = i + 1; - } - } - if (pos === 0) { - yield input; - } else if (pos < input.length) { - let buf = input.subarray(pos); - yield buf; - } + if (this.state !== 'body') { + this.headerChunks.push(chunk); + return; } - async writeAsync(chunk: any, encoding: BufferEncoding) { - if (!chunk || !chunk.length) { - return; - } - - if (typeof chunk === 'string') { - chunk = Buffer.from(chunk, encoding); + await this.nextChunk(chunk); + } + + *ensureLinebreaks(input: Buffer) { + let pos = 0; + for (let i = 0; i < input.length; i++) { + let c = input[i]; + if (c !== 0x0a) { + this.lastByte = c; + } else if (this.lastByte !== 0x0d) { + // emit line break + let buf; + if (i === 0 || pos === i) { + buf = Buffer.from('\r\n'); + } else { + buf = Buffer.concat([input.subarray(pos, i), Buffer.from('\r\n')]); } + yield buf; - for (let partialChunk of this.ensureLinebreaks(chunk)) { - // separate chunk is emitted for every line that uses \n instead of \r\n - await this.processChunk(partialChunk); - this.byteLength += partialChunk.length; - } + pos = i + 1; + } + } + if (pos === 0) { + yield input; + } else if (pos < input.length) { + let buf = input.subarray(pos); + yield buf; } + } - _write(chunk: any, encoding: BufferEncoding, callback: (error?: Error | null) => void) { - this.writeAsync(chunk, encoding) - .then(() => callback()) - .catch(err => callback(err)); + async writeAsync(chunk: any, encoding: BufferEncoding) { + if (!chunk || !chunk.length) { + return; } - async finish() { - // generate final hash and emit it - await this.finalChunk(); + if (typeof chunk === 'string') { + chunk = Buffer.from(chunk, encoding); + } - if (!this.headers && this.headerChunks.length) { - this.headers = parseHeaders(Buffer.concat(this.headerChunks)); - await this.messageHeaders(this.headers); - } + for (let partialChunk of this.ensureLinebreaks(chunk)) { + // separate chunk is emitted for every line that uses \n instead of \r\n + await this.processChunk(partialChunk); + this.byteLength += partialChunk.length; } + } - _final(callback: (error?: Error | null) => void) { - this.finish() - .then(() => callback()) - .catch(err => callback(err)); + _write(chunk: any, encoding: BufferEncoding, callback: (error?: Error | null) => void) { + this.writeAsync(chunk, encoding) + .then(() => callback()) + .catch((err) => callback(err)); + } + + async finish() { + // generate final hash and emit it + await this.finalChunk(); + + if (!this.headers && this.headerChunks.length) { + this.headers = parseHeaders(Buffer.concat(this.headerChunks)); + await this.messageHeaders(this.headers); } + } + + _final(callback: (error?: Error | null) => void) { + this.finish() + .then(() => callback()) + .catch((err) => callback(err)); + } } diff --git a/packages/helpers/src/lib/mailauth/parse-dkim-headers.ts b/packages/helpers/src/lib/mailauth/parse-dkim-headers.ts index b52d4c55c..83935555c 100644 --- a/packages/helpers/src/lib/mailauth/parse-dkim-headers.ts +++ b/packages/helpers/src/lib/mailauth/parse-dkim-headers.ts @@ -1,308 +1,311 @@ // NB! fails to properly parse nested comments (should be rare enough though) interface Part { - [key: string]: string; + [key: string]: string; } const valueParser = (str: string) => { - let line = str.replace(/\s+/g, ' ').trim(); - - let parts: Part[] = []; - let lastState: string | boolean = false; - - const createPart = () => { - let part: Part = { - key: '', - value: '' - }; - parts.push(part); - return part; - }; + let line = str.replace(/\s+/g, ' ').trim(); + + let parts: Part[] = []; + let lastState: string | boolean = false; - const parse = () => { - let state = 'key'; - let escaped; - let quote; - - let curPart = createPart(); - - for (let i = 0; i < line.length; i++) { - let c = line.charAt(i); - - switch (state) { - // @ts-ignore - case 'key': - if (c === '=') { - state = 'value'; - break; - } - // falls through - - case 'value': { - if (escaped === true) { - curPart[state] += c; - break; - } - - switch (c) { - case ' ': - // start new part - curPart = createPart(); - state = 'key'; - break; - - case '\\': - escaped = true; - break; - - case '"': - case "'": - lastState = state; - state = 'quoted'; - quote = c; - break; - - default: - curPart[state] += c; - break; - } - - break; - } - - case 'quoted': - if (escaped === true && typeof lastState === 'string') { - curPart[lastState] += c; - break; - } - - switch (c) { - case '\\': - escaped = true; - break; - - case quote: - state = lastState as string; - break; - - default: - if (typeof lastState === 'string') { - curPart[lastState] += c; - } - break; - } - - break; - } + const createPart = () => { + let part: Part = { + key: '', + value: '', + }; + parts.push(part); + return part; + }; + + const parse = () => { + let state = 'key'; + let escaped; + let quote; + + let curPart = createPart(); + + for (let i = 0; i < line.length; i++) { + let c = line.charAt(i); + + switch (state) { + // @ts-ignore + case 'key': + if (c === '=') { + state = 'value'; + break; + } + // falls through + + case 'value': { + if (escaped === true) { + curPart[state] += c; + break; + } + + switch (c) { + case ' ': + // start new part + curPart = createPart(); + state = 'key'; + break; + + case '\\': + escaped = true; + break; + + case '"': + case "'": + lastState = state; + state = 'quoted'; + quote = c; + break; + + default: + curPart[state] += c; + break; + } + + break; } - let result: { [key: string]: any } = { - value: parts[0].key - }; - parts.slice(1).forEach(part => { - if (part.key || part.value) { - let path = part.key.split('.'); - let curRes = result; - let final = path.pop(); - for (let p of path) { - if (typeof curRes[p] !== 'object' || !curRes[p]) { - curRes[p] = {}; - } - curRes = curRes[p]; - } - curRes[final ?? ''] = part.value; - } - }); - - return result; + case 'quoted': + if (escaped === true && typeof lastState === 'string') { + curPart[lastState] += c; + break; + } + + switch (c) { + case '\\': + escaped = true; + break; + + case quote: + state = lastState as string; + break; + + default: + if (typeof lastState === 'string') { + curPart[lastState] += c; + } + break; + } + + break; + } + } + + let result: { [key: string]: any } = { + value: parts[0].key, }; + parts.slice(1).forEach((part) => { + if (part.key || part.value) { + let path = part.key.split('.'); + let curRes = result; + let final = path.pop(); + for (let p of path) { + if (typeof curRes[p] !== 'object' || !curRes[p]) { + curRes[p] = {}; + } + curRes = curRes[p]; + } + curRes[final ?? ''] = part.value; + } + }); + + return result; + }; - return parse(); + return parse(); }; const headerParser = (buf: Buffer | string) => { - let line = (buf || '').toString().trim(); - let splitterPos = line.indexOf(':'); - let headerKey: string; - if (splitterPos >= 0) { - headerKey = line.substr(0, splitterPos).trim().toLowerCase(); - line = line.substr(splitterPos + 1).trim(); + let line = (buf || '').toString().trim(); + let splitterPos = line.indexOf(':'); + let headerKey: string; + if (splitterPos >= 0) { + headerKey = line.substr(0, splitterPos).trim().toLowerCase(); + line = line.substr(splitterPos + 1).trim(); + } + + let parts: { [key: string]: any }[] = []; + let lastState: string | boolean = false; + + const createPart = (): { [key: string]: string | boolean } => { + let part = { + key: '', + value: '', + comment: '', + hasValue: false, + }; + parts.push(part); + return part; + }; + + const parse = () => { + let state = 'key'; + let escaped; + let quote; + + let curPart = createPart(); + + for (let i = 0; i < line.length; i++) { + let c = line.charAt(i); + + switch (state) { + // @ts-ignore + case 'key': + if (c === '=') { + state = 'value'; + curPart.hasValue = true; + break; + } + // falls through + + case 'value': { + if (escaped === true) { + curPart[state] += c; + } + + switch (c) { + case ';': + // start new part + curPart = createPart(); + state = 'key'; + break; + + case '\\': + escaped = true; + break; + + case '(': + lastState = state; + state = 'comment'; + break; + + case '"': + case "'": + lastState = state; + curPart[state] += c; + state = 'quoted'; + quote = c; + break; + + default: + curPart[state] += c; + break; + } + + break; + } + + case 'comment': + switch (c) { + case '\\': + escaped = true; + break; + + case ')': + state = lastState as string; + break; + + default: + curPart[state] += c; + break; + } + + break; + + case 'quoted': + switch (c) { + case '\\': + escaped = true; + break; + // @ts-ignore + case quote: + state = lastState as string; + // falls through + + default: + if (typeof lastState === 'string') { + curPart[lastState] += c; + } + break; + } + + break; + } + } + + for (let i = parts.length - 1; i >= 0; i--) { + for (let key of Object.keys(parts[i])) { + if (typeof parts[i][key] === 'string') { + parts[i][key] = parts[i][key].replace(/\s+/g, ' ').trim(); + } + } + + parts[i].key = parts[i].key.toLowerCase(); + + if (!parts[i].key) { + // remove empty value + parts.splice(i, 1); + } else if (['bh', 'b', 'p', 'h'].includes(parts[i].key)) { + // remove unneeded whitespace + parts[i].value = parts[i].value?.replace(/\s+/g, ''); + } else if (['l', 'v', 't'].includes(parts[i].key) && !isNaN(parts[i].value)) { + parts[i].value = Number(parts[i].value); + } else if (parts[i].key === 'i' && /^arc-/i.test(headerKey)) { + parts[i].value = Number(parts[i].value); + } } - let parts: { [key: string]: any }[] = []; - let lastState: string | boolean = false; - - const createPart = (): { [key: string]: string | boolean } => { - let part = { - key: '', - value: '', - comment: '', - hasValue: false - }; - parts.push(part); - return part; + let result: { [key: string]: any } = { + header: headerKey, }; - const parse = () => { - let state = 'key'; - let escaped; - let quote; - - let curPart = createPart(); - - for (let i = 0; i < line.length; i++) { - let c = line.charAt(i); - - switch (state) { - // @ts-ignore - case 'key': - if (c === '=') { - state = 'value'; - curPart.hasValue = true; - break; - } - // falls through - - case 'value': { - if (escaped === true) { - curPart[state] += c; - } - - switch (c) { - case ';': - // start new part - curPart = createPart(); - state = 'key'; - break; - - case '\\': - escaped = true; - break; - - case '(': - lastState = state; - state = 'comment'; - break; - - case '"': - case "'": - lastState = state; - curPart[state] += c; - state = 'quoted'; - quote = c; - break; - - default: - curPart[state] += c; - break; - } - - break; - } - - case 'comment': - switch (c) { - case '\\': - escaped = true; - break; - - case ')': - state = lastState as string; - break; - - default: - curPart[state] += c; - break; - } - - break; - - case 'quoted': - switch (c) { - case '\\': - escaped = true; - break; - // @ts-ignore - case quote: - state = lastState as string; - // falls through - - default: - if (typeof lastState === 'string') { - curPart[lastState] += c; - } - break; - } - - break; - } - } + for (let i = 0; i < parts.length; i++) { + // find the first entry with key only and use it as the default value + if (parts[i].key && !parts[i].hasValue) { + result.value = parts[i].key; + parts.splice(i, 1); + break; + } + } - for (let i = parts.length - 1; i >= 0; i--) { - for (let key of Object.keys(parts[i])) { - if (typeof parts[i][key] === 'string') { - parts[i][key] = parts[i][key].replace(/\s+/g, ' ').trim(); - } - } - - parts[i].key = (parts[i].key).toLowerCase(); - - if (!parts[i].key) { - // remove empty value - parts.splice(i, 1); - } else if (['bh', 'b', 'p', 'h'].includes(parts[i].key)) { - // remove unneeded whitespace - parts[i].value = parts[i].value?.replace(/\s+/g, ''); - } else if (['l', 'v', 't'].includes(parts[i].key) && !isNaN(parts[i].value)) { - parts[i].value = Number(parts[i].value); - } else if (parts[i].key === 'i' && /^arc-/i.test(headerKey)) { - parts[i].value = Number(parts[i].value); - } + parts.forEach((part) => { + let entry: { [key: string]: any } = { + value: part.value, + }; + + if ( + ['arc-authentication-results', 'authentication-results'].includes(headerKey) && + typeof part.value === 'string' + ) { + // parse value into subparts as well + entry = Object.assign(entry, valueParser(entry.value)); + } + + if (part.comment) { + entry.comment = part.comment; + } + + if (['arc-authentication-results', 'authentication-results'].includes(headerKey) && part.key === 'dkim') { + if (!result[part.key]) { + result[part.key] = []; } - - let result: { [key: string]: any } = { - header: headerKey - }; - - for (let i = 0; i < parts.length; i++) { - // find the first entry with key only and use it as the default value - if (parts[i].key && !parts[i].hasValue) { - result.value = parts[i].key; - parts.splice(i, 1); - break; - } + if (Array.isArray(result[part.key])) { + result[part.key].push(entry); } + } else { + result[part.key] = entry; + } + }); - parts.forEach(part => { - let entry: { [key: string]: any } = { - value: part.value - }; - - if (['arc-authentication-results', 'authentication-results'].includes(headerKey) && typeof part.value === 'string') { - // parse value into subparts as well - entry = Object.assign(entry, valueParser(entry.value)); - } - - if (part.comment) { - entry.comment = part.comment; - } - - if (['arc-authentication-results', 'authentication-results'].includes(headerKey) && part.key === 'dkim') { - if (!result[part.key]) { - result[part.key] = []; - } - if (Array.isArray(result[part.key])) { - result[part.key].push(entry); - } - } else { - result[part.key] = entry; - } - }); - - return result; - }; + return result; + }; - return { parsed: parse(), original: buf }; + return { parsed: parse(), original: buf }; }; -export default headerParser; \ No newline at end of file +export default headerParser; diff --git a/packages/helpers/src/lib/mailauth/tools.ts b/packages/helpers/src/lib/mailauth/tools.ts index cf3beaeac..3b0b2298a 100644 --- a/packages/helpers/src/lib/mailauth/tools.ts +++ b/packages/helpers/src/lib/mailauth/tools.ts @@ -1,50 +1,35 @@ // @ts-ignore -import libmime from "libmime"; +import libmime from 'libmime'; // @ts-ignore -import psl from "psl"; -import { setImmediate } from "timers"; -import { pki } from "node-forge"; -import punycode from "punycode"; -import crypto, { KeyObject } from "crypto"; -import parseDkimHeaders from "./parse-dkim-headers"; -import { DkimVerifier } from "./dkim-verifier"; -import type { Parsed, SignatureType } from "./dkim-verifier"; +import psl from 'psl'; +import { setImmediate } from 'timers'; +import { pki } from 'node-forge'; +import punycode from 'punycode'; +import crypto, { KeyObject } from 'crypto'; +import parseDkimHeaders from './parse-dkim-headers'; +import { DkimVerifier } from './dkim-verifier'; +import type { Parsed, SignatureType } from './dkim-verifier'; -const IS_BROWSER = typeof window !== "undefined"; +const IS_BROWSER = typeof window !== 'undefined'; export const defaultDKIMFieldNames = - "From:Sender:Reply-To:Subject:Date:Message-ID:To:" + - "Cc:MIME-Version:Content-Type:Content-Transfer-Encoding:Content-ID:" + - "Content-Description:Resent-Date:Resent-From:Resent-Sender:" + - "Resent-To:Resent-Cc:Resent-Message-ID:In-Reply-To:References:" + - "List-Id:List-Help:List-Unsubscribe:List-Subscribe:List-Post:" + - "List-Owner:List-Archive:BIMI-Selector"; - -const keyOrderingDKIM = [ - "v", - "a", - "c", - "d", - "h", - "i", - "l", - "q", - "s", - "t", - "x", - "z", - "bh", - "b", -]; + 'From:Sender:Reply-To:Subject:Date:Message-ID:To:' + + 'Cc:MIME-Version:Content-Type:Content-Transfer-Encoding:Content-ID:' + + 'Content-Description:Resent-Date:Resent-From:Resent-Sender:' + + 'Resent-To:Resent-Cc:Resent-Message-ID:In-Reply-To:References:' + + 'List-Id:List-Help:List-Unsubscribe:List-Subscribe:List-Post:' + + 'List-Owner:List-Archive:BIMI-Selector'; + +const keyOrderingDKIM = ['v', 'a', 'c', 'd', 'h', 'i', 'l', 'q', 's', 't', 'x', 'z', 'bh', 'b']; export const writeToStream = async ( stream: DkimVerifier, input: Buffer & { pipe: (...args: any) => void; on: (...args: any) => void }, - chunkSize: number = 0 + chunkSize: number = 0, ) => { chunkSize = chunkSize || 64 * 1024; - if (typeof input === "string") { + if (typeof input === 'string') { input = Buffer.from(input) as Buffer & { pipe: (...args: any) => void; on: (...args: any) => void; @@ -52,11 +37,11 @@ export const writeToStream = async ( } return new Promise((resolve, reject) => { - if (typeof input?.on === "function") { + if (typeof input?.on === 'function') { // pipe as stream - console.log("pipe"); + console.log('pipe'); input.pipe(stream); - input.on("error", reject); + input.on('error', reject); } else { let pos = 0; let writeChunk = () => { @@ -73,7 +58,7 @@ export const writeToStream = async ( pos += chunk.length; if (stream.write(chunk) === false) { - stream.once("drain", () => writeChunk()); + stream.once('drain', () => writeChunk()); return; } setImmediate(writeChunk); @@ -81,16 +66,16 @@ export const writeToStream = async ( setImmediate(writeChunk); } - stream.on("end", resolve); - stream.on("finish", resolve); - stream.on("error", reject); + stream.on('end', resolve); + stream.on('finish', resolve); + stream.on('error', reject); }); }; export const parseHeaders = (buf: Buffer) => { let rows: string[][] = buf - .toString("binary") - .replace(/[\r\n]+$/, "") + .toString('binary') + .replace(/[\r\n]+$/, '') .split(/\r?\n/) .map((row) => [row]); for (let i = rows.length - 1; i >= 0; i--) { @@ -105,7 +90,7 @@ export const parseHeaders = (buf: Buffer) => { casedKey: string | undefined; line: Buffer; }[] = rows.map((row) => { - const str = row.join("\r\n"); + const str = row.join('\r\n'); let key: RegExpMatchArray | string | null = str.match(/^[^:]+/); let casedKey; if (key) { @@ -113,21 +98,15 @@ export const parseHeaders = (buf: Buffer) => { key = casedKey.toLowerCase(); } - return { key, casedKey, line: Buffer.from(str, "binary") }; + return { key, casedKey, line: Buffer.from(str, 'binary') }; }); return { parsed: mappedRows, original: buf }; }; -export const getSigningHeaderLines = ( - parsedHeaders: Parsed[], - fieldNames: string | string[], - verify: boolean -) => { - fieldNames = ( - typeof fieldNames === "string" ? fieldNames : defaultDKIMFieldNames - ) - .split(":") +export const getSigningHeaderLines = (parsedHeaders: Parsed[], fieldNames: string | string[], verify: boolean) => { + fieldNames = (typeof fieldNames === 'string' ? fieldNames : defaultDKIMFieldNames) + .split(':') .map((key) => key.trim().toLowerCase()) .filter((key) => key); @@ -148,14 +127,14 @@ export const getSigningHeaderLines = ( } else { for (let i = parsedHeaders.length - 1; i >= 0; i--) { let header = parsedHeaders[i]; - if (fieldNames.includes(header.key ?? "")) { + if (fieldNames.includes(header.key ?? '')) { signingList.push(header); } } } return { - keys: signingList.map((entry) => entry.casedKey).join(": "), + keys: signingList.map((entry) => entry.casedKey).join(': '), headers: signingList, }; }; @@ -167,31 +146,31 @@ export const getSigningHeaderLines = ( export const formatSignatureHeaderLine = ( type: SignatureType, values: Record, - folded: boolean + folded: boolean, ): string => { - type = (type ?? "").toString().toUpperCase() as SignatureType; + type = (type ?? '').toString().toUpperCase() as SignatureType; let keyOrdering: string[], headerKey: string; switch (type) { - case "DKIM": - headerKey = "DKIM-Signature"; + case 'DKIM': + headerKey = 'DKIM-Signature'; keyOrdering = keyOrderingDKIM; values = Object.assign( { v: 1, t: Math.round(Date.now() / 1000), - q: "dns/txt", + q: 'dns/txt', }, - values + values, ); break; - case "ARC": - case "AS": - throw Error("err"); + case 'ARC': + case 'AS': + throw Error('err'); default: - throw new Error("Unknown Signature type"); + throw new Error('Unknown Signature type'); } const header = @@ -200,19 +179,19 @@ export const formatSignatureHeaderLine = ( .filter( (key) => values[key] !== false && - typeof values[key] !== "undefined" && + typeof values[key] !== 'undefined' && values.key !== null && - keyOrdering.includes(key) + keyOrdering.includes(key), ) .sort((a, b) => keyOrdering.indexOf(a) - keyOrdering.indexOf(b)) .map((key) => { - let val = values[key] ?? ""; - if (key === "b" && folded && val) { + let val = values[key] ?? ''; + if (key === 'b' && folded && val) { // fold signature value - return `${key}=${val}`.replace(/.{75}/g, "$& ").trim(); + return `${key}=${val}`.replace(/.{75}/g, '$& ').trim(); } - if (["d", "s"].includes(key) && typeof val === "string") { + if (['d', 's'].includes(key) && typeof val === 'string') { try { // convert to A-label if needed val = punycode.toASCII(val); @@ -221,8 +200,8 @@ export const formatSignatureHeaderLine = ( } } - if (key === "i" && type === "DKIM" && typeof val === "string") { - let atPos = val.indexOf("@"); + if (key === 'i' && type === 'DKIM' && typeof val === 'string') { + let atPos = val.indexOf('@'); if (atPos >= 0) { let domainPart = val.substr(atPos + 1); try { @@ -237,7 +216,7 @@ export const formatSignatureHeaderLine = ( return `${key}=${val}`; }) - .join("; "); + .join('; '); if (folded) { return libmime.foldLines(header); @@ -258,26 +237,23 @@ function str2ab(str: string) { function importRsaKey(pem: string) { // fetch the part of the PEM string between header and footer - const pemHeader = "-----BEGIN PUBLIC KEY-----"; - const pemFooter = "-----END PUBLIC KEY-----"; - const pemContents = pem.substring( - pemHeader.length, - pem.length - pemFooter.length - ); + const pemHeader = '-----BEGIN PUBLIC KEY-----'; + const pemFooter = '-----END PUBLIC KEY-----'; + const pemContents = pem.substring(pemHeader.length, pem.length - pemFooter.length); // base64 decode the string to get the binary data const binaryDerString = window.atob(pemContents); // convert from a binary string to an ArrayBuffer const binaryDer = str2ab(binaryDerString); return window.crypto.subtle.importKey( - "spki", + 'spki', binaryDer, { - name: "RSA-OAEP", - hash: "SHA-256", + name: 'RSA-OAEP', + hash: 'SHA-256', }, true, - ["encrypt"] + ['encrypt'], ); } @@ -285,29 +261,29 @@ export const getPublicKey = async ( type: string, name: string, minBitLength: number, - resolver: (...args: [name: string, type: string]) => Promise + resolver: (...args: [name: string, type: string]) => Promise, ) => { minBitLength = minBitLength || 1024; - let list = await resolver(name, "TXT"); + let list = await resolver(name, 'TXT'); let rr = list && [] .concat(list[0] || []) - .join("") - .replaceAll(/\s+/g, "") - .replaceAll('"', ""); + .join('') + .replaceAll(/\s+/g, '') + .replaceAll('"', ''); if (rr) { // prefix value for parsing as there is no default value - let entry = parseDkimHeaders("DNS: TXT;" + rr); + let entry = parseDkimHeaders('DNS: TXT;' + rr); const publicKeyValue = entry?.parsed?.p?.value; //'v=DKIM1;p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwe34ubzrMzM9sT0XVkcc3UXd7W+EHCyHoqn70l2AxXox52lAZzH/UnKwAoO+5qsuP7T9QOifIJ9ddNH9lEQ95Y/GdHBsPLGdgSJIs95mXNxscD6MSyejpenMGL9TPQAcxfqY5xPViZ+1wA1qcryjdZKRqf1f4fpMY+x3b8k7H5Qyf/Smz0sv4xFsx1r+THNIz0rzk2LO3GvE0f1ybp6P+5eAelYU4mGeZQqsKw/eB20I3jHWEyGrXuvzB67nt6ddI+N2eD5K38wg/aSytOsb5O+bUSEe7P0zx9ebRRVknCD6uuqG3gSmQmttlD5OrMWSXzrPIXe8eTBaaPd+e/jfxwIDAQAB' // v=DKIM1;p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwe34ubzrMzM9sT0XVkcc3UXd7W+EHCyHoqn70l2AxXox52lAZzH/UnKwAoO+5qsuP7T9QOifIJ9ddNH9lEQ95Y/GdHBsPLGdgSJIs95mXNxscD6MSyejpenMGL9TPQAcxfqY5xPViZ+1wA1qcr""yjdZKRqf1f4fpMY+x3b8k7H5Qyf/Smz0sv4xFsx1r+THNIz0rzk2LO3GvE0f1ybp6P+5eAelYU4mGeZQqsKw/eB20I3jHWEyGrXuvzB67nt6ddI+N2eD5K38wg/aSytOsb5O+bUSEe7P0zx9ebRRVknCD6uuqG3gSmQmttlD5OrMWSXzrPIXe8eTBaaPd+e/jfxwIDAQAB if (!publicKeyValue) { - const err = new CustomError("Missing key value", "EINVALIDVAL", rr); + const err = new CustomError('Missing key value', 'EINVALIDVAL', rr); throw err; } @@ -321,28 +297,27 @@ export const getPublicKey = async ( }*/ if ( - type === "DKIM" && + type === 'DKIM' && entry?.parsed?.v && - (entry?.parsed?.v?.value || "").toString().toLowerCase().trim() !== - "dkim1" + (entry?.parsed?.v?.value || '').toString().toLowerCase().trim() !== 'dkim1' ) { - const err = new CustomError("Unknown key version", "EINVALIDVER", rr); + const err = new CustomError('Unknown key version', 'EINVALIDVER', rr); throw err; } - let paddingNeeded = - publicKeyValue.length % 4 ? 4 - (publicKeyValue.length % 4) : 0; + let paddingNeeded = publicKeyValue.length % 4 ? 4 - (publicKeyValue.length % 4) : 0; const publicKeyPem = Buffer.from( - `-----BEGIN PUBLIC KEY-----\n${( - publicKeyValue + "=".repeat(paddingNeeded) - ).replace(/.{64}/g, "$&\n")}\n-----END PUBLIC KEY-----` + `-----BEGIN PUBLIC KEY-----\n${(publicKeyValue + '='.repeat(paddingNeeded)).replace( + /.{64}/g, + '$&\n', + )}\n-----END PUBLIC KEY-----`, ); let publicKeyObj; if (!IS_BROWSER) { publicKeyObj = crypto.createPublicKey({ key: publicKeyPem, - format: "pem", + format: 'pem', }); } else { publicKeyObj = await importRsaKey(publicKeyPem.toString()); @@ -352,27 +327,19 @@ export const getPublicKey = async ( if (!IS_BROWSER) { keyType = (publicKeyObj as KeyObject).asymmetricKeyType; } else { - keyType = (publicKeyObj as CryptoKey).algorithm.name - .split("-")[0] - .toLowerCase(); + keyType = (publicKeyObj as CryptoKey).algorithm.name.split('-')[0].toLowerCase(); } if ( - !["rsa", "ed25519"].includes(keyType ?? "") || + !['rsa', 'ed25519'].includes(keyType ?? '') || (entry?.parsed?.k && entry?.parsed?.k?.value?.toLowerCase() !== keyType) ) { - throw new CustomError( - "Unknown key type (${keyType})", - "EINVALIDTYPE", - rr - ); + throw new CustomError('Unknown key type (${keyType})', 'EINVALIDTYPE', rr); } let modulusLength; if ((publicKeyObj as CryptoKey).algorithm) { - modulusLength = ( - publicKeyObj as CryptoKey & { algorithm: { modulusLength: number } } - ).algorithm?.modulusLength; + modulusLength = (publicKeyObj as CryptoKey & { algorithm: { modulusLength: number } }).algorithm?.modulusLength; } else { // fall back to node-forge const pubKeyData = pki.publicKeyFromPem(publicKeyPem.toString()); @@ -380,8 +347,8 @@ export const getPublicKey = async ( modulusLength = pubKeyData.n.bitLength(); } - if (keyType === "rsa" && modulusLength < 1024) { - throw new CustomError("RSA key too short", "ESHORTKEY", rr); + if (keyType === 'rsa' && modulusLength < 1024) { + throw new CustomError('RSA key too short', 'ESHORTKEY', rr); } return { @@ -391,14 +358,14 @@ export const getPublicKey = async ( }; } - throw new CustomError("Missing key value", "EINVALIDVAL", rr); + throw new CustomError('Missing key value', 'EINVALIDVAL', rr); }; export const escapePropValue = (value: string) => { - value = (value || "") + value = (value || '') .toString() - .replace(/[\x00-\x1F]+/g, " ") - .replace(/\s+/g, " ") + .replace(/[\x00-\x1F]+/g, ' ') + .replace(/\s+/g, ' ') .trim(); if (!/[\s\x00-\x1F\x7F-\uFFFF()<>,;:\\"/[\]?=]/.test(value)) { @@ -411,30 +378,27 @@ export const escapePropValue = (value: string) => { }; export const escapeCommentValue = (value: string) => { - value = (value || "") + value = (value || '') .toString() - .replace(/[\x00-\x1F]+/g, " ") - .replace(/\s+/g, " ") + .replace(/[\x00-\x1F]+/g, ' ') + .replace(/\s+/g, ' ') .trim(); return `${value.replace(/[\\)]/g, (c) => `\\${c}`)}`; }; -export const formatAuthHeaderRow = ( - method: string, - status: Record -) => { +export const formatAuthHeaderRow = (method: string, status: Record) => { status = status || {}; let parts = []; - parts.push(`${method}=${status.result || "none"}`); + parts.push(`${method}=${status.result || 'none'}`); if (status.comment) { parts.push(`(${escapeCommentValue(status.comment)})`); } - for (let ptype of ["policy", "smtp", "body", "header"]) { - if (!status[ptype] || typeof status[ptype] !== "object") { + for (let ptype of ['policy', 'smtp', 'body', 'header']) { + if (!status[ptype] || typeof status[ptype] !== 'object') { continue; } @@ -445,22 +409,22 @@ export const formatAuthHeaderRow = ( } } - return parts.join(" "); + return parts.join(' '); }; export const formatRelaxedLine = (line: Buffer | string, suffix?: string) => { let result = line - ?.toString("binary") + ?.toString('binary') // unfold - .replace(/\r?\n/g, "") + .replace(/\r?\n/g, '') // key to lowercase, trim around : - .replace(/^([^:]*):\s*/, (m, k) => k.toLowerCase().trim() + ":") + .replace(/^([^:]*):\s*/, (m, k) => k.toLowerCase().trim() + ':') // single WSP - .replace(/\s+/g, " ") - .trim() + (suffix ? suffix : ""); + .replace(/\s+/g, ' ') + .trim() + (suffix ? suffix : ''); - return Buffer.from(result, "binary"); + return Buffer.from(result, 'binary'); }; export const formatDomain = (domain: string) => { @@ -473,11 +437,7 @@ export const formatDomain = (domain: string) => { return domain; }; -export const getAlignment = ( - fromDomain: string, - domainList: string[], - strict: boolean = false -) => { +export const getAlignment = (fromDomain: string, domainList: string[], strict: boolean = false) => { domainList = ([] as string[]).concat(domainList || []); if (strict) { fromDomain = formatDomain(fromDomain); @@ -504,21 +464,21 @@ export const getAlignment = ( export const validateAlgorithm = (algorithm: string, strict: boolean) => { try { if (!algorithm || !/^[^-]+-[^-]+$/.test(algorithm)) { - throw new Error("Invalid algorithm format"); + throw new Error('Invalid algorithm format'); } - let [signAlgo, hashAlgo] = algorithm.toLowerCase().split("-"); + let [signAlgo, hashAlgo] = algorithm.toLowerCase().split('-'); - if (!["rsa", "ed25519"].includes(signAlgo)) { - throw new Error("Unknown signing algorithm: " + signAlgo); + if (!['rsa', 'ed25519'].includes(signAlgo)) { + throw new Error('Unknown signing algorithm: ' + signAlgo); } - if (!["sha256"].concat(!strict ? "sha1" : []).includes(hashAlgo)) { - throw new Error("Unknown hashing algorithm: " + hashAlgo); + if (!['sha256'].concat(!strict ? 'sha1' : []).includes(hashAlgo)) { + throw new Error('Unknown hashing algorithm: ' + hashAlgo); } } catch (err: unknown) { - if (err !== null && typeof err === "object" && Object.hasOwn(err, "code")) { - (err as { code: string }).code = "EINVALIDALGO"; + if (err !== null && typeof err === 'object' && Object.hasOwn(err, 'code')) { + (err as { code: string }).code = 'EINVALIDALGO'; } throw err; } @@ -530,7 +490,7 @@ export class CustomError extends Error { constructor(message: string, code: string, rr?: string) { super(message); this.code = code; - this.rr = rr ?? ""; + this.rr = rr ?? ''; } } diff --git a/packages/helpers/src/sha-utils.ts b/packages/helpers/src/sha-utils.ts index 0db2d8b84..2352b1841 100644 --- a/packages/helpers/src/sha-utils.ts +++ b/packages/helpers/src/sha-utils.ts @@ -1,16 +1,8 @@ import * as CryptoJS from 'crypto'; -import { - assert, - int64toBytes, - int8toBytes, - mergeUInt8Arrays, -} from './binary-format'; +import { assert, int64toBytes, int8toBytes, mergeUInt8Arrays } from './binary-format'; import { Hash } from './lib/fast-sha256'; -export function findIndexInUint8Array( - array: Uint8Array, - selector: Uint8Array, -): number { +export function findIndexInUint8Array(array: Uint8Array, selector: Uint8Array): number { let i = 0; let j = 0; while (i < array.length) { @@ -93,10 +85,7 @@ export function partialSha(msg: Uint8Array, msgLen: number): Uint8Array { } // Puts an end selector, a bunch of 0s, then the length, then fill the rest with 0s. -export function sha256Pad( - message: Uint8Array, - maxShaBytes: number, -): [Uint8Array, number] { +export function sha256Pad(message: Uint8Array, maxShaBytes: number): [Uint8Array, number] { const msgLen = message.length * 8; // bytes to bits const msgLenBytes = int64toBytes(msgLen); diff --git a/packages/helpers/tests/dkim.test.ts b/packages/helpers/tests/dkim.test.ts index 3d9ad1c62..e98fa0286 100644 --- a/packages/helpers/tests/dkim.test.ts +++ b/packages/helpers/tests/dkim.test.ts @@ -8,9 +8,7 @@ jest.setTimeout(10000); describe('DKIM signature verification', () => { it('should pass for valid email', async () => { - const email = fs.readFileSync( - path.join(__dirname, 'test-data/email-good.eml'), - ); + const email = fs.readFileSync(path.join(__dirname, 'test-data/email-good.eml')); const result = await verifyDKIMSignature(email); @@ -19,25 +17,19 @@ describe('DKIM signature verification', () => { }); it('should fail for invalid selector', async () => { - const email = fs.readFileSync( - path.join(__dirname, 'test-data/email-invalid-selector.eml'), - ); + const email = fs.readFileSync(path.join(__dirname, 'test-data/email-invalid-selector.eml')); expect.assertions(1); try { await verifyDKIMSignature(email); } catch (e) { - expect(e.message).toBe( - 'DKIM signature verification failed for domain icloud.com. Reason: no key', - ); + expect(e.message).toBe('DKIM signature verification failed for domain icloud.com. Reason: no key'); } }); it('should fail for tampered body', async () => { - const email = fs.readFileSync( - path.join(__dirname, 'test-data/email-body-tampered.eml'), - ); + const email = fs.readFileSync(path.join(__dirname, 'test-data/email-body-tampered.eml')); expect.assertions(1); @@ -52,26 +44,20 @@ describe('DKIM signature verification', () => { 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'), - ); + const email = fs.readFileSync(path.join(__dirname, 'test-data/email-invalid-domain.eml')); expect.assertions(1); 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 () => { // From address domain is icloud.com - const email = fs.readFileSync( - path.join(__dirname, 'test-data/email-different-domain.eml'), - ); + const email = fs.readFileSync(path.join(__dirname, 'test-data/email-different-domain.eml')); // Should pass with default domain await verifyDKIMSignature(email); @@ -83,76 +69,64 @@ describe('DKIM signature verification', () => { try { 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") - ); +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")); + .spyOn(dnsOverHttp, 'resolveDNSHTTP') + .mockRejectedValue(new Error('Failed due to mock')); - const consoleSpy = jest.spyOn(console, "log"); - await verifyDKIMSignature(email, "icloud.com", true, true); + 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" - ); + 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") - ); +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")); + .spyOn(dnsOverHttp, 'resolveDNSHTTP') + .mockRejectedValue(new Error('Failed due to mock')); expect.assertions(1); try { - await verifyDKIMSignature(email, "icloud.com", true, false); + 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" + '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") - ); +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")); - + .spyOn(dnsOverHttp, 'resolveDNSHTTP') + .mockRejectedValue(new Error('Failed due to mock')); + const mockResolveDNSFromZKEmailArchive = jest - .spyOn(dnsArchive, "resolveDNSFromZKEmailArchive") - .mockRejectedValue(new Error("Failed due to mock")); + .spyOn(dnsArchive, 'resolveDNSFromZKEmailArchive') + .mockRejectedValue(new Error('Failed due to mock')); expect.assertions(1); try { - await verifyDKIMSignature(email, "icloud.com", true, false); + 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" + 'DKIM signature verification failed for domain icloud.com. Reason: DNS failure: Failed due to mock', ); } @@ -162,9 +136,7 @@ it("should fail if both DNS over HTTP and ZK Email Archive fail", 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'), - ); + const email = fs.readFileSync(path.join(__dirname, 'test-data/email-good.eml')); // Add a label to the subject const tamperedEmail = email.toString().replace('Subject: ', 'Subject: [EmailListABC]'); @@ -173,4 +145,4 @@ describe('DKIM with sanitization', () => { expect(result.appliedSanitization).toBe('removeLabels'); }); -}); \ No newline at end of file +}); diff --git a/packages/helpers/tests/input-generators.test.ts b/packages/helpers/tests/input-generators.test.ts index 04ce0931e..0830ba945 100644 --- a/packages/helpers/tests/input-generators.test.ts +++ b/packages/helpers/tests/input-generators.test.ts @@ -1,15 +1,13 @@ -import fs from "fs"; -import path from "path"; -import { generateEmailVerifierInputs } from "../src/input-generators"; -import { bytesToString } from "../src/binary-format"; +import fs from 'fs'; +import path from 'path'; +import { generateEmailVerifierInputs } from '../src/input-generators'; +import { bytesToString } from '../src/binary-format'; jest.setTimeout(10000); -describe("Input generators", () => { - it("should generate input from raw email", async () => { - const email = fs.readFileSync( - path.join(__dirname, "test-data/email-good.eml") - ); +describe('Input generators', () => { + it('should generate input from raw email', async () => { + const email = fs.readFileSync(path.join(__dirname, 'test-data/email-good.eml')); const inputs = await generateEmailVerifierInputs(email); @@ -22,10 +20,8 @@ describe("Input generators", () => { expect(inputs.bodyHashIndex).toBeDefined(); }); - it("should generate input without body params when ignoreBodyHash is true", async () => { - const email = fs.readFileSync( - path.join(__dirname, "test-data/email-good.eml") - ); + it('should generate input without body params when ignoreBodyHash is true', async () => { + const email = fs.readFileSync(path.join(__dirname, 'test-data/email-good.eml')); const inputs = await generateEmailVerifierInputs(email, { ignoreBodyHashCheck: true, @@ -40,35 +36,29 @@ describe("Input generators", () => { expect(inputs.bodyHashIndex).toBeFalsy(); }); - it("should generate input with SHA precompute selector", async () => { - const email = fs.readFileSync( - path.join(__dirname, "test-data/email-good-large.eml") - ); + it('should generate input with SHA precompute selector', async () => { + const email = fs.readFileSync(path.join(__dirname, 'test-data/email-good-large.eml')); const inputs = await generateEmailVerifierInputs(email, { - shaPrecomputeSelector: "thousands", + shaPrecomputeSelector: 'thousands', }); expect(inputs.emailBody).toBeDefined(); - const strBody = bytesToString( - Uint8Array.from(inputs.emailBody!.map((b) => Number(b))) - ); + const strBody = bytesToString(Uint8Array.from(inputs.emailBody!.map((b) => Number(b)))); - const expected = "h hundreds of thousands of blocks."; // will round till previous 64x th byte + const expected = 'h hundreds of thousands of blocks.'; // will round till previous 64x th byte expect(strBody.startsWith(expected)).toBeTruthy(); }); - it("should throw if SHA precompute selector is invalid", async () => { - const email = fs.readFileSync( - path.join(__dirname, "test-data/email-good.eml") - ); + it('should throw if SHA precompute selector is invalid', async () => { + const email = fs.readFileSync(path.join(__dirname, 'test-data/email-good.eml')); await expect(() => generateEmailVerifierInputs(email, { - shaPrecomputeSelector: "Bla Bla", - }) + shaPrecomputeSelector: 'Bla Bla', + }), ).rejects.toThrow('SHA precompute selector "Bla Bla" not found in the body'); }); });