keeper_secrets_manager 17.1.0 → 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.
@@ -12,6 +12,13 @@ module KeeperSecretsManager
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'],
@@ -20,7 +27,7 @@ module KeeperSecretsManager
20
27
 
21
28
  def initialize(options = {})
22
29
  # Check Ruby version
23
- raise Error, 'KSM SDK requires Ruby 2.6 or greater' if RUBY_VERSION < '2.6'
30
+ raise Error, 'KSM SDK requires Ruby 3.1 or greater' if RUBY_VERSION < '3.1'
24
31
 
25
32
  # Check AES-GCM support
26
33
  begin
@@ -39,10 +46,33 @@ module KeeperSecretsManager
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]
@@ -73,6 +103,19 @@ module KeeperSecretsManager
73
103
  raise Error, 'Either token or initialized config must be provided'
74
104
  end
75
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)
117
+ end
118
+
76
119
  # Override hostname if provided
77
120
  if options[:hostname]
78
121
  @hostname = options[:hostname]
@@ -81,16 +124,13 @@ module KeeperSecretsManager
81
124
  @hostname = @config.get_string(ConfigKeys::KEY_HOSTNAME) || KeeperGlobals::DEFAULT_SERVER
82
125
  end
83
126
 
84
- # Cache configuration
85
- @cache = {}
86
- @cache_expiry = {}
87
127
  end
88
128
 
89
129
  # Get secrets with optional filtering
90
- def get_secrets(uids = nil, full_response: false)
130
+ def get_secrets(uids = nil, full_response: false, request_links: false)
91
131
  uids = [uids] if uids.is_a?(String)
92
132
 
93
- query_options = Dto::QueryOptions.new(records: uids, folders: nil)
133
+ query_options = Dto::QueryOptions.new(records: uids, folders: nil, request_links: request_links)
94
134
  get_secrets_with_options(query_options, full_response: full_response)
95
135
  end
96
136
 
@@ -202,59 +242,50 @@ module KeeperSecretsManager
202
242
  get_secrets_by_title(title).first
203
243
  end
204
244
 
205
- # Create a new secret
206
- def create_secret(record_data, options = nil)
207
- options ||= Dto::CreateOptions.new
208
-
209
- # Validate folder UID is provided
210
- raise ArgumentError, 'folder_uid is required to create a record' unless options.folder_uid
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
211
247
 
212
- # Get folders from dedicated endpoint to find folder key
213
- folders = get_folders
248
+ folders ||= get_folders
214
249
 
215
- # Find the folder
216
- folder = folders.find { |f| f.uid == options.folder_uid }
217
- raise Error, "Folder #{options.folder_uid} not found or not accessible" unless folder
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
218
252
 
219
- # Get folder key
220
253
  folder_key = folder.folder_key
221
- raise Error, "Unable to create record - folder key for #{options.folder_uid} is missing" unless folder_key
254
+ raise Error, "Unable to create record - folder key for #{create_options.folder_uid} is missing" unless folder_key
222
255
 
223
- # Generate UIDs and keys
224
256
  record_uid = Utils.generate_uid
225
257
  record_key = Crypto.generate_encryption_key_bytes
226
258
 
227
- # Prepare record data
228
- record = if record_data.is_a?(Dto::KeeperRecord)
229
- record_data.to_h
230
- else
231
- record_data
232
- end
259
+ record = record_data.is_a?(Dto::KeeperRecord) ? record_data.to_h : record_data
233
260
 
234
- # Encrypt record data
235
- encrypted_data = Crypto.encrypt_aes_gcm(
236
- Utils.dict_to_json(record),
237
- record_key
238
- )
261
+ encrypted_data = Crypto.encrypt_aes_gcm(Utils.dict_to_json(record), record_key)
239
262
 
240
- # Prepare payload
241
263
  payload = prepare_create_payload(
242
- record_uid: record_uid,
243
- record_key: record_key,
244
- folder_uid: options.folder_uid,
245
- folder_key: folder_key,
246
- 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
247
270
  )
248
271
 
249
- # Send request
250
- response = post_query('create_secret', payload)
251
-
252
- # Return created record UID
272
+ post_query('create_secret', payload)
253
273
  record_uid
254
274
  end
255
275
 
256
- # Update existing secret
257
- 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)
258
289
  # Handle both record object and hash
259
290
  if record.is_a?(Dto::KeeperRecord)
260
291
  record_uid = record.uid
@@ -274,56 +305,29 @@ module KeeperSecretsManager
274
305
  record_key = existing.record_key
275
306
  raise Error, "Record key not available for #{record_uid}" unless record_key
276
307
 
277
- # Record key is already raw bytes (stored during decryption)
278
- # No conversion needed - use directly for encryption
279
-
280
- # Debug: Log record data before encryption
281
- @logger&.debug("update_secret: record_uid=#{record_uid}")
282
- @logger&.debug("update_secret: record_data keys=#{record_data.keys.inspect}")
283
- @logger&.debug("update_secret: record_data=#{record_data.inspect[0..200]}...")
284
- @logger&.debug("update_secret: record_key present=#{!record_key.nil?}, length=#{record_key&.bytesize}")
285
-
286
- # Encrypt record data with record key (same as create_secret)
287
- json_data = Utils.dict_to_json(record_data)
288
- @logger&.debug("update_secret: json_data length=#{json_data.bytesize}")
289
- @logger&.debug("update_secret: json_data=#{json_data[0..200]}...")
290
-
291
- encrypted_data = Crypto.encrypt_aes_gcm(json_data, record_key)
292
- @logger&.debug("update_secret: encrypted_data length=#{encrypted_data.bytesize}")
293
- @logger&.debug("update_secret: encrypted_data (base64)=#{Base64.strict_encode64(encrypted_data)[0..50]}...")
294
-
295
- # Prepare payload
308
+ # Prepare payload (handles UpdateOptions internally)
296
309
  payload = prepare_update_payload(
297
310
  record_uid: record_uid,
298
- data: encrypted_data,
311
+ record_data: record_data,
312
+ record_key: record_key,
299
313
  revision: existing.revision,
300
- transaction_type: transaction_type
314
+ update_options: update_options
301
315
  )
302
316
 
303
- @logger&.debug("update_secret: payload revision=#{existing.revision}")
304
- @logger&.debug("update_secret: payload transaction_type=#{transaction_type}")
305
-
306
317
  # Send request
307
- @logger&.debug("update_secret: sending post_query to update_secret endpoint")
308
- response = post_query('update_secret', payload)
309
- @logger&.debug("update_secret: response received")
310
- @logger&.debug("update_secret: response class=#{response.class}")
311
- @logger&.debug("update_secret: response=#{response.inspect[0..500]}...")
312
-
313
- # Always finalize the update (required for changes to persist)
314
- # This applies to both 'general' and 'rotation' transaction types
315
- complete_payload = Dto::CompleteTransactionPayload.new
316
- complete_payload.client_version = KeeperGlobals.client_version
317
- complete_payload.client_id = @config.get_string(ConfigKeys::KEY_CLIENT_ID)
318
- complete_payload.record_uid = record_uid
319
-
320
- post_query('finalize_secret_update', complete_payload)
321
-
322
- # Update local record's revision to reflect server state
323
- # Since the server doesn't return the new revision in the response,
324
- # we need to refetch the record to get the actual revision
318
+ post_query('update_secret', 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
+
325
329
  if record.is_a?(Dto::KeeperRecord)
326
- updated_record = get_secrets([record_uid]).first
330
+ updated_record = get_secrets([record.uid]).first
327
331
  if updated_record
328
332
  record.revision = updated_record.revision
329
333
  @logger&.debug("update_secret: updated local revision to #{record.revision}")
@@ -333,6 +337,56 @@ module KeeperSecretsManager
333
337
  true
334
338
  end
335
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
351
+ end
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
+
387
+ true
388
+ end
389
+
336
390
  # Delete secrets
337
391
  def delete_secret(record_uids)
338
392
  record_uids = [record_uids] if record_uids.is_a?(String)
@@ -341,7 +395,53 @@ module KeeperSecretsManager
341
395
  response = post_query('delete_secret', payload)
342
396
 
343
397
  result = JSON.parse(response)
344
- 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
345
445
  end
346
446
 
347
447
  # Get notation value
@@ -350,6 +450,28 @@ module KeeperSecretsManager
350
450
  parser.parse(notation_uri)
351
451
  end
352
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
+
353
475
  # Create folder
354
476
  def create_folder(folder_name, parent_uid: nil)
355
477
  raise ArgumentError, 'parent_uid is required to create a folder' unless parent_uid
@@ -461,7 +583,12 @@ module KeeperSecretsManager
461
583
  response = post_query('delete_folder', payload)
462
584
 
463
585
  result = JSON.parse(response)
464
- 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
465
592
  end
466
593
 
467
594
  # Get folder hierarchy manager
@@ -576,6 +703,27 @@ module KeeperSecretsManager
576
703
  file_uid
577
704
  end
578
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
+
579
727
  # Download file from record's file data
580
728
  def download_file(file_data)
581
729
  # Extract file metadata (already decrypted)
@@ -604,6 +752,33 @@ module KeeperSecretsManager
604
752
  }
605
753
  end
606
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
+
607
782
  # Get file metadata from server
608
783
  def get_file_data(file_uid)
609
784
  payload = prepare_get_payload(nil)
@@ -644,7 +819,7 @@ module KeeperSecretsManager
644
819
 
645
820
  request = Net::HTTP::Get.new(uri)
646
821
 
647
- http = Net::HTTP.new(uri.host, uri.port)
822
+ http = create_http_client(uri)
648
823
  configure_http_ssl(http)
649
824
 
650
825
  response = http.request(request)
@@ -664,21 +839,50 @@ module KeeperSecretsManager
664
839
  def process_token_binding(token, hostname = nil)
665
840
  # Parse token
666
841
  token = token.strip
667
- token_parts = token.split(':')
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
668
848
 
669
849
  # Modern format: REGION:BASE64_TOKEN
670
850
  if token_parts.length >= 2
671
851
  region = token_parts[0].upcase
672
852
  @hostname = KeeperGlobals::KEEPER_SERVERS[region] || KeeperGlobals::DEFAULT_SERVER
673
- @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
674
874
  else
675
875
  # Legacy format
676
876
  @token = token
677
877
  @hostname = hostname || KeeperGlobals::DEFAULT_SERVER
678
878
  end
679
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
+
680
884
  # Bind the one-time token
681
- bound_config = bind_one_time_token(@token, @hostname)
885
+ bound_config = bind_one_time_token(@token, @hostname, effective_key_id, effective_public_key)
682
886
 
683
887
  # Merge bound config into existing config if present
684
888
  if @config
@@ -697,7 +901,7 @@ module KeeperSecretsManager
697
901
  end
698
902
 
699
903
  # Bind one-time token
700
- def bind_one_time_token(token, hostname)
904
+ def bind_one_time_token(token, hostname, key_id = nil, server_public_key = nil)
701
905
  storage = Storage::InMemoryStorage.new
702
906
 
703
907
  # Generate EC key pair
@@ -714,7 +918,10 @@ module KeeperSecretsManager
714
918
 
715
919
  # Store configuration
716
920
  storage.save_string(ConfigKeys::KEY_HOSTNAME, hostname)
717
- 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?
718
925
  storage.save_string(ConfigKeys::KEY_CLIENT_KEY, token)
719
926
  storage.save_bytes(ConfigKeys::KEY_PRIVATE_KEY, keys[:private_key_bytes])
720
927
  storage.save_string(ConfigKeys::KEY_CLIENT_ID, client_id)
@@ -878,9 +1085,13 @@ module KeeperSecretsManager
878
1085
  # Create record object
879
1086
  record = Dto::KeeperRecord.new(
880
1087
  'recordUid' => record_uid,
1088
+ 'folderUid' => encrypted_record['folderUid'],
1089
+ 'innerFolderUid' => encrypted_record['innerFolderUid'],
1090
+ 'isEditable' => encrypted_record['isEditable'],
881
1091
  'data' => data,
882
1092
  'revision' => encrypted_record['revision'],
883
- 'files' => decrypted_files
1093
+ 'files' => decrypted_files,
1094
+ 'links' => encrypted_record['links'] || []
884
1095
  )
885
1096
 
886
1097
  # Store record key for later use (e.g., file downloads)
@@ -1026,19 +1237,21 @@ module KeeperSecretsManager
1026
1237
  if query_options
1027
1238
  payload.requested_records = query_options.records_filter
1028
1239
  payload.requested_folders = query_options.folders_filter
1240
+ payload.request_links = query_options.request_links if query_options.request_links
1029
1241
  end
1030
1242
 
1031
1243
  payload
1032
1244
  end
1033
1245
 
1034
1246
  # Prepare create payload
1035
- 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)
1036
1248
  payload = Dto::CreatePayload.new
1037
1249
  payload.client_version = KeeperGlobals.client_version
1038
1250
  payload.client_id = @config.get_string(ConfigKeys::KEY_CLIENT_ID)
1039
1251
  payload.record_uid = record_uid
1040
1252
  payload.record_key = Utils.bytes_to_base64(record_key)
1041
1253
  payload.folder_uid = folder_uid
1254
+ payload.sub_folder_uid = subfolder_uid
1042
1255
  payload.data = Utils.bytes_to_base64(data)
1043
1256
 
1044
1257
  # Encrypt the record key with the folder key
@@ -1058,16 +1271,21 @@ module KeeperSecretsManager
1058
1271
  server = get_server(@hostname)
1059
1272
  url = "https://#{server}/api/rest/sm/v1/#{path}"
1060
1273
 
1274
+ throttle_attempt = 0
1061
1275
  loop do
1062
1276
  # Generate transmission key
1063
1277
  key_id = config.get_string(ConfigKeys::KEY_SERVER_PUBLIC_KEY_ID) || DEFAULT_KEY_ID
1064
- transmission_key = generate_transmission_key(key_id)
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)
1065
1281
 
1066
1282
  # Encrypt and sign payload
1067
1283
  encrypted_payload = encrypt_and_sign_payload(config, transmission_key, payload)
1068
1284
 
1069
1285
  # Make request
1070
- response = if @custom_post_function && path == 'get_secret'
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')
1071
1289
  @custom_post_function.call(url, transmission_key, encrypted_payload, @verify_ssl_certs)
1072
1290
  else
1073
1291
  post_function(url, transmission_key, encrypted_payload)
@@ -1082,16 +1300,38 @@ module KeeperSecretsManager
1082
1300
  return response.data
1083
1301
  end
1084
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
+
1085
1320
  handle_http_error(response, config)
1086
1321
  end
1087
1322
  end
1088
1323
  end
1089
1324
 
1090
1325
  # Generate transmission key
1091
- def generate_transmission_key(key_id)
1092
- # Get server public key
1093
- server_public_key_str = KeeperGlobals::KEEPER_PUBLIC_KEYS[key_id.to_s]
1094
- raise Error, "Unknown public key ID: #{key_id}" unless server_public_key_str
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
1095
1335
 
1096
1336
  @logger.debug("Using server public key ID: #{key_id}")
1097
1337
  @logger.debug("Server public key string: #{server_public_key_str[0..20]}...")
@@ -1101,7 +1341,7 @@ module KeeperSecretsManager
1101
1341
  @logger.debug("Generated transmission key: #{Utils.bytes_to_base64(key)[0..20]}...")
1102
1342
 
1103
1343
  # Encrypt key with server public key
1104
- 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)
1105
1345
  @logger.debug("Server public key bytes length: #{server_public_key.bytesize}")
1106
1346
 
1107
1347
  encrypted_key = Crypto.encrypt_ec(key, server_public_key)
@@ -1154,6 +1394,33 @@ module KeeperSecretsManager
1154
1394
  )
1155
1395
  end
1156
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
+
1157
1424
  # Configure SSL for HTTP connection
1158
1425
  # Sets up certificate store and verification mode
1159
1426
  def configure_http_ssl(http)
@@ -1201,7 +1468,7 @@ module KeeperSecretsManager
1201
1468
  request['Content-Length'] = encrypted_payload.encrypted_payload.bytesize.to_s
1202
1469
  request.body = encrypted_payload.encrypted_payload
1203
1470
 
1204
- http = Net::HTTP.new(uri.host, uri.port)
1471
+ http = create_http_client(uri)
1205
1472
  configure_http_ssl(http)
1206
1473
 
1207
1474
  response = http.request(request)
@@ -1232,16 +1499,27 @@ module KeeperSecretsManager
1232
1499
  when 'key'
1233
1500
  # Server wants different key
1234
1501
  key_id = error_data['key_id']
1235
- @logger.info("Server requested key ID: #{key_id}")
1236
1502
  # Use passed config or fall back to instance config
1237
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"
1518
+ end
1519
+
1520
+ @logger.info("Server requested key ID: #{key_id}")
1238
1521
  config_to_use.save_string(ConfigKeys::KEY_SERVER_PUBLIC_KEY_ID, key_id.to_s) if config_to_use
1239
1522
  nil # Retry
1240
- when 'throttled'
1241
- sleep_time = error_data['retry_after'] || 60
1242
- @logger.warn("Request throttled, waiting #{sleep_time} seconds")
1243
- sleep(sleep_time)
1244
- nil # Retry
1245
1523
  else
1246
1524
  raise ErrorFactory.from_server_response(result_code, message)
1247
1525
  end
@@ -1251,6 +1529,37 @@ module KeeperSecretsManager
1251
1529
  response_body: response.data)
1252
1530
  end
1253
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
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
1561
+ end
1562
+
1254
1563
  # Get server hostname
1255
1564
  def get_server(hostname)
1256
1565
  return hostname if hostname.include?('.')
@@ -1316,14 +1625,38 @@ module KeeperSecretsManager
1316
1625
  end
1317
1626
 
1318
1627
  # Other helper methods...
1319
- 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)
1320
1629
  payload = Dto::UpdatePayload.new
1321
1630
  payload.client_version = KeeperGlobals.client_version
1322
1631
  payload.client_id = @config.get_string(ConfigKeys::KEY_CLIENT_ID)
1323
1632
  payload.record_uid = record_uid
1324
- payload.data = Utils.bytes_to_base64(data)
1325
1633
  payload.revision = revision
1326
- 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
+
1327
1660
  payload
1328
1661
  end
1329
1662
 
@@ -1335,6 +1668,14 @@ module KeeperSecretsManager
1335
1668
  payload
1336
1669
  end
1337
1670
 
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
+
1338
1679
  def prepare_create_folder_payload(folder_uid:, shared_folder_uid:, encrypted_folder_key:, data:, parent_uid:)
1339
1680
  payload = Dto::CreateFolderPayload.new
1340
1681
  payload.client_version = KeeperGlobals.client_version
@@ -1417,7 +1758,7 @@ module KeeperSecretsManager
1417
1758
  request.body = body.join
1418
1759
  request['Content-Type'] = "multipart/form-data; boundary=#{boundary}"
1419
1760
 
1420
- http = Net::HTTP.new(uri.host, uri.port)
1761
+ http = create_http_client(uri)
1421
1762
  configure_http_ssl(http)
1422
1763
 
1423
1764
  response = http.request(request)