/
client.rb
163 lines (130 loc) · 4.96 KB
/
client.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
%w(openssl base64 json httparty dnsimple tempfile net/scp resolv nitlink/response).each { |lib| require lib }
HTTParty::Basement.default_options.update(debug_output: $stdout)
# Ruby EC key implementation monkey-patch (see https://alexpeattie.com/blog/signing-a-csr-with-ecdsa-in-ruby)
OpenSSL::PKey::EC.send(:alias_method, :private?, :private_key?)
DIRECTORY_URI = 'https://acme-v01.api.letsencrypt.org/directory'.freeze
domain, root_domain, email = 'le.example.com', 'example.com', 'me@example.com'
preferred_challenge = 'http-01' # or 'dns-01',
certificate_type = 'rsa' # or ecdsa
def base64_le(data)
txt_data = data.respond_to?(:entries) ? JSON.dump(data) : data
Base64.urlsafe_encode64(txt_data).delete('=')
end
def client_key
@client_key ||= begin
client_key_path = File.expand_path('~/.ssh/id_rsa')
OpenSSL::PKey::RSA.new IO.read(client_key_path)
end
end
def header
@header ||= {
alg: 'RS256',
jwk: {
e: base64_le(client_key.e.to_s(2)),
kty: 'RSA',
n: base64_le(client_key.n.to_s(2))
}
}
end
def hash_algo
OpenSSL::Digest::SHA256.new
end
def nonce
HTTParty.head(DIRECTORY_URI)['Replay-Nonce']
end
def endpoints
@endpoints ||= HTTParty.get(DIRECTORY_URI).to_h
end
def signed_request(url, payload)
request = {
payload: base64_le(payload),
header: header,
protected: base64_le(header.merge(nonce: nonce))
}
request[:signature] = base64_le client_key.sign(hash_algo, [request[:protected], request[:payload]].join('.'))
HTTParty.post(url, body: JSON.dump(request))
end
def thumbprint
jwk = JSON.dump(header[:jwk])
thumbprint = base64_le(Digest::SHA256.digest jwk)
end
def upload(local_path, remote_path)
server_ip = '162.243.201.152' # see Appendix 3
Net::SCP.upload!(server_ip, 'root', local_path, remote_path)
end
new_registration = signed_request(endpoints['new-reg'], {
resource: 'new-reg',
contact: ['mailto:' + email]
})
# accept Subscriber Agreement
if new_registration.code == 201
signed_request(new_registration.headers['Location'], {
resource: 'reg',
agreement: new_registration.links.by_rel('terms-of-service').target
})
end
auth = signed_request(endpoints['new-authz'], {
resource: 'new-authz',
identifier: {
type: 'dns',
value: domain
}
})
challenge, challenge_response = nil, nil
http_challenge, dns_challenge = ['http-01', 'dns-01'].map do |challenge_type|
auth['challenges'].find { |challenge| challenge['type'] == challenge_type }
end
if preferred_challenge == 'http-01'
challenge, challenge_response = http_challenge, [http_challenge['token'], thumbprint].join('.')
destination_dir = '/usr/share/nginx/html/.well-known/acme-challenge/'
IO.write('challenge.tmp', challenge_response)
upload('challenge.tmp', destination_dir + http_challenge['token'])
File.delete('challenge.tmp')
end
if preferred_challenge == 'dns-01'
record_name = ('_acme-challenge.' + domain.sub(root_domain, '')).chomp('.')
challenge, challenge_response = dns_challenge, [dns_challenge['token'], thumbprint].join('.')
record_contents = base64_le(hash_algo.digest challenge_response)
dnsimple = Dnsimple::Client.new(username: ENV['DNSIMPLE_USERNAME'], api_token: ENV['DNSIMPLE_TOKEN'])
challenge_record = dnsimple.domains.create_record(root_domain, record_type: 'TXT', name: record_name, content: record_contents, ttl: 60)
loop do
resolved_record = Resolv::DNS.open { |r| r.getresources("#{record_name}.#{root_domain}", Resolv::DNS::Resource::IN::TXT) }[0]
break if resolved_record && resolved_record.data == record_contents
sleep 5
end
end
signed_request(challenge['uri'], {
resource: 'challenge',
keyAuthorization: challenge_response
})
loop do
challenge_result = HTTParty.get(challenge['uri'])
case challenge_result['status']
when 'valid' then break
when 'pending' then sleep 2
else raise "Challenge attempt #{ challenge_result['status'] }: #{ challenge_result['error']['details'] }"
end
end
dnsimple.domains.delete_record(root_domain, challenge_record.id) if defined?(:challenge_record)
domain_key = case certificate_type
when 'rsa' then OpenSSL::PKey::RSA.new(4096)
when 'ecdsa' then OpenSSL::PKey::EC.new('secp384r1').generate_key
else raise 'Unknown certificate type'
end
domain_filename = domain.gsub('.', '-')
IO.write(domain_filename + '.key', domain_key.to_pem)
csr = OpenSSL::X509::Request.new
csr.subject = OpenSSL::X509::Name.new([['CN', domain]])
csr.public_key = case certificate_type
when 'rsa' then domain_key.public_key
when 'ecdsa' then OpenSSL::PKey::EC.new(domain_key)
end
csr.sign domain_key, hash_algo
certificate_response = signed_request(endpoints['new-cert'], {
resource: 'new-cert',
csr: base64_le(csr.to_der),
})
certificate = OpenSSL::X509::Certificate.new(certificate_response.body)
intermediate = OpenSSL::X509::Certificate.new HTTParty.get(certificate_response.links.by_rel('up').target).body
IO.write(domain_filename + '-cert.pem', certificate.to_pem)
IO.write(domain_filename + '-chained.pem', [certificate.to_pem, intermediate].join("\n"))