Fix config generation
[acme] / acme.pm
1 # acme package
2 package acme;
3
4 # Best practice
5 use strict;
6 use warnings;
7
8 # Symbol export
9 use Exporter;
10 our @ISA = qw(Exporter);
11
12 # Load dependancies
13 use Carp qw(carp confess);
14 use Date::Parse qw(str2time);
15 use DateTime;
16 use Digest::SHA qw(sha256_base64);
17 use Email::Valid;
18 use File::Path qw(make_path);
19 use File::Slurp qw(read_file write_file);
20 use File::Temp; # qw( :seekable );
21 use IPC::System::Simple qw(capturex);
22 use JSON qw(encode_json decode_json);
23 use LWP;
24 use MIME::Base64 qw(encode_base64url encode_base64);
25 use Net::Domain::TLD;
26 use Tie::IxHash;
27 use POSIX qw(EXIT_FAILURE);
28
29 # Documentation links
30 #XXX: see https://letsencrypt.github.io/acme-spec/ (probably based on https://ietf-wg-acme.github.io/acme/)
31 #XXX: see jwk rfc http://www.rfc-editor.org/rfc/rfc7517.txt
32 #XXX: see javascript implementation https://github.com/diafygi/gethttpsforfree/blob/gh-pages/js/index.js
33
34 # Set constants
35 use constant {
36 # Directory separator
37 DS => '/',
38
39 # Directory for certificates
40 CERT_DIR => 'cert',
41
42 # Directory for keys
43 KEY_DIR => 'key',
44
45 # Directory for pending cache
46 PENDING_DIR => 'pending',
47
48 # Request certificate file name
49 REQUEST_CSR => 'request.der',
50
51 # Account key file name
52 ACCOUNT_KEY => 'account.pem',
53
54 # Server private key
55 SERVER_KEY => 'server.pem',
56
57 # Server public certificate
58 SERVER_CRT => 'server.crt',
59
60 # rsa
61 KEY_TYPE => 'rsa',
62
63 # 2048|4096
64 KEY_SIZE => 4096,
65
66 # Acme infos
67 ACME_CERT => 'https://letsencrypt.org/certs/lets-encrypt-x3-cross-signed.pem',
68 ACME_DIR => 'https://acme-staging.api.letsencrypt.org/directory',
69 ACME_PROD_DIR => 'https://acme-v01.api.letsencrypt.org/directory',
70 ACME_TERMS => 'https://letsencrypt.org/documents/LE-SA-v1.0.1-July-27-2015.pdf',
71
72 # Version
73 VERSION => 'v0.4',
74
75 # Config
76 CONFIG => '/etc/acmepl/config'
77 };
78
79 # User agent object
80 our $ua;
81
82 # Strerr backup
83 our $_stderr;
84
85 # JSON Web Key (JWK)
86 #XXX: tie to Tie::IxHash to keep a stable ordering of hash keys
87 #our %jwk = (
88 # pubkey => undef,
89 # jwk => {
90 # alg => 'RS256',
91 # jwk => {
92 # # Exponent
93 # e => undef,
94 # # Key type
95 # kty => uc(KEY_TYPE),
96 # # Modulus
97 # n => undef
98 # }
99 # },
100 # thumbprint => undef
101 #);
102 tie(our %jwk, 'Tie::IxHash', pubkey => undef, jwk => undef, thumbprint => undef);
103 tie(%{$jwk{jwk}}, 'Tie::IxHash', alg => 'RS256', jwk => undef);
104 #XXX: strict ordering only really needed here for thumbprint sha256 digest
105 tie(%{$jwk{jwk}{jwk}}, 'Tie::IxHash', e => undef, kty => uc(KEY_TYPE), n => undef);
106
107 # Constructor
108 sub new {
109 # Extract params
110 my ($class, $mail, $debug, $prod, @domains) = @_;
111
112 # Create self hash
113 my $self = {};
114
115 # Link self to package
116 bless($self, $class);
117
118 # Save debug
119 $self->{debug} = $debug;
120
121 # Save prod
122 $self->{prod} = $prod;
123
124 # Add extra check to mail validity
125 #XXX: mxcheck fail if there is only a A record on the domain
126 my $ev = Email::Valid->new(-fqdn => 1, -tldcheck => 1, -mxcheck => 1);
127
128 # Show error if check fail
129 if (! defined $ev->address($mail)) {
130 map { carp 'failed check: '.$_ if ($self->{debug}) } $ev->details();
131 confess 'Email::Valid->address failed';
132 }
133
134 # Save mail
135 $self->{mail} = $mail;
136
137 # Create resolver
138 my $res = new Net::DNS::Resolver();
139
140 # Check domains
141 map {
142 my $tld;
143
144 # Extract tld
145 unless (($tld) = $_ =~ m/\.(\w+)$/) {
146 confess $_.'\'s tld extraction failed';
147 }
148
149 # Check if tld exists
150 unless(Net::Domain::TLD::tld_exists($tld)) {
151 confess $tld.' tld from '.$_.' don\'t exists';
152 }
153
154 # Check if we get dns answer
155 #XXX: only search A type because letsencrypt don't support ipv6 (AAAA) yet
156 unless(my $rep = $res->search($_, 'A')) {
157 confess 'search A record for '.$_.' failed';
158 } else {
159 unless (scalar map { $_->type eq 'A' ? 1 : (); } $rep->answer) {
160 confess 'search recursively A record for '.$_.' failed';
161 }
162 }
163 } @domains;
164
165 # Save domains
166 @{$self->{domains}} = @domains;
167
168 # Return class reference
169 return $self;
170 }
171
172 # Prepare environement
173 sub prepare {
174 my ($self) = @_;
175
176 # Create all paths
177 make_path(CERT_DIR, KEY_DIR, PENDING_DIR.'/'.$self->{mail}.'.'.($self->{prod} ? 'prod' : 'staging'), {error => \my $err});
178 if (@$err) {
179 map {
180 my ($file, $msg) = %$_;
181 carp ($file eq '' ? '' : $file.': ').$msg if ($self->{debug});
182 } @$err;
183 confess 'make_path failed';
184 }
185
186 # Create user agent
187 $ua = LWP::UserAgent->new;
188 $ua->agent(__PACKAGE__.'/'.VERSION)
189 }
190
191 # Drop stderr
192 sub _dropStdErr {
193 # Save stderr
194 open($_stderr, '>&STDERR') or die $!;
195 # Close it
196 close(STDERR) or die $!;
197 # Send to /dev/null
198 open(STDERR, '>', '/dev/null') or die $!;
199 }
200
201 # Restore stderr
202 sub _restoreStdErr {
203 # Close stderr
204 close(STDERR);
205 # Open it back
206 open(STDERR, '>&', $_stderr) or die $!;
207 }
208
209 # Generate required keys
210 sub genKeys {
211 my ($self) = @_;
212
213 # Generate account and server key if required
214 map {
215 # Check key existence
216 if (! -f $_) {
217 # Drop stderr
218 _dropStdErr();
219 # Generate key
220 #XXX: we drop stderr here because openssl can't be quiet on this command
221 capturex('openssl', ('genrsa', '-out', $_, KEY_SIZE));
222 # Restore stderr
223 _restoreStdErr();
224 }
225 } (KEY_DIR.DS.ACCOUNT_KEY, KEY_DIR.DS.SERVER_KEY);
226
227 # Extract modulus and publicExponent jwk
228 #XXX: same here we tie to keep ordering
229 tie(%{$self->{account}}, 'Tie::IxHash', %jwk);
230 map {
231 if (/^Modulus=([0-9A-F]+)$/) {
232 # Extract to binary from hex and convert to base64 url
233 $self->{account}{jwk}{jwk}{n} = encode_base64url(pack("H*", $1) =~ s/^\0+//r);
234 } elsif (/^publicExponent:\s([0-9]+)\s\(0x[0-1]+\)$/) {
235 # Extract to binary from int, trim leading zeros and convert to base64 url
236 chomp ($self->{account}{jwk}{jwk}{e} = encode_base64url(pack("N", $1) =~ s/^\0+//r));
237 }
238 } capturex('openssl', ('rsa', '-text', '-in', KEY_DIR.DS.ACCOUNT_KEY, '-noout', '-modulus'));
239
240 # Drop stderr
241 _dropStdErr();
242 # Extract account public key
243 $self->{account}{pubkey} = join('', map { chomp; $_; } capturex('openssl', ('rsa', '-in', KEY_DIR.DS.ACCOUNT_KEY, '-pubout')));
244 # Restore stderr
245 _restoreStdErr();
246
247 # Store thumbprint
248 #XXX: convert base64 to base64 url
249 $self->{account}{thumbprint} = (sha256_base64(encode_json($self->{account}{jwk}{jwk})) =~ s/=+\z//r) =~ tr[+/][-_]r;
250 }
251
252 # Generate certificate request
253 sub genCsr {
254 my ($self) = @_;
255
256 # Openssl config template
257 my $oct = File::Temp->new();
258
259 # Load template from data
260 map { s/__EMAIL_ADDRESS__/$self->{mail}/; s/__COMMON_NAME__/$self->{domains}[0]/; print $oct $_; } <DATA>;
261
262 # Close data
263 close(DATA);
264
265 # Append domain names
266 my $i = 1;
267 map { print $oct 'DNS.'.$i++.' = '.$_."\n"; } @{$self->{domains}};
268
269 # Generate csr
270 capturex('openssl', ('req', '-new', '-outform', 'DER', '-key', KEY_DIR.DS.SERVER_KEY, '-config', $oct->filename, '-out', CERT_DIR.DS.REQUEST_CSR));
271
272 # Close oct
273 close($oct);
274 }
275
276 # Directory call
277 sub directory {
278 my ($self) = @_;
279
280 # Set time
281 my $time = time;
282
283 # Set directory
284 my $dir = $self->{prod} ? ACME_PROD_DIR : ACME_DIR;
285
286 # Create a request
287 my $req = HTTP::Request->new(GET => $dir.'?'.$time);
288
289 # Get request
290 my $res = $ua->request($req);
291
292 # Handle error
293 unless ($res->is_success) {
294 confess 'GET '.$dir.'?'.$time.' failed: '.$res->status_line;
295 }
296
297 # Save nonce
298 $self->{nonce} = $res->headers->{'replay-nonce'};
299
300 # Merge uris in self content
301 %$self = (%$self, %{decode_json($res->content)});
302 }
303
304 # Post request
305 sub _post {
306 my ($self, $uri, $payload) = @_;
307
308 # Protected field
309 my $protected = encode_base64url(encode_json({nonce => $self->{nonce}}));
310
311 # Payload field
312 $payload = encode_base64url(encode_json($payload));
313
314 # Sign temp file
315 my $stf = File::Temp->new();
316
317 # Append protect.payload to stf
318 print $stf $protected.'.'.$payload;
319
320 # Close stf
321 close($stf);
322
323 # Generate digest of stf
324 my $signature = encode_base64url(join('', capturex('openssl', ('dgst', '-sha256', '-binary', '-sign', KEY_DIR.DS.ACCOUNT_KEY, $stf->filename))) =~ s/^\0+//r);
325
326 # Create a request
327 my $req = HTTP::Request->new(POST => $uri);
328
329 # Set new-reg request content
330 $req->content(encode_json({
331 header => $self->{account}{jwk},
332 protected => $protected,
333 payload => $payload,
334 signature => $signature
335 }));
336
337 # Post request
338 my $res = $ua->request($req);
339
340 # Save nonce
341 if (defined $res->headers->{'replay-nonce'}) {
342 $self->{nonce} = $res->headers->{'replay-nonce'};
343 }
344
345 # Return res object
346 return $res;
347 }
348
349 # Resolve dns and check content
350 #XXX: see https://community.centminmod.com/threads/looks-like-letsencrypt-dns-01-is-ready.5845/#12 for example
351 sub _dnsCheck {
352 my ($self, $domain, $token) = @_;
353
354 # Generate signature from content
355 my $signature = ((sha256_base64($token.'.'.$self->{account}{thumbprint})) =~ s/=+\z//r) =~ tr[+/][-_]r;
356
357 # Fix domain
358 $domain = '_acme-challenge.'.$domain.'.';
359
360 # Create resolver
361 my $res = new Net::DNS::Resolver();
362
363 # Check if we get dns answer
364 unless(my $rep = $res->search($domain, 'TXT')) {
365 carp 'TXT record search for '.$domain.' failed' if ($self->{debug});
366 return;
367 } else {
368 unless (scalar map { $_->type eq 'TXT' && $_->txtdata =~ /^$signature$/ ? 1 : (); } $rep->answer) {
369 carp 'TXT record recursive search for '.$domain.' failed' if ($self->{debug});
370 return;
371 }
372 }
373
374 return 1;
375 }
376
377 # Get uri and check content
378 sub _httpCheck {
379 my ($self, $domain, $token) = @_;
380
381 # Create a request
382 my $req = HTTP::Request->new(GET => 'http://'.$domain.'/.well-known/acme-challenge/'.$token);
383
384 # Load config if available
385 my $config = undef;
386 if (
387 #XXX: use eval to workaround a fatal in decode_json
388 defined eval {
389 # Check that file exists
390 -f CONFIG &&
391 # Read it
392 ($config = read_file(CONFIG)) &&
393 # Decode it
394 ($config = decode_json($config)) &&
395 # Check defined
396 $config->{thumbprint}
397 }
398 ) {
399 # Try to write thumbprint
400 write_file($config->{thumbprint}, $self->{account}{thumbprint});
401 }
402
403 # Get request
404 my $res = $ua->request($req);
405
406 # Handle error
407 unless ($res->is_success) {
408 carp 'GET http://'.$domain.'/.well-known/acme-challenge/'.$token.' failed: '.$res->status_line if ($self->{debug});
409 return;
410 }
411
412 # Handle invalid content
413 unless($res->content =~ /^$token.$self->{account}{thumbprint}\s*$/) {
414 carp 'GET http://'.$domain.'/.well-known/acme-challenge/'.$token.' content match failed: /^'.$token.'.'.$self->{account}{thumbprint}.'\s*$/ !~ '.$res->content if ($self->{debug});
415 return;
416 }
417
418 # Return success
419 return 1;
420 }
421
422 # Register account
423 #XXX: see doc at https://ietf-wg-acme.github.io/acme/#rfc.section.6.3
424 sub register {
425 my ($self) = @_;
426
427 # Post new-reg request
428 #XXX: contact array may contain a tel:+33612345678 for example
429 my $res = $self->_post($self->{'new-reg'}, {resource => 'new-reg', contact => ['mailto:'.$self->{mail}], agreement => ACME_TERMS});
430
431 # Handle error
432 unless ($res->is_success || $res->code eq 409) {
433 confess 'POST '.$self->{'new-reg'}.' failed: '.$res->status_line;
434 }
435
436 # Update mail informations
437 if ($res->code eq 409) {
438 # Save registration uri
439 $self->{'reg'} = $res->headers->{location};
440
441 # Post reg request
442 #XXX: contact array may contain a tel:+33612345678 for example
443 $res = $self->_post($self->{'reg'}, {resource => 'reg', contact => ['mailto:'.$self->{mail}]});
444
445 # Handle error
446 unless ($res->is_success) {
447 confess 'POST '.$self->{'reg'}.' failed: '.$res->status_line;
448 }
449 }
450 }
451
452 # Authorize domains
453 sub authorize {
454 my ($self) = @_;
455
456 # Create challenges hash
457 %{$self->{challenges}} = ();
458
459 # Pending list
460 my @pending = ();
461
462 # Create or load auth request for each domain
463 map {
464 # Init content
465 my $content = undef;
466
467 # Init file
468 my $file = PENDING_DIR.'/'.$self->{mail}.'.'.($self->{prod} ? 'prod' : 'staging').'/'.$_;
469
470 # Load auth request content or post a new one
471 #TODO: add more check on cache file ???
472 if (
473 #XXX: use eval to workaround a fatal in decode_json
474 ! defined eval {
475 # Check that file exists
476 -f $file &&
477 # Read it
478 ($content = read_file($file)) &&
479 # Decode it
480 ($content = decode_json($content)) &&
481 # Check expiration
482 (DateTime->from_epoch(epoch => str2time($content->{expires})) >= DateTime->now()->add(hours => 1))
483 }
484 ) {
485 # Post new-authz request
486 my $res = $self->_post($self->{'new-authz'}, {resource => 'new-authz', identifier => {type => 'dns', value => $_}, existing => 'accept'});
487
488 # Handle error
489 unless ($res->is_success) {
490 confess 'POST '.$self->{'new-authz'}.' for '.$_.' failed: '.$res->status_line;
491 }
492
493 # Decode content
494 $content = decode_json($res->content);
495
496 # Check domain
497 unless (defined $content->{identifier}{value} && $content->{identifier}{value} eq $_) {
498 confess 'domain matching '.$content->{identifier}{value}.' for '.$_.' failed: '.$res->status_line;
499 }
500
501 # Check status
502 unless ($content->{status} eq 'valid' or $content->{status} eq 'pending') {
503 confess 'POST '.$self->{'new-authz'}.' for '.$_.' failed: '.$res->status_line;
504 }
505
506 # Write to file
507 write_file($file, encode_json($content));
508 }
509
510 # Add challenge
511 %{$self->{challenges}{$_}} = (
512 status => $content->{status},
513 expires => $content->{expires},
514 polls => []
515 );
516
517 # Save pending data
518 if ($content->{status} eq 'pending') {
519 # Extract validation data
520 foreach my $challenge (@{$content->{challenges}}) {
521 # One test already validated this auth request
522 if ($self->{challenges}{$_}{status} eq 'valid') {
523 next;
524 } elsif ($challenge->{status} eq 'valid') {
525 $self->{challenges}{$_}{status} = $challenge->{status};
526 next;
527 } elsif ($challenge->{status} eq 'pending') {
528 # Handle check
529 if (
530 ($challenge->{type} =~ /^http-01$/ and $self->_httpCheck($_, $challenge->{token})) or
531 ($challenge->{type} =~ /^dns-01$/ and $self->_dnsCheck($_, $challenge->{token}))
532 ) {
533 # Post challenge request
534 my $res = $self->_post($challenge->{uri}, {resource => 'challenge', keyAuthorization => $challenge->{token}.'.'.$self->{account}{thumbprint}});
535
536 # Handle error
537 unless ($res->is_success) {
538 confess 'POST '.$challenge->{uri}.' failed: '.$res->status_line;
539 }
540
541 # Extract content
542 my $content = decode_json($res->content);
543
544 # Save if valid
545 if ($content->{status} eq 'valid') {
546 $self->{challenges}{$_}{status} = $content->{status};
547 # Check is still polling
548 } elsif ($content->{status} eq 'pending') {
549 # Add to poll list for later use
550 push(@{$self->{challenges}{$_}{polls}}, {
551 type => (split(/-/, $challenge->{type}))[0],
552 status => $content->{status},
553 poll => $content->{uri}
554 });
555 }
556 }
557 }
558 }
559 # Check if check is challenge still in pending and no polls
560 if ($self->{challenges}{$_}{status} eq 'pending' && scalar @{$self->{challenges}{$_}{polls}} == 0) {
561 # Loop on all remaining challenges
562 foreach my $challenge (@{$content->{challenges}}) {
563 # Display help for http-01 check
564 if ($challenge->{type} eq 'http-01') {
565 print STDERR 'Create URI http://'.$_.'/.well-known/acme-challenge/'.$challenge->{token}.' with content '.$challenge->{token}.'.'.$self->{account}{thumbprint}."\n";
566 # Display help for dns-01 check
567 } elsif ($challenge->{type} eq 'dns-01') {
568 print STDERR 'Create TXT record _acme-challenge.'.$_.'. with value '.(((sha256_base64($challenge->{token}.'.'.$self->{account}{thumbprint})) =~ s/=+\z//r) =~ tr[+/][-_]r)."\n";
569 }
570 }
571 }
572 }
573 } @{$self->{domains}};
574
575 # Init max run
576 my $remaining = 10;
577
578 # Poll pending
579 while (--$remaining >= 0 and scalar map { $_->{status} eq 'valid' ? 1 : (); } values %{$self->{challenges}}) {
580 # Sleep
581 sleep(1);
582 # Poll remaining pending
583 map {
584 # Init domain
585 my $domain = $_;
586
587 # Poll remaining polls
588 map {
589 # Create a request
590 my $req = HTTP::Request->new(GET => $_->{poll});
591
592 # Get request
593 my $res = $ua->request($req);
594
595 # Handle error
596 unless ($res->is_success) {
597 carp 'GET '.$self->{challenges}{$_}{http_challenge}.' failed: '.$res->status_line if ($self->{debug});
598 }
599
600 # Extract content
601 my $content = decode_json($res->content);
602
603 # Save status
604 if ($content->{status} ne 'pending') {
605 $self->{challenges}{$domain}{status} = $content->{status};
606 }
607 } @{$self->{challenges}{$_}{polls}};
608 } map { $self->{challenges}{$_}{status} eq 'pending' ? $_ : (); } keys %{$self->{challenges}};
609 }
610
611 # Load config if available
612 my $config = undef;
613 if (
614 #XXX: use eval to workaround a fatal in decode_json
615 defined eval {
616 # Check that file exists
617 -f CONFIG &&
618 # Read it
619 ($config = read_file(CONFIG)) &&
620 # Decode it
621 ($config = decode_json($config)) &&
622 # Check defined
623 $config->{thumbprint}
624 }
625 ) {
626 # Try to write thumbprint
627 write_file($config->{thumbprint}, '');
628 }
629
630 # Stop here with remaining chanllenge
631 if (scalar map { ! defined $_->{status} or $_->{status} ne 'valid' ? 1 : (); } values %{$self->{challenges}}) {
632 # Deactivate all activated domains
633 #XXX: not implemented by letsencrypt
634 #map {
635 # # Post deactivation request
636 # my $res = $self->_post($self->{challenges}{$_}{http_uri}, {resource => 'authz', status => 'deactivated'});
637 # # Handle error
638 # unless ($res->is_success) {
639 # print Dumper($res);
640 # confess 'POST '.$self->{challenges}{$_}{http_uri}.' failed: '.$res->status_line;
641 # }
642 #} map { $self->{challenges}{$_}{status} eq 'valid' ? $_ : () } keys %{$self->{challenges}};
643
644 # Stop here as a domain of csr list failed authorization
645 if ($self->{debug}) {
646 my @domains = map { ! defined $self->{challenges}{$_}{status} or $self->{challenges}{$_}{status} ne 'valid' ? $_ : (); } keys %{$self->{challenges}};
647 confess 'Fix the challenge'.(scalar @domains > 1?'s':'').' for domain'.(scalar @domains > 1?'s':'').': '.join(', ', @domains);
648 } else {
649 exit EXIT_FAILURE;
650 }
651 }
652 }
653
654 # Issue certificate
655 sub issue {
656 my ($self) = @_;
657
658 # Open csr file
659 open(my $fh, '<', CERT_DIR.DS.REQUEST_CSR) or die $!;
660
661 # Load csr
662 my $csr = encode_base64url(join('', <$fh>) =~ s/^\0+//r);
663
664 # Close csr file
665 close($fh) or die $!;
666
667 # Post certificate request
668 my $res = $self->_post($self->{'new-cert'}, {resource => 'new-cert', csr => $csr});
669
670 # Handle error
671 unless ($res->is_success) {
672 confess 'POST '.$self->{'new-cert'}.' failed: '.$res->status_line;
673 }
674
675 # Open crt file
676 open($fh, '>', CERT_DIR.DS.SERVER_CRT) or die $!;
677
678 # Convert to pem
679 print $fh '-----BEGIN CERTIFICATE-----'."\n".encode_base64($res->content).'-----END CERTIFICATE-----'."\n";
680
681 # Create a request
682 my $req = HTTP::Request->new(GET => ACME_CERT);
683
684 # Get request
685 $res = $ua->request($req);
686
687 # Handle error
688 unless ($res->is_success) {
689 carp 'GET '.ACME_CERT.' failed: '.$res->status_line if ($self->{debug});
690 }
691
692 # Append content
693 print $fh $res->content;
694
695 # Close file
696 close($fh) or die $!;
697
698 # Print success
699 carp 'Success, pem certificate in '.CERT_DIR.DS.SERVER_CRT if ($self->{debug});
700 }
701
702 1;
703
704 __DATA__
705 #
706 # OpenSSL configuration file.
707 # This is mostly being used for generation of certificate requests.
708 #
709
710 [ req ]
711 default_bits = 2048
712 default_md = sha256
713 prompt = no
714 distinguished_name = req_distinguished_name
715 # The extentions to add to the self signed cert
716 x509_extensions = v3_ca
717 # The extensions to add to a certificate request
718 req_extensions = v3_req
719
720 # This sets a mask for permitted string types. There are several options.
721 # utf8only: only UTF8Strings (PKIX recommendation after 2004).
722 # WARNING: ancient versions of Netscape crash on BMPStrings or UTF8Strings.
723 string_mask = utf8only
724
725 [ req_distinguished_name ]
726 countryName = US
727 stateOrProvinceName = State or Province Name
728 localityName = Locality Name
729 organizationName = Organization Name
730 organizationalUnitName = Organizational Unit Name
731 commonName = __COMMON_NAME__
732 emailAddress = __EMAIL_ADDRESS__
733
734 [ v3_req ]
735 basicConstraints = CA:false
736 keyUsage = nonRepudiation, digitalSignature, keyEncipherment
737 subjectAltName = email:move
738 subjectAltName = @alt_names
739
740 [ v3_ca ]
741 subjectKeyIdentifier = hash
742 authorityKeyIdentifier = keyid:always,issuer
743 basicConstraints = CA:true
744
745 [alt_names]