1 # Coquelicot: "one-click" file sharing with a focus on users' privacy.
2 # Copyright © 2010-2012 potager.org <jardiniers@potager.org>
4 # This program is free software: you can redistribute it and/or modify
5 # it under the terms of the GNU Affero General Public License as
6 # published by the Free Software Foundation, either version 3 of the
7 # License, or (at your option) any later version.
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU Affero General Public License for more details.
14 # You should have received a copy of the GNU Affero General Public License
15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
24 describe StoredFile do
25 shared_context 'create new StoredFile' do
27 Dir.mktmpdir('coquelicot') do |tmpdir|
33 def create_stored_file(extra_meta = {})
34 @stored_file_path ||= File.expand_path('stored_file', @tmpdir)
37 @src_length = File.stat(@src).size
38 meta = { 'Expire-at' => 0 }
39 meta.merge!(extra_meta)
40 content = File.read(@src)
41 StoredFile.create(@stored_file_path, @pass, meta) do
42 buf, content = content, nil
48 describe '.get_cipher' do
49 context 'when given an unknown method' do
50 it 'should raise an error' do
52 StoredFile.get_cipher('secret', 'salt', :whatever)
53 }.to raise_error(NameError)
56 [ :encrypt, :decrypt ].each do |method|
57 let(:key_len) { 32 } # this is AES-256-CBC
58 let(:iv_len) { 16 } # this is AES-256-CBC
59 let(:hmac_len) { key_len + iv_len }
60 let(:hmac) { (1..hmac_len).to_a.collect { |c| c.chr }.join }
61 context "when given #{method} as method" do
62 it 'should use PKCS5.pbkdf2_hmac_sha1' do
63 OpenSSL::PKCS5.should_receive(:pbkdf2_hmac_sha1).
64 with('secret', 'salt', 2000, hmac_len).
66 StoredFile.get_cipher('secret', 'salt', method)
68 it 'should set the key to lower part of the HMAC' do
69 OpenSSL::PKCS5.stub(:pbkdf2_hmac_sha1).
71 cipher = OpenSSL::Cipher.new 'AES-256-CBC'
72 cipher.should_receive(:key=).with(hmac[0..key_len-1])
73 OpenSSL::Cipher.stub(:new).and_return(cipher)
74 StoredFile.get_cipher('secret', 'salt', method)
76 it 'should set the IV to the higher part of the HMAC' do
77 OpenSSL::PKCS5.stub(:pbkdf2_hmac_sha1).
79 cipher = OpenSSL::Cipher.new 'AES-256-CBC'
80 cipher.should_receive(:iv=).with(hmac[key_len..-1])
81 OpenSSL::Cipher.stub(:new).and_return(cipher)
82 StoredFile.get_cipher('secret', 'salt', method)
84 it 'should return an OpenSSL::Cipher' do
85 cipher = StoredFile.get_cipher('secret', 'salt', method)
86 cipher.should be_a(OpenSSL::Cipher)
92 describe '.gen_salt' do
93 it 'should return a string of proper length' do
94 StoredFile.gen_salt.length == StoredFile::SALT_LEN
96 it 'should call OpenSSL::Random every time' do
97 OpenSSL::Random.should_receive(:random_bytes).
99 StoredFile.gen_salt == 1
100 StoredFile.gen_salt == 2
105 context 'when the given file does not exist' do
106 it 'should raise an error' do
108 StoredFile.open('/nonexistent')
109 }.to raise_error(Errno::ENOENT)
112 context 'when the file is not a StoredFile' do
113 it 'should raise an error' do
115 StoredFile.open(__FILE__)
117 # XXX: make this an ArgumentError
120 context 'when giving no pass' do
121 for_all_file_versions do
122 subject { StoredFile.open(stored_file_path) }
123 it 'should read clear metadata' do
124 subject.meta['Coquelicot'] == reference['Coquelicot']
126 # XXX: maybe we want a way to know that we can't uncrypt the rest
129 context 'when giving a wrong pass' do
130 for_all_file_versions do
131 it 'should raise an error' do
133 StoredFile.open(stored_file_path, 'whatever')
134 }.to raise_error(BadKey)
138 context 'when giving the right pass' do
139 for_all_file_versions do
140 subject { StoredFile.open(stored_file_path, 'secret') }
141 it 'should read the metadata' do
142 subject.meta['Length'] == reference['Length']
148 describe '.create' do
149 include_context 'create new StoredFile'
150 context 'when the metadata file already exists' do
151 it 'should raise an error' do
152 @stored_file_path = File.expand_path('stored_file', @tmpdir)
153 FileUtils.touch @stored_file_path
156 }.to raise_error(Errno::EEXIST)
159 context 'when the content file already exists' do
160 it 'should raise an error' do
161 @stored_file_path = File.expand_path('stored_file', @tmpdir)
162 FileUtils.touch "#{@stored_file_path}.content"
165 }.to raise_error(Errno::EEXIST)
168 context 'in metadata file, clear part' do
169 let(:test_salt) { "\0" * StoredFile::SALT_LEN }
170 let(:expire_at) { Time.now + 60 }
172 StoredFile.stub(:gen_salt).and_return(test_salt)
173 create_stored_file('Expire-at' => expire_at)
175 let(:clear_meta) { YAML.load_file(@stored_file_path) }
176 it 'should write Coquelicot file version' do
177 clear_meta['Coquelicot'].should == '2.0'
179 it 'should generate a random Salt' do
180 salt = Base64.decode64(clear_meta['Salt'])
181 salt.should == test_salt
183 it 'should record expiration time' do
184 clear_meta['Expire-at'].should == expire_at
187 shared_context 'in encrypted part' do |path_regex|
191 def initialize; reset; end
192 def reset; @buf, @content = '', nil; end
193 def update(str); @buf << str ; str; end
194 def final; @content = @buf; ''; end
196 cipher = NullCipher.new
197 StoredFile.stub(:get_cipher).and_return(cipher)
198 @content = StringIO.new
199 open = File.method(:open)
200 File.should_receive(:open).at_least(1).times do |path, *args, &block|
201 if path =~ path_regex
202 ret = block.call(@content)
206 open.call(path, *args, &block)
211 context 'in metadata file, encrypted part' do
212 include_context 'in encrypted part', /stored_file$/
213 it 'should contain metadata as YAML block' do
215 @cipher.content.split(/^--- \n/, 3).length.should == 2
216 YAML.load(@cipher.content).should be_a(Hash)
218 context 'in encrypted metadata' do
221 @meta = YAML.load(@cipher.content)
223 it 'should contain Length' do
224 @meta['Length'].should == @src_length
226 it 'should Created-at' do
227 @meta.should include('Created-at')
231 context 'in encrypted content' do
232 include_context 'in encrypted part', /stored_file\.content$/
236 it 'should contain the file content' do
237 @cipher.content.should == File.read(@src)
239 it 'should have the whole file for encrypted content' do
240 @content.string == File.read(@src)
243 context 'when the given block raise an error' do
244 it 'should not leave files' do
246 path = File.expand_path('stored_file', @tmpdir)
248 StoredFile.create(path, 'secret', {}) do
249 raise StandardError.new
254 }.to_not change { Dir.entries(@tmpdir) }
259 describe '#created_at' do
260 context 'with a new file' do
261 include_context 'create new StoredFile'
262 it 'should return the creation time' do
263 Timecop.freeze(2012, 1, 1) do
265 stored_file = StoredFile.open(@stored_file_path, @pass)
266 stored_file.created_at.should == Time.local(2012, 1, 1)
270 for_all_file_versions do
271 it 'should return the creation time' do
272 stored_file.created_at.should == Time.at(reference['Created-at'])
277 describe '#expire_at' do
278 context 'with a new file' do
279 include_context 'create new StoredFile'
280 it 'should return the date of expiration' do
281 create_stored_file('Expire-at' => Time.local(2012, 1, 1))
282 stored_file = StoredFile.open(@stored_file_path, @pass)
283 stored_file.expire_at.should == Time.local(2012, 1, 1)
286 for_all_file_versions do
287 specify { stored_file.expire_at.should == Time.at(reference['Expire-at']) }
291 describe '#expired?' do
292 include_context 'create new StoredFile'
293 context 'when expiration time is in the past' do
294 it 'should return true' do
296 create_stored_file('Expire-at' => Time.now - 60)
297 stored_file = StoredFile.open(@stored_file_path, @pass)
298 stored_file.should be_expired
302 context 'when expiration time is in the future' do
303 it 'should return false' do
305 create_stored_file('Expire-at' => Time.now + 60)
306 stored_file = StoredFile.open(@stored_file_path, @pass)
307 stored_file.should_not be_expired
313 describe '#one_time_only?' do
314 include_context 'create new StoredFile'
315 context 'when file is labelled as "one time only"' do
316 it 'should be true' do
317 create_stored_file('One-time-only' => true)
318 stored_file = StoredFile.open(@stored_file_path, @pass)
319 stored_file.should be_one_time_only
322 context 'when file is not labelled as "one time only"' do
323 it 'should be false' do
325 stored_file = StoredFile.open(@stored_file_path, @pass)
326 stored_file.should_not be_one_time_only
331 describe '#empty!' do
332 for_all_file_versions do
333 include_context 'create new StoredFile'
335 FileUtils.cp Dir.glob("#{stored_file_path}*"), @tmpdir
336 @stored_file_path = File.expand_path('stored_file', @tmpdir)
337 @stored_file = StoredFile.open(@stored_file_path, @pass)
339 it 'should overwrite file contents with \0' do
340 Dir.glob("#{@stored_file_path}*").each do |path|
341 File.should_receive(:open) do |*args, &block|
342 length = File.stat(path).size
343 file = StringIO.new(File.read(path))
345 file.string.should == "\0" * length
350 it 'should truncate files' do
352 Dir.glob("#{@stored_file_path}*").each do |path|
353 File.stat(path).size.should == 0
359 describe '#lockfile' do
360 for_all_file_versions do
361 let(:stored_file) { StoredFile.open(stored_file_path, 'secret') }
362 it 'should return a Lockfile' do
363 stored_file.lockfile.should be_a(Lockfile)
365 it 'should create a Lockfile using the path followed by ".lock"' do
366 Lockfile.should_receive(:new) do |path, options|
367 path.should == "#{stored_file_path}.lock"
375 context 'when the right pass has been given' do
376 for_all_file_versions do
377 it 'should output the whole content with several yields' do
379 stored_file.each do |data|
382 buf.should == reference['Content']
386 context 'when no password has been given' do
387 for_all_file_versions do
388 let(:stored_file) { StoredFile.open(stored_file_path) }
389 it 'should raise BadKey' do
392 }.to raise_error(BadKey)
399 for_all_file_versions do
400 it 'should reset the cipher' do
401 salt = Base64::decode64(YAML.load_file(stored_file_path)['Salt'])
402 cipher = StoredFile.get_cipher('secret', salt, :decrypt)
403 StoredFile.stub(:get_cipher).and_return(cipher)
405 stored_file = StoredFile.open(stored_file_path, 'secret')
406 cipher.should_receive(:reset)
410 context 'when file is "one-time only"' do
411 include_context 'create new StoredFile'
413 create_stored_file('One-time-only' => true)
414 @stored_file = StoredFile.open(@stored_file_path, @pass)
415 # XXX: that is not a nice assumption (at all)
416 @stored_file.lockfile.lock
418 context 'when the file has not been fully sent' do
419 it 'should leave the content untouched' do
421 @stored_file.each { |data| raise StandardError }
426 another = StoredFile.open(@stored_file_path, @pass)
428 another.each { |data| buf << data }
429 buf.should == File.read(@src)
432 context 'when the file has been fully sent' do
435 @stored_file.each { |data| nil }
437 it 'should empty the file' do
438 @stored_file.should_receive(:empty!)