]>
Commit | Line | Data |
---|---|---|
1360e6f0 DM |
1 | package PMG::Ticket; |
2 | ||
3 | use strict; | |
4 | use warnings; | |
5 | use Net::SSLeay; | |
6 | use Digest::SHA; | |
7 | ||
3dbcd6a0 | 8 | use PVE::SafeSyslog; |
9d7f54a3 | 9 | use PVE::Tools; |
1360e6f0 | 10 | use PVE::Ticket; |
bc44eb02 | 11 | use PVE::INotify; |
1360e6f0 DM |
12 | |
13 | use Crypt::OpenSSL::RSA; | |
14 | ||
896ef634 | 15 | use PMG::Utils; |
56efb270 | 16 | use PMG::Config; |
896ef634 | 17 | |
1360e6f0 DM |
18 | my $min_ticket_lifetime = -60*5; # allow 5 minutes time drift |
19 | my $max_ticket_lifetime = 60*60*2; # 2 hours | |
20 | ||
3278b571 | 21 | my $basedir = "/etc/pmg"; |
d883fac2 DM |
22 | |
23 | my $pmg_api_cert_fn = "$basedir/pmg-api.pem"; | |
24 | ||
1359baef | 25 | # this is just a secret accessible by all API servers |
d883fac2 DM |
26 | # and is used for CSRF prevention |
27 | my $pmg_csrf_key_fn = "$basedir/pmg-csrf.key"; | |
9d7f54a3 | 28 | |
c151b711 DM |
29 | my $authprivkeyfn = "$basedir/pmg-authkey.key"; |
30 | my $authpubkeyfn = "$basedir/pmg-authkey.pub"; | |
1360e6f0 | 31 | |
9d7f54a3 | 32 | sub generate_api_cert { |
bc44eb02 DM |
33 | my ($force) = @_; |
34 | ||
35 | my $nodename = PVE::INotify::nodename(); | |
9d7f54a3 DM |
36 | |
37 | if (-f $pmg_api_cert_fn) { | |
38 | return $pmg_api_cert_fn if !$force; | |
39 | unlink $pmg_api_cert_fn; | |
40 | } | |
41 | ||
c151b711 DM |
42 | my $gid = getgrnam('www-data') || |
43 | die "user www-data not in group file\n"; | |
44 | ||
45 | my $tmp_fn = "$pmg_api_cert_fn.tmp$$"; | |
46 | ||
9d7f54a3 | 47 | my $cmd = ['openssl', 'req', '-batch', '-x509', '-newkey', 'rsa:4096', |
c151b711 | 48 | '-nodes', '-keyout', $tmp_fn, '-out', $tmp_fn, |
9d7f54a3 DM |
49 | '-subj', "/CN=$nodename/", |
50 | '-days', '3650']; | |
51 | ||
c151b711 | 52 | eval { |
896ef634 | 53 | PMG::Utils::run_silent_cmd($cmd); |
c151b711 DM |
54 | chown(0, $gid, $tmp_fn) || die "chown failed - $!\n"; |
55 | chmod(0640, $tmp_fn) || die "chmod failed - $!\n"; | |
56 | rename($tmp_fn, $pmg_api_cert_fn) || die "rename failed - $!\n"; | |
57 | }; | |
58 | if (my $err = $@) { | |
59 | unlink $tmp_fn; | |
60 | die "unable to generate pmg api cert '$pmg_api_cert_fn':\n$err"; | |
61 | } | |
9d7f54a3 DM |
62 | |
63 | return $pmg_api_cert_fn; | |
64 | } | |
65 | ||
d883fac2 DM |
66 | sub generate_csrf_key { |
67 | ||
68 | return if -f $pmg_csrf_key_fn; | |
69 | ||
c151b711 DM |
70 | my $gid = getgrnam('www-data') || |
71 | die "user www-data not in group file\n"; | |
d883fac2 | 72 | |
c151b711 DM |
73 | my $tmp_fn = "$pmg_csrf_key_fn.tmp$$"; |
74 | my $cmd = ['openssl', 'genrsa', '-out', $tmp_fn, '2048']; | |
75 | ||
76 | eval { | |
896ef634 | 77 | PMG::Utils::run_silent_cmd($cmd); |
c151b711 DM |
78 | chown(0, $gid, $tmp_fn) || die "chown failed - $!\n"; |
79 | chmod(0640, $tmp_fn) || die "chmod failed - $!\n"; | |
80 | rename($tmp_fn, $pmg_csrf_key_fn) || die "rename failed - $!\n"; | |
81 | }; | |
82 | if (my $err = $@) { | |
83 | unlink $tmp_fn; | |
84 | die "unable to generate pmg csrf key '$pmg_csrf_key_fn':\n$@"; | |
85 | } | |
d883fac2 | 86 | |
c151b711 DM |
87 | return $pmg_csrf_key_fn; |
88 | } | |
89 | ||
90 | sub generate_auth_key { | |
91 | ||
92 | return if -f "$authprivkeyfn"; | |
93 | ||
94 | eval { | |
896ef634 DM |
95 | my $cmd = ['openssl', 'genrsa', '-out', $authprivkeyfn, '2048']; |
96 | PMG::Utils::run_silent_cmd($cmd); | |
c151b711 | 97 | |
896ef634 DM |
98 | $cmd = ['openssl', 'rsa', '-in', $authprivkeyfn, '-pubout', |
99 | '-out', $authpubkeyfn]; | |
100 | PMG::Utils::run_silent_cmd($cmd); | |
c151b711 DM |
101 | }; |
102 | ||
103 | die "unable to generate pmg auth key:\n$@" if $@; | |
104 | } | |
105 | ||
3dbcd6a0 DM |
106 | my $read_rsa_priv_key = sub { |
107 | my ($filename, $fh) = @_; | |
c151b711 | 108 | |
3dbcd6a0 | 109 | local $/ = undef; # slurp mode |
c151b711 | 110 | |
3dbcd6a0 | 111 | my $input = <$fh>; |
c151b711 | 112 | |
3dbcd6a0 | 113 | return Crypt::OpenSSL::RSA->new_private_key($input); |
c151b711 | 114 | |
3dbcd6a0 | 115 | }; |
c151b711 | 116 | |
3dbcd6a0 DM |
117 | PVE::INotify::register_file('auth_priv_key', $authprivkeyfn, |
118 | $read_rsa_priv_key, undef, undef, | |
119 | noclone => 1); | |
c151b711 | 120 | |
3dbcd6a0 DM |
121 | my $read_rsa_pub_key = sub { |
122 | my ($filename, $fh) = @_; | |
c151b711 | 123 | |
3dbcd6a0 | 124 | local $/ = undef; # slurp mode |
c151b711 | 125 | |
3dbcd6a0 | 126 | my $input = <$fh>; |
c151b711 | 127 | |
3dbcd6a0 DM |
128 | return Crypt::OpenSSL::RSA->new_public_key($input); |
129 | }; | |
d883fac2 | 130 | |
3dbcd6a0 DM |
131 | PVE::INotify::register_file('auth_pub_key', $authpubkeyfn, |
132 | $read_rsa_pub_key, undef, undef, | |
133 | noclone => 1); | |
134 | ||
ba75b669 | 135 | my $read_csrf_secret = sub { |
3dbcd6a0 DM |
136 | my ($filename, $fh) = @_; |
137 | ||
138 | local $/ = undef; # slurp mode | |
139 | ||
140 | my $input = <$fh>; | |
141 | ||
a730a4fd | 142 | return Digest::SHA::hmac_sha256_base64($input); |
1360e6f0 DM |
143 | }; |
144 | ||
ba75b669 DM |
145 | PVE::INotify::register_file('csrf_secret', $pmg_csrf_key_fn, |
146 | $read_csrf_secret, undef, undef, | |
3dbcd6a0 | 147 | noclone => 1); |
1360e6f0 DM |
148 | |
149 | sub verify_csrf_prevention_token { | |
150 | my ($username, $token, $noerr) = @_; | |
151 | ||
ba75b669 | 152 | my $secret = PVE::INotify::read_file('csrf_secret'); |
1360e6f0 DM |
153 | |
154 | return PVE::Ticket::verify_csrf_prevention_token( | |
d883fac2 | 155 | $secret, $username, $token, $min_ticket_lifetime, |
1360e6f0 DM |
156 | $max_ticket_lifetime, $noerr); |
157 | } | |
158 | ||
159 | sub assemble_csrf_prevention_token { | |
160 | my ($username) = @_; | |
161 | ||
ba75b669 | 162 | my $secret = PVE::INotify::read_file('csrf_secret'); |
1360e6f0 DM |
163 | |
164 | return PVE::Ticket::assemble_csrf_prevention_token ($secret, $username); | |
165 | } | |
166 | ||
5accfdf2 WB |
167 | sub assemble_ticket : prototype($;$) { |
168 | my ($data, $aad) = @_; | |
1360e6f0 | 169 | |
3dbcd6a0 | 170 | my $rsa_priv = PVE::INotify::read_file('auth_priv_key'); |
c151b711 | 171 | |
5accfdf2 | 172 | return PVE::Ticket::assemble_rsa_ticket($rsa_priv, 'PMG', $data, $aad); |
1360e6f0 DM |
173 | } |
174 | ||
5accfdf2 WB |
175 | # Returns (username, age, tfa-challenge) or just the username in scalar context. |
176 | # Note that in scalar context, tfa tickets return `undef`. | |
177 | sub verify_ticket : prototype($$$) { | |
178 | my ($ticket, $aad, $noerr) = @_; | |
1360e6f0 | 179 | |
3dbcd6a0 | 180 | my $rsa_pub = PVE::INotify::read_file('auth_pub_key'); |
c151b711 | 181 | |
5accfdf2 WB |
182 | my $tfa_challenge; |
183 | my ($data, $age) = PVE::Ticket::verify_rsa_ticket( | |
184 | $rsa_pub, 'PMG', $ticket, $aad, | |
1360e6f0 | 185 | $min_ticket_lifetime, $max_ticket_lifetime, $noerr); |
5accfdf2 WB |
186 | |
187 | if ($noerr && !$data) { | |
188 | # if $noerr was set $data can be undef: | |
189 | return wantarray ? (undef, undef, undef) : undef; | |
190 | } | |
191 | ||
192 | ||
193 | if ($data =~ /^!tfa!(.*)$/) { | |
194 | return (undef, $age, $1) if wantarray; | |
195 | return undef if $noerr; | |
196 | die "second factor required\n"; | |
197 | } | |
198 | return wantarray ? ($data, $age, undef) : $data; | |
1360e6f0 DM |
199 | } |
200 | ||
201 | # VNC tickets | |
202 | # - they do not contain the username in plain text | |
203 | # - they are restricted to a specific resource path (example: '/vms/100') | |
204 | sub assemble_vnc_ticket { | |
205 | my ($username, $path) = @_; | |
206 | ||
3dbcd6a0 | 207 | my $rsa_priv = PVE::INotify::read_file('auth_priv_key'); |
c151b711 | 208 | |
1360e6f0 DM |
209 | my $secret_data = "$username:$path"; |
210 | ||
211 | return PVE::Ticket::assemble_rsa_ticket( | |
c151b711 | 212 | $rsa_priv, 'PMGVNC', undef, $secret_data); |
1360e6f0 DM |
213 | } |
214 | ||
215 | sub verify_vnc_ticket { | |
216 | my ($ticket, $username, $path, $noerr) = @_; | |
217 | ||
3dbcd6a0 | 218 | my $rsa_pub = PVE::INotify::read_file('auth_pub_key'); |
c151b711 | 219 | |
1360e6f0 DM |
220 | my $secret_data = "$username:$path"; |
221 | ||
222 | return PVE::Ticket::verify_rsa_ticket( | |
c151b711 | 223 | $rsa_pub, 'PMGVNC', $ticket, $secret_data, -20, 40, $noerr); |
1360e6f0 DM |
224 | } |
225 | ||
6060e345 DM |
226 | # Note: we only encode $pmail into the ticket, |
227 | # and add '@quarantine' in verify_quarantine_ticket() | |
9a728eba | 228 | sub assemble_quarantine_ticket { |
f46af1cf | 229 | my ($pmail) = @_; |
9a728eba DM |
230 | |
231 | my $rsa_priv = PVE::INotify::read_file('auth_priv_key'); | |
232 | ||
f46af1cf | 233 | return PVE::Ticket::assemble_rsa_ticket($rsa_priv, 'PMGQUAR', $pmail); |
9a728eba DM |
234 | } |
235 | ||
56efb270 DM |
236 | my $quarantine_lifetime; |
237 | ||
238 | my $get_quarantine_lifetime = sub { | |
239 | ||
240 | return $quarantine_lifetime if defined($quarantine_lifetime); | |
241 | ||
242 | my $cfg = PMG::Config->new(); | |
243 | ||
244 | $quarantine_lifetime = $cfg->get('spamquar', 'lifetime'); | |
245 | ||
246 | return $quarantine_lifetime; | |
247 | }; | |
248 | ||
9a728eba | 249 | sub verify_quarantine_ticket { |
56efb270 | 250 | my ($ticket, $noerr) = @_; |
9a728eba DM |
251 | |
252 | my $rsa_pub = PVE::INotify::read_file('auth_pub_key'); | |
253 | ||
56efb270 DM |
254 | my $lifetime = $get_quarantine_lifetime->(); |
255 | ||
6060e345 | 256 | my ($username, $age) = PVE::Ticket::verify_rsa_ticket( |
f46af1cf | 257 | $rsa_pub, 'PMGQUAR', $ticket, undef, -20, $lifetime*86400, $noerr); |
6060e345 DM |
258 | |
259 | $username = "$username\@quarantine" if defined($username); | |
260 | ||
261 | return wantarray ? ($username, $age) : $username; | |
9a728eba DM |
262 | } |
263 | ||
1360e6f0 | 264 | 1; |