acmesmith 2.2.0 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.dockerignore +6 -0
- data/.github/workflows/build.yml +123 -0
- data/.gitignore +0 -1
- data/CHANGELOG.md +35 -0
- data/Dockerfile +29 -0
- data/Gemfile +2 -0
- data/Gemfile.lock +73 -0
- data/LICENSE.txt +1 -1
- data/README.md +71 -93
- data/acmesmith.gemspec +1 -1
- data/config.sample.yml +41 -2
- data/docs/challenge_responders/route53.md +28 -0
- data/docs/examples/UpdateWindowsCertificate.ps1 +58 -0
- data/docs/post_issuing_hooks/acm.md +16 -0
- data/docs/post_issuing_hooks/shell.md +17 -0
- data/docs/storages/filesystem.md +11 -0
- data/docs/storages/s3.md +32 -0
- data/lib/acmesmith/account_key.rb +12 -1
- data/lib/acmesmith/authorization_service.rb +175 -0
- data/lib/acmesmith/certificate.rb +42 -11
- data/lib/acmesmith/challenge_responder_filter.rb +23 -0
- data/lib/acmesmith/challenge_responders/base.rb +11 -2
- data/lib/acmesmith/challenge_responders/pebble_challtestsrv_dns.rb +53 -0
- data/lib/acmesmith/challenge_responders/route53.rb +13 -2
- data/lib/acmesmith/client.rb +13 -131
- data/lib/acmesmith/config.rb +23 -2
- data/lib/acmesmith/ordering_service.rb +104 -0
- data/lib/acmesmith/storages/base.rb +15 -0
- data/lib/acmesmith/storages/s3.rb +3 -3
- data/lib/acmesmith/version.rb +1 -1
- metadata +19 -6
data/acmesmith.gemspec
CHANGED
@@ -6,7 +6,7 @@ require 'acmesmith/version'
|
|
6
6
|
Gem::Specification.new do |spec|
|
7
7
|
spec.name = "acmesmith"
|
8
8
|
spec.version = Acmesmith::VERSION
|
9
|
-
spec.authors = ["
|
9
|
+
spec.authors = ["Sorah Fukumori"]
|
10
10
|
spec.email = ["her@sorah.jp"]
|
11
11
|
|
12
12
|
spec.summary = %q{ACME client (Let's encrypt client) to manage certificate in multi server environment with cloud services (e.g. AWS)}
|
data/config.sample.yml
CHANGED
@@ -1,6 +1,15 @@
|
|
1
|
+
###
|
2
|
+
### ACME Endpoint Configuration
|
3
|
+
###
|
4
|
+
|
1
5
|
directory: https://acme-staging-v02.api.letsencrypt.org/directory
|
2
6
|
# directory: https://acme-v02.api.letsencrypt.org/directory
|
3
7
|
|
8
|
+
###
|
9
|
+
### Storage
|
10
|
+
###
|
11
|
+
|
12
|
+
# Check README and ./docs/storages/* for details
|
4
13
|
storage:
|
5
14
|
type: s3
|
6
15
|
region: 'ap-northeast-1'
|
@@ -12,8 +21,38 @@ storage:
|
|
12
21
|
# type: filesystem
|
13
22
|
# path: ./storage
|
14
23
|
|
24
|
+
###
|
25
|
+
### Challenge responders
|
26
|
+
###
|
27
|
+
|
28
|
+
# Check README and ./docs/challenge_responders/* for details
|
15
29
|
challenge_responders:
|
30
|
+
# Use dns_manual for subjects under ".manual-domain.example.org"
|
31
|
+
- dns_manual: {}
|
32
|
+
filter:
|
33
|
+
subject_name_suffix:
|
34
|
+
- .manual-domain.example.org
|
35
|
+
# subject_name_exact:
|
36
|
+
# subject_name_regexp:
|
37
|
+
|
38
|
+
# Last resort
|
16
39
|
- route53: {}
|
17
40
|
|
18
|
-
|
19
|
-
|
41
|
+
###
|
42
|
+
### advanced options
|
43
|
+
###
|
44
|
+
|
45
|
+
## Passphrase to encrypt key files (optional)
|
46
|
+
# account_key_passphrase: password
|
47
|
+
# certificate_key_passphrase: secret
|
48
|
+
|
49
|
+
## Instead, read passphrases from $ACMESMITH_ACCOUNT_KEY_PASSPHRASE, $ACMESMITH_CERTIFICATE_KEY_PASSPHRASE
|
50
|
+
# passphrase_from_env: true
|
51
|
+
|
52
|
+
## ACME connection options (Faraday)
|
53
|
+
# connection_options:
|
54
|
+
# :tls:
|
55
|
+
# :ca_file: custom_ca_bundle.pem
|
56
|
+
|
57
|
+
## acme-client bad_nonce_retry
|
58
|
+
# bad_nonce_retry: 2
|
@@ -0,0 +1,28 @@
|
|
1
|
+
# Challenge Responder: Route 53
|
2
|
+
|
3
|
+
`route53` responder supports `dns-01` challenge type. This assumes domain NS are managed under Route53 hosted zone.
|
4
|
+
|
5
|
+
```yaml
|
6
|
+
challenge_responders:
|
7
|
+
- route53:
|
8
|
+
### AWS Access key (optional, default to aws-sdk standard)
|
9
|
+
aws_access_key:
|
10
|
+
access_key_id:
|
11
|
+
secret_access_key:
|
12
|
+
# session_token:
|
13
|
+
|
14
|
+
### Assume IAM role to access Route 53
|
15
|
+
# Available options are https://docs.aws.amazon.com/sdk-for-ruby/v3/api/Aws/AssumeRoleCredentials.html
|
16
|
+
assume_role:
|
17
|
+
role_arn: "arn:aws:iam:::..."
|
18
|
+
|
19
|
+
### Hosted zone map (optional)
|
20
|
+
## This specifies an exact hosted zone ID for each domain name.
|
21
|
+
## Required when you have multiple hosted zones for the same domain name.
|
22
|
+
hosted_zone_map:
|
23
|
+
"example.org.": "/hostedzone/DEADBEEF"
|
24
|
+
```
|
25
|
+
|
26
|
+
## IAM Policy
|
27
|
+
|
28
|
+
- [docs/vendor/aws.md](../vendor/aws.md): IAM and KMS key policies, and some tips
|
@@ -0,0 +1,58 @@
|
|
1
|
+
#Requires -RunAsAdministrator
|
2
|
+
Set-StrictMode -Version Latest
|
3
|
+
$ErrorActionPreference = "Stop"
|
4
|
+
$PSDefaultParameterValues['*:ErrorAction']='Stop'
|
5
|
+
|
6
|
+
Set-Location -Path cert:\LocalMachine\My
|
7
|
+
|
8
|
+
$region = "ap-northeast-1"
|
9
|
+
$bucket = "your-s3-bucket"
|
10
|
+
$prefix = "certs/"
|
11
|
+
$cn = "example.com"
|
12
|
+
$pfxPassword = ConvertTo-SecureString -String "your-p12-password" -AsPlainText -Force
|
13
|
+
$lastCertificateMarkFile = "C:\AcmesmithCert.txt"
|
14
|
+
|
15
|
+
if (Test-Path $lastCertificateMarkFile) {
|
16
|
+
$lastCertificate = Get-ChildItem -Path (Get-Content -Path $lastCertificateMarkFile)
|
17
|
+
} else {
|
18
|
+
$lastCertificate = $null
|
19
|
+
}
|
20
|
+
|
21
|
+
Import-Module AWSPowershell
|
22
|
+
|
23
|
+
# In powershell 4.x or earlier
|
24
|
+
# [System.IO.FileInfo]([System.IO.Path]::GetTempFileName())
|
25
|
+
$currentFile = New-TemporaryFile
|
26
|
+
Read-S3Object -Region $region -BucketName $bucket -Key ("{0}{1}/current" -f $prefix,$cn) -File $currentFile
|
27
|
+
$current = Get-Content $currentFile
|
28
|
+
|
29
|
+
$pfxKey = "{0}{1}/{2}/cert.p12" -f $prefix,$cn,$current
|
30
|
+
$chainKey = "{0}{1}/{2}/chain.pem" -f $prefix,$cn,$current
|
31
|
+
|
32
|
+
$pfxFile = New-TemporaryFile
|
33
|
+
Read-S3Object -Region $region -BucketName $bucket -Key $pfxKey -File $pfxFile
|
34
|
+
|
35
|
+
$chainFile = New-TemporaryFile
|
36
|
+
Read-S3Object -Region $region -BucketName $bucket -Key $chainKey -File $chainFile
|
37
|
+
|
38
|
+
$cert = Import-PfxCertificate -Password $pfxPassword -FilePath $pfxFile.FullName -CertStoreLocation 'cert:\LocalMachine\My'
|
39
|
+
Remove-Item $pfxFile
|
40
|
+
|
41
|
+
$intermediate = Import-Certificate -FilePath $chainFile.FullName -CertStoreLocation 'cert:\LocalMachine\CA'
|
42
|
+
Write-Output $intermediate
|
43
|
+
Write-Output $cert
|
44
|
+
|
45
|
+
if ($lastCertificate) {
|
46
|
+
Write-Output "Switching"
|
47
|
+
if ($lastCertificate.Thumbprint -ne $cert.Thumbprint) {
|
48
|
+
Switch-Certificate -OldCert $lastCertificate -NewCert $cert
|
49
|
+
}
|
50
|
+
}
|
51
|
+
$cert.Thumbprint | Out-File $lastCertificateMarkFile
|
52
|
+
|
53
|
+
$expiredCerts = Get-ChildItem -Path 'Cert:\LocalMachine\My' -SSLServerAuthentication -ExpiringInDays 0 -DnsName $cert.DnsNameList[0].Unicode
|
54
|
+
$expiredCerts | Remove-Item -DeleteKey
|
55
|
+
|
56
|
+
## http.sys
|
57
|
+
$appid = "{existing-uuid}" # or "{{{0}}}" -f [GUID]::NewGuid().Guid
|
58
|
+
netsh http update sslcert ipport=0.0.0.0:443 ("certhash={0}" -f $cert.Thumbprint) ("appid={0}" -f $appid)
|
@@ -0,0 +1,16 @@
|
|
1
|
+
# Post issuing hook: Amazon Certificate Manager
|
2
|
+
|
3
|
+
`acm` imports certificate into AWS ACM.
|
4
|
+
|
5
|
+
```yaml
|
6
|
+
post_issuing_hooks:
|
7
|
+
"test.example.com":
|
8
|
+
- acm:
|
9
|
+
region: us-east-1 # required
|
10
|
+
certificate_arn: arn:aws:acm:... # (optional)
|
11
|
+
```
|
12
|
+
|
13
|
+
When `certificate_arn` is not present, `acm` hook attempts to find the certificate ARN from existing certificate list. Certificate with same common name ("domain name" on ACM), and `Acmesmith` tag
|
14
|
+
will be used. Otherwise, `acm` hook imports as a new certificate with `Acmesmith` tag.
|
15
|
+
|
16
|
+
|
@@ -0,0 +1,17 @@
|
|
1
|
+
# Post Issuing Hook: Shell
|
2
|
+
|
3
|
+
Execute specified command on a shell. Environment variable `${COMMON_NAME}` is available.
|
4
|
+
|
5
|
+
```yaml
|
6
|
+
post_issuing_hooks:
|
7
|
+
"test.example.com":
|
8
|
+
- shell:
|
9
|
+
command: mail -s "New cert for ${COMMON_NAME} has been issued" user@example.com < /dev/null
|
10
|
+
- shell:
|
11
|
+
command: touch /tmp/certs-has-been-issued-${COMMON_NAME}
|
12
|
+
"admin.example.com":
|
13
|
+
- shell:
|
14
|
+
command: /usr/bin/dosomethingelse ${COMMON_NAME}
|
15
|
+
```
|
16
|
+
|
17
|
+
|
data/docs/storages/s3.md
ADDED
@@ -0,0 +1,32 @@
|
|
1
|
+
# Storage: S3
|
2
|
+
|
3
|
+
```yaml
|
4
|
+
storage:
|
5
|
+
type: s3
|
6
|
+
region:
|
7
|
+
bucket:
|
8
|
+
# prefix:
|
9
|
+
# aws_access_key: # aws credentials (optional); If omit, default configuration of aws-sdk use will be used.
|
10
|
+
# access_key_id:
|
11
|
+
# secret_access_key:
|
12
|
+
# session_token:
|
13
|
+
# use_kms: true
|
14
|
+
# kms_key_id: # KMS key id (optional); if omit, default AWS managed key for S3 will be used
|
15
|
+
# kms_key_id_account: # KMS key id for account key (optional); This overrides kms_key_id
|
16
|
+
# kms_key_id_certificate_key: # KMS key id for private keys for certificates (optional); This oveerides kms_key_id
|
17
|
+
# pkcs12_passphrase: # (optional) Set passphrase to generate PKCS#12 file (for scripts that reads S3 bucket directly)
|
18
|
+
# pkcs12_common_names: ['example.org'] # (optional) List of common names to limit certificates for generating PKCS#12 file.
|
19
|
+
```
|
20
|
+
|
21
|
+
This saves certificates and keys in the following S3 keys:
|
22
|
+
|
23
|
+
- `{prefix}/account.pem`: Account private key in pem
|
24
|
+
- `{prefix}/certs/{common_name}/current`: text file contains current version name
|
25
|
+
- `{prefix}/certs/{common_name}/{version}/cert.pem`: certificate in pem
|
26
|
+
- `{prefix}/certs/{common_name}/{version}/key.pem`: private key in pem
|
27
|
+
- `{prefix}/certs/{common_name}/{version}/chain.pem`: CA chain in pem
|
28
|
+
- `{prefix}/certs/{common_name}/{version}/fullchain.pem`: certificate + CA chain in pem. This is suitable for some server softwares like nginx.
|
29
|
+
|
30
|
+
## IAM/KMS Policy
|
31
|
+
|
32
|
+
- [docs/vendor/aws.md](../vendor/aws.md): IAM and KMS key policies, and some tips
|
@@ -3,11 +3,16 @@ require 'openssl'
|
|
3
3
|
module Acmesmith
|
4
4
|
class AccountKey
|
5
5
|
class PassphraseRequired < StandardError; end
|
6
|
+
class PrivateKeyDecrypted < StandardError; end
|
6
7
|
|
8
|
+
# @param bit_length [Integer]
|
9
|
+
# @return [Acmesmith::AccountKey]
|
7
10
|
def self.generate(bit_length = 2048)
|
8
11
|
new OpenSSL::PKey::RSA.new(bit_length)
|
9
12
|
end
|
10
13
|
|
14
|
+
# @param private_key [String, OpenSSL::PKey::RSA]
|
15
|
+
# @param passphrase [String, nil]
|
11
16
|
def initialize(private_key, passphrase = nil)
|
12
17
|
case private_key
|
13
18
|
when String
|
@@ -28,8 +33,11 @@ module Acmesmith
|
|
28
33
|
end
|
29
34
|
end
|
30
35
|
|
36
|
+
# Try to decrypt private_key if encrypted.
|
37
|
+
# @param pw [String] passphrase for encrypted PEM
|
38
|
+
# @raise [PrivateKeyDecrypted] if private_key is decrypted
|
31
39
|
def key_passphrase=(pw)
|
32
|
-
raise 'private_key already given' if @private_key
|
40
|
+
raise PrivateKeyDecrypted, 'private_key already given' if @private_key
|
33
41
|
|
34
42
|
@private_key = OpenSSL::PKey::RSA.new(@raw_private_key, pw)
|
35
43
|
|
@@ -37,11 +45,14 @@ module Acmesmith
|
|
37
45
|
nil
|
38
46
|
end
|
39
47
|
|
48
|
+
# @return [OpenSSL::PKey::RSA]
|
49
|
+
# @raise [PassphraseRequired] if private_key is not yet decrypted
|
40
50
|
def private_key
|
41
51
|
return @private_key if @private_key
|
42
52
|
raise PassphraseRequired, 'key_passphrase required'
|
43
53
|
end
|
44
54
|
|
55
|
+
# @return [String] PEM
|
45
56
|
def export(passphrase, cipher: OpenSSL::Cipher.new('aes-256-cbc'))
|
46
57
|
if passphrase
|
47
58
|
private_key.export(cipher, passphrase)
|
@@ -0,0 +1,175 @@
|
|
1
|
+
module Acmesmith
|
2
|
+
class AuthorizationService
|
3
|
+
class NoApplicableChallengeResponder < StandardError; end
|
4
|
+
class AuthorizationFailed < StandardError; end
|
5
|
+
|
6
|
+
# @!attribute [r] domain
|
7
|
+
# @return [String] domain name
|
8
|
+
# @!attribute [r] authorization
|
9
|
+
# @return [Acme::Client::Resources::Authorization] authz object
|
10
|
+
# @!attribute [r] challenge_responder
|
11
|
+
# @return [Acmesmith::ChallengeResponders::Base] responder
|
12
|
+
# @!attribute [r] challenge
|
13
|
+
# @return [Acme::Client::Resources::Challenges::Base] challenge
|
14
|
+
AuthorizationProcess = Struct.new(:domain, :authorization, :challenge_responder, :challenge, keyword_init: true) do
|
15
|
+
def completed?
|
16
|
+
invalid? || valid?
|
17
|
+
end
|
18
|
+
|
19
|
+
def invalid?
|
20
|
+
challenge.status == 'invalid'
|
21
|
+
end
|
22
|
+
|
23
|
+
def valid?
|
24
|
+
challenge.status == 'valid'
|
25
|
+
end
|
26
|
+
|
27
|
+
def responder_id
|
28
|
+
challenge_responder.__id__
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
# @param challenge_responder_rules [Array<Acmemith::Config::ChallengeReponderRule>]
|
33
|
+
# @param authorizations [Array<Acme::Client::Resources::Authorization>]
|
34
|
+
def initialize(challenge_responder_rules, authorizations)
|
35
|
+
@challenge_responder_rules = challenge_responder_rules
|
36
|
+
@authorizations = authorizations
|
37
|
+
end
|
38
|
+
|
39
|
+
attr_reader :challenge_responder_rules, :authorizations
|
40
|
+
|
41
|
+
def perform!
|
42
|
+
return if authorizations.empty?
|
43
|
+
|
44
|
+
respond()
|
45
|
+
request_validation()
|
46
|
+
wait_for_validation()
|
47
|
+
cleanup()
|
48
|
+
|
49
|
+
puts "=> Authorized!"
|
50
|
+
end
|
51
|
+
|
52
|
+
def respond
|
53
|
+
processes_by_responder.each do |responder, ps|
|
54
|
+
puts "=> Responsing to the challenges for the following identifier:"
|
55
|
+
puts
|
56
|
+
puts " * Responder: #{responder.class}"
|
57
|
+
puts " * Identifiers:"
|
58
|
+
|
59
|
+
ps.each do |process|
|
60
|
+
puts " - #{process.domain} (#{process.challenge.challenge_type})"
|
61
|
+
end
|
62
|
+
|
63
|
+
puts
|
64
|
+
responder.respond_all(*ps.map{ |t| [t.domain, t.challenge] })
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
def request_validation
|
69
|
+
puts "=> Requesting validations..."
|
70
|
+
puts
|
71
|
+
processes.each do |process|
|
72
|
+
print " * #{process.domain} (#{process.challenge.challenge_type}) ..."
|
73
|
+
process.challenge.request_validation()
|
74
|
+
puts " [ ok ]"
|
75
|
+
end
|
76
|
+
puts
|
77
|
+
|
78
|
+
end
|
79
|
+
|
80
|
+
def wait_for_validation
|
81
|
+
puts "=> Waiting for the validation..."
|
82
|
+
puts
|
83
|
+
|
84
|
+
loop do
|
85
|
+
processes.each do |process|
|
86
|
+
next if process.valid?
|
87
|
+
|
88
|
+
process.challenge.reload
|
89
|
+
|
90
|
+
status = process.challenge.status
|
91
|
+
puts " * [#{process.domain}] status: #{status}"
|
92
|
+
|
93
|
+
case
|
94
|
+
when process.valid?
|
95
|
+
next
|
96
|
+
when process.invalid?
|
97
|
+
err = process[:challenge].error
|
98
|
+
puts " ! [#{process[:domain]}] error: #{err.inspect}"
|
99
|
+
end
|
100
|
+
end
|
101
|
+
break if processes.all?(&:completed?)
|
102
|
+
sleep 3
|
103
|
+
end
|
104
|
+
|
105
|
+
puts
|
106
|
+
|
107
|
+
invalid_processes = processes.select(&:invalid?)
|
108
|
+
unless invalid_processes.empty?
|
109
|
+
$stderr.puts ""
|
110
|
+
$stderr.puts "!! Some identitiers failed to challenge"
|
111
|
+
$stderr.puts ""
|
112
|
+
invalid_processes.each do |process|
|
113
|
+
$stderr.puts " - #{process.domain}: #{process.challenge.error.inspect}"
|
114
|
+
end
|
115
|
+
$stderr.puts ""
|
116
|
+
raise AuthorizationFailed, "Some identifiers failed to challenge: #{invalid_processes.map(&:domain).inspect}"
|
117
|
+
end
|
118
|
+
|
119
|
+
end
|
120
|
+
|
121
|
+
def cleanup
|
122
|
+
processes_by_responder.each do |responder, ps|
|
123
|
+
puts "=> Cleaning the responses the challenges for the following identifier:"
|
124
|
+
puts
|
125
|
+
puts " * Responder: #{responder.class}"
|
126
|
+
puts " * Identifiers:"
|
127
|
+
ps.each do |process|
|
128
|
+
puts " - #{process.domain} (#{process.challenge.challenge_type})"
|
129
|
+
end
|
130
|
+
puts
|
131
|
+
|
132
|
+
responder.cleanup_all(*ps.map{ |t| [t.domain, t.challenge] })
|
133
|
+
end
|
134
|
+
end
|
135
|
+
|
136
|
+
# @return [Array<AuthorizationProcess>]
|
137
|
+
def processes
|
138
|
+
@processes ||= authorizations.map do |authz|
|
139
|
+
challenge = nil
|
140
|
+
responder_rule = challenge_responder_rules.select do |rule|
|
141
|
+
rule.filter.applicable?(authz.domain)
|
142
|
+
end.find do |rule|
|
143
|
+
challenge = authz.challenges.find do |c|
|
144
|
+
# OMG, acme-client might return a Hash instead of Acme::Client::Resources::Challenge::* object...
|
145
|
+
challenge_type = case
|
146
|
+
when c.is_a?(Hash)
|
147
|
+
c[:challenge_type]
|
148
|
+
when c.is_a?(Acme::Client::Resources::Challenges::Unsupported)
|
149
|
+
next
|
150
|
+
when c.respond_to?(:challenge_type)
|
151
|
+
c.challenge_type
|
152
|
+
end
|
153
|
+
rule.challenge_responder.support?(challenge_type)
|
154
|
+
end
|
155
|
+
end
|
156
|
+
|
157
|
+
unless responder_rule
|
158
|
+
raise NoApplicableChallengeResponder, "Cannot find a challenge responder for domain #{authz.domain.inspect}"
|
159
|
+
end
|
160
|
+
|
161
|
+
AuthorizationProcess.new(
|
162
|
+
domain: authz.domain,
|
163
|
+
authorization: authz,
|
164
|
+
challenge_responder: responder_rule.challenge_responder,
|
165
|
+
challenge: challenge,
|
166
|
+
)
|
167
|
+
end
|
168
|
+
end
|
169
|
+
|
170
|
+
# @return [Array<(Acmesmith::ChallengeResponders::Base, Array<AuthorizationProcess>)>]
|
171
|
+
def processes_by_responder
|
172
|
+
@processes_by_responder ||= processes.group_by(&:responder_id).map { |_, ps| [ps[0].challenge_responder, ps] }
|
173
|
+
end
|
174
|
+
end
|
175
|
+
end
|