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.
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 = ["sorah (Shota Fukumori)"]
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
- account_key_passphrase: password
19
- certificate_key_passphrase: secret
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
+
@@ -0,0 +1,11 @@
1
+ # Storage: Filesystem
2
+
3
+ This is not recommended for production use. If you're planning to use this, make sure backing up the keys.
4
+
5
+ ```yaml
6
+ storage:
7
+ type: filesystem
8
+ path: /path/to/directory/to/store/keys
9
+ ```
10
+
11
+
@@ -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