konfidant 0.7.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +119 -255
- data/lib/konfidant/client.rb +139 -95
- data/lib/konfidant/errors.rb +8 -1
- data/lib/konfidant/knf.rb +469 -0
- data/lib/konfidant/types.rb +14 -18
- data/lib/konfidant/version.rb +1 -1
- data/lib/konfidant.rb +1 -0
- metadata +6 -5
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
require 'openssl'
|
|
2
|
+
require 'securerandom'
|
|
3
|
+
require 'stringio'
|
|
4
|
+
require 'uri'
|
|
5
|
+
|
|
6
|
+
module Konfidant
|
|
7
|
+
# KNF1 — Konfidant client-side encryption format.
|
|
8
|
+
#
|
|
9
|
+
# Content is encrypted on the sender's machine with a random 256-bit key that travels only in the share link's
|
|
10
|
+
# URL fragment, so Konfidant's servers store and deliver ciphertext they cannot decrypt.
|
|
11
|
+
#
|
|
12
|
+
# ciphertext = header (16 bytes) || sealed_chunk_0 || ... || sealed_chunk_n
|
|
13
|
+
# stream = uint32_be(len(meta)) || meta || content
|
|
14
|
+
# meta = kind (1) || uint16_be(len(name)) || name || uint16_be(len(mime)) || mime
|
|
15
|
+
# nonce_i = nonce_prefix (7) || uint32_be(i) || last_flag (1)
|
|
16
|
+
#
|
|
17
|
+
# Every chunk is sealed with AES-256-GCM (16-byte tag) and authenticates the header as AAD.
|
|
18
|
+
# All byte strings produced and consumed here are binary (ASCII-8BIT).
|
|
19
|
+
module Knf
|
|
20
|
+
MAGIC = 'KNF1'.b.freeze
|
|
21
|
+
HEADER_SIZE = 16
|
|
22
|
+
TAG_SIZE = 16
|
|
23
|
+
KEY_SIZE = 32
|
|
24
|
+
NONCE_PREFIX_SIZE = 7
|
|
25
|
+
DEFAULT_CHUNK_SIZE = 1024 * 1024
|
|
26
|
+
MIN_CHUNK_SIZE = 4096
|
|
27
|
+
MAX_CHUNK_SIZE = 16 * 1024 * 1024
|
|
28
|
+
MAX_NAME_BYTES = 1024
|
|
29
|
+
MAX_MIME_BYTES = 255
|
|
30
|
+
MAX_CHUNK_INDEX = 0xFFFFFFFF
|
|
31
|
+
|
|
32
|
+
KIND_TEXT = 1
|
|
33
|
+
KIND_FILE = 2
|
|
34
|
+
KINDS = { 'text' => KIND_TEXT, 'file' => KIND_FILE }.freeze
|
|
35
|
+
META_FIXED_SIZE = 5 # kind (1) + name length (2) + mime length (2)
|
|
36
|
+
META_LENGTH_PREFIX = 4
|
|
37
|
+
CIPHER = 'aes-256-gcm'.freeze
|
|
38
|
+
KEY_PATTERN = /\A[A-Za-z0-9_-]{43}\z/
|
|
39
|
+
|
|
40
|
+
# Result of a successful decryption. +data+ is a binary String; +kind+ is "text" or "file".
|
|
41
|
+
Decrypted = Data.define(:kind, :name, :mime, :data) do
|
|
42
|
+
# Content of a text share as a UTF-8 String.
|
|
43
|
+
def text
|
|
44
|
+
raise KnfError, 'Share is not a text share' unless kind == 'text'
|
|
45
|
+
|
|
46
|
+
Knf.send(:utf8_string, data, 'Text')
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
module_function
|
|
51
|
+
|
|
52
|
+
# 32 random bytes from the OS CSPRNG. Use a fresh key for every share.
|
|
53
|
+
def generate_key
|
|
54
|
+
SecureRandom.random_bytes(KEY_SIZE)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Unpadded base64url (43 characters), as carried in the share link's +k+ fragment parameter.
|
|
58
|
+
def encode_key(key)
|
|
59
|
+
check_key!(key)
|
|
60
|
+
[key].pack('m0').tr('+/', '-_').delete('=')
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def decode_key(encoded)
|
|
64
|
+
raise KnfError, 'Invalid key encoding' unless encoded.is_a?(String) && KEY_PATTERN.match?(encoded)
|
|
65
|
+
|
|
66
|
+
key = "#{encoded.tr('-_', '+/')}=".unpack1('m0')
|
|
67
|
+
raise KnfError, 'Invalid key length' unless key.bytesize == KEY_SIZE
|
|
68
|
+
|
|
69
|
+
key
|
|
70
|
+
rescue ArgumentError
|
|
71
|
+
raise KnfError, 'Invalid key encoding'
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Encodes the metadata block. +kind+ is "text" or "file"; texts carry no name or MIME type.
|
|
75
|
+
def encode_metadata(kind:, name: '', mime: '')
|
|
76
|
+
kind_byte = KINDS.fetch(kind.to_s) { raise KnfError, "Unknown content kind: #{kind.inspect}" }
|
|
77
|
+
name_bytes = utf8_bytes(name, 'File name')
|
|
78
|
+
mime_bytes = utf8_bytes(mime, 'MIME type')
|
|
79
|
+
if kind_byte == KIND_TEXT && !(name_bytes.empty? && mime_bytes.empty?)
|
|
80
|
+
raise KnfError, 'Text shares must not carry a name or MIME type'
|
|
81
|
+
end
|
|
82
|
+
raise KnfError, "File name exceeds #{MAX_NAME_BYTES} bytes" if name_bytes.bytesize > MAX_NAME_BYTES
|
|
83
|
+
raise KnfError, "MIME type exceeds #{MAX_MIME_BYTES} bytes" if mime_bytes.bytesize > MAX_MIME_BYTES
|
|
84
|
+
|
|
85
|
+
[kind_byte, name_bytes.bytesize].pack('Cn') + name_bytes + [mime_bytes.bytesize].pack('n') + mime_bytes
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Exact ciphertext size for a metadata block of +metadata_length+ bytes and +content_length+ content bytes.
|
|
89
|
+
def ciphertext_size(metadata_length:, content_length:, chunk_size: DEFAULT_CHUNK_SIZE)
|
|
90
|
+
stream_length = META_LENGTH_PREFIX + metadata_length + content_length
|
|
91
|
+
HEADER_SIZE + stream_length + (TAG_SIZE * ((stream_length + chunk_size - 1) / chunk_size))
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Largest ciphertext a file share of at most +max_content_bytes+ can produce (longest name and MIME type).
|
|
95
|
+
def max_file_ciphertext_size(max_content_bytes)
|
|
96
|
+
ciphertext_size(metadata_length: META_FIXED_SIZE + MAX_NAME_BYTES + MAX_MIME_BYTES,
|
|
97
|
+
content_length: max_content_bytes)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Largest ciphertext a text share of at most +max_text_bytes+ UTF-8 bytes can produce.
|
|
101
|
+
def max_text_ciphertext_size(max_text_bytes)
|
|
102
|
+
ciphertext_size(metadata_length: META_FIXED_SIZE, content_length: max_text_bytes)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Returns a streaming, IO-like Encryptor (responds to #read, #readpartial and #size) that yields the KNF1
|
|
106
|
+
# ciphertext chunk by chunk, so large inputs never have to be held in memory.
|
|
107
|
+
#
|
|
108
|
+
# +content+ is a String (any encoding; its bytes are used) or a readable IO. IOs that respond to #size and #pos
|
|
109
|
+
# (File, Tempfile, StringIO) are streamed from their current position; other IOs are read into memory first.
|
|
110
|
+
#
|
|
111
|
+
# +nonce_prefix+ exists for test vectors only. Never pass it in production.
|
|
112
|
+
def encryptor(key:, content:, kind:, name: '', mime: '', chunk_size: DEFAULT_CHUNK_SIZE, nonce_prefix: nil)
|
|
113
|
+
Encryptor.new(key: key, content: content, metadata: encode_metadata(kind: kind, name: name, mime: mime),
|
|
114
|
+
chunk_size: chunk_size, nonce_prefix: nonce_prefix)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Encrypts +content+ and returns the complete KNF1 ciphertext as a binary String.
|
|
118
|
+
def encrypt(key:, content:, kind:, name: '', mime: '', chunk_size: DEFAULT_CHUNK_SIZE, nonce_prefix: nil)
|
|
119
|
+
encryptor(key: key, content: content, kind: kind, name: name, mime: mime,
|
|
120
|
+
chunk_size: chunk_size, nonce_prefix: nonce_prefix).read
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def encrypt_text(key:, text:, chunk_size: DEFAULT_CHUNK_SIZE, nonce_prefix: nil)
|
|
124
|
+
encrypt(key: key, content: utf8_bytes(text, 'Text'), kind: 'text',
|
|
125
|
+
chunk_size: chunk_size, nonce_prefix: nonce_prefix)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Decrypts a complete KNF1 ciphertext. Raises KnfError on any format or authentication failure.
|
|
129
|
+
def decrypt(key:, ciphertext:)
|
|
130
|
+
decryptor = Decryptor.new(key: key)
|
|
131
|
+
decryptor.push(ciphertext)
|
|
132
|
+
decryptor.finish
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# True when +bytes+ starts with a well-formed KNF1 header followed by at least one tag's worth of data.
|
|
136
|
+
def valid_header?(bytes)
|
|
137
|
+
bytes = bytes.b
|
|
138
|
+
return false if bytes.bytesize < HEADER_SIZE + TAG_SIZE
|
|
139
|
+
|
|
140
|
+
parse_header(bytes.byteslice(0, HEADER_SIZE))
|
|
141
|
+
true
|
|
142
|
+
rescue KnfError
|
|
143
|
+
false
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Builds the share link from the server-issued download URL (already carrying "#t=<token>") and the key.
|
|
147
|
+
def build_share_url(download_url, key)
|
|
148
|
+
"#{download_url}&k=#{encode_key(key)}"
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Parses "#t=<token>&k=<key>" from a share link. Returns [origin, token, key] or raises ArgumentError.
|
|
152
|
+
def parse_share_url(share_url)
|
|
153
|
+
uri = URI.parse(share_url.to_s)
|
|
154
|
+
raise ArgumentError, 'Share URL must be an absolute http(s) URL' unless uri.is_a?(URI::HTTP) && uri.host
|
|
155
|
+
|
|
156
|
+
params = URI.decode_www_form(uri.fragment.to_s).to_h
|
|
157
|
+
token = params['t']
|
|
158
|
+
encoded_key = params['k']
|
|
159
|
+
raise ArgumentError, 'Share URL is missing the token (t) or key (k)' if blank?(token) || blank?(encoded_key)
|
|
160
|
+
|
|
161
|
+
origin = "#{uri.scheme}://#{uri.host}"
|
|
162
|
+
origin += ":#{uri.port}" unless uri.port == uri.default_port
|
|
163
|
+
[origin, token, decode_key(encoded_key)]
|
|
164
|
+
rescue URI::InvalidURIError
|
|
165
|
+
raise ArgumentError, 'Invalid share URL'
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# --- internal helpers --------------------------------------------------------------------------------------
|
|
169
|
+
|
|
170
|
+
def check_key!(key)
|
|
171
|
+
raise KnfError, 'Key must be 32 bytes' unless key.is_a?(String) && key.bytesize == KEY_SIZE
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def check_chunk_size!(chunk_size)
|
|
175
|
+
return if chunk_size.is_a?(Integer) && chunk_size.between?(MIN_CHUNK_SIZE, MAX_CHUNK_SIZE)
|
|
176
|
+
|
|
177
|
+
raise KnfError, 'Invalid chunk size'
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def build_header(chunk_size, nonce_prefix)
|
|
181
|
+
MAGIC + [chunk_size].pack('N') + nonce_prefix + "\x00".b
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Validates a 16-byte header and returns [chunk_size, nonce_prefix].
|
|
185
|
+
def parse_header(header)
|
|
186
|
+
raise KnfError, 'Not a KNF1 payload' unless header.byteslice(0, 4) == MAGIC && header.getbyte(15).zero?
|
|
187
|
+
|
|
188
|
+
chunk_size = header.byteslice(4, 4).unpack1('N')
|
|
189
|
+
check_chunk_size!(chunk_size)
|
|
190
|
+
[chunk_size, header.byteslice(8, NONCE_PREFIX_SIZE)]
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def chunk_nonce(nonce_prefix, index, last)
|
|
194
|
+
raise KnfError, 'Too many chunks' if index > MAX_CHUNK_INDEX
|
|
195
|
+
|
|
196
|
+
nonce_prefix + [index, last ? 1 : 0].pack('NC')
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def seal(key, nonce, header, plaintext)
|
|
200
|
+
cipher = OpenSSL::Cipher.new(CIPHER).encrypt
|
|
201
|
+
cipher.key = key
|
|
202
|
+
cipher.iv = nonce
|
|
203
|
+
cipher.auth_data = header
|
|
204
|
+
sealed = cipher.update(plaintext) + cipher.final
|
|
205
|
+
sealed << cipher.auth_tag(TAG_SIZE)
|
|
206
|
+
sealed.b
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def open_sealed(key, nonce, header, sealed)
|
|
210
|
+
cipher = OpenSSL::Cipher.new(CIPHER).decrypt
|
|
211
|
+
cipher.key = key
|
|
212
|
+
cipher.iv = nonce
|
|
213
|
+
cipher.auth_tag = sealed.byteslice(-TAG_SIZE, TAG_SIZE)
|
|
214
|
+
cipher.auth_data = header
|
|
215
|
+
(cipher.update(sealed.byteslice(0, sealed.bytesize - TAG_SIZE)) + cipher.final).b
|
|
216
|
+
rescue OpenSSL::Cipher::CipherError
|
|
217
|
+
raise KnfError, 'Decryption failed: wrong key or corrupted or truncated ciphertext'
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def decode_stream(stream)
|
|
221
|
+
raise KnfError, 'Metadata truncated' if stream.bytesize < META_LENGTH_PREFIX
|
|
222
|
+
|
|
223
|
+
meta_length = stream.byteslice(0, META_LENGTH_PREFIX).unpack1('N')
|
|
224
|
+
raise KnfError, 'Metadata truncated' if META_LENGTH_PREFIX + meta_length > stream.bytesize
|
|
225
|
+
|
|
226
|
+
meta = stream.byteslice(META_LENGTH_PREFIX, meta_length)
|
|
227
|
+
kind, name, mime = decode_metadata(meta)
|
|
228
|
+
data = stream.byteslice((META_LENGTH_PREFIX + meta_length)..) || ''.b
|
|
229
|
+
Decrypted.new(kind: kind, name: name, mime: mime, data: data.b)
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def decode_metadata(meta)
|
|
233
|
+
raise KnfError, 'Metadata truncated' if meta.bytesize < META_FIXED_SIZE
|
|
234
|
+
|
|
235
|
+
kind = KINDS.key(meta.getbyte(0))
|
|
236
|
+
raise KnfError, 'Unknown content kind' unless kind
|
|
237
|
+
|
|
238
|
+
name_length = meta.byteslice(1, 2).unpack1('n')
|
|
239
|
+
raise KnfError, 'Metadata truncated' if 3 + name_length + 2 > meta.bytesize
|
|
240
|
+
|
|
241
|
+
mime_length = meta.byteslice(3 + name_length, 2).unpack1('n')
|
|
242
|
+
raise KnfError, 'Metadata length mismatch' unless META_FIXED_SIZE + name_length + mime_length == meta.bytesize
|
|
243
|
+
|
|
244
|
+
[kind,
|
|
245
|
+
utf8_string(meta.byteslice(3, name_length), 'File name'),
|
|
246
|
+
utf8_string(meta.byteslice(5 + name_length, mime_length), 'MIME type')]
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# Converts +value+ to its UTF-8 bytes (binary String). Binary strings are taken as UTF-8 bytes.
|
|
250
|
+
def utf8_bytes(value, label)
|
|
251
|
+
str = value.to_s
|
|
252
|
+
str = str.encoding == Encoding::BINARY ? str.dup.force_encoding(Encoding::UTF_8) : str.encode(Encoding::UTF_8)
|
|
253
|
+
raise KnfError, "#{label} must be valid UTF-8" unless str.valid_encoding?
|
|
254
|
+
|
|
255
|
+
str.b
|
|
256
|
+
rescue EncodingError
|
|
257
|
+
raise KnfError, "#{label} must be valid UTF-8"
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def utf8_string(bytes, label)
|
|
261
|
+
str = bytes.dup.force_encoding(Encoding::UTF_8)
|
|
262
|
+
raise KnfError, "#{label} is not valid UTF-8" unless str.valid_encoding?
|
|
263
|
+
|
|
264
|
+
str
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def blank?(value)
|
|
268
|
+
value.nil? || value.empty?
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
private_class_method :check_key!, :check_chunk_size!, :build_header, :parse_header, :chunk_nonce, :seal,
|
|
272
|
+
:open_sealed, :decode_stream, :decode_metadata, :utf8_bytes, :utf8_string, :blank?
|
|
273
|
+
|
|
274
|
+
# Streaming KNF1 encryptor with an IO-like read interface. #size is the exact ciphertext length, known up front
|
|
275
|
+
# so it can be declared to the server (ciphertext_size) and sent as Content-Length.
|
|
276
|
+
class Encryptor
|
|
277
|
+
attr_reader :size
|
|
278
|
+
|
|
279
|
+
def initialize(key:, content:, metadata:, chunk_size: DEFAULT_CHUNK_SIZE, nonce_prefix: nil)
|
|
280
|
+
Knf.send(:check_key!, key)
|
|
281
|
+
Knf.send(:check_chunk_size!, chunk_size)
|
|
282
|
+
nonce_prefix = nonce_prefix ? nonce_prefix.b : SecureRandom.random_bytes(NONCE_PREFIX_SIZE)
|
|
283
|
+
raise KnfError, 'Nonce prefix must be 7 bytes' unless nonce_prefix.bytesize == NONCE_PREFIX_SIZE
|
|
284
|
+
|
|
285
|
+
@key = key.b
|
|
286
|
+
@chunk_size = chunk_size
|
|
287
|
+
@nonce_prefix = nonce_prefix
|
|
288
|
+
@source, content_length = normalize_source(content)
|
|
289
|
+
@prefix = [metadata.bytesize].pack('N') + metadata
|
|
290
|
+
@remaining = @prefix.bytesize + content_length
|
|
291
|
+
@chunk_count = (@remaining + chunk_size - 1) / chunk_size
|
|
292
|
+
raise KnfError, 'Too many chunks' if @chunk_count - 1 > MAX_CHUNK_INDEX
|
|
293
|
+
|
|
294
|
+
@header = Knf.send(:build_header, chunk_size, nonce_prefix)
|
|
295
|
+
@size = Knf.ciphertext_size(metadata_length: metadata.bytesize, content_length: content_length,
|
|
296
|
+
chunk_size: chunk_size)
|
|
297
|
+
@index = 0
|
|
298
|
+
@buffer = @header.dup
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
# IO#read semantics: without +length+ returns everything left ("" at EOF); with +length+ returns up to
|
|
302
|
+
# +length+ bytes, or nil at EOF.
|
|
303
|
+
def read(length = nil, outbuf = nil)
|
|
304
|
+
if length.nil?
|
|
305
|
+
fill(Float::INFINITY)
|
|
306
|
+
data = take(@buffer.bytesize)
|
|
307
|
+
else
|
|
308
|
+
raise ArgumentError, "negative length #{length} given" if length.negative?
|
|
309
|
+
|
|
310
|
+
fill(length)
|
|
311
|
+
return replace(outbuf, ''.b) if length.zero?
|
|
312
|
+
return replace(outbuf, nil) if @buffer.empty?
|
|
313
|
+
|
|
314
|
+
data = take([length, @buffer.bytesize].min)
|
|
315
|
+
end
|
|
316
|
+
replace(outbuf, data)
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
def readpartial(length, outbuf = nil)
|
|
320
|
+
data = read(length, outbuf)
|
|
321
|
+
raise EOFError, 'end of file reached' if data.nil?
|
|
322
|
+
|
|
323
|
+
data
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
def eof?
|
|
327
|
+
@buffer.empty? && @index >= @chunk_count
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
private
|
|
331
|
+
|
|
332
|
+
def normalize_source(content)
|
|
333
|
+
case content
|
|
334
|
+
when String
|
|
335
|
+
bytes = content.b
|
|
336
|
+
[StringIO.new(bytes), bytes.bytesize]
|
|
337
|
+
else
|
|
338
|
+
raise ArgumentError, 'content must be a String or a readable IO' unless content.respond_to?(:read)
|
|
339
|
+
|
|
340
|
+
if content.respond_to?(:size) && content.respond_to?(:pos)
|
|
341
|
+
[content, content.size - content.pos]
|
|
342
|
+
else
|
|
343
|
+
bytes = (content.read || '').b
|
|
344
|
+
[StringIO.new(bytes), bytes.bytesize]
|
|
345
|
+
end
|
|
346
|
+
end
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
def fill(length)
|
|
350
|
+
@buffer << next_sealed_chunk while @buffer.bytesize < length && @index < @chunk_count
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
def take(length)
|
|
354
|
+
data = @buffer.byteslice(0, length)
|
|
355
|
+
@buffer = @buffer.byteslice(length..) || ''.b
|
|
356
|
+
data
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
def replace(outbuf, data)
|
|
360
|
+
return data if outbuf.nil?
|
|
361
|
+
|
|
362
|
+
outbuf.replace(data || ''.b)
|
|
363
|
+
data && outbuf
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
def next_sealed_chunk
|
|
367
|
+
length = [@chunk_size, @remaining].min
|
|
368
|
+
chunk = ''.b
|
|
369
|
+
unless @prefix.empty?
|
|
370
|
+
chunk << @prefix.byteslice(0, length)
|
|
371
|
+
@prefix = @prefix.byteslice(length..) || ''.b
|
|
372
|
+
end
|
|
373
|
+
chunk << read_source(length - chunk.bytesize)
|
|
374
|
+
@remaining -= length
|
|
375
|
+
last = @index == @chunk_count - 1
|
|
376
|
+
ensure_source_exhausted if last
|
|
377
|
+
|
|
378
|
+
nonce = Knf.send(:chunk_nonce, @nonce_prefix, @index, last)
|
|
379
|
+
@index += 1
|
|
380
|
+
Knf.send(:seal, @key, nonce, @header, chunk)
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
def read_source(length)
|
|
384
|
+
data = ''.b
|
|
385
|
+
while data.bytesize < length
|
|
386
|
+
part = @source.read(length - data.bytesize)
|
|
387
|
+
raise KnfError, 'Content is shorter than its declared size' if part.nil? || part.empty?
|
|
388
|
+
|
|
389
|
+
data << part.b
|
|
390
|
+
end
|
|
391
|
+
data
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
def ensure_source_exhausted
|
|
395
|
+
extra = @source.read(1)
|
|
396
|
+
raise KnfError, 'Content is longer than its declared size' unless extra.nil? || extra.empty?
|
|
397
|
+
end
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
# Incremental decryptor: #push bytes as they arrive, then #finish. A full-size chunk is only opened once more
|
|
401
|
+
# data has arrived, because the final chunk is authenticated with the "last" flag — this detects truncation.
|
|
402
|
+
class Decryptor
|
|
403
|
+
def initialize(key:)
|
|
404
|
+
Knf.send(:check_key!, key)
|
|
405
|
+
@key = key.b
|
|
406
|
+
@header = nil
|
|
407
|
+
@buffer = ''.b
|
|
408
|
+
@plaintext = ''.b
|
|
409
|
+
@index = 0
|
|
410
|
+
@finished = false
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
def push(bytes)
|
|
414
|
+
raise KnfError, 'Decryptor already finished' if @finished
|
|
415
|
+
|
|
416
|
+
@buffer << bytes.b
|
|
417
|
+
return self unless header_ready?
|
|
418
|
+
|
|
419
|
+
sealed_size = @chunk_size + TAG_SIZE
|
|
420
|
+
offset = 0
|
|
421
|
+
# Keep at least one byte beyond a full chunk before opening it as non-final.
|
|
422
|
+
while @buffer.bytesize - offset > sealed_size
|
|
423
|
+
open_chunk(@buffer.byteslice(offset, sealed_size), last: false)
|
|
424
|
+
offset += sealed_size
|
|
425
|
+
end
|
|
426
|
+
@buffer = @buffer.byteslice(offset..) if offset.positive?
|
|
427
|
+
self
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
# Opens the final chunk and returns Knf::Decrypted. Raises KnfError if the ciphertext is truncated.
|
|
431
|
+
def finish
|
|
432
|
+
raise KnfError, 'Decryptor already finished' if @finished
|
|
433
|
+
raise KnfError, 'Ciphertext truncated' if @header.nil? || @buffer.bytesize <= TAG_SIZE
|
|
434
|
+
|
|
435
|
+
open_chunk(@buffer, last: true)
|
|
436
|
+
@finished = true
|
|
437
|
+
@buffer = ''.b
|
|
438
|
+
stream = @plaintext
|
|
439
|
+
@plaintext = ''.b
|
|
440
|
+
Knf.send(:decode_stream, stream)
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
private
|
|
444
|
+
|
|
445
|
+
def header_ready?
|
|
446
|
+
return true if @header
|
|
447
|
+
return false if @buffer.bytesize < HEADER_SIZE
|
|
448
|
+
|
|
449
|
+
header = @buffer.byteslice(0, HEADER_SIZE)
|
|
450
|
+
@chunk_size, @nonce_prefix = Knf.send(:parse_header, header)
|
|
451
|
+
@header = header
|
|
452
|
+
@buffer = @buffer.byteslice(HEADER_SIZE..)
|
|
453
|
+
true
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
def open_chunk(sealed, last:)
|
|
457
|
+
nonce = Knf.send(:chunk_nonce, @nonce_prefix, @index, last)
|
|
458
|
+
@plaintext << Knf.send(:open_sealed, @key, nonce, @header, sealed)
|
|
459
|
+
@index += 1
|
|
460
|
+
rescue KnfError
|
|
461
|
+
# Any authentication failure is fatal: discard everything decrypted so far.
|
|
462
|
+
@plaintext = ''.b
|
|
463
|
+
@buffer = ''.b
|
|
464
|
+
@finished = true
|
|
465
|
+
raise
|
|
466
|
+
end
|
|
467
|
+
end
|
|
468
|
+
end
|
|
469
|
+
end
|
data/lib/konfidant/types.rb
CHANGED
|
@@ -1,24 +1,22 @@
|
|
|
1
1
|
module Konfidant
|
|
2
|
-
|
|
2
|
+
# Result of Client#share_text. +share_url+ carries the decryption key in its fragment — treat it as a secret.
|
|
3
|
+
TextShare = Data.define(:share_url, :text_id, :expires_at)
|
|
3
4
|
|
|
4
|
-
|
|
5
|
+
# Result of Client#share_file. +share_url+ carries the decryption key in its fragment — treat it as a secret.
|
|
6
|
+
FileShare = Data.define(:share_url, :file_id, :expires_at, :verified_burn)
|
|
5
7
|
|
|
6
|
-
|
|
8
|
+
# Result of Client#create_file_upload. +ciphertext_size+ is the size declared to the server (echoed client-side).
|
|
9
|
+
FileUpload = Data.define(:upload_url, :file_key, :upload_headers, :upload_expires_in, :ciphertext_size)
|
|
7
10
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
)
|
|
11
|
-
def initialize(status:, message: nil, file_id: nil, file_name: nil,
|
|
12
|
-
share_url: nil, expires_at: nil, verified_burn: nil)
|
|
13
|
-
super
|
|
14
|
-
end
|
|
15
|
-
end
|
|
11
|
+
# Result of Client#complete_file_upload. +download_url+ holds only the token (#t=…); append the key with
|
|
12
|
+
# Konfidant::Knf.build_share_url to obtain a usable share link.
|
|
13
|
+
CompletedUpload = Data.define(:download_url, :file_id, :expires_at, :verified_burn)
|
|
16
14
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
15
|
+
# Result of Client#open_share. +kind+ is "text" or "file"; +data+ is binary; +text+ is set for text shares only.
|
|
16
|
+
OpenedShare = Data.define(:kind, :name, :mime, :data, :text)
|
|
17
|
+
|
|
18
|
+
Share = Data.define(:type, :file_size_bytes, :created_at, :expires_at, :accessed_at, :created_by) do
|
|
19
|
+
def initialize(type:, created_at:, expires_at:, file_size_bytes: nil, accessed_at: nil, created_by: nil)
|
|
22
20
|
super
|
|
23
21
|
end
|
|
24
22
|
end
|
|
@@ -26,6 +24,4 @@ module Konfidant
|
|
|
26
24
|
Pagination = Data.define(:total, :limit, :offset, :has_more)
|
|
27
25
|
|
|
28
26
|
ListSharesResponse = Data.define(:shares, :pagination)
|
|
29
|
-
|
|
30
|
-
ShareResult = Data.define(:share_url, :file_id, :expires_at, :verified_burn)
|
|
31
27
|
end
|
data/lib/konfidant/version.rb
CHANGED
data/lib/konfidant.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: konfidant
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version:
|
|
4
|
+
version: 1.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Konfidant
|
|
@@ -57,14 +57,14 @@ dependencies:
|
|
|
57
57
|
requirements:
|
|
58
58
|
- - "~>"
|
|
59
59
|
- !ruby/object:Gem::Version
|
|
60
|
-
version: '0
|
|
60
|
+
version: '1.0'
|
|
61
61
|
type: :development
|
|
62
62
|
prerelease: false
|
|
63
63
|
version_requirements: !ruby/object:Gem::Requirement
|
|
64
64
|
requirements:
|
|
65
65
|
- - "~>"
|
|
66
66
|
- !ruby/object:Gem::Version
|
|
67
|
-
version: '0
|
|
67
|
+
version: '1.0'
|
|
68
68
|
- !ruby/object:Gem::Dependency
|
|
69
69
|
name: simplecov-lcov
|
|
70
70
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -89,6 +89,7 @@ files:
|
|
|
89
89
|
- lib/konfidant.rb
|
|
90
90
|
- lib/konfidant/client.rb
|
|
91
91
|
- lib/konfidant/errors.rb
|
|
92
|
+
- lib/konfidant/knf.rb
|
|
92
93
|
- lib/konfidant/types.rb
|
|
93
94
|
- lib/konfidant/version.rb
|
|
94
95
|
homepage: https://github.com/konfidant/sdk-ruby
|
|
@@ -109,7 +110,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
109
110
|
- !ruby/object:Gem::Version
|
|
110
111
|
version: '0'
|
|
111
112
|
requirements: []
|
|
112
|
-
rubygems_version: 4.0.
|
|
113
|
+
rubygems_version: 4.0.20
|
|
113
114
|
specification_version: 4
|
|
114
|
-
summary: Official Ruby SDK for the Konfidant API
|
|
115
|
+
summary: Official Ruby SDK for the Konfidant API (client-side, zero-knowledge encryption)
|
|
115
116
|
test_files: []
|