mirror of
https://github.com/mailscope/kumomta.git
synced 2026-10-29 09:46:15 +00:00
262 lines
6.8 KiB
Lua
262 lines
6.8 KiB
Lua
local mod = {}
|
|
local kumo = require 'kumo'
|
|
local utils = require 'policy-extras.policy_utils'
|
|
|
|
local DKIM_PATH = '/opt/kumomta/etc/dkim'
|
|
|
|
--[[
|
|
Usage example:
|
|
|
|
local dkim_sign = require 'policy-extras.dkim_sign'
|
|
local dkim_signer = dkim_sign:setup({'/opt/kumomta/etc/dkim_data.toml'})
|
|
|
|
kumo.on('smtp_server_message_received', function(msg)
|
|
dkim_signer(msg)
|
|
end)
|
|
|
|
kumo.on('http_server_message_received', function(msg)
|
|
dkim_signer(msg)
|
|
end)
|
|
|
|
]]
|
|
|
|
--[[
|
|
Example data file structure:
|
|
|
|
[base]
|
|
# If these are present, we'll use hashicorp vault instead
|
|
# of reading from disk
|
|
vault_mount = "secret"
|
|
vault_path_prefix = "dkim/"
|
|
|
|
# To do double or triple signing, add each additional
|
|
# signature name to this list and see the `signature."MyESPName"`
|
|
# block below
|
|
additional_signatures = ["MyESPName"]
|
|
|
|
# Default selector to assume if the domain/signature block
|
|
# doesn't specify one
|
|
selector = "dkim1024"
|
|
|
|
# The default set of headers to sign if otherwise unspecified
|
|
headers = ["From", "To", "Subject", "Date", "MIME-Version", "Content-Type", "Sender"]
|
|
|
|
# Domain blocks match based on the sender domain of the
|
|
# incoming message
|
|
[domain."example.com"]
|
|
selector = 'dkim1024'
|
|
headers = ["From", "To", "Subject", "Date", "MIME-Version", "Content-Type", "Sender"]
|
|
algo = "sha256" # or "ed25519". Omit to use the default of "sha256"
|
|
|
|
# optional overridden filename.
|
|
# Default is "/opt/kumomta/etc/dkim/DOMAIN/SELECTOR.key"
|
|
filename = "/full/path/to/key."
|
|
|
|
# TODO: reception-time policy for signing based on DNS.
|
|
policy = "TempFailIfNotInDNS" # Reject
|
|
#policy = "SignAlways" # Sign and relay
|
|
#policy = "SignOnlyIfInDNS" # Don't sign. Allow fallback to additional_signatures
|
|
|
|
# The signature block is independent of the sender domain.
|
|
# They are consulted based on the value of `base.additional_signatures`
|
|
# above.
|
|
# In addition to the same values that are found in the `domain` block,
|
|
# the following keys are supported
|
|
[signature."MyESPName"]
|
|
# Policy is interpreted differently for these
|
|
policy = "Always" # Always add this signature
|
|
#policy = "OnlyIfMissingDomainBlock" # Use this as a fallback
|
|
|
|
# specifies the signing domain for this signature block
|
|
domain = "myesp.com"
|
|
]]
|
|
|
|
local function load_dkim_data_from_file(file_name, target)
|
|
local data = utils.load_json_or_toml_file(file_name)
|
|
|
|
if data.base then
|
|
utils.merge_into(data.base, target.base)
|
|
end
|
|
|
|
if data.domain then
|
|
for domain, params in pairs(data.domain) do
|
|
if not target.domain[domain] then
|
|
target.domain[domain] = params
|
|
else
|
|
utils.merge_into(params, target.domain[domain])
|
|
end
|
|
end
|
|
end
|
|
|
|
if data.signature then
|
|
for signame, params in pairs(data.signature) do
|
|
if not target.signature[signame] then
|
|
target.signature[signame] = params
|
|
else
|
|
utils.merge_into(params, target.signature[signame])
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
local function load_dkim_data(dkim_data_files)
|
|
local data = {
|
|
domain = {},
|
|
signature = {},
|
|
base = {},
|
|
}
|
|
for _, file_name in ipairs(dkim_data_files) do
|
|
load_dkim_data_from_file(file_name, data)
|
|
end
|
|
|
|
-- Sanity checks
|
|
if data.base.additional_signatures then
|
|
for _, signame in ipairs(data.base.additional_signatures) do
|
|
if not data.signature[signame] then
|
|
error(
|
|
string.format(
|
|
"dkim policy lists base.additional_signature '%s' but that signature is not defined in any signature block",
|
|
signame
|
|
)
|
|
)
|
|
end
|
|
end
|
|
end
|
|
|
|
for domain, params in pairs(data.domain) do
|
|
if not data.base.selector and not domain.selector then
|
|
error(
|
|
string.format(
|
|
"dkim domain '%s' is missing a selector and no default selector is defined in base",
|
|
domain
|
|
)
|
|
)
|
|
end
|
|
end
|
|
|
|
return data
|
|
end
|
|
|
|
local function make_signer(params, algo)
|
|
algo = algo or 'sha256'
|
|
|
|
if algo == 'sha256' then
|
|
return kumo.dkim.rsa_sha256_signer(params)
|
|
end
|
|
|
|
if algo == 'ed25519' then
|
|
return kumo.dkim.ed25519_signer(params)
|
|
end
|
|
|
|
error(
|
|
string.format("invalid algo '%s' for domain '%s'", algo, params.domain)
|
|
)
|
|
end
|
|
|
|
local function do_dkim_sign(msg, data)
|
|
local sender_domain = msg:from_header().domain
|
|
|
|
local signed_domain = false
|
|
local domain_config = data.domain[sender_domain]
|
|
local base = data.base
|
|
|
|
if domain_config then
|
|
-- TODO: check DNS to decide whether to try and sign based
|
|
-- on the domain_config.policy value
|
|
|
|
local params = {
|
|
domain = sender_domain,
|
|
selector = domain_config.selector or data.base.selector,
|
|
headers = domain_config.headers or base.headers,
|
|
}
|
|
|
|
if base.vault_mount then
|
|
params.key = {
|
|
vault_mount = base.vault_mount,
|
|
vault_path = domain_config.filename or string.format(
|
|
'%s/%s/%s.key',
|
|
base.vault_path_prefix or 'dkim',
|
|
params.domain,
|
|
params.selector
|
|
),
|
|
}
|
|
else
|
|
params.key = domain_config.filename
|
|
or string.format(
|
|
'%s/%s/%s.key',
|
|
DKIM_PATH,
|
|
params.domain,
|
|
params.selector
|
|
)
|
|
end
|
|
|
|
local signer = make_signer(params, domain_config.algo)
|
|
msg:dkim_sign(signer)
|
|
signed_domain = true
|
|
end
|
|
|
|
if base.additional_signatures then
|
|
for _, signame in ipairs(base.additional_signatures) do
|
|
local sig_config = data.signature[signame]
|
|
|
|
local need_sign = true
|
|
if
|
|
sig_config.policy == 'OnlyIfMissingDomainBlock' and signed_domain
|
|
then
|
|
-- Ideally we'd simply "continue" here, but lua doesn't have continue!
|
|
need_sign = false
|
|
end
|
|
|
|
if need_sign then
|
|
local params = {
|
|
domain = sig_config.domain,
|
|
selector = sig_config.selector or data.base.selector,
|
|
headers = sig_config.headers or base.headers,
|
|
}
|
|
|
|
if base.vault_mount then
|
|
params.key = {
|
|
vault_mount = base.vault_mount,
|
|
vault_path = sig_config.filename or string.format(
|
|
'%s/%s/%s.key',
|
|
base.vault_path_prefix or 'dkim',
|
|
params.domain,
|
|
params.selector
|
|
),
|
|
}
|
|
else
|
|
params.key = sig_config.filename
|
|
or string.format(
|
|
'%s/%s/%s.key',
|
|
DKIM_PATH,
|
|
params.domain,
|
|
params.selector
|
|
)
|
|
end
|
|
|
|
local signer = make_signer(params, sig_config.algo)
|
|
msg:dkim_sign(signer)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
function mod:setup(dkim_data_files)
|
|
local cached_load_data = kumo.memoize(load_dkim_data, {
|
|
name = 'dkim_signing_data',
|
|
ttl = '5 minutes',
|
|
capacity = 10,
|
|
})
|
|
|
|
local sign_message = function(msg)
|
|
local data = cached_load_data(dkim_data_files)
|
|
-- Compile the domain map for pattern matching
|
|
data.domain = kumo.domain_map.new(data.domain)
|
|
do_dkim_sign(msg, data)
|
|
end
|
|
|
|
return sign_message
|
|
end
|
|
|
|
return mod
|