Comparing version 1.0.4 to 1.0.5
'use strict'; | ||
const { authenticate } = require('../lib/mailauth'); | ||
//const { dkimSign } = require('../lib/dkim/sign'); | ||
const dns = require('dns'); | ||
@@ -14,4 +14,16 @@ const fs = require('fs'); | ||
mta: 'mx.ethereal.email', | ||
sender: 'andris@ekiri.ee' | ||
sender: 'andris@ekiri.ee', | ||
// optional. add ARC seal if possible | ||
seal: { | ||
algorithm: 'rsa-sha256', | ||
signingDomain: 'tahvel.info', | ||
selector: 'test.rsa', | ||
privateKey: fs.readFileSync('./test/fixtures/private-rsa.pem') | ||
}, | ||
resolver: async (name, rr) => { | ||
console.log('DNS', rr, name); | ||
return await dns.promises.resolve(name, rr); | ||
} | ||
}); | ||
console.log(JSON.stringify(res, false, 2)); | ||
@@ -18,0 +30,0 @@ |
@@ -40,2 +40,3 @@ 'use strict'; | ||
{ | ||
//canonicalization: 'relaxed/relaxed', | ||
algorithm: algo, | ||
@@ -42,0 +43,0 @@ signingDomain: 'tahvel.info', |
'use strict'; | ||
const { parseDkimHeaders, formatRelaxedLine, getPublicKey, formatAuthHeaderRow } = require('../../lib/tools'); | ||
const { parseDkimHeaders, formatRelaxedLine, getPublicKey, formatAuthHeaderRow, formatSignatureHeaderLine } = require('../../lib/tools'); | ||
const crypto = require('crypto'); | ||
const { DkimSigner } = require('../dkim/dkim-signer'); | ||
@@ -67,2 +68,81 @@ const verifyAS = async (chain, opts) => { | ||
const signAS = async (chain, entry, signatureData) => { | ||
let { instance, algorithm, selector, signingDomain, bodyHash, cv, signTime, privateKey } = signatureData; | ||
const signAlgo = algorithm?.split('-').shift(); | ||
signTime = signTime || new Date(); | ||
let chunks = []; | ||
for (let i = 0; i < chain.length; i++) { | ||
let link = chain[i]; | ||
chunks.push(formatRelaxedLine(link['arc-authentication-results'].original, '\r\n')); | ||
chunks.push(formatRelaxedLine(link['arc-message-signature'].original, '\r\n')); | ||
chunks.push(formatRelaxedLine(link['arc-seal'].original, '\r\n')); | ||
} | ||
chunks.push(formatRelaxedLine(entry['arc-authentication-results'], '\r\n')); | ||
chunks.push(formatRelaxedLine(entry['arc-message-signature'], '\r\n')); | ||
let headerOpts = { | ||
i: instance, | ||
a: algorithm, | ||
s: selector, | ||
d: signingDomain, | ||
cv, | ||
bh: bodyHash | ||
}; | ||
if (signTime) { | ||
if (typeof signTime === 'string' || typeof signTime === 'number') { | ||
signTime = new Date(signTime); | ||
} | ||
if (Object.prototype.toString.call(signTime) === '[object Date]' && signTime.toString() !== 'Invalid Date') { | ||
// we need a unix timestamp value | ||
signTime = Math.round(signTime.getTime() / 1000); | ||
headerOpts.t = signTime; | ||
} | ||
} | ||
let canonSignatureHeaderLine = formatSignatureHeaderLine( | ||
'AS', | ||
Object.assign( | ||
{ | ||
// make sure that b= always has a value, otherwise folding would be different | ||
b: 'a'.repeat(73) | ||
}, | ||
headerOpts | ||
), | ||
true | ||
); | ||
chunks.push( | ||
Buffer.from( | ||
formatRelaxedLine(canonSignatureHeaderLine) | ||
.toString('binary') | ||
// remove value from b= key | ||
.replace(/([;:\s]+b=)[^;]+/, '$1'), | ||
'binary' | ||
) | ||
); | ||
let canonicalizedHeader = Buffer.concat(chunks); | ||
let signature = crypto | ||
.sign( | ||
// use `null` as algorithm to detect it from the key file | ||
signAlgo === 'rsa' ? algorithm : null, | ||
canonicalizedHeader, | ||
privateKey | ||
) | ||
.toString('base64'); | ||
headerOpts.b = signature; | ||
return formatSignatureHeaderLine('AS', headerOpts, true); | ||
}; | ||
const verifyASChain = async (data, opts) => { | ||
@@ -83,3 +163,3 @@ if (!data?.chain?.length) { | ||
// throws if validation fails | ||
await verifyAS(data.chain.slice(0, i + 1)); | ||
await verifyAS(data.chain.slice(0, i + 1), opts); | ||
} | ||
@@ -105,3 +185,3 @@ | ||
// value for this header is already set | ||
let err = new Error(`Multiple "${row.key}" values for the same instance "${instance}"`); | ||
let err = new Error(`i=${instance} error=multiple ${row.key}`); | ||
err.code = 'multiple_arc_keys'; | ||
@@ -122,3 +202,3 @@ throw err; | ||
if (arcChain.length > 50) { | ||
let err = new Error(`Too many ARC instances found: "${arcChain.length}"`); | ||
let err = new Error(`chain-length=${arcChain.length}`); | ||
err.code = 'invalid_arc_count'; | ||
@@ -133,3 +213,3 @@ throw err; | ||
// not a complete sequence | ||
let err = new Error(`Invalid instance number "${arcInstance.i}". Expecting "${i + 1}"`); | ||
let err = new Error(`i=${arcInstance.i} expected=${i + 1}`); | ||
err.code = 'invalid_arc_instance'; | ||
@@ -142,3 +222,3 @@ throw err; | ||
// missing required header | ||
let err = new Error(`Missing header ${headerKey} from ARC instance ${arcInstance.i}`); | ||
let err = new Error(`i=${arcInstance.i} error=no ${headerKey}`); | ||
err.code = 'missing_arc_header'; | ||
@@ -150,3 +230,3 @@ throw err; | ||
if (i === 0 && arcInstance['arc-seal']?.parsed?.cv?.value?.toLowerCase() !== 'none') { | ||
let err = new Error(`Unexpected cv value for first ARC instance: "${arcInstance['arc-seal']?.parsed?.cv?.value}". Expecting "none"`); | ||
let err = new Error(`i=1 cv="${arcInstance['arc-seal']?.parsed?.cv?.value}`); | ||
err.code = 'invalid_cv_value'; | ||
@@ -157,3 +237,3 @@ throw err; | ||
if (i > 0 && arcInstance['arc-seal']?.parsed?.cv?.value?.toLowerCase() !== 'pass') { | ||
let err = new Error(`Unexpected cv value ARC instance ${arcInstance.i}: "${arcInstance['arc-seal']?.parsed?.cv?.value}". Expecting "pass"`); | ||
let err = new Error(`i=${arcInstance.i} cv=${arcInstance['arc-seal']?.parsed?.cv?.value}`); | ||
err.code = 'invalid_cv_value'; | ||
@@ -164,3 +244,3 @@ throw err; | ||
if (arcInstance['arc-seal']?.parsed?.h) { | ||
let err = new Error(`Unexpected h value found from ARC-Seal i=${arcInstance.i}: "${arcInstance['arc-seal']?.parsed?.h?.value}"`); | ||
let err = new Error(`i=${arcInstance.i} error=unexpected h`); | ||
err.code = 'unexpected_h_value'; | ||
@@ -198,3 +278,3 @@ throw err; | ||
result.authenticationResults.host = result.authenticationResults.value; | ||
result.authenticationResults.mta = result.authenticationResults.value; | ||
delete result.authenticationResults.value; | ||
@@ -304,2 +384,44 @@ | ||
module.exports = { getARChain, arc }; | ||
const createSeal = async data => { | ||
const { headers, arc, seal } = data; | ||
// Step 1. Calculate ARC-Message-Signature | ||
let dkimSigner = new DkimSigner({ | ||
headers, | ||
bodyHash: seal.bodyHash, | ||
arc: { | ||
instance: seal.i, | ||
algorithm: seal.algorithm, | ||
signingDomain: seal.signingDomain, | ||
selector: seal.selector, | ||
privateKey: seal.privateKey | ||
} | ||
}); | ||
// this gives us dkimSigner.arc.messageSignature | ||
await dkimSigner.finalize(); | ||
// Step 2. Calculate ARC-Seal | ||
const arcSeal = await signAS( | ||
arc.chain, | ||
{ | ||
'arc-authentication-results': seal?.authResults, | ||
'arc-message-signature': dkimSigner?.arc?.messageSignature | ||
}, | ||
{ | ||
instance: seal.i, | ||
algorithm: seal.algorithm, | ||
signingDomain: seal.signingDomain, | ||
selector: seal.selector, | ||
bodyHash: seal.bodyHash, | ||
cv: seal.cv, | ||
signTime: new Date(), | ||
privateKey: seal.privateKey | ||
} | ||
); | ||
return { | ||
headers: [arcSeal, dkimSigner?.arc?.messageSignature, seal?.authResults].map(v => v) | ||
}; | ||
}; | ||
module.exports = { getARChain, arc, createSeal }; |
@@ -13,8 +13,6 @@ 'use strict'; | ||
let { canonicalization, signTime, headerList, signatureData, arc } = options || {}; | ||
let { canonicalization, algorithm, signTime, headerList, signatureData, arc, bodyHash, headers } = options || {}; | ||
this.algorithm = algorithm || false; | ||
this.canonicalization = canonicalization || 'relaxed/relaxed'; | ||
this.headerCanon = this.canonicalization.split('/').shift().toLowerCase().trim(); | ||
// if body canonicalization is not set, then defaults to 'simple' | ||
this.bodyCanon = (this.canonicalization.split('/')[1] || 'simple').toLowerCase().trim(); | ||
@@ -35,3 +33,2 @@ this.errors = []; | ||
if (this.arc && this.arc.instance && this.arc.signingDomain && this.arc.selector && this.arc.privateKey) { | ||
this.arc.set = this.arc.set || {}; | ||
this.signatureData.push({ | ||
@@ -42,3 +39,4 @@ type: 'ARC', | ||
privateKey: this.arc.privateKey, | ||
algorithm: 'rsa-sha256', // fixed for now | ||
canonicalization: 'relaxed/relaxed', | ||
algorithm: 'rsa-sha256', // fixed for now, throws if non-rsa key is used | ||
instance: this.arc.instance | ||
@@ -49,5 +47,25 @@ }); | ||
this.bodyHashes = new Map(); | ||
// precalculated hash and headers | ||
this.bodyHash = bodyHash || null; | ||
this.headers = headers; | ||
this.setupHashes(); | ||
} | ||
getCanonicalization(signatureData) { | ||
let canonicalization = signatureData?.canonicalization || this.canonicalization; | ||
let headerCanon = canonicalization.split('/').shift().toLowerCase().trim(); | ||
let bodyCanon = (canonicalization.split('/')[1] || 'simple').toLowerCase().trim(); | ||
return { canonicalization, headerCanon, bodyCanon }; | ||
} | ||
getAlgorithm(signatureData) { | ||
let algorithm = (signatureData?.algorithm || this.algorithm || '').toLowerCase().trim(); | ||
let signAlgo = algorithm.split('-').shift().toLowerCase().trim() || false; // default is derived from key | ||
let hashAlgo = algorithm.split('-').pop().toLowerCase().trim() || 'sha256'; | ||
return { algorithm, signAlgo, hashAlgo }; | ||
} | ||
setupHashes() { | ||
@@ -59,7 +77,14 @@ for (let signatureData of this.signatureData) { | ||
let algorithm = (signatureData.algorithm || '').toLowerCase().trim(); | ||
let hashAlgo = algorithm.split('-').pop().toLowerCase().trim() || 'sha256'; | ||
let { hashAlgo } = this.getAlgorithm(signatureData); | ||
let { bodyCanon } = this.getCanonicalization(signatureData); | ||
if (!this.bodyHashes.has(hashAlgo)) { | ||
this.bodyHashes.set(hashAlgo, { hasher: null, hash: null }); | ||
let hashKey = `${bodyCanon}:${hashAlgo}`; | ||
if (!this.bodyHashes.has(hashKey)) { | ||
this.bodyHashes.set(hashKey, { | ||
bodyCanon, | ||
hashAlgo, | ||
hasher: null, | ||
hash: this.bodyHash | ||
}); | ||
} | ||
@@ -75,10 +100,10 @@ } | ||
let [signing, hashing] = algorithm.split('-'); | ||
let [signAlgo, hashAlgo] = algorithm.split('-'); | ||
if (!['rsa', 'ed25519'].includes(signing)) { | ||
throw new Error('Unknown signing algorithm: ' + signing); | ||
if (!['rsa', 'ed25519'].includes(signAlgo)) { | ||
throw new Error('Unknown signing algorithm: ' + signAlgo); | ||
} | ||
if (!['sha256', 'sha1'].includes(hashing)) { | ||
throw new Error('Unknown hashing algorithm: ' + hashing); | ||
if (!['sha256', 'sha1'].includes(hashAlgo)) { | ||
throw new Error('Unknown hashing algorithm: ' + hashAlgo); | ||
} | ||
@@ -94,4 +119,5 @@ } catch (err) { | ||
for (let hashAlgo of this.bodyHashes.keys()) { | ||
this.bodyHashes.get(hashAlgo).hasher = dkimBody(this.bodyCanon, hashAlgo); | ||
for (let hashKey of this.bodyHashes.keys()) { | ||
let [bodyCanon, hashAlgo] = hashKey.split(':'); | ||
this.bodyHashes.get(hashKey).hasher = dkimBody(bodyCanon, hashAlgo); | ||
} | ||
@@ -101,5 +127,5 @@ } | ||
async nextChunk(chunk) { | ||
for (let hashAlgo of this.bodyHashes.keys()) { | ||
if (this.bodyHashes.get(hashAlgo).hasher) { | ||
this.bodyHashes.get(hashAlgo).hasher.update(chunk); | ||
for (let hashKey of this.bodyHashes.keys()) { | ||
if (this.bodyHashes.get(hashKey).hasher) { | ||
this.bodyHashes.get(hashKey).hasher.update(chunk); | ||
} | ||
@@ -114,8 +140,12 @@ } | ||
for (let hashAlgo of this.bodyHashes.keys()) { | ||
if (this.bodyHashes.get(hashAlgo).hasher) { | ||
this.bodyHashes.get(hashAlgo).hash = this.bodyHashes.get(hashAlgo).hasher.digest('base64'); | ||
for (let hashKey of this.bodyHashes.keys()) { | ||
if (this.bodyHashes.get(hashKey).hasher) { | ||
this.bodyHashes.get(hashKey).hash = this.bodyHashes.get(hashKey).hasher.digest('base64'); | ||
} | ||
} | ||
return this.finalize(); | ||
} | ||
async finalize() { | ||
for (let signatureData of this.signatureData || []) { | ||
@@ -142,6 +172,7 @@ if (!signatureData.privateKey) { | ||
let algorithm = (signatureData.algorithm || '').toLowerCase().trim(); | ||
let signAlgo = algorithm.split('-').shift().toLowerCase().trim() || null; | ||
let hashAlgo = algorithm.split('-').pop().toLowerCase().trim() || 'sha256'; | ||
let { algorithm, signAlgo, hashAlgo } = this.getAlgorithm(signatureData); | ||
let { bodyCanon } = this.getCanonicalization(signatureData); | ||
let hashKey = `${bodyCanon}:${hashAlgo}`; | ||
try { | ||
@@ -195,5 +226,5 @@ let keyType = crypto.createPrivateKey({ key: signatureData.privateKey, format: 'pem' }).asymmetricKeyType; | ||
algorithm, | ||
canonicalization: this.canonicalization, | ||
canonicalization: this.getCanonicalization(signatureData).canonicalization, | ||
signTime: this.signTime, | ||
bodyHash: this.bodyHashes.has(hashAlgo) ? this.bodyHashes.get(hashAlgo).hash : null | ||
bodyHash: this.bodyHashes.has(hashKey) ? this.bodyHashes.get(hashKey).hash : null | ||
}) | ||
@@ -218,3 +249,3 @@ ); | ||
case 'ARC': | ||
this.arc.set['arc-message-signature'] = signatureHeaderLine; | ||
this.arc.messageSignature = signatureHeaderLine; | ||
break; | ||
@@ -221,0 +252,0 @@ |
@@ -26,3 +26,15 @@ 'use strict'; | ||
// ARC verification info | ||
this.arc = { chain: false }; | ||
// should we also seal this message using ARC | ||
this.seal = this.options.seal; | ||
if (this.seal) { | ||
// calculate body hash for the seal | ||
let bodyCanon = 'relaxed'; | ||
let hashAlgo = 'sha256'; | ||
this.sealBodyHashKey = [bodyCanon, hashAlgo].join(':'); | ||
this.bodyHashes.set(this.sealBodyHashKey, dkimBody(bodyCanon, hashAlgo, false)); | ||
} | ||
} | ||
@@ -147,2 +159,3 @@ | ||
// convert bodyHashes from hash objects to base64 strings | ||
for (let [key, bodyHash] of this.bodyHashes.entries()) { | ||
@@ -291,2 +304,6 @@ this.bodyHashes.set(key, bodyHash.digest('base64')); | ||
} | ||
if (this.seal && this.bodyHashes.has(this.sealBodyHashKey) && typeof this.bodyHashes.get(this.sealBodyHashKey) === 'string') { | ||
this.seal.bodyHash = this.bodyHashes.get(this.sealBodyHashKey); | ||
} | ||
} | ||
@@ -293,0 +310,0 @@ } |
@@ -17,16 +17,29 @@ 'use strict'; | ||
Object.defineProperty(result, 'headers', { | ||
enumerable: false, | ||
configurable: false, | ||
writable: false, | ||
value: dkimVerifier.headers | ||
}); | ||
if (dkimVerifier.headers) { | ||
Object.defineProperty(result, 'headers', { | ||
enumerable: false, | ||
configurable: false, | ||
writable: false, | ||
value: dkimVerifier.headers | ||
}); | ||
} | ||
Object.defineProperty(result, 'arc', { | ||
enumerable: false, | ||
configurable: false, | ||
writable: false, | ||
value: dkimVerifier.arc | ||
}); | ||
if (dkimVerifier.arc) { | ||
Object.defineProperty(result, 'arc', { | ||
enumerable: false, | ||
configurable: false, | ||
writable: false, | ||
value: dkimVerifier.arc | ||
}); | ||
} | ||
if (dkimVerifier.seal) { | ||
Object.defineProperty(result, 'seal', { | ||
enumerable: false, | ||
configurable: false, | ||
writable: false, | ||
value: dkimVerifier.seal | ||
}); | ||
} | ||
return result; | ||
@@ -33,0 +46,0 @@ }; |
@@ -6,3 +6,3 @@ 'use strict'; | ||
const { dmarc } = require('./dmarc'); | ||
const { arc } = require('./arc'); | ||
const { arc, createSeal } = require('./arc'); | ||
const libmime = require('libmime'); | ||
@@ -28,3 +28,4 @@ const os = require('os'); | ||
resolver: opts.resolver, | ||
sender: opts.sender | ||
sender: opts.sender, | ||
seal: opts.seal | ||
}), | ||
@@ -39,10 +40,8 @@ spf(opts) | ||
let headers = []; | ||
let arHeader = []; | ||
if (dkimResult && dkimResult.results) { | ||
dkimResult.results.forEach(row => { | ||
arHeader.push(`${libmime.foldLines(row.info, 160)}`); | ||
}); | ||
} | ||
dkimResult?.results?.forEach(row => { | ||
arHeader.push(`${libmime.foldLines(row.info, 160)}`); | ||
}); | ||
if (spfResult) { | ||
@@ -53,3 +52,3 @@ arHeader.push(libmime.foldLines(spfResult.info, 160)); | ||
if (arcResult && arcResult.info) { | ||
if (arcResult?.info) { | ||
arHeader.push(`${libmime.foldLines(arcResult.info, 160)}`); | ||
@@ -74,2 +73,27 @@ } | ||
// seal only messages with a valid ARC chain | ||
if (dkimResult?.seal && ['none', 'pass'].includes(arcResult?.status?.result)) { | ||
let i = arcResult.i + 1; | ||
let seal = Object.assign( | ||
{ | ||
i, | ||
cv: arcResult.status.result, | ||
authResults: `ARC-Authentication-Results: i=${i}; ${opts.mta};\r\n ` + arHeader.join(';\r\n ') | ||
}, | ||
dkimResult.seal | ||
); | ||
// get ARC sealing headers to prepend to the message | ||
let sealResult = await createSeal( | ||
{ | ||
headers: dkimResult.headers, | ||
arc: dkimResult.arc, | ||
seal | ||
}, | ||
opts | ||
); | ||
sealResult?.headers?.reverse().forEach(header => headers.unshift(header)); | ||
} | ||
return { | ||
@@ -76,0 +100,0 @@ dkim: dkimResult, |
{ | ||
"name": "mailauth", | ||
"version": "1.0.4", | ||
"version": "1.0.5", | ||
"description": "Email authentication library for Node.js", | ||
@@ -5,0 +5,0 @@ "main": "lib/mailauth.js", |
@@ -11,2 +11,4 @@ # mailauth | ||
- [ ] ARC sealing | ||
- [x] Sealing on authentication | ||
- [ ] Sealing after modifications | ||
- [ ] MTA-STS resolver | ||
@@ -62,7 +64,7 @@ | ||
Validate DKIM signatures, SPF, DMARC and ARC for an email. | ||
Validate DKIM signatures, SPF, DMARC and ARC for an email. Also can seal a validated message with ARC. | ||
```js | ||
const { authenticate } = require('mailauth'); | ||
const { headers } = await authenticate( | ||
const { dkim, spf, arc, dmarc, headers } = await authenticate( | ||
message, // either a String, a Buffer or a Readable Stream | ||
@@ -75,3 +77,14 @@ { | ||
mta: 'mx.ethereal.email', // server processing this message, defaults to os.hostname() | ||
sender: 'andris@ekiri.ee' // MAIL FROM address | ||
sender: 'andris@ekiri.ee', // MAIL FROM address | ||
// Optional ARC seal settings. If this is set then resulting headers include | ||
// a complete ARC header set (unless the message has a failing ARC chain) | ||
seal: { | ||
signingDomain: 'tahvel.info', | ||
selector: 'test.rsa', | ||
privateKey: fs.readFileSync('./test/fixtures/private-rsa.pem') | ||
}, | ||
// Optional DNS resolver function (defaults to `dns.promises.resolve`) | ||
resolver: async (name, rr) => await dns.promises.resolve(name, rr) | ||
} | ||
@@ -97,2 +110,4 @@ ); | ||
You can see full output (structured data for DKIM, SPF, DMARC and ARC) from [this example](https://gist.github.com/andris9/6514b5e7c59154a5b08636f99052ce37). | ||
## DKIM | ||
@@ -107,7 +122,9 @@ | ||
{ | ||
// optional canonicalization, default is "relaxed/relaxed" | ||
// this option applies to all signatures, so you can't create multiple signatures | ||
// that use different canonicalization | ||
// Optional default canonicalization, default is "relaxed/relaxed" | ||
canonicalization: 'relaxed/relaxed', // c= | ||
// Optional default signing and hashing algorithm | ||
// Mostly useful when you want to use rsa-sha1, otherwise no need to set | ||
algorithm: 'rsa-sha256', | ||
// optional, default is current time | ||
@@ -125,5 +142,9 @@ signTime: new Date(), // t= | ||
privateKey: fs.readFileSync('./test/fixtures/private-rsa.pem'), | ||
// Optional algorithm, default is derived from the key. | ||
// Mostly useful when you want to use rsa-sha1, otherwise no need to set | ||
algorithm: 'rsa-sha256' | ||
// Overrides whatever was set in parent object | ||
algorithm: 'rsa-sha256', | ||
// Optional signature specifc canonicalization, overrides whatever was set in parent object | ||
canonicalization: 'relaxed/relaxed' // c= | ||
} | ||
@@ -130,0 +151,0 @@ ] |
Sorry, the diff of this file is not supported yet
425851
3495
215
9