dkim.test.js 3.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106
  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. function verifyDkimSignature(rawMessage, publicKey) {
  25. const separator = rawMessage.indexOf('\r\n\r\n');
  26. const headers = parseHeaders(rawMessage.slice(0, separator));
  27. const body = rawMessage.slice(separator + 4);
  28. const dkim = headers.find((header) => header.name.toLowerCase() === 'dkim-signature');
  29. assert.ok(dkim);
  30. const tags = parseDkimTags(dkim.value);
  31. const bodyHash = crypto
  32. .createHash('sha256')
  33. .update(relaxedBody(body))
  34. .digest('base64');
  35. const dkimWithoutSignature = dkim.value.replace(/(^|;\s*)b=[^;]*/i, '$1b=');
  36. const signedHeaderNames = tags.h.split(':').map((name) => name.trim().toLowerCase()).filter(Boolean);
  37. const signingInput = [
  38. ...signedHeaderNames.map((name) => relaxedHeader(findLastHeader(headers, name))),
  39. relaxedHeader({ name: 'DKIM-Signature', value: dkimWithoutSignature }, '')
  40. ].join('');
  41. const signatureValid = crypto
  42. .createVerify('RSA-SHA256')
  43. .update(signingInput)
  44. .verify(dkimPublicPem(publicKey), tags.b, 'base64');
  45. return {
  46. bodyHashValid: bodyHash === tags.bh,
  47. signatureValid
  48. };
  49. }
  50. function parseHeaders(headerBlock) {
  51. const headers = [];
  52. for (const line of headerBlock.split('\r\n')) {
  53. if (/^[\t ]/.test(line) && headers.length) {
  54. headers[headers.length - 1].value += ` ${line.trim()}`;
  55. continue;
  56. }
  57. const index = line.indexOf(':');
  58. if (index === -1) continue;
  59. headers.push({
  60. name: line.slice(0, index),
  61. value: line.slice(index + 1)
  62. });
  63. }
  64. return headers;
  65. }
  66. function parseDkimTags(value) {
  67. const tags = {};
  68. for (const part of value.split(';')) {
  69. const index = part.indexOf('=');
  70. if (index === -1) continue;
  71. tags[part.slice(0, index).trim()] = part.slice(index + 1).trim();
  72. }
  73. return tags;
  74. }
  75. function findLastHeader(headers, name) {
  76. const found = [...headers].reverse().find((header) => header.name.toLowerCase() === name);
  77. return found || { name, value: '' };
  78. }
  79. function relaxedHeader(header, suffix = '\r\n') {
  80. return `${header.name.toLowerCase()}:${header.value.replace(/\s+/g, ' ').trim()}${suffix}`;
  81. }
  82. function relaxedBody(body) {
  83. const lines = String(body || '')
  84. .replace(/\r?\n/g, '\r\n')
  85. .split('\r\n')
  86. .map((line) => line.replace(/[ \t]+$/g, '').replace(/[ \t]+/g, ' '));
  87. while (lines.length && lines[lines.length - 1] === '') lines.pop();
  88. return `${lines.join('\r\n')}\r\n`;
  89. }
  90. function dkimPublicPem(publicKey) {
  91. return [
  92. '-----BEGIN PUBLIC KEY-----',
  93. publicKey.match(/.{1,64}/g).join('\n'),
  94. '-----END PUBLIC KEY-----',
  95. ''
  96. ].join('\n');
  97. }