Fix name-typo in NEWS
[coquelicot.git] / lib / coquelicot / stored_file.rb
1 # -*- coding: UTF-8 -*-
2 # Coquelicot: "one-click" file sharing with a focus on users' privacy.
3 # Copyright © 2010-2013 potager.org <jardiniers@potager.org>
4 #
5 # This program is free software: you can redistribute it and/or modify
6 # it under the terms of the GNU Affero General Public License as
7 # published by the Free Software Foundation, either version 3 of the
8 # License, or (at your option) any later version.
9 #
10 # This program is distributed in the hope that it will be useful,
11 # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13 # GNU Affero General Public License for more details.
14 #
15 # You should have received a copy of the GNU Affero General Public License
16 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
17
18 require 'base64'
19 require 'lockfile'
20 require 'openssl'
21 require 'yaml'
22
23 module Coquelicot
24   class BadKey < StandardError; end
25
26   class StoredFile
27     BUFFER_LEN = 4096
28
29     attr_reader :path, :meta, :expire_at
30
31     def self.open(path, pass = nil)
32       StoredFile.new(path, pass)
33     end
34
35     def created_at
36       Time.at(@meta['Created-at'])
37     end
38
39     def expired?
40       @expire_at < Time.now
41     end
42
43     def one_time_only?
44       @meta['One-time-only']
45     end
46
47     def self.create(path, pass, meta)
48       salt = gen_salt
49       clear_meta = { "Coquelicot" => COQUELICOT_VERSION,
50                      "Salt" => Base64.encode64(salt).strip,
51                      "Expire-at" => meta.delete('Expire-at'),
52                    }
53       cipher = get_cipher(pass, salt, :encrypt)
54       length = 0
55       File.open("#{path}.content", File::WRONLY|File::EXCL|File::CREAT, :encoding => 'binary') do |dest|
56         until (buf = yield).nil?
57           length += buf.bytesize
58           dest.write(cipher.update(buf))
59         end
60         dest.write(cipher.final)
61       end
62       cipher.reset
63       File.open(path, File::WRONLY|File::EXCL|File::CREAT, :encoding => 'binary') do |dest|
64         dest.write(YAML.dump(clear_meta) + YAML_START)
65         dest.write(cipher.update(
66             YAML.dump(meta.merge('Created-at' => Time.now.to_i,
67                                  'Length' => length))))
68         dest.write(cipher.final)
69       end
70     rescue Errno::EEXIST
71       # do not remove the file if it already existed before!
72       raise
73     rescue
74       FileUtils.rm path, :force => true
75       FileUtils.rm "#{path}.content", :force => true
76       raise
77     end
78
79     def empty!
80       # XXX: probably this should be locked
81       paths = [@path]
82       paths.unshift "#{@path}.content" unless @features.include? :meta_include_content
83       paths.each do |path|
84         # zero the content before truncating
85         File.open(path, 'r+', :encoding => 'binary') do |f|
86           f.seek 0, IO::SEEK_END
87           length = f.tell
88           f.rewind
89           while length > 0 do
90             write_len = [StoredFile::BUFFER_LEN, length].min
91             length -= f.write("\0" * write_len)
92           end
93           f.fsync
94         end
95         File.truncate(path, 0)
96       end
97     end
98
99     def lockfile
100       @lockfile ||= Lockfile.new "#{File.expand_path(@path)}.lock", :timeout => 4
101     end
102
103     # used by Rack streaming mechanism
104     def each
105       raise BadKey.new if @cipher.nil?
106
107       # output content
108       if @features.include? :meta_include_content
109         yield @initial_content
110         @initial_content = nil
111         file = @file
112       else
113         file = File.open("#{path}.content", :encoding => 'binary')
114         @cipher.reset
115       end
116       unless file.eof?
117         until (buf = file.read(BUFFER_LEN)).nil?
118           yield @cipher.update(buf)
119         end
120         yield @cipher.final
121       end
122       @fully_sent = true
123     end
124
125     def close
126       if @cipher
127         @cipher.reset
128         @cipher = nil
129       end
130       @file.close
131       if one_time_only?
132         empty! if @fully_sent
133         lockfile.unlock
134       end
135     end
136
137   private
138
139     YAML_START = "--- \n"
140     YAML_START_RE = /^---( |\n)/
141     CIPHER = 'AES-256-CBC'
142     SALT_LEN = 8
143     COQUELICOT_VERSION = '2.0'
144     COQUELICOT_FEATURES = {               '1.0' => [:meta_include_content],
145                              COQUELICOT_VERSION => [:current]
146                           }
147
148     def self.get_cipher(pass, salt, method)
149       cipher = OpenSSL::Cipher.new CIPHER
150       hmac = OpenSSL::PKCS5.pbkdf2_hmac_sha1(
151           pass, salt, 2000, cipher.key_len + cipher.iv_len)
152       cipher.method(method).call
153       cipher.key = hmac.slice!(0, cipher.key_len)
154       cipher.iv = hmac
155       cipher
156     end
157
158     def self.gen_salt
159       OpenSSL::Random::random_bytes(SALT_LEN)
160     end
161
162     def initialize(path, pass)
163       @path = path
164       @file = File.open(@path, :encoding => 'binary')
165       if @file.lstat.size == 0 then
166         @expire_at = Time.now - 1
167         return
168       end
169
170       unless YAML_START_RE =~ (buf = @file.readline)
171         raise ArgumentError.new("unknown file, read #{buf.inspect}")
172       end
173       parse_clear_meta
174       return if pass.nil?
175
176       init_decrypt_cipher pass
177
178       yaml = find_meta
179       @meta.merge! YAML.load(yaml)
180     end
181
182     def parse_clear_meta
183       meta = ''
184       until YAML_START_RE =~ (line = @file.readline) do
185         meta += line
186       end
187       @meta = YAML.load(meta)
188       @features = COQUELICOT_FEATURES[@meta['Coquelicot']]
189       unless @features
190         raise ArgumentError.new('unknown file')
191       end
192       if @meta['Expire-at'].respond_to? :to_time
193         @expire_at = @meta['Expire-at'].to_time
194       else
195         @expire_at = Time.at(@meta['Expire-at'])
196       end
197     end
198
199     def init_decrypt_cipher(pass)
200       salt = Base64.decode64(@meta["Salt"])
201       @cipher = StoredFile::get_cipher(pass, salt, :decrypt)
202     end
203
204     def find_meta
205       return find_meta_in_meta_and_content if @features.include? :meta_include_content
206
207       begin
208         content = @cipher.update(@file.read)
209         content << @cipher.final
210         raise BadKey.new unless content =~ YAML_START_RE
211         content
212       rescue OpenSSL::Cipher::CipherError
213         raise BadKey.new
214       end
215     end
216
217     def find_meta_in_meta_and_content
218       yaml = ''
219       buf = @file.read(BUFFER_LEN)
220       begin
221         content = @cipher.update(buf)
222         content << @cipher.final if @file.eof?
223         raise BadKey.new unless content =~ YAML_START_RE
224       rescue OpenSSL::Cipher::CipherError
225         raise BadKey.new
226       end
227       yaml << YAML_START
228       block = content.split(YAML_START, 3)
229       yaml << block[1]
230       if block.length == 3 then
231         @initial_content = block[2]
232         return yaml
233       end
234
235       until (buf = @file.read(BUFFER_LEN)).nil? do
236         content = @cipher.update(buf)
237         block = content.split(YAML_START, 3)
238         yaml << block[0]
239         break if block.length == 2
240       end
241       @initial_content = block[1]
242       yaml
243     end
244   end
245 end