acmesmith 2.4.0 → 2.6.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.github/FUNDING.yml +2 -0
- data/.github/workflows/build.yml +4 -4
- data/CHANGELOG.md +16 -1
- data/Dockerfile +2 -2
- data/Gemfile.lock +50 -43
- data/README.md +26 -1
- data/acmesmith.gemspec +1 -1
- data/docs/challenge_responders/route53.md +10 -1
- data/docs/vendor/aws.md +7 -1
- data/lib/acmesmith/certificate.rb +12 -7
- data/lib/acmesmith/certificate_retrieving_service.rb +126 -0
- data/lib/acmesmith/challenge_responder_filter.rb +8 -13
- data/lib/acmesmith/challenge_responders/route53.rb +11 -1
- data/lib/acmesmith/client.rb +52 -22
- data/lib/acmesmith/command.rb +9 -1
- data/lib/acmesmith/config.rb +20 -0
- data/lib/acmesmith/domain_name_filter.rb +22 -0
- data/lib/acmesmith/ordering_service.rb +15 -4
- data/lib/acmesmith/storages/s3.rb +2 -1
- data/lib/acmesmith/version.rb +1 -1
- metadata +16 -7
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 914be0de4c237bcd0db59a908f8ce70c3f7b364bb2ab0ac25b6e26ff95ecd7bc
|
4
|
+
data.tar.gz: 14b96550d4ef0274d1546e78b5121c6b9356e9c71e479a4ed17b4d9ff8fc669e
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: ce305e972fcb06a4bc97b2ceea2177cd759601c55064dd5e22ea15b0981ea838e7c11d8e5401d08c41ec1f249cfa0d7a6489f67b2c3e52cc8d67bd1871d77e69
|
7
|
+
data.tar.gz: dfabb130f347dc7829cbaad1b5de6aca553dfedc7172d53d163736baee2b07b00124ef7f5f31d9270df660f4b9381b05ed6ca6cfa2e9a96163f25d6fa2d83286
|
data/.github/FUNDING.yml
ADDED
data/.github/workflows/build.yml
CHANGED
@@ -18,9 +18,9 @@ jobs:
|
|
18
18
|
strategy:
|
19
19
|
fail-fast: false
|
20
20
|
matrix:
|
21
|
-
ruby-version: ['
|
21
|
+
ruby-version: ['3.0', '3.1', '3.2']
|
22
22
|
container:
|
23
|
-
image: sorah/ruby:${{ matrix.ruby-version }}-dev
|
23
|
+
image: public.ecr.aws/sorah/ruby:${{ matrix.ruby-version }}-dev
|
24
24
|
steps:
|
25
25
|
|
26
26
|
- name: Cache bundled gems
|
@@ -40,7 +40,7 @@ jobs:
|
|
40
40
|
strategy:
|
41
41
|
fail-fast: false
|
42
42
|
matrix:
|
43
|
-
ruby-version: ['
|
43
|
+
ruby-version: ['3.0', '3.1', '3.2']
|
44
44
|
|
45
45
|
# FIXME: once GitHub Actions gains support of adding command line arguments to container
|
46
46
|
# services:
|
@@ -72,7 +72,7 @@ jobs:
|
|
72
72
|
|
73
73
|
- run: 'docker run -d --net=host --rm letsencrypt/pebble pebble -config /test/config/pebble-config.json -strict -dnsserver 127.0.0.1:8053'
|
74
74
|
- run: 'docker run -d --net=host --rm letsencrypt/pebble-challtestsrv pebble-challtestsrv -management :8055 -defaultIPv4 127.0.0.1'
|
75
|
-
- run: 'docker run --net=host -e CI --rm -v $(pwd):/work -v $(realpath ~/bundle):/bundle sorah/ruby:${{ matrix.ruby-version }}-dev sh -c "cd /work && bundle install --path /bundle && bundle exec rspec -fd -t integration_pebble"'
|
75
|
+
- run: 'docker run --net=host -e CI --rm -v $(pwd):/work -v $(realpath ~/bundle):/bundle public.ecr.aws/sorah/ruby:${{ matrix.ruby-version }}-dev sh -c "cd /work && bundle install --path /bundle && bundle exec rspec -fd -t integration_pebble"'
|
76
76
|
|
77
77
|
docker-build:
|
78
78
|
name: docker-build
|
data/CHANGELOG.md
CHANGED
@@ -1,4 +1,19 @@
|
|
1
|
-
## v2.
|
1
|
+
## v2.6.0 (2023-10-05)
|
2
|
+
|
3
|
+
### Enhancement
|
4
|
+
|
5
|
+
- order: Gains `--key-type`, `--rsa-key-size`, `--elliptic-curve` options to customize private key generation, and generating EC keys. [#58](https://github.com/sorah/acmesmith/pull/58)
|
6
|
+
- autorenew: Respect the existing key configuration when regenerating a fresh key pair for renewal. [#58](https://github.com/sorah/acmesmith/pull/58)
|
7
|
+
|
8
|
+
## v2.5.0 (2020-10-09)
|
9
|
+
|
10
|
+
### Enhancement
|
11
|
+
|
12
|
+
- Gains `chain_preferences` configuration to choose alternate chain. [#47](https://github.com/sorah/acmesmith/pull/47)
|
13
|
+
- route53: Gains `substitution_map` to allow delegation of `_acme-challenge` via predefined CNAME record. [#53](https://github.com/sorah/acmesmith/pull/53)
|
14
|
+
- s3: Gains `endpoint` option. [#52](https://github.com/sorah/acmesmith/pull/52)
|
15
|
+
|
16
|
+
## v2.4.0 (2020-12-03)
|
2
17
|
|
3
18
|
### Enhancement
|
4
19
|
|
data/Dockerfile
CHANGED
@@ -1,4 +1,4 @@
|
|
1
|
-
FROM sorah/ruby:2
|
1
|
+
FROM sorah/ruby:3.2-dev as builder
|
2
2
|
|
3
3
|
#RUN apt-get update \
|
4
4
|
# && apt-get install -y libmysqlclient-dev git-core \
|
@@ -12,7 +12,7 @@ RUN sed -i -e 's|Acmesmith::VERSION|"0.0.0"|g' -e '/^require.*acmesmith.version/
|
|
12
12
|
|
13
13
|
RUN bundle install --path /gems --jobs 100 --without development
|
14
14
|
|
15
|
-
FROM sorah/ruby:2
|
15
|
+
FROM sorah/ruby:3.2
|
16
16
|
|
17
17
|
#RUN apt-get update \
|
18
18
|
# && apt-get install -y libmysqlclient20 \
|
data/Gemfile.lock
CHANGED
@@ -1,8 +1,8 @@
|
|
1
1
|
PATH
|
2
2
|
remote: .
|
3
3
|
specs:
|
4
|
-
acmesmith (2.
|
5
|
-
acme-client (
|
4
|
+
acmesmith (2.6.0)
|
5
|
+
acme-client (>= 2.0.7, < 3)
|
6
6
|
aws-sdk-acm
|
7
7
|
aws-sdk-route53
|
8
8
|
aws-sdk-s3
|
@@ -11,55 +11,62 @@ PATH
|
|
11
11
|
GEM
|
12
12
|
remote: https://rubygems.org/
|
13
13
|
specs:
|
14
|
-
acme-client (2.0.
|
15
|
-
faraday (>= 0
|
16
|
-
|
17
|
-
aws-
|
18
|
-
aws-
|
19
|
-
|
14
|
+
acme-client (2.0.14)
|
15
|
+
faraday (>= 1.0, < 3.0.0)
|
16
|
+
faraday-retry (>= 1.0, < 3.0.0)
|
17
|
+
aws-eventstream (1.2.0)
|
18
|
+
aws-partitions (1.832.0)
|
19
|
+
aws-sdk-acm (1.62.0)
|
20
|
+
aws-sdk-core (~> 3, >= 3.184.0)
|
20
21
|
aws-sigv4 (~> 1.1)
|
21
|
-
aws-sdk-core (3.
|
22
|
+
aws-sdk-core (3.185.0)
|
22
23
|
aws-eventstream (~> 1, >= 1.0.2)
|
23
|
-
aws-partitions (~> 1, >= 1.
|
24
|
+
aws-partitions (~> 1, >= 1.651.0)
|
25
|
+
aws-sigv4 (~> 1.5)
|
26
|
+
jmespath (~> 1, >= 1.6.1)
|
27
|
+
aws-sdk-kms (1.72.0)
|
28
|
+
aws-sdk-core (~> 3, >= 3.184.0)
|
24
29
|
aws-sigv4 (~> 1.1)
|
25
|
-
|
26
|
-
|
27
|
-
aws-sdk-core (~> 3, >= 3.109.0)
|
30
|
+
aws-sdk-route53 (1.79.0)
|
31
|
+
aws-sdk-core (~> 3, >= 3.184.0)
|
28
32
|
aws-sigv4 (~> 1.1)
|
29
|
-
aws-sdk-
|
30
|
-
aws-sdk-core (~> 3, >= 3.
|
31
|
-
aws-sigv4 (~> 1.1)
|
32
|
-
aws-sdk-s3 (1.86.0)
|
33
|
-
aws-sdk-core (~> 3, >= 3.109.0)
|
33
|
+
aws-sdk-s3 (1.136.0)
|
34
|
+
aws-sdk-core (~> 3, >= 3.181.0)
|
34
35
|
aws-sdk-kms (~> 1)
|
35
|
-
aws-sigv4 (~> 1.
|
36
|
-
aws-sigv4 (1.
|
36
|
+
aws-sigv4 (~> 1.6)
|
37
|
+
aws-sigv4 (1.6.0)
|
37
38
|
aws-eventstream (~> 1, >= 1.0.2)
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
rspec
|
39
|
+
base64 (0.1.1)
|
40
|
+
diff-lcs (1.4.4)
|
41
|
+
faraday (2.7.11)
|
42
|
+
base64
|
43
|
+
faraday-net_http (>= 2.0, < 3.1)
|
44
|
+
ruby2_keywords (>= 0.0.4)
|
45
|
+
faraday-net_http (3.0.2)
|
46
|
+
faraday-retry (2.2.0)
|
47
|
+
faraday (~> 2.0)
|
48
|
+
jmespath (1.6.2)
|
49
|
+
mini_portile2 (2.8.1)
|
50
|
+
nokogiri (1.14.3)
|
51
|
+
mini_portile2 (~> 2.8.0)
|
52
|
+
racc (~> 1.4)
|
53
|
+
racc (1.6.2)
|
54
|
+
rake (13.0.6)
|
55
|
+
rspec (3.10.0)
|
56
|
+
rspec-core (~> 3.10.0)
|
57
|
+
rspec-expectations (~> 3.10.0)
|
58
|
+
rspec-mocks (~> 3.10.0)
|
59
|
+
rspec-core (3.10.1)
|
60
|
+
rspec-support (~> 3.10.0)
|
61
|
+
rspec-expectations (3.10.1)
|
55
62
|
diff-lcs (>= 1.2.0, < 2.0)
|
56
|
-
rspec-support (~> 3.
|
57
|
-
rspec-mocks (3.
|
63
|
+
rspec-support (~> 3.10.0)
|
64
|
+
rspec-mocks (3.10.2)
|
58
65
|
diff-lcs (>= 1.2.0, < 2.0)
|
59
|
-
rspec-support (~> 3.
|
60
|
-
rspec-support (3.
|
61
|
-
ruby2_keywords (0.0.
|
62
|
-
thor (1.
|
66
|
+
rspec-support (~> 3.10.0)
|
67
|
+
rspec-support (3.10.2)
|
68
|
+
ruby2_keywords (0.0.5)
|
69
|
+
thor (1.2.2)
|
63
70
|
|
64
71
|
PLATFORMS
|
65
72
|
ruby
|
data/README.md
CHANGED
@@ -1,6 +1,7 @@
|
|
1
1
|
# Acmesmith: A simple, effective ACME v2 client to use with many servers and a cloud
|
2
2
|
|
3
|
-
![ci](https://github.com/sorah/acmesmith/workflows/ci/badge.svg?event=push)
|
3
|
+
![ci](https://github.com/sorah/acmesmith/workflows/ci/badge.svg?event=push) <a href='https://ko-fi.com/J3J8CKMUU' target='_blank'><img height='36' style='border:0px;height:36px;' src='https://cdn.ko-fi.com/cdn/kofi3.png?v=3' border='0' alt='Buy Me a Coffee at ko-fi.com' /></a>
|
4
|
+
|
4
5
|
|
5
6
|
Acmesmith is an [ACME (Automatic Certificate Management Environment)](https://github.com/ietf-wg-acme/acme) client that works perfect on environment with multiple servers. This client saves certificate and keys on cloud services (e.g. AWS S3) securely, then allow to deploy issued certificates onto your servers smoothly. This works well on [Let's encrypt](https://letsencrypt.org).
|
6
7
|
|
@@ -151,6 +152,30 @@ are configurable per certificate's common-name.
|
|
151
152
|
- Shell script: [shell](./docs/post_issuing_hooks/shell.md)
|
152
153
|
- Amazon Certificate Manager (ACM): [acm](./docs/post_issuing_hooks/acm.md)
|
153
154
|
|
155
|
+
### Chain preference
|
156
|
+
|
157
|
+
If you want to prefer an alternative chain given by CA ([RFC8555 Section 7.4.2.](https://datatracker.ietf.org/doc/html/rfc8555#section-7.4.2)), use the following configuration. Preference may be delcared with common name.
|
158
|
+
|
159
|
+
When chain preferences are configured for the common name of an ordered certificate, Acmesmith will retrieve all available alternative chains and evaluate rules in an configured order. The first chain matched to a rule will be used and saved to a storage.
|
160
|
+
|
161
|
+
During rule evaluation, a root issuer name and key id are taken from the last available intermediate (Issuer and AKI) provided in a chain, when a chain doesn't have a root certificate (trust anchor).
|
162
|
+
|
163
|
+
```yaml
|
164
|
+
chain_preferences:
|
165
|
+
- root_issuer_name: "ISRG Root X1"
|
166
|
+
### Optionally, you may specify CA SKI/AKI:
|
167
|
+
# root_issuer_key_id: "79:b4:59:e6:7b:b6:e5:e4:01:73:80:08:88:c8:1a:58:f6:e9:9b:6e"
|
168
|
+
|
169
|
+
### Filter by common name (optional)
|
170
|
+
filter:
|
171
|
+
exact:
|
172
|
+
- my-app.example.com
|
173
|
+
suffix:
|
174
|
+
- .example.org
|
175
|
+
regexp:
|
176
|
+
- '\Aapp\d+.example.org\z'
|
177
|
+
```
|
178
|
+
|
154
179
|
## Vendor dependent notes
|
155
180
|
|
156
181
|
- [./docs/vendor/aws.md](./docs/vendor/aws.md): IAM and KMS key policies, and some tips
|
data/acmesmith.gemspec
CHANGED
@@ -21,7 +21,7 @@ Acmesmith is an [ACME (Automatic Certificate Management Environment)](https://gi
|
|
21
21
|
spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
|
22
22
|
spec.require_paths = ["lib"]
|
23
23
|
|
24
|
-
spec.add_dependency "acme-client", '
|
24
|
+
spec.add_dependency "acme-client", '>= 2.0.7', '< 3'
|
25
25
|
spec.add_dependency "aws-sdk-acm"
|
26
26
|
spec.add_dependency "aws-sdk-route53"
|
27
27
|
spec.add_dependency "aws-sdk-s3"
|
@@ -24,7 +24,16 @@ challenge_responders:
|
|
24
24
|
|
25
25
|
# Restore to original records on cleanup (after domain authorization). Default to false.
|
26
26
|
# Useful when you need to keep existing record as long as possible.
|
27
|
-
restore_to_original_records:
|
27
|
+
restore_to_original_records: false
|
28
|
+
|
29
|
+
### Substitution record names map (optional)
|
30
|
+
## This specifies alias for specific _acme-challenge record. For instance the following example
|
31
|
+
## updates _acme-challenge.test-example-com.example.org instead of _acme-challenge.test.example.com.
|
32
|
+
##
|
33
|
+
## This eases using the route53 responder for domains not managed in route53, by registering CNAME record to
|
34
|
+
## the alias record name on the original record name in advance. This is called delegation.
|
35
|
+
substitution_map:
|
36
|
+
"test.example.com.": "test-example-com.example.org."
|
28
37
|
```
|
29
38
|
|
30
39
|
## IAM Policy
|
data/docs/vendor/aws.md
CHANGED
@@ -20,12 +20,18 @@
|
|
20
20
|
"Effect": "Allow",
|
21
21
|
"Action": "route53:ChangeResourceRecordSets",
|
22
22
|
"Resource": ["arn:aws:route53:::hostedzone/*"]
|
23
|
+
},
|
24
|
+
{
|
25
|
+
"Effect": "Allow",
|
26
|
+
"Action": "route53:ListResourceRecordSets",
|
27
|
+
"Resource": ["arn:aws:route53:::hostedzone/*"]
|
23
28
|
}
|
24
29
|
]
|
25
30
|
}
|
26
31
|
```
|
27
32
|
|
28
|
-
|
33
|
+
- You can limit allowed hosted zone by `Resource` of `route53:ChangeResourceRecordSets` grant
|
34
|
+
- `route53:ListResourceRecordSets` will be only required when `restore_to_original_records` is set
|
29
35
|
|
30
36
|
##### Only fetching certificates
|
31
37
|
|
@@ -25,7 +25,7 @@ module Acmesmith
|
|
25
25
|
|
26
26
|
# @param certificate [OpenSSL::X509::Certificate, String]
|
27
27
|
# @param chain [String, Array<String>, Array<OpenSSL::X509::Certificate>]
|
28
|
-
# @param private_key [String, OpenSSL::PKey::
|
28
|
+
# @param private_key [String, OpenSSL::PKey::PKey]
|
29
29
|
# @param key_passphrase [String, nil]
|
30
30
|
# @param csr [String, OpenSSL::X509::Request, nil]
|
31
31
|
def initialize(certificate, chain, private_key, key_passphrase = nil, csr = nil)
|
@@ -66,15 +66,15 @@ module Acmesmith
|
|
66
66
|
self.key_passphrase = key_passphrase
|
67
67
|
else
|
68
68
|
begin
|
69
|
-
@private_key = OpenSSL::PKey
|
70
|
-
rescue OpenSSL::PKey::
|
69
|
+
@private_key = OpenSSL::PKey.read(@raw_private_key) { nil }
|
70
|
+
rescue OpenSSL::PKey::PKeyError
|
71
71
|
# may be encrypted
|
72
72
|
end
|
73
73
|
end
|
74
|
-
when OpenSSL::PKey::
|
74
|
+
when OpenSSL::PKey::PKey
|
75
75
|
@private_key = private_key
|
76
76
|
else
|
77
|
-
raise TypeError, 'private_key is expected to be a String or OpenSSL::PKey::
|
77
|
+
raise TypeError, 'private_key is expected to be a String or OpenSSL::PKey::PKey'
|
78
78
|
end
|
79
79
|
|
80
80
|
@csr = case csr
|
@@ -100,19 +100,24 @@ module Acmesmith
|
|
100
100
|
def key_passphrase=(pw)
|
101
101
|
raise PrivateKeyDecrypted, 'private_key already given' if @private_key
|
102
102
|
|
103
|
-
@private_key = OpenSSL::PKey
|
103
|
+
@private_key = OpenSSL::PKey.read(@raw_private_key, pw)
|
104
104
|
|
105
105
|
@raw_private_key = nil
|
106
106
|
nil
|
107
107
|
end
|
108
108
|
|
109
|
-
# @return [OpenSSL::PKey::
|
109
|
+
# @return [OpenSSL::PKey::PKey]
|
110
110
|
# @raise [PassphraseRequired] if private_key is not yet decrypted
|
111
111
|
def private_key
|
112
112
|
return @private_key if @private_key
|
113
113
|
raise PassphraseRequired, 'key_passphrase required'
|
114
114
|
end
|
115
115
|
|
116
|
+
# @return [OpenSSL::PKey::PKey]
|
117
|
+
def public_key
|
118
|
+
@certificate.public_key
|
119
|
+
end
|
120
|
+
|
116
121
|
# @return [String] leaf certificate + full certificate chain
|
117
122
|
def fullchain
|
118
123
|
"#{certificate.to_pem}\n#{issuer_pems}".gsub(/\n+/,?\n)
|
@@ -0,0 +1,126 @@
|
|
1
|
+
require 'acmesmith/certificate'
|
2
|
+
|
3
|
+
module Acmesmith
|
4
|
+
class CertificateRetrievingService
|
5
|
+
# @param acme [Acme::Client]
|
6
|
+
# @param common_name [String]
|
7
|
+
# @param url [String] ACME Certificate URL
|
8
|
+
# @param chain_preferences [Array<Acmesmith::Config::ChainPreference>]
|
9
|
+
def initialize(acme, common_name, url, chain_preferences: [])
|
10
|
+
@acme = acme
|
11
|
+
@url = url
|
12
|
+
@chain_preferences = chain_preferences.select { |_| _.filter.match?(common_name) }
|
13
|
+
end
|
14
|
+
|
15
|
+
attr_reader :acme
|
16
|
+
attr_reader :url
|
17
|
+
attr_reader :chain_preferences
|
18
|
+
|
19
|
+
def pem_chain
|
20
|
+
response = download(url, format: :pem)
|
21
|
+
pem = response.body
|
22
|
+
|
23
|
+
return pem if chain_preferences.empty?
|
24
|
+
|
25
|
+
puts " * Retrieving all chains..."
|
26
|
+
alternative_urls = Array(response.headers.dig('link', 'alternate'))
|
27
|
+
alternative_chains = alternative_urls.map { |_| CertificateChain.new(download(_, format: :pem).body) }
|
28
|
+
|
29
|
+
chains = [CertificateChain.new(pem), *alternative_chains]
|
30
|
+
|
31
|
+
chains.each_with_index do |chain, i|
|
32
|
+
puts " #{i.succ}. #{chain.to_s}"
|
33
|
+
end
|
34
|
+
puts
|
35
|
+
|
36
|
+
chain_preferences.each do |rule|
|
37
|
+
chains.each_with_index do |chain, i|
|
38
|
+
if chain.match?(name: rule.root_issuer_name, key_id: rule.root_issuer_key_id)
|
39
|
+
puts " * Chain chosen: ##{i.succ}"
|
40
|
+
return chain.pem_chain
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
warn " ! Preferred chain is not available, chain chosen: #1"
|
46
|
+
chains.first.pem_chain
|
47
|
+
end
|
48
|
+
|
49
|
+
class CertificateChain
|
50
|
+
def initialize(pem_chain)
|
51
|
+
@pem_chain = pem_chain
|
52
|
+
@pems = Certificate.split_pems(pem_chain)
|
53
|
+
@certificates = @pems.map { |_| OpenSSL::X509::Certificate.new(_) }
|
54
|
+
end
|
55
|
+
|
56
|
+
attr_reader :pem_chain
|
57
|
+
attr_reader :certificates
|
58
|
+
|
59
|
+
def to_s
|
60
|
+
certificates[1..-1].map do |c|
|
61
|
+
"s:#{c.subject},i:#{c.issuer}"
|
62
|
+
end.join(" | ")
|
63
|
+
end
|
64
|
+
|
65
|
+
def match?(name: nil, key_id: nil)
|
66
|
+
has_root = top.issuer == top.subject
|
67
|
+
|
68
|
+
if name
|
69
|
+
return false unless name == (has_root ? top.subject : top.issuer).to_a.assoc('CN')[1]
|
70
|
+
end
|
71
|
+
|
72
|
+
if key_id
|
73
|
+
top_key_id = if has_root
|
74
|
+
value_der(top.extensions.find { |e| e.oid == 'subjectKeyIdentifier' })&.slice(2..-1)
|
75
|
+
else
|
76
|
+
value_der(top.extensions.find { |e| e.oid == 'authorityKeyIdentifier' })&.slice(4,20)
|
77
|
+
end&.unpack1('H*')&.downcase
|
78
|
+
return false unless key_id.downcase.gsub(/:/,'') == top_key_id
|
79
|
+
end
|
80
|
+
|
81
|
+
true
|
82
|
+
end
|
83
|
+
|
84
|
+
def top
|
85
|
+
@top ||= find_top()
|
86
|
+
end
|
87
|
+
|
88
|
+
private def find_top
|
89
|
+
c = certificates.first
|
90
|
+
while c
|
91
|
+
up = find_issuer(c)
|
92
|
+
return c unless up
|
93
|
+
c = up
|
94
|
+
end
|
95
|
+
end
|
96
|
+
|
97
|
+
private def find_issuer(cert)
|
98
|
+
return nil if cert.issuer == cert.subject
|
99
|
+
|
100
|
+
# https://datatracker.ietf.org/doc/html/rfc5280#section-4.2.1.1
|
101
|
+
# sequence(\x30\x16) context-specific(\x80\x14) + keyid
|
102
|
+
aki = value_der(cert.extensions.find { |e| e.oid == 'authorityKeyIdentifier' })
|
103
|
+
|
104
|
+
# compare using SKI as a AKI DER. this doesn't support AKI using other than keyid but it should be okay
|
105
|
+
certificates.find do |c|
|
106
|
+
ski_der = value_der(c.extensions.find { |e| e.oid == 'subjectKeyIdentifier' })
|
107
|
+
next unless ski_der
|
108
|
+
hdr = "\x30\x16\x80\x14".b
|
109
|
+
keyid = ski_der[2..-1]
|
110
|
+
|
111
|
+
"#{hdr}#{keyid}" == aki && cert.issuer == c.subject
|
112
|
+
end
|
113
|
+
end
|
114
|
+
|
115
|
+
private def value_der(ext)
|
116
|
+
return nil unless ext
|
117
|
+
ext.respond_to?(:value_der) ? ext.value_der : ext.to_der[9..-1]
|
118
|
+
end
|
119
|
+
end
|
120
|
+
|
121
|
+
private def download(url, format:)
|
122
|
+
# XXX: Use of private API https://github.com/unixcharles/acme-client/blob/5990b3105569a9d791ea011e0c5e57506eb54353/lib/acme/client.rb#L311
|
123
|
+
acme.__send__(:download, url, format: format)
|
124
|
+
end
|
125
|
+
end
|
126
|
+
end
|
@@ -1,23 +1,18 @@
|
|
1
|
+
require 'acmesmith/domain_name_filter'
|
2
|
+
|
1
3
|
module Acmesmith
|
2
4
|
class ChallengeResponderFilter
|
3
5
|
def initialize(responder, subject_name_exact: nil, subject_name_suffix: nil, subject_name_regexp: nil)
|
4
6
|
@responder = responder
|
5
|
-
@
|
6
|
-
|
7
|
-
|
7
|
+
@domain_name_filter = DomainNameFilter.new(
|
8
|
+
exact: subject_name_exact,
|
9
|
+
suffix: subject_name_suffix,
|
10
|
+
regexp: subject_name_regexp,
|
11
|
+
)
|
8
12
|
end
|
9
13
|
|
10
14
|
def applicable?(domain)
|
11
|
-
|
12
|
-
return false unless @subject_name_exact.include?(domain)
|
13
|
-
end
|
14
|
-
if @subject_name_suffix
|
15
|
-
return false unless @subject_name_suffix.any? { |suffix| domain.end_with?(suffix) }
|
16
|
-
end
|
17
|
-
if @subject_name_regexp
|
18
|
-
return false unless @subject_name_regexp.any? { |regexp| domain.match?(regexp) }
|
19
|
-
end
|
20
|
-
@responder.applicable?(domain)
|
15
|
+
@domain_name_filter.match?(domain) && @responder.applicable?(domain)
|
21
16
|
end
|
22
17
|
end
|
23
18
|
end
|
@@ -17,7 +17,7 @@ module Acmesmith
|
|
17
17
|
true
|
18
18
|
end
|
19
19
|
|
20
|
-
def initialize(aws_access_key: nil, assume_role: nil, hosted_zone_map: {}, restore_to_original_records: false)
|
20
|
+
def initialize(aws_access_key: nil, assume_role: nil, hosted_zone_map: {}, restore_to_original_records: false, substitution_map: {})
|
21
21
|
aws_options = {region: 'us-east-1'}.tap do |opt|
|
22
22
|
opt[:credentials] = Aws::Credentials.new(aws_access_key['access_key_id'], aws_access_key['secret_access_key'], aws_access_key['session_token']) if aws_access_key
|
23
23
|
end
|
@@ -37,9 +37,13 @@ module Acmesmith
|
|
37
37
|
|
38
38
|
@restore_to_original_records = restore_to_original_records
|
39
39
|
@original_records = {}
|
40
|
+
|
41
|
+
@substitution_map = substitution_map.map { |k,v| [canonical_fqdn(k), v] }.to_h
|
40
42
|
end
|
41
43
|
|
42
44
|
def respond_all(*domain_and_challenges)
|
45
|
+
domain_and_challenges = apply_substitution_for_domain_and_challenges(domain_and_challenges)
|
46
|
+
|
43
47
|
save_original_records(*domain_and_challenges) if @restore_to_original_records
|
44
48
|
|
45
49
|
challenges_by_hosted_zone = domain_and_challenges.group_by { |(domain, _)| find_hosted_zone(domain) }
|
@@ -60,6 +64,8 @@ module Acmesmith
|
|
60
64
|
end
|
61
65
|
|
62
66
|
def cleanup_all(*domain_and_challenges)
|
67
|
+
domain_and_challenges = apply_substitution_for_domain_and_challenges(domain_and_challenges)
|
68
|
+
|
63
69
|
challenges_by_hosted_zone = domain_and_challenges.group_by { |(domain, _)| find_hosted_zone(domain) }
|
64
70
|
|
65
71
|
zone_and_batches = challenges_by_hosted_zone.map do |zone_id, dcs|
|
@@ -264,6 +270,10 @@ module Acmesmith
|
|
264
270
|
end
|
265
271
|
end
|
266
272
|
|
273
|
+
def apply_substitution_for_domain_and_challenges(domain_and_challenges)
|
274
|
+
domain_and_challenges.map { |(domain, challenge)| [@substitution_map.fetch(canonical_fqdn(domain), domain), challenge] }
|
275
|
+
end
|
276
|
+
|
267
277
|
def list_existing_rrsets(hosted_zone_id, name)
|
268
278
|
rrsets = []
|
269
279
|
start_record_name = name
|
data/lib/acmesmith/client.rb
CHANGED
@@ -21,26 +21,9 @@ module Acmesmith
|
|
21
21
|
key
|
22
22
|
end
|
23
23
|
|
24
|
-
def order(*identifiers, not_before: nil, not_after: nil)
|
25
|
-
|
26
|
-
|
27
|
-
identifiers: identifiers,
|
28
|
-
challenge_responder_rules: config.challenge_responders,
|
29
|
-
not_before: not_before,
|
30
|
-
not_after: not_after
|
31
|
-
)
|
32
|
-
order.perform!
|
33
|
-
cert = order.certificate
|
34
|
-
|
35
|
-
puts
|
36
|
-
print " * securing into the storage ..."
|
37
|
-
storage.put_certificate(cert, certificate_key_passphrase)
|
38
|
-
puts " [ ok ]"
|
39
|
-
puts
|
40
|
-
|
41
|
-
execute_post_issue_hooks(cert)
|
42
|
-
|
43
|
-
cert
|
24
|
+
def order(*identifiers, key_type: 'rsa', rsa_key_size: 2048, elliptic_curve: 'prime256v1', not_before: nil, not_after: nil)
|
25
|
+
private_key = generate_private_key(key_type: key_type, rsa_key_size: rsa_key_size, elliptic_curve: elliptic_curve)
|
26
|
+
order_with_private_key(*identifiers, private_key: private_key, not_before: not_before, not_after: not_after)
|
44
27
|
end
|
45
28
|
|
46
29
|
def authorize(*identifiers)
|
@@ -148,7 +131,7 @@ module Acmesmith
|
|
148
131
|
puts " Not valid after: #{not_after}"
|
149
132
|
next unless (cert.certificate.not_after.utc - Time.now.utc) < (days.to_i * 86400)
|
150
133
|
puts " * Renewing: CN=#{cert.common_name}, SANs=#{cert.sans.join(',')}"
|
151
|
-
|
134
|
+
order_with_private_key(cert.common_name, *cert.sans, private_key: regenerate_private_key(cert.public_key))
|
152
135
|
end
|
153
136
|
end
|
154
137
|
|
@@ -157,7 +140,7 @@ module Acmesmith
|
|
157
140
|
cert = storage.get_certificate(common_name)
|
158
141
|
sans = cert.sans + add_sans
|
159
142
|
puts " * SANs will be: #{sans.join(?,)}"
|
160
|
-
|
143
|
+
order_with_private_key(cert.common_name, *sans, private_key: regenerate_private_key(cert.public_key))
|
161
144
|
end
|
162
145
|
|
163
146
|
private
|
@@ -196,5 +179,52 @@ module Acmesmith
|
|
196
179
|
config['account_key_passphrase']
|
197
180
|
end
|
198
181
|
end
|
182
|
+
|
183
|
+
def order_with_private_key(*identifiers, private_key:, not_before: nil, not_after: nil)
|
184
|
+
order = OrderingService.new(
|
185
|
+
acme: acme,
|
186
|
+
identifiers: identifiers,
|
187
|
+
private_key: private_key,
|
188
|
+
challenge_responder_rules: config.challenge_responders,
|
189
|
+
chain_preferences: config.chain_preferences,
|
190
|
+
not_before: not_before,
|
191
|
+
not_after: not_after
|
192
|
+
)
|
193
|
+
order.perform!
|
194
|
+
cert = order.certificate
|
195
|
+
|
196
|
+
puts
|
197
|
+
print " * securing into the storage ..."
|
198
|
+
storage.put_certificate(cert, certificate_key_passphrase)
|
199
|
+
puts " [ ok ]"
|
200
|
+
puts
|
201
|
+
|
202
|
+
execute_post_issue_hooks(cert)
|
203
|
+
|
204
|
+
cert
|
205
|
+
end
|
206
|
+
|
207
|
+
def generate_private_key(key_type:, rsa_key_size:, elliptic_curve:)
|
208
|
+
case key_type
|
209
|
+
when 'rsa'
|
210
|
+
OpenSSL::PKey::RSA.generate(rsa_key_size)
|
211
|
+
when 'ec'
|
212
|
+
OpenSSL::PKey::EC.generate(elliptic_curve)
|
213
|
+
else
|
214
|
+
raise ArgumentError, "Key type #{key_type} is not supported"
|
215
|
+
end
|
216
|
+
end
|
217
|
+
|
218
|
+
# Generate a new key pair with the same type and key size / curve as existing one
|
219
|
+
def regenerate_private_key(template)
|
220
|
+
case template
|
221
|
+
when OpenSSL::PKey::RSA
|
222
|
+
OpenSSL::PKey::RSA.generate(template.n.num_bits)
|
223
|
+
when OpenSSL::PKey::EC
|
224
|
+
OpenSSL::PKey::EC.generate(template.group)
|
225
|
+
else
|
226
|
+
raise ArgumentError, "Unknown key type: #{template.class}"
|
227
|
+
end
|
228
|
+
end
|
199
229
|
end
|
200
230
|
end
|
data/lib/acmesmith/command.rb
CHANGED
@@ -32,8 +32,16 @@ module Acmesmith
|
|
32
32
|
|
33
33
|
desc "order COMMON_NAME [SAN]", "order certificate for CN +COMMON_NAME+ with SANs +SAN+"
|
34
34
|
method_option :show_certificate, type: :boolean, aliases: %w(-s), default: true, desc: 'show an issued certificate in PEM and text when exiting'
|
35
|
+
method_option :key_type, type: :string, enum: %w(rsa ec), default: 'rsa', desc: 'key type'
|
36
|
+
method_option :rsa_key_size, type: :numeric, default: 2048, desc: 'size of RSA key'
|
37
|
+
method_option :elliptic_curve, type: :string, default: 'prime256v1', desc: 'elliptic curve group for EC key'
|
35
38
|
def order(common_name, *sans)
|
36
|
-
cert = client.order(
|
39
|
+
cert = client.order(
|
40
|
+
common_name, *sans,
|
41
|
+
key_type: options[:key_type],
|
42
|
+
rsa_key_size: options[:rsa_key_size],
|
43
|
+
elliptic_curve: options[:elliptic_curve],
|
44
|
+
)
|
37
45
|
if options[:show_certificate]
|
38
46
|
puts cert.certificate.to_text
|
39
47
|
puts cert.certificate.to_pem
|
data/lib/acmesmith/config.rb
CHANGED
@@ -2,11 +2,13 @@ require 'yaml'
|
|
2
2
|
require 'acmesmith/storages'
|
3
3
|
require 'acmesmith/challenge_responders'
|
4
4
|
require 'acmesmith/challenge_responder_filter'
|
5
|
+
require 'acmesmith/domain_name_filter'
|
5
6
|
require 'acmesmith/post_issuing_hooks'
|
6
7
|
|
7
8
|
module Acmesmith
|
8
9
|
class Config
|
9
10
|
ChallengeResponderRule = Struct.new(:challenge_responder, :filter, keyword_init: true)
|
11
|
+
ChainPreference = Struct.new(:root_issuer_name, :root_issuer_key_id, :filter, keyword_init: true)
|
10
12
|
|
11
13
|
def self.load_yaml(path)
|
12
14
|
new YAML.load_file(path)
|
@@ -29,6 +31,10 @@ module Acmesmith
|
|
29
31
|
unless @config['directory']
|
30
32
|
raise ArgumentError, "config['directory'] must be provided, e.g. https://acme-v02.api.letsencrypt.org/directory or https://acme-staging-v02.api.letsencrypt.org/directory"
|
31
33
|
end
|
34
|
+
|
35
|
+
if @config.key?('chain_preferences') && !@config.fetch('chain_preferences').kind_of?(Array)
|
36
|
+
raise ArgumentError, "config['chain_preferences'] must be an Array"
|
37
|
+
end
|
32
38
|
end
|
33
39
|
|
34
40
|
def [](key)
|
@@ -104,6 +110,20 @@ module Acmesmith
|
|
104
110
|
end
|
105
111
|
end
|
106
112
|
|
113
|
+
def chain_preferences
|
114
|
+
@preferred_chains ||= begin
|
115
|
+
specs = @config['chain_preferences'] || []
|
116
|
+
specs.flat_map do |spec|
|
117
|
+
filter = spec.fetch('filter', {}).map { |k,v| [k.to_sym, v] }.to_h
|
118
|
+
ChainPreference.new(
|
119
|
+
root_issuer_name: spec['root_issuer_name'],
|
120
|
+
root_issuer_key_id: spec['root_issuer_key_id'],
|
121
|
+
filter: DomainNameFilter.new(**filter),
|
122
|
+
)
|
123
|
+
end
|
124
|
+
end
|
125
|
+
end
|
126
|
+
|
107
127
|
# def post_actions
|
108
128
|
# end
|
109
129
|
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
module Acmesmith
|
2
|
+
class DomainNameFilter
|
3
|
+
def initialize(exact: nil, suffix: nil, regexp: nil)
|
4
|
+
@exact = exact && [*exact].flatten.compact
|
5
|
+
@suffix = suffix && [*suffix].flatten.compact
|
6
|
+
@regexp = regexp && [*regexp].flatten.compact.map{ |_| Regexp.new(_) }
|
7
|
+
end
|
8
|
+
|
9
|
+
def match?(domain)
|
10
|
+
if @exact
|
11
|
+
return false unless @exact.include?(domain)
|
12
|
+
end
|
13
|
+
if @suffix
|
14
|
+
return false unless @suffix.any? { |suffix| domain.end_with?(suffix) }
|
15
|
+
end
|
16
|
+
if @regexp
|
17
|
+
return false unless @regexp.any? { |regexp| domain.match?(regexp) }
|
18
|
+
end
|
19
|
+
true
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
@@ -1,5 +1,6 @@
|
|
1
1
|
require 'acmesmith/authorization_service'
|
2
2
|
require 'acmesmith/certificate'
|
3
|
+
require 'acmesmith/certificate_retrieving_service'
|
3
4
|
|
4
5
|
module Acmesmith
|
5
6
|
class OrderingService
|
@@ -7,18 +8,22 @@ module Acmesmith
|
|
7
8
|
|
8
9
|
# @param acme [Acme::Client] ACME client
|
9
10
|
# @param identifiers [Array<String>] Array of domain names for a ordering certificate. The first item will be a common name.
|
11
|
+
# @param private_key [OpenSSL::PKey::PKey] Private key
|
10
12
|
# @param challenge_responder_rules [Array<Acmesmith::Config::ChallengeResponderRule>] responders
|
13
|
+
# @param chain_preferences [Array<Acmesmith::Config::ChainPreference>] chain_preferences
|
11
14
|
# @param not_before [Time]
|
12
15
|
# @param not_after [Time]
|
13
|
-
def initialize(acme:, identifiers:, challenge_responder_rules:, not_before: nil, not_after: nil)
|
16
|
+
def initialize(acme:, identifiers:, private_key:, challenge_responder_rules:, chain_preferences:, not_before: nil, not_after: nil)
|
14
17
|
@acme = acme
|
15
18
|
@identifiers = identifiers
|
19
|
+
@private_key = private_key
|
16
20
|
@challenge_responder_rules = challenge_responder_rules
|
21
|
+
@chain_preferences = chain_preferences
|
17
22
|
@not_before = not_before
|
18
23
|
@not_after = not_after
|
19
24
|
end
|
20
25
|
|
21
|
-
attr_reader :acme, :identifiers, :challenge_responder_rules, :not_before, :not_after
|
26
|
+
attr_reader :acme, :identifiers, :private_key, :challenge_responder_rules, :chain_preferences, :not_before, :not_after
|
22
27
|
|
23
28
|
def perform!
|
24
29
|
puts "=> Ordering a certificate for the following identifiers:"
|
@@ -38,7 +43,7 @@ module Acmesmith
|
|
38
43
|
finalize_order()
|
39
44
|
wait_order_for_complete()
|
40
45
|
|
41
|
-
@certificate = Certificate.by_issuance(
|
46
|
+
@certificate = Certificate.by_issuance(pem_chain, csr)
|
42
47
|
|
43
48
|
puts
|
44
49
|
puts "=> Certificate issued"
|
@@ -77,6 +82,12 @@ module Acmesmith
|
|
77
82
|
end
|
78
83
|
end
|
79
84
|
|
85
|
+
# @return String
|
86
|
+
def pem_chain
|
87
|
+
url = order.certificate_url or raise NotCompleted, "not completed yet"
|
88
|
+
CertificateRetrievingService.new(acme, common_name, url, chain_preferences: chain_preferences).pem_chain
|
89
|
+
end
|
90
|
+
|
80
91
|
def certificate
|
81
92
|
@certificate or raise NotCompleted, "not completed yet"
|
82
93
|
end
|
@@ -98,7 +109,7 @@ module Acmesmith
|
|
98
109
|
|
99
110
|
# @return [Acme::Client::CertificateRequest]
|
100
111
|
def csr
|
101
|
-
@csr ||= Acme::Client::CertificateRequest.new(subject: { common_name: common_name }, names: sans)
|
112
|
+
@csr ||= Acme::Client::CertificateRequest.new(subject: { common_name: common_name }, names: sans, private_key: private_key)
|
102
113
|
end
|
103
114
|
end
|
104
115
|
end
|
@@ -7,7 +7,7 @@ require 'acmesmith/certificate'
|
|
7
7
|
module Acmesmith
|
8
8
|
module Storages
|
9
9
|
class S3 < Base
|
10
|
-
def initialize(aws_access_key: nil, bucket:, prefix: nil, region:, use_kms: true, kms_key_id: nil, kms_key_id_account: nil, kms_key_id_certificate_key: nil, pkcs12_passphrase: nil, pkcs12_common_names: nil)
|
10
|
+
def initialize(aws_access_key: nil, bucket:, prefix: nil, region:, use_kms: true, kms_key_id: nil, kms_key_id_account: nil, kms_key_id_certificate_key: nil, pkcs12_passphrase: nil, pkcs12_common_names: nil, endpoint: nil)
|
11
11
|
@region = region
|
12
12
|
@bucket = bucket
|
13
13
|
@prefix = prefix
|
@@ -25,6 +25,7 @@ module Acmesmith
|
|
25
25
|
|
26
26
|
@s3 = Aws::S3::Client.new({region: region}.tap do |opt|
|
27
27
|
opt[:credentials] = Aws::Credentials.new(aws_access_key['access_key_id'], aws_access_key['secret_access_key'], aws_access_key['session_token']) if aws_access_key
|
28
|
+
opt[:endpoint] = endpoint if endpoint
|
28
29
|
end)
|
29
30
|
end
|
30
31
|
|
data/lib/acmesmith/version.rb
CHANGED
metadata
CHANGED
@@ -1,29 +1,35 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: acmesmith
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 2.
|
4
|
+
version: 2.6.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Sorah Fukumori
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date:
|
11
|
+
date: 2023-10-04 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: acme-client
|
15
15
|
requirement: !ruby/object:Gem::Requirement
|
16
16
|
requirements:
|
17
|
-
- - "
|
17
|
+
- - ">="
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: 2.0.7
|
20
|
+
- - "<"
|
18
21
|
- !ruby/object:Gem::Version
|
19
|
-
version: '
|
22
|
+
version: '3'
|
20
23
|
type: :runtime
|
21
24
|
prerelease: false
|
22
25
|
version_requirements: !ruby/object:Gem::Requirement
|
23
26
|
requirements:
|
24
|
-
- - "
|
27
|
+
- - ">="
|
28
|
+
- !ruby/object:Gem::Version
|
29
|
+
version: 2.0.7
|
30
|
+
- - "<"
|
25
31
|
- !ruby/object:Gem::Version
|
26
|
-
version: '
|
32
|
+
version: '3'
|
27
33
|
- !ruby/object:Gem::Dependency
|
28
34
|
name: aws-sdk-acm
|
29
35
|
requirement: !ruby/object:Gem::Requirement
|
@@ -136,6 +142,7 @@ extensions: []
|
|
136
142
|
extra_rdoc_files: []
|
137
143
|
files:
|
138
144
|
- ".dockerignore"
|
145
|
+
- ".github/FUNDING.yml"
|
139
146
|
- ".github/stale.yml"
|
140
147
|
- ".github/workflows/build.yml"
|
141
148
|
- ".gitignore"
|
@@ -162,6 +169,7 @@ files:
|
|
162
169
|
- lib/acmesmith/account_key.rb
|
163
170
|
- lib/acmesmith/authorization_service.rb
|
164
171
|
- lib/acmesmith/certificate.rb
|
172
|
+
- lib/acmesmith/certificate_retrieving_service.rb
|
165
173
|
- lib/acmesmith/challenge_responder_filter.rb
|
166
174
|
- lib/acmesmith/challenge_responders.rb
|
167
175
|
- lib/acmesmith/challenge_responders/base.rb
|
@@ -171,6 +179,7 @@ files:
|
|
171
179
|
- lib/acmesmith/client.rb
|
172
180
|
- lib/acmesmith/command.rb
|
173
181
|
- lib/acmesmith/config.rb
|
182
|
+
- lib/acmesmith/domain_name_filter.rb
|
174
183
|
- lib/acmesmith/ordering_service.rb
|
175
184
|
- lib/acmesmith/post_issueing_hooks.rb
|
176
185
|
- lib/acmesmith/post_issueing_hooks/base.rb
|
@@ -206,7 +215,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
206
215
|
- !ruby/object:Gem::Version
|
207
216
|
version: '0'
|
208
217
|
requirements: []
|
209
|
-
rubygems_version: 3.
|
218
|
+
rubygems_version: 3.4.6
|
210
219
|
signing_key:
|
211
220
|
specification_version: 4
|
212
221
|
summary: ACME client (Let's encrypt client) to manage certificate in multi server
|