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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +83 -10
- data/Gemfile +3 -0
- data/README.md +75 -270
- data/bin/console +47 -0
- data/keeper_secrets_manager.gemspec +36 -0
- data/lib/keeper_secrets_manager/cache.rb +139 -0
- data/lib/keeper_secrets_manager/config_keys.rb +2 -0
- data/lib/keeper_secrets_manager/core.rb +455 -114
- data/lib/keeper_secrets_manager/crypto.rb +3 -11
- data/lib/keeper_secrets_manager/dto/payload.rb +3 -2
- data/lib/keeper_secrets_manager/dto.rb +326 -8
- data/lib/keeper_secrets_manager/keeper_globals.rb +6 -6
- data/lib/keeper_secrets_manager/notation.rb +105 -3
- data/lib/keeper_secrets_manager/notation_enhancements.rb +2 -0
- data/lib/keeper_secrets_manager/storage.rb +3 -2
- data/lib/keeper_secrets_manager/utils.rb +2 -1
- data/lib/keeper_secrets_manager/version.rb +1 -1
- data/lib/keeper_secrets_manager.rb +8 -0
- metadata +34 -3
|
@@ -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
|
|
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
|
-
|
|
206
|
-
|
|
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
|
-
|
|
213
|
-
folders = get_folders
|
|
248
|
+
folders ||= get_folders
|
|
214
249
|
|
|
215
|
-
|
|
216
|
-
|
|
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 #{
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
243
|
-
record_key:
|
|
244
|
-
folder_uid:
|
|
245
|
-
folder_key:
|
|
246
|
-
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
|
-
|
|
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
|
-
|
|
257
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
311
|
+
record_data: record_data,
|
|
312
|
+
record_key: record_key,
|
|
299
313
|
revision: existing.revision,
|
|
300
|
-
|
|
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
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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([
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1094
|
-
|
|
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 =
|
|
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 =
|
|
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:,
|
|
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
|
-
|
|
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 =
|
|
1761
|
+
http = create_http_client(uri)
|
|
1421
1762
|
configure_http_ssl(http)
|
|
1422
1763
|
|
|
1423
1764
|
response = http.request(request)
|