]>
Commit | Line | Data |
---|---|---|
ad6e35cf SI |
1 | package PMG::DKIMSign; |
2 | ||
3 | use strict; | |
4 | use warnings; | |
dbabc1f9 | 5 | use Email::Address::XS; |
ad6e35cf SI |
6 | use Mail::DKIM::Signer; |
7 | use Mail::DKIM::TextWrap; | |
8 | use Crypt::OpenSSL::RSA; | |
9 | ||
10 | use PVE::Tools; | |
11 | use PVE::INotify; | |
12 | use PVE::SafeSyslog; | |
13 | ||
14 | use PMG::Utils; | |
15 | use PMG::Config; | |
16 | use base qw(Mail::DKIM::Signer); | |
17 | ||
18 | sub 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 | ||
42 | sub print { | |
43 | my ($self, $chunk) = @_; | |
44 | $chunk =~ s/\012/\015\012/g; | |
45 | $self->PRINT($chunk); | |
46 | } | |
47 | ||
48 | sub 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 | |
56 | sub 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 |
93 | sub 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 | 115 | sub 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 | |
156 | sub 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 | ||
193 | sub 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 | } |
225 | 1; |