Răsfoiți Sursa

fix: stabilize dkim signing and platform host display

Codex 1 lună în urmă
părinte
comite
8cb0f5314b
8 a modificat fișierele cu 158 adăugiri și 33 ștergeri
  1. 6 2
      public/app.js
  2. 6 0
      public/styles.css
  3. 6 2
      src/dkim.js
  4. 3 2
      src/dns-guide.js
  5. 1 1
      src/dns-providers.js
  6. 11 6
      src/mailer.js
  7. 106 0
      test/dkim.test.js
  8. 19 20
      test/dns-providers.test.js

+ 6 - 2
public/app.js

@@ -634,13 +634,17 @@ function renderRecordCard(record, index) {
       <div class="record-step"><span>${index}</span></div>
       <div class="record-card-body">
         <div class="record-card-title">
-          <div><h4>${escapeHtml(record.label)}</h4><p>${escapeHtml(record.type)} · ${escapeHtml(record.host)}</p></div>
+          <div>
+            <h4>${escapeHtml(record.label)}</h4>
+            <p>${escapeHtml(record.type)} · ${escapeHtml(record.host)}</p>
+            ${record.managed ? '<p class="managed-note">平台维护,无需在当前域名 DNS 中配置。</p>' : ''}
+          </div>
           ${badge(meta)}
         </div>
         <div class="dns-value">
           <span>目标值</span>
           <code>${escapeHtml(record.value || '')}</code>
-          <button class="btn btn-sm btn-outline-secondary" data-copy="${escapeAttr(record.value || '')}" type="button">复制值</button>
+          ${record.managed ? '' : `<button class="btn btn-sm btn-outline-secondary" data-copy="${escapeAttr(record.value || '')}" type="button">复制值</button>`}
         </div>
         ${current.length ? `<div class="dns-current"><span>当前值</span>${current.map((value) => `<code>${escapeHtml(value)}</code>`).join('')}</div>` : ''}
         ${warnings.length ? `<ul class="inline-warnings">${warnings.map((warning) => `<li>${escapeHtml(warning)}</li>`).join('')}</ul>` : ''}

+ 6 - 0
public/styles.css

@@ -259,6 +259,7 @@
 }
 
 .record-card-title p,
+.managed-note,
 .dns-value > span,
 .dns-current > span,
 .live-row span,
@@ -269,6 +270,11 @@
   margin: 0;
 }
 
+.managed-note {
+  color: var(--mh-blue);
+  margin-top: 0.25rem;
+}
+
 .dns-value,
 .dns-current,
 .live-list,

+ 6 - 2
src/dkim.js

@@ -103,8 +103,12 @@ function canonicalizeHeader(header) {
 }
 
 function canonicalizeBody(body) {
-  const normalized = body.replace(/\r?\n/g, '\r\n');
-  return `${normalized.replace(/(\r\n)*$/g, '')}\r\n`;
+  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`;
 }
 
 export function foldHeader(name, value) {

+ 3 - 2
src/dns-guide.js

@@ -55,15 +55,16 @@ export async function buildDnsGuide(domain) {
     },
     {
       key: 'sender-a',
-      label: '发信主机 A 记录',
+      label: '平台发信主机 A',
       host: domain.senderHost,
       type: 'A',
       value: domain.sendingIp,
+      managed: true,
       status: live.senderA.includes(domain.sendingIp) ? 'ok' : 'warn',
       current: live.senderA.join(', '),
       warnings: live.senderA.includes(domain.sendingIp)
         ? []
-        : [`${domain.senderHost} 当前未解析到 ${domain.sendingIp},HELO/PTR/SPF 会出现不一致。`]
+        : [`${domain.senderHost} 当前未解析到 ${domain.sendingIp},这是平台发信主机,请联系管理员检查。`]
     },
     {
       key: 'ptr',

+ 1 - 1
src/dns-providers.js

@@ -16,7 +16,7 @@ export async function testDnsCredential(credential) {
 
 export async function applyDnsSetup(domain, credential, guide) {
   const provider = createProvider(credential);
-  const records = (guide.records || []).filter((record) => ['verification', 'dkim', 'spf', 'dmarc', 'sender-a'].includes(record.key));
+  const records = (guide.records || []).filter((record) => ['verification', 'dkim', 'spf', 'dmarc'].includes(record.key));
   const results = [];
   for (const record of records) {
     const zoneName = credential.zoneName || '';

+ 11 - 6
src/mailer.js

@@ -48,14 +48,14 @@ export function buildMessage({ from, to, subject, text, html, baseUrl }) {
     const body = [
       `--${boundary}`,
       'Content-Type: text/plain; charset=UTF-8',
-      'Content-Transfer-Encoding: 8bit',
+      'Content-Transfer-Encoding: base64',
       '',
-      normalizeBody(text || stripHtml(html)),
+      encodeBase64Body(text || stripHtml(html)),
       `--${boundary}`,
       'Content-Type: text/html; charset=UTF-8',
-      'Content-Transfer-Encoding: 8bit',
+      'Content-Transfer-Encoding: base64',
       '',
-      normalizeBody(html),
+      encodeBase64Body(html),
       `--${boundary}--`,
       ''
     ].join('\r\n');
@@ -65,9 +65,9 @@ export function buildMessage({ from, to, subject, text, html, baseUrl }) {
   const headers = [
     ...commonHeaders,
     ['Content-Type', 'text/plain; charset=UTF-8'],
-    ['Content-Transfer-Encoding', '8bit']
+    ['Content-Transfer-Encoding', 'base64']
   ];
-  return `${formatHeaders(headers)}\r\n\r\n${normalizeBody(text || '')}\r\n`;
+  return `${formatHeaders(headers)}\r\n\r\n${encodeBase64Body(text || '')}\r\n`;
 }
 
 export function signMessageForDomain(rawMessage, domain) {
@@ -127,6 +127,11 @@ function normalizeBody(value) {
   return String(value || '').replace(/\r?\n/g, '\r\n');
 }
 
+function encodeBase64Body(value) {
+  const encoded = Buffer.from(normalizeBody(value), 'utf8').toString('base64');
+  return encoded.replace(/.{1,76}/g, '$&\r\n').trimEnd();
+}
+
 function stripHtml(value) {
   return String(value || '')
     .replace(/<style[\s\S]*?<\/style>/gi, '')

+ 106 - 0
test/dkim.test.js

@@ -0,0 +1,106 @@
+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);
+});
+
+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
+  };
+}
+
+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) {
+  return `${header.name.toLowerCase()}:${header.value.replace(/\s+/g, ' ').trim()}\r\n`;
+}
+
+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');
+}

+ 19 - 20
test/dns-providers.test.js

@@ -86,9 +86,24 @@ test('dnspod provider signs and sends create record actions', async () => {
   assert.ok(actions.includes('CreateRecord'));
 });
 
-test('skips external sender host when it already resolves correctly', async () => {
+test('one-click dns setup only applies records under the user domain zone', async () => {
+  const calls = [];
+  globalThis.fetch = async (url, options = {}) => {
+    calls.push({ url: String(url), method: options.method || 'GET' });
+    if (String(url).includes('/zones?')) return json({ success: true, result: [{ id: 'zone-1', name: 'example.com' }] });
+    if (String(url).includes('/dns_records?')) return json({ success: true, result: [] });
+    return json({ success: true, result: { id: 'ok' } });
+  };
+
   const result = await applyDnsSetup(domainFixture(), cloudflareCredential(), {
     records: [
+      {
+        key: 'dkim',
+        host: 'mh._domainkey.example.com',
+        type: 'TXT',
+        value: 'v=DKIM1; k=rsa; p=abc',
+        status: 'missing'
+      },
       {
         key: 'sender-a',
         host: 'in.ss5.xyz',
@@ -100,25 +115,9 @@ test('skips external sender host when it already resolves correctly', async () =
   });
 
   assert.equal(result.ok, true);
-  assert.equal(result.results[0].skipped, true);
-});
-
-test('fails external sender host when it is not already correct', async () => {
-  const result = await applyDnsSetup(domainFixture(), cloudflareCredential(), {
-    records: [
-      {
-        key: 'sender-a',
-        host: 'in.ss5.xyz',
-        type: 'A',
-        value: '127.0.0.1',
-        status: 'warn'
-      }
-    ]
-  });
-
-  assert.equal(result.ok, false);
-  assert.equal(result.results[0].skipped, true);
-  assert.match(result.results[0].error, /不在 DNS Zone/);
+  assert.equal(result.results.length, 1);
+  assert.equal(result.results[0].key, 'dkim');
+  assert.equal(calls.filter((call) => call.method === 'POST').length, 1);
 });
 
 function cloudflareCredential() {