lester 1.0.0.pre1

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.
@@ -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