7e2656e19af399a58b7e26b95807442f0acad7da
[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(src, 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       yield YAML.dump(clear_meta) + YAML_START
53
54       cipher = get_cipher(pass, salt, :encrypt)
55       yield cipher.update(YAML.dump(meta.merge("Created-at" => Time.now.to_i)) +
56                           YAML_START)
57       src.rewind
58       while not (buf = src.read(BUFFER_LEN)).nil?
59         yield cipher.update(buf)
60       end
61       yield cipher.final
62     end
63
64     def empty!
65       # zero the content before truncating
66       File.open(@path, 'r+') do |f|
67         f.seek 0, IO::SEEK_END
68         length = f.tell
69         f.rewind
70         while length > 0 do
71           write_len = [StoredFile::BUFFER_LEN, length].min
72           length -= f.write("\0" * write_len)
73         end
74         f.fsync
75       end
76       File.truncate(@path, 0)
77     end
78
79     def lockfile
80       @lockfile ||= Lockfile.new "#{File.expand_path(@path)}.lock", :timeout => 4
81     end
82
83     # used by Rack streaming mechanism
84     def each
85       # output content
86       yield @initial_content
87       @initial_content = nil
88       until (buf = @file.read(BUFFER_LEN)).nil?
89         yield @cipher.update(buf)
90       end
91       yield @cipher.final
92       @fully_sent = true
93     end
94
95     def close
96       if @cipher
97         @cipher.reset
98         @cipher = nil
99       end
100       @file.close
101       if one_time_only?
102         empty! if @fully_sent
103         lockfile.unlock
104       end
105     end
106
107   private
108
109     YAML_START = "--- \n"
110     CIPHER = 'AES-256-CBC'
111     SALT_LEN = 8
112     COQUELICOT_VERSION = "1.0"
113
114     def self.get_cipher(pass, salt, method)
115       cipher = OpenSSL::Cipher.new CIPHER
116       hmac = OpenSSL::PKCS5.pbkdf2_hmac_sha1(
117           pass, salt, 2000, cipher.key_len + cipher.iv_len)
118       cipher.method(method).call
119       cipher.key = hmac.slice!(0, cipher.key_len)
120       cipher.iv = hmac
121       cipher
122     end
123
124     def self.gen_salt
125       OpenSSL::Random::random_bytes(SALT_LEN)
126     end
127
128     def initialize(path, pass)
129       @path = path
130       @file = File.open(@path)
131       if @file.lstat.size == 0 then
132         @expire_at = Time.now - 1
133         return
134       end
135
136       if YAML_START != (buf = @file.read(YAML_START.length)) then
137         raise "unknown file, read #{buf.inspect}"
138       end
139       parse_clear_meta
140       return if pass.nil?
141       init_decrypt_cipher pass
142       parse_meta
143     end
144
145     def parse_clear_meta
146       meta = ''
147       until YAML_START == (line = @file.readline) do
148         meta += line
149       end
150       @meta = YAML.load(meta)
151       if @meta["Coquelicot"].nil? or @meta["Coquelicot"] != COQUELICOT_VERSION then
152         raise "unknown file"
153       end
154       @expire_at = Time.at(@meta['Expire-at'])
155     end
156
157     def init_decrypt_cipher(pass)
158       salt = Base64.decode64(@meta["Salt"])
159       @cipher = StoredFile::get_cipher(pass, salt, :decrypt)
160     end
161
162     def parse_meta
163       yaml = ''
164       buf = @file.read(BUFFER_LEN)
165       content = @cipher.update(buf)
166       raise BadKey unless content.start_with? YAML_START
167       yaml << YAML_START
168       block = content.split(YAML_START, 3)
169       yaml << block[1]
170       if block.length == 3 then
171         @initial_content = block[2]
172         @meta.merge! YAML.load(yaml)
173         return
174       end
175
176       until (buf = @file.read(BUFFER_LEN)).nil? do
177         block = @cipher.update(buf).split(YAML_START, 3)
178         yaml << block[0]
179         break if block.length == 2
180       end
181       @initial_content = block[1]
182       @meta.merge! YAML.load(yaml)
183     end
184   end
185 end