dkim.test.js 4.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143
  1. import assert from 'node:assert/strict';
  2. import crypto from 'node:crypto';
  3. import { test } from 'node:test';
  4. import { buildMessage, signMessageForDomain } from '../src/mailer.js';
  5. import { createDkimKeyPair } from '../src/dkim.js';
  6. test('signs messages with a verifiable DKIM relaxed body hash', () => {
  7. const keys = createDkimKeyPair();
  8. const raw = buildMessage({
  9. from: 'noreply@example.com',
  10. to: 'user@example.net',
  11. subject: '中文 DKIM test',
  12. text: 'Hello MailHub \n中文正文\twith spacing',
  13. baseUrl: 'https://mailhub.test'
  14. });
  15. const signed = signMessageForDomain(raw, {
  16. domain: 'example.com',
  17. selector: 'mh202607',
  18. dkimPrivate: keys.privateKey
  19. });
  20. const verification = verifyDkimSignature(signed, keys.publicKey);
  21. assert.equal(verification.bodyHashValid, true);
  22. assert.equal(verification.signatureValid, true);
  23. });
  24. test('signs deliverability headers and includes DKIM identity metadata', () => {
  25. const keys = createDkimKeyPair();
  26. const raw = buildMessage({
  27. from: 'noreply@example.com',
  28. to: 'user@example.net',
  29. subject: 'Deliverability headers',
  30. text: 'Hello',
  31. baseUrl: 'https://mailhub.test',
  32. headers: [
  33. ['List-Unsubscribe', '<mailto:unsubscribe@example.com>, <https://example.com/unsubscribe/123>'],
  34. ['List-Unsubscribe-Post', 'List-Unsubscribe=One-Click'],
  35. ['Feedback-Id', 'mh.u123.d456.e789:MailHub'],
  36. ['X-Report-Abuse-To', 'abuse@example.com']
  37. ]
  38. });
  39. const signed = signMessageForDomain(raw, {
  40. domain: 'example.com',
  41. selector: 'mh202607',
  42. dkimPrivate: keys.privateKey
  43. });
  44. const verification = verifyDkimSignature(signed, keys.publicKey);
  45. const tags = verification.tags;
  46. const signedHeaders = tags.h.split(':');
  47. assert.equal(verification.bodyHashValid, true);
  48. assert.equal(verification.signatureValid, true);
  49. assert.equal(tags.i, '@example.com');
  50. assert.equal(tags.q, 'dns/txt');
  51. assert.match(tags.t, /^\d+$/);
  52. assert.ok(signedHeaders.includes('list-unsubscribe'));
  53. assert.ok(signedHeaders.includes('list-unsubscribe-post'));
  54. assert.ok(signedHeaders.includes('feedback-id'));
  55. assert.ok(signedHeaders.includes('x-report-abuse-to'));
  56. });
  57. function verifyDkimSignature(rawMessage, publicKey) {
  58. const separator = rawMessage.indexOf('\r\n\r\n');
  59. const headers = parseHeaders(rawMessage.slice(0, separator));
  60. const body = rawMessage.slice(separator + 4);
  61. const dkim = headers.find((header) => header.name.toLowerCase() === 'dkim-signature');
  62. assert.ok(dkim);
  63. const tags = parseDkimTags(dkim.value);
  64. const bodyHash = crypto
  65. .createHash('sha256')
  66. .update(relaxedBody(body))
  67. .digest('base64');
  68. const dkimWithoutSignature = dkim.value.replace(/(^|;\s*)b=[^;]*/i, '$1b=');
  69. const signedHeaderNames = tags.h.split(':').map((name) => name.trim().toLowerCase()).filter(Boolean);
  70. const signingInput = [
  71. ...signedHeaderNames.map((name) => relaxedHeader(findLastHeader(headers, name))),
  72. relaxedHeader({ name: 'DKIM-Signature', value: dkimWithoutSignature }, '')
  73. ].join('');
  74. const signatureValid = crypto
  75. .createVerify('RSA-SHA256')
  76. .update(signingInput)
  77. .verify(dkimPublicPem(publicKey), tags.b, 'base64');
  78. return {
  79. bodyHashValid: bodyHash === tags.bh,
  80. signatureValid,
  81. tags
  82. };
  83. }
  84. function parseHeaders(headerBlock) {
  85. const headers = [];
  86. for (const line of headerBlock.split('\r\n')) {
  87. if (/^[\t ]/.test(line) && headers.length) {
  88. headers[headers.length - 1].value += ` ${line.trim()}`;
  89. continue;
  90. }
  91. const index = line.indexOf(':');
  92. if (index === -1) continue;
  93. headers.push({
  94. name: line.slice(0, index),
  95. value: line.slice(index + 1)
  96. });
  97. }
  98. return headers;
  99. }
  100. function parseDkimTags(value) {
  101. const tags = {};
  102. for (const part of value.split(';')) {
  103. const index = part.indexOf('=');
  104. if (index === -1) continue;
  105. tags[part.slice(0, index).trim()] = part.slice(index + 1).trim();
  106. }
  107. return tags;
  108. }
  109. function findLastHeader(headers, name) {
  110. const found = [...headers].reverse().find((header) => header.name.toLowerCase() === name);
  111. return found || { name, value: '' };
  112. }
  113. function relaxedHeader(header, suffix = '\r\n') {
  114. return `${header.name.toLowerCase()}:${header.value.replace(/\s+/g, ' ').trim()}${suffix}`;
  115. }
  116. function relaxedBody(body) {
  117. const lines = String(body || '')
  118. .replace(/\r?\n/g, '\r\n')
  119. .split('\r\n')
  120. .map((line) => line.replace(/[ \t]+$/g, '').replace(/[ \t]+/g, ' '));
  121. while (lines.length && lines[lines.length - 1] === '') lines.pop();
  122. return `${lines.join('\r\n')}\r\n`;
  123. }
  124. function dkimPublicPem(publicKey) {
  125. return [
  126. '-----BEGIN PUBLIC KEY-----',
  127. publicKey.match(/.{1,64}/g).join('\n'),
  128. '-----END PUBLIC KEY-----',
  129. ''
  130. ].join('\n');
  131. }