691e70b621b9a66017fb4a5dc5974c4750353e87
[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     },
82 });
83
84 # see RFC 7468
85 my $b64_char_re = qr![0-9A-Za-z\+/]!;
86 my $header_re = sub {
87     my ($label) = @_;
88     return qr!-----BEGIN\ $label-----(?:\s|\n)*!;
89 };
90 my $footer_re = sub {
91     my ($label) = @_;
92     return qr!-----END\ $label-----(?:\s|\n)*!;
93 };
94 my $pem_re = sub {
95     my ($label) = @_;
96
97     my $header = $header_re->($label);
98     my $footer = $footer_re->($label);
99
100     return qr{
101         $header
102         (?:(?:$b64_char_re)+\s*\n)*
103         (?:$b64_char_re)*(?:=\s*\n=|={0,2})?\s*\n
104         $footer
105     }x;
106 };
107
108 sub strip_leading_text {
109     my ($content) = @_;
110
111     my $header = $header_re->(qr/.*?/);
112     $content =~ s/^.*?(?=$header)//s;
113     return $content;
114 };
115
116 sub split_pem {
117     my ($content, %opts) = @_;
118     my $label = $opts{label} // 'CERTIFICATE';
119
120     my $header = $header_re->($label);
121     return split(/(?=$header)/,$content);
122 }
123
124 sub check_pem {
125     my ($content, %opts) = @_;
126
127     my $label = $opts{label} // 'CERTIFICATE';
128     my $multiple = $opts{multiple};
129     my $noerr = $opts{noerr};
130
131     $content = strip_leading_text($content);
132
133     my $re = $pem_re->($label);
134
135     $re = qr/($re\n+)*$re/ if $multiple;
136
137     if ($content =~ /^$re$/) {
138         return $content;
139     } else {
140         return undef if $noerr;
141         die "not a valid PEM-formatted string.\n";
142     }
143 }
144
145 sub pem_to_der {
146     my ($content) = @_;
147
148     my $header = $header_re->(qr/.*?/);
149     my $footer = $footer_re->(qr/.*?/);
150
151     $content = strip_leading_text($content);
152
153     # only take first PEM entry
154     $content =~ s/^$header$//mg;
155     $content =~ s/$footer.*//sg;
156
157     $content = decode_base64($content);
158
159     return $content;
160 }
161
162 sub der_to_pem {
163     my ($content, %opts) = @_;
164
165     my $label = $opts{label} // 'CERTIFICATE';
166
167     my $b64 = encode_base64($content, '');
168     $b64 = join("\n", ($b64 =~ /.{1,64}/sg));
169     return "-----BEGIN $label-----\n$b64\n-----END $label-----\n";
170 }
171
172 my $ssl_die = sub {
173     my ($msg) = @_;
174     Net::SSLeay::die_now($msg);
175 };
176
177 my $ssl_warn = sub {
178     my ($msg) = @_;
179     Net::SSLeay::print_errs();
180     warn $msg if $msg;
181 };
182
183 my $read_certificate = sub {
184     my ($cert_path) = @_;
185
186     die "'$cert_path' does not exist!\n" if ! -e $cert_path;
187
188     my $bio = Net::SSLeay::BIO_new_file($cert_path, 'r')
189         or $ssl_die->("unable to read '$cert_path' - $!\n");
190
191     my $cert = Net::SSLeay::PEM_read_bio_X509($bio);
192     if (!$cert) {
193         Net::SSLeay::BIO_free($bio);
194         die "unable to read certificate from '$cert_path'\n";
195     }
196
197     return $cert;
198 };
199
200 sub convert_asn1_to_epoch {
201     my ($asn1_time) = @_;
202
203     $ssl_die->("invalid ASN1 time object\n") if !$asn1_time;
204     my $iso_time = Net::SSLeay::P_ASN1_TIME_get_isotime($asn1_time);
205     $ssl_die->("unable to parse ASN1 time\n") if $iso_time eq '';
206     return Date::Parse::str2time($iso_time);
207 }
208
209 sub get_certificate_info {
210     my ($cert_path) = @_;
211
212     my $cert = $read_certificate->($cert_path);
213
214     my $parse_san = sub {
215         my $res = [];
216         while (my ($type, $value) = splice(@_, 0, 2)) {
217             if ($type != 2 && $type != 7) {
218                 warn "unexpected SAN type encountered: $type\n";
219                 next;
220             }
221
222             if ($type == 7) {
223                 my $hex = unpack("H*", $value);
224                 if (length($hex) == 8) {
225                     # IPv4
226                     $value = join(".", unpack("C4C4C4C4", $value));
227                 } elsif (length($hex) == 32) {
228                     # IPv6
229                     $value = join(":", unpack("H4H4H4H4H4H4H4H4", $value));
230                 } else {
231                     warn "cannot parse SAN IP entry '0x${hex}'\n";
232                     next;
233                 }
234             }
235
236             push @$res, $value;
237         }
238         return $res;
239     };
240
241     my $info = {};
242
243     $info->{fingerprint} = Net::SSLeay::X509_get_fingerprint($cert, 'sha256');
244
245     my $subject = Net::SSLeay::X509_get_subject_name($cert);
246     if ($subject) {
247         $info->{subject} = Net::SSLeay::X509_NAME_oneline($subject);
248     }
249
250     my $issuer = Net::SSLeay::X509_get_issuer_name($cert);
251     if ($issuer) {
252         $info->{issuer} = Net::SSLeay::X509_NAME_oneline($issuer);
253     }
254
255     eval { $info->{notbefore} = convert_asn1_to_epoch(Net::SSLeay::X509_get_notBefore($cert)) };
256     warn $@ if $@;
257     eval { $info->{notafter} = convert_asn1_to_epoch(Net::SSLeay::X509_get_notAfter($cert)) };
258     warn $@ if $@;
259
260     $info->{san} = $parse_san->(Net::SSLeay::X509_get_subjectAltNames($cert));
261     $info->{pem} = Net::SSLeay::PEM_get_string_X509($cert);
262
263     Net::SSLeay::X509_free($cert);
264
265     $cert_path =~ s!^.*/!!g;
266     $info->{filename} = $cert_path;
267
268     return $info;
269 };
270
271 # Checks whether certificate expires before $timestamp (UNIX epoch)
272 sub check_expiry {
273     my ($cert_path, $timestamp) = @_;
274
275     $timestamp //= time();
276
277     my $cert = $read_certificate->($cert_path);
278     my $not_after = eval { convert_asn1_to_epoch(Net::SSLeay::X509_get_notAfter($cert)) };
279     my $err = $@;
280
281     Net::SSLeay::X509_free($cert);
282
283     die $err if $err;
284
285     return ($not_after < $timestamp) ? 1 : 0;
286 };
287
288 # Create a CSR and certificate key for a given order
289 # returns path to CSR file or path to CSR and key files
290 sub generate_csr {
291     my (%attr) = @_;
292
293     # optional
294     my $bits = delete($attr{bits}) // 4096;
295     my $dig_alg = delete($attr{digest}) // 'sha256';
296     my $pem_key = delete($attr{private_key});
297
298     # required
299     my $identifiers = delete($attr{identifiers});
300
301     die "Identifiers are required to generate a CSR.\n"
302         if !defined($identifiers);
303
304     my $san = [ map { $_->{value} } grep { $_->{type} eq 'dns' } @$identifiers ];
305     die "DNS identifiers are required to generate a CSR.\n" if !scalar @$san;
306
307     my $md = eval { Net::SSLeay::EVP_get_digestbyname($dig_alg) };
308     die "Invalid digest algorithm '$dig_alg'\n" if !$md;
309
310     my ($bio, $pk, $req);
311
312     my $cleanup = sub {
313         my ($warn, $die_msg) = @_;
314         $ssl_warn->() if $warn;
315
316         Net::SSLeay::X509_REQ_free($req) if  $req;
317         Net::SSLeay::EVP_PKEY_free($pk) if $pk;
318         Net::SSLeay::BIO_free($bio) if $bio;
319
320         die $die_msg if $die_msg;
321     };
322
323     # this unfortunately causes a small memory leak, since there is no
324     # X509_NAME_free() (yet)
325     my $name = Net::SSLeay::X509_NAME_new();
326     $ssl_die->("Failed to allocate X509_NAME object\n") if !$name;
327     my $add_name_entry = sub {
328         my ($k, $v) = @_;
329         if (!Net::SSLeay::X509_NAME_add_entry_by_txt($name,
330                                                      $k,
331                                                      &Net::SSLeay::MBSTRING_UTF8,
332                                                      encode('utf-8', $v))) {
333             $cleanup->(1, "Failed to add '$k'='$v' to DN\n");
334         }
335     };
336
337     $add_name_entry->('CN', @$san[0]);
338     for (qw(C ST L O OU)) {
339         if (defined(my $v = $attr{$_})) {
340             $add_name_entry->($_, $v);
341         }
342     }
343
344     if (defined($pem_key)) {
345         my $bio_s_mem = Net::SSLeay::BIO_s_mem();
346         $cleanup->(1, "Failed to allocate BIO_s_mem for private key\n")
347             if !$bio_s_mem;
348
349         $bio = Net::SSLeay::BIO_new($bio_s_mem);
350         $cleanup->(1, "Failed to allocate BIO for private key\n") if !$bio;
351
352         $cleanup->(1, "Failed to write PEM-encoded key to BIO\n")
353             if Net::SSLeay::BIO_write($bio, $pem_key) <= 0;
354
355         $pk = Net::SSLeay::PEM_read_bio_PrivateKey($bio);
356         $cleanup->(1, "Failed to read private key into EVP_PKEY\n") if !$pk;
357     } else {
358         $pk = Net::SSLeay::EVP_PKEY_new();
359         $cleanup->(1, "Failed to allocate EVP_PKEY for private key\n") if !$pk;
360
361         my $rsa = Net::SSLeay::RSA_generate_key($bits, 65537);
362         $cleanup->(1, "Failed to generate RSA key pair\n") if !$rsa;
363
364         $cleanup->(1, "Failed to assign RSA key to EVP_PKEY\n")
365             if !Net::SSLeay::EVP_PKEY_assign_RSA($pk, $rsa);
366     }
367
368     $req = Net::SSLeay::X509_REQ_new();
369     $cleanup->(1, "Failed to allocate X509_REQ\n") if !$req;
370
371     $cleanup->(1, "Failed to set subject name\n")
372         if (!Net::SSLeay::X509_REQ_set_subject_name($req, $name));
373
374     $cleanup->(1, "Failed to add extensions to CSR\n")
375         if !Net::SSLeay::P_X509_REQ_add_extensions($req,
376                 &Net::SSLeay::NID_key_usage => 'digitalSignature,keyEncipherment',
377                 &Net::SSLeay::NID_basic_constraints => 'CA:FALSE',
378                 &Net::SSLeay::NID_ext_key_usage => 'serverAuth,clientAuth',
379                 &Net::SSLeay::NID_subject_alt_name => join(',', map { "DNS:$_" } @$san),
380         );
381
382     $cleanup->(1, "Failed to set public key\n")
383         if !Net::SSLeay::X509_REQ_set_pubkey($req, $pk);
384
385     $cleanup->(1, "Failed to set CSR version\n")
386         if !Net::SSLeay::X509_REQ_set_version($req, 2);
387
388     $cleanup->(1, "Failed to sign CSR\n")
389         if !Net::SSLeay::X509_REQ_sign($req, $pk, $md);
390
391     my $pk_pem = Net::SSLeay::PEM_get_string_PrivateKey($pk);
392     my $req_pem = Net::SSLeay::PEM_get_string_X509_REQ($req);
393
394     $cleanup->();
395
396     return wantarray ? ($req_pem, $pk_pem) : $req_pem;
397 }
398
399 1;