From d6c991e1637b5f45b2699238296ab7f7cc907481 Mon Sep 17 00:00:00 2001 From: John Coffey <2+jcoffey-dev@noreply.git.coffeylabs.org> Date: Tue, 29 Sep 2026 10:22:25 -0700 Subject: [PATCH] DNS checks read a long TXT record the server splits across lines The server writes a TXT value longer than 255 bytes the way BIND does: quoted chunks inside parentheses, one per line. The zone parser read a line at a time, so an RSA DKIM key was the record "(" and never matched what public DNS returned. The Security page (SS-13), the DNS wizard's record checks and the domain hover card all reported DKIM missing for every domain with an RSA key. Lines inside parentheses are now joined into one record first, with the parentheses dropped and any inside a quoted value kept. --- src/features/dns/zone.test.ts | 21 ++++++++++++++ src/features/dns/zone.ts | 54 ++++++++++++++++++++++++++++++++++- 2 files changed, 74 insertions(+), 1 deletion(-) diff --git a/src/features/dns/zone.test.ts b/src/features/dns/zone.test.ts index f0f8f5e..977d0ca 100644 --- a/src/features/dns/zone.test.ts +++ b/src/features/dns/zone.test.ts @@ -54,6 +54,27 @@ describe('parseZone', () => { expect(records.find((r) => r.kind === 'dkim')?.value).toBe('"v=DKIM1; k=ed25519; p=abc"'); }); + it('joins a long TXT record the server splits across lines, as BIND does', () => { + const zone = [ + 'example.com. IN MX 10 mail.example.com.', + 'rsa._domainkey.example.com. IN TXT (', + ' "v=DKIM1; k=rsa; p=MIIBIjAN"', + ' "BgkqhkiG9w0BAQEFAAOC"', + ')', + '_dmarc.example.com. IN TXT "v=DMARC1; p=reject"', + ].join('\n'); + const records = parseZone(zone); + const dkim = records.find((r) => r.kind === 'dkim'); + expect(dkim?.value).toBe('"v=DKIM1; k=rsa; p=MIIBIjAN" "BgkqhkiG9w0BAQEFAAOC"'); + expect(normalizeValue('TXT', dkim!.value)).toBe(normalizeValue('TXT', '"v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOC"')); + expect(records.map((r) => r.kind)).toEqual(['mx', 'dkim', 'dmarc']); + }); + + it('keeps parentheses that are inside a quoted value', () => { + const [r] = parseZone('_dmarc.example.com. IN TXT "v=DMARC1; p=none; (test)"'); + expect(r.value).toBe('"v=DMARC1; p=none; (test)"'); + }); + it('copes with nothing', () => { expect(parseZone(undefined)).toEqual([]); expect(parseZone('')).toEqual([]); diff --git a/src/features/dns/zone.ts b/src/features/dns/zone.ts index 1a5e9d4..ebb03e0 100644 --- a/src/features/dns/zone.ts +++ b/src/features/dns/zone.ts @@ -67,10 +67,62 @@ function kindOf(name: string, type: string, value: string): RecordKind | null { return null; } +/** The text with the quoted strings blanked, so parentheses inside them don't count. */ +function unquoted(line: string): string { + return line.replace(/"(?:[^"\\]|\\.)*"/g, '""'); +} + +/** + * Join the lines a record spans. The server writes a TXT value longer than + * 255 bytes the way BIND does, as quoted chunks inside parentheses on lines + * of their own: + * + * sel._domainkey.example.com. IN TXT ( + * "v=DKIM1; k=rsa; p=MIIB…" + * "…IDAQAB" + * ) + * + * Read a line at a time, the record was "(" and the DKIM key never matched + * what DNS returns, so every domain with an RSA key showed as missing it. + */ +function logicalLines(text: string): string[] { + const out: string[] = []; + let pending: string | null = null; + for (const raw of text.split('\n')) { + const line = raw.trim(); + if (pending !== null) { + pending += ` ${line}`; + if (unquoted(line).includes(')')) { + out.push(pending); + pending = null; + } + continue; + } + const bare = unquoted(line); + if (bare.includes('(') && !bare.includes(')')) pending = line; + else out.push(line); + } + if (pending !== null) out.push(pending); + // The parentheses only group lines; the record doesn't contain them. + return out.map((l) => { + const bare = unquoted(l); + if (!bare.includes('(')) return l; + let result = ''; + let inQuote = false; + for (let i = 0; i < l.length; i++) { + const c = l[i]; + if (c === '"' && l[i - 1] !== '\\') inQuote = !inQuote; + if (!inQuote && (c === '(' || c === ')')) continue; + result += c; + } + return result.replace(/\s+/g, ' ').trim(); + }); +} + /** Parse the zone text. Lines it can't place are left out, not guessed at. */ export function parseZone(text: string | null | undefined): ZoneRecord[] { const out: ZoneRecord[] = []; - for (const raw of (text ?? '').split('\n')) { + for (const raw of logicalLines(text ?? '')) { const line = raw.trim(); if (!line || line.startsWith(';')) continue; const f = fields(line); -- 2.54.0