]>
Raphaël G. Git Repositories - acme/blob - Acme.pm
adcc99e0fbab09d0b0555dd35c9e05de577c0859
1 # This file is part of Acmepl
3 # Acmepl is is free software: you can redistribute it and/or modify
4 # it under the terms of the GNU General Public License as published by
5 # the Free Software Foundation, either version 3 of the License, or
6 # (at your option) any later version.
8 # This program is distributed in the hope that it will be useful,
9 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # GNU General Public License for more details.
13 # You should have received a copy of the GNU General Public License
14 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 # Copyright (C) 2016 - 2017 Raphaël Gertz <acme@rapsys.eu>
26 use filetest
qw(access) ;
30 our @ISA = qw(Exporter) ;
31 our @EXPORT_OK = qw(VERSION) ;
34 use Carp
qw(carp confess) ;
35 use Date
:: Parse
qw(str2time) ;
36 use Digest
:: SHA
qw(sha256_base64) ;
38 use File
:: Copy
qw(copy) ;
39 use File
:: Path
qw(make_path) ;
40 use File
:: Slurp
qw(read_file write_file) ;
41 use File
:: Temp
; # qw( :seekable );
42 use IPC
:: System
:: Simple
qw(capturex) ;
43 use JSON
qw(from_json to_json) ;
45 use MIME
:: Base64
qw(encode_base64url encode_base64) ;
47 use POSIX
qw(EXIT_FAILURE) ;
54 #XXX: see https://letsencrypt.github.io/acme-spec/ (probably based on https://ietf-wg-acme.github.io/acme/)
55 #XXX: see jwk rfc http://www.rfc-editor.org/rfc/rfc7517.txt
56 #XXX: see javascript implementation https://github.com/diafygi/gethttpsforfree/blob/gh-pages/js/index.js
60 # Request certificate file name
61 REQUEST_CSR
=> 'request.der' ,
70 ACME_CERT
=> 'https://letsencrypt.org/certs/lets-encrypt-x3-cross-signed.pem' ,
71 ACME_DIR
=> 'https://acme-staging.api.letsencrypt.org/directory' ,
72 ACME_PROD_DIR
=> 'https://acme-v01.api.letsencrypt.org/directory' ,
85 #XXX: tie to Tie::IxHash to keep a stable ordering of hash keys
94 # kty => uc(KEY_TYPE),
101 tie
( our %jwk , 'Tie::IxHash' , pubkey
=> undef , jwk
=> undef , thumbprint
=> undef );
102 tie
(%{ $jwk { jwk
}}, 'Tie::IxHash' , alg
=> 'RS256' , jwk
=> undef );
103 #XXX: strict ordering only really needed here for thumbprint sha256 digest
104 tie
(%{ $jwk { jwk
}{ jwk
}}, 'Tie::IxHash' , e
=> undef , kty
=> uc ( KEY_TYPE
), n
=> undef );
109 my ( $class , $debug , $domain , $config ) = @_ ;
114 # Link self to package
115 bless ( $self , $class );
118 $self ->{ debug
} = $debug ;
121 $self ->{ domain
} = $domain ;
124 $self ->{ config
} = $config ;
127 @{ $self ->{ domains
}} = ( $domain ->{ domain
}, @{ $domain ->{ domains
}});
129 # Add extra check to mail validity
130 #XXX: mxcheck fail if there is only a A record on the domain
131 my $ev = Email
:: Valid-
> new (- fqdn
=> 1 , - tldcheck
=> 1 , - mxcheck
=> 1 );
133 # Show error if check fail
134 if (! defined $ev -> address ( $self ->{ domain
}{ mail
})) {
135 map { carp
'failed check: ' . $_ if ( $self ->{ debug
}) } $ev -> details ();
136 confess
'Email::Valid->address failed' ;
140 $self ->{ mail
} = $self ->{ domain
}{ mail
};
143 my $res = new Net
:: DNS
:: Resolver
();
150 unless (( $tld ) = $_ =~ m/\.(\w+)$/ ) {
151 confess
$_ . ' \' s tld extraction failed' ;
154 # Check if tld exists
155 unless ( Net
:: Domain
:: TLD
:: tld_exists
( $tld )) {
156 confess
$tld . ' tld from ' . $_ . ' don \' t exists' ;
159 # Check if we get dns answer
160 #XXX: only search A type because letsencrypt don't support ipv6 (AAAA) yet
161 unless ( my $rep = $res -> search ( $_ , 'A' )) {
162 confess
'search A record for ' . $_ . ' failed' ;
164 unless ( scalar map { $_ -> type eq 'A' ? 1 : (); } $rep -> answer ) {
165 confess
'search recursively A record for ' . $_ . ' failed' ;
168 } @{ $self ->{ domains
}};
170 # Return class reference
174 # Prepare environement
178 # Extract cert directory and filename
179 my ( $certFile , $certDir ) = File
:: Spec-
> splitpath ( $self ->{ domain
}{ cert
});
181 # Extract key directory and filename
182 my ( $keyFile , $keyDir ) = File
:: Spec-
> splitpath ( $self ->{ domain
}{ key
});
184 # Extract account directory and filename
185 my ( $accountFile , $accountDir ) = File
:: Spec-
> splitpath ( $self ->{ domain
}{ account
});
189 make_path
( $certDir , $keyDir , $accountDir , $self ->{ config
}{ pending
}. '/' . $self ->{ mail
}. '.' .( $self ->{ domain
}{ prod
} ? 'prod' : 'staging' ), { error
=> \
my $err });
192 my ( $file , $msg ) = %$_ ;
193 carp
( $file eq '' ? '' : $file . ': ' ). $msg if ( $self ->{ debug
});
195 confess
'make_path failed' ;
200 $ua = LWP
:: UserAgent-
> new ;
201 $ua -> agent ( __PACKAGE__
. '/' . VERSION
);
203 # Check that certificate is writable
204 unless (- w
$certDir || - w
$self ->{ domain
}{ cert
}) {
205 confess
( 'Directory ' . $certDir . ' or file ' . $self ->{ domain
}{ cert
}. ' must be writable: ' . $! );
208 # Check that key is writable
209 unless (- r
$self ->{ domain
}{ key
} || - w
$keyDir ) {
210 confess
( 'File ' . $self ->{ domain
}{ key
}. ' must be readable or directory ' . $keyDir . ' must be writable: ' . $! );
213 # Check that account is writable
214 unless (- r
$self ->{ domain
}{ account
} || - w
$accountDir ) {
215 confess
( 'File ' . $self ->{ domain
}{ account
}. ' must be readable or directory ' . $accountDir . ' must be writable: ' . $! );
218 # Backup old certificate if possible
219 if (- w
$certDir && - f
$self ->{ domain
}{ cert
}) {
220 my ( $dt , $suffix ) = undef ;
222 # Extract datetime suffix
223 $suffix = ( $dt = DateTime-
> from_epoch ( epoch
=> stat ( $self ->{ domain
}{ cert
})-> mtime ))-> ymd ( '' ). $dt -> hms ( '' );
225 # Rename old certificate
226 unless ( copy
( $self ->{ domain
}{ cert
}, $self ->{ domain
}{ cert
}. '.' . $suffix )) {
227 carp
( 'Copy ' . $self ->{ domain
}{ cert
}. ' to ' . $self ->{ domain
}{ cert
}. '.' . $suffix . ' failed: ' . $! );
235 open ( $_stderr , '>&STDERR' ) or die $! ;
237 close ( STDERR
) or die $! ;
239 open ( STDERR
, '>' , '/dev/null' ) or die $! ;
247 open ( STDERR
, '>&' , $_stderr ) or die $! ;
250 # Generate required keys
254 # Generate account and server key if required
256 # Check key existence
261 #XXX: we drop stderr here because openssl can't be quiet on this command
262 capturex
( 'openssl' , ( 'genrsa' , '-out' , $_ , KEY_SIZE
));
266 } ( $self ->{ domain
}{ account
}, $self ->{ domain
}{ key
});
268 # Extract modulus and publicExponent jwk
269 #XXX: same here we tie to keep ordering
270 tie
(%{ $self ->{ account
}}, 'Tie::IxHash' , %jwk );
272 if ( /^Modulus=([0-9A-F]+)$/ ) {
273 # Extract to binary from hex and convert to base64 url
274 $self ->{ account
}{ jwk
}{ jwk
}{ n
} = encode_base64url
( pack ( "H*" , $1 ) =~ s/^\0+//r );
275 } elsif ( /^publicExponent:\s([0-9]+)\s\(0x[0-1]+\)$/ ) {
276 # Extract to binary from int, trim leading zeros and convert to base64 url
277 chomp ( $self ->{ account
}{ jwk
}{ jwk
}{ e
} = encode_base64url
( pack ( "N" , $1 ) =~ s/^\0+//r ));
279 } capturex
( 'openssl' , ( 'rsa' , '-text' , '-in' , $self ->{ domain
}{ account
}, '-noout' , '-modulus' ));
283 # Extract account public key
284 $self ->{ account
}{ pubkey
} = join ( '' , map { chomp ; $_ ; } capturex
( 'openssl' , ( 'rsa' , '-in' , $self ->{ domain
}{ account
}, '-pubout' )));
289 #XXX: convert base64 to base64 url
290 $self ->{ account
}{ thumbprint
} = ( sha256_base64
( to_json
( $self ->{ account
}{ jwk
}{ jwk
})) =~ s/=+\z//r ) =~ tr
[+/][- _
] r
;
293 # Generate certificate request
297 # Openssl config template
298 my $oct = File
:: Temp-
> new ();
300 # Save data start position
303 # Load template from data
304 map { s/__EMAIL_ADDRESS__/$self->{mail}/ ; s/__COMMON_NAME__/$self->{domains}[0]/ ; print $oct $_ ; } < DATA
>;
309 # Append domain names
311 map { print $oct 'DNS.' . $i++ . ' = ' . $_ . " \n " ; } @{ $self ->{ domains
}};
314 capturex
( 'openssl' , ( 'req' , '-new' , '-outform' , 'DER' , '-key' , $self ->{ domain
}{ key
}, '-config' , $oct -> filename , '-out' , $self ->{ config
}{ pending
}. '/' . $self ->{ mail
}. '.' .( $self ->{ domain
}{ prod
} ? 'prod' : 'staging' ). '/' . REQUEST_CSR
));
328 my $dir = $self ->{ prod
} ? ACME_PROD_DIR
: ACME_DIR
;
331 my $req = HTTP
:: Request-
> new ( GET
=> $dir . '?' . $time );
334 my $res = $ua -> request ( $req );
337 unless ( $res -> is_success ) {
338 confess
'GET ' . $dir . '?' . $time . ' failed: ' . $res -> status_line ;
342 $self ->{ nonce
} = $res -> headers ->{ 'replay-nonce' };
344 # Merge uris in self content
345 %$self = ( %$self , %{ from_json
( $res -> content )});
350 my ( $self , $uri , $payload ) = @_ ;
353 my $protected = encode_base64url
( to_json
({ nonce
=> $self ->{ nonce
}}));
356 $payload = encode_base64url
( to_json
( $payload ));
359 my $stf = File
:: Temp-
> new ();
361 # Append protect.payload to stf
362 print $stf $protected . '.' . $payload ;
367 # Generate digest of stf
368 my $signature = encode_base64url
( join ( '' , capturex
( 'openssl' , ( 'dgst' , '-sha256' , '-binary' , '-sign' , $self ->{ domain
}{ account
}, $stf -> filename ))) =~ s/^\0+//r );
371 my $req = HTTP
:: Request-
> new ( POST
=> $uri );
373 # Set new-reg request content
374 $req -> content ( to_json
({
375 header
=> $self ->{ account
}{ jwk
},
376 protected
=> $protected ,
378 signature
=> $signature
382 my $res = $ua -> request ( $req );
385 if ( defined $res -> headers ->{ 'replay-nonce' }) {
386 $self ->{ nonce
} = $res -> headers ->{ 'replay-nonce' };
393 # Resolve dns and check content
394 #XXX: see https://community.centminmod.com/threads/looks-like-letsencrypt-dns-01-is-ready.5845/#12 for example
396 my ( $self , $domain , $token ) = @_ ;
398 # Generate signature from content
399 my $signature = (( sha256_base64
( $token . '.' . $self ->{ account
}{ thumbprint
})) =~ s/=+\z//r ) =~ tr
[+/][- _
] r
;
402 $domain = '_acme-challenge.' . $domain . '.' ;
405 my $res = new Net
:: DNS
:: Resolver
();
407 # Check if we get dns answer
408 unless ( my $rep = $res -> search ( $domain , 'TXT' )) {
409 carp
'TXT record search for ' . $domain . ' failed' if ( $self ->{ debug
});
412 unless ( scalar map { $_ -> type eq 'TXT' && $_ -> txtdata =~ /^$signature$/ ? 1 : (); } $rep -> answer ) {
413 carp
'TXT record recursive search for ' . $domain . ' failed' if ( $self ->{ debug
});
421 # Get uri and check content
423 my ( $self , $domain , $token ) = @_ ;
426 my $req = HTTP
:: Request-
> new ( GET
=> 'http://' . $domain . '/.well-known/acme-challenge/' . $token );
428 # Check if thumbprint is writeable
429 if (- w
$self ->{ config
}{ thumbprint
}) {
430 # Try to write thumbprint
431 write_file
( $self ->{ config
}{ thumbprint
}, $self ->{ account
}{ thumbprint
});
435 my $res = $ua -> request ( $req );
438 unless ( $res -> is_success ) {
439 carp
'GET http://' . $domain . '/.well-known/acme-challenge/' . $token . ' failed: ' . $res -> status_line if ( $self ->{ debug
});
443 # Handle invalid content
444 unless ( $res -> content =~ /^$token.$self->{account}{thumbprint}\s*$/ ) {
445 carp
'GET http://' . $domain . '/.well-known/acme-challenge/' . $token . ' content match failed: /^' . $token . '.' . $self ->{ account
}{ thumbprint
}. '\s* $/ !~ ' . $res -> content if ( $self ->{ debug
});
454 #XXX: see doc at https://ietf-wg-acme.github.io/acme/#rfc.section.6.3
458 # Post new-reg request
459 #XXX: contact array may contain a tel:+33612345678 for example
460 my $res = $self -> _post ( $self ->{ 'new-reg' }, { resource
=> 'new-reg' , contact
=> [ 'mailto:' . $self ->{ mail
}], agreement
=> $self ->{ term
}});
463 unless ( $res -> is_success || $res -> code eq 409 ) {
464 confess
'POST ' . $self ->{ 'new-reg' }. ' failed: ' . $res -> status_line ;
467 # Update mail informations
468 if ( $res -> code eq 409 ) {
469 # Save registration uri
470 $self ->{ 'reg' } = $res -> headers ->{ location
};
473 #XXX: contact array may contain a tel:+33612345678 for example
474 $res = $self -> _post ( $self ->{ 'reg' }, { resource
=> 'reg' , contact
=> [ 'mailto:' . $self ->{ mail
}]});
477 unless ( $res -> is_success ) {
478 confess
'POST ' . $self ->{ 'reg' }. ' failed: ' . $res -> status_line ;
487 # Create challenges hash
488 %{ $self ->{ challenges
}} = ();
493 # Create or load auth request for each domain
499 my $file = $self ->{ config
}{ pending
}. '/' . $self ->{ mail
}. '.' .( $self ->{ domain
}{ prod
} ? 'prod' : 'staging' ). '/' . $_ ;
501 # Load auth request content or post a new one
502 #TODO: add more check on cache file ???
504 #XXX: use eval to workaround a fatal in from_json
506 # Check that file exists
509 ( $content = read_file
( $file )) &&
511 ( $content = from_json
( $content ))
513 } || ( str2time
( $content ->{ expires
}) <= time ()+ 3600 )
515 # Post new-authz request
516 my $res = $self -> _post ( $self ->{ 'new-authz' }, { resource
=> 'new-authz' , identifier
=> { type
=> 'dns' , value
=> $_ }, existing
=> 'accept' });
519 unless ( $res -> is_success ) {
520 confess
'POST ' . $self ->{ 'new-authz' }. ' for ' . $_ . ' failed: ' . $res -> status_line ;
524 $content = from_json
( $res -> content );
527 unless ( defined $content ->{ identifier
}{ value
} && $content ->{ identifier
}{ value
} eq $_ ) {
528 confess
'domain matching ' . $content ->{ identifier
}{ value
}. ' for ' . $_ . ' failed: ' . $res -> status_line ;
532 unless ( $content ->{ status
} eq 'valid' or $content ->{ status
} eq 'pending' ) {
533 confess
'POST ' . $self ->{ 'new-authz' }. ' for ' . $_ . ' failed: ' . $res -> status_line ;
537 write_file
( $file , to_json
( $content ));
541 %{ $self ->{ challenges
}{ $_ }} = (
542 status
=> $content ->{ status
},
543 expires
=> $content ->{ expires
},
548 if ( $content ->{ status
} eq 'pending' ) {
549 # Extract validation data
550 foreach my $challenge (@{ $content ->{ challenges
}}) {
551 # One test already validated this auth request
552 if ( $self ->{ challenges
}{ $_ }{ status
} eq 'valid' ) {
554 } elsif ( $challenge ->{ status
} eq 'valid' ) {
555 $self ->{ challenges
}{ $_ }{ status
} = $challenge ->{ status
};
557 } elsif ( $challenge ->{ status
} eq 'pending' ) {
560 ( $challenge ->{ type
} =~ /^http-01$/ and $self -> _httpCheck ( $_ , $challenge ->{ token
})) or
561 ( $challenge ->{ type
} =~ /^dns-01$/ and $self -> _dnsCheck ( $_ , $challenge ->{ token
}))
563 # Post challenge request
564 my $res = $self -> _post ( $challenge ->{ uri
}, { resource
=> 'challenge' , keyAuthorization
=> $challenge ->{ token
}. '.' . $self ->{ account
}{ thumbprint
}});
567 unless ( $res -> is_success ) {
568 confess
'POST ' . $challenge ->{ uri
}. ' failed: ' . $res -> status_line ;
572 my $content = from_json
( $res -> content );
575 if ( $content ->{ status
} eq 'valid' ) {
576 $self ->{ challenges
}{ $_ }{ status
} = $content ->{ status
};
577 # Check is still polling
578 } elsif ( $content ->{ status
} eq 'pending' ) {
579 # Add to poll list for later use
580 push (@{ $self ->{ challenges
}{ $_ }{ polls
}}, {
581 type
=> ( split ( /-/ , $challenge ->{ type
}))[ 0 ],
582 status
=> $content ->{ status
},
583 poll
=> $content ->{ uri
}
589 # Check if check is challenge still in pending and no polls
590 if ( $self ->{ challenges
}{ $_ }{ status
} eq 'pending' && scalar @{ $self ->{ challenges
}{ $_ }{ polls
}} == 0 ) {
591 # Loop on all remaining challenges
592 foreach my $challenge (@{ $content ->{ challenges
}}) {
593 # Display help for http-01 check
594 if ( $challenge ->{ type
} eq 'http-01' ) {
595 print STDERR
'Create URI http://' . $_ . '/.well-known/acme-challenge/' . $challenge ->{ token
}. ' with content ' . $challenge ->{ token
}. '.' . $self ->{ account
}{ thumbprint
}. " \n " ;
596 # Display help for dns-01 check
597 } elsif ( $challenge ->{ type
} eq 'dns-01' ) {
598 print STDERR
'Create TXT record _acme-challenge.' . $_ . '. with value ' .((( sha256_base64
( $challenge ->{ token
}. '.' . $self ->{ account
}{ thumbprint
})) =~ s/=+\z//r ) =~ tr
[+/][- _
] r
). " \n " ;
603 } @{ $self ->{ domains
}};
609 while (-- $remaining >= 0 and scalar map { $_ ->{ status
} eq 'valid' ? 1 : (); } values %{ $self ->{ challenges
}}) {
612 # Poll remaining pending
617 # Poll remaining polls
620 my $req = HTTP
:: Request-
> new ( GET
=> $_ ->{ poll
});
623 my $res = $ua -> request ( $req );
626 unless ( $res -> is_success ) {
627 carp
'GET ' . $self ->{ challenges
}{ $_ }{ http_challenge
}. ' failed: ' . $res -> status_line if ( $self ->{ debug
});
631 my $content = from_json
( $res -> content );
634 if ( $content ->{ status
} ne 'pending' ) {
635 $self ->{ challenges
}{ $domain }{ status
} = $content ->{ status
};
637 } @{ $self ->{ challenges
}{ $_ }{ polls
}};
638 } map { $self ->{ challenges
}{ $_ }{ status
} eq 'pending' ? $_ : (); } keys %{ $self ->{ challenges
}};
641 # Check if thumbprint is writeable
642 if (- w
$self ->{ config
}{ thumbprint
}) {
643 # Try to write thumbprint
644 write_file
( $self ->{ config
}{ thumbprint
}, '' );
647 # Stop here with remaining chanllenge
648 if ( scalar map { ! defined $_ ->{ status
} or $_ ->{ status
} ne 'valid' ? 1 : (); } values %{ $self ->{ challenges
}}) {
649 # Deactivate all activated domains
650 #XXX: not implemented by letsencrypt
652 # # Post deactivation request
653 # my $res = $self->_post($self->{challenges}{$_}{http_uri}, {resource => 'authz', status => 'deactivated'});
655 # unless ($res->is_success) {
656 # confess 'POST '.$self->{challenges}{$_}{http_uri}.' failed: '.$res->status_line;
658 #} map { $self->{challenges}{$_}{status} eq 'valid' ? $_ : () } keys %{$self->{challenges}};
660 # Stop here as a domain of csr list failed authorization
661 if ( $self ->{ debug
}) {
662 my @domains = map { ! defined $self ->{ challenges
}{ $_ }{ status
} or $self ->{ challenges
}{ $_ }{ status
} ne 'valid' ? $_ : (); } keys %{ $self ->{ challenges
}};
663 confess
'Fix the challenge' .( scalar @domains > 1 ? 's' : '' ). ' for domain' .( scalar @domains > 1 ? 's' : '' ). ': ' . join ( ', ' , @domains );
675 open ( my $fh , '<' , $self ->{ config
}{ pending
}. '/' . $self ->{ mail
}. '.' .( $self ->{ domain
}{ prod
} ? 'prod' : 'staging' ). '/' . REQUEST_CSR
) or die $! ;
678 my $csr = encode_base64url
( join ( '' , < $fh >) =~ s/^\0+//r );
681 close ( $fh ) or die $! ;
683 # Post certificate request
684 my $res = $self -> _post ( $self ->{ 'new-cert' }, { resource
=> 'new-cert' , csr
=> $csr });
687 unless ( $res -> is_success ) {
688 confess
'POST ' . $self ->{ 'new-cert' }. ' failed: ' . $res -> status_line ;
692 open ( $fh , '>' , $self ->{ domain
}{ cert
}) or die $! ;
695 print $fh '-----BEGIN CERTIFICATE-----' . " \n " . encode_base64
( $res -> content ). '-----END CERTIFICATE-----' . " \n " ;
698 my $req = HTTP
:: Request-
> new ( GET
=> ACME_CERT
);
701 $res = $ua -> request ( $req );
704 unless ( $res -> is_success ) {
705 carp
'GET ' . ACME_CERT
. ' failed: ' . $res -> status_line if ( $self ->{ debug
});
709 print $fh $res -> content ;
712 close ( $fh ) or die $! ;
715 carp
'Success, pem certificate in ' . $self ->{ domain
}{ cert
} if ( $self ->{ debug
});
722 # OpenSSL configuration file.
723 # This is mostly being used for generation of certificate requests.
730 distinguished_name
= req_distinguished_name
731 # The extentions to add to the self signed cert
732 x509_extensions
= v3_ca
733 # The extensions to add to a certificate request
734 req_extensions
= v3_req
736 # This sets a mask for permitted string types. There are several options.
737 # utf8only: only UTF8Strings (PKIX recommendation after 2004).
738 # WARNING: ancient versions of Netscape crash on BMPStrings or UTF8Strings.
739 string_mask
= utf8only
741 [ req_distinguished_name
]
743 stateOrProvinceName
= State
or Province Name
744 localityName
= Locality Name
745 organizationName
= Organization Name
746 organizationalUnitName
= Organizational Unit Name
747 commonName
= __COMMON_NAME__
748 emailAddress
= __EMAIL_ADDRESS__
751 basicConstraints
= CA
: false
752 keyUsage
= nonRepudiation
, digitalSignature
, keyEncipherment
753 subjectAltName
= email
: move
754 subjectAltName
= @alt_names
757 subjectKeyIdentifier
= hash
758 authorityKeyIdentifier
= keyid
: always
, issuer
759 basicConstraints
= CA
: true