keeper_secrets_manager 17.2.1

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.
@@ -0,0 +1,1781 @@
1
+ require 'net/http'
2
+ require 'uri'
3
+ require 'json'
4
+ require 'logger'
5
+ require 'openssl'
6
+
7
+ module KeeperSecretsManager
8
+ module Core
9
+ class SecretsManager
10
+ attr_reader :config, :hostname, :verify_ssl_certs
11
+
12
+ NOTATION_PREFIX = 'keeper'.freeze
13
+ DEFAULT_KEY_ID = '7'.freeze
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
+
22
+ # Field types that can be inflated
23
+ INFLATE_REF_TYPES = {
24
+ 'addressRef' => ['address'],
25
+ 'cardRef' => %w[paymentCard text pinCode addressRef]
26
+ }.freeze
27
+
28
+ def initialize(options = {})
29
+ # Check Ruby version
30
+ raise Error, 'KSM SDK requires Ruby 3.1 or greater' if RUBY_VERSION < '3.1'
31
+
32
+ # Check AES-GCM support
33
+ begin
34
+ OpenSSL::Cipher.new('AES-256-GCM')
35
+ rescue RuntimeError => e
36
+ if e.message.include?('unsupported cipher')
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+"
39
+ end
40
+
41
+ raise e
42
+ end
43
+
44
+ @token = nil
45
+ @hostname = nil
46
+ @verify_ssl_certs = options.fetch(:verify_ssl_certs, true)
47
+ @custom_post_function = options[:custom_post_function]
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
+
70
+ # Set up logging
71
+ @logger = options[:logger] || Logger.new(STDOUT)
72
+ @logger.level = options[:log_level] || Logger::WARN
73
+
74
+ @logger.debug("Proxy configuration: #{@proxy_url ? @proxy_url : 'none'}") if @proxy_url
75
+
76
+ # Handle configuration
77
+ config = options[:config]
78
+ token = options[:token]
79
+
80
+ # Check environment variable if no config provided
81
+ config = Storage::InMemoryStorage.new(ENV['KSM_CONFIG']) if config.nil? && ENV['KSM_CONFIG']
82
+
83
+ # If we have config, check if it's already initialized
84
+ if config
85
+ @config = config
86
+ # Check if already bound (has client ID and app key)
87
+ if @config.get_string(ConfigKeys::KEY_CLIENT_ID) && @config.get_bytes(ConfigKeys::KEY_APP_KEY)
88
+ @logger.debug('Using existing credentials from config')
89
+ elsif token
90
+ # Config exists but not bound, use token to bind
91
+ @logger.debug('Config provided but not bound, using token to initialize')
92
+ process_token_binding(token, options[:hostname])
93
+ else
94
+ @logger.warn('Config provided but no credentials found and no token provided')
95
+ end
96
+ elsif token
97
+ # No config provided, create new one with token
98
+ @logger.debug('No config provided, creating new one with token')
99
+ process_token_binding(token, options[:hostname])
100
+ @config ||= Storage::InMemoryStorage.new
101
+ else
102
+ # No config and no token
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)
117
+ end
118
+
119
+ # Override hostname if provided
120
+ if options[:hostname]
121
+ @hostname = options[:hostname]
122
+ @config.save_string(ConfigKeys::KEY_HOSTNAME, @hostname)
123
+ else
124
+ @hostname = @config.get_string(ConfigKeys::KEY_HOSTNAME) || KeeperGlobals::DEFAULT_SERVER
125
+ end
126
+
127
+ end
128
+
129
+ # Get secrets with optional filtering
130
+ def get_secrets(uids = nil, full_response: false, request_links: false)
131
+ uids = [uids] if uids.is_a?(String)
132
+
133
+ query_options = Dto::QueryOptions.new(records: uids, folders: nil, request_links: request_links)
134
+ get_secrets_with_options(query_options, full_response: full_response)
135
+ end
136
+
137
+ # Get secrets with query options
138
+ def get_secrets_with_options(query_options = nil, full_response: false)
139
+ records_resp = fetch_and_decrypt_secrets(query_options)
140
+
141
+ # If just bound, fetch again
142
+ records_resp = fetch_and_decrypt_secrets(query_options) if records_resp.just_bound
143
+
144
+ # Log warnings
145
+ records_resp.warnings&.each { |warning| @logger.warn(warning) }
146
+
147
+ # Log bad records/folders
148
+ if records_resp.errors&.any?
149
+ records_resp.errors.each do |error|
150
+ @logger.error("Error: #{error}")
151
+ end
152
+ end
153
+
154
+ full_response ? records_resp : (records_resp.records || [])
155
+ end
156
+
157
+ # Get all folders
158
+ def get_folders
159
+ fetch_and_decrypt_folders
160
+ end
161
+
162
+ # Fetch and decrypt folders from dedicated endpoint
163
+ def fetch_and_decrypt_folders
164
+ # Prepare payload for get_folders endpoint (no filters)
165
+ payload = prepare_get_payload(nil)
166
+
167
+ # Make request to get_folders endpoint
168
+ response_json = post_query('get_folders', payload)
169
+ response_dict = JSON.parse(response_json)
170
+
171
+ # Get app key for decryption
172
+ app_key_str = @config.get_string(ConfigKeys::KEY_APP_KEY)
173
+
174
+ # If we have app key directly (one-time token binding), use it
175
+ if app_key_str && !app_key_str.empty?
176
+ app_key = Utils.base64_to_bytes(app_key_str)
177
+ else
178
+ # Otherwise decrypt it using client key
179
+ app_key_encrypted = Utils.base64_to_bytes(@config.get_string(ConfigKeys::KEY_ENCRYPTED_APP_KEY))
180
+ client_key = Utils.base64_to_bytes(@config.get_string(ConfigKeys::KEY_CLIENT_KEY))
181
+ app_key = Crypto.decrypt_aes_gcm(app_key_encrypted, client_key)
182
+ end
183
+
184
+ # Decrypt folders - need to handle them in order for shared folder keys
185
+ folders = []
186
+ response_folders = response_dict['folders'] || []
187
+
188
+ response_folders.each do |encrypted_folder|
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
203
+ end
204
+ folder_key_encrypted = Utils.base64_to_bytes(encrypted_folder['folderKey'])
205
+ folder_key = Crypto.decrypt_aes_cbc(folder_key_encrypted, shared_folder_key)
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}")
229
+ end
230
+
231
+ folders
232
+ end
233
+
234
+ # Get secrets by title
235
+ def get_secrets_by_title(title)
236
+ records = get_secrets
237
+ records.select { |r| r.title == title }
238
+ end
239
+
240
+ # Get first secret by title
241
+ def get_secret_by_title(title)
242
+ get_secrets_by_title(title).first
243
+ end
244
+
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
+
253
+ folder_key = folder.folder_key
254
+ raise Error, "Unable to create record - folder key for #{create_options.folder_uid} is missing" unless folder_key
255
+
256
+ record_uid = Utils.generate_uid
257
+ record_key = Crypto.generate_encryption_key_bytes
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
+
263
+ payload = prepare_create_payload(
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
270
+ )
271
+
272
+ post_query('create_secret', payload)
273
+ record_uid
274
+ end
275
+
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)
289
+ # Handle both record object and hash
290
+ if record.is_a?(Dto::KeeperRecord)
291
+ record_uid = record.uid
292
+ record_data = record.to_h
293
+ else
294
+ record_uid = record['uid'] || record[:uid]
295
+ record_data = record
296
+ end
297
+
298
+ raise ArgumentError, 'Record UID is required' unless record_uid
299
+
300
+ # Get existing record to get the key
301
+ existing = get_secrets([record_uid]).first
302
+ raise RecordNotFoundError, "Record #{record_uid} not found" unless existing
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)
309
+ payload = prepare_update_payload(
310
+ record_uid: record_uid,
311
+ record_data: record_data,
312
+ record_key: record_key,
313
+ revision: existing.revision,
314
+ update_options: update_options
315
+ )
316
+
317
+ # Send request
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
+
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
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
+
390
+ # Delete secrets
391
+ def delete_secret(record_uids)
392
+ record_uids = [record_uids] if record_uids.is_a?(String)
393
+
394
+ payload = prepare_delete_payload(record_uids)
395
+ response = post_query('delete_secret', payload)
396
+
397
+ result = JSON.parse(response)
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
445
+ end
446
+
447
+ # Get notation value
448
+ def get_notation(notation_uri)
449
+ parser = Notation::Parser.new(self)
450
+ parser.parse(notation_uri)
451
+ end
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
+
475
+ # Create folder
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
518
+ folder_uid = Utils.generate_uid
519
+ folder_key = Crypto.generate_encryption_key_bytes
520
+
521
+ # Prepare folder data
522
+ folder_data = {
523
+ 'name' => folder_name
524
+ }
525
+
526
+ # Encrypt folder data with NEW folder's key using AES-CBC
527
+ encrypted_data = Crypto.encrypt_aes_cbc(
528
+ Utils.dict_to_json(folder_data),
529
+ folder_key
530
+ )
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
536
+ payload = prepare_create_folder_payload(
537
+ folder_uid: folder_uid,
538
+ shared_folder_uid: shared_folder_uid,
539
+ encrypted_folder_key: encrypted_folder_key,
540
+ data: encrypted_data,
541
+ parent_uid: actual_parent_uid # nil for root, subfolder UID for nested
542
+ )
543
+
544
+ post_query('create_folder', payload)
545
+ folder_uid
546
+ end
547
+
548
+ # Update folder
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
559
+ folder_data = {
560
+ 'name' => folder_name
561
+ }
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
+
569
+ payload = prepare_update_folder_payload(
570
+ folder_uid: folder_uid,
571
+ data: encrypted_data
572
+ )
573
+
574
+ post_query('update_folder', payload)
575
+ true
576
+ end
577
+
578
+ # Delete folders
579
+ def delete_folder(folder_uids, force: false)
580
+ folder_uids = [folder_uids] if folder_uids.is_a?(String)
581
+
582
+ payload = prepare_delete_folder_payload(folder_uids, force)
583
+ response = post_query('delete_folder', payload)
584
+
585
+ result = JSON.parse(response)
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
592
+ end
593
+
594
+ # Get folder hierarchy manager
595
+ def folder_manager
596
+ folders = get_folders
597
+ FolderManager.new(folders)
598
+ end
599
+
600
+ # Get folder path (convenience method)
601
+ def get_folder_path(folder_uid)
602
+ folder_manager.get_folder_path(folder_uid)
603
+ end
604
+
605
+ # Find folder by name (convenience method)
606
+ def find_folder_by_name(name, parent_uid: nil)
607
+ folder_manager.find_folder_by_name(name, parent_uid: parent_uid)
608
+ end
609
+
610
+ # Upload file
611
+ def upload_file(owner_record_uid, file_data, file_name, file_title = nil)
612
+ file_title ||= file_name
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
635
+ file_uid = Utils.generate_uid
636
+ file_key = Crypto.generate_encryption_key_bytes
637
+
638
+ # Encrypt file data with file key
639
+ encrypted_file = Crypto.encrypt_aes_gcm(file_data, file_key)
640
+
641
+ # Create file record metadata
642
+ file_record = {
643
+ 'name' => file_name,
644
+ 'size' => file_data.bytesize,
645
+ 'title' => file_title,
646
+ 'lastModified' => (Time.now.to_f * 1000).to_i,
647
+ 'type' => 'application/octet-stream'
648
+ }
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
+
680
+ # Prepare payload
681
+ payload = prepare_file_upload_payload(
682
+ file_record_uid: file_uid,
683
+ file_record_key: encrypted_file_record_key,
684
+ file_record_data: encrypted_file_record,
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,
689
+ file_size: encrypted_file.bytesize
690
+ )
691
+
692
+ # Get upload URL
693
+ response = post_query('add_file', payload)
694
+ upload_result = JSON.parse(response)
695
+
696
+ # Upload file
697
+ upload_file_function(
698
+ upload_result['url'],
699
+ upload_result['parameters'],
700
+ encrypted_file
701
+ )
702
+
703
+ file_uid
704
+ end
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
+
727
+ # Download file from record's file data
728
+ def download_file(file_data)
729
+ # Extract file metadata (already decrypted)
730
+ file_uid = file_data['fileUid']
731
+ file_url = file_data['url']
732
+ file_name = file_data['name'] || file_data['title'] || 'unnamed'
733
+
734
+ raise Error, "No download URL available for file #{file_uid}" unless file_url
735
+
736
+ # The file key should already be decrypted (base64 encoded)
737
+ file_key = Utils.base64_to_bytes(file_data['fileKey'])
738
+
739
+ # Download the encrypted file content
740
+ encrypted_content = download_encrypted_file(file_url)
741
+
742
+ # Decrypt the file content with the file key
743
+ decrypted_content = Crypto.decrypt_aes_gcm(encrypted_content, file_key)
744
+
745
+ # Return file info and data
746
+ {
747
+ 'name' => file_name,
748
+ 'title' => file_data['title'] || file_name,
749
+ 'type' => file_data['type'],
750
+ 'size' => file_data['size'] || decrypted_content.bytesize,
751
+ 'data' => decrypted_content
752
+ }
753
+ end
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
+
782
+ # Get file metadata from server
783
+ def get_file_data(file_uid)
784
+ payload = prepare_get_payload(nil)
785
+ payload.file_uids = [file_uid]
786
+
787
+ response = post_query('get_files', payload)
788
+ response_dict = JSON.parse(response)
789
+
790
+ if response_dict['files'] && !response_dict['files'].empty?
791
+ file_data = response_dict['files'].first
792
+
793
+ # Decrypt file metadata
794
+ # Get app key for decryption
795
+ app_key_str = @config.get_string(ConfigKeys::KEY_APP_KEY)
796
+ if app_key_str && !app_key_str.empty?
797
+ app_key = Utils.base64_to_bytes(app_key_str)
798
+ else
799
+ # Decrypt app key with client key
800
+ app_key_encrypted = Utils.base64_to_bytes(@config.get_string(ConfigKeys::KEY_ENCRYPTED_APP_KEY))
801
+ client_key = get_client_key
802
+ app_key = Crypto.decrypt_aes_gcm(app_key_encrypted, client_key)
803
+ end
804
+
805
+ encrypted_data = Utils.base64_to_bytes(file_data['data'])
806
+ decrypted_json = Crypto.decrypt_aes_gcm(encrypted_data, app_key)
807
+
808
+ JSON.parse(decrypted_json).merge('fileKey' => file_data['fileKey'])
809
+ else
810
+ raise Error, "File not found: #{file_uid}"
811
+ end
812
+ end
813
+
814
+ # Download encrypted file from URL
815
+ def download_encrypted_file(url)
816
+ uri = URI(url)
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
+
829
+ if response.code == '200'
830
+ response.body
831
+ else
832
+ raise Error, "Failed to download file: #{response.code} #{response.message}"
833
+ end
834
+ end
835
+
836
+ private
837
+
838
+ # Process token binding
839
+ def process_token_binding(token, hostname = nil)
840
+ # Parse token
841
+ token = token.strip
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
+
849
+ # Modern format: REGION:BASE64_TOKEN
850
+ if token_parts.length >= 2
851
+ region = token_parts[0].upcase
852
+ @hostname = KeeperGlobals::KEEPER_SERVERS[region] || KeeperGlobals::DEFAULT_SERVER
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
874
+ else
875
+ # Legacy format
876
+ @token = token
877
+ @hostname = hostname || KeeperGlobals::DEFAULT_SERVER
878
+ end
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
+
884
+ # Bind the one-time token
885
+ bound_config = bind_one_time_token(@token, @hostname, effective_key_id, effective_public_key)
886
+
887
+ # Merge bound config into existing config if present
888
+ if @config
889
+ # Copy all values from bound config to existing config
890
+ bound_data = bound_config.instance_variable_get(:@data)
891
+ bound_data&.each do |key, value|
892
+ if value.is_a?(String)
893
+ @config.save_string(key, value)
894
+ else
895
+ @config.save_bytes(key, value)
896
+ end
897
+ end
898
+ else
899
+ @config = bound_config
900
+ end
901
+ end
902
+
903
+ # Bind one-time token
904
+ def bind_one_time_token(token, hostname, key_id = nil, server_public_key = nil)
905
+ storage = Storage::InMemoryStorage.new
906
+
907
+ # Generate EC key pair
908
+ keys = Crypto.generate_ecc_keys
909
+
910
+ # Convert token to bytes and create client ID hash
911
+ token_bytes = Utils.url_safe_str_to_bytes(token)
912
+ client_id_hash = OpenSSL::HMAC.digest(
913
+ 'SHA512',
914
+ token_bytes,
915
+ 'KEEPER_SECRETS_MANAGER_CLIENT_ID'
916
+ )
917
+ client_id = Utils.bytes_to_base64(client_id_hash)
918
+
919
+ # Store configuration
920
+ storage.save_string(ConfigKeys::KEY_HOSTNAME, hostname)
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?
925
+ storage.save_string(ConfigKeys::KEY_CLIENT_KEY, token)
926
+ storage.save_bytes(ConfigKeys::KEY_PRIVATE_KEY, keys[:private_key_bytes])
927
+ storage.save_string(ConfigKeys::KEY_CLIENT_ID, client_id)
928
+
929
+ # Prepare binding payload
930
+ payload = Dto::GetPayload.new
931
+ payload.client_version = KeeperGlobals.client_version
932
+ payload.client_id = client_id
933
+ payload.public_key = keys[:public_key_str]
934
+
935
+ # Send binding request
936
+ response = post_query('get_secret', payload, storage)
937
+ response_dict = JSON.parse(response)
938
+
939
+ # Process binding response
940
+ if response_dict['encryptedAppKey']
941
+ # Decrypt app key
942
+ encrypted_app_key = Utils.url_safe_str_to_bytes(response_dict['encryptedAppKey'])
943
+ client_key_bytes = Utils.url_safe_str_to_bytes(token)
944
+
945
+ app_key = Crypto.decrypt_aes_gcm(encrypted_app_key, client_key_bytes)
946
+ storage.save_bytes(ConfigKeys::KEY_APP_KEY, app_key)
947
+
948
+ # Store app owner public key if present
949
+ if response_dict['appOwnerPublicKey']
950
+ owner_key = Utils.url_safe_str_to_bytes(response_dict['appOwnerPublicKey'])
951
+ storage.save_bytes(ConfigKeys::KEY_OWNER_PUBLIC_KEY, owner_key)
952
+ end
953
+
954
+ # Clean up client key after successful binding
955
+ storage.delete(ConfigKeys::KEY_CLIENT_KEY)
956
+ else
957
+ raise Error, 'Failed to bind one-time token - no encrypted app key in response'
958
+ end
959
+
960
+ storage
961
+ end
962
+
963
+ # Fetch and decrypt secrets
964
+ def fetch_and_decrypt_secrets(query_options = nil)
965
+ payload = prepare_get_payload(query_options)
966
+
967
+ response = post_query('get_secret', payload)
968
+ response_dict = JSON.parse(response)
969
+
970
+ # Decrypt app key if present (during token binding)
971
+ if response_dict['encryptedAppKey']
972
+ encrypted_app_key = Utils.url_safe_str_to_bytes(response_dict['encryptedAppKey'])
973
+ client_key = Utils.url_safe_str_to_bytes(@config.get_string(ConfigKeys::KEY_CLIENT_KEY))
974
+
975
+ # Decrypt app key using AES with client key (the original token)
976
+ app_key = Crypto.decrypt_aes_gcm(encrypted_app_key, client_key)
977
+ @config.save_bytes(ConfigKeys::KEY_APP_KEY, app_key)
978
+
979
+ # Clean up client key after successful binding
980
+ @config.delete(ConfigKeys::KEY_CLIENT_KEY)
981
+
982
+ # Store app owner public key if present
983
+ if response_dict['appOwnerPublicKey']
984
+ owner_key = Utils.url_safe_str_to_bytes(response_dict['appOwnerPublicKey'])
985
+ @config.save_bytes(ConfigKeys::KEY_OWNER_PUBLIC_KEY, owner_key)
986
+ end
987
+
988
+ # Set just bound flag
989
+ just_bound = true
990
+ else
991
+ just_bound = false
992
+ end
993
+
994
+ # Get app key
995
+ app_key = @config.get_bytes(ConfigKeys::KEY_APP_KEY)
996
+ raise Error, 'No app key available' unless app_key
997
+
998
+ # Decrypt records
999
+ records = []
1000
+ if response_dict['records']
1001
+ response_dict['records'].each do |encrypted_record|
1002
+ record = decrypt_record(encrypted_record, app_key)
1003
+ records << record
1004
+ rescue StandardError => e
1005
+ @logger.error("Failed to decrypt record: #{e.message}")
1006
+ end
1007
+ end
1008
+
1009
+ # Decrypt folders - need to handle them in order for shared folder keys
1010
+ folders = []
1011
+ response_folders = response_dict['folders'] || []
1012
+
1013
+ # First pass - decrypt folders in order
1014
+ response_folders.each do |encrypted_folder|
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?
1020
+ end
1021
+ rescue StandardError => e
1022
+ @logger.error("Failed to decrypt folder: #{e.message}")
1023
+ end
1024
+
1025
+ # Build response
1026
+ response = Dto::SecretsManagerResponse.new(
1027
+ records: records,
1028
+ folders: folders,
1029
+ warnings: response_dict['warnings']
1030
+ )
1031
+
1032
+ response.just_bound = just_bound if response.respond_to?(:just_bound=)
1033
+ response
1034
+ end
1035
+
1036
+ # Decrypt record
1037
+ def decrypt_record(encrypted_record, app_key)
1038
+ record_uid = encrypted_record['recordUid']
1039
+ record_key_encrypted = Utils.base64_to_bytes(encrypted_record['recordKey'])
1040
+ data_encrypted = Utils.base64_to_bytes(encrypted_record['data'])
1041
+
1042
+ # Decrypt record key
1043
+ record_key = Crypto.decrypt_aes_gcm(record_key_encrypted, app_key)
1044
+
1045
+ # Decrypt data
1046
+ data_json = Crypto.decrypt_aes_gcm(data_encrypted, record_key)
1047
+ data = JSON.parse(data_json)
1048
+
1049
+ # Decrypt files if present
1050
+ decrypted_files = []
1051
+ if encrypted_record['files']
1052
+ encrypted_record['files'].each do |file|
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 = {}
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}")
1082
+ end
1083
+ end
1084
+
1085
+ # Create record object
1086
+ record = Dto::KeeperRecord.new(
1087
+ 'recordUid' => record_uid,
1088
+ 'folderUid' => encrypted_record['folderUid'],
1089
+ 'innerFolderUid' => encrypted_record['innerFolderUid'],
1090
+ 'isEditable' => encrypted_record['isEditable'],
1091
+ 'data' => data,
1092
+ 'revision' => encrypted_record['revision'],
1093
+ 'files' => decrypted_files,
1094
+ 'links' => encrypted_record['links'] || []
1095
+ )
1096
+
1097
+ # Store record key for later use (e.g., file downloads)
1098
+ record.instance_variable_set(:@record_key, record_key)
1099
+ record.define_singleton_method(:record_key) { @record_key }
1100
+
1101
+ record
1102
+ end
1103
+
1104
+ # Get shared folder key by traversing up the folder hierarchy
1105
+ def get_shared_folder_key(folders, response_folders, parent_uid)
1106
+ while parent_uid
1107
+ # Find parent folder in response
1108
+ parent_folder = response_folders.find { |f| f['folderUid'] == parent_uid }
1109
+ return nil unless parent_folder
1110
+
1111
+ # If parent has no parent, it's the shared folder root
1112
+ if !parent_folder['parent'] || parent_folder['parent'].empty?
1113
+ # Find the decrypted folder object
1114
+ shared_folder = folders.find { |f| f.uid == parent_uid }
1115
+ return shared_folder&.folder_key
1116
+ end
1117
+
1118
+ # Continue up the hierarchy
1119
+ parent_uid = parent_folder['parent']
1120
+ end
1121
+
1122
+ nil
1123
+ end
1124
+
1125
+ # Decrypt folder
1126
+ def decrypt_folder(encrypted_folder, app_key, existing_folders = [], response_folders = [])
1127
+ folder_uid = encrypted_folder['folderUid']
1128
+ folder_parent = encrypted_folder['parent']
1129
+
1130
+ @logger.debug("Decrypting folder #{folder_uid}, parent: #{folder_parent || 'none'}")
1131
+
1132
+ # Determine the decryption key to use
1133
+ decryption_key = if !folder_parent || folder_parent.empty?
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
+
1148
+ # Some folders might not have encryption data
1149
+ unless encrypted_folder['folderKey']
1150
+ # Create a basic folder object without decrypted data
1151
+ return Dto::KeeperFolder.new(
1152
+ 'folderUid' => folder_uid,
1153
+ 'folderKey' => nil,
1154
+ 'data' => {},
1155
+ 'name' => encrypted_folder['name'] || folder_uid,
1156
+ 'parent' => folder_parent,
1157
+ 'records' => []
1158
+ )
1159
+ end
1160
+
1161
+ # Decrypt folder key
1162
+ folder_key_encrypted = Utils.base64_to_bytes(encrypted_folder['folderKey'])
1163
+ folder_key = if !folder_parent || folder_parent.empty?
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
+
1171
+ # Get folder name - either from encrypted data or direct field
1172
+ folder_name = ''
1173
+ folder_type = nil
1174
+
1175
+ # Check if there's a direct name field (unencrypted)
1176
+ if encrypted_folder['name']
1177
+ folder_name = encrypted_folder['name']
1178
+ @logger.debug("Using direct name field for folder #{folder_uid}: #{folder_name}")
1179
+ elsif encrypted_folder['data'] && !encrypted_folder['data'].empty?
1180
+ # Decrypt folder data if present
1181
+ begin
1182
+ data_encrypted = Utils.base64_to_bytes(encrypted_folder['data'])
1183
+ # Folder data always uses CBC
1184
+ data_json = Crypto.decrypt_aes_cbc(data_encrypted, folder_key)
1185
+ data = JSON.parse(data_json)
1186
+ folder_name = data['name'] || ''
1187
+ folder_type = data['folderType']
1188
+ @logger.debug("Successfully decrypted folder #{folder_uid}: #{folder_name}")
1189
+ rescue StandardError => e
1190
+ @logger.error("Failed to decrypt folder data for #{folder_uid}: #{e.class} - #{e.message}")
1191
+ @logger.debug("Backtrace: #{e.backtrace.first(3).join("\n")}")
1192
+ end
1193
+ else
1194
+ @logger.debug("Folder #{folder_uid} has no name or data field - using UID as name")
1195
+ folder_name = folder_uid
1196
+ end
1197
+
1198
+ # Decrypt records in this folder
1199
+ folder_records = []
1200
+ if encrypted_folder['records']
1201
+ encrypted_folder['records'].each do |encrypted_record|
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}")
1210
+ end
1211
+ end
1212
+
1213
+ # Create folder object
1214
+ Dto::KeeperFolder.new(
1215
+ 'folderUid' => folder_uid,
1216
+ 'name' => folder_name,
1217
+ 'folderType' => folder_type,
1218
+ 'folderKey' => folder_key,
1219
+ 'parent' => folder_parent,
1220
+ 'records' => folder_records
1221
+ )
1222
+ end
1223
+
1224
+ # Prepare get payload
1225
+ def prepare_get_payload(query_options = nil)
1226
+ payload = Dto::GetPayload.new
1227
+ payload.client_version = KeeperGlobals.client_version
1228
+
1229
+ # Client ID should be URL-safe base64
1230
+ client_id_str = @config.get_string(ConfigKeys::KEY_CLIENT_ID)
1231
+ payload.client_id = client_id_str
1232
+
1233
+ @logger.debug("Client ID for payload: #{client_id_str}")
1234
+
1235
+ # Public key is sent during initial binding only
1236
+
1237
+ if query_options
1238
+ payload.requested_records = query_options.records_filter
1239
+ payload.requested_folders = query_options.folders_filter
1240
+ payload.request_links = query_options.request_links if query_options.request_links
1241
+ end
1242
+
1243
+ payload
1244
+ end
1245
+
1246
+ # Prepare create payload
1247
+ def prepare_create_payload(record_uid:, record_key:, folder_uid:, folder_key:, data:, subfolder_uid: nil)
1248
+ owner_public_key = @config.get_string(ConfigKeys::KEY_OWNER_PUBLIC_KEY)
1249
+ unless owner_public_key && !owner_public_key.empty?
1250
+ raise Error, 'Unable to create record - owner key is missing. Looks like application was created ' \
1251
+ 'using out of date client (Web Vault or Commander)'
1252
+ end
1253
+
1254
+ owner_public_key_bytes = Utils.url_safe_str_to_bytes(owner_public_key)
1255
+ record_key_encrypted = Crypto.encrypt_ec(record_key, owner_public_key_bytes)
1256
+
1257
+ payload = Dto::CreatePayload.new
1258
+ payload.client_version = KeeperGlobals.client_version
1259
+ payload.client_id = @config.get_string(ConfigKeys::KEY_CLIENT_ID)
1260
+ payload.record_uid = record_uid
1261
+ payload.record_key = Utils.bytes_to_base64(record_key_encrypted)
1262
+ payload.folder_uid = folder_uid
1263
+ payload.sub_folder_uid = subfolder_uid
1264
+ payload.data = Utils.bytes_to_base64(data)
1265
+
1266
+ # Encrypt the record key with the folder key
1267
+ if folder_key
1268
+ folder_key_encrypted = Crypto.encrypt_aes_gcm(record_key, folder_key)
1269
+ payload.folder_key = Utils.bytes_to_base64(folder_key_encrypted)
1270
+ end
1271
+
1272
+ payload
1273
+ end
1274
+
1275
+ # Other payload preparation methods...
1276
+
1277
+ # Post query to API
1278
+ def post_query(path, payload, config = nil)
1279
+ config ||= @config
1280
+ server = get_server(@hostname)
1281
+ url = "https://#{server}/api/rest/sm/v1/#{path}"
1282
+
1283
+ throttle_attempt = 0
1284
+ loop do
1285
+ # Generate transmission key
1286
+ key_id = config.get_string(ConfigKeys::KEY_SERVER_PUBLIC_KEY_ID) || DEFAULT_KEY_ID
1287
+ # a custom (IL5) server public key in config overrides the built-in key table.
1288
+ custom_public_key = config.get_string(ConfigKeys::KEY_SERVER_PUBLIC_KEY)
1289
+ transmission_key = generate_transmission_key(key_id, custom_public_key)
1290
+
1291
+ # Encrypt and sign payload
1292
+ encrypted_payload = encrypt_and_sign_payload(config, transmission_key, payload)
1293
+
1294
+ # Make request
1295
+ # Use custom post function for read-only operations (get_secret, get_folders)
1296
+ # This enables caching for disaster recovery
1297
+ response = if @custom_post_function && (path == 'get_secret' || path == 'get_folders')
1298
+ @custom_post_function.call(url, transmission_key, encrypted_payload, @verify_ssl_certs)
1299
+ else
1300
+ post_function(url, transmission_key, encrypted_payload)
1301
+ end
1302
+
1303
+ # Handle response
1304
+ if response.success?
1305
+ # Decrypt response if present
1306
+ if response.data && !response.data.empty?
1307
+ return Crypto.decrypt_aes_gcm(response.data, transmission_key.key)
1308
+ else
1309
+ return response.data
1310
+ end
1311
+ else
1312
+ # Throttle retry with exponential backoff + jitter (KSM-876 / KSM-883). Checked before
1313
+ # handle_http_error (which still drives key-rotation retry), and gated on the 403 status
1314
+ # so a non-403 response carrying a {"error":"throttled"} body is not retried.
1315
+ retry_after = parse_throttle(response)
1316
+ if response.status_code == 403 && !retry_after.nil?
1317
+ if throttle_attempt >= MAX_THROTTLE_RETRIES
1318
+ raise ThrottledError.new('throttled',
1319
+ "Request throttled by Keeper backend; exhausted #{MAX_THROTTLE_RETRIES} retries")
1320
+ end
1321
+ delay = throttle_delay(throttle_attempt, retry_after)
1322
+ @logger.warn("Request throttled (attempt #{throttle_attempt + 1}/#{MAX_THROTTLE_RETRIES}); " \
1323
+ "retrying in #{delay.round(1)}s")
1324
+ sleep(delay)
1325
+ throttle_attempt += 1
1326
+ next
1327
+ end
1328
+
1329
+ handle_http_error(response, config)
1330
+ end
1331
+ end
1332
+ end
1333
+
1334
+ # Generate transmission key
1335
+ def generate_transmission_key(key_id, custom_public_key = nil)
1336
+ # Get server public key. A custom (e.g. IL5) key overrides the built-in table,
1337
+ # which only covers ids 1..18 - IL5 uses id 20 supplied out-of-band via the OTT/config.
1338
+ if custom_public_key && !custom_public_key.empty?
1339
+ server_public_key_str = custom_public_key
1340
+ else
1341
+ server_public_key_str = KeeperGlobals::KEEPER_PUBLIC_KEYS[key_id.to_s]
1342
+ raise Error, "Unknown public key ID: #{key_id}" unless server_public_key_str
1343
+ end
1344
+
1345
+ @logger.debug("Using server public key ID: #{key_id}")
1346
+ @logger.debug("Server public key string: #{server_public_key_str[0..20]}...")
1347
+
1348
+ # Generate random key
1349
+ key = Crypto.generate_encryption_key_bytes
1350
+ @logger.debug("Generated transmission key: #{Utils.bytes_to_base64(key)[0..20]}...")
1351
+
1352
+ # Encrypt key with server public key
1353
+ server_public_key = Utils.url_safe_str_to_bytes(server_public_key_str)
1354
+ @logger.debug("Server public key bytes length: #{server_public_key.bytesize}")
1355
+
1356
+ encrypted_key = Crypto.encrypt_ec(key, server_public_key)
1357
+ @logger.debug("Encrypted key length: #{encrypted_key.bytesize}")
1358
+
1359
+ Dto::TransmissionKey.new(
1360
+ public_key_id: key_id,
1361
+ key: key,
1362
+ encrypted_key: encrypted_key
1363
+ )
1364
+ end
1365
+
1366
+ # Encrypt and sign payload
1367
+ def encrypt_and_sign_payload(config, transmission_key, payload)
1368
+ # Convert payload to JSON
1369
+ payload_json = payload.to_json
1370
+
1371
+ @logger.debug("Payload: #{payload_json}")
1372
+
1373
+ # Encrypt payload
1374
+ encrypted_payload = Crypto.encrypt_aes_gcm(payload_json, transmission_key.key)
1375
+
1376
+ # Generate signature
1377
+ signature_base = transmission_key.encrypted_key + encrypted_payload
1378
+
1379
+ # After binding, use ECDSA signature with private key (not HMAC)
1380
+ private_key_bytes = config.get_bytes(ConfigKeys::KEY_PRIVATE_KEY)
1381
+ if private_key_bytes
1382
+ # Load private key
1383
+ private_key = load_ec_private_key(private_key_bytes)
1384
+
1385
+ # Generate ECDSA signature
1386
+ signature = Crypto.sign_ec(signature_base, private_key)
1387
+ @logger.debug("Using ECDSA signature, length: #{signature.bytesize}")
1388
+ else
1389
+ # Fallback to HMAC with client key (for one-time token binding)
1390
+ client_key = config.get_string(ConfigKeys::KEY_CLIENT_KEY)
1391
+ if client_key
1392
+ signature_key = Utils.base64_to_bytes(client_key)
1393
+ signature = Crypto.generate_hmac(signature_key, signature_base)
1394
+ @logger.debug("Using HMAC signature, length: #{signature.bytesize}")
1395
+ else
1396
+ raise Error, 'No key available for signature'
1397
+ end
1398
+ end
1399
+
1400
+ Dto::EncryptedPayload.new(
1401
+ encrypted_payload: encrypted_payload,
1402
+ signature: signature
1403
+ )
1404
+ end
1405
+
1406
+ # Create Net::HTTP instance with proxy support
1407
+ # Configures proxy if @proxy_url is set
1408
+ def create_http_client(uri)
1409
+ if @proxy_url
1410
+ # Parse proxy URL
1411
+ proxy_uri = URI(@proxy_url)
1412
+
1413
+ # Create HTTP client with proxy
1414
+ http = Net::HTTP.new(
1415
+ uri.host,
1416
+ uri.port,
1417
+ proxy_uri.host,
1418
+ proxy_uri.port,
1419
+ proxy_uri.user,
1420
+ proxy_uri.password
1421
+ )
1422
+
1423
+ @logger.debug("Using HTTP proxy: #{proxy_uri.host}:#{proxy_uri.port}")
1424
+ @logger.debug("Proxy authentication: #{proxy_uri.user ? 'yes' : 'no'}")
1425
+ else
1426
+ # Create HTTP client without proxy
1427
+ http = Net::HTTP.new(uri.host, uri.port)
1428
+ end
1429
+
1430
+ http
1431
+ end
1432
+
1433
+ # Configure SSL for HTTP connection
1434
+ # Sets up certificate store and verification mode
1435
+ def configure_http_ssl(http)
1436
+ http.use_ssl = true
1437
+
1438
+ if @verify_ssl_certs
1439
+ http.verify_mode = OpenSSL::SSL::VERIFY_PEER
1440
+
1441
+ # Set up certificate store with system defaults
1442
+ # This ensures we use the system's trusted CA certificates
1443
+ store = OpenSSL::X509::Store.new
1444
+ store.set_default_paths
1445
+
1446
+ # Note: We don't enable CRL checking by default because:
1447
+ # 1. CRL endpoints may be temporarily unreachable
1448
+ # 2. Many enterprise networks block CRL endpoints
1449
+ # 3. Certificate chain validation still provides strong security
1450
+ # If strict CRL checking is needed, it can be added via configuration option
1451
+ http.cert_store = store
1452
+
1453
+ @logger.debug('SSL verification enabled with system certificate store')
1454
+ @logger.debug("OpenSSL version: #{OpenSSL::OPENSSL_VERSION}")
1455
+ else
1456
+ http.verify_mode = OpenSSL::SSL::VERIFY_NONE
1457
+ @logger.warn('SSL verification disabled - not recommended for production')
1458
+ end
1459
+ end
1460
+
1461
+ # HTTP post function
1462
+ def post_function(url, transmission_key, encrypted_payload)
1463
+ uri = URI(url)
1464
+
1465
+ @logger.debug("POST URL: #{url}")
1466
+ @logger.debug("PublicKeyId header: #{transmission_key.public_key_id}")
1467
+ @logger.debug("TransmissionKey header: #{Utils.bytes_to_base64(transmission_key.encrypted_key)[0..50]}...")
1468
+ @logger.debug("TransmissionKey full base64 length: #{Utils.bytes_to_base64(transmission_key.encrypted_key).length}")
1469
+ @logger.debug("Signature header: #{Utils.bytes_to_base64(encrypted_payload.signature)[0..50]}...")
1470
+ @logger.debug("Request body length: #{encrypted_payload.encrypted_payload.bytesize} bytes")
1471
+
1472
+ request = Net::HTTP::Post.new(uri)
1473
+ request['Content-Type'] = 'application/octet-stream'
1474
+ request['PublicKeyId'] = transmission_key.public_key_id.to_s
1475
+ request['TransmissionKey'] = Utils.bytes_to_base64(transmission_key.encrypted_key)
1476
+ request['Authorization'] = "Signature #{Utils.bytes_to_base64(encrypted_payload.signature)}"
1477
+ request['Content-Length'] = encrypted_payload.encrypted_payload.bytesize.to_s
1478
+ request.body = encrypted_payload.encrypted_payload
1479
+
1480
+ http = create_http_client(uri)
1481
+ configure_http_ssl(http)
1482
+
1483
+ response = http.request(request)
1484
+
1485
+ @logger.debug("Response status: #{response.code}")
1486
+ @logger.debug("Response body length: #{response.body&.bytesize || 0} bytes")
1487
+ @logger.debug("Response body (first 100 bytes): #{response.body&.[](0..100).inspect}") if response.body && !response.body.empty?
1488
+
1489
+ Dto::KSMHttpResponse.new(
1490
+ status_code: response.code.to_i,
1491
+ data: response.body,
1492
+ http_response: response
1493
+ )
1494
+ rescue StandardError => e
1495
+ raise NetworkError, "HTTP request failed: #{e.message}"
1496
+ end
1497
+
1498
+ # Handle HTTP errors
1499
+ def handle_http_error(response, config = nil)
1500
+ error_data = JSON.parse(response.data)
1501
+ result_code = error_data['result_code'] || error_data['error']
1502
+ message = error_data['message']
1503
+
1504
+ @logger.debug("Server error response: #{error_data.inspect}")
1505
+
1506
+ # Handle specific errors
1507
+ case result_code
1508
+ when 'key'
1509
+ # Server wants different key
1510
+ key_id = error_data['key_id']
1511
+ # Use passed config or fall back to instance config
1512
+ config_to_use = config || @config
1513
+ custom_key = config_to_use&.get_string(ConfigKeys::KEY_SERVER_PUBLIC_KEY)
1514
+
1515
+ # when a custom (IL5) server public key is configured, the server rejecting it
1516
+ # cannot be fixed by switching to a built-in key, and retrying would loop on the same key.
1517
+ # Fail fast with actionable remediation instead.
1518
+ if custom_key && !custom_key.empty?
1519
+ raise Error, "Server rejected the custom server public key (requested key ID #{key_id.inspect}). " \
1520
+ 'The IL5 server public key may have rotated; update your IL5 KSM configuration ' \
1521
+ '(serverPublicKey / serverPublicKeyId), e.g. by redeeming a freshly issued one-time token.'
1522
+ end
1523
+
1524
+ raise Error, 'The public key is blank from the server' if key_id.nil?
1525
+ unless KeeperGlobals::KEEPER_PUBLIC_KEYS.key?(key_id.to_s)
1526
+ raise Error, "The public key at #{key_id} does not exist in the SDK"
1527
+ end
1528
+
1529
+ @logger.info("Server requested key ID: #{key_id}")
1530
+ config_to_use.save_string(ConfigKeys::KEY_SERVER_PUBLIC_KEY_ID, key_id.to_s) if config_to_use
1531
+ nil # Retry
1532
+ else
1533
+ raise ErrorFactory.from_server_response(result_code, message)
1534
+ end
1535
+ rescue JSON::ParserError
1536
+ raise NetworkError.new("Server error: HTTP #{response.status_code}",
1537
+ status_code: response.status_code,
1538
+ response_body: response.data)
1539
+ end
1540
+
1541
+ # Returns the throttle retry_after (>= 0) when +response+ is a backend throttle error
1542
+ # (result_code/error == "throttled"), otherwise nil so the caller falls through to
1543
+ # handle_http_error. Non-JSON / non-object bodies return nil. (KSM-876 / KSM-883)
1544
+ def parse_throttle(response)
1545
+ data = JSON.parse(response.data)
1546
+ return nil unless data.is_a?(Hash)
1547
+
1548
+ result_code = data['result_code'] || data['error']
1549
+ return nil unless result_code == 'throttled'
1550
+
1551
+ retry_after = begin
1552
+ Float(data['retry_after'])
1553
+ rescue ArgumentError, TypeError
1554
+ 0.0
1555
+ end
1556
+ retry_after.negative? ? 0.0 : retry_after
1557
+ rescue JSON::ParserError
1558
+ nil
1559
+ end
1560
+
1561
+ # Backoff delay (seconds) for a 0-based attempt: retry_after when > 0, otherwise exponential
1562
+ # backoff (BASE_THROTTLE_DELAY_SEC * 2**attempt -> 11, 22, 44, 88, 176s). A 0 to +25% jitter
1563
+ # is applied (one-sided so delay is always >= floor, preventing a retry before the backend's
1564
+ # 10s memcached window expires).
1565
+ def throttle_delay(attempt, retry_after = 0)
1566
+ base = retry_after.to_f.positive? ? retry_after.to_f : BASE_THROTTLE_DELAY_SEC * (2**attempt)
1567
+ base = MAX_THROTTLE_DELAY_SEC if base > MAX_THROTTLE_DELAY_SEC
1568
+ delay = base + base * rand(0.0..0.25)
1569
+ delay.negative? ? 0.0 : delay
1570
+ end
1571
+
1572
+ # Get server hostname
1573
+ def get_server(hostname)
1574
+ return hostname if hostname.include?('.')
1575
+
1576
+ # Look up in server list
1577
+ KeeperGlobals::KEEPER_SERVERS[hostname.upcase] || hostname
1578
+ end
1579
+
1580
+ # Load EC private key from bytes
1581
+ def load_ec_private_key(private_key_bytes)
1582
+ # If it's already a key object, return it
1583
+ return private_key_bytes if private_key_bytes.is_a?(OpenSSL::PKey::EC)
1584
+
1585
+ @logger.debug("Loading private key, bytes length: #{private_key_bytes.bytesize}")
1586
+
1587
+ # Try to load as DER format first
1588
+ begin
1589
+ key = OpenSSL::PKey.read(private_key_bytes, nil)
1590
+ # Ensure it's an EC key
1591
+ if key.is_a?(OpenSSL::PKey::EC)
1592
+ @logger.debug('Successfully loaded EC private key from DER format')
1593
+ key
1594
+ else
1595
+ raise "Not an EC key, got #{key.class}"
1596
+ end
1597
+ rescue StandardError => e
1598
+ @logger.debug("DER format failed: #{e.message}, trying raw bytes")
1599
+
1600
+ # If DER fails, it might be raw key bytes (32 bytes)
1601
+ if private_key_bytes.bytesize == 32
1602
+ begin
1603
+ # Create EC key from raw bytes (OpenSSL 3.0 compatible)
1604
+ group = OpenSSL::PKey::EC::Group.new('prime256v1')
1605
+
1606
+ # Generate key components
1607
+ private_key_bn = OpenSSL::BN.new(private_key_bytes, 2)
1608
+ public_key_point = group.generator.mul(private_key_bn)
1609
+
1610
+ # Create ASN1 sequence for the key
1611
+ asn1 = OpenSSL::ASN1::Sequence([
1612
+ OpenSSL::ASN1::Integer(1),
1613
+ OpenSSL::ASN1::OctetString(private_key_bytes),
1614
+ OpenSSL::ASN1::ObjectId('prime256v1', 0, :EXPLICIT),
1615
+ OpenSSL::ASN1::BitString(
1616
+ public_key_point.to_octet_string(:uncompressed), 1, :EXPLICIT
1617
+ )
1618
+ ])
1619
+
1620
+ # Create key from DER
1621
+ key = OpenSSL::PKey::EC.new(asn1.to_der)
1622
+
1623
+ @logger.debug('Successfully created EC key from raw bytes')
1624
+ key
1625
+ rescue StandardError => raw_error
1626
+ @logger.debug("Raw bytes failed: #{raw_error.message}")
1627
+ raise CryptoError, "Failed to load private key: DER: #{e.message}, Raw: #{raw_error.message}"
1628
+ end
1629
+ else
1630
+ raise CryptoError,
1631
+ "Failed to load private key: #{e.message} (got #{private_key_bytes.bytesize} bytes)"
1632
+ end
1633
+ end
1634
+ end
1635
+
1636
+ # Other helper methods...
1637
+ def prepare_update_payload(record_uid:, record_data:, record_key:, revision:, update_options: nil)
1638
+ payload = Dto::UpdatePayload.new
1639
+ payload.client_version = KeeperGlobals.client_version
1640
+ payload.client_id = @config.get_string(ConfigKeys::KEY_CLIENT_ID)
1641
+ payload.record_uid = record_uid
1642
+ payload.revision = revision
1643
+
1644
+ if update_options
1645
+ # nil clears UpdatePayload's 'general' default (BasePayload#to_h skips nil fields).
1646
+ payload.transaction_type = update_options.transaction_type
1647
+
1648
+ # Handle links_to_remove
1649
+ if update_options.links_to_remove && !update_options.links_to_remove.empty?
1650
+ payload.links2_remove = update_options.links_to_remove
1651
+
1652
+ # Filter fileRef field values - remove specified link UIDs from record data
1653
+ # This modifies the data hash before encryption (matches Python SDK behavior)
1654
+ fileref_field = record_data['fields']&.find { |f| f['type'] == 'fileRef' }
1655
+ if fileref_field && fileref_field['value'].is_a?(Array)
1656
+ original_values = fileref_field['value']
1657
+ filtered_values = original_values.reject { |uid| update_options.links_to_remove.include?(uid) }
1658
+ fileref_field['value'] = filtered_values if filtered_values.length != original_values.length
1659
+ end
1660
+ end
1661
+ end
1662
+ # When update_options is nil: UpdatePayload keeps its default 'general' from #initialize.
1663
+
1664
+ # Encrypt record data
1665
+ json_data = Utils.dict_to_json(record_data)
1666
+ encrypted_data = Crypto.encrypt_aes_gcm(json_data, record_key)
1667
+ payload.data = Utils.bytes_to_base64(encrypted_data)
1668
+
1669
+ payload
1670
+ end
1671
+
1672
+ def prepare_delete_payload(record_uids)
1673
+ payload = Dto::DeletePayload.new
1674
+ payload.client_version = KeeperGlobals.client_version
1675
+ payload.client_id = @config.get_string(ConfigKeys::KEY_CLIENT_ID)
1676
+ payload.record_uids = record_uids
1677
+ payload
1678
+ end
1679
+
1680
+ def prepare_complete_transaction_payload(record_uid)
1681
+ payload = Dto::CompleteTransactionPayload.new
1682
+ payload.client_version = KeeperGlobals.client_version
1683
+ payload.client_id = @config.get_string(ConfigKeys::KEY_CLIENT_ID)
1684
+ payload.record_uid = record_uid
1685
+ payload
1686
+ end
1687
+
1688
+ def prepare_create_folder_payload(folder_uid:, shared_folder_uid:, encrypted_folder_key:, data:, parent_uid:)
1689
+ payload = Dto::CreateFolderPayload.new
1690
+ payload.client_version = KeeperGlobals.client_version
1691
+ payload.client_id = @config.get_string(ConfigKeys::KEY_CLIENT_ID)
1692
+ payload.folder_uid = folder_uid
1693
+ payload.shared_folder_uid = shared_folder_uid
1694
+ payload.data = Utils.bytes_to_url_safe_str(data)
1695
+ payload.parent_uid = parent_uid
1696
+
1697
+ # Use encrypted folder key (already encrypted with shared folder's key using AES-CBC)
1698
+ payload.shared_folder_key = Utils.bytes_to_url_safe_str(encrypted_folder_key)
1699
+
1700
+ payload
1701
+ end
1702
+
1703
+ def prepare_update_folder_payload(folder_uid:, data:)
1704
+ payload = Dto::UpdateFolderPayload.new
1705
+ payload.client_version = KeeperGlobals.client_version
1706
+ payload.client_id = @config.get_string(ConfigKeys::KEY_CLIENT_ID)
1707
+ payload.folder_uid = folder_uid
1708
+ payload.data = Utils.bytes_to_url_safe_str(data)
1709
+ payload
1710
+ end
1711
+
1712
+ def prepare_delete_folder_payload(folder_uids, force)
1713
+ payload = Dto::DeleteFolderPayload.new
1714
+ payload.client_version = KeeperGlobals.client_version
1715
+ payload.client_id = @config.get_string(ConfigKeys::KEY_CLIENT_ID)
1716
+ payload.folder_uids = folder_uids
1717
+ payload.force_deletion = force
1718
+ payload
1719
+ end
1720
+
1721
+ 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:)
1722
+ payload = Dto::FileUploadPayload.new
1723
+ payload.client_version = KeeperGlobals.client_version
1724
+ payload.client_id = @config.get_string(ConfigKeys::KEY_CLIENT_ID)
1725
+ payload.file_record_uid = file_record_uid
1726
+ payload.file_record_key = Utils.bytes_to_base64(file_record_key)
1727
+ payload.file_record_data = Utils.bytes_to_base64(file_record_data)
1728
+ payload.owner_record_uid = owner_record_uid
1729
+ payload.owner_record_data = Utils.bytes_to_base64(owner_record_data)
1730
+ payload.owner_record_revision = owner_record_revision
1731
+ payload.link_key = Utils.bytes_to_base64(link_key)
1732
+ payload.file_size = file_size
1733
+ payload
1734
+ end
1735
+
1736
+ def upload_file_function(url, parameters, encrypted_file_data)
1737
+ uri = URI(url)
1738
+
1739
+ # Parse parameters if it's a JSON string
1740
+ params_hash = parameters.is_a?(String) ? JSON.parse(parameters) : parameters
1741
+
1742
+ # Build multipart form data
1743
+ # Form data should include both the file and the parameters
1744
+ form_data = params_hash.merge({ 'file' => encrypted_file_data })
1745
+
1746
+ request = Net::HTTP::Post.new(uri)
1747
+
1748
+ # Set form with file upload
1749
+ # The file field needs to be handled as binary data
1750
+ boundary = "----RubyMultipartPost#{rand(1000000)}"
1751
+ body = []
1752
+
1753
+ # Add regular form fields first
1754
+ params_hash.each do |key, value|
1755
+ body << "--#{boundary}\r\n"
1756
+ body << "Content-Disposition: form-data; name=\"#{key}\"\r\n\r\n"
1757
+ body << "#{value}\r\n"
1758
+ end
1759
+
1760
+ # Add file field
1761
+ body << "--#{boundary}\r\n"
1762
+ body << "Content-Disposition: form-data; name=\"file\"; filename=\"file\"\r\n"
1763
+ body << "Content-Type: application/octet-stream\r\n\r\n"
1764
+ body << encrypted_file_data
1765
+ body << "\r\n--#{boundary}--\r\n"
1766
+
1767
+ request.body = body.join
1768
+ request['Content-Type'] = "multipart/form-data; boundary=#{boundary}"
1769
+
1770
+ http = create_http_client(uri)
1771
+ configure_http_ssl(http)
1772
+
1773
+ response = http.request(request)
1774
+
1775
+ raise NetworkError, "File upload failed: HTTP #{response.code} - #{response.body}" unless response.code.to_i.between?(200, 299)
1776
+
1777
+ true
1778
+ end
1779
+ end
1780
+ end
1781
+ end