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