3cc714b01e05df4c2696fdd47ff27371849e008e
[coquelicot.git] / lib / coquelicot.rb
1 require 'base64'
2 require 'lockfile'
3 require 'openssl'
4 require 'yaml'
5
6 module Coquelicot
7   class BadKey < StandardError; end
8
9   class StoredFile
10     BUFFER_LEN = 4096
11
12     attr_reader :path, :meta, :expire_at
13
14     def self.open(path, pass = nil)
15       StoredFile.new(path, pass)
16     end
17
18     def created_at
19       Time.at(@meta['Created-at'])
20     end
21
22     def expired?
23       @expire_at < Time.now
24     end
25
26     def one_time_only?
27       @meta['One-time-only'] && @meta['One-time-only'] == 'true'
28     end
29
30     def self.create(src, pass, meta)
31       salt = gen_salt
32       clear_meta = { "Coquelicot" => COQUELICOT_VERSION,
33                      "Salt" => Base64.encode64(salt).strip,
34                      "Expire-at" => meta.delete('Expire-at'),
35                    }
36       yield YAML.dump(clear_meta) + YAML_START
37
38       cipher = get_cipher(pass, salt, :encrypt)
39       yield cipher.update(YAML.dump(meta.merge("Created-at" => Time.now.to_i)) +
40                           YAML_START)
41       src.rewind
42       while not (buf = src.read(BUFFER_LEN)).nil?
43         yield cipher.update(buf)
44       end
45       yield cipher.final
46     end
47
48     def empty!
49       # zero the content before truncating
50       File.open(@path, 'r+') do |f|
51         f.seek 0, IO::SEEK_END
52         length = f.tell
53         f.rewind
54         while length > 0 do
55           write_len = [StoredFile::BUFFER_LEN, length].min
56           length -= f.write("\0" * write_len)
57         end
58         f.fsync
59       end
60       File.truncate(@path, 0)
61     end
62
63   private
64
65     YAML_START = "--- \n"
66     CIPHER = 'AES-256-CBC'
67     SALT_LEN = 8
68     COQUELICOT_VERSION = "1.0"
69
70     def self.get_cipher(pass, salt, method)
71       hmac = OpenSSL::PKCS5.pbkdf2_hmac_sha1(pass, salt, 2000, 48)
72       cipher = OpenSSL::Cipher.new CIPHER
73       cipher.method(method).call
74       cipher.key = hmac[0..31]
75       cipher.iv = hmac[32..-1]
76       cipher
77     end
78
79     def self.gen_salt
80       OpenSSL::Random::random_bytes(SALT_LEN)
81     end
82
83     def initialize(path, pass)
84       @path = path
85       @file = File.open(@path)
86       if @file.lstat.size == 0 then
87         @expire_at = Time.now - 1
88         return
89       end
90
91       if YAML_START != (buf = @file.read(YAML_START.length)) then
92         raise "unknown file, read #{buf.inspect}"
93       end
94       parse_clear_meta
95       return if pass.nil?
96       init_decrypt_cipher pass
97       parse_meta
98     end
99
100     def parse_clear_meta
101       meta = ''
102       until YAML_START == (line = @file.readline) do
103         meta += line
104       end
105       @meta = YAML.load(meta)
106       if @meta["Coquelicot"].nil? or @meta["Coquelicot"] != COQUELICOT_VERSION then
107         raise "unknown file"
108       end
109       @expire_at = Time.at(@meta['Expire-at'])
110     end
111
112     def init_decrypt_cipher(pass)
113       salt = Base64.decode64(@meta["Salt"])
114       @cipher = StoredFile::get_cipher(pass, salt, :decrypt)
115     end
116
117     def parse_meta
118       yaml = ''
119       buf = @file.read(BUFFER_LEN)
120       content = @cipher.update(buf)
121       raise BadKey unless content.start_with? YAML_START
122       yaml << YAML_START
123       block = content.split(YAML_START, 3)
124       yaml << block[1]
125       if block.length == 3 then
126         @initial_content = block[2]
127         @meta.merge! YAML.load(yaml)
128         return
129       end
130
131       until (buf = @file.read(BUFFER_LEN)).nil? do
132         block = @cipher.update(buf).split(YAML_START, 3)
133         yaml << block[0]
134         break if block.length == 2
135       end
136       @initial_content = block[1]
137       @meta.merge! YAML.load(yaml)
138     end
139   end
140
141   class Depot
142     attr_reader :path
143
144     def initialize(path)
145       @path = path
146     end
147
148     def add_file(src, pass, options)
149       dst = nil
150       lockfile.lock do
151         dst = gen_random_file_name
152         File.open(full_path(dst), 'w').close
153       end
154       begin
155         File.open(full_path(dst), 'w') do |dest|
156           StoredFile.create(src, pass, options) { |data| dest.write data }
157         end
158       rescue
159         File.unlink full_path(dst)
160         raise
161       end
162       link = gen_random_file_name
163       add_link(link, dst)
164       link
165     end
166
167     def get_file(link, pass=nil)
168       name = read_link(link)
169       return nil if name.nil?
170       StoredFile::open(full_path(name), pass)
171     end
172
173     def file_exists?(link)
174       name = read_link(link)
175       return !name.nil?
176     end
177
178     def gc!
179       files.each do |name|
180         path = full_path(name)
181         if File.lstat(path).size > 0
182           file = StoredFile::open path
183           file.empty! if file.expired?
184         elsif Time.now - File.lstat(path).mtime > (Coquelicot.settings.gone_period * 60)
185           remove_from_links { |l| l.strip.end_with? " #{name}" }
186           File.unlink path
187         end
188       end
189     end
190
191   private
192
193     LOCKFILE_OPTIONS = { :timeout => 60,
194                          :max_age => 8,
195                          :refresh => 2,
196                          :debug   => false }
197
198     def lockfile
199       Lockfile.new "#{@path}/.lock", LOCKFILE_OPTIONS
200     end
201
202     def links_path
203       "#{@path}/.links"
204     end
205
206     def add_link(src, dst)
207       lockfile.lock do
208         File.open(links_path, 'a') do |f|
209           f.write("#{src} #{dst}\n")
210         end
211       end
212     end
213
214     def remove_from_links(&block)
215       lockfile.lock do
216         links = []
217         File.open(links_path, 'r+') do |f|
218           f.readlines.each do |l|
219             links << l unless yield l
220           end
221           f.rewind
222           f.truncate(0)
223           f.write links.join
224         end
225       end
226     end
227
228     def remove_link(src)
229       remove_from_links { |l| l.start_with? "#{src} " }
230     end
231
232     def read_link(src)
233       dst = nil
234       lockfile.lock do
235         File.open(links_path) do |f|
236           begin
237             line = f.readline rescue break
238             if line.start_with? "#{src} " then
239               dst = line.split[1]
240               break
241             end
242           end until line.empty?
243         end
244       end
245       dst
246     end
247
248     def files
249       lockfile.lock do
250         File.open(links_path) do |f|
251           f.readlines.collect { |l| l.split[1] }
252         end
253       end
254     end
255
256     def gen_random_file_name
257       begin
258         name = Coquelicot.gen_random_base32(Coquelicot.settings.filename_length)
259       end while File.exists?(full_path(name))
260       name
261     end
262
263     def full_path(name)
264       raise "Wrong name" unless name.each_char.collect { |c| Coquelicot::FILENAME_CHARS.include? c }.all?
265       "#{@path}/#{name}"
266     end
267   end
268
269   DEFAULT_SETTINGS = { :default_expire => 60,
270                        :maximum_expire => 60 * 24 * 30, # 1 month
271                        :gone_period => 10080,
272                        :filename_length => 20,
273                        :random_pass_length => 16,
274                     }
275
276   # Like RFC 4648 (Base32)
277   FILENAME_CHARS = %w(a b c d e f g h i j k l m n o p q r s t u v w x y z 2 3 4 5 6 7)
278
279   class << self
280
281     def setup(settings)
282       @settings = DEFAULT_SETTINGS.merge(settings)
283       @settings.each_key do |k|
284         @settings.class.send(:define_method, k) { self[k] }
285       end
286       @depot = nil
287       @settings
288     end
289
290     def settings
291       @settings ||= setup({})
292     end
293
294     def depot
295       @depot ||= Depot.new(settings.depot_path)
296     end
297
298     def gen_random_base32(length)
299       name = ''
300       OpenSSL::Random::random_bytes(length).each_byte do |i|
301         name << FILENAME_CHARS[i % FILENAME_CHARS.length]
302       end
303       name
304     end
305     def gen_random_pass
306       gen_random_base32(settings.random_pass_length)
307     end
308     def remap_base32_extra_characters(str)
309       map = {}
310       FILENAME_CHARS.each { |c| map[c] = c; map[c.upcase] = c }
311       map.merge!({ '1' => 'l', '0' => 'o' })
312       result = ''
313       str.each_char { |c| result << map[c] }
314       result
315     end
316   end
317 end