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.
@@ -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
- def share_text(text:, ttl_hours:)
19
- body = request(:post, '/api/v1/texts', { text: text, ttl_hours: ttl_hours })
20
- ShareTextResponse.new(
21
- text_id: body['text_id'],
22
- share_url: body['share_url'],
23
- expires_at: body['expires_at'],
24
- verified_burn: body['verified_burn']
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
- def share_file(filename:, file_size:, ttl_hours:)
29
- body = request(:post, '/api/v1/files', { filename: filename, file_size: file_size, ttl_hours: ttl_hours })
30
- h = body['metadata_headers']
31
- ShareFileResponse.new(
32
- upload_url: body['upload_url'],
33
- file_key: body['file_key'],
34
- metadata_headers: FileMetadataHeaders.new(
35
- user_id: h['x-amz-meta-user-id'],
36
- ttl_hours: h['x-amz-meta-ttl-hours'],
37
- organization_id: h['x-amz-meta-organization-id']
38
- ),
39
- poll_url: body['poll_url']
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
- def get_file_status(file_key)
44
- encoded = encode_path_segment(file_key)
45
- body = request(:get, "/api/v1/files/#{encoded}/status")
46
- FileStatusResponse.new(
47
- status: body['status'],
48
- message: body['message'],
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 = request(:get, path)
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
- def upload_file(io:, size:, content_type:, presigned:)
75
- uri = URI.parse(presigned.upload_url)
76
- http = build_http(uri)
77
-
78
- req = Net::HTTP::Put.new(uri.request_uri)
79
- req['Content-Type'] = content_type
80
- req['Content-Length'] = size.to_s
81
- req['x-amz-meta-organization-id'] = presigned.metadata_headers.organization_id
82
- req['x-amz-meta-ttl-hours'] = presigned.metadata_headers.ttl_hours
83
- req['x-amz-meta-user-id'] = presigned.metadata_headers.user_id
84
- req.body_stream = io
85
-
86
- resp = http.request(req)
87
- return if resp.code.to_i.between?(200, 299)
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
- raise ApiError.new("file upload failed: HTTP #{resp.code}", resp.code.to_i, resp.body)
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
- def share_and_upload_file(io:, size:, filename:, content_type:, ttl_hours:,
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
- raise "konfidant: encryption timed out after #{timeout}s"
172
+ def with_ttl(payload, ttl_hours)
173
+ ttl_hours.nil? ? payload : payload.merge(ttl_hours: ttl_hours)
112
174
  end
113
175
 
114
- private
176
+ def byte_size(ciphertext)
177
+ return ciphertext.bytesize if ciphertext.is_a?(String)
115
178
 
116
- def auth_headers
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 = @http_timeout
128
- http.read_timeout = @http_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 request(method, path, payload = nil)
134
- uri = URI.parse("#{@base_url}#{path}")
135
- http = build_http(uri)
193
+ def perform(uri, req)
194
+ build_http(uri).request(req)
195
+ end
136
196
 
137
- req = case method
138
- when :get then Net::HTTP::Get.new(uri.request_uri)
139
- when :post then Net::HTTP::Post.new(uri.request_uri)
140
- end
197
+ def success?(resp)
198
+ resp.code.to_i.between?(200, 299)
199
+ end
141
200
 
142
- auth_headers.each { |k, v| req[k] = v }
143
- req.body = JSON.generate(payload) if payload
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
- resp = http.request(req)
146
- content_type = resp['content-type'] || ''
212
+ handle_response(perform(uri, req))
213
+ end
147
214
 
148
- body = if content_type.include?('application/json')
149
- JSON.parse(resp.body)
150
- else
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 resp.code.to_i.between?(200, 299)
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'],
@@ -1,5 +1,9 @@
1
1
  module Konfidant
2
- class ApiError < StandardError
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