change StoredFile.create interface
[coquelicot.git] / lib / coquelicot / stored_file.rb
1 # Coquelicot: "one-click" file sharing with a focus on users' privacy.
2 # Copyright © 2010-2012 potager.org <jardiniers@potager.org>
3 #
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.
8 #
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.
13 #
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/>.
16
17 require 'base64'
18 require 'lockfile'
19 require 'openssl'
20 require 'yaml'
21
22 module Coquelicot
23   class BadKey < StandardError; end
24
25   class StoredFile
26     BUFFER_LEN = 4096
27
28     attr_reader :path, :meta, :expire_at
29
30     def self.open(path, pass = nil)
31       StoredFile.new(path, pass)
32     end
33
34     def created_at
35       Time.at(@meta['Created-at'])
36     end
37
38     def expired?
39       @expire_at < Time.now
40     end
41
42     def one_time_only?
43       @meta['One-time-only'] == 'true'
44     end
45
46     def self.create(dest, pass, meta)
47       salt = gen_salt
48       clear_meta = { "Coquelicot" => COQUELICOT_VERSION,
49                      "Salt" => Base64.encode64(salt).strip,
50                      "Expire-at" => meta.delete('Expire-at'),
51                    }
52       File.open(dest, 'w') do |dest|
53         dest.write(YAML.dump(clear_meta) + YAML_START)
54
55         cipher = get_cipher(pass, salt, :encrypt)
56         dest.write(cipher.update(
57             YAML.dump(meta.merge("Created-at" => Time.now.to_i)) +
58             YAML_START))
59         while not (buf = yield).nil?
60           dest.write(cipher.update(buf))
61         end
62         dest.write(cipher.final)
63       end
64     end
65
66     def empty!
67       # zero the content before truncating
68       File.open(@path, 'r+') do |f|
69         f.seek 0, IO::SEEK_END
70         length = f.tell
71         f.rewind
72         while length > 0 do
73           write_len = [StoredFile::BUFFER_LEN, length].min
74           length -= f.write("\0" * write_len)
75         end
76         f.fsync
77       end
78       File.truncate(@path, 0)
79     end
80
81     def lockfile
82       @lockfile ||= Lockfile.new "#{File.expand_path(@path)}.lock", :timeout => 4
83     end
84
85     # used by Rack streaming mechanism
86     def each
87       # output content
88       yield @initial_content
89       @initial_content = nil
90       until (buf = @file.read(BUFFER_LEN)).nil?
91         yield @cipher.update(buf)
92       end
93       yield @cipher.final
94       @fully_sent = true
95     end
96
97     def close
98       if @cipher
99         @cipher.reset
100         @cipher = nil
101       end
102       @file.close
103       if one_time_only?
104         empty! if @fully_sent
105         lockfile.unlock
106       end
107     end
108
109   private
110
111     YAML_START = "--- \n"
112     CIPHER = 'AES-256-CBC'
113     SALT_LEN = 8
114     COQUELICOT_VERSION = "1.0"
115
116     def self.get_cipher(pass, salt, method)
117       cipher = OpenSSL::Cipher.new CIPHER
118       hmac = OpenSSL::PKCS5.pbkdf2_hmac_sha1(
119           pass, salt, 2000, cipher.key_len + cipher.iv_len)
120       cipher.method(method).call
121       cipher.key = hmac.slice!(0, cipher.key_len)
122       cipher.iv = hmac
123       cipher
124     end
125
126     def self.gen_salt
127       OpenSSL::Random::random_bytes(SALT_LEN)
128     end
129
130     def initialize(path, pass)
131       @path = path
132       @file = File.open(@path)
133       if @file.lstat.size == 0 then
134         @expire_at = Time.now - 1
135         return
136       end
137
138       if YAML_START != (buf = @file.read(YAML_START.length)) then
139         raise "unknown file, read #{buf.inspect}"
140       end
141       parse_clear_meta
142       return if pass.nil?
143       init_decrypt_cipher pass
144       parse_meta
145     end
146
147     def parse_clear_meta
148       meta = ''
149       until YAML_START == (line = @file.readline) do
150         meta += line
151       end
152       @meta = YAML.load(meta)
153       if @meta["Coquelicot"].nil? or @meta["Coquelicot"] != COQUELICOT_VERSION then
154         raise "unknown file"
155       end
156       @expire_at = Time.at(@meta['Expire-at'])
157     end
158
159     def init_decrypt_cipher(pass)
160       salt = Base64.decode64(@meta["Salt"])
161       @cipher = StoredFile::get_cipher(pass, salt, :decrypt)
162     end
163
164     def parse_meta
165       yaml = ''
166       buf = @file.read(BUFFER_LEN)
167       content = @cipher.update(buf)
168       raise BadKey unless content.start_with? YAML_START
169       yaml << YAML_START
170       block = content.split(YAML_START, 3)
171       yaml << block[1]
172       if block.length == 3 then
173         @initial_content = block[2]
174         @meta.merge! YAML.load(yaml)
175         return
176       end
177
178       until (buf = @file.read(BUFFER_LEN)).nil? do
179         block = @cipher.update(buf).split(YAML_START, 3)
180         yaml << block[0]
181         break if block.length == 2
182       end
183       @initial_content = block[1]
184       @meta.merge! YAML.load(yaml)
185     end
186   end
187 end