[WIP] Start DKIM implementation - body hash works, header hash does not
This commit is contained in:
parent
ec5bb4a4a8
commit
2774a32366
171
src/dkim.ts
Normal file
171
src/dkim.ts
Normal file
@ -0,0 +1,171 @@
|
||||
import {parseHeaders} from "letterparser";
|
||||
import type {Maybe} from "./bones";
|
||||
import {z} from "zod";
|
||||
import dns from "node:dns/promises";
|
||||
import crypto from "node:crypto";
|
||||
|
||||
type MailHeaders = {[key: string]: Maybe<string>}
|
||||
|
||||
const dkimPublicKey = z.object({
|
||||
v: z.literal('DKIM1').optional(),
|
||||
h: z.string().optional(),
|
||||
k: z.literal('rsa').optional(),
|
||||
n: z.string().optional(),
|
||||
p: z.string(),
|
||||
s: z.string().optional(),
|
||||
t: z.string().optional(),
|
||||
})
|
||||
|
||||
type DKIMPublicKey = z.infer<typeof dkimPublicKey>
|
||||
|
||||
const dkimHeaderSchema = z.object({
|
||||
v: z.literal('1'),
|
||||
a: z.string(),
|
||||
c: z.string(),
|
||||
d: z.string(),
|
||||
s: z.string(),
|
||||
t: z.string(),
|
||||
h: z.string(),
|
||||
bh: z.string(),
|
||||
b: z.string(),
|
||||
canonicalizeHeaders: z.enum(['simple', 'relaxed']),
|
||||
canonicalizeBody: z.enum(['simple', 'relaxed']),
|
||||
})
|
||||
|
||||
type DKIMHeader = z.infer<typeof dkimHeaderSchema>
|
||||
|
||||
function canonicalizeHeaders(headers: MailHeaders, mode: 'simple'|'relaxed'): MailHeaders {
|
||||
if ( mode === 'simple' ) {
|
||||
return headers
|
||||
}
|
||||
|
||||
const newHeaders: MailHeaders = {}
|
||||
for ( const key of Object.keys(headers) ) {
|
||||
newHeaders[key.toLowerCase()] = headers[key]?.replace(/\s\s+/g, ' ').trimEnd()
|
||||
}
|
||||
return newHeaders
|
||||
}
|
||||
|
||||
function hashDKIMHeaders(headers: MailHeaders, dkim: DKIMHeader): [string, string] {
|
||||
const canonicalizedHeaders = canonicalizeHeaders(headers, dkim.canonicalizeHeaders)
|
||||
|
||||
let rawDKIMHeaderString = ''
|
||||
const seenHeaders: string[] = []
|
||||
for ( const header of dkim.h.split(':') ) {
|
||||
if ( seenHeaders.includes(header) ) continue
|
||||
rawDKIMHeaderString += `${header}:${canonicalizedHeaders[header]?.trim() || ''}\r\n`
|
||||
seenHeaders.push(header)
|
||||
}
|
||||
|
||||
rawDKIMHeaderString += `dkim-signature:${canonicalizedHeaders['dkim-signature']!.trim().replace(/b=[\w0-9\s/+=]+/, 'b=')}\r\n`
|
||||
|
||||
const hasher = new Bun.CryptoHasher('sha256')
|
||||
hasher.update(rawDKIMHeaderString)
|
||||
return [rawDKIMHeaderString, hasher.digest('base64')]
|
||||
}
|
||||
|
||||
function parseDKIMHeader(raw: string): DKIMHeader {
|
||||
const val: Record<string, string> = {}
|
||||
|
||||
for ( const part of raw.split(';') ) {
|
||||
const split = part.split('=')
|
||||
if ( split.length < 2 ) {
|
||||
throw new Error('Bad DKIM record entry: ' + part)
|
||||
}
|
||||
|
||||
const key = split.shift()!
|
||||
val[key.trim()] = split.join('=').replace(/(\n|\t|\r|\s)/g, '')
|
||||
}
|
||||
|
||||
val.canonicalizeHeaders = val.c?.split('/')?.[0]
|
||||
val.canonicalizeBody = val.c?.split('/')?.[1]
|
||||
|
||||
return dkimHeaderSchema.parse(val)
|
||||
}
|
||||
|
||||
function parseRawBody(raw: string): string {
|
||||
// Split the raw body by new line:
|
||||
const lines = raw.split('\n')
|
||||
for ( let i = 0; i < lines.length; i += 1 ) {
|
||||
if ( !lines[i].trim() ) {
|
||||
return lines.slice(i).join('\n').trimStart()
|
||||
}
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
function computeDKIMBodyHash(raw: string): string {
|
||||
const body = parseRawBody(raw)
|
||||
const hasher = new Bun.CryptoHasher('sha256')
|
||||
hasher.update(body)
|
||||
return hasher.digest('base64')
|
||||
}
|
||||
|
||||
async function getDKIMPublicKey(domain: string, selector: string) {
|
||||
const query = `${selector}._domainkey.${domain}.`
|
||||
const r = await dns.resolveTxt(query)
|
||||
const str = r.map(i => i.join('')).join('')
|
||||
|
||||
const val: Record<string, string> = {}
|
||||
for ( const part of str.split(';') ) {
|
||||
const split = part.split('=')
|
||||
if ( split.length < 2 ) {
|
||||
throw new Error('Bad DKIM record entry: ' + part)
|
||||
}
|
||||
|
||||
const key = split.shift()!
|
||||
val[key.trim()] = split.join('=').replace(/(\n|\t|\r|\s)/g, '')
|
||||
}
|
||||
|
||||
return dkimPublicKey.parse(val)
|
||||
}
|
||||
|
||||
const file = Bun.file('sample.eml')
|
||||
const arrBuf = await file.arrayBuffer()
|
||||
const buf = Buffer.from(arrBuf)
|
||||
const content = buf.toString('utf-8')
|
||||
|
||||
const bh = computeDKIMBodyHash(content)
|
||||
const rawHeaders = parseHeaders(content)
|
||||
const headers = canonicalizeHeaders(rawHeaders, 'relaxed')
|
||||
const dkim = parseDKIMHeader(headers['dkim-signature'] || '')
|
||||
|
||||
const bhMatches = bh === dkim.bh
|
||||
|
||||
const pk = await getDKIMPublicKey(dkim.d, dkim.s)
|
||||
|
||||
const [dkimHeaders, dkimHeaderHash] = hashDKIMHeaders(rawHeaders, dkim)
|
||||
|
||||
const verifier = crypto.createVerify('RSA-SHA256')
|
||||
verifier.update(dkimHeaderHash, 'base64')
|
||||
|
||||
const pemkey = `-----BEGIN PUBLIC KEY-----\n${pk.p}\n-----END PUBLIC KEY-----`
|
||||
const result = verifier.verify(pemkey, dkim.b, 'base64')
|
||||
|
||||
console.log({
|
||||
bh,
|
||||
headers,
|
||||
dkim,
|
||||
bhMatches,
|
||||
pk,
|
||||
dkimHeaders,
|
||||
dkimHeaderHash,
|
||||
result,
|
||||
})
|
||||
|
||||
|
||||
type DKIMResult = {
|
||||
pass: boolean,
|
||||
bodyHashResult: boolean,
|
||||
rawMail: string,
|
||||
bodyHash: string,
|
||||
canonicalizedHeaders: MailHeaders,
|
||||
dkim: DKIMHeader,
|
||||
key: DKIMPublicKey,
|
||||
headerString: string,
|
||||
headerHash: string,
|
||||
headerHashResult: boolean,
|
||||
}
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user