| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143 |
- import assert from 'node:assert/strict';
- import crypto from 'node:crypto';
- import { test } from 'node:test';
- import { buildMessage, signMessageForDomain } from '../src/mailer.js';
- import { createDkimKeyPair } from '../src/dkim.js';
- test('signs messages with a verifiable DKIM relaxed body hash', () => {
- const keys = createDkimKeyPair();
- const raw = buildMessage({
- from: 'noreply@example.com',
- to: 'user@example.net',
- subject: '中文 DKIM test',
- text: 'Hello MailHub \n中文正文\twith spacing',
- baseUrl: 'https://mailhub.test'
- });
- const signed = signMessageForDomain(raw, {
- domain: 'example.com',
- selector: 'mh202607',
- dkimPrivate: keys.privateKey
- });
- const verification = verifyDkimSignature(signed, keys.publicKey);
- assert.equal(verification.bodyHashValid, true);
- assert.equal(verification.signatureValid, true);
- });
- test('signs deliverability headers and includes DKIM identity metadata', () => {
- const keys = createDkimKeyPair();
- const raw = buildMessage({
- from: 'noreply@example.com',
- to: 'user@example.net',
- subject: 'Deliverability headers',
- text: 'Hello',
- baseUrl: 'https://mailhub.test',
- headers: [
- ['List-Unsubscribe', '<mailto:unsubscribe@example.com>, <https://example.com/unsubscribe/123>'],
- ['List-Unsubscribe-Post', 'List-Unsubscribe=One-Click'],
- ['Feedback-Id', 'mh.u123.d456.e789:MailHub'],
- ['X-Report-Abuse-To', 'abuse@example.com']
- ]
- });
- const signed = signMessageForDomain(raw, {
- domain: 'example.com',
- selector: 'mh202607',
- dkimPrivate: keys.privateKey
- });
- const verification = verifyDkimSignature(signed, keys.publicKey);
- const tags = verification.tags;
- const signedHeaders = tags.h.split(':');
- assert.equal(verification.bodyHashValid, true);
- assert.equal(verification.signatureValid, true);
- assert.equal(tags.i, '@example.com');
- assert.equal(tags.q, 'dns/txt');
- assert.match(tags.t, /^\d+$/);
- assert.ok(signedHeaders.includes('list-unsubscribe'));
- assert.ok(signedHeaders.includes('list-unsubscribe-post'));
- assert.ok(signedHeaders.includes('feedback-id'));
- assert.ok(signedHeaders.includes('x-report-abuse-to'));
- });
- function verifyDkimSignature(rawMessage, publicKey) {
- const separator = rawMessage.indexOf('\r\n\r\n');
- const headers = parseHeaders(rawMessage.slice(0, separator));
- const body = rawMessage.slice(separator + 4);
- const dkim = headers.find((header) => header.name.toLowerCase() === 'dkim-signature');
- assert.ok(dkim);
- const tags = parseDkimTags(dkim.value);
- const bodyHash = crypto
- .createHash('sha256')
- .update(relaxedBody(body))
- .digest('base64');
- const dkimWithoutSignature = dkim.value.replace(/(^|;\s*)b=[^;]*/i, '$1b=');
- const signedHeaderNames = tags.h.split(':').map((name) => name.trim().toLowerCase()).filter(Boolean);
- const signingInput = [
- ...signedHeaderNames.map((name) => relaxedHeader(findLastHeader(headers, name))),
- relaxedHeader({ name: 'DKIM-Signature', value: dkimWithoutSignature }, '')
- ].join('');
- const signatureValid = crypto
- .createVerify('RSA-SHA256')
- .update(signingInput)
- .verify(dkimPublicPem(publicKey), tags.b, 'base64');
- return {
- bodyHashValid: bodyHash === tags.bh,
- signatureValid,
- tags
- };
- }
- function parseHeaders(headerBlock) {
- const headers = [];
- for (const line of headerBlock.split('\r\n')) {
- if (/^[\t ]/.test(line) && headers.length) {
- headers[headers.length - 1].value += ` ${line.trim()}`;
- continue;
- }
- const index = line.indexOf(':');
- if (index === -1) continue;
- headers.push({
- name: line.slice(0, index),
- value: line.slice(index + 1)
- });
- }
- return headers;
- }
- function parseDkimTags(value) {
- const tags = {};
- for (const part of value.split(';')) {
- const index = part.indexOf('=');
- if (index === -1) continue;
- tags[part.slice(0, index).trim()] = part.slice(index + 1).trim();
- }
- return tags;
- }
- function findLastHeader(headers, name) {
- const found = [...headers].reverse().find((header) => header.name.toLowerCase() === name);
- return found || { name, value: '' };
- }
- function relaxedHeader(header, suffix = '\r\n') {
- return `${header.name.toLowerCase()}:${header.value.replace(/\s+/g, ' ').trim()}${suffix}`;
- }
- function relaxedBody(body) {
- const lines = String(body || '')
- .replace(/\r?\n/g, '\r\n')
- .split('\r\n')
- .map((line) => line.replace(/[ \t]+$/g, '').replace(/[ \t]+/g, ' '));
- while (lines.length && lines[lines.length - 1] === '') lines.pop();
- return `${lines.join('\r\n')}\r\n`;
- }
- function dkimPublicPem(publicKey) {
- return [
- '-----BEGIN PUBLIC KEY-----',
- publicKey.match(/.{1,64}/g).join('\n'),
- '-----END PUBLIC KEY-----',
- ''
- ].join('\n');
- }
|