keeper_secrets_manager 17.0.4 → 17.2.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.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +100 -16
  3. data/Gemfile +6 -3
  4. data/README.md +75 -270
  5. data/Rakefile +1 -1
  6. data/bin/console +47 -0
  7. data/keeper_secrets_manager.gemspec +36 -0
  8. data/lib/keeper_secrets_manager/cache.rb +139 -0
  9. data/lib/keeper_secrets_manager/config_keys.rb +4 -2
  10. data/lib/keeper_secrets_manager/core.rb +993 -452
  11. data/lib/keeper_secrets_manager/crypto.rb +101 -116
  12. data/lib/keeper_secrets_manager/dto/payload.rb +7 -6
  13. data/lib/keeper_secrets_manager/dto.rb +374 -38
  14. data/lib/keeper_secrets_manager/errors.rb +13 -2
  15. data/lib/keeper_secrets_manager/field_types.rb +3 -3
  16. data/lib/keeper_secrets_manager/folder_manager.rb +25 -29
  17. data/lib/keeper_secrets_manager/keeper_globals.rb +11 -17
  18. data/lib/keeper_secrets_manager/notation.rb +202 -93
  19. data/lib/keeper_secrets_manager/notation_enhancements.rb +24 -24
  20. data/lib/keeper_secrets_manager/storage.rb +38 -38
  21. data/lib/keeper_secrets_manager/totp.rb +27 -27
  22. data/lib/keeper_secrets_manager/utils.rb +85 -18
  23. data/lib/keeper_secrets_manager/version.rb +2 -2
  24. data/lib/keeper_secrets_manager.rb +11 -3
  25. metadata +39 -22
  26. data/DEVELOPER_SETUP.md +0 -0
  27. data/MANUAL_TESTING_GUIDE.md +0 -332
  28. data/RUBY_SDK_COMPLETE_DOCUMENTATION.md +0 -354
  29. data/RUBY_SDK_COMPREHENSIVE_SUMMARY.md +0 -192
  30. data/examples/01_quick_start.rb +0 -45
  31. data/examples/02_authentication.rb +0 -82
  32. data/examples/03_retrieve_secrets.rb +0 -81
  33. data/examples/04_create_update_delete.rb +0 -104
  34. data/examples/05_field_types.rb +0 -135
  35. data/examples/06_files.rb +0 -137
  36. data/examples/07_folders.rb +0 -145
  37. data/examples/08_notation.rb +0 -103
  38. data/examples/09_totp.rb +0 -100
  39. data/examples/README.md +0 -89
@@ -8,29 +8,36 @@ module KeeperSecretsManager
8
8
  module Core
9
9
  class SecretsManager
10
10
  attr_reader :config, :hostname, :verify_ssl_certs
11
-
11
+
12
12
  NOTATION_PREFIX = 'keeper'.freeze
13
13
  DEFAULT_KEY_ID = '7'.freeze
14
-
14
+
15
+ # Throttle retry (KSM-876 / KSM-883). The backend throttles HTTP 403 {"error":"throttled"}
16
+ # per clientId+endpoint (100 requests / 10s window; memcached TTL 10s that resets on every
17
+ # request, so the counter only clears after 10s of silence).
18
+ MAX_THROTTLE_RETRIES = 5
19
+ BASE_THROTTLE_DELAY_SEC = 11 # 1s safety margin over the backend's 10s memcached TTL
20
+ MAX_THROTTLE_DELAY_SEC = 176 # cap on server-supplied retry_after (BASE * 2**4)
21
+
15
22
  # Field types that can be inflated
16
23
  INFLATE_REF_TYPES = {
17
24
  'addressRef' => ['address'],
18
- 'cardRef' => ['paymentCard', 'text', 'pinCode', 'addressRef']
25
+ 'cardRef' => %w[paymentCard text pinCode addressRef]
19
26
  }.freeze
20
27
 
21
28
  def initialize(options = {})
22
29
  # Check Ruby version
23
- if RUBY_VERSION < '2.6'
24
- raise Error, 'KSM SDK requires Ruby 2.6 or greater'
25
- end
26
-
30
+ raise Error, 'KSM SDK requires Ruby 3.1 or greater' if RUBY_VERSION < '3.1'
31
+
27
32
  # Check AES-GCM support
28
33
  begin
29
34
  OpenSSL::Cipher.new('AES-256-GCM')
30
35
  rescue RuntimeError => e
31
36
  if e.message.include?('unsupported cipher')
32
- raise Error, "KSM SDK requires AES-GCM support. Your Ruby/OpenSSL version (#{OpenSSL::OPENSSL_LIBRARY_VERSION}) does not support AES-256-GCM. Please upgrade to Ruby 2.7+ or use a Ruby compiled with OpenSSL 1.1.0+"
37
+ raise Error,
38
+ "KSM SDK requires AES-GCM support. Your Ruby/OpenSSL version (#{OpenSSL::OPENSSL_LIBRARY_VERSION}) does not support AES-256-GCM. Please upgrade to Ruby 2.7+ or use a Ruby compiled with OpenSSL 1.1.0+"
33
39
  end
40
+
34
41
  raise e
35
42
  end
36
43
 
@@ -38,43 +45,77 @@ module KeeperSecretsManager
38
45
  @hostname = nil
39
46
  @verify_ssl_certs = options.fetch(:verify_ssl_certs, true)
40
47
  @custom_post_function = options[:custom_post_function]
41
-
48
+
49
+ # optional custom server public key overrides (isolated deployments).
50
+ # Precedence, highest first: these programmatic params > OTT segments > pre-existing config.
51
+ @server_public_key_override = options[:server_public_key]
52
+ @server_public_key_id_override = options[:server_public_key_id]
53
+
54
+ # Set up proxy configuration
55
+ # Priority: explicit proxy_url parameter > HTTPS_PROXY env var > no proxy
56
+ @proxy_url = options[:proxy_url] || ENV['HTTPS_PROXY'] || ENV['https_proxy']
57
+
58
+ if @proxy_url
59
+ begin
60
+ proxy_uri = URI.parse(@proxy_url)
61
+ unless proxy_uri.is_a?(URI::HTTP) && !proxy_uri.host.to_s.empty?
62
+ raise ArgumentError,
63
+ "Invalid proxy_url '#{@proxy_url}': must be a valid http or https URL with a host (e.g., http://proxy.example.com:8080)"
64
+ end
65
+ rescue URI::InvalidURIError => e
66
+ raise ArgumentError, "Invalid proxy_url '#{@proxy_url}': #{e.message}"
67
+ end
68
+ end
69
+
42
70
  # Set up logging
43
71
  @logger = options[:logger] || Logger.new(STDOUT)
44
72
  @logger.level = options[:log_level] || Logger::WARN
45
73
 
74
+ @logger.debug("Proxy configuration: #{@proxy_url ? @proxy_url : 'none'}") if @proxy_url
75
+
46
76
  # Handle configuration
47
77
  config = options[:config]
48
78
  token = options[:token]
49
-
79
+
50
80
  # Check environment variable if no config provided
51
- if config.nil? && ENV['KSM_CONFIG']
52
- config = Storage::InMemoryStorage.new(ENV['KSM_CONFIG'])
53
- end
54
-
81
+ config = Storage::InMemoryStorage.new(ENV['KSM_CONFIG']) if config.nil? && ENV['KSM_CONFIG']
82
+
55
83
  # If we have config, check if it's already initialized
56
84
  if config
57
85
  @config = config
58
86
  # Check if already bound (has client ID and app key)
59
87
  if @config.get_string(ConfigKeys::KEY_CLIENT_ID) && @config.get_bytes(ConfigKeys::KEY_APP_KEY)
60
- @logger.debug("Using existing credentials from config")
88
+ @logger.debug('Using existing credentials from config')
61
89
  elsif token
62
90
  # Config exists but not bound, use token to bind
63
- @logger.debug("Config provided but not bound, using token to initialize")
91
+ @logger.debug('Config provided but not bound, using token to initialize')
64
92
  process_token_binding(token, options[:hostname])
65
93
  else
66
- @logger.warn("Config provided but no credentials found and no token provided")
94
+ @logger.warn('Config provided but no credentials found and no token provided')
67
95
  end
68
96
  elsif token
69
97
  # No config provided, create new one with token
70
- @logger.debug("No config provided, creating new one with token")
98
+ @logger.debug('No config provided, creating new one with token')
71
99
  process_token_binding(token, options[:hostname])
72
100
  @config ||= Storage::InMemoryStorage.new
73
101
  else
74
102
  # No config and no token
75
- raise Errors::KeeperError.new("Either token or initialized config must be provided")
103
+ raise Error, 'Either token or initialized config must be provided'
104
+ end
105
+
106
+ # programmatic custom key wins over token/config (applied once @config is set).
107
+ @config.save_string(ConfigKeys::KEY_SERVER_PUBLIC_KEY, @server_public_key_override) if @server_public_key_override && !@server_public_key_override.empty?
108
+ @config.save_string(ConfigKeys::KEY_SERVER_PUBLIC_KEY_ID, @server_public_key_id_override.to_s) if @server_public_key_id_override
109
+
110
+ # if the configured key id is not in the built-in table AND no custom key backs it,
111
+ # fall back to the default (mirrors Python). A persisted custom serverPublicKey is preserved.
112
+ current_key_id = @config.get_string(ConfigKeys::KEY_SERVER_PUBLIC_KEY_ID)
113
+ custom_server_key = @config.get_string(ConfigKeys::KEY_SERVER_PUBLIC_KEY)
114
+ if current_key_id && !KeeperGlobals::KEEPER_PUBLIC_KEYS.key?(current_key_id) && (custom_server_key.nil? || custom_server_key.empty?)
115
+ @logger.debug("Public key id #{current_key_id} unknown and no custom key present; using default #{DEFAULT_KEY_ID}")
116
+ @config.save_string(ConfigKeys::KEY_SERVER_PUBLIC_KEY_ID, DEFAULT_KEY_ID)
76
117
  end
77
-
118
+
78
119
  # Override hostname if provided
79
120
  if options[:hostname]
80
121
  @hostname = options[:hostname]
@@ -82,39 +123,34 @@ module KeeperSecretsManager
82
123
  else
83
124
  @hostname = @config.get_string(ConfigKeys::KEY_HOSTNAME) || KeeperGlobals::DEFAULT_SERVER
84
125
  end
85
-
86
- # Cache configuration
87
- @cache = {}
88
- @cache_expiry = {}
126
+
89
127
  end
90
128
 
91
129
  # Get secrets with optional filtering
92
- def get_secrets(uids = nil, full_response: false)
130
+ def get_secrets(uids = nil, full_response: false, request_links: false)
93
131
  uids = [uids] if uids.is_a?(String)
94
-
95
- query_options = Dto::QueryOptions.new(records: uids, folders: nil)
132
+
133
+ query_options = Dto::QueryOptions.new(records: uids, folders: nil, request_links: request_links)
96
134
  get_secrets_with_options(query_options, full_response: full_response)
97
135
  end
98
136
 
99
137
  # Get secrets with query options
100
138
  def get_secrets_with_options(query_options = nil, full_response: false)
101
139
  records_resp = fetch_and_decrypt_secrets(query_options)
102
-
140
+
103
141
  # If just bound, fetch again
104
- if records_resp.just_bound
105
- records_resp = fetch_and_decrypt_secrets(query_options)
106
- end
107
-
142
+ records_resp = fetch_and_decrypt_secrets(query_options) if records_resp.just_bound
143
+
108
144
  # Log warnings
109
145
  records_resp.warnings&.each { |warning| @logger.warn(warning) }
110
-
146
+
111
147
  # Log bad records/folders
112
148
  if records_resp.errors&.any?
113
149
  records_resp.errors.each do |error|
114
150
  @logger.error("Error: #{error}")
115
151
  end
116
152
  end
117
-
153
+
118
154
  full_response ? records_resp : (records_resp.records || [])
119
155
  end
120
156
 
@@ -122,19 +158,19 @@ module KeeperSecretsManager
122
158
  def get_folders
123
159
  fetch_and_decrypt_folders
124
160
  end
125
-
161
+
126
162
  # Fetch and decrypt folders from dedicated endpoint
127
163
  def fetch_and_decrypt_folders
128
164
  # Prepare payload for get_folders endpoint (no filters)
129
165
  payload = prepare_get_payload(nil)
130
-
166
+
131
167
  # Make request to get_folders endpoint
132
168
  response_json = post_query('get_folders', payload)
133
169
  response_dict = JSON.parse(response_json)
134
-
170
+
135
171
  # Get app key for decryption
136
172
  app_key_str = @config.get_string(ConfigKeys::KEY_APP_KEY)
137
-
173
+
138
174
  # If we have app key directly (one-time token binding), use it
139
175
  if app_key_str && !app_key_str.empty?
140
176
  app_key = Utils.base64_to_bytes(app_key_str)
@@ -144,56 +180,54 @@ module KeeperSecretsManager
144
180
  client_key = Utils.base64_to_bytes(@config.get_string(ConfigKeys::KEY_CLIENT_KEY))
145
181
  app_key = Crypto.decrypt_aes_gcm(app_key_encrypted, client_key)
146
182
  end
147
-
183
+
148
184
  # Decrypt folders - need to handle them in order for shared folder keys
149
185
  folders = []
150
186
  response_folders = response_dict['folders'] || []
151
-
187
+
152
188
  response_folders.each do |encrypted_folder|
153
- begin
154
- folder_uid = encrypted_folder['folderUid']
155
- folder_parent = encrypted_folder['parent']
156
-
157
- # Decrypt folder key based on whether it has a parent
158
- if !folder_parent || folder_parent.empty?
159
- # Root folder - decrypt with app key
160
- folder_key_encrypted = Utils.base64_to_bytes(encrypted_folder['folderKey'])
161
- folder_key = Crypto.decrypt_aes_gcm(folder_key_encrypted, app_key)
162
- else
163
- # Child folder - decrypt with parent's shared folder key
164
- shared_folder_key = get_shared_folder_key(folders, response_folders, folder_parent)
165
- unless shared_folder_key
166
- @logger.error("Cannot find shared folder key for parent #{folder_parent}")
167
- next
168
- end
169
- folder_key_encrypted = Utils.base64_to_bytes(encrypted_folder['folderKey'])
170
- folder_key = Crypto.decrypt_aes_cbc(folder_key_encrypted, shared_folder_key)
171
- end
172
-
173
- # Decrypt folder data if present
174
- folder_name = ''
175
- if encrypted_folder['data'] && !encrypted_folder['data'].empty?
176
- data_encrypted = Utils.base64_to_bytes(encrypted_folder['data'])
177
- data_json = Crypto.decrypt_aes_cbc(data_encrypted, folder_key)
178
- data = JSON.parse(data_json)
179
- folder_name = data['name'] || ''
189
+ folder_uid = encrypted_folder['folderUid']
190
+ folder_parent = encrypted_folder['parent']
191
+
192
+ # Decrypt folder key based on whether it has a parent
193
+ if !folder_parent || folder_parent.empty?
194
+ # Root folder - decrypt with app key
195
+ folder_key_encrypted = Utils.base64_to_bytes(encrypted_folder['folderKey'])
196
+ folder_key = Crypto.decrypt_aes_gcm(folder_key_encrypted, app_key)
197
+ else
198
+ # Child folder - decrypt with parent's shared folder key
199
+ shared_folder_key = get_shared_folder_key(folders, response_folders, folder_parent)
200
+ unless shared_folder_key
201
+ @logger.error("Cannot find shared folder key for parent #{folder_parent}")
202
+ next
180
203
  end
181
-
182
- # Create folder object
183
- folder = Dto::KeeperFolder.new(
184
- 'folderUid' => folder_uid,
185
- 'name' => folder_name,
186
- 'folderKey' => folder_key,
187
- 'parent' => folder_parent,
188
- 'records' => []
189
- )
190
-
191
- folders << folder
192
- rescue => e
193
- @logger.error("Failed to decrypt folder #{encrypted_folder['folderUid']}: #{e.message}")
204
+ folder_key_encrypted = Utils.base64_to_bytes(encrypted_folder['folderKey'])
205
+ folder_key = Crypto.decrypt_aes_cbc(folder_key_encrypted, shared_folder_key)
194
206
  end
207
+
208
+ # Decrypt folder data if present
209
+ folder_name = ''
210
+ if encrypted_folder['data'] && !encrypted_folder['data'].empty?
211
+ data_encrypted = Utils.base64_to_bytes(encrypted_folder['data'])
212
+ data_json = Crypto.decrypt_aes_cbc(data_encrypted, folder_key)
213
+ data = JSON.parse(data_json)
214
+ folder_name = data['name'] || ''
215
+ end
216
+
217
+ # Create folder object
218
+ folder = Dto::KeeperFolder.new(
219
+ 'folderUid' => folder_uid,
220
+ 'name' => folder_name,
221
+ 'folderKey' => folder_key,
222
+ 'parent' => folder_parent,
223
+ 'records' => []
224
+ )
225
+
226
+ folders << folder
227
+ rescue StandardError => e
228
+ @logger.error("Failed to decrypt folder #{encrypted_folder['folderUid']}: #{e.message}")
195
229
  end
196
-
230
+
197
231
  folders
198
232
  end
199
233
 
@@ -208,59 +242,50 @@ module KeeperSecretsManager
208
242
  get_secrets_by_title(title).first
209
243
  end
210
244
 
211
- # Create a new secret
212
- def create_secret(record_data, options = nil)
213
- options ||= Dto::CreateOptions.new
214
-
215
- # Validate folder UID is provided
216
- raise ArgumentError, "folder_uid is required to create a record" unless options.folder_uid
217
-
218
- # Get folders from dedicated endpoint to find folder key
219
- folders = get_folders
220
-
221
- # Find the folder
222
- folder = folders.find { |f| f.uid == options.folder_uid }
223
- raise Error, "Folder #{options.folder_uid} not found or not accessible" unless folder
224
-
225
- # Get folder key
245
+ def create_secret_with_options(create_options, record_data, folders: nil)
246
+ raise ArgumentError, 'folder_uid is required to create a record' unless create_options&.folder_uid
247
+
248
+ folders ||= get_folders
249
+
250
+ folder = folders.find { |f| f.uid == create_options.folder_uid }
251
+ raise Error, "Folder #{create_options.folder_uid} not found or not accessible" unless folder
252
+
226
253
  folder_key = folder.folder_key
227
- raise Error, "Unable to create record - folder key for #{options.folder_uid} is missing" unless folder_key
228
-
229
- # Generate UIDs and keys
254
+ raise Error, "Unable to create record - folder key for #{create_options.folder_uid} is missing" unless folder_key
255
+
230
256
  record_uid = Utils.generate_uid
231
257
  record_key = Crypto.generate_encryption_key_bytes
232
-
233
- # Prepare record data
234
- record = if record_data.is_a?(Dto::KeeperRecord)
235
- record_data.to_h
236
- else
237
- record_data
238
- end
239
-
240
- # Encrypt record data
241
- encrypted_data = Crypto.encrypt_aes_gcm(
242
- Utils.dict_to_json(record),
243
- record_key
244
- )
245
-
246
- # Prepare payload
258
+
259
+ record = record_data.is_a?(Dto::KeeperRecord) ? record_data.to_h : record_data
260
+
261
+ encrypted_data = Crypto.encrypt_aes_gcm(Utils.dict_to_json(record), record_key)
262
+
247
263
  payload = prepare_create_payload(
248
- record_uid: record_uid,
249
- record_key: record_key,
250
- folder_uid: options.folder_uid,
251
- folder_key: folder_key,
252
- data: encrypted_data
264
+ record_uid: record_uid,
265
+ record_key: record_key,
266
+ folder_uid: create_options.folder_uid,
267
+ folder_key: folder_key,
268
+ data: encrypted_data,
269
+ subfolder_uid: create_options.subfolder_uid
253
270
  )
254
-
255
- # Send request
256
- response = post_query('create_secret', payload)
257
-
258
- # Return created record UID
271
+
272
+ post_query('create_secret', payload)
259
273
  record_uid
260
274
  end
261
275
 
262
- # Update existing secret
263
- def update_secret(record, transaction_type: 'general')
276
+ def create_secret(record_data, options = nil)
277
+ create_options = if options.is_a?(Dto::CreateOptions)
278
+ options
279
+ elsif options.is_a?(String)
280
+ Dto::CreateOptions.new(folder_uid: options)
281
+ else
282
+ Dto::CreateOptions.new
283
+ end
284
+ create_secret_with_options(create_options, record_data)
285
+ end
286
+
287
+ # Update existing secret with UpdateOptions
288
+ def update_secret_with_options(record, update_options = nil)
264
289
  # Handle both record object and hash
265
290
  if record.is_a?(Dto::KeeperRecord)
266
291
  record_uid = record.uid
@@ -269,46 +294,154 @@ module KeeperSecretsManager
269
294
  record_uid = record['uid'] || record[:uid]
270
295
  record_data = record
271
296
  end
272
-
297
+
273
298
  raise ArgumentError, 'Record UID is required' unless record_uid
274
-
299
+
275
300
  # Get existing record to get the key
276
301
  existing = get_secrets([record_uid]).first
277
302
  raise RecordNotFoundError, "Record #{record_uid} not found" unless existing
278
-
279
- # Prepare payload
303
+
304
+ # Get record key for encryption
305
+ record_key = existing.record_key
306
+ raise Error, "Record key not available for #{record_uid}" unless record_key
307
+
308
+ # Prepare payload (handles UpdateOptions internally)
280
309
  payload = prepare_update_payload(
281
310
  record_uid: record_uid,
282
- data: record_data,
311
+ record_data: record_data,
312
+ record_key: record_key,
283
313
  revision: existing.revision,
284
- transaction_type: transaction_type
314
+ update_options: update_options
285
315
  )
286
-
316
+
287
317
  # Send request
288
318
  post_query('update_secret', payload)
289
-
290
- # If rotation, complete transaction
291
- if transaction_type == 'rotation'
292
- complete_payload = Dto::CompleteTransactionPayload.new
293
- complete_payload.client_version = KeeperGlobals.client_version
294
- complete_payload.client_id = @config.get_string(ConfigKeys::KEY_CLIENT_ID)
295
- complete_payload.record_uid = record_uid
296
-
297
- post_query('complete_transaction', complete_payload)
319
+ end
320
+
321
+ # Update existing secret (convenience wrapper)
322
+ def update_secret(record, transaction_type: 'general')
323
+ update_options = Dto::UpdateOptions.new(transaction_type: transaction_type)
324
+ update_secret_with_options(record, update_options)
325
+
326
+ uid = record.is_a?(Dto::KeeperRecord) ? record.uid : (record['uid'] || record[:uid])
327
+ complete_transaction(uid) if uid
328
+
329
+ if record.is_a?(Dto::KeeperRecord)
330
+ updated_record = get_secrets([record.uid]).first
331
+ if updated_record
332
+ record.revision = updated_record.revision
333
+ @logger&.debug("update_secret: updated local revision to #{record.revision}")
334
+ end
335
+ end
336
+
337
+ true
338
+ end
339
+
340
+ def save_with_options(record, update_options = nil)
341
+ if record.is_a?(Dto::KeeperRecord)
342
+ record_uid = record.uid
343
+ record_data = record.to_h
344
+ record_key = record.record_key
345
+ revision = record.revision
346
+ else
347
+ record_uid = record['uid'] || record[:uid]
348
+ record_data = record
349
+ record_key = record['record_key'] || record[:record_key]
350
+ revision = record['revision'] || record[:revision] || 0
298
351
  end
299
-
352
+
353
+ raise ArgumentError, 'Record UID is required' unless record_uid
354
+ raise Error, "Record key not available for #{record_uid} - record must be obtained via get_secrets" unless record_key
355
+
356
+ payload = prepare_update_payload(
357
+ record_uid: record_uid,
358
+ record_data: record_data,
359
+ record_key: record_key,
360
+ revision: revision,
361
+ update_options: update_options
362
+ )
363
+
364
+ post_query('update_secret', payload)
365
+ true
366
+ end
367
+
368
+ def save(record, transaction_type: nil, links_to_remove: nil)
369
+ update_options = Dto::UpdateOptions.new(transaction_type: transaction_type, links_to_remove: links_to_remove)
370
+ save_with_options(record, update_options)
371
+ end
372
+
373
+ # Complete transaction - commit or rollback
374
+ # Used after update_secret with transaction_type to finalize PAM rotation
375
+ def complete_transaction(record_uid, rollback: false)
376
+ @logger.debug("Completing transaction for record #{record_uid}, rollback: #{rollback}")
377
+
378
+ # Prepare payload
379
+ payload = prepare_complete_transaction_payload(record_uid)
380
+
381
+ # Route to different endpoints based on rollback parameter
382
+ endpoint = rollback ? 'rollback_secret_update' : 'finalize_secret_update'
383
+
384
+ # Send request
385
+ post_query(endpoint, payload)
386
+
300
387
  true
301
388
  end
302
389
 
303
390
  # Delete secrets
304
391
  def delete_secret(record_uids)
305
392
  record_uids = [record_uids] if record_uids.is_a?(String)
306
-
393
+
307
394
  payload = prepare_delete_payload(record_uids)
308
395
  response = post_query('delete_secret', payload)
309
-
396
+
310
397
  result = JSON.parse(response)
311
- result['records']
398
+ records = result['records'] || []
399
+ records.each do |r|
400
+ next if r['responseCode'] == 'ok'
401
+ @logger.error("Failed to delete record #{r['recordUid']}: #{r['responseCode']} #{r['errorMessage']}")
402
+ end
403
+ records
404
+ end
405
+
406
+ def get_inflate_ref_types(field_type)
407
+ INFLATE_REF_TYPES.fetch(field_type, [])
408
+ end
409
+
410
+ def inflate_field_value(uids, replace_fields)
411
+ records = get_secrets(uids)
412
+ lookup = records.each_with_object({}) { |r, h| h[r.uid] = r }
413
+
414
+ uids.filter_map do |uid|
415
+ record = lookup[uid]
416
+ next nil unless record
417
+
418
+ new_value = nil
419
+ replace_fields.each do |field_type|
420
+ field = record.get_field(field_type)
421
+ next unless field
422
+
423
+ raw_values = field['value'] || []
424
+ next if raw_values.empty?
425
+
426
+ real_value = raw_values.first
427
+
428
+ if INFLATE_REF_TYPES.key?(field_type)
429
+ inflated = inflate_field_value([real_value], INFLATE_REF_TYPES[field_type])
430
+ real_value = inflated.first unless inflated.empty?
431
+ end
432
+
433
+ label = field['label'] || field_type
434
+ if new_value.nil?
435
+ new_value = real_value
436
+ elsif new_value.is_a?(Hash) && real_value.is_a?(Hash)
437
+ new_value[label] = real_value
438
+ else
439
+ new_value = { label => new_value, field_type => real_value }
440
+ end
441
+ end
442
+
443
+ new_value
444
+ end
312
445
  end
313
446
 
314
447
  # Get notation value
@@ -317,43 +450,127 @@ module KeeperSecretsManager
317
450
  parser.parse(notation_uri)
318
451
  end
319
452
 
453
+ def get_notation_results(notation_uri)
454
+ Notation::Parser.new(self).get_notation_results(notation_uri)
455
+ end
456
+
457
+ # Like get_notation_results but never raises; logs errors and returns [].
458
+ def try_get_notation_results(notation_uri)
459
+ get_notation_results(notation_uri)
460
+ rescue NotationError, RecordNotFoundError, StandardError => e
461
+ @logger.error("try_get_notation_results failed for '#{notation_uri}': #{e.message}")
462
+ []
463
+ end
464
+
465
+ # Get notation value without raising exceptions (convenience method)
466
+ # Returns empty array if notation is invalid or record not found
467
+ def try_get_notation(notation_uri)
468
+ parser = Notation::Parser.new(self)
469
+ Array(parser.parse(notation_uri))
470
+ rescue NotationError, RecordNotFoundError, StandardError => e
471
+ @logger.debug("try_get_notation failed for '#{notation_uri}': #{e.message}")
472
+ []
473
+ end
474
+
320
475
  # Create folder
321
476
  def create_folder(folder_name, parent_uid: nil)
477
+ raise ArgumentError, 'parent_uid is required to create a folder' unless parent_uid
478
+
479
+ # Get folders to find parent's shared folder key
480
+ folders = get_folders
481
+
482
+ # Find parent folder
483
+ parent_folder = folders.find { |f| f.uid == parent_uid }
484
+ raise Error, "Parent folder #{parent_uid} not found" unless parent_folder
485
+
486
+ # Determine if parent is a shared folder root (no parent_uid)
487
+ is_shared_root = parent_folder.parent_uid.nil? || parent_folder.parent_uid.empty?
488
+
489
+ # Find the shared folder root by traversing up the hierarchy
490
+ if is_shared_root
491
+ # Parent is the shared root, so new folder is at root level
492
+ shared_folder_uid = parent_uid
493
+ actual_parent_uid = nil # nil for root-level folders
494
+ else
495
+ # Parent is a subfolder, traverse up to find shared root
496
+ shared_folder_uid = parent_uid
497
+ current_folder = parent_folder
498
+
499
+ while current_folder.parent_uid && !current_folder.parent_uid.empty?
500
+ parent = folders.find { |f| f.uid == current_folder.parent_uid }
501
+ break unless parent
502
+
503
+ shared_folder_uid = current_folder.parent_uid
504
+ current_folder = parent
505
+ end
506
+
507
+ actual_parent_uid = parent_uid # Subfolder creation
508
+ end
509
+
510
+ # Get shared folder's key (the root folder's key)
511
+ shared_folder = folders.find { |f| f.uid == shared_folder_uid }
512
+ raise Error, "Shared folder #{shared_folder_uid} not found" unless shared_folder
513
+
514
+ shared_folder_key = shared_folder.folder_key
515
+ raise Error, "Shared folder key missing for #{shared_folder_uid}" unless shared_folder_key
516
+
517
+ # Generate new folder UID and key
322
518
  folder_uid = Utils.generate_uid
323
519
  folder_key = Crypto.generate_encryption_key_bytes
324
-
520
+
521
+ # Prepare folder data
325
522
  folder_data = {
326
- 'name' => folder_name,
327
- 'folderType' => 'user_folder'
523
+ 'name' => folder_name
328
524
  }
329
-
330
- encrypted_data = Crypto.encrypt_aes_gcm(
525
+
526
+ # Encrypt folder data with NEW folder's key using AES-CBC
527
+ encrypted_data = Crypto.encrypt_aes_cbc(
331
528
  Utils.dict_to_json(folder_data),
332
529
  folder_key
333
530
  )
334
-
531
+
532
+ # Encrypt folder key with SHARED folder's key using AES-CBC
533
+ encrypted_folder_key = Crypto.encrypt_aes_cbc(folder_key, shared_folder_key)
534
+
535
+ # Prepare payload
335
536
  payload = prepare_create_folder_payload(
336
537
  folder_uid: folder_uid,
337
- folder_key: folder_key,
538
+ shared_folder_uid: shared_folder_uid,
539
+ encrypted_folder_key: encrypted_folder_key,
338
540
  data: encrypted_data,
339
- parent_uid: parent_uid
541
+ parent_uid: actual_parent_uid # nil for root, subfolder UID for nested
340
542
  )
341
-
543
+
342
544
  post_query('create_folder', payload)
343
545
  folder_uid
344
546
  end
345
547
 
346
548
  # Update folder
347
549
  def update_folder(folder_uid, folder_name)
550
+ # Get folders to find the folder's key
551
+ folders = get_folders
552
+ folder = folders.find { |f| f.uid == folder_uid }
553
+ raise Error, "Folder #{folder_uid} not found" unless folder
554
+
555
+ folder_key = folder.folder_key
556
+ raise Error, "Folder key missing for #{folder_uid}" unless folder_key
557
+
558
+ # Prepare folder data
348
559
  folder_data = {
349
560
  'name' => folder_name
350
561
  }
351
-
562
+
563
+ # Encrypt folder data with folder's key using AES-CBC
564
+ encrypted_data = Crypto.encrypt_aes_cbc(
565
+ Utils.dict_to_json(folder_data),
566
+ folder_key
567
+ )
568
+
352
569
  payload = prepare_update_folder_payload(
353
570
  folder_uid: folder_uid,
354
- data: folder_data
571
+ data: encrypted_data
355
572
  )
356
-
573
+
357
574
  post_query('update_folder', payload)
358
575
  true
359
576
  end
@@ -361,25 +578,30 @@ module KeeperSecretsManager
361
578
  # Delete folders
362
579
  def delete_folder(folder_uids, force: false)
363
580
  folder_uids = [folder_uids] if folder_uids.is_a?(String)
364
-
581
+
365
582
  payload = prepare_delete_folder_payload(folder_uids, force)
366
583
  response = post_query('delete_folder', payload)
367
-
584
+
368
585
  result = JSON.parse(response)
369
- result['folders']
586
+ folders = result['folders'] || []
587
+ folders.each do |f|
588
+ next if f['responseCode'] == 'ok'
589
+ @logger.error("Failed to delete folder #{f['folderUid']}: #{f['responseCode']} #{f['errorMessage']}")
590
+ end
591
+ folders
370
592
  end
371
-
593
+
372
594
  # Get folder hierarchy manager
373
595
  def folder_manager
374
596
  folders = get_folders
375
597
  FolderManager.new(folders)
376
598
  end
377
-
599
+
378
600
  # Get folder path (convenience method)
379
601
  def get_folder_path(folder_uid)
380
602
  folder_manager.get_folder_path(folder_uid)
381
603
  end
382
-
604
+
383
605
  # Find folder by name (convenience method)
384
606
  def find_folder_by_name(name, parent_uid: nil)
385
607
  folder_manager.find_folder_by_name(name, parent_uid: parent_uid)
@@ -388,66 +610,138 @@ module KeeperSecretsManager
388
610
  # Upload file
389
611
  def upload_file(owner_record_uid, file_data, file_name, file_title = nil)
390
612
  file_title ||= file_name
391
-
392
- # Generate file record
613
+
614
+ # Fetch the owner record (decrypted) to get current state
615
+ owner_records = get_secrets([owner_record_uid])
616
+ raise Error, "Owner record #{owner_record_uid} not found" if owner_records.empty?
617
+
618
+ owner_record = owner_records.first
619
+ owner_revision = owner_record.revision
620
+
621
+ # Get owner record data as hash for manipulation
622
+ owner_record_data = owner_record.to_h
623
+
624
+ # Get the record_key (stored during decryption)
625
+ owner_record_key = owner_record.record_key
626
+ raise Error, "Record key not available for owner record #{owner_record_uid}" unless owner_record_key
627
+
628
+ # Get owner record's public key from storage (app owner public key)
629
+ owner_public_key = @config.get_string(ConfigKeys::KEY_OWNER_PUBLIC_KEY)
630
+ raise Error, "Owner public key not found in config - application may need re-binding" unless owner_public_key
631
+
632
+ owner_public_key_bytes = Utils.url_safe_str_to_bytes(owner_public_key)
633
+
634
+ # Generate file record UID and key
393
635
  file_uid = Utils.generate_uid
394
636
  file_key = Crypto.generate_encryption_key_bytes
395
-
396
- # Encrypt file data
637
+
638
+ # Encrypt file data with file key
397
639
  encrypted_file = Crypto.encrypt_aes_gcm(file_data, file_key)
398
-
399
- # Create file record
640
+
641
+ # Create file record metadata
400
642
  file_record = {
401
- 'fileUid' => file_uid,
402
643
  'name' => file_name,
403
- 'title' => file_title,
404
644
  'size' => file_data.bytesize,
405
- 'mimeType' => 'application/octet-stream'
645
+ 'title' => file_title,
646
+ 'lastModified' => (Time.now.to_f * 1000).to_i,
647
+ 'type' => 'application/octet-stream'
406
648
  }
407
-
649
+
650
+ # Encrypt file record metadata with file key
651
+ file_record_json = Utils.dict_to_json(file_record)
652
+ file_record_bytes = file_record_json.bytes
653
+ encrypted_file_record = Crypto.encrypt_aes_gcm(file_record_bytes.pack('C*'), file_key)
654
+
655
+ # Encrypt file record key with owner's public key (ECIES)
656
+ encrypted_file_record_key = Crypto.encrypt_ec(file_key, owner_public_key_bytes)
657
+
658
+ # Encrypt file record key with owner record key (for linkKey)
659
+ encrypted_link_key = Crypto.encrypt_aes_gcm(file_key, owner_record_key)
660
+
661
+ # Add fileRef to owner record's fields
662
+ fields = owner_record_data['fields'] || []
663
+
664
+ file_ref_field = fields.find { |f| f['type'] == 'fileRef' }
665
+ if file_ref_field
666
+ file_ref_field['value'] ||= []
667
+ file_ref_field['value'] << file_uid
668
+ else
669
+ fields << { 'type' => 'fileRef', 'value' => [file_uid] }
670
+ end
671
+
672
+ # Update owner record data
673
+ owner_record_data['fields'] = fields
674
+ owner_record_json = Utils.dict_to_json(owner_record_data)
675
+ owner_record_bytes = owner_record_json.bytes.pack('C*')
676
+
677
+ # Encrypt updated owner record with its record key
678
+ encrypted_owner_record_data = Crypto.encrypt_aes_gcm(owner_record_bytes, owner_record_key)
679
+
408
680
  # Prepare payload
409
681
  payload = prepare_file_upload_payload(
410
682
  file_record_uid: file_uid,
411
- file_record_key: file_key,
412
- file_record_data: file_record,
683
+ file_record_key: encrypted_file_record_key,
684
+ file_record_data: encrypted_file_record,
413
685
  owner_record_uid: owner_record_uid,
686
+ owner_record_data: encrypted_owner_record_data,
687
+ owner_record_revision: owner_revision,
688
+ link_key: encrypted_link_key,
414
689
  file_size: encrypted_file.bytesize
415
690
  )
416
-
691
+
417
692
  # Get upload URL
418
- response = post_query('request_upload', payload)
693
+ response = post_query('add_file', payload)
419
694
  upload_result = JSON.parse(response)
420
-
695
+
421
696
  # Upload file
422
697
  upload_file_function(
423
698
  upload_result['url'],
424
699
  upload_result['parameters'],
425
700
  encrypted_file
426
701
  )
427
-
702
+
428
703
  file_uid
429
704
  end
430
705
 
706
+ # Upload file from disk path (convenience method)
707
+ # Reads file from disk and uploads to specified record
708
+ def upload_file_from_path(owner_record_uid, file_path, file_title: nil)
709
+ raise ArgumentError, "File not found: #{file_path}" unless File.exist?(file_path)
710
+ raise ArgumentError, "Path is a directory: #{file_path}" if File.directory?(file_path)
711
+
712
+ # Read file data
713
+ file_data = File.binread(file_path)
714
+
715
+ # Extract filename from path
716
+ file_name = File.basename(file_path)
717
+
718
+ # Use file_title if provided, otherwise use filename
719
+ file_title ||= file_name
720
+
721
+ @logger.debug("Uploading file from path: #{file_path} (#{file_data.bytesize} bytes)")
722
+
723
+ # Delegate to existing upload_file method
724
+ upload_file(owner_record_uid, file_data, file_name, file_title)
725
+ end
726
+
431
727
  # Download file from record's file data
432
728
  def download_file(file_data)
433
729
  # Extract file metadata (already decrypted)
434
730
  file_uid = file_data['fileUid']
435
731
  file_url = file_data['url']
436
732
  file_name = file_data['name'] || file_data['title'] || 'unnamed'
437
-
438
- unless file_url
439
- raise Error, "No download URL available for file #{file_uid}"
440
- end
441
-
733
+
734
+ raise Error, "No download URL available for file #{file_uid}" unless file_url
735
+
442
736
  # The file key should already be decrypted (base64 encoded)
443
737
  file_key = Utils.base64_to_bytes(file_data['fileKey'])
444
-
738
+
445
739
  # Download the encrypted file content
446
740
  encrypted_content = download_encrypted_file(file_url)
447
-
741
+
448
742
  # Decrypt the file content with the file key
449
743
  decrypted_content = Crypto.decrypt_aes_gcm(encrypted_content, file_key)
450
-
744
+
451
745
  # Return file info and data
452
746
  {
453
747
  'name' => file_name,
@@ -457,18 +751,45 @@ module KeeperSecretsManager
457
751
  'data' => decrypted_content
458
752
  }
459
753
  end
460
-
754
+
755
+ # Download file thumbnail
756
+ def download_thumbnail(file_data)
757
+ if file_data.is_a?(Dto::KeeperFile)
758
+ file_uid = file_data.uid
759
+ thumbnail_url = file_data.thumbnail_url
760
+ file_key_str = file_data.file_key
761
+ else
762
+ file_uid = file_data['fileUid'] || file_data['uid']
763
+ thumbnail_url = file_data['thumbnailUrl'] || file_data['thumbnail_url']
764
+ file_key_str = file_data['fileKey'] || file_data['file_key']
765
+ end
766
+
767
+ raise ArgumentError, 'File UID is required' unless file_uid
768
+ raise Error, "No thumbnail URL available for file #{file_uid}" unless thumbnail_url
769
+ raise Error, "File key not available for #{file_uid}" unless file_key_str
770
+
771
+ file_key = Utils.base64_to_bytes(file_key_str)
772
+ encrypted_content = download_encrypted_file(thumbnail_url)
773
+ decrypted_content = Crypto.decrypt_aes_gcm(encrypted_content, file_key)
774
+
775
+ {
776
+ 'file_uid' => file_uid,
777
+ 'data' => decrypted_content,
778
+ 'size' => decrypted_content.bytesize
779
+ }
780
+ end
781
+
461
782
  # Get file metadata from server
462
783
  def get_file_data(file_uid)
463
784
  payload = prepare_get_payload(nil)
464
785
  payload.file_uids = [file_uid]
465
-
786
+
466
787
  response = post_query('get_files', payload)
467
788
  response_dict = JSON.parse(response)
468
-
789
+
469
790
  if response_dict['files'] && !response_dict['files'].empty?
470
791
  file_data = response_dict['files'].first
471
-
792
+
472
793
  # Decrypt file metadata
473
794
  # Get app key for decryption
474
795
  app_key_str = @config.get_string(ConfigKeys::KEY_APP_KEY)
@@ -480,21 +801,31 @@ module KeeperSecretsManager
480
801
  client_key = get_client_key
481
802
  app_key = Crypto.decrypt_aes_gcm(app_key_encrypted, client_key)
482
803
  end
483
-
804
+
484
805
  encrypted_data = Utils.base64_to_bytes(file_data['data'])
485
806
  decrypted_json = Crypto.decrypt_aes_gcm(encrypted_data, app_key)
486
-
807
+
487
808
  JSON.parse(decrypted_json).merge('fileKey' => file_data['fileKey'])
488
809
  else
489
810
  raise Error, "File not found: #{file_uid}"
490
811
  end
491
812
  end
492
-
813
+
493
814
  # Download encrypted file from URL
494
815
  def download_encrypted_file(url)
495
816
  uri = URI(url)
496
- response = Net::HTTP.get_response(uri)
497
-
817
+
818
+ @logger.debug("Downloading file from URL: #{url}")
819
+
820
+ request = Net::HTTP::Get.new(uri)
821
+
822
+ http = create_http_client(uri)
823
+ configure_http_ssl(http)
824
+
825
+ response = http.request(request)
826
+
827
+ @logger.debug("Download response status: #{response.code}")
828
+
498
829
  if response.code == '200'
499
830
  response.body
500
831
  else
@@ -508,26 +839,56 @@ module KeeperSecretsManager
508
839
  def process_token_binding(token, hostname = nil)
509
840
  # Parse token
510
841
  token = token.strip
511
- token_parts = token.split(':')
512
-
842
+ # -1 keeps trailing empties so "IL5:key:20:" yields 4 parts and the empty serverPublicKey
843
+ # segment is rejected, instead of default split() dropping it and looking like 3 parts.
844
+ token_parts = token.split(':', -1)
845
+
846
+ il5_key_id = nil
847
+ il5_server_public_key = nil
848
+
513
849
  # Modern format: REGION:BASE64_TOKEN
514
850
  if token_parts.length >= 2
515
851
  region = token_parts[0].upcase
516
852
  @hostname = KeeperGlobals::KEEPER_SERVERS[region] || KeeperGlobals::DEFAULT_SERVER
517
- @token = token_parts[1..].join(':')
853
+
854
+ if region == 'IL5' && token_parts.length > 2
855
+ # IL5 dynamic-key OTT: IL5:clientKey:serverPublicKeyId:serverPublicKey
856
+ unless token_parts.length == 4
857
+ raise Error, 'Malformed IL5 one-time token: expected exactly 4 colon-separated ' \
858
+ "segments 'IL5:clientKey:keyId:serverPublicKey', got #{token_parts.length}"
859
+ end
860
+ il5_key_id = token_parts[2]
861
+ il5_server_public_key = token_parts[3]
862
+ if il5_key_id.to_s.empty? || il5_server_public_key.to_s.empty?
863
+ raise Error, 'Malformed IL5 one-time token: keyId and serverPublicKey segments must be non-empty'
864
+ end
865
+ begin
866
+ Utils.url_safe_str_to_bytes(il5_server_public_key)
867
+ rescue StandardError
868
+ raise Error, 'Malformed IL5 one-time token: serverPublicKey segment is not valid url-safe base64'
869
+ end
870
+ @token = token_parts[1]
871
+ else
872
+ @token = token_parts[1..].join(':')
873
+ end
518
874
  else
519
875
  # Legacy format
520
876
  @token = token
521
877
  @hostname = hostname || KeeperGlobals::DEFAULT_SERVER
522
878
  end
523
-
879
+
880
+ # Precedence: programmatic override > OTT segment.
881
+ effective_key_id = @server_public_key_id_override || il5_key_id
882
+ effective_public_key = @server_public_key_override || il5_server_public_key
883
+
524
884
  # Bind the one-time token
525
- bound_config = bind_one_time_token(@token, @hostname)
526
-
885
+ bound_config = bind_one_time_token(@token, @hostname, effective_key_id, effective_public_key)
886
+
527
887
  # Merge bound config into existing config if present
528
888
  if @config
529
889
  # Copy all values from bound config to existing config
530
- bound_config.instance_variable_get(:@config).each do |key, value|
890
+ bound_data = bound_config.instance_variable_get(:@data)
891
+ bound_data&.each do |key, value|
531
892
  if value.is_a?(String)
532
893
  @config.save_string(key, value)
533
894
  else
@@ -540,12 +901,12 @@ module KeeperSecretsManager
540
901
  end
541
902
 
542
903
  # Bind one-time token
543
- def bind_one_time_token(token, hostname)
904
+ def bind_one_time_token(token, hostname, key_id = nil, server_public_key = nil)
544
905
  storage = Storage::InMemoryStorage.new
545
-
906
+
546
907
  # Generate EC key pair
547
908
  keys = Crypto.generate_ecc_keys
548
-
909
+
549
910
  # Convert token to bytes and create client ID hash
550
911
  token_bytes = Utils.url_safe_str_to_bytes(token)
551
912
  client_id_hash = OpenSSL::HMAC.digest(
@@ -554,121 +915,120 @@ module KeeperSecretsManager
554
915
  'KEEPER_SECRETS_MANAGER_CLIENT_ID'
555
916
  )
556
917
  client_id = Utils.bytes_to_base64(client_id_hash)
557
-
918
+
558
919
  # Store configuration
559
920
  storage.save_string(ConfigKeys::KEY_HOSTNAME, hostname)
560
- storage.save_string(ConfigKeys::KEY_SERVER_PUBLIC_KEY_ID, DEFAULT_KEY_ID)
921
+ storage.save_string(ConfigKeys::KEY_SERVER_PUBLIC_KEY_ID, (key_id || DEFAULT_KEY_ID).to_s)
922
+ # persist the dynamic (IL5) server public key so it is used for binding AND all
923
+ # later requests, and survives restart via the saved config.
924
+ storage.save_string(ConfigKeys::KEY_SERVER_PUBLIC_KEY, server_public_key) if server_public_key && !server_public_key.empty?
561
925
  storage.save_string(ConfigKeys::KEY_CLIENT_KEY, token)
562
926
  storage.save_bytes(ConfigKeys::KEY_PRIVATE_KEY, keys[:private_key_bytes])
563
927
  storage.save_string(ConfigKeys::KEY_CLIENT_ID, client_id)
564
-
928
+
565
929
  # Prepare binding payload
566
930
  payload = Dto::GetPayload.new
567
931
  payload.client_version = KeeperGlobals.client_version
568
932
  payload.client_id = client_id
569
933
  payload.public_key = keys[:public_key_str]
570
-
934
+
571
935
  # Send binding request
572
936
  response = post_query('get_secret', payload, storage)
573
937
  response_dict = JSON.parse(response)
574
-
938
+
575
939
  # Process binding response
576
940
  if response_dict['encryptedAppKey']
577
941
  # Decrypt app key
578
942
  encrypted_app_key = Utils.url_safe_str_to_bytes(response_dict['encryptedAppKey'])
579
943
  client_key_bytes = Utils.url_safe_str_to_bytes(token)
580
-
944
+
581
945
  app_key = Crypto.decrypt_aes_gcm(encrypted_app_key, client_key_bytes)
582
946
  storage.save_bytes(ConfigKeys::KEY_APP_KEY, app_key)
583
-
947
+
584
948
  # Store app owner public key if present
585
949
  if response_dict['appOwnerPublicKey']
586
950
  owner_key = Utils.url_safe_str_to_bytes(response_dict['appOwnerPublicKey'])
587
951
  storage.save_bytes(ConfigKeys::KEY_OWNER_PUBLIC_KEY, owner_key)
588
952
  end
589
-
953
+
590
954
  # Clean up client key after successful binding
591
955
  storage.delete(ConfigKeys::KEY_CLIENT_KEY)
592
956
  else
593
- raise Errors::KeeperError.new("Failed to bind one-time token - no encrypted app key in response")
957
+ raise Error, 'Failed to bind one-time token - no encrypted app key in response'
594
958
  end
595
-
959
+
596
960
  storage
597
961
  end
598
962
 
599
963
  # Fetch and decrypt secrets
600
964
  def fetch_and_decrypt_secrets(query_options = nil)
601
965
  payload = prepare_get_payload(query_options)
602
-
966
+
603
967
  response = post_query('get_secret', payload)
604
968
  response_dict = JSON.parse(response)
605
-
969
+
606
970
  # Decrypt app key if present (during token binding)
607
971
  if response_dict['encryptedAppKey']
608
972
  encrypted_app_key = Utils.url_safe_str_to_bytes(response_dict['encryptedAppKey'])
609
973
  client_key = Utils.url_safe_str_to_bytes(@config.get_string(ConfigKeys::KEY_CLIENT_KEY))
610
-
974
+
611
975
  # Decrypt app key using AES with client key (the original token)
612
976
  app_key = Crypto.decrypt_aes_gcm(encrypted_app_key, client_key)
613
977
  @config.save_bytes(ConfigKeys::KEY_APP_KEY, app_key)
614
-
978
+
615
979
  # Clean up client key after successful binding
616
980
  @config.delete(ConfigKeys::KEY_CLIENT_KEY)
617
-
981
+
618
982
  # Store app owner public key if present
619
983
  if response_dict['appOwnerPublicKey']
620
984
  owner_key = Utils.url_safe_str_to_bytes(response_dict['appOwnerPublicKey'])
621
985
  @config.save_bytes(ConfigKeys::KEY_OWNER_PUBLIC_KEY, owner_key)
622
986
  end
623
-
987
+
624
988
  # Set just bound flag
625
989
  just_bound = true
626
990
  else
627
991
  just_bound = false
628
992
  end
629
-
993
+
630
994
  # Get app key
631
995
  app_key = @config.get_bytes(ConfigKeys::KEY_APP_KEY)
632
996
  raise Error, 'No app key available' unless app_key
633
-
997
+
634
998
  # Decrypt records
635
999
  records = []
636
1000
  if response_dict['records']
637
1001
  response_dict['records'].each do |encrypted_record|
638
- begin
639
- record = decrypt_record(encrypted_record, app_key)
640
- records << record
641
- rescue => e
642
- @logger.error("Failed to decrypt record: #{e.message}")
643
- end
1002
+ record = decrypt_record(encrypted_record, app_key)
1003
+ records << record
1004
+ rescue StandardError => e
1005
+ @logger.error("Failed to decrypt record: #{e.message}")
644
1006
  end
645
1007
  end
646
-
1008
+
647
1009
  # Decrypt folders - need to handle them in order for shared folder keys
648
1010
  folders = []
649
1011
  response_folders = response_dict['folders'] || []
650
-
1012
+
651
1013
  # First pass - decrypt folders in order
652
1014
  response_folders.each do |encrypted_folder|
653
- begin
654
- folder = decrypt_folder(encrypted_folder, app_key, folders, response_folders)
655
- if folder
656
- folders << folder
657
- # Add folder's records to the main records list
658
- records.concat(folder.records) if folder.records && !folder.records.empty?
659
- end
660
- rescue => e
661
- @logger.error("Failed to decrypt folder: #{e.message}")
1015
+ folder = decrypt_folder(encrypted_folder, app_key, folders, response_folders)
1016
+ if folder
1017
+ folders << folder
1018
+ # Add folder's records to the main records list
1019
+ records.concat(folder.records) if folder.records && !folder.records.empty?
662
1020
  end
1021
+ rescue StandardError => e
1022
+ @logger.error("Failed to decrypt folder: #{e.message}")
663
1023
  end
664
-
1024
+
665
1025
  # Build response
666
1026
  response = Dto::SecretsManagerResponse.new(
667
1027
  records: records,
668
1028
  folders: folders,
669
1029
  warnings: response_dict['warnings']
670
1030
  )
671
-
1031
+
672
1032
  response.just_bound = just_bound if response.respond_to?(:just_bound=)
673
1033
  response
674
1034
  end
@@ -678,64 +1038,66 @@ module KeeperSecretsManager
678
1038
  record_uid = encrypted_record['recordUid']
679
1039
  record_key_encrypted = Utils.base64_to_bytes(encrypted_record['recordKey'])
680
1040
  data_encrypted = Utils.base64_to_bytes(encrypted_record['data'])
681
-
1041
+
682
1042
  # Decrypt record key
683
1043
  record_key = Crypto.decrypt_aes_gcm(record_key_encrypted, app_key)
684
-
1044
+
685
1045
  # Decrypt data
686
1046
  data_json = Crypto.decrypt_aes_gcm(data_encrypted, record_key)
687
1047
  data = JSON.parse(data_json)
688
-
1048
+
689
1049
  # Decrypt files if present
690
1050
  decrypted_files = []
691
1051
  if encrypted_record['files']
692
1052
  encrypted_record['files'].each do |file|
693
- begin
694
- # Decrypt file key with record key
695
- file_key_encrypted = Utils.base64_to_bytes(file['fileKey'])
696
- file_key = Crypto.decrypt_aes_gcm(file_key_encrypted, record_key)
697
-
698
- # Decrypt file metadata with file key
699
- if file['data']
700
- file_data_encrypted = Utils.base64_to_bytes(file['data'])
701
- file_metadata_json = Crypto.decrypt_aes_gcm(file_data_encrypted, file_key)
702
- file_metadata = JSON.parse(file_metadata_json)
703
- else
704
- file_metadata = {}
705
- end
706
-
707
- # Create decrypted file object
708
- decrypted_file = {
709
- 'fileUid' => file['fileUid'],
710
- 'fileKey' => Utils.bytes_to_base64(file_key), # Store decrypted key
711
- 'url' => file['url'],
712
- 'thumbnailUrl' => file['thumbnailUrl'],
713
- 'name' => file_metadata['name'],
714
- 'title' => file_metadata['title'] || file_metadata['name'],
715
- 'type' => file_metadata['type'],
716
- 'size' => file_metadata['size'],
717
- 'lastModified' => file_metadata['lastModified']
718
- }
719
-
720
- decrypted_files << decrypted_file
721
- rescue => e
722
- @logger&.error("Failed to decrypt file #{file['fileUid']}: #{e.message}")
1053
+ # Decrypt file key with record key
1054
+ file_key_encrypted = Utils.base64_to_bytes(file['fileKey'])
1055
+ file_key = Crypto.decrypt_aes_gcm(file_key_encrypted, record_key)
1056
+
1057
+ # Decrypt file metadata with file key
1058
+ if file['data']
1059
+ file_data_encrypted = Utils.base64_to_bytes(file['data'])
1060
+ file_metadata_json = Crypto.decrypt_aes_gcm(file_data_encrypted, file_key)
1061
+ file_metadata = JSON.parse(file_metadata_json)
1062
+ else
1063
+ file_metadata = {}
723
1064
  end
1065
+
1066
+ # Create decrypted file object
1067
+ decrypted_file = {
1068
+ 'fileUid' => file['fileUid'],
1069
+ 'fileKey' => Utils.bytes_to_base64(file_key), # Store decrypted key
1070
+ 'url' => file['url'],
1071
+ 'thumbnailUrl' => file['thumbnailUrl'],
1072
+ 'name' => file_metadata['name'],
1073
+ 'title' => file_metadata['title'] || file_metadata['name'],
1074
+ 'type' => file_metadata['type'],
1075
+ 'size' => file_metadata['size'],
1076
+ 'lastModified' => file_metadata['lastModified']
1077
+ }
1078
+
1079
+ decrypted_files << decrypted_file
1080
+ rescue StandardError => e
1081
+ @logger&.error("Failed to decrypt file #{file['fileUid']}: #{e.message}")
724
1082
  end
725
1083
  end
726
-
1084
+
727
1085
  # Create record object
728
1086
  record = Dto::KeeperRecord.new(
729
1087
  'recordUid' => record_uid,
1088
+ 'folderUid' => encrypted_record['folderUid'],
1089
+ 'innerFolderUid' => encrypted_record['innerFolderUid'],
1090
+ 'isEditable' => encrypted_record['isEditable'],
730
1091
  'data' => data,
731
1092
  'revision' => encrypted_record['revision'],
732
- 'files' => decrypted_files
1093
+ 'files' => decrypted_files,
1094
+ 'links' => encrypted_record['links'] || []
733
1095
  )
734
-
1096
+
735
1097
  # Store record key for later use (e.g., file downloads)
736
1098
  record.instance_variable_set(:@record_key, record_key)
737
1099
  record.define_singleton_method(:record_key) { @record_key }
738
-
1100
+
739
1101
  record
740
1102
  end
741
1103
 
@@ -745,44 +1107,44 @@ module KeeperSecretsManager
745
1107
  # Find parent folder in response
746
1108
  parent_folder = response_folders.find { |f| f['folderUid'] == parent_uid }
747
1109
  return nil unless parent_folder
748
-
1110
+
749
1111
  # If parent has no parent, it's the shared folder root
750
1112
  if !parent_folder['parent'] || parent_folder['parent'].empty?
751
1113
  # Find the decrypted folder object
752
1114
  shared_folder = folders.find { |f| f.uid == parent_uid }
753
1115
  return shared_folder&.folder_key
754
1116
  end
755
-
1117
+
756
1118
  # Continue up the hierarchy
757
1119
  parent_uid = parent_folder['parent']
758
1120
  end
759
-
1121
+
760
1122
  nil
761
1123
  end
762
-
1124
+
763
1125
  # Decrypt folder
764
1126
  def decrypt_folder(encrypted_folder, app_key, existing_folders = [], response_folders = [])
765
1127
  folder_uid = encrypted_folder['folderUid']
766
1128
  folder_parent = encrypted_folder['parent']
767
-
1129
+
768
1130
  @logger.debug("Decrypting folder #{folder_uid}, parent: #{folder_parent || 'none'}")
769
-
1131
+
770
1132
  # Determine the decryption key to use
771
1133
  decryption_key = if !folder_parent || folder_parent.empty?
772
- # Root folder - use app key
773
- @logger.debug("Using app key for root folder #{folder_uid}")
774
- app_key
775
- else
776
- # Child folder - use shared folder key
777
- shared_folder_key = get_shared_folder_key(existing_folders, response_folders, folder_parent)
778
- unless shared_folder_key
779
- @logger.error("Cannot find shared folder key for parent #{folder_parent}")
780
- return nil
781
- end
782
- @logger.debug("Using shared folder key from parent for folder #{folder_uid}")
783
- shared_folder_key
784
- end
785
-
1134
+ # Root folder - use app key
1135
+ @logger.debug("Using app key for root folder #{folder_uid}")
1136
+ app_key
1137
+ else
1138
+ # Child folder - use shared folder key
1139
+ shared_folder_key = get_shared_folder_key(existing_folders, response_folders, folder_parent)
1140
+ unless shared_folder_key
1141
+ @logger.error("Cannot find shared folder key for parent #{folder_parent}")
1142
+ return nil
1143
+ end
1144
+ @logger.debug("Using shared folder key from parent for folder #{folder_uid}")
1145
+ shared_folder_key
1146
+ end
1147
+
786
1148
  # Some folders might not have encryption data
787
1149
  unless encrypted_folder['folderKey']
788
1150
  # Create a basic folder object without decrypted data
@@ -795,21 +1157,21 @@ module KeeperSecretsManager
795
1157
  'records' => []
796
1158
  )
797
1159
  end
798
-
1160
+
799
1161
  # Decrypt folder key
800
1162
  folder_key_encrypted = Utils.base64_to_bytes(encrypted_folder['folderKey'])
801
1163
  folder_key = if !folder_parent || folder_parent.empty?
802
- # Root folder key uses AES-GCM
803
- Crypto.decrypt_aes_gcm(folder_key_encrypted, decryption_key)
804
- else
805
- # Child folder key uses AES-CBC
806
- Crypto.decrypt_aes_cbc(folder_key_encrypted, decryption_key)
807
- end
808
-
1164
+ # Root folder key uses AES-GCM
1165
+ Crypto.decrypt_aes_gcm(folder_key_encrypted, decryption_key)
1166
+ else
1167
+ # Child folder key uses AES-CBC
1168
+ Crypto.decrypt_aes_cbc(folder_key_encrypted, decryption_key)
1169
+ end
1170
+
809
1171
  # Get folder name - either from encrypted data or direct field
810
1172
  folder_name = ''
811
1173
  folder_type = nil
812
-
1174
+
813
1175
  # Check if there's a direct name field (unencrypted)
814
1176
  if encrypted_folder['name']
815
1177
  folder_name = encrypted_folder['name']
@@ -824,7 +1186,7 @@ module KeeperSecretsManager
824
1186
  folder_name = data['name'] || ''
825
1187
  folder_type = data['folderType']
826
1188
  @logger.debug("Successfully decrypted folder #{folder_uid}: #{folder_name}")
827
- rescue => e
1189
+ rescue StandardError => e
828
1190
  @logger.error("Failed to decrypt folder data for #{folder_uid}: #{e.class} - #{e.message}")
829
1191
  @logger.debug("Backtrace: #{e.backtrace.first(3).join("\n")}")
830
1192
  end
@@ -832,24 +1194,22 @@ module KeeperSecretsManager
832
1194
  @logger.debug("Folder #{folder_uid} has no name or data field - using UID as name")
833
1195
  folder_name = folder_uid
834
1196
  end
835
-
1197
+
836
1198
  # Decrypt records in this folder
837
1199
  folder_records = []
838
1200
  if encrypted_folder['records']
839
1201
  encrypted_folder['records'].each do |encrypted_record|
840
- begin
841
- # Decrypt the record using folder key
842
- record = decrypt_record(encrypted_record, folder_key)
843
-
844
- # Set folder_uid on the record
845
- record.folder_uid = folder_uid if record
846
- folder_records << record if record
847
- rescue => e
848
- @logger.error("Failed to decrypt record in folder #{folder_uid}: #{e.message}")
849
- end
1202
+ # Decrypt the record using folder key
1203
+ record = decrypt_record(encrypted_record, folder_key)
1204
+
1205
+ # Set folder_uid on the record
1206
+ record.folder_uid = folder_uid if record
1207
+ folder_records << record if record
1208
+ rescue StandardError => e
1209
+ @logger.error("Failed to decrypt record in folder #{folder_uid}: #{e.message}")
850
1210
  end
851
1211
  end
852
-
1212
+
853
1213
  # Create folder object
854
1214
  Dto::KeeperFolder.new(
855
1215
  'folderUid' => folder_uid,
@@ -865,65 +1225,72 @@ module KeeperSecretsManager
865
1225
  def prepare_get_payload(query_options = nil)
866
1226
  payload = Dto::GetPayload.new
867
1227
  payload.client_version = KeeperGlobals.client_version
868
-
1228
+
869
1229
  # Client ID should be URL-safe base64
870
1230
  client_id_str = @config.get_string(ConfigKeys::KEY_CLIENT_ID)
871
1231
  payload.client_id = client_id_str
872
-
1232
+
873
1233
  @logger.debug("Client ID for payload: #{client_id_str}")
874
-
1234
+
875
1235
  # Public key is sent during initial binding only
876
-
1236
+
877
1237
  if query_options
878
1238
  payload.requested_records = query_options.records_filter
879
1239
  payload.requested_folders = query_options.folders_filter
1240
+ payload.request_links = query_options.request_links if query_options.request_links
880
1241
  end
881
-
1242
+
882
1243
  payload
883
1244
  end
884
1245
 
885
1246
  # Prepare create payload
886
- def prepare_create_payload(record_uid:, record_key:, folder_uid:, folder_key:, data:)
1247
+ def prepare_create_payload(record_uid:, record_key:, folder_uid:, folder_key:, data:, subfolder_uid: nil)
887
1248
  payload = Dto::CreatePayload.new
888
1249
  payload.client_version = KeeperGlobals.client_version
889
1250
  payload.client_id = @config.get_string(ConfigKeys::KEY_CLIENT_ID)
890
1251
  payload.record_uid = record_uid
891
1252
  payload.record_key = Utils.bytes_to_base64(record_key)
892
1253
  payload.folder_uid = folder_uid
1254
+ payload.sub_folder_uid = subfolder_uid
893
1255
  payload.data = Utils.bytes_to_base64(data)
894
-
1256
+
895
1257
  # Encrypt the record key with the folder key
896
1258
  if folder_key
897
1259
  folder_key_encrypted = Crypto.encrypt_aes_gcm(record_key, folder_key)
898
1260
  payload.folder_key = Utils.bytes_to_base64(folder_key_encrypted)
899
1261
  end
900
-
1262
+
901
1263
  payload
902
1264
  end
903
1265
 
904
1266
  # Other payload preparation methods...
905
-
1267
+
906
1268
  # Post query to API
907
1269
  def post_query(path, payload, config = nil)
908
1270
  config ||= @config
909
1271
  server = get_server(@hostname)
910
1272
  url = "https://#{server}/api/rest/sm/v1/#{path}"
911
-
1273
+
1274
+ throttle_attempt = 0
912
1275
  loop do
913
1276
  # Generate transmission key
914
1277
  key_id = config.get_string(ConfigKeys::KEY_SERVER_PUBLIC_KEY_ID) || DEFAULT_KEY_ID
915
- transmission_key = generate_transmission_key(key_id)
916
-
1278
+ # a custom (IL5) server public key in config overrides the built-in key table.
1279
+ custom_public_key = config.get_string(ConfigKeys::KEY_SERVER_PUBLIC_KEY)
1280
+ transmission_key = generate_transmission_key(key_id, custom_public_key)
1281
+
917
1282
  # Encrypt and sign payload
918
1283
  encrypted_payload = encrypt_and_sign_payload(config, transmission_key, payload)
919
-
1284
+
920
1285
  # Make request
921
- if @custom_post_function && path == 'get_secret'
922
- response = @custom_post_function.call(url, transmission_key, encrypted_payload, @verify_ssl_certs)
923
- else
924
- response = post_function(url, transmission_key, encrypted_payload)
925
- end
926
-
1286
+ # Use custom post function for read-only operations (get_secret, get_folders)
1287
+ # This enables caching for disaster recovery
1288
+ response = if @custom_post_function && (path == 'get_secret' || path == 'get_folders')
1289
+ @custom_post_function.call(url, transmission_key, encrypted_payload, @verify_ssl_certs)
1290
+ else
1291
+ post_function(url, transmission_key, encrypted_payload)
1292
+ end
1293
+
927
1294
  # Handle response
928
1295
  if response.success?
929
1296
  # Decrypt response if present
@@ -933,31 +1300,53 @@ module KeeperSecretsManager
933
1300
  return response.data
934
1301
  end
935
1302
  else
1303
+ # Throttle retry with exponential backoff + jitter (KSM-876 / KSM-883). Checked before
1304
+ # handle_http_error (which still drives key-rotation retry), and gated on the 403 status
1305
+ # so a non-403 response carrying a {"error":"throttled"} body is not retried.
1306
+ retry_after = parse_throttle(response)
1307
+ if response.status_code == 403 && !retry_after.nil?
1308
+ if throttle_attempt >= MAX_THROTTLE_RETRIES
1309
+ raise ThrottledError.new('throttled',
1310
+ "Request throttled by Keeper backend; exhausted #{MAX_THROTTLE_RETRIES} retries")
1311
+ end
1312
+ delay = throttle_delay(throttle_attempt, retry_after)
1313
+ @logger.warn("Request throttled (attempt #{throttle_attempt + 1}/#{MAX_THROTTLE_RETRIES}); " \
1314
+ "retrying in #{delay.round(1)}s")
1315
+ sleep(delay)
1316
+ throttle_attempt += 1
1317
+ next
1318
+ end
1319
+
936
1320
  handle_http_error(response, config)
937
1321
  end
938
1322
  end
939
1323
  end
940
1324
 
941
1325
  # Generate transmission key
942
- def generate_transmission_key(key_id)
943
- # Get server public key
944
- server_public_key_str = KeeperGlobals::KEEPER_PUBLIC_KEYS[key_id.to_s]
945
- raise Error, "Unknown public key ID: #{key_id}" unless server_public_key_str
946
-
1326
+ def generate_transmission_key(key_id, custom_public_key = nil)
1327
+ # Get server public key. A custom (e.g. IL5) key overrides the built-in table,
1328
+ # which only covers ids 1..18 - IL5 uses id 20 supplied out-of-band via the OTT/config.
1329
+ if custom_public_key && !custom_public_key.empty?
1330
+ server_public_key_str = custom_public_key
1331
+ else
1332
+ server_public_key_str = KeeperGlobals::KEEPER_PUBLIC_KEYS[key_id.to_s]
1333
+ raise Error, "Unknown public key ID: #{key_id}" unless server_public_key_str
1334
+ end
1335
+
947
1336
  @logger.debug("Using server public key ID: #{key_id}")
948
1337
  @logger.debug("Server public key string: #{server_public_key_str[0..20]}...")
949
-
1338
+
950
1339
  # Generate random key
951
1340
  key = Crypto.generate_encryption_key_bytes
952
1341
  @logger.debug("Generated transmission key: #{Utils.bytes_to_base64(key)[0..20]}...")
953
-
1342
+
954
1343
  # Encrypt key with server public key
955
- server_public_key = Crypto.url_safe_str_to_bytes(server_public_key_str)
1344
+ server_public_key = Utils.url_safe_str_to_bytes(server_public_key_str)
956
1345
  @logger.debug("Server public key bytes length: #{server_public_key.bytesize}")
957
-
1346
+
958
1347
  encrypted_key = Crypto.encrypt_ec(key, server_public_key)
959
1348
  @logger.debug("Encrypted key length: #{encrypted_key.bytesize}")
960
-
1349
+
961
1350
  Dto::TransmissionKey.new(
962
1351
  public_key_id: key_id,
963
1352
  key: key,
@@ -969,21 +1358,21 @@ module KeeperSecretsManager
969
1358
  def encrypt_and_sign_payload(config, transmission_key, payload)
970
1359
  # Convert payload to JSON
971
1360
  payload_json = payload.to_json
972
-
1361
+
973
1362
  @logger.debug("Payload: #{payload_json}")
974
-
1363
+
975
1364
  # Encrypt payload
976
1365
  encrypted_payload = Crypto.encrypt_aes_gcm(payload_json, transmission_key.key)
977
-
1366
+
978
1367
  # Generate signature
979
1368
  signature_base = transmission_key.encrypted_key + encrypted_payload
980
-
1369
+
981
1370
  # After binding, use ECDSA signature with private key (not HMAC)
982
1371
  private_key_bytes = config.get_bytes(ConfigKeys::KEY_PRIVATE_KEY)
983
1372
  if private_key_bytes
984
1373
  # Load private key
985
1374
  private_key = load_ec_private_key(private_key_bytes)
986
-
1375
+
987
1376
  # Generate ECDSA signature
988
1377
  signature = Crypto.sign_ec(signature_base, private_key)
989
1378
  @logger.debug("Using ECDSA signature, length: #{signature.bytesize}")
@@ -995,27 +1384,82 @@ module KeeperSecretsManager
995
1384
  signature = Crypto.generate_hmac(signature_key, signature_base)
996
1385
  @logger.debug("Using HMAC signature, length: #{signature.bytesize}")
997
1386
  else
998
- raise Error, "No key available for signature"
1387
+ raise Error, 'No key available for signature'
999
1388
  end
1000
1389
  end
1001
-
1390
+
1002
1391
  Dto::EncryptedPayload.new(
1003
1392
  encrypted_payload: encrypted_payload,
1004
1393
  signature: signature
1005
1394
  )
1006
1395
  end
1007
1396
 
1397
+ # Create Net::HTTP instance with proxy support
1398
+ # Configures proxy if @proxy_url is set
1399
+ def create_http_client(uri)
1400
+ if @proxy_url
1401
+ # Parse proxy URL
1402
+ proxy_uri = URI(@proxy_url)
1403
+
1404
+ # Create HTTP client with proxy
1405
+ http = Net::HTTP.new(
1406
+ uri.host,
1407
+ uri.port,
1408
+ proxy_uri.host,
1409
+ proxy_uri.port,
1410
+ proxy_uri.user,
1411
+ proxy_uri.password
1412
+ )
1413
+
1414
+ @logger.debug("Using HTTP proxy: #{proxy_uri.host}:#{proxy_uri.port}")
1415
+ @logger.debug("Proxy authentication: #{proxy_uri.user ? 'yes' : 'no'}")
1416
+ else
1417
+ # Create HTTP client without proxy
1418
+ http = Net::HTTP.new(uri.host, uri.port)
1419
+ end
1420
+
1421
+ http
1422
+ end
1423
+
1424
+ # Configure SSL for HTTP connection
1425
+ # Sets up certificate store and verification mode
1426
+ def configure_http_ssl(http)
1427
+ http.use_ssl = true
1428
+
1429
+ if @verify_ssl_certs
1430
+ http.verify_mode = OpenSSL::SSL::VERIFY_PEER
1431
+
1432
+ # Set up certificate store with system defaults
1433
+ # This ensures we use the system's trusted CA certificates
1434
+ store = OpenSSL::X509::Store.new
1435
+ store.set_default_paths
1436
+
1437
+ # Note: We don't enable CRL checking by default because:
1438
+ # 1. CRL endpoints may be temporarily unreachable
1439
+ # 2. Many enterprise networks block CRL endpoints
1440
+ # 3. Certificate chain validation still provides strong security
1441
+ # If strict CRL checking is needed, it can be added via configuration option
1442
+ http.cert_store = store
1443
+
1444
+ @logger.debug('SSL verification enabled with system certificate store')
1445
+ @logger.debug("OpenSSL version: #{OpenSSL::OPENSSL_VERSION}")
1446
+ else
1447
+ http.verify_mode = OpenSSL::SSL::VERIFY_NONE
1448
+ @logger.warn('SSL verification disabled - not recommended for production')
1449
+ end
1450
+ end
1451
+
1008
1452
  # HTTP post function
1009
1453
  def post_function(url, transmission_key, encrypted_payload)
1010
1454
  uri = URI(url)
1011
-
1455
+
1012
1456
  @logger.debug("POST URL: #{url}")
1013
1457
  @logger.debug("PublicKeyId header: #{transmission_key.public_key_id}")
1014
1458
  @logger.debug("TransmissionKey header: #{Utils.bytes_to_base64(transmission_key.encrypted_key)[0..50]}...")
1015
1459
  @logger.debug("TransmissionKey full base64 length: #{Utils.bytes_to_base64(transmission_key.encrypted_key).length}")
1016
1460
  @logger.debug("Signature header: #{Utils.bytes_to_base64(encrypted_payload.signature)[0..50]}...")
1017
1461
  @logger.debug("Request body length: #{encrypted_payload.encrypted_payload.bytesize} bytes")
1018
-
1462
+
1019
1463
  request = Net::HTTP::Post.new(uri)
1020
1464
  request['Content-Type'] = 'application/octet-stream'
1021
1465
  request['PublicKeyId'] = transmission_key.public_key_id.to_s
@@ -1023,64 +1467,103 @@ module KeeperSecretsManager
1023
1467
  request['Authorization'] = "Signature #{Utils.bytes_to_base64(encrypted_payload.signature)}"
1024
1468
  request['Content-Length'] = encrypted_payload.encrypted_payload.bytesize.to_s
1025
1469
  request.body = encrypted_payload.encrypted_payload
1026
-
1027
- http = Net::HTTP.new(uri.host, uri.port)
1028
- http.use_ssl = true
1029
- http.verify_mode = @verify_ssl_certs ? OpenSSL::SSL::VERIFY_PEER : OpenSSL::SSL::VERIFY_NONE
1030
-
1470
+
1471
+ http = create_http_client(uri)
1472
+ configure_http_ssl(http)
1473
+
1031
1474
  response = http.request(request)
1032
-
1475
+
1033
1476
  @logger.debug("Response status: #{response.code}")
1034
-
1477
+ @logger.debug("Response body length: #{response.body&.bytesize || 0} bytes")
1478
+ @logger.debug("Response body (first 100 bytes): #{response.body&.[](0..100).inspect}") if response.body && !response.body.empty?
1479
+
1035
1480
  Dto::KSMHttpResponse.new(
1036
1481
  status_code: response.code.to_i,
1037
1482
  data: response.body,
1038
1483
  http_response: response
1039
1484
  )
1040
- rescue => e
1041
- raise NetworkError.new("HTTP request failed: #{e.message}")
1485
+ rescue StandardError => e
1486
+ raise NetworkError, "HTTP request failed: #{e.message}"
1042
1487
  end
1043
1488
 
1044
1489
  # Handle HTTP errors
1045
1490
  def handle_http_error(response, config = nil)
1046
- begin
1047
- error_data = JSON.parse(response.data)
1048
- result_code = error_data['result_code'] || error_data['error']
1049
- message = error_data['message']
1050
-
1051
- @logger.debug("Server error response: #{error_data.inspect}")
1052
-
1053
- # Handle specific errors
1054
- case result_code
1055
- when 'key'
1056
- # Server wants different key
1057
- key_id = error_data['key_id']
1058
- @logger.info("Server requested key ID: #{key_id}")
1059
- # Use passed config or fall back to instance config
1060
- config_to_use = config || @config
1061
- if config_to_use
1062
- config_to_use.save_string(ConfigKeys::KEY_SERVER_PUBLIC_KEY_ID, key_id.to_s)
1063
- end
1064
- return # Retry
1065
- when 'throttled'
1066
- sleep_time = error_data['retry_after'] || 60
1067
- @logger.warn("Request throttled, waiting #{sleep_time} seconds")
1068
- sleep(sleep_time)
1069
- return # Retry
1070
- else
1071
- raise ErrorFactory.from_server_response(result_code, message)
1491
+ error_data = JSON.parse(response.data)
1492
+ result_code = error_data['result_code'] || error_data['error']
1493
+ message = error_data['message']
1494
+
1495
+ @logger.debug("Server error response: #{error_data.inspect}")
1496
+
1497
+ # Handle specific errors
1498
+ case result_code
1499
+ when 'key'
1500
+ # Server wants different key
1501
+ key_id = error_data['key_id']
1502
+ # Use passed config or fall back to instance config
1503
+ config_to_use = config || @config
1504
+ custom_key = config_to_use&.get_string(ConfigKeys::KEY_SERVER_PUBLIC_KEY)
1505
+
1506
+ # when a custom (IL5) server public key is configured, the server rejecting it
1507
+ # cannot be fixed by switching to a built-in key, and retrying would loop on the same key.
1508
+ # Fail fast with actionable remediation instead.
1509
+ if custom_key && !custom_key.empty?
1510
+ raise Error, "Server rejected the custom server public key (requested key ID #{key_id.inspect}). " \
1511
+ 'The IL5 server public key may have rotated; update your IL5 KSM configuration ' \
1512
+ '(serverPublicKey / serverPublicKeyId), e.g. by redeeming a freshly issued one-time token.'
1513
+ end
1514
+
1515
+ raise Error, 'The public key is blank from the server' if key_id.nil?
1516
+ unless KeeperGlobals::KEEPER_PUBLIC_KEYS.key?(key_id.to_s)
1517
+ raise Error, "The public key at #{key_id} does not exist in the SDK"
1072
1518
  end
1073
- rescue JSON::ParserError
1074
- raise NetworkError.new("Server error: HTTP #{response.status_code}",
1075
- status_code: response.status_code,
1076
- response_body: response.data)
1519
+
1520
+ @logger.info("Server requested key ID: #{key_id}")
1521
+ config_to_use.save_string(ConfigKeys::KEY_SERVER_PUBLIC_KEY_ID, key_id.to_s) if config_to_use
1522
+ nil # Retry
1523
+ else
1524
+ raise ErrorFactory.from_server_response(result_code, message)
1525
+ end
1526
+ rescue JSON::ParserError
1527
+ raise NetworkError.new("Server error: HTTP #{response.status_code}",
1528
+ status_code: response.status_code,
1529
+ response_body: response.data)
1530
+ end
1531
+
1532
+ # Returns the throttle retry_after (>= 0) when +response+ is a backend throttle error
1533
+ # (result_code/error == "throttled"), otherwise nil so the caller falls through to
1534
+ # handle_http_error. Non-JSON / non-object bodies return nil. (KSM-876 / KSM-883)
1535
+ def parse_throttle(response)
1536
+ data = JSON.parse(response.data)
1537
+ return nil unless data.is_a?(Hash)
1538
+
1539
+ result_code = data['result_code'] || data['error']
1540
+ return nil unless result_code == 'throttled'
1541
+
1542
+ retry_after = begin
1543
+ Float(data['retry_after'])
1544
+ rescue ArgumentError, TypeError
1545
+ 0.0
1077
1546
  end
1547
+ retry_after.negative? ? 0.0 : retry_after
1548
+ rescue JSON::ParserError
1549
+ nil
1550
+ end
1551
+
1552
+ # Backoff delay (seconds) for a 0-based attempt: retry_after when > 0, otherwise exponential
1553
+ # backoff (BASE_THROTTLE_DELAY_SEC * 2**attempt -> 11, 22, 44, 88, 176s). A 0 to +25% jitter
1554
+ # is applied (one-sided so delay is always >= floor, preventing a retry before the backend's
1555
+ # 10s memcached window expires).
1556
+ def throttle_delay(attempt, retry_after = 0)
1557
+ base = retry_after.to_f.positive? ? retry_after.to_f : BASE_THROTTLE_DELAY_SEC * (2**attempt)
1558
+ base = MAX_THROTTLE_DELAY_SEC if base > MAX_THROTTLE_DELAY_SEC
1559
+ delay = base + base * rand(0.0..0.25)
1560
+ delay.negative? ? 0.0 : delay
1078
1561
  end
1079
1562
 
1080
1563
  # Get server hostname
1081
1564
  def get_server(hostname)
1082
1565
  return hostname if hostname.include?('.')
1083
-
1566
+
1084
1567
  # Look up in server list
1085
1568
  KeeperGlobals::KEEPER_SERVERS[hostname.upcase] || hostname
1086
1569
  end
@@ -1089,64 +1572,91 @@ module KeeperSecretsManager
1089
1572
  def load_ec_private_key(private_key_bytes)
1090
1573
  # If it's already a key object, return it
1091
1574
  return private_key_bytes if private_key_bytes.is_a?(OpenSSL::PKey::EC)
1092
-
1575
+
1093
1576
  @logger.debug("Loading private key, bytes length: #{private_key_bytes.bytesize}")
1094
-
1577
+
1095
1578
  # Try to load as DER format first
1096
1579
  begin
1097
1580
  key = OpenSSL::PKey.read(private_key_bytes, nil)
1098
1581
  # Ensure it's an EC key
1099
1582
  if key.is_a?(OpenSSL::PKey::EC)
1100
- @logger.debug("Successfully loaded EC private key from DER format")
1101
- return key
1583
+ @logger.debug('Successfully loaded EC private key from DER format')
1584
+ key
1102
1585
  else
1103
1586
  raise "Not an EC key, got #{key.class}"
1104
1587
  end
1105
- rescue => der_error
1106
- @logger.debug("DER format failed: #{der_error.message}, trying raw bytes")
1107
-
1588
+ rescue StandardError => e
1589
+ @logger.debug("DER format failed: #{e.message}, trying raw bytes")
1590
+
1108
1591
  # If DER fails, it might be raw key bytes (32 bytes)
1109
1592
  if private_key_bytes.bytesize == 32
1110
1593
  begin
1111
1594
  # Create EC key from raw bytes (OpenSSL 3.0 compatible)
1112
1595
  group = OpenSSL::PKey::EC::Group.new('prime256v1')
1113
-
1596
+
1114
1597
  # Generate key components
1115
1598
  private_key_bn = OpenSSL::BN.new(private_key_bytes, 2)
1116
1599
  public_key_point = group.generator.mul(private_key_bn)
1117
-
1600
+
1118
1601
  # Create ASN1 sequence for the key
1119
1602
  asn1 = OpenSSL::ASN1::Sequence([
1120
- OpenSSL::ASN1::Integer(1),
1121
- OpenSSL::ASN1::OctetString(private_key_bytes),
1122
- OpenSSL::ASN1::ObjectId('prime256v1', 0, :EXPLICIT),
1123
- OpenSSL::ASN1::BitString(public_key_point.to_octet_string(:uncompressed), 1, :EXPLICIT)
1124
- ])
1125
-
1603
+ OpenSSL::ASN1::Integer(1),
1604
+ OpenSSL::ASN1::OctetString(private_key_bytes),
1605
+ OpenSSL::ASN1::ObjectId('prime256v1', 0, :EXPLICIT),
1606
+ OpenSSL::ASN1::BitString(
1607
+ public_key_point.to_octet_string(:uncompressed), 1, :EXPLICIT
1608
+ )
1609
+ ])
1610
+
1126
1611
  # Create key from DER
1127
1612
  key = OpenSSL::PKey::EC.new(asn1.to_der)
1128
-
1129
- @logger.debug("Successfully created EC key from raw bytes")
1130
- return key
1131
- rescue => raw_error
1613
+
1614
+ @logger.debug('Successfully created EC key from raw bytes')
1615
+ key
1616
+ rescue StandardError => raw_error
1132
1617
  @logger.debug("Raw bytes failed: #{raw_error.message}")
1133
- raise CryptoError, "Failed to load private key: DER: #{der_error.message}, Raw: #{raw_error.message}"
1618
+ raise CryptoError, "Failed to load private key: DER: #{e.message}, Raw: #{raw_error.message}"
1134
1619
  end
1135
1620
  else
1136
- raise CryptoError, "Failed to load private key: #{der_error.message} (got #{private_key_bytes.bytesize} bytes)"
1621
+ raise CryptoError,
1622
+ "Failed to load private key: #{e.message} (got #{private_key_bytes.bytesize} bytes)"
1137
1623
  end
1138
1624
  end
1139
1625
  end
1140
1626
 
1141
1627
  # Other helper methods...
1142
- def prepare_update_payload(record_uid:, data:, revision:, transaction_type:)
1628
+ def prepare_update_payload(record_uid:, record_data:, record_key:, revision:, update_options: nil)
1143
1629
  payload = Dto::UpdatePayload.new
1144
1630
  payload.client_version = KeeperGlobals.client_version
1145
1631
  payload.client_id = @config.get_string(ConfigKeys::KEY_CLIENT_ID)
1146
1632
  payload.record_uid = record_uid
1147
- payload.data = Utils.dict_to_json(data)
1148
1633
  payload.revision = revision
1149
- payload.transaction_type = transaction_type
1634
+
1635
+ if update_options
1636
+ # nil clears UpdatePayload's 'general' default (BasePayload#to_h skips nil fields).
1637
+ payload.transaction_type = update_options.transaction_type
1638
+
1639
+ # Handle links_to_remove
1640
+ if update_options.links_to_remove && !update_options.links_to_remove.empty?
1641
+ payload.links2_remove = update_options.links_to_remove
1642
+
1643
+ # Filter fileRef field values - remove specified link UIDs from record data
1644
+ # This modifies the data hash before encryption (matches Python SDK behavior)
1645
+ fileref_field = record_data['fields']&.find { |f| f['type'] == 'fileRef' }
1646
+ if fileref_field && fileref_field['value'].is_a?(Array)
1647
+ original_values = fileref_field['value']
1648
+ filtered_values = original_values.reject { |uid| update_options.links_to_remove.include?(uid) }
1649
+ fileref_field['value'] = filtered_values if filtered_values.length != original_values.length
1650
+ end
1651
+ end
1652
+ end
1653
+ # When update_options is nil: UpdatePayload keeps its default 'general' from #initialize.
1654
+
1655
+ # Encrypt record data
1656
+ json_data = Utils.dict_to_json(record_data)
1657
+ encrypted_data = Crypto.encrypt_aes_gcm(json_data, record_key)
1658
+ payload.data = Utils.bytes_to_base64(encrypted_data)
1659
+
1150
1660
  payload
1151
1661
  end
1152
1662
 
@@ -1158,17 +1668,26 @@ module KeeperSecretsManager
1158
1668
  payload
1159
1669
  end
1160
1670
 
1161
- def prepare_create_folder_payload(folder_uid:, folder_key:, data:, parent_uid:)
1671
+ def prepare_complete_transaction_payload(record_uid)
1672
+ payload = Dto::CompleteTransactionPayload.new
1673
+ payload.client_version = KeeperGlobals.client_version
1674
+ payload.client_id = @config.get_string(ConfigKeys::KEY_CLIENT_ID)
1675
+ payload.record_uid = record_uid
1676
+ payload
1677
+ end
1678
+
1679
+ def prepare_create_folder_payload(folder_uid:, shared_folder_uid:, encrypted_folder_key:, data:, parent_uid:)
1162
1680
  payload = Dto::CreateFolderPayload.new
1163
1681
  payload.client_version = KeeperGlobals.client_version
1164
1682
  payload.client_id = @config.get_string(ConfigKeys::KEY_CLIENT_ID)
1165
1683
  payload.folder_uid = folder_uid
1166
- payload.data = Utils.bytes_to_base64(data)
1684
+ payload.shared_folder_uid = shared_folder_uid
1685
+ payload.data = Utils.bytes_to_url_safe_str(data)
1167
1686
  payload.parent_uid = parent_uid
1168
-
1169
- # Handle shared folder key
1170
- payload.shared_folder_key = Utils.bytes_to_base64(folder_key)
1171
-
1687
+
1688
+ # Use encrypted folder key (already encrypted with shared folder's key using AES-CBC)
1689
+ payload.shared_folder_key = Utils.bytes_to_url_safe_str(encrypted_folder_key)
1690
+
1172
1691
  payload
1173
1692
  end
1174
1693
 
@@ -1177,7 +1696,7 @@ module KeeperSecretsManager
1177
1696
  payload.client_version = KeeperGlobals.client_version
1178
1697
  payload.client_id = @config.get_string(ConfigKeys::KEY_CLIENT_ID)
1179
1698
  payload.folder_uid = folder_uid
1180
- payload.data = Utils.dict_to_json(data)
1699
+ payload.data = Utils.bytes_to_url_safe_str(data)
1181
1700
  payload
1182
1701
  end
1183
1702
 
@@ -1190,42 +1709,64 @@ module KeeperSecretsManager
1190
1709
  payload
1191
1710
  end
1192
1711
 
1193
- def prepare_file_upload_payload(file_record_uid:, file_record_key:, file_record_data:, owner_record_uid:, file_size:)
1712
+ def prepare_file_upload_payload(file_record_uid:, file_record_key:, file_record_data:, owner_record_uid:, owner_record_data:, owner_record_revision:, link_key:, file_size:)
1194
1713
  payload = Dto::FileUploadPayload.new
1195
1714
  payload.client_version = KeeperGlobals.client_version
1196
1715
  payload.client_id = @config.get_string(ConfigKeys::KEY_CLIENT_ID)
1197
1716
  payload.file_record_uid = file_record_uid
1198
1717
  payload.file_record_key = Utils.bytes_to_base64(file_record_key)
1199
- payload.file_record_data = Utils.dict_to_json(file_record_data)
1718
+ payload.file_record_data = Utils.bytes_to_base64(file_record_data)
1200
1719
  payload.owner_record_uid = owner_record_uid
1720
+ payload.owner_record_data = Utils.bytes_to_base64(owner_record_data)
1721
+ payload.owner_record_revision = owner_record_revision
1722
+ payload.link_key = Utils.bytes_to_base64(link_key)
1201
1723
  payload.file_size = file_size
1202
1724
  payload
1203
1725
  end
1204
1726
 
1205
1727
  def upload_file_function(url, parameters, encrypted_file_data)
1206
1728
  uri = URI(url)
1207
-
1208
- # Use multipart form data
1209
- # This is a simplified version - might need proper multipart handling
1729
+
1730
+ # Parse parameters if it's a JSON string
1731
+ params_hash = parameters.is_a?(String) ? JSON.parse(parameters) : parameters
1732
+
1733
+ # Build multipart form data
1734
+ # Form data should include both the file and the parameters
1735
+ form_data = params_hash.merge({ 'file' => encrypted_file_data })
1736
+
1210
1737
  request = Net::HTTP::Post.new(uri)
1211
- request.set_form([['file', encrypted_file_data]], 'multipart/form-data')
1212
-
1213
- # Add parameters
1214
- parameters&.each do |key, value|
1215
- request[key] = value
1738
+
1739
+ # Set form with file upload
1740
+ # The file field needs to be handled as binary data
1741
+ boundary = "----RubyMultipartPost#{rand(1000000)}"
1742
+ body = []
1743
+
1744
+ # Add regular form fields first
1745
+ params_hash.each do |key, value|
1746
+ body << "--#{boundary}\r\n"
1747
+ body << "Content-Disposition: form-data; name=\"#{key}\"\r\n\r\n"
1748
+ body << "#{value}\r\n"
1216
1749
  end
1217
-
1218
- http = Net::HTTP.new(uri.host, uri.port)
1219
- http.use_ssl = true
1220
-
1750
+
1751
+ # Add file field
1752
+ body << "--#{boundary}\r\n"
1753
+ body << "Content-Disposition: form-data; name=\"file\"; filename=\"file\"\r\n"
1754
+ body << "Content-Type: application/octet-stream\r\n\r\n"
1755
+ body << encrypted_file_data
1756
+ body << "\r\n--#{boundary}--\r\n"
1757
+
1758
+ request.body = body.join
1759
+ request['Content-Type'] = "multipart/form-data; boundary=#{boundary}"
1760
+
1761
+ http = create_http_client(uri)
1762
+ configure_http_ssl(http)
1763
+
1221
1764
  response = http.request(request)
1222
-
1223
- unless response.code.to_i == 200
1224
- raise NetworkError, "File upload failed: HTTP #{response.code}"
1225
- end
1226
-
1765
+
1766
+ raise NetworkError, "File upload failed: HTTP #{response.code} - #{response.body}" unless response.code.to_i.between?(200, 299)
1767
+
1227
1768
  true
1228
1769
  end
1229
1770
  end
1230
1771
  end
1231
- end
1772
+ end