mina-attestations
Version:
Private Attestations on Mina
317 lines • 13.3 kB
JavaScript
/**
* Self-contained implementation of DKIM verification.
* Spec: https://datatracker.ietf.org/doc/html/rfc6376
*
* Significant portions are copied and modified from
* - zk-email-verify: https://github.com/zkemail/zk-email-verify
* - mailauth: https://github.com/postalsys/mailauth
*/
import { arrayEqual, assert, assertDefined } from "../util.js";
import { parseDkimHeaders } from "./parse-dkim-headers.js";
import { TupleN } from 'o1js';
import { fromBase64 } from "./base64.js";
import { resolveDNSHTTP } from "./dns-over-http.js";
export { verifyDkim, prepareEmailForVerification, fetchPublicKeyFromDNS };
let dec = new TextDecoder();
let enc = new TextEncoder();
/**
* Verify DKIM signature on the given email.
*/
async function verifyDkim(email) {
// parse email for verification inputs
let { canonicalHeader, canonicalBody, dkimHeader } = prepareEmailForVerification(email);
// compute and compare sha256 body hash
let { bodyHashSpec, bodyHash } = dkimHeader;
let actualBodyHash = await computeBodyHash(canonicalBody, bodyHashSpec);
assert(arrayEqual(actualBodyHash, fromBase64(bodyHash)), 'Body hash mismatch');
// get public key from DNS, verify signature
assert(dkimHeader.signAlgo === 'rsa', 'Only RSA signature is supported');
let { publicKey } = await fetchPublicKeyFromDNS(dkimHeader);
let ok = await crypto.subtle.verify('RSASSA-PKCS1-v1_5', publicKey, fromBase64(dkimHeader.signature), enc.encode(canonicalHeader));
assert(ok, 'Signature verification failed');
}
/**
* Given an email string, extracts email header and body in their
* canonicalized form and parses the DKIM configuration from the header.
*/
function prepareEmailForVerification(email) {
let emailBytes = enc.encode(email);
let { headerBytes, bodyBytes } = splitEmail(emailBytes);
let header = dec.decode(headerBytes);
let body = dec.decode(bodyBytes);
// parse and validate headers
let headers = parseHeaders(header);
// TODO: is it correct to only allow one DKIM signature?
let [dkimHeaderRaw, ...others] = headers.filter((h) => h.key === 'dkim-signature');
assertDefined(dkimHeaderRaw, 'No DKIM signature found');
assert(others.length === 0, 'Expected at most one DKIM signature');
let dkimHeaderParsed = parseDkimHeaders(dkimHeaderRaw.line).parsed;
assertDefined(dkimHeaderParsed, 'Failed to parse DKIM header');
let dkimHeader = validateDkimHeader(dkimHeaderParsed);
// canonical body
let canonicalBody = canonicalizeBody(body, dkimHeader.bodyCanon);
// canonical header
let headersToSign = getHeadersToSign(headers, dkimHeader.headerFields);
let canonicalHeaders = canonicalizeHeader(headersToSign, dkimHeader.headerCanon);
let canonicalHeader = canonicalHeaders.join('\r\n');
let canonicalDkimHeader = canonicalHeaders.pop();
return { canonicalHeader, canonicalBody, canonicalDkimHeader, dkimHeader };
}
async function fetchPublicKeyFromDNS({ selector, signingDomain, }) {
let dnsName = `${selector}._domainkey.${signingDomain}`;
let response = await resolveDNSHTTP(dnsName);
return await extractDnsPublicKey(response);
}
async function computeBodyHash(canonicalBody, spec) {
assert(spec.algo !== 'sha1', 'sha1 is not supported');
// TODO use the maxBodyLength?
let canonicalBodyBytes = enc.encode(canonicalBody);
return crypto.subtle.digest('SHA-256', canonicalBodyBytes);
}
/**
* Find end of the header and split the email into header and body
*/
function splitEmail(emailBytes) {
let n = emailBytes.length;
let headerLength;
const LF = 0x0a; // \n
const CR = 0x0d; // \r
// header ends with either \n\n or \n\r\n
for (let i = 0; i < n; i++) {
let b0 = emailBytes[i];
if (b0 === LF && i > 0) {
let b1 = emailBytes[i - 1];
let b2 = emailBytes[i - 2];
if (b1 === LF || (b1 === CR && b2 === LF)) {
headerLength = i + 1;
break;
}
}
}
assert(headerLength !== undefined, 'No end of header found');
let headerBytes = emailBytes.subarray(0, headerLength);
let bodyBytes = emailBytes.subarray(headerLength);
return { headerBytes, bodyBytes };
}
/**
* Parse email headers into individual lines with keys.
*
* This was copied and modified from zk-email-verify, which copied and modified from mailauth:
* https://github.com/postalsys/mailauth
*
* TODO: where is the spec for this?
*/
function parseHeaders(headerString) {
let rows = headerString
.replace(/[\r\n]+$/, '')
.split(/\r?\n/)
.map((row) => [row]);
// lines that start with any whitespace are collapsed with the previous line
for (let i = rows.length - 1; i > 0; i--) {
if (/^\s/.test(rows[i][0])) {
rows[i - 1] = rows[i - 1].concat(rows[i]);
rows.splice(i, 1);
}
}
return rows.map((row) => {
let line = row.join('\r\n');
let key = line.match(/^[^:]+/) ?? [''];
let casedKey = key[0].trim();
return { key: casedKey.toLowerCase(), casedKey, line };
});
}
/**
* Validate and extract DKIM header fields after initial parsing
*/
function validateDkimHeader(dkimHeader) {
// validate algorithms
let algorithm = dkimHeader.a?.value;
assertString(algorithm, 'Invalid algorithm');
let [signAlgo, hashAlgo] = TupleN.fromArray(2, algorithm.split('-'));
signAlgo = signAlgo.toLowerCase().trim();
hashAlgo = hashAlgo.toLowerCase().trim();
assertContains(['rsa', 'ed25519'], signAlgo, 'Invalid sign algorithm');
assertContains(['sha256', 'sha1'], hashAlgo, 'Invalid hash algorithm');
// validate canonicalization
let canonicalization = dkimHeader.c?.value;
assertString(canonicalization, 'Invalid canonicalization');
let [headerCanon, bodyCanon] = canonicalization.split('/');
headerCanon = headerCanon?.toLowerCase().trim();
// if body canonicalization is not set, then defaults to 'simple'
bodyCanon = (bodyCanon || 'simple').toLowerCase().trim();
assertContains(['simple', 'relaxed'], headerCanon, 'Invalid header canon');
assertContains(['simple', 'relaxed'], bodyCanon, 'Invalid body canon');
// validate max body length and body hash
let maxBodyLength = dkimHeader.l?.value;
assert(maxBodyLength === undefined ||
(typeof maxBodyLength === 'number' && !isNaN(maxBodyLength)), 'Invalid max body length');
let bodyHashSpec = { algo: hashAlgo, maxBodyLength };
let bodyHash = dkimHeader.bh?.value;
assertString(bodyHash, 'Invalid or missing body hash');
// validate header fields
let signingHeaderFields = dkimHeader.h?.value;
assertNonemptyString(signingHeaderFields, 'Invalid or missing signing header fields');
let headerFields = signingHeaderFields
.split(':')
.map((f) => f.trim().toLowerCase());
// validate signing domain and selector
let signingDomain = dkimHeader.d?.value;
let selector = dkimHeader.s?.value;
assertNonemptyString(signingDomain, 'Invalid signing domain');
assertNonemptyString(selector, 'Invalid selector');
// signature value
let signature = dkimHeader.b?.value;
assertString(signature, 'Invalid signature');
return {
signAlgo,
hashAlgo,
headerCanon,
bodyCanon,
signingDomain,
selector,
bodyHashSpec,
bodyHash,
headerFields,
signature,
};
}
function canonicalizeBody(s, canonicalization) {
switch (canonicalization) {
case 'simple':
return canonicalizeBodySimple(s);
case 'relaxed':
return canonicalizeBodyRelaxed(s);
}
}
/**
* The "relaxed" body canonicalization algorithm MUST apply the
* following steps (a) and (b) in order:
*
* a. Reduce whitespace:
* - Ignore all whitespace at the end of lines. Implementations
* MUST NOT remove the CRLF at the end of the line.
* - Reduce all sequences of WSP within a line to a single SP character.
*
* b. Ignore all empty lines at the end of the message body. "Empty
* line" is defined in Section 3.4.3. If the body is non-empty but
* does not end with a CRLF, a CRLF is added. (For email, this is
* only possible when using extensions to SMTP or non-SMTP transport
* mechanisms.)
*/
function canonicalizeBodyRelaxed(s) {
// NOTE: This section assumes that the message is already in "network
// normal" format (text is ASCII encoded, lines are separated with CRLF
// characters, etc.)
let lines = normalizeLineBreaks(s).split(/\r\n/);
// a. Reduce whitespace
lines = lines.map((line) => line.trimEnd().replace(/\s+/g, ' '));
// b. Ignore all empty lines at the end of the message body
while (lines.length > 0 && lines[lines.length - 1] === '') {
lines.pop();
}
// Implementations MUST NOT remove the CRLF at the end of the line.
// If the body is non-empty but does not end with a CRLF, a CRLF is added.
return lines.join('\r\n') + '\r\n';
}
/**
* The "simple" body canonicalization algorithm ignores all empty lines
* at the end of the message body. An empty line is a line of zero
* length after removal of the line terminator. If there is no body or
* no trailing CRLF on the message body, a CRLF is added.
*/
function canonicalizeBodySimple(s) {
// same as relaxed but without changing whitespace within lines
let lines = normalizeLineBreaks(s).split(/\r\n/);
while (lines.length > 0 && lines[lines.length - 1] === '') {
lines.pop();
}
return lines.join('\r\n') + '\r\n';
}
function canonicalizeHeader(headers, canonicalization) {
// no changes at all for simple canonicalization
if (canonicalization === 'simple')
return headers;
return headers.map(canonicalizeHeaderLineRelaxed);
}
function canonicalizeHeaderLineRelaxed(line) {
// 3.4.2
return (line
// unfold continuation lines
.replace(/\r?\n/g, '')
// keys to lowercase, trim around :
.replace(/^([^:]*):\s*/, (m, k) => k.toLowerCase().trim() + ':')
// single WSP
.replace(/\s+/g, ' ')
.trim());
}
/**
* Sole \r and \n are normalized to \r\n
*/
function normalizeLineBreaks(s) {
return s.replace(/\r(?!\n)|(?<!\r)\n/g, '\r\n');
}
/**
* Returns header lines to sign, in the correct order.
*
* See 3.5, h= tag on non-existent headers and case-insensivity
* See 5.4.2 for treatment of duplicate headers
*/
function getHeadersToSign(inputHeaders, headerFields) {
// we find each correct header field starting from the bottom of the header!
let unusedHeaders = [...inputHeaders];
let headers = [];
for (let field of headerFields) {
let i = unusedHeaders.findLastIndex((h) => h.key === field);
// non-existent headers have to be ignored i.e. treated as an empty string
if (i === -1)
continue;
headers.push(unusedHeaders[i].line);
unusedHeaders.splice(i, 1);
}
// replace "b=<...>" with "b=" in the signature header
let sig = unusedHeaders.find((h) => h.key === 'dkim-signature');
assertDefined(sig, 'No DKIM signature header found');
let signatureHeader = sig.line.replace(/([;:\s]+b=)[^;]+/, (_, p1) => p1);
// signature header must not be included in the signed header fields so far
// it's appended at the end instead (without the actual signature)
assert(!headerFields.includes('dkim-signature'), 'Invalid header fields');
headers.push(signatureHeader);
// header fields must include "from"
assert(headerFields.includes('from'), 'Invalid header fields (missing from)');
return headers;
}
/**
* Extract public key from DNS TXT record
*
* This was copied and modified from zk-email-verify, which copied and modified from mailauth:
* https://github.com/postalsys/mailauth
*/
async function extractDnsPublicKey(s) {
let rr = s.replaceAll(/\s+/g, '').replaceAll('"', '');
let entry = parseDkimHeaders(rr).parsed;
assertDefined(entry, 'Failed to parse public key response');
let publicKeyBase64 = entry.p?.value;
let keyVersion = entry.v?.value;
let keyType = entry.k?.value;
assertNonemptyString(publicKeyBase64, 'Invalid public key value');
assertNonemptyString(keyVersion, 'Invalid key version');
assertNonemptyString(keyType, 'Invalid key type');
assert(keyVersion.toLowerCase() === 'dkim1', 'Invalid key version');
assert(keyType.toLowerCase() === 'rsa', 'Key type must be RSA');
let publicKeyBytesDer = fromBase64(publicKeyBase64);
let publicKey = await crypto.subtle.importKey('spki', publicKeyBytesDer, { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' }, false, ['verify']);
let modulusLength = publicKey.algorithm
.modulusLength;
assert(modulusLength !== undefined && modulusLength >= 1024, `Invalid public key length: ${modulusLength}`);
return { publicKey, publicKeyBytesDer, modulusLength };
}
function assertString(value, message) {
assert(typeof value === 'string', message);
}
function assertNonemptyString(value, message) {
assert(typeof value === 'string' && value !== '', message);
}
function assertContains(arr, value, message) {
assert(arr.includes(value), message);
}
//# sourceMappingURL=dkim.js.map