]>
Commit | Line | Data |
---|---|---|
1 | package PMG::Ticket; | |
2 | ||
3 | use strict; | |
4 | use warnings; | |
5 | use Net::SSLeay; | |
6 | use Digest::SHA; | |
7 | ||
8 | use PVE::SafeSyslog; | |
9 | use PVE::Tools; | |
10 | use PVE::Ticket; | |
11 | use PVE::INotify; | |
12 | ||
13 | use Crypt::OpenSSL::RSA; | |
14 | ||
15 | use PMG::Utils; | |
16 | use PMG::Config; | |
17 | ||
18 | my $min_ticket_lifetime = -60*5; # allow 5 minutes time drift | |
19 | my $max_ticket_lifetime = 60*60*2; # 2 hours | |
20 | ||
21 | my $basedir = "/etc/pmg"; | |
22 | ||
23 | my $pmg_api_cert_fn = "$basedir/pmg-api.pem"; | |
24 | ||
25 | # this is just a secret accessible by all API servers | |
26 | # and is used for CSRF prevention | |
27 | my $pmg_csrf_key_fn = "$basedir/pmg-csrf.key"; | |
28 | ||
29 | my $authprivkeyfn = "$basedir/pmg-authkey.key"; | |
30 | my $authpubkeyfn = "$basedir/pmg-authkey.pub"; | |
31 | ||
32 | sub generate_api_cert { | |
33 | my ($force) = @_; | |
34 | ||
35 | my $nodename = PVE::INotify::nodename(); | |
36 | ||
37 | if (-f $pmg_api_cert_fn) { | |
38 | return $pmg_api_cert_fn if !$force; | |
39 | unlink $pmg_api_cert_fn; | |
40 | } | |
41 | ||
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 | ||
47 | my $cmd = ['openssl', 'req', '-batch', '-x509', '-newkey', 'rsa:4096', | |
48 | '-nodes', '-keyout', $tmp_fn, '-out', $tmp_fn, | |
49 | '-subj', "/CN=$nodename/", | |
50 | '-days', '3650']; | |
51 | ||
52 | eval { | |
53 | PMG::Utils::run_silent_cmd($cmd); | |
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 | } | |
62 | ||
63 | return $pmg_api_cert_fn; | |
64 | } | |
65 | ||
66 | sub generate_csrf_key { | |
67 | ||
68 | return if -f $pmg_csrf_key_fn; | |
69 | ||
70 | my $gid = getgrnam('www-data') || | |
71 | die "user www-data not in group file\n"; | |
72 | ||
73 | my $tmp_fn = "$pmg_csrf_key_fn.tmp$$"; | |
74 | my $cmd = ['openssl', 'genrsa', '-out', $tmp_fn, '2048']; | |
75 | ||
76 | eval { | |
77 | PMG::Utils::run_silent_cmd($cmd); | |
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 | } | |
86 | ||
87 | return $pmg_csrf_key_fn; | |
88 | } | |
89 | ||
90 | sub generate_auth_key { | |
91 | ||
92 | return if -f "$authprivkeyfn"; | |
93 | ||
94 | eval { | |
95 | my $cmd = ['openssl', 'genrsa', '-out', $authprivkeyfn, '2048']; | |
96 | PMG::Utils::run_silent_cmd($cmd); | |
97 | ||
98 | $cmd = ['openssl', 'rsa', '-in', $authprivkeyfn, '-pubout', | |
99 | '-out', $authpubkeyfn]; | |
100 | PMG::Utils::run_silent_cmd($cmd); | |
101 | }; | |
102 | ||
103 | die "unable to generate pmg auth key:\n$@" if $@; | |
104 | } | |
105 | ||
106 | my $read_rsa_priv_key = sub { | |
107 | my ($filename, $fh) = @_; | |
108 | ||
109 | local $/ = undef; # slurp mode | |
110 | ||
111 | my $input = <$fh>; | |
112 | ||
113 | return Crypt::OpenSSL::RSA->new_private_key($input); | |
114 | ||
115 | }; | |
116 | ||
117 | PVE::INotify::register_file('auth_priv_key', $authprivkeyfn, | |
118 | $read_rsa_priv_key, undef, undef, | |
119 | noclone => 1); | |
120 | ||
121 | my $read_rsa_pub_key = sub { | |
122 | my ($filename, $fh) = @_; | |
123 | ||
124 | local $/ = undef; # slurp mode | |
125 | ||
126 | my $input = <$fh>; | |
127 | ||
128 | return Crypt::OpenSSL::RSA->new_public_key($input); | |
129 | }; | |
130 | ||
131 | PVE::INotify::register_file('auth_pub_key', $authpubkeyfn, | |
132 | $read_rsa_pub_key, undef, undef, | |
133 | noclone => 1); | |
134 | ||
135 | my $read_csrf_secret = sub { | |
136 | my ($filename, $fh) = @_; | |
137 | ||
138 | local $/ = undef; # slurp mode | |
139 | ||
140 | my $input = <$fh>; | |
141 | ||
142 | return Digest::SHA::hmac_sha256_base64($input); | |
143 | }; | |
144 | ||
145 | PVE::INotify::register_file('csrf_secret', $pmg_csrf_key_fn, | |
146 | $read_csrf_secret, undef, undef, | |
147 | noclone => 1); | |
148 | ||
149 | sub verify_csrf_prevention_token { | |
150 | my ($username, $token, $noerr) = @_; | |
151 | ||
152 | my $secret = PVE::INotify::read_file('csrf_secret'); | |
153 | ||
154 | return PVE::Ticket::verify_csrf_prevention_token( | |
155 | $secret, $username, $token, $min_ticket_lifetime, | |
156 | $max_ticket_lifetime, $noerr); | |
157 | } | |
158 | ||
159 | sub assemble_csrf_prevention_token { | |
160 | my ($username) = @_; | |
161 | ||
162 | my $secret = PVE::INotify::read_file('csrf_secret'); | |
163 | ||
164 | return PVE::Ticket::assemble_csrf_prevention_token ($secret, $username); | |
165 | } | |
166 | ||
167 | sub assemble_ticket : prototype($;$) { | |
168 | my ($data, $aad) = @_; | |
169 | ||
170 | my $rsa_priv = PVE::INotify::read_file('auth_priv_key'); | |
171 | ||
172 | return PVE::Ticket::assemble_rsa_ticket($rsa_priv, 'PMG', $data, $aad); | |
173 | } | |
174 | ||
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) = @_; | |
179 | ||
180 | my $rsa_pub = PVE::INotify::read_file('auth_pub_key'); | |
181 | ||
182 | my $tfa_challenge; | |
183 | my ($data, $age) = PVE::Ticket::verify_rsa_ticket( | |
184 | $rsa_pub, 'PMG', $ticket, $aad, | |
185 | $min_ticket_lifetime, $max_ticket_lifetime, $noerr); | |
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; | |
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 | ||
207 | my $rsa_priv = PVE::INotify::read_file('auth_priv_key'); | |
208 | ||
209 | my $secret_data = "$username:$path"; | |
210 | ||
211 | return PVE::Ticket::assemble_rsa_ticket( | |
212 | $rsa_priv, 'PMGVNC', undef, $secret_data); | |
213 | } | |
214 | ||
215 | sub verify_vnc_ticket { | |
216 | my ($ticket, $username, $path, $noerr) = @_; | |
217 | ||
218 | my $rsa_pub = PVE::INotify::read_file('auth_pub_key'); | |
219 | ||
220 | my $secret_data = "$username:$path"; | |
221 | ||
222 | return PVE::Ticket::verify_rsa_ticket( | |
223 | $rsa_pub, 'PMGVNC', $ticket, $secret_data, -20, 40, $noerr); | |
224 | } | |
225 | ||
226 | # Note: we only encode $pmail into the ticket, | |
227 | # and add '@quarantine' in verify_quarantine_ticket() | |
228 | sub assemble_quarantine_ticket { | |
229 | my ($pmail) = @_; | |
230 | ||
231 | my $rsa_priv = PVE::INotify::read_file('auth_priv_key'); | |
232 | ||
233 | return PVE::Ticket::assemble_rsa_ticket($rsa_priv, 'PMGQUAR', $pmail); | |
234 | } | |
235 | ||
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 | ||
249 | sub verify_quarantine_ticket { | |
250 | my ($ticket, $noerr) = @_; | |
251 | ||
252 | my $rsa_pub = PVE::INotify::read_file('auth_pub_key'); | |
253 | ||
254 | my $lifetime = $get_quarantine_lifetime->(); | |
255 | ||
256 | my ($username, $age) = PVE::Ticket::verify_rsa_ticket( | |
257 | $rsa_pub, 'PMGQUAR', $ticket, undef, -20, $lifetime*86400, $noerr); | |
258 | ||
259 | $username = "$username\@quarantine" if defined($username); | |
260 | ||
261 | return wantarray ? ($username, $age) : $username; | |
262 | } | |
263 | ||
264 | 1; |