lester 1.0.0.pre1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,28 @@
1
+ module Lester
2
+ class Uploader
3
+ def initialize(iam, cloudfront, distribution_id)
4
+ @iam = iam
5
+ @cloudfront = cloudfront
6
+ @distribution_id = distribution_id
7
+ end
8
+
9
+ def upload(name, certificate, private_key)
10
+ metadata = @iam.upload_server_certificate({
11
+ path: '/cloudfront/',
12
+ server_certificate_name: name,
13
+ private_key: private_key.to_pem,
14
+ certificate_body: certificate.to_pem,
15
+ certificate_chain: certificate.chain_to_pem,
16
+ }).server_certificate_metadata
17
+ certificate_id = metadata.server_certificate_id
18
+ response = @cloudfront.get_distribution_config(id: @distribution_id)
19
+ distribution_config = response.distribution_config.to_hash
20
+ distribution_config[:viewer_certificate][:iam_certificate_id] = certificate_id
21
+ @cloudfront.update_distribution({
22
+ distribution_config: distribution_config,
23
+ id: @distribution_id,
24
+ if_match: response.etag,
25
+ })
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,3 @@
1
+ module Lester
2
+ VERSION = '1.0.0.pre1'.freeze
3
+ end
@@ -0,0 +1,47 @@
1
+ require 'spec_helper'
2
+
3
+ describe 'bin/lester init' do
4
+ include_context 'acceptance setup'
5
+
6
+ let :command do
7
+ Lester::Cli.new(argv, io)
8
+ end
9
+
10
+ let :argv do
11
+ [
12
+ 'init',
13
+ '--domain', 'example.org',
14
+ '--storage-bucket', 'example-org-backup',
15
+ '--private-key', private_key_path,
16
+ ]
17
+ end
18
+
19
+ context 'when the private key exists' do
20
+ it 'stores it' do
21
+ command.run
22
+ object = storage_bucket.object('example.org/account/private_key.json')
23
+ expect { JSON::JWK.new(JSON.parse(object.read)).to_key }.to_not raise_error
24
+ end
25
+
26
+ it 'returns an ok exit code' do
27
+ code = command.run
28
+ expect(code).to eq(0)
29
+ end
30
+ end
31
+
32
+ context 'when the private key does not exist' do
33
+ let :private_key_path do
34
+ 'this/should/not/exist.pem'
35
+ end
36
+
37
+ it 'prints an error message' do
38
+ command.run
39
+ expect(io.string).to match(/No such file or directory/)
40
+ end
41
+
42
+ it 'returns a non-ok exit code' do
43
+ code = command.run
44
+ expect(code).to eq(1)
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,149 @@
1
+ require 'spec_helper'
2
+
3
+ describe 'bin/lester renew' do
4
+ include_context 'acceptance setup'
5
+
6
+ let :command do
7
+ Lester::Cli.new(argv, io)
8
+ end
9
+
10
+ let :argv do
11
+ [
12
+ 'renew',
13
+ '--domain', 'example.org',
14
+ '--endpoint', 'http://127.0.0.1:4000',
15
+ '--site-bucket', 'example-org-site',
16
+ '--storage-bucket', 'example-org-backup',
17
+ '--email', 'contact@example.org',
18
+ '--distribution-id', 'distribution-id',
19
+ ]
20
+ end
21
+
22
+ before do
23
+ storage_bucket.put_object(key: 'example.org/account/private_key.json', body: Pathname.new(private_key_path))
24
+ cloudfront.add_config('distribution-id', {
25
+ viewer_certificate: { iam_certificate_id: 'example.org-old' },
26
+ })
27
+ end
28
+
29
+ describe '#run' do
30
+ context 'with a registered private key', vcr: { cassette_name: 'new-certificate' } do
31
+ it 'writes challenges to the expected path' do
32
+ command.run
33
+ key = '.well-known/acme-challenge/EGJjhjKuz9x5yNbH8IXfcZO6OljhIrwPQeTeZUcsKao'
34
+ object = site_bucket.object(key)
35
+ expect(object.read).to eq('EGJjhjKuz9x5yNbH8IXfcZO6OljhIrwPQeTeZUcsKao.XUQVUxLBe1x_yLe3HwXhatO3NImdF032T7C7BjKu6pc')
36
+ key = '.well-known/acme-challenge/wdo3QEPA0D2mUav-Yx34nbNkq61bLOMjj_3obOYvE2E'
37
+ object = site_bucket.object(key)
38
+ expect(object.read).to eq('wdo3QEPA0D2mUav-Yx34nbNkq61bLOMjj_3obOYvE2E.XUQVUxLBe1x_yLe3HwXhatO3NImdF032T7C7BjKu6pc')
39
+ end
40
+
41
+ it 'uploads the new certificate' do
42
+ command.run
43
+ expect(iam.certificates).to_not be_empty
44
+ end
45
+
46
+ it 'installs the certificate' do
47
+ command.run
48
+ update = cloudfront.updates.first
49
+ expect(update[:distribution_config][:viewer_certificate][:iam_certificate_id]).to eq('2ae9ea04d305762117cf854b39bb5ede')
50
+ end
51
+
52
+ it 'stores the certificate' do
53
+ command.run
54
+ object = storage_bucket.object('example.org/certificates/201512120949/cert.pem')
55
+ expect { OpenSSL::X509::Certificate.new(object.read) }.to_not raise_error
56
+ end
57
+
58
+ it 'stores the certificate request' do
59
+ command.run
60
+ object = storage_bucket.object('example.org/certificates/201512120949/csr.pem')
61
+ expect { OpenSSL::X509::Request.new(object.read) }.to_not raise_error
62
+ end
63
+
64
+ it 'stores the certificate chain' do
65
+ command.run
66
+ object = storage_bucket.object('example.org/certificates/201512120949/chain.pem')
67
+ expect { OpenSSL::X509::Certificate.new(object.read) }.to_not raise_error
68
+ end
69
+
70
+ it 'stores the certificate fullchain' do
71
+ command.run
72
+ object = storage_bucket.object('example.org/certificates/201512120949/fullchain.pem')
73
+ expect { OpenSSL::X509::Certificate.new(object.read) }.to_not raise_error
74
+ end
75
+
76
+ it 'stores the certificate private key' do
77
+ command.run
78
+ object = storage_bucket.object('example.org/certificates/201512120949/privkey.pem')
79
+ expect { OpenSSL::PKey::RSA.new(object.read) }.to_not raise_error
80
+ end
81
+
82
+ it 'uses server side encryption for everything that is stored' do
83
+ command.run
84
+ keys = storage_bucket.keys.select { |k| k.start_with?('example.org/certificates') }
85
+ expect(keys).to_not be_empty
86
+ keys.each do |key|
87
+ object = storage_bucket.object(key)
88
+ expect(object.options).to include(server_side_encryption: 'AES256')
89
+ end
90
+ end
91
+
92
+ it 'returns an ok exit code' do
93
+ code = command.run
94
+ expect(code).to eq(0)
95
+ end
96
+
97
+ context 'when a KMS ID is specified' do
98
+ let :argv do
99
+ [
100
+ 'renew',
101
+ '--domain', 'example.org',
102
+ '--endpoint', 'http://127.0.0.1:4000',
103
+ '--site-bucket', 'example-org-site',
104
+ '--storage-bucket', 'example-org-backup',
105
+ '--email', 'contact@example.org',
106
+ '--distribution-id', 'distribution-id',
107
+ '--kms-id', 'alias/letsencrypt',
108
+ ]
109
+ end
110
+
111
+ it 'uses server side encryption through AWS KMS' do
112
+ command.run
113
+ keys = storage_bucket.keys.select { |k| k.start_with?('example.org/certificates') }
114
+ expect(keys).to_not be_empty
115
+ keys.each do |key|
116
+ object = storage_bucket.object(key)
117
+ expect(object.options).to include(server_side_encryption: 'aws:kms')
118
+ expect(object.options).to include(ssekms_key_id: 'alias/letsencrypt')
119
+ end
120
+ end
121
+ end
122
+ end
123
+
124
+ context 'when verification fails', vcr: { cassette_name: 'verification-fail' } do
125
+ it 'prints an error message' do
126
+ command.run
127
+ expect(io.string).to match(/unauthorized: Invalid response/)
128
+ end
129
+
130
+ it 'returns a non-ok exit code' do
131
+ command.run
132
+ code = command.run
133
+ expect(code).to eq(1)
134
+ end
135
+ end
136
+
137
+ context 'with a non-registered private key', vcr: { cassette_name: 'new-certificate-fail' } do
138
+ it 'prints an error message' do
139
+ command.run
140
+ expect(io.string.chomp).to eq('No registration exists matching provided key (Acme::Error::Unauthorized)')
141
+ end
142
+
143
+ it 'returns a non-ok exit code' do
144
+ code = command.run
145
+ expect(code).to eq(1)
146
+ end
147
+ end
148
+ end
149
+ end
@@ -0,0 +1,80 @@
1
+ require 'spec_helper'
2
+
3
+ module Lester
4
+ describe Authenticator do
5
+ let :authenticator do
6
+ described_class.new(bucket, sleeper: sleeper)
7
+ end
8
+
9
+ let :challenge do
10
+ double(:challenge)
11
+ end
12
+
13
+ let :bucket do
14
+ double(:bucket)
15
+ end
16
+
17
+ let :sleeper do
18
+ double(:sleeper)
19
+ end
20
+
21
+ before do
22
+ allow(challenge).to receive(:filename).and_return('challenge-filename')
23
+ allow(challenge).to receive(:file_content).and_return('challenge-file-content')
24
+ allow(challenge).to receive(:content_type).and_return('text/plain')
25
+ allow(challenge).to receive(:request_verification).and_return(true)
26
+ allow(challenge).to receive(:status).and_return('valid')
27
+ allow(challenge).to receive(:error)
28
+ allow(bucket).to receive(:put_object)
29
+ allow(sleeper).to receive(:sleep)
30
+ end
31
+
32
+ describe '#authenticate' do
33
+ it 'uses the challenge\'s filename as S3 key' do
34
+ authenticator.authenticate(challenge)
35
+ expect(bucket).to have_received(:put_object).with(hash_including(key: 'challenge-filename'))
36
+ end
37
+
38
+ it 'writes the challenge\'s content' do
39
+ authenticator.authenticate(challenge)
40
+ expect(bucket).to have_received(:put_object).with(hash_including(body: 'challenge-file-content'))
41
+ end
42
+
43
+ it 'uses `public-read` as acl' do
44
+ authenticator.authenticate(challenge)
45
+ expect(bucket).to have_received(:put_object).with(hash_including(acl: 'public-read'))
46
+ end
47
+
48
+ it 'requests verification' do
49
+ authenticator.authenticate(challenge)
50
+ expect(challenge).to have_received(:request_verification)
51
+ end
52
+
53
+ it 'raises an error when verification is refused' do
54
+ allow(challenge).to receive(:request_verification).and_return(false)
55
+ expect { authenticator.authenticate(challenge) }.to raise_error(RequestVerificationError)
56
+ end
57
+
58
+ context 'request verification' do
59
+ it 'verifies the status of the challenge' do
60
+ allow(challenge).to receive(:status).and_return('pending')
61
+ allow(challenge).to receive(:verify_status) do
62
+ allow(challenge).to receive(:status).and_return('valid')
63
+ end
64
+ authenticator.authenticate(challenge)
65
+ expect(challenge).to have_received(:verify_status)
66
+ end
67
+
68
+ it 'raises an error when the verification returns an error' do
69
+ allow(challenge).to receive(:error).and_return('type' => 'urn:acme:error:unauthorized', 'detail' => 'Invalid response: 404')
70
+ expect { authenticator.authenticate(challenge) }.to raise_error(RequestVerificationError, 'urn:acme:error:unauthorized: Invalid response: 404')
71
+ end
72
+
73
+ it 'raises an error when the verification status is != valid' do
74
+ allow(challenge).to receive(:status).and_return('invalid')
75
+ expect { authenticator.authenticate(challenge) }.to raise_error(RequestVerificationError, 'invalid')
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,102 @@
1
+ require 'spec_helper'
2
+
3
+ module Lester
4
+ describe Cli do
5
+ let :cli do
6
+ described_class.new(argv, io)
7
+ end
8
+
9
+ let :io do
10
+ StringIO.new
11
+ end
12
+
13
+ let :output do
14
+ io.string
15
+ end
16
+
17
+ describe 'help|-h' do
18
+ let :argv do
19
+ ['help']
20
+ end
21
+
22
+ it 'prints usage' do
23
+ cli.run
24
+ expect(output).to match(/Usage/)
25
+ end
26
+
27
+ it 'returns an ok exit code' do
28
+ code = cli.run
29
+ expect(code).to eq(0)
30
+ end
31
+ end
32
+
33
+ describe 'init' do
34
+ let :command_name do
35
+ 'init'
36
+ end
37
+
38
+ context 'cli parameters' do
39
+ context '-d / --domain DOMAIN' do
40
+ parameter_validation 'domain'
41
+ end
42
+
43
+ context '-s / --storage-bucket NAME' do
44
+ parameter_validation 'storage-bucket', 'storage bucket'
45
+ end
46
+
47
+ context '-p / --private-key PATH' do
48
+ parameter_validation 'private-key', 'private key path'
49
+ end
50
+ end
51
+ end
52
+
53
+ describe 'renew|new' do
54
+ let :command_name do
55
+ 'renew'
56
+ end
57
+
58
+ context 'cli parameters' do
59
+ context '-d / --domain DOMAIN' do
60
+ parameter_validation 'domain'
61
+ end
62
+
63
+ context '-s / --storage-bucket NAME' do
64
+ parameter_validation 'storage-bucket', 'storage bucket'
65
+ end
66
+
67
+ context '-b / --site-bucket NAME' do
68
+ parameter_validation 'site-bucket', 'site bucket'
69
+ end
70
+
71
+ context '-e / --email ADDRESS' do
72
+ parameter_validation 'email'
73
+ end
74
+
75
+ context '-D / --distribution-id ID' do
76
+ parameter_validation 'distribution-id', 'distribution id'
77
+ end
78
+ end
79
+ end
80
+
81
+ describe 'any other command' do
82
+ let :argv do
83
+ ['other']
84
+ end
85
+
86
+ it 'prints an error message' do
87
+ cli.run
88
+ expect(output).to match(/Unknown command "other"/)
89
+ end
90
+
91
+ it 'prints usage' do
92
+ cli.run
93
+ expect(output).to match(/Usage/)
94
+ end
95
+
96
+ it 'returns a non-ok exit code' do
97
+ code = cli.run
98
+ expect(code).to eq(1)
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,31 @@
1
+ require 'spec_helper'
2
+
3
+ module Lester
4
+ module Command
5
+ describe Init do
6
+ let :command do
7
+ described_class.new(private_key, store)
8
+ end
9
+
10
+ let :private_key do
11
+ double(:private_key)
12
+ end
13
+
14
+ let :store do
15
+ double(:store)
16
+ end
17
+
18
+ before do
19
+ allow(private_key).to receive(:to_jwk).and_return({})
20
+ allow(store).to receive(:put)
21
+ end
22
+
23
+ describe '#run' do
24
+ it 'unconditionally stores the key in JSON JWK format' do
25
+ command.run
26
+ expect(store).to have_received(:put).with('private_key.json', '{}')
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end