import type { IEmailDnsRecordIntent, TEmailDnsVerificationOutcome } from '../../ts_interfaces/data/email-domain.js'; import * as plugins from '../plugins.js'; import { decodeTxtRecordContent } from '../dns/txt-record-presentation.js'; export interface IMailDnsTxtPolicyResult { outcome: TEmailDnsVerificationOutcome; reason?: string; effectiveValue?: string; } export interface IMailDnsTxtPolicyOptions { resolveDns?: (name: string, type: 'A' | 'AAAA') => Promise; lookupTimeoutMs?: number; maxDnsLookups?: number; } export interface IMailTxtRecordClassification { relevant: boolean; canonicalValue?: string; malformed?: string; } type TMailTxtKind = 'spf' | 'dmarc' | 'dkim' | 'exact'; type TSpfQualifier = '+' | '-' | '~' | '?'; interface IIpNetwork { family: 4 | 6; bytes: number[]; prefixLength: number; } interface ISpfAMechanism { domain?: string; ipv4PrefixLength: number; ipv6PrefixLength: number; } type TSpfMechanism = | { kind: 'ip'; qualifier: TSpfQualifier; network: IIpNetwork } | { kind: 'a'; qualifier: TSpfQualifier; mechanism: ISpfAMechanism } | { kind: 'all'; qualifier: TSpfQualifier } | { kind: 'unsupported'; name: string }; interface IParsedSpfRecord { mechanisms: TSpfMechanism[]; hasRedirect: boolean; } function intentKind(intent: IEmailDnsRecordIntent): TMailTxtKind { const desired = intent.value.trim().toLowerCase(); if (desired.startsWith('v=spf1')) return 'spf'; if (desired.startsWith('v=dmarc1')) return 'dmarc'; if (desired.startsWith('v=dkim1')) return 'dkim'; return 'exact'; } function markerForKind(kind: TMailTxtKind): string | undefined { if (kind === 'spf') return 'v=spf1'; if (kind === 'dmarc') return 'v=dmarc1'; if (kind === 'dkim') return 'v=dkim1'; return undefined; } export function classifyMailTxtRecord( intent: IEmailDnsRecordIntent, rawValue: string, ): IMailTxtRecordClassification { const kind = intentKind(intent); const decoded = decodeTxtRecordContent(rawValue); if (!decoded.ok) { const marker = markerForKind(kind); const rawNormalized = rawValue.trim().toLowerCase(); const plausiblyRelevant = kind === 'dkim' ? rawNormalized.length > 0 : kind === 'dmarc' ? /(?:^|;)\s*v=dmarc1(?:;|$)/.test(rawNormalized.replace(/^"/, '')) : marker ? rawNormalized.slice(0, marker.length + 4).includes(marker) : rawValue.trim().startsWith('"'); return plausiblyRelevant ? { relevant: true, malformed: decoded.error } : { relevant: false }; } const canonicalValue = decoded.value.trim(); const marker = markerForKind(kind); const normalizedValue = canonicalValue.toLowerCase(); return { relevant: kind === 'dkim' ? canonicalValue.length > 0 : kind === 'dmarc' ? /(?:^|;)\s*v=dmarc1(?:;|$)/.test(normalizedValue) : marker ? normalizedValue.startsWith(marker) : canonicalValue === intent.value.trim(), canonicalValue, }; } function ipv4Bytes(address: string): number[] | undefined { const parts = address.split('.'); if (parts.length !== 4) return undefined; const bytes = parts.map((part) => Number(part)); return bytes.every((part) => Number.isInteger(part) && part >= 0 && part <= 255) ? bytes : undefined; } function ipv6Bytes(address: string): number[] | undefined { let normalized = address.toLowerCase(); if (normalized.includes('.')) { const lastColon = normalized.lastIndexOf(':'); const ipv4 = ipv4Bytes(normalized.slice(lastColon + 1)); if (lastColon < 0 || !ipv4) return undefined; normalized = `${normalized.slice(0, lastColon)}:${((ipv4[0] << 8) | ipv4[1]).toString(16)}:${((ipv4[2] << 8) | ipv4[3]).toString(16)}`; } const halves = normalized.split('::'); if (halves.length > 2) return undefined; const left = halves[0] ? halves[0].split(':') : []; const right = halves[1] ? halves[1].split(':') : []; const missing = 8 - left.length - right.length; if ((halves.length === 1 && missing !== 0) || missing < 0) return undefined; const groups = [...left, ...Array.from({ length: missing }, () => '0'), ...right]; if (groups.length !== 8 || groups.some((group) => !/^[0-9a-f]{1,4}$/.test(group))) { return undefined; } return groups.flatMap((group) => { const value = Number.parseInt(group, 16); return [value >> 8, value & 0xff]; }); } function parseIpNetwork(value: string, family: 4 | 6): IIpNetwork | undefined { const slashIndex = value.lastIndexOf('/'); const address = slashIndex >= 0 ? value.slice(0, slashIndex) : value; const maximumPrefix = family === 4 ? 32 : 128; const rawPrefix = slashIndex >= 0 ? value.slice(slashIndex + 1) : String(maximumPrefix); if (!/^\d{1,3}$/.test(rawPrefix)) return undefined; const prefixLength = Number.parseInt(rawPrefix, 10); if (prefixLength < 0 || prefixLength > maximumPrefix) return undefined; const bytes = family === 4 ? ipv4Bytes(address) : ipv6Bytes(address); return bytes ? { family, bytes, prefixLength } : undefined; } function networkContains(network: IIpNetwork, address: IIpNetwork): boolean { if (network.family !== address.family) return false; const wholeBytes = Math.floor(network.prefixLength / 8); const remainingBits = network.prefixLength % 8; for (let index = 0; index < wholeBytes; index++) { if (network.bytes[index] !== address.bytes[index]) return false; } if (remainingBits === 0) return true; const mask = (0xff << (8 - remainingBits)) & 0xff; return (network.bytes[wholeBytes] & mask) === (address.bytes[wholeBytes] & mask); } function parseAMechanism(body: string): ISpfAMechanism | undefined { let remaining = body.slice(1); let ipv6PrefixLength = 128; const doubleSlashIndex = remaining.lastIndexOf('//'); if (doubleSlashIndex >= 0) { const value = remaining.slice(doubleSlashIndex + 2); if (!/^\d{1,3}$/.test(value)) return undefined; ipv6PrefixLength = Number.parseInt(value, 10); remaining = remaining.slice(0, doubleSlashIndex); } let ipv4PrefixLength = 32; const slashIndex = remaining.lastIndexOf('/'); if (slashIndex >= 0) { const value = remaining.slice(slashIndex + 1); if (!/^\d{1,2}$/.test(value)) return undefined; ipv4PrefixLength = Number.parseInt(value, 10); remaining = remaining.slice(0, slashIndex); } if (ipv4PrefixLength > 32 || ipv6PrefixLength > 128) return undefined; if (remaining && !remaining.startsWith(':')) return undefined; const domain = remaining.slice(1).trim().toLowerCase().replace(/\.$/, ''); if (domain.includes('%') || (domain && !/^[a-z0-9_.-]+$/.test(domain))) return undefined; return { ...(domain ? { domain } : {}), ipv4PrefixLength, ipv6PrefixLength, }; } function parseSpfRecord(value: string): { parsed?: IParsedSpfRecord; error?: string } { const terms = value.trim().split(/\s+/); if (terms.shift()?.toLowerCase() !== 'v=spf1') { return { error: 'SPF record must start with v=spf1' }; } const parsed: IParsedSpfRecord = { mechanisms: [], hasRedirect: false }; const modifiers = new Set(); for (const rawTerm of terms) { if (!rawTerm) continue; let term = rawTerm; let qualifier: TSpfQualifier = '+'; let hadQualifier = false; if (/^[+\-~?]/.test(term)) { qualifier = term[0] as TSpfQualifier; term = term.slice(1); hadQualifier = true; } if (!term) return { error: 'SPF contains an empty mechanism' }; const normalized = term.toLowerCase(); if (normalized.includes('=')) { const match = term.match(/^([a-z][a-z0-9_.-]*)=(\S+)$/i); if (hadQualifier || !match) return { error: `SPF contains an invalid modifier: ${rawTerm}` }; const name = match[1].toLowerCase(); if (modifiers.has(name)) return { error: `SPF contains duplicate ${name} modifiers` }; modifiers.add(name); if (name === 'redirect') parsed.hasRedirect = true; continue; } if (normalized.startsWith('ip4:') || normalized.startsWith('ip6:')) { const family = normalized.startsWith('ip4:') ? 4 : 6; const network = parseIpNetwork(term.slice(4), family); if (!network) return { error: `SPF contains an invalid ${family === 4 ? 'ip4' : 'ip6'} mechanism: ${rawTerm}` }; parsed.mechanisms.push({ kind: 'ip', qualifier, network }); continue; } if (normalized === 'a' || normalized.startsWith('a:') || normalized.startsWith('a/')) { const mechanism = parseAMechanism(term); if (!mechanism) return { error: `SPF contains an invalid a mechanism: ${rawTerm}` }; parsed.mechanisms.push({ kind: 'a', qualifier, mechanism }); continue; } if (normalized === 'all') { parsed.mechanisms.push({ kind: 'all', qualifier }); continue; } if ( normalized === 'mx' || normalized.startsWith('mx:') || normalized.startsWith('mx/') || normalized.startsWith('include:') || normalized.startsWith('exists:') || normalized === 'ptr' || normalized.startsWith('ptr:') ) { parsed.mechanisms.push({ kind: 'unsupported', name: rawTerm }); continue; } return { error: `SPF contains an unknown mechanism: ${rawTerm}` }; } return { parsed }; } function desiredSpfAddresses(value: string): { addresses?: IIpNetwork[]; error?: string } { const parsed = parseSpfRecord(value); if (!parsed.parsed) return { error: parsed.error }; const addresses: IIpNetwork[] = []; for (const mechanism of parsed.parsed.mechanisms) { if (mechanism.kind === 'all' && mechanism.qualifier === '-') continue; if ( mechanism.kind !== 'ip' || mechanism.qualifier !== '+' || mechanism.network.prefixLength !== (mechanism.network.family === 4 ? 32 : 128) ) { return { error: 'Desired SPF intent does not contain only concrete sender addresses' }; } addresses.push(mechanism.network); } return addresses.length > 0 ? { addresses } : { error: 'Desired SPF intent does not contain concrete sender addresses' }; } async function withTimeout(promise: Promise, timeoutMs: number): Promise { let timer: ReturnType | undefined; try { return await Promise.race([ promise, new Promise((_resolve, reject) => { timer = setTimeout(() => reject(new Error(`DNS lookup timed out after ${timeoutMs}ms`)), timeoutMs); }), ]); } finally { if (timer) clearTimeout(timer); } } async function evaluateSpf( intent: IEmailDnsRecordIntent, actualValue: string, options: IMailDnsTxtPolicyOptions, ): Promise { const desired = desiredSpfAddresses(intent.value); if (!desired.addresses) return { outcome: 'invalid', reason: desired.error }; const actual = parseSpfRecord(actualValue); if (!actual.parsed) return { outcome: 'invalid', reason: actual.error }; const timeoutMs = Math.max(1, options.lookupTimeoutMs ?? 2_000); const maxDnsLookups = Math.max(1, options.maxDnsLookups ?? 10); let lookupCount = 0; const cache = new Map>(); const resolveBounded = async (name: string, type: 'A' | 'AAAA'): Promise => { const key = `${type}:${name}`; const cached = cache.get(key); if (cached) return await cached; if (!options.resolveDns) throw new Error('SPF a mechanism requires a DNS resolver'); if (lookupCount >= maxDnsLookups) { throw new Error(`SPF DNS lookup budget of ${maxDnsLookups} was exceeded`); } lookupCount++; const lookup = withTimeout(options.resolveDns(name, type), timeoutMs); cache.set(key, lookup); return await lookup; }; try { for (const address of desired.addresses) { let authorized = false; for (const mechanism of actual.parsed.mechanisms) { if (mechanism.kind === 'unsupported') { return { outcome: 'conflict', reason: `SPF authorization is indeterminate before a proven match: ${mechanism.name}`, }; } let matches = false; if (mechanism.kind === 'ip') { matches = networkContains(mechanism.network, address); } else if (mechanism.kind === 'a') { const domain = mechanism.mechanism.domain || intent.name.toLowerCase().replace(/\.$/, ''); const type = address.family === 4 ? 'A' : 'AAAA'; const prefixLength = address.family === 4 ? mechanism.mechanism.ipv4PrefixLength : mechanism.mechanism.ipv6PrefixLength; for (const answer of await resolveBounded(domain, type)) { const network = parseIpNetwork(`${answer}/${prefixLength}`, address.family); if (network && networkContains(network, address)) { matches = true; break; } } } else { matches = true; } if (!matches) continue; if (mechanism.qualifier !== '+') { return { outcome: 'conflict', reason: `SPF first match for a desired edge has qualifier ${mechanism.qualifier}`, }; } authorized = true; break; } if (!authorized) { return { outcome: 'conflict', reason: actual.parsed.hasRedirect ? 'SPF redirect is unsupported and authorization cannot be proven' : 'SPF record does not authorize every desired mail edge address', }; } } } catch (error: unknown) { return { outcome: 'lookup-error', reason: `SPF DNS lookup failed: ${(error as Error).message}` }; } return { outcome: 'valid', effectiveValue: actualValue }; } interface IDmarcPolicy { policy: 'none' | 'quarantine' | 'reject'; } function parseDmarc(value: string): { policy?: IDmarcPolicy; error?: string } { const orderedTags: Array<{ key: string; value: string }> = []; const seen = new Set(); for (const segment of value.split(';')) { const trimmed = segment.trim(); if (!trimmed) continue; const equalsIndex = trimmed.indexOf('='); if (equalsIndex <= 0) return { error: `Malformed DMARC tag: ${trimmed}` }; const key = trimmed.slice(0, equalsIndex).trim().toLowerCase(); const tagValue = trimmed.slice(equalsIndex + 1).trim(); if (!key || !tagValue || seen.has(key)) return { error: `Invalid or duplicate DMARC tag: ${key}` }; seen.add(key); orderedTags.push({ key, value: tagValue }); } if (orderedTags[0]?.key !== 'v' || orderedTags[0].value !== 'DMARC1') { return { error: 'DMARC v=DMARC1 must be the first tag' }; } const tags = new Map(orderedTags.map((tag) => [tag.key, tag.value])); const rawPolicy = tags.get('p')?.toLowerCase(); // Malformed optional tag values are ignored and locally defaulted. A missing // or malformed p therefore has effective policy "none". pct and unknown // extension tags do not change the policy-strength comparison. const policy = rawPolicy === 'quarantine' || rawPolicy === 'reject' || rawPolicy === 'none' ? rawPolicy : 'none'; return { policy: { policy } }; } function evaluateDmarc(intent: IEmailDnsRecordIntent, actualValue: string): IMailDnsTxtPolicyResult { const desired = parseDmarc(intent.value); const actual = parseDmarc(actualValue); if (!desired.policy) return { outcome: 'invalid', reason: desired.error }; if (!actual.policy) return { outcome: 'invalid', reason: actual.error }; const strength: Record = { none: 0, quarantine: 1, reject: 2 }; if (strength[actual.policy.policy] < strength[desired.policy.policy]) { return { outcome: 'conflict', reason: 'Operator DMARC policy is weaker than the desired policy' }; } return { outcome: 'valid', effectiveValue: actualValue }; } interface IDkimPolicy { publicKey: Uint8Array; keyType: string; hashes: string[]; services: string[]; } function decodeDkimPublicKey(value: string): Uint8Array | undefined { const normalized = value.replace(/\s+/g, ''); if (!normalized || !/^[A-Za-z0-9+/]+={0,2}$/.test(normalized) || normalized.length % 4 !== 0) { return undefined; } const decoded = plugins.buffer.Buffer.from(normalized, 'base64'); return decoded.toString('base64').replace(/=+$/, '') === normalized.replace(/=+$/, '') ? decoded : undefined; } function parseDkim(value: string): { policy?: IDkimPolicy; error?: string } { const tags = new Map(); for (const segment of value.split(';')) { const trimmed = segment.trim(); if (!trimmed) continue; const equalsIndex = trimmed.indexOf('='); if (equalsIndex <= 0) return { error: `Malformed DKIM tag: ${trimmed}` }; const key = trimmed.slice(0, equalsIndex).trim().toLowerCase(); const tagValue = trimmed.slice(equalsIndex + 1).trim(); if (!/^[a-z][a-z0-9]*$/.test(key) || tags.has(key)) { return { error: `Invalid or duplicate DKIM tag: ${key}` }; } tags.set(key, tagValue); } if (tags.has('v') && tags.get('v') !== 'DKIM1') { return { error: 'DKIM v must be DKIM1 when present' }; } const publicKey = decodeDkimPublicKey(tags.get('p') || ''); if (!publicKey) return { error: 'DKIM p must contain a valid non-empty base64 public key' }; const keyType = (tags.get('k') || 'rsa').toLowerCase(); const hashes = (tags.get('h') || 'sha256') .split(':').map((entry) => entry.trim().toLowerCase()).filter(Boolean); const services = (tags.get('s') || '*') .split(':').map((entry) => entry.trim().toLowerCase()).filter(Boolean); return { policy: { publicKey, keyType, hashes, services } }; } function equalBytes(left: Uint8Array, right: Uint8Array): boolean { return left.length === right.length && left.every((value, index) => value === right[index]); } function evaluateDkim(intent: IEmailDnsRecordIntent, actualValue: string): IMailDnsTxtPolicyResult { const desired = parseDkim(intent.value); const actual = parseDkim(actualValue); if (!desired.policy) return { outcome: 'invalid', reason: desired.error }; if (!actual.policy) return { outcome: 'invalid', reason: actual.error }; if (actual.policy.keyType !== 'rsa') { return { outcome: 'conflict', reason: 'DKIM k does not permit the required RSA key' }; } if (!actual.policy.hashes.includes('sha256')) { return { outcome: 'conflict', reason: 'DKIM h does not permit sha256' }; } if (!actual.policy.services.some((service) => service === '*' || service === 'email')) { return { outcome: 'conflict', reason: 'DKIM s does not permit email service' }; } if (!equalBytes(actual.policy.publicKey, desired.policy.publicKey)) { return { outcome: 'conflict', reason: `DKIM record at ${intent.name} contains a different public key` }; } return { outcome: 'valid', effectiveValue: actualValue }; } export async function evaluateMailTxtIntent( intent: IEmailDnsRecordIntent, rawValues: string[], options: IMailDnsTxtPolicyOptions = {}, ): Promise { const classified = rawValues .map((value) => classifyMailTxtRecord(intent, value)) .filter((entry) => entry.relevant); if (classified.length === 0) { return { outcome: 'missing', reason: `${intent.type} ${intent.name} is missing` }; } if (classified.length > 1) { return { outcome: 'duplicate', reason: `${intent.name} has multiple relevant TXT records` }; } const record = classified[0]; if (record.malformed || record.canonicalValue === undefined) { return { outcome: 'invalid', reason: record.malformed || 'TXT record is malformed' }; } const kind = intentKind(intent); if (kind === 'spf') return await evaluateSpf(intent, record.canonicalValue, options); if (kind === 'dmarc') return evaluateDmarc(intent, record.canonicalValue); if (kind === 'dkim') return evaluateDkim(intent, record.canonicalValue); if (record.canonicalValue === intent.value.trim()) { return { outcome: 'valid', effectiveValue: record.canonicalValue }; } return { outcome: 'conflict', reason: `TXT record at ${intent.name} does not exactly match the desired value`, }; }