File: //usr/share/perl5/vendor_perl/Amavis/Tools.pm
# SPDX-License-Identifier: GPL-2.0-or-later
package Amavis::Tools;
use strict;
use re 'taint';
use warnings;
use warnings FATAL => qw(utf8 void);
no warnings 'uninitialized';
# use warnings 'extra'; no warnings 'experimental::re_strict'; use re 'strict';
BEGIN {
require Exporter;
use vars qw(@ISA @EXPORT @EXPORT_OK %EXPORT_TAGS $VERSION);
$VERSION = '2.412';
@ISA = qw(Exporter);
@EXPORT_OK = qw(&show_or_test_dkim_public_keys &generate_dkim_private_key
&convert_dkim_keys_file);
}
use subs @EXPORT_OK;
use Errno qw(ENOENT EACCES);
use IO::File qw(O_RDONLY O_WRONLY O_RDWR O_APPEND O_CREAT O_EXCL);
use Crypt::OpenSSL::RSA ();
use Amavis::Conf qw(:platform c cr ca
@dkim_signing_keys_list @dkim_signing_keys_storage);
use Amavis::rfc2821_2822_Tools qw(rfc2822_timestamp);
use Amavis::Util qw(untaint ll do_log
safe_encode_utf8_inplace idn_to_ascii idn_to_utf8);
# Prints DNS TXT resource records for corresponding DKIM private keys (as
# previously declared by calls to dkim_key) in a format directly suitable
# for inclusion in DNS zone files. If an argument is provided the result is
# restricted to listed domains only, otherwise RR for all domains are shown.
# Note that a domain may have more than one RR: one RR for each selector.
#
# When a search argument is provided (even if '.'), the printed list is
# sorted according to reversed domain labels (e.g. com.example.sub.host),
# entries with the same domain are kept in original order. When there are
# no search arguments, the original order is retained.
#
sub show_or_test_dkim_public_keys($$) {
my($cmd,$args) = @_;
# when list is empty all domains are implied
my(@seek_domains) = map(idn_to_ascii($_), @$args);
my(@sort_list) = map { my $d = lc($dkim_signing_keys_list[$_]->{domain});
my $d_re = $dkim_signing_keys_list[$_]->{domain_re};
[$_, $d, $d_re, join('.',reverse split(/\./,$d,-1))] }
0 .. $#dkim_signing_keys_list;
if (@seek_domains) { # sort only when there are any search arguments present
@sort_list = sort {$a->[3] cmp $b->[3] || $a->[0] <=> $b->[0]} @sort_list;
}
my $any = 0;
for my $e (@sort_list) {
my($j,$domain,$domain_re) = @$e; local($1);
safe_encode_utf8_inplace($domain); # to octets (if not already)
my $domain_ace = idn_to_ascii($domain);
next if @seek_domains &&
!grep { defined $domain_re ? lc($_) =~ /$domain_re/
: /^\.(.*)\z/s ?
$domain_ace eq lc($1) ||
$domain_ace =~ /(?:\.|\z)\Q$1\E\z/si
: $domain_ace eq lc($_) } @seek_domains;
$any++;
my $key_opts = $dkim_signing_keys_list[$j];
if ($cmd eq 'testkeys' || $cmd eq 'testkey') {
test_dkim_key(%$key_opts);
} else {
my $selector = $key_opts->{selector};
safe_encode_utf8_inplace($selector); # to octets (if not already)
my $selector_ace = idn_to_ascii($selector);
my $key_storage_ind = $key_opts->{key_storage_ind};
my($key,$dev,$inode,$fname) =
@{ $dkim_signing_keys_storage[$key_storage_ind] };
my(@pub) = split(/\r?\n/, $key->get_public_key_x509_string);
@pub = grep(!/^---.*?---\z/ && !/^[ \t]*\z/, @pub);
my(@tags) = map($_.'='.$key_opts->{$_},
grep(defined $key_opts->{$_}, qw(v g h k s t n)));
my $key_size = 8 * $key->size;
printf("; key#%d %d bits, s=%s, d=%s%s\n",
$key_opts->{key_ind} + 1, $key_size,
$selector, $domain,
defined $fname ? ', '.$fname : '');
printf("; CANNOT DECLARE A WILDCARDED LABEL IN DNS, ".
"AVOID OR EDIT MANUALLY!\n") if defined $key_opts->{domain_re};
printf("%s._domainkey.%s.\t%s TXT (%s)\n\n",
$selector_ace, $domain_ace, '3600',
join('', map("\n" . ' "' . $_ . '"',
join('; ',@tags,'p='), @pub)) );
}
}
if (!@dkim_signing_keys_list) {
printf("No DKIM private keys declared in a config file.\n");
} elsif (!$any) {
printf("No DKIM private keys match the selection list.\n");
}
}
sub test_dkim_key(@) {
my(%key_options) = @_;
my $now = Time::HiRes::time;
my $key_storage_ind = $key_options{key_storage_ind};
my($key,$dev,$inode,$fname) =
@{ $dkim_signing_keys_storage[$key_storage_ind] };
if (UNIVERSAL::isa($key,'Crypt::OpenSSL::RSA')) {
$key = Mail::DKIM::PrivateKey->load(Cork => $key); # avail since 0.31
# my $pkcs1 = $key->get_private_key_string; # most compact
# $pkcs1 =~ s/^---.*?---(?:\r?\n|\z)//gm; $pkcs1 =~ tr/\r\n//d;
# $key = Mail::DKIM::PrivateKey->load(Data => $pkcs1);
}
my $domain = idn_to_utf8($key_options{domain});
my $domain_ace = idn_to_ascii($domain);
my $selector_ace = idn_to_ascii($key_options{selector});
my $policyfn = sub {
my $dkim = $_[0];
$dkim->add_signature( Mail::DKIM::Signature->new(
Selector => $selector_ace, Domain => $domain_ace,
Method => 'simple/simple', Algorithm => 'rsa-sha256',
Timestamp => int($now), Expiration => int($now)+24*3600, Key => $key,
)); undef;
};
my $msg = sprintf(
"From: test\@%s\nMessage-ID: <123\@%s>\nDate: %s\nSubject: test\n\ntest\n",
$domain, $domain, rfc2822_timestamp($now));
$msg =~ s{\n}{\015\012}gs;
my(@gen_signatures, @read_signatures);
eval {
my $dkim_signer = Mail::DKIM::Signer->new(Policy => $policyfn);
$dkim_signer or die "Could not create a Mail::DKIM::Signer object";
$dkim_signer->PRINT($msg) or die "Can't write to dkim: $!";
$dkim_signer->CLOSE or die "Can't close dkim signer: $!";
@gen_signatures = $dkim_signer->signatures;
} or do {
my $eval_stat = $@ ne '' ? $@ : "errno=$!"; chomp $eval_stat;
print STDERR "dkim signing failed: $eval_stat\n";
};
$msg = $_->as_string . "\015\012" . $msg for @gen_signatures;
eval {
my $dkim_verifier = Mail::DKIM::Verifier->new;
$dkim_verifier or die "Could not create a Mail::DKIM::Verifier object";
$dkim_verifier->PRINT($msg) or die "Can't write to dkim: $!";
$dkim_verifier->CLOSE or die "Can't close dkim_verifier: $!";
@read_signatures = $dkim_verifier->signatures;
} or do {
my $eval_stat = $@ ne '' ? $@ : "errno=$!"; chomp $eval_stat;
print STDERR "dkim verification failed: $eval_stat\n";
};
# printf("%s\n", $fname) if defined $fname;
printf("TESTING#%d %s: %s => %s\n",
$key_options{key_ind} + 1, $domain,
$_->selector . '._domainkey.' . $_->domain,
$_->result_detail) for @read_signatures;
}
sub generate_dkim_private_key(@) {
my($fname,$nbits) = @_;
my $fh;
eval {
$nbits = 1024 if !defined($nbits) || $nbits eq '';
$nbits =~ /^\d+\z/ or die "Number of bits in a key must be numeric\n";
$nbits >= 512
or die "Number of bits is below 512 (suggested 1024..2048)\n";
$nbits <= 4096
or die "Number of bits too large (suggested 1024..2048)\n";
defined $fname && $fname ne ''
or die "File name for a key not provided\n";
$nbits >= 1024
or printf STDERR ("INFO: RFC 6376 states: Signers MUST use RSA keys ".
"of at least 1024 bits for long-lived keys.\n");
$fh = IO::File->new;
$fh->open(untaint($fname), O_CREAT|O_EXCL|O_RDWR, 0600)
or die "Can't create file \"$fname\": $!\n";
my $rsa = Crypt::OpenSSL::RSA->generate_key($nbits);
$fh->print($rsa->get_private_key_string)
or die "Error writing key to a file \"$fname\": $!\n";
$fh->close or die "Can't close file \"$fname\": $!\n";
undef $fh;
printf STDERR ("Private RSA key successfully written to file \"%s\" ".
"(%d bits, PEM format) \n", $fname,$nbits);
1;
} or do {
my $eval_stat = $@ ne '' ? $@ : "errno=$!"; chomp $eval_stat;
$fh->close if defined $fh; # ignoring status
die "genrsa: $eval_stat\n";
}
}
# Reads a dkim-filter -compatible key specifications. From the dkim-filter
# man page: The keyfile should contain a set of lines of the form
# sender-pattern:signing-domain:keypath where sender-pattern is a pattern
# to match against message senders (with a special character "*" interpreted
# as "zero or more characters"), signing-domain is the domain to announce as
# the signing domain when generating signatures (or a '*', implying author's
# domain), and keypath is a path to the PEM-formatted private key to be used
# for signing messages which match the sender-pattern. The selector used in
# the signature will be the filename portion of keypath. A line starting
# with "/" is interpreted as a root directory for keys, meaning the keypath
# values after that line in the file are taken relative to that path. If a
# file referenced by keypath cannot be opened, the filter will try again by
# appending ".pem" and then ".private". '#'-delimited comments and blank
# lines are ignored.
#
sub convert_dkim_keys_file($) {
my $keysfile = $_[0];
my $inp = IO::File->new;
$inp->open($keysfile,'<')
or die "dkim_key_file: Can't open file $keysfile for reading: $!";
my($basedir,@options,@opt_re,%domain_selectors); my $rn = 0; my $ln;
for ($! = 0; defined($ln=$inp->getline); $! = 0) {
chomp($ln); $rn++; local($1); my($selector,$key_fn);
if ($ln =~ /^ \s* (?: \# | \z)/xs) {
# skip empty and all-comment lines
} elsif ($ln =~ m{^/}) {
$basedir = $ln; $basedir .= '/' if $basedir !~ m{/\z};
} else {
my($sender_pattern, $signing_domain, $keypath) =
map { my $s = $_; $s =~ s/^\s+//; $s =~ s/\s+\z//; $s }
split(/:/, $ln, 3);
defined $sender_pattern && $sender_pattern ne ''
or die "Error in $keysfile, empty sender pattern, line $rn: $ln\n";
defined $keypath && $keypath ne '' || $signing_domain eq ''
or die "Error in $keysfile, empty file name field, line $rn: $ln\n";
$keypath = $basedir . $keypath if defined $basedir && $keypath !~ m{^/};
for my $ext ('', '.pem', '.private') {
my $errn = stat($keypath.$ext) ? 0 : 0+$!;
if ($errn != ENOENT) { $key_fn = $keypath.$ext; last }
}
defined $key_fn
or die "File $keypath does not exist, $keysfile line $rn: $ln\n";
$selector = lc($1) if $keypath =~ m{ (?: ^ | / ) ( [^/]+? )
(?: \.pem | \.private )? \z }xs;
# must convert sender pattern to unquoted form to match actual addresses
my $sender_domain;
if ($sender_pattern eq '*' || $sender_pattern eq '*@*') {
$sender_pattern = $sender_domain = '*';
} else {
my $sender_localpart;
($sender_localpart, $sender_domain) =
Amavis::rfc2821_2822_Tools::split_address(
Amavis::rfc2821_2822_Tools::unquote_rfc2821_local($sender_pattern));
$sender_domain =~ s/^\@//;
$sender_pattern = $sender_localpart.'@'.idn_to_ascii($sender_domain);
}
if ($signing_domain eq '*') { $signing_domain = $sender_domain }
$signing_domain = idn_to_ascii($signing_domain);
if ($signing_domain ne '' &&
!$domain_selectors{$signing_domain}{$selector}) {
# dkim_key($signing_domain,$selector,$key_fn); # declare a signing key
printf("dkim_key(%-18s %-12s '%s');\n",
"'".$signing_domain."',", "'".$selector."',", $key_fn);
$domain_selectors{$signing_domain}{$selector} = 1;
}
if ($signing_domain eq $sender_domain) { $signing_domain = '*' }
push(@options, [$sender_pattern, $signing_domain, $selector]);
}
}
defined $ln || $! == 0 or die "Error reading from $keysfile: $!";
$inp->close or die "Error closing $keysfile: $!";
#
# prepare by_sender signature options lookup table when non-default
# signing is required (e.g. third-party signatures)
#
my $in_options = 0;
for my $opt (@options) {
my($sender_pattern, $signing_domain, $selector) = @$opt;
if ($signing_domain eq '*') {
# implies author domain signature, no need for special options
} else {
$sender_pattern =~ s/\*{2,}/*/gs; # collapse successive wildcards
$sender_pattern =~ # '*' is a wildcard, quote the rest
s{ ([@\#/.^\$|*+?(){}\[\]\\]) }{ $1 eq '*' ? '.*' : '\\'.$1 }xgse;
$sender_pattern = '^' . $sender_pattern . '\\z'; # implicit anchors
# remove trailing first, leading next, preferring /^.*\z/ -> /^/, not /\z/
$sender_pattern =~ s/\.\*\\z\z//s; # remove trailing anchor if redundant
$sender_pattern =~ s/^\^\.\*//s; # remove leading anchor if redundant
$sender_pattern = '(?:)' if $sender_pattern eq ''; # just in case
$signing_domain = undef if $signing_domain eq '';
$selector = undef if $selector eq '';
# case insensitive matching for compatibility with dkim-milter
push(@opt_re, [ qr/$sender_pattern/is =>
( !defined($signing_domain) ||
keys(%{$domain_selectors{$signing_domain}})==1
? { d => $signing_domain }
: { d => $signing_domain, s => $selector } ) ]);
if (!$in_options) {
printf("\n%s\n", '@dkim_signature_options_bysender_maps = (new_RE(');
$in_options = 1;
}
printf(" [ %-30s => { d=>%s%s} ],\n",
'qr/' . $sender_pattern . '/is',
!defined($signing_domain) ? 'undef' : "'".$signing_domain."'",
!defined($signing_domain) ||
keys %{$domain_selectors{$signing_domain}} == 1 ? ''
: !defined($selector) ? ', s=>undef' : ", s=>'".$selector."'");
}
}
printf("%s\n", '));') if $in_options;
# use Devel::Peek qw(Dump);
# use Data::Dump (); Data::Dump::dump(@opt_re);
# unshift(@dkim_signature_options_bysender_maps,
# Amavis::Lookup::RE->new(@opt_re)) if @opt_re;
}
1;