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
data/lib/konfidant/client.rb
CHANGED
|
@@ -6,6 +6,8 @@ module Konfidant
|
|
|
6
6
|
DEFAULT_BASE_URL = 'https://www.konfidant.app'
|
|
7
7
|
DEFAULT_TIMEOUT = 120
|
|
8
8
|
|
|
9
|
+
# Konfidant API client. All content is encrypted locally (KNF1) before it leaves this process; the key is only
|
|
10
|
+
# ever placed in the returned share link's URL fragment and is never sent to Konfidant.
|
|
9
11
|
class Client
|
|
10
12
|
def initialize(api_key:, base_url: nil, http_timeout: DEFAULT_TIMEOUT)
|
|
11
13
|
raise ArgumentError, 'api_key is required' if api_key.nil? || api_key.empty?
|
|
@@ -15,156 +17,198 @@ module Konfidant
|
|
|
15
17
|
@http_timeout = http_timeout
|
|
16
18
|
end
|
|
17
19
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
20
|
+
# Encrypts +text+ locally and shares it. Returns a TextShare whose +share_url+ contains the key.
|
|
21
|
+
def share_text(text:, ttl_hours: nil)
|
|
22
|
+
key = Knf.generate_key
|
|
23
|
+
ciphertext = Knf.encrypt_text(key: key, text: text)
|
|
24
|
+
payload = with_ttl({ ciphertext: [ciphertext].pack('m0') }, ttl_hours)
|
|
25
|
+
body = api_request(:post, '/api/v1/texts', payload)
|
|
26
|
+
|
|
27
|
+
TextShare.new(
|
|
28
|
+
share_url: Knf.build_share_url(body['download_url'], key),
|
|
29
|
+
text_id: body['text_id'],
|
|
30
|
+
expires_at: body['expires_at']
|
|
31
|
+
)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Encrypts a file locally, uploads the ciphertext and completes the share. Returns a FileShare whose
|
|
35
|
+
# +share_url+ contains the key.
|
|
36
|
+
#
|
|
37
|
+
# +content+ is a String of file bytes or a readable IO (File, StringIO, ...). IOs with a known size are
|
|
38
|
+
# streamed chunk by chunk. +filename+ and +content_type+ are encrypted together with the content.
|
|
39
|
+
def share_file(content:, filename:, content_type: '', ttl_hours: nil)
|
|
40
|
+
key = Knf.generate_key
|
|
41
|
+
encryptor = Knf.encryptor(key: key, content: content, kind: 'file', name: filename, mime: content_type)
|
|
42
|
+
upload = create_file_upload(ciphertext_size: encryptor.size, ttl_hours: ttl_hours)
|
|
43
|
+
upload_ciphertext(upload: upload, ciphertext: encryptor)
|
|
44
|
+
completed = complete_file_upload(file_key: upload.file_key)
|
|
45
|
+
|
|
46
|
+
FileShare.new(
|
|
47
|
+
share_url: Knf.build_share_url(completed.download_url, key),
|
|
48
|
+
file_id: completed.file_id,
|
|
49
|
+
expires_at: completed.expires_at,
|
|
50
|
+
verified_burn: completed.verified_burn
|
|
25
51
|
)
|
|
26
52
|
end
|
|
27
53
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
poll_url: body['poll_url']
|
|
54
|
+
# Low level, step 1: reserves an upload for exactly +ciphertext_size+ KNF1 bytes.
|
|
55
|
+
def create_file_upload(ciphertext_size:, ttl_hours: nil)
|
|
56
|
+
payload = with_ttl({ ciphertext_size: ciphertext_size }, ttl_hours)
|
|
57
|
+
body = api_request(:post, '/api/v1/files', payload)
|
|
58
|
+
|
|
59
|
+
FileUpload.new(
|
|
60
|
+
upload_url: body['upload_url'],
|
|
61
|
+
file_key: body['file_key'],
|
|
62
|
+
upload_headers: body['upload_headers'] || {},
|
|
63
|
+
upload_expires_in: body['upload_expires_in'],
|
|
64
|
+
ciphertext_size: ciphertext_size
|
|
40
65
|
)
|
|
41
66
|
end
|
|
42
67
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
68
|
+
# Low level, step 2: PUTs the KNF1 ciphertext (a binary String, or an IO-like object such as a Knf::Encryptor)
|
|
69
|
+
# to the upload URL with exactly the server-provided headers. The API key is never sent to the upload URL.
|
|
70
|
+
def upload_ciphertext(upload:, ciphertext:)
|
|
71
|
+
size = upload.ciphertext_size
|
|
72
|
+
actual = byte_size(ciphertext)
|
|
73
|
+
if actual && actual != size
|
|
74
|
+
raise ArgumentError, "ciphertext is #{actual} bytes but the upload was created for #{size} bytes"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
uri = URI.parse(upload.upload_url)
|
|
78
|
+
req = Net::HTTP::Put.new(uri)
|
|
79
|
+
upload.upload_headers.each { |name, value| req[name] = value.to_s }
|
|
80
|
+
# Only when the server did not specify one: Net::HTTP would otherwise send application/x-www-form-urlencoded.
|
|
81
|
+
req['Content-Type'] ||= 'application/octet-stream'
|
|
82
|
+
req['Content-Length'] = size.to_s
|
|
83
|
+
if ciphertext.is_a?(String)
|
|
84
|
+
req.body = ciphertext.b
|
|
85
|
+
else
|
|
86
|
+
req.body_stream = ciphertext
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
resp = perform(uri, req)
|
|
90
|
+
return nil if success?(resp)
|
|
91
|
+
|
|
92
|
+
raise ApiError.new("file upload failed: HTTP #{resp.code}", resp.code.to_i, resp.body)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Low level, step 3: finalizes the upload. Raises ApiError (409, "upload_incomplete") if the PUT did not land.
|
|
96
|
+
def complete_file_upload(file_key:)
|
|
97
|
+
body = api_request(:post, "/api/v1/files/#{encode_path_segment(file_key)}/complete")
|
|
98
|
+
|
|
99
|
+
CompletedUpload.new(
|
|
100
|
+
download_url: body['download_url'],
|
|
49
101
|
file_id: body['file_id'],
|
|
50
|
-
file_name: body['file_name'],
|
|
51
|
-
share_url: body['share_url'],
|
|
52
102
|
expires_at: body['expires_at'],
|
|
53
103
|
verified_burn: body['verified_burn']
|
|
54
104
|
)
|
|
55
105
|
end
|
|
56
106
|
|
|
57
107
|
def list_shares(type: nil, status: nil, limit: nil, offset: nil)
|
|
58
|
-
params = {}
|
|
59
|
-
params[:type] = type if type
|
|
60
|
-
params[:status] = status if status
|
|
61
|
-
params[:limit] = limit if limit
|
|
62
|
-
params[:offset] = offset if offset
|
|
108
|
+
params = { type: type, status: status, limit: limit, offset: offset }.compact
|
|
63
109
|
|
|
64
110
|
path = '/api/v1/shares'
|
|
65
111
|
path += "?#{URI.encode_www_form(params)}" unless params.empty?
|
|
66
112
|
|
|
67
|
-
body =
|
|
113
|
+
body = api_request(:get, path)
|
|
68
114
|
ListSharesResponse.new(
|
|
69
115
|
shares: body['shares'].map { |s| parse_share(s) },
|
|
70
116
|
pagination: parse_pagination(body['pagination'])
|
|
71
117
|
)
|
|
72
118
|
end
|
|
73
119
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
req
|
|
81
|
-
req['
|
|
82
|
-
req['
|
|
83
|
-
req
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
120
|
+
# Recipient side: fetches the ciphertext behind +share_url+ (consuming its single-use token) and decrypts it
|
|
121
|
+
# locally. Only the token is sent; the key and the API key are not. Raises ApiError (410) if the share was
|
|
122
|
+
# already opened or has expired, KnfError if decryption fails.
|
|
123
|
+
def open_share(share_url)
|
|
124
|
+
origin, token, key = Knf.parse_share_url(share_url)
|
|
125
|
+
uri = URI.parse("#{origin}/api/download")
|
|
126
|
+
req = Net::HTTP::Post.new(uri)
|
|
127
|
+
req['Content-Type'] = 'application/json'
|
|
128
|
+
req['Accept'] = 'application/octet-stream'
|
|
129
|
+
req.body = JSON.generate({ t: token })
|
|
130
|
+
|
|
131
|
+
ciphertext = handle_response(perform(uri, req))
|
|
132
|
+
raise KnfError, 'Unexpected download response' unless ciphertext.is_a?(String)
|
|
133
|
+
|
|
134
|
+
decrypted = Knf.decrypt(key: key, ciphertext: ciphertext.b)
|
|
135
|
+
OpenedShare.new(
|
|
136
|
+
kind: decrypted.kind,
|
|
137
|
+
name: decrypted.name,
|
|
138
|
+
mime: decrypted.mime,
|
|
139
|
+
data: decrypted.data,
|
|
140
|
+
text: decrypted.kind == 'text' ? decrypted.text : nil
|
|
141
|
+
)
|
|
90
142
|
end
|
|
91
143
|
|
|
92
|
-
|
|
93
|
-
poll_interval: 2, timeout: 60)
|
|
94
|
-
presigned = share_file(filename: filename, file_size: size, ttl_hours: ttl_hours)
|
|
95
|
-
upload_file(io: io, size: size, content_type: content_type, presigned: presigned)
|
|
96
|
-
|
|
97
|
-
deadline = Time.now + timeout
|
|
98
|
-
while Time.now < deadline
|
|
99
|
-
status = get_file_status(presigned.file_key)
|
|
100
|
-
if status.status == 'complete'
|
|
101
|
-
return ShareResult.new(
|
|
102
|
-
share_url: status.share_url,
|
|
103
|
-
file_id: status.file_id,
|
|
104
|
-
expires_at: status.expires_at,
|
|
105
|
-
verified_burn: status.verified_burn
|
|
106
|
-
)
|
|
107
|
-
end
|
|
108
|
-
sleep(poll_interval)
|
|
109
|
-
end
|
|
144
|
+
private
|
|
110
145
|
|
|
111
|
-
|
|
146
|
+
def with_ttl(payload, ttl_hours)
|
|
147
|
+
ttl_hours.nil? ? payload : payload.merge(ttl_hours: ttl_hours)
|
|
112
148
|
end
|
|
113
149
|
|
|
114
|
-
|
|
150
|
+
def byte_size(ciphertext)
|
|
151
|
+
return ciphertext.bytesize if ciphertext.is_a?(String)
|
|
115
152
|
|
|
116
|
-
|
|
117
|
-
{
|
|
118
|
-
'Authorization' => "Bearer #{@api_key}",
|
|
119
|
-
'Content-Type' => 'application/json'
|
|
120
|
-
}
|
|
153
|
+
ciphertext.size if ciphertext.respond_to?(:size)
|
|
121
154
|
end
|
|
122
155
|
|
|
123
156
|
def build_http(uri)
|
|
124
157
|
http = Net::HTTP.new(uri.host, uri.port)
|
|
125
158
|
http.use_ssl = uri.scheme == 'https'
|
|
126
159
|
unless @http_timeout.nil?
|
|
127
|
-
http.open_timeout
|
|
128
|
-
http.read_timeout
|
|
160
|
+
http.open_timeout = @http_timeout
|
|
161
|
+
http.read_timeout = @http_timeout
|
|
162
|
+
http.write_timeout = @http_timeout
|
|
129
163
|
end
|
|
130
164
|
http
|
|
131
165
|
end
|
|
132
166
|
|
|
133
|
-
def
|
|
134
|
-
uri
|
|
135
|
-
|
|
167
|
+
def perform(uri, req)
|
|
168
|
+
build_http(uri).request(req)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def success?(resp)
|
|
172
|
+
resp.code.to_i.between?(200, 299)
|
|
173
|
+
end
|
|
136
174
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
175
|
+
def api_request(method, path, payload = nil)
|
|
176
|
+
uri = URI.parse("#{@base_url}#{path}")
|
|
177
|
+
req = method == :get ? Net::HTTP::Get.new(uri) : Net::HTTP::Post.new(uri)
|
|
178
|
+
req['Authorization'] = "Bearer #{@api_key}"
|
|
179
|
+
if payload
|
|
180
|
+
req['Content-Type'] = 'application/json'
|
|
181
|
+
req.body = JSON.generate(payload)
|
|
182
|
+
elsif method == :post
|
|
183
|
+
req.body = ''
|
|
184
|
+
end
|
|
141
185
|
|
|
142
|
-
|
|
143
|
-
|
|
186
|
+
handle_response(perform(uri, req))
|
|
187
|
+
end
|
|
144
188
|
|
|
145
|
-
|
|
189
|
+
def handle_response(resp)
|
|
146
190
|
content_type = resp['content-type'] || ''
|
|
191
|
+
body = content_type.include?('application/json') ? parse_json(resp.body) : resp.body
|
|
147
192
|
|
|
148
|
-
body
|
|
149
|
-
JSON.parse(resp.body)
|
|
150
|
-
else
|
|
151
|
-
resp.body
|
|
152
|
-
end
|
|
153
|
-
|
|
154
|
-
return body if resp.code.to_i.between?(200, 299)
|
|
193
|
+
return body if success?(resp)
|
|
155
194
|
|
|
156
195
|
message = body.is_a?(Hash) && body['error'] ? body['error'] : "HTTP #{resp.code}"
|
|
157
196
|
raise ApiError.new(message, resp.code.to_i, body)
|
|
158
197
|
end
|
|
159
198
|
|
|
199
|
+
def parse_json(raw)
|
|
200
|
+
JSON.parse(raw.to_s)
|
|
201
|
+
rescue JSON::ParserError
|
|
202
|
+
raw
|
|
203
|
+
end
|
|
204
|
+
|
|
160
205
|
def encode_path_segment(str)
|
|
161
|
-
str.gsub(/[^A-Za-z0-9\-._~]/) { |c| c.bytes.map { |b| format('%%%02X', b) }.join }
|
|
206
|
+
str.to_s.gsub(/[^A-Za-z0-9\-._~]/) { |c| c.bytes.map { |b| format('%%%02X', b) }.join }
|
|
162
207
|
end
|
|
163
208
|
|
|
164
209
|
def parse_share(s)
|
|
165
210
|
Share.new(
|
|
166
211
|
type: s['type'],
|
|
167
|
-
file_name: s['file_name'],
|
|
168
212
|
file_size_bytes: s['file_size_bytes'],
|
|
169
213
|
created_at: s['created_at'],
|
|
170
214
|
expires_at: s['expires_at'],
|
data/lib/konfidant/errors.rb
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
module Konfidant
|
|
2
|
-
class
|
|
2
|
+
# Base class for every error raised by this SDK.
|
|
3
|
+
class Error < StandardError; end
|
|
4
|
+
|
|
5
|
+
# Raised for non-2xx responses from the Konfidant API, the upload URL or the download endpoint.
|
|
6
|
+
class ApiError < Error
|
|
3
7
|
attr_reader :status_code, :body
|
|
4
8
|
|
|
5
9
|
def initialize(message, status_code, body)
|
|
@@ -8,4 +12,7 @@ module Konfidant
|
|
|
8
12
|
@body = body
|
|
9
13
|
end
|
|
10
14
|
end
|
|
15
|
+
|
|
16
|
+
# Raised when KNF1 encryption or decryption fails: invalid input, wrong key, or modified/truncated ciphertext.
|
|
17
|
+
class KnfError < Error; end
|
|
11
18
|
end
|