]> git.proxmox.com Git - pmg-api.git/blame - src/PMG/DKIMSign.pm
pmg7to8: allow arbitrary newer running -pve kernels after upgrade
[pmg-api.git] / src / PMG / DKIMSign.pm
CommitLineData
ad6e35cf
SI
1package PMG::DKIMSign;
2
3use strict;
4use warnings;
dbabc1f9 5use Email::Address::XS;
ad6e35cf
SI
6use Mail::DKIM::Signer;
7use Mail::DKIM::TextWrap;
8use Crypt::OpenSSL::RSA;
9
10use PVE::Tools;
11use PVE::INotify;
12use PVE::SafeSyslog;
13
14use PMG::Utils;
15use PMG::Config;
16use base qw(Mail::DKIM::Signer);
17
18sub new {
19 my ($class, $selector, $sign_all) = @_;
20
21 die "no selector provided\n" if ! $selector;
22
23 my %opts = (
24 Algorithm => 'rsa-sha256',
25 Method => 'relaxed/relaxed',
26 Selector => $selector,
27 KeyFile => "/etc/pmg/dkim/$selector.private",
28 );
29
30 my $self = $class->SUPER::new(%opts);
31
32 $self->{sign_all} = $sign_all;
33
34 return $self;
35}
36
37# MIME::Entity can output to all objects responding to 'print' (and does so in
38# chunks) Mail::DKIM::Signer has a 'PRINT' method and expects each line
39# terminated with "\r\n"
40
41
42sub print {
43 my ($self, $chunk) = @_;
44 $chunk =~ s/\012/\015\012/g;
45 $self->PRINT($chunk);
46}
47
48sub create_signature {
49 my ($self) = @_;
50
51 $self->CLOSE();
52 return $self->signature->as_string();
53}
54
55#determines which domain should be used for signing based on the e-mailaddress
56sub signing_domain {
dbabc1f9
MS
57 my ($self, $sender_email, $entity, $use_domain) = @_;
58
59 my $input_domain;
60 if ($use_domain eq 'header') {
61 $input_domain = parse_headers_for_signing($entity);
62 } else {
63 my @parts = split('@', $sender_email);
64 die "no domain in sender e-mail\n" if scalar(@parts) < 2;
65 $input_domain = $parts[-1];
66 }
ad6e35cf
SI
67
68 if ($self->{sign_all}) {
69 $self->domain($input_domain) if $self->{sign_all};
b8e7dac7 70 return 1;
ad6e35cf
SI
71 }
72
73 # check that input_domain is in/a subdomain of in the
74 # dkimdomains, falling back to the relay domains.
75 my $dkimdomains = PVE::INotify::read_file('dkimdomains');
76 $dkimdomains = PVE::INotify::read_file('domains') if !scalar(%$dkimdomains);
77
3caa5927
DB
78 # Sort domains by length first, so if we have both a sub domain and its parent
79 # the correct one will be returned
80 foreach my $domain (sort { length($b) <=> length($a) || $a cmp $b} keys %$dkimdomains) {
ad6e35cf
SI
81 if ( $input_domain =~ /\Q$domain\E$/i ) {
82 $self->domain($domain);
b8e7dac7 83 return 1;
ad6e35cf
SI
84 }
85 }
86
87 syslog('info', "not DKIM signing mail from $sender_email");
88
b8e7dac7 89 return 0;
ad6e35cf
SI
90}
91
92
dbabc1f9
MS
93sub parse_headers_for_signing {
94 # Following RFC 7489 [1], we only sign emails with exactly one sender in the
95 # From header.
96 #
97 # [1] https://datatracker.ietf.org/doc/html/rfc7489#section-6.6.1
98 my ($entity) = @_;
99
dbabc1f9
MS
100 my $domain;
101
102 my @from_headers = $entity->head->get('from');
103 foreach my $from_header (@from_headers) {
559bf04e 104 my @addresses = Email::Address::XS::parse_email_addresses($from_header);
e065addf
SI
105 die "there is more than one sender in the header\n"
106 if defined($domain) || scalar(@addresses) > 1;
107 $domain = $addresses[0]->host();
dbabc1f9
MS
108 }
109
e065addf 110 die "there is no sender in the header\n" if !defined($domain);
dbabc1f9
MS
111 return $domain;
112}
113
114
ad6e35cf 115sub sign_entity {
dbabc1f9
MS
116 my ($entity, $dkim, $sender) = @_;
117
118 my $sign_all = $dkim->{sign_all};
119 my $use_domain = $dkim->{use_domain};
120 my $selector = $dkim->{selector};
ad6e35cf
SI
121
122 die "no selector provided\n" if ! $selector;
123
124 #oversign certain headers
125 my @oversign_headers = (
126 'from',
127 'to',
128 'cc',
129 'reply-to',
130 'subject',
131 );
132
133 my @cond_headers = (
134 'content-type',
135 );
136
137 push(@oversign_headers, grep { $entity->head->mime_attr($_) } @cond_headers);
138
139 my $extended_headers = { map { $_ => '+' } @oversign_headers };
140
141 my $signer = __PACKAGE__->new($selector, $sign_all);
142
143 $signer->extended_headers($extended_headers);
ad6e35cf 144
dbabc1f9 145 if ($signer->signing_domain($sender, $entity, $use_domain)) {
b8e7dac7
SI
146 $entity->print($signer);
147 my $signature = $signer->create_signature();
148 $entity->head->add('DKIM-Signature', $signature, 0);
149 }
ad6e35cf
SI
150
151 return $entity;
152
153}
154
155# key-handling and utility methods
156sub get_selector_info {
157 my ($selector) = @_;
158
159 die "no selector provided\n" if !defined($selector);
160 my ($pubkey, $size);
161 eval {
162 my $privkeytext = PVE::Tools::file_get_contents("/etc/pmg/dkim/$selector.private");
163 my $privkey = Crypt::OpenSSL::RSA->new_private_key($privkeytext);
164 $size = $privkey->size() * 8;
165
166 $pubkey = $privkey->get_public_key_x509_string();
167 };
168 die "$@\n" if $@;
169
170 $pubkey =~ s/-----(?:BEGIN|END) PUBLIC KEY-----//g;
171 $pubkey =~ s/\v//mg;
172
173 # split record into 250 byte chunks for DNS-server compatibility
174 # see opendkim-genkey
175 my $record = qq{$selector._domainkey\tIN\tTXT\t( "v=DKIM1; h=sha256; k=rsa; "\n\t "p=};
176 my $len = length($pubkey);
177 my $cur = 0;
178 while ($len > 0) {
179 if ($len < 250) {
180 $record .= substr($pubkey, $cur);
181 $len = 0;
182 } else {
183 $record .= substr($pubkey, $cur, 250) . qq{"\n\t "};
184 $cur += 250;
185 $len -= 250;
186 }
187 }
188 $record .= qq{" ) ; ----- DKIM key $selector};
189
190 return ($record, $size);
191}
192
193sub set_selector {
d95c075a 194 my ($selector, $keysize, $force) = @_;
ad6e35cf
SI
195
196 die "no selector provided\n" if !defined($selector);
197 die "no keysize provided\n" if !defined($keysize);
198 die "invalid keysize\n" if ($keysize < 1024);
199 my $privkey_file = "/etc/pmg/dkim/$selector.private";
200
d95c075a
SI
201 my $code = sub {
202 my $genkey = $force || (! -e $privkey_file);
203 if (!$genkey) {
204 my ($privkey, $cursize);
205 eval {
206 my $privkeytext = PVE::Tools::file_get_contents($privkey_file);
207 $privkey = Crypt::OpenSSL::RSA->new_private_key($privkeytext);
208 $cursize = $privkey->size() * 8;
209 };
210 die "error checking $privkey_file: $@\n" if $@;
211 die "$privkey_file already exists, but has different size ($cursize bits)\n"
212 if $cursize != $keysize;
213 } else {
214 my $cmd = ['openssl', 'genrsa', '-out', $privkey_file, $keysize];
215 PMG::Utils::run_silent_cmd($cmd);
216 }
ad6e35cf
SI
217 my $cfg = PMG::Config->new();
218 $cfg->set('admin', 'dkim_selector', $selector);
219 $cfg->write();
220 PMG::Utils::reload_smtp_filter();
221 };
222
d95c075a 223 PMG::Config::lock_config($code, "unable to set DKIM key ($selector - $keysize bits)");
ad6e35cf
SI
224}
2251;