move Coquelicot::StoredFile to its own file
[coquelicot.git] / lib / coquelicot / stored_file.rb
1 module Coquelicot
2   class BadKey < StandardError; end
3
4   class StoredFile
5     BUFFER_LEN = 4096
6
7     attr_reader :path, :meta, :expire_at
8
9     def self.open(path, pass = nil)
10       StoredFile.new(path, pass)
11     end
12
13     def created_at
14       Time.at(@meta['Created-at'])
15     end
16
17     def expired?
18       @expire_at < Time.now
19     end
20
21     def one_time_only?
22       @meta['One-time-only'] == 'true'
23     end
24
25     def self.create(src, pass, meta)
26       salt = gen_salt
27       clear_meta = { "Coquelicot" => COQUELICOT_VERSION,
28                      "Salt" => Base64.encode64(salt).strip,
29                      "Expire-at" => meta.delete('Expire-at'),
30                    }
31       yield YAML.dump(clear_meta) + YAML_START
32
33       cipher = get_cipher(pass, salt, :encrypt)
34       yield cipher.update(YAML.dump(meta.merge("Created-at" => Time.now.to_i)) +
35                           YAML_START)
36       src.rewind
37       while not (buf = src.read(BUFFER_LEN)).nil?
38         yield cipher.update(buf)
39       end
40       yield cipher.final
41     end
42
43     def empty!
44       # zero the content before truncating
45       File.open(@path, 'r+') do |f|
46         f.seek 0, IO::SEEK_END
47         length = f.tell
48         f.rewind
49         while length > 0 do
50           write_len = [StoredFile::BUFFER_LEN, length].min
51           length -= f.write("\0" * write_len)
52         end
53         f.fsync
54       end
55       File.truncate(@path, 0)
56     end
57
58   private
59
60     YAML_START = "--- \n"
61     CIPHER = 'AES-256-CBC'
62     SALT_LEN = 8
63     COQUELICOT_VERSION = "1.0"
64
65     def self.get_cipher(pass, salt, method)
66       hmac = OpenSSL::PKCS5.pbkdf2_hmac_sha1(pass, salt, 2000, 48)
67       cipher = OpenSSL::Cipher.new CIPHER
68       cipher.method(method).call
69       cipher.key = hmac[0..31]
70       cipher.iv = hmac[32..-1]
71       cipher
72     end
73
74     def self.gen_salt
75       OpenSSL::Random::random_bytes(SALT_LEN)
76     end
77
78     def initialize(path, pass)
79       @path = path
80       @file = File.open(@path)
81       if @file.lstat.size == 0 then
82         @expire_at = Time.now - 1
83         return
84       end
85
86       if YAML_START != (buf = @file.read(YAML_START.length)) then
87         raise "unknown file, read #{buf.inspect}"
88       end
89       parse_clear_meta
90       return if pass.nil?
91       init_decrypt_cipher pass
92       parse_meta
93     end
94
95     def parse_clear_meta
96       meta = ''
97       until YAML_START == (line = @file.readline) do
98         meta += line
99       end
100       @meta = YAML.load(meta)
101       if @meta["Coquelicot"].nil? or @meta["Coquelicot"] != COQUELICOT_VERSION then
102         raise "unknown file"
103       end
104       @expire_at = Time.at(@meta['Expire-at'])
105     end
106
107     def init_decrypt_cipher(pass)
108       salt = Base64.decode64(@meta["Salt"])
109       @cipher = StoredFile::get_cipher(pass, salt, :decrypt)
110     end
111
112     def parse_meta
113       yaml = ''
114       buf = @file.read(BUFFER_LEN)
115       content = @cipher.update(buf)
116       raise BadKey unless content.start_with? YAML_START
117       yaml << YAML_START
118       block = content.split(YAML_START, 3)
119       yaml << block[1]
120       if block.length == 3 then
121         @initial_content = block[2]
122         @meta.merge! YAML.load(yaml)
123         return
124       end
125
126       until (buf = @file.read(BUFFER_LEN)).nil? do
127         block = @cipher.update(buf).split(YAML_START, 3)
128         yaml << block[0]
129         break if block.length == 2
130       end
131       @initial_content = block[1]
132       @meta.merge! YAML.load(yaml)
133     end
134   end
135 end