UNPKG

mailauth

Version:

Email authentication library for Node.js

305 lines (244 loc) 9.73 kB
/* eslint no-control-regex: 0 */ 'use strict'; const { Buffer } = require('node:buffer'); const crypto = require('node:crypto'); const { MimeStructureStartFinder } = require('../mime-structure-start-finder'); const CHAR_CR = 0x0d; const CHAR_LF = 0x0a; const CHAR_SPACE = 0x20; const CHAR_TAB = 0x09; /** * Class for calculating body hash of an email message body stream * using the "relaxed" canonicalization * * @class */ class RelaxedHash { /** * @param {String} [algorithm] Hashing algo, either "sha1" or "sha256" * @param {Number} [maxBodyLength] Allowed body length count, the value from the l= parameter */ constructor(algorithm, maxBodyLength) { algorithm = (algorithm || 'sha256').split('-').pop().toLowerCase(); this.bodyHash = crypto.createHash(algorithm); this.remainder = false; // total body size this.byteLength = 0; // total canonicalized body size this.canonicalizedLength = 0; // hashed canonicalized body size (after l= tag) this.bodyHashedBytes = 0; this.maxBodyLength = maxBodyLength; this.maxSizeReached = maxBodyLength === 0; this.emptyLinesQueue = []; this.mimeStructureStartFinder = new MimeStructureStartFinder(); } setContentType(contentTypeObj) { if (/^multipart\//i.test(contentTypeObj.value) && contentTypeObj.params.boundary) { this.mimeStructureStartFinder.setBoundary(contentTypeObj.params.boundary); } } _updateBodyHash(chunk) { // serach through the entire document, not just signed part this.mimeStructureStartFinder.update(chunk); this.canonicalizedLength += chunk.length; 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; } // only use allowed size of bytes chunk = chunk.subarray(0, this.maxBodyLength - this.bodyHashedBytes); } this.bodyHashedBytes += chunk.length; this.bodyHash.update(chunk); //process.stdout.write(chunk); } _drainPendingEmptyLines() { if (this.emptyLinesQueue.length) { for (let emptyLine of this.emptyLinesQueue) { this._updateBodyHash(emptyLine); } this.emptyLinesQueue = []; } } _pushBodyHash(chunk) { 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; } } if (!foundNonLn) { this.emptyLinesQueue.push(chunk); return; } this._updateBodyHash(chunk); } /** * Performs the following modifications for a single line: * - Replace all <LF> chars with <CR><LF> * - Replace all spaces and tabs with a single space. * - Remove trailing whitespace * @param {Buffer} line * @returns {Buffer} fixed line */ fixLineBuffer(line) { // Allocate maximum expected buffer length // If the line is only filled with <LF> bytes then we need 2 times the size of the line let lineBuf = Buffer.alloc(line.length * 2); // Start processing the line from the end to beginning let writePos = lineBuf.length - 1; let nonWspFound = false; let prevWsp = false; for (let i = line.length - 1; i >= 0; i--) { if (line[i] === CHAR_LF) { lineBuf[writePos--] = line[i]; if (i === 0 || line[i - 1] !== CHAR_CR) { // add missing carriage return lineBuf[writePos--] = CHAR_CR; } continue; } if (line[i] === CHAR_CR) { lineBuf[writePos--] = line[i]; continue; } if (line[i] === CHAR_SPACE || line[i] === CHAR_TAB) { if (nonWspFound) { prevWsp = true; } continue; } if (prevWsp) { lineBuf[writePos--] = CHAR_SPACE; prevWsp = false; } nonWspFound = true; lineBuf[writePos--] = line[i]; } if (prevWsp && nonWspFound) { lineBuf[writePos--] = CHAR_SPACE; } return lineBuf.subarray(writePos + 1); } update(chunk, final) { this.byteLength += (chunk && chunk.length) || 0; if (this.maxSizeReached) { return; } // 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.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 (chunk && chunk.length) { for (let pos = 0; pos < chunk.length; pos++) { switch (chunk[pos]) { case CHAR_LF: if ( !lineNeedsFixing && // previous character is not <CR> ((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: } } } 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; } 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) { this.update(null, true); // finalize return this.bodyHash.digest(encoding); } getMimeStructureStart() { return this.mimeStructureStartFinder.getMimeStructureStart(); } } module.exports = { RelaxedHash };