]> git.proxmox.com Git - pve-common.git/blob - src/PVE/Certificate.pm
cert: make die helper a private sub and fix code style on use
[pve-common.git] / src / PVE / Certificate.pm
1 package PVE::Certificate;
2
3 use strict;
4 use warnings;
5
6 use Date::Parse;
7 use Encode qw(decode encode);
8 use MIME::Base64 qw(decode_base64 encode_base64);
9 use Net::SSLeay;
10
11 use PVE::JSONSchema qw(get_standard_option);
12
13 Net::SSLeay::load_error_strings();
14 Net::SSLeay::randomize();
15
16 PVE::JSONSchema::register_format('pem-certificate', sub {
17 my ($content, $noerr) = @_;
18
19 return check_pem($content, noerr => $noerr);
20 });
21
22 PVE::JSONSchema::register_format('pem-certificate-chain', sub {
23 my ($content, $noerr) = @_;
24
25 return check_pem($content, noerr => $noerr, multiple => 1);
26 });
27
28 PVE::JSONSchema::register_format('pem-string', sub {
29 my ($content, $noerr) = @_;
30
31 return check_pem($content, noerr => $noerr, label => qr/.*?/);
32 });
33
34 PVE::JSONSchema::register_standard_option('pve-certificate-info', {
35 type => 'object',
36 properties => {
37 filename => {
38 type => 'string',
39 optional => 1,
40 },
41 fingerprint => get_standard_option('fingerprint-sha256', {
42 optional => 1,
43 }),
44 subject => {
45 type => 'string',
46 description => 'Certificate subject name.',
47 optional => 1,
48 },
49 issuer => {
50 type => 'string',
51 description => 'Certificate issuer name.',
52 optional => 1,
53 },
54 notbefore => {
55 type => 'integer',
56 description => 'Certificate\'s notBefore timestamp (UNIX epoch).',
57 renderer => 'timestamp',
58 optional => 1,
59 },
60 notafter => {
61 type => 'integer',
62 description => 'Certificate\'s notAfter timestamp (UNIX epoch).',
63 renderer => 'timestamp',
64 optional => 1,
65 },
66 san => {
67 type => 'array',
68 description => 'List of Certificate\'s SubjectAlternativeName entries.',
69 optional => 1,
70 renderer => 'yaml',
71 items => {
72 type => 'string',
73 },
74 },
75 pem => {
76 type => 'string',
77 description => 'Certificate in PEM format',
78 format => 'pem-certificate',
79 optional => 1,
80 },
81 'public-key-type' => {
82 type => 'string',
83 description => 'Certificate\'s public key algorithm',
84 optional => 1,
85 },
86 'public-key-bits' => {
87 type => 'integer',
88 description => 'Certificate\'s public key size',
89 optional => 1,
90 },
91 },
92 });
93
94 # see RFC 7468
95 my $b64_char_re = qr![0-9A-Za-z\+/]!;
96 my $header_re = sub {
97 my ($label) = @_;
98 return qr!-----BEGIN\ $label-----(?:\s|\n)*!;
99 };
100 my $footer_re = sub {
101 my ($label) = @_;
102 return qr!-----END\ $label-----(?:\s|\n)*!;
103 };
104 my $pem_re = sub {
105 my ($label) = @_;
106
107 my $header = $header_re->($label);
108 my $footer = $footer_re->($label);
109
110 return qr{
111 $header
112 (?:(?:$b64_char_re)+\s*\n)*
113 (?:$b64_char_re)*(?:=\s*\n=|={0,2})?\s*\n
114 $footer
115 }x;
116 };
117
118 sub strip_leading_text {
119 my ($content) = @_;
120
121 my $header = $header_re->(qr/.*?/);
122 $content =~ s/^.*?(?=$header)//s;
123 return $content;
124 };
125
126 sub split_pem {
127 my ($content, %opts) = @_;
128 my $label = $opts{label} // 'CERTIFICATE';
129
130 my $header = $header_re->($label);
131 return split(/(?=$header)/,$content);
132 }
133
134 sub check_pem {
135 my ($content, %opts) = @_;
136
137 my $label = $opts{label} // 'CERTIFICATE';
138 my $multiple = $opts{multiple};
139 my $noerr = $opts{noerr};
140
141 $content = strip_leading_text($content);
142
143 my $re = $pem_re->($label);
144
145 $re = qr/($re\n+)*$re/ if $multiple;
146
147 if ($content =~ /^$re$/) {
148 return $content;
149 } else {
150 return undef if $noerr;
151 die "not a valid PEM-formatted string.\n";
152 }
153 }
154
155 sub pem_to_der {
156 my ($content) = @_;
157
158 my $header = $header_re->(qr/.*?/);
159 my $footer = $footer_re->(qr/.*?/);
160
161 $content = strip_leading_text($content);
162
163 # only take first PEM entry
164 $content =~ s/^$header$//mg;
165 $content =~ s/$footer.*//sg;
166
167 $content = decode_base64($content);
168
169 return $content;
170 }
171
172 sub der_to_pem {
173 my ($content, %opts) = @_;
174
175 my $label = $opts{label} // 'CERTIFICATE';
176
177 my $b64 = encode_base64($content, '');
178 $b64 = join("\n", ($b64 =~ /.{1,64}/sg));
179 return "-----BEGIN $label-----\n$b64\n-----END $label-----\n";
180 }
181
182 my sub ssl_die {
183 my ($msg) = @_;
184 Net::SSLeay::die_now($msg);
185 };
186
187 my $ssl_warn = sub {
188 my ($msg) = @_;
189 Net::SSLeay::print_errs();
190 warn $msg if $msg;
191 };
192
193 my $read_certificate = sub {
194 my ($cert_path) = @_;
195
196 die "'$cert_path' does not exist!\n" if ! -e $cert_path;
197
198 my $bio = Net::SSLeay::BIO_new_file($cert_path, 'r')
199 or ssl_die("unable to read '$cert_path' - $!\n");
200
201 my $cert = Net::SSLeay::PEM_read_bio_X509($bio);
202 Net::SSLeay::BIO_free($bio);
203 die "unable to read certificate from '$cert_path'\n" if !$cert;
204
205 return $cert;
206 };
207
208 sub convert_asn1_to_epoch {
209 my ($asn1_time) = @_;
210
211 ssl_die("invalid ASN1 time object\n") if !$asn1_time;
212 my $iso_time = Net::SSLeay::P_ASN1_TIME_get_isotime($asn1_time);
213 ssl_die("unable to parse ASN1 time\n") if $iso_time eq '';
214 return Date::Parse::str2time($iso_time);
215 }
216
217 sub get_certificate_fingerprint {
218 my ($cert_path) = @_;
219
220 my $cert = $read_certificate->($cert_path);
221
222 my $fp = Net::SSLeay::X509_get_fingerprint($cert, 'sha256');
223 Net::SSLeay::X509_free($cert);
224
225 die "unable to get fingerprint for '$cert_path' - got empty value\n"
226 if !defined($fp) || $fp eq '';
227
228 return $fp;
229 }
230
231 sub check_certificate_matches_key {
232 my ($cert_path, $key_path) = @_;
233
234 die "No certificate path given!\n" if !$cert_path;
235 die "No certificate key path given!\n" if !$key_path;
236
237 die "Certificate at '$cert_path' does not exist!\n" if ! -e $cert_path;
238 die "Certificate key '$key_path' does not exist!\n" if ! -e $key_path;
239
240 my $ctx = Net::SSLeay::CTX_new()
241 or ssl_die("Failed to create SSL context in order to verify private key");
242
243 eval {
244 my $filetype = &Net::SSLeay::FILETYPE_PEM;
245
246 Net::SSLeay::CTX_use_PrivateKey_file($ctx, $key_path, $filetype)
247 or ssl_die("Failed to load private key from '$key_path' into SSL context");
248
249 Net::SSLeay::CTX_use_certificate_file($ctx, $cert_path, $filetype)
250 or ssl_die("Failed to load certificate from '$cert_path' into SSL context");
251
252 Net::SSLeay::CTX_check_private_key($ctx)
253 or ssl_die("Failed to validate private key and certificate");
254 };
255 my $err = $@;
256
257 Net::SSLeay::CTX_free($ctx);
258
259 die $err if $err;
260
261 return 1;
262 }
263
264 sub get_certificate_info {
265 my ($cert_path) = @_;
266
267 my $cert = $read_certificate->($cert_path);
268
269 my $parse_san = sub {
270 my $res = [];
271 while (my ($type, $value) = splice(@_, 0, 2)) {
272 if ($type != 2 && $type != 7) {
273 warn "unexpected SAN type encountered: $type\n";
274 next;
275 }
276
277 if ($type == 7) {
278 my $hex = unpack("H*", $value);
279 if (length($hex) == 8) {
280 # IPv4
281 $value = join(".", unpack("C4C4C4C4", $value));
282 } elsif (length($hex) == 32) {
283 # IPv6
284 $value = join(":", unpack("H4H4H4H4H4H4H4H4", $value));
285 } else {
286 warn "cannot parse SAN IP entry '0x${hex}'\n";
287 next;
288 }
289 }
290
291 push @$res, $value;
292 }
293 return $res;
294 };
295
296 my $info = {};
297
298 $info->{fingerprint} = Net::SSLeay::X509_get_fingerprint($cert, 'sha256');
299
300 my $subject = Net::SSLeay::X509_get_subject_name($cert);
301 if ($subject) {
302 $info->{subject} = Net::SSLeay::X509_NAME_oneline($subject);
303 }
304
305 my $issuer = Net::SSLeay::X509_get_issuer_name($cert);
306 if ($issuer) {
307 $info->{issuer} = Net::SSLeay::X509_NAME_oneline($issuer);
308 }
309
310 eval { $info->{notbefore} = convert_asn1_to_epoch(Net::SSLeay::X509_get_notBefore($cert)) };
311 warn $@ if $@;
312 eval { $info->{notafter} = convert_asn1_to_epoch(Net::SSLeay::X509_get_notAfter($cert)) };
313 warn $@ if $@;
314
315 $info->{san} = $parse_san->(Net::SSLeay::X509_get_subjectAltNames($cert));
316 $info->{pem} = Net::SSLeay::PEM_get_string_X509($cert);
317
318 my $pub_key = eval { Net::SSLeay::X509_get_pubkey($cert) };
319 warn $@ if $@;
320 if ($pub_key) {
321 $info->{'public-key-type'} = Net::SSLeay::OBJ_nid2sn(Net::SSLeay::EVP_PKEY_id($pub_key));
322 $info->{'public-key-bits'} = Net::SSLeay::EVP_PKEY_bits($pub_key);
323 Net::SSLeay::EVP_PKEY_free($pub_key);
324 }
325
326 Net::SSLeay::X509_free($cert);
327
328 $cert_path =~ s!^.*/!!g;
329 $info->{filename} = $cert_path;
330
331 return $info;
332 };
333
334 # Checks whether certificate expires before $timestamp (UNIX epoch)
335 sub check_expiry {
336 my ($cert_path, $timestamp) = @_;
337
338 $timestamp //= time();
339
340 my $cert = $read_certificate->($cert_path);
341 my $not_after = eval { convert_asn1_to_epoch(Net::SSLeay::X509_get_notAfter($cert)) };
342 my $err = $@;
343
344 Net::SSLeay::X509_free($cert);
345
346 die $err if $err;
347
348 return ($not_after < $timestamp) ? 1 : 0;
349 };
350
351 # Create a CSR and certificate key for a given order
352 # returns path to CSR file or path to CSR and key files
353 sub generate_csr {
354 my (%attr) = @_;
355
356 # optional
357 my $bits = delete($attr{bits}) // 4096;
358 my $dig_alg = delete($attr{digest}) // 'sha256';
359 my $pem_key = delete($attr{private_key});
360
361 # required
362 my $identifiers = delete($attr{identifiers});
363
364 die "Identifiers are required to generate a CSR.\n"
365 if !defined($identifiers);
366
367 my $san = [ map { $_->{value} } grep { $_->{type} eq 'dns' } @$identifiers ];
368 die "DNS identifiers are required to generate a CSR.\n" if !scalar @$san;
369
370 # optional
371 my $common_name = delete($attr{common_name}) // $san->[0];
372
373 my $md = eval { Net::SSLeay::EVP_get_digestbyname($dig_alg) };
374 die "Invalid digest algorithm '$dig_alg'\n" if !$md;
375
376 my ($bio, $pk, $req);
377
378 my $cleanup = sub {
379 my ($warn, $die_msg) = @_;
380 $ssl_warn->() if $warn;
381
382 Net::SSLeay::X509_REQ_free($req) if $req;
383 Net::SSLeay::EVP_PKEY_free($pk) if $pk;
384 Net::SSLeay::BIO_free($bio) if $bio;
385
386 die $die_msg if $die_msg;
387 };
388
389 # this unfortunately causes a small memory leak, since there is no
390 # X509_NAME_free() (yet)
391 my $name = Net::SSLeay::X509_NAME_new();
392 ssl_die("Failed to allocate X509_NAME object\n") if !$name;
393 my $add_name_entry = sub {
394 my ($k, $v) = @_;
395
396 my $res = Net::SSLeay::X509_NAME_add_entry_by_txt(
397 $name,
398 $k,
399 &Net::SSLeay::MBSTRING_UTF8,
400 encode('utf-8', $v),
401 );
402
403 $cleanup->(1, "Failed to add '$k'='$v' to DN\n") if !$res;
404 };
405
406 $add_name_entry->('CN', $common_name);
407 for (qw(C ST L O OU)) {
408 if (defined(my $v = $attr{$_})) {
409 $add_name_entry->($_, $v);
410 }
411 }
412
413 if (defined($pem_key)) {
414 my $bio_s_mem = Net::SSLeay::BIO_s_mem();
415 $cleanup->(1, "Failed to allocate BIO_s_mem for private key\n")
416 if !$bio_s_mem;
417
418 $bio = Net::SSLeay::BIO_new($bio_s_mem);
419 $cleanup->(1, "Failed to allocate BIO for private key\n") if !$bio;
420
421 $cleanup->(1, "Failed to write PEM-encoded key to BIO\n")
422 if Net::SSLeay::BIO_write($bio, $pem_key) <= 0;
423
424 $pk = Net::SSLeay::PEM_read_bio_PrivateKey($bio);
425 $cleanup->(1, "Failed to read private key into EVP_PKEY\n") if !$pk;
426 } else {
427 $pk = Net::SSLeay::EVP_PKEY_new();
428 $cleanup->(1, "Failed to allocate EVP_PKEY for private key\n") if !$pk;
429
430 my $rsa = Net::SSLeay::RSA_generate_key($bits, 65537);
431 $cleanup->(1, "Failed to generate RSA key pair\n") if !$rsa;
432
433 $cleanup->(1, "Failed to assign RSA key to EVP_PKEY\n")
434 if !Net::SSLeay::EVP_PKEY_assign_RSA($pk, $rsa);
435 }
436
437 $req = Net::SSLeay::X509_REQ_new();
438 $cleanup->(1, "Failed to allocate X509_REQ\n") if !$req;
439
440 $cleanup->(1, "Failed to set subject name\n")
441 if (!Net::SSLeay::X509_REQ_set_subject_name($req, $name));
442
443 Net::SSLeay::P_X509_REQ_add_extensions(
444 $req,
445 &Net::SSLeay::NID_key_usage => 'digitalSignature,keyEncipherment',
446 &Net::SSLeay::NID_basic_constraints => 'CA:FALSE',
447 &Net::SSLeay::NID_ext_key_usage => 'serverAuth,clientAuth',
448 &Net::SSLeay::NID_subject_alt_name => join(',', map { "DNS:$_" } @$san),
449 ) or $cleanup->(1, "Failed to add extensions to CSR\n");
450
451 $cleanup->(1, "Failed to set public key\n")
452 if !Net::SSLeay::X509_REQ_set_pubkey($req, $pk);
453
454 $cleanup->(1, "Failed to set CSR version\n")
455 if !Net::SSLeay::X509_REQ_set_version($req, 2);
456
457 $cleanup->(1, "Failed to sign CSR\n")
458 if !Net::SSLeay::X509_REQ_sign($req, $pk, $md);
459
460 my $pk_pem = Net::SSLeay::PEM_get_string_PrivateKey($pk);
461 my $req_pem = Net::SSLeay::PEM_get_string_X509_REQ($req);
462
463 $cleanup->();
464
465 return wantarray ? ($req_pem, $pk_pem) : $req_pem;
466 }
467
468 1;