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.
@@ -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
- 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']
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
- 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']
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
- 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'],
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 = request(:get, path)
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
- 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)
88
-
89
- raise ApiError.new("file upload failed: HTTP #{resp.code}", resp.code.to_i, resp.body)
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
- 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
144
+ private
110
145
 
111
- raise "konfidant: encryption timed out after #{timeout}s"
146
+ def with_ttl(payload, ttl_hours)
147
+ ttl_hours.nil? ? payload : payload.merge(ttl_hours: ttl_hours)
112
148
  end
113
149
 
114
- private
150
+ def byte_size(ciphertext)
151
+ return ciphertext.bytesize if ciphertext.is_a?(String)
115
152
 
116
- def auth_headers
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 = @http_timeout
128
- http.read_timeout = @http_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 request(method, path, payload = nil)
134
- uri = URI.parse("#{@base_url}#{path}")
135
- http = build_http(uri)
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
- 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
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
- auth_headers.each { |k, v| req[k] = v }
143
- req.body = JSON.generate(payload) if payload
186
+ handle_response(perform(uri, req))
187
+ end
144
188
 
145
- resp = http.request(req)
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 = if content_type.include?('application/json')
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'],
@@ -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