dkim.js 3.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137
  1. import crypto from 'node:crypto';
  2. export function createDkimKeyPair() {
  3. const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', {
  4. modulusLength: 2048,
  5. publicKeyEncoding: {
  6. type: 'spki',
  7. format: 'pem'
  8. },
  9. privateKeyEncoding: {
  10. type: 'pkcs8',
  11. format: 'pem'
  12. }
  13. });
  14. return {
  15. publicKey: pemToDkimPublic(publicKey),
  16. privateKey
  17. };
  18. }
  19. export function pemToDkimPublic(pem) {
  20. return pem
  21. .replace(/-----BEGIN (?:RSA )?PUBLIC KEY-----/g, '')
  22. .replace(/-----END (?:RSA )?PUBLIC KEY-----/g, '')
  23. .replace(/\s+/g, '');
  24. }
  25. export function dkimPublicFromPrivateKey(privateKey) {
  26. const publicKey = crypto
  27. .createPublicKey(privateKey)
  28. .export({ type: 'spki', format: 'pem' });
  29. return pemToDkimPublic(publicKey);
  30. }
  31. export function buildDkimRecord(publicKey) {
  32. return `v=DKIM1; k=rsa; p=${publicKey}`;
  33. }
  34. export function signDkim(rawMessage, options) {
  35. const headers = parseHeaders(rawMessage);
  36. const body = rawMessage.slice(rawMessage.indexOf('\r\n\r\n') + 4);
  37. const signedHeaderNames = [
  38. 'from',
  39. 'to',
  40. 'subject',
  41. 'date',
  42. 'message-id',
  43. 'mime-version',
  44. 'content-type'
  45. ];
  46. const bodyHash = crypto
  47. .createHash('sha256')
  48. .update(canonicalizeBody(body))
  49. .digest('base64');
  50. const signatureFields = [
  51. 'v=1',
  52. 'a=rsa-sha256',
  53. 'c=relaxed/relaxed',
  54. `d=${options.domain}`,
  55. `s=${options.selector}`,
  56. `h=${signedHeaderNames.join(':')}`,
  57. `bh=${bodyHash}`,
  58. 'b='
  59. ];
  60. const dkimValueWithoutSignature = signatureFields.join('; ');
  61. const signingInput = [
  62. ...signedHeaderNames.map((name) => canonicalizeHeader(findHeader(headers, name))),
  63. canonicalizeHeader({ name: 'DKIM-Signature', value: dkimValueWithoutSignature }, '')
  64. ].join('');
  65. const signature = crypto
  66. .createSign('RSA-SHA256')
  67. .update(signingInput)
  68. .sign(options.privateKey, 'base64');
  69. const folded = foldHeader('DKIM-Signature', `${dkimValueWithoutSignature}${signature}`);
  70. return `${folded}\r\n${rawMessage}`;
  71. }
  72. function parseHeaders(rawMessage) {
  73. const head = rawMessage.slice(0, rawMessage.indexOf('\r\n\r\n'));
  74. const lines = head.split('\r\n');
  75. const headers = [];
  76. for (const line of lines) {
  77. if (/^[\t ]/.test(line) && headers.length) {
  78. headers[headers.length - 1].value += ` ${line.trim()}`;
  79. continue;
  80. }
  81. const index = line.indexOf(':');
  82. if (index === -1) continue;
  83. headers.push({
  84. name: line.slice(0, index),
  85. value: line.slice(index + 1)
  86. });
  87. }
  88. return headers;
  89. }
  90. function findHeader(headers, name) {
  91. const found = [...headers].reverse().find((header) => header.name.toLowerCase() === name);
  92. if (!found) return { name, value: '' };
  93. return found;
  94. }
  95. function canonicalizeHeader(header, suffix = '\r\n') {
  96. const name = header.name.toLowerCase();
  97. const value = header.value.replace(/\s+/g, ' ').trim();
  98. return `${name}:${value}${suffix}`;
  99. }
  100. function canonicalizeBody(body) {
  101. const lines = String(body || '')
  102. .replace(/\r?\n/g, '\r\n')
  103. .split('\r\n')
  104. .map((line) => line.replace(/[ \t]+$/g, '').replace(/[ \t]+/g, ' '));
  105. while (lines.length && lines[lines.length - 1] === '') lines.pop();
  106. return `${lines.join('\r\n')}\r\n`;
  107. }
  108. export function foldHeader(name, value) {
  109. const prefix = `${name}: `;
  110. const limit = 76;
  111. const words = value.split(' ');
  112. const lines = [];
  113. let current = prefix;
  114. for (const word of words) {
  115. if ((current + word).length > limit && current.trim() !== `${name}:`) {
  116. lines.push(current.trimEnd());
  117. current = ` ${word} `;
  118. } else {
  119. current += `${word} `;
  120. }
  121. }
  122. lines.push(current.trimEnd());
  123. return lines.join('\r\n');
  124. }