keeper_secrets_manager 17.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +3 -0
  3. data/.ruby-version +1 -0
  4. data/CHANGELOG.md +49 -0
  5. data/Gemfile +13 -0
  6. data/LICENSE +21 -0
  7. data/README.md +305 -0
  8. data/Rakefile +30 -0
  9. data/examples/basic_usage.rb +139 -0
  10. data/examples/config_string_example.rb +99 -0
  11. data/examples/debug_secrets.rb +84 -0
  12. data/examples/demo_list_secrets.rb +182 -0
  13. data/examples/download_files.rb +100 -0
  14. data/examples/flexible_records_example.rb +94 -0
  15. data/examples/folder_hierarchy_demo.rb +109 -0
  16. data/examples/full_demo.rb +176 -0
  17. data/examples/my_test_standalone.rb +176 -0
  18. data/examples/simple_test.rb +162 -0
  19. data/examples/storage_examples.rb +126 -0
  20. data/lib/keeper_secrets_manager/config_keys.rb +27 -0
  21. data/lib/keeper_secrets_manager/core.rb +1231 -0
  22. data/lib/keeper_secrets_manager/crypto.rb +348 -0
  23. data/lib/keeper_secrets_manager/dto/payload.rb +152 -0
  24. data/lib/keeper_secrets_manager/dto.rb +221 -0
  25. data/lib/keeper_secrets_manager/errors.rb +79 -0
  26. data/lib/keeper_secrets_manager/field_types.rb +152 -0
  27. data/lib/keeper_secrets_manager/folder_manager.rb +114 -0
  28. data/lib/keeper_secrets_manager/keeper_globals.rb +59 -0
  29. data/lib/keeper_secrets_manager/notation.rb +354 -0
  30. data/lib/keeper_secrets_manager/notation_enhancements.rb +67 -0
  31. data/lib/keeper_secrets_manager/storage.rb +254 -0
  32. data/lib/keeper_secrets_manager/totp.rb +140 -0
  33. data/lib/keeper_secrets_manager/utils.rb +196 -0
  34. data/lib/keeper_secrets_manager/version.rb +3 -0
  35. data/lib/keeper_secrets_manager.rb +38 -0
  36. metadata +82 -0
@@ -0,0 +1,1231 @@
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
+ # Field types that can be inflated
16
+ INFLATE_REF_TYPES = {
17
+ 'addressRef' => ['address'],
18
+ 'cardRef' => ['paymentCard', 'text', 'pinCode', 'addressRef']
19
+ }.freeze
20
+
21
+ def initialize(options = {})
22
+ # Check Ruby version
23
+ if RUBY_VERSION < '2.6'
24
+ raise Error, 'KSM SDK requires Ruby 2.6 or greater'
25
+ end
26
+
27
+ # Check AES-GCM support
28
+ begin
29
+ OpenSSL::Cipher.new('AES-256-GCM')
30
+ rescue RuntimeError => e
31
+ if e.message.include?('unsupported cipher')
32
+ raise Error, "KSM SDK requires AES-GCM support. Your Ruby/OpenSSL version (#{OpenSSL::OPENSSL_LIBRARY_VERSION}) does not support AES-256-GCM. Please upgrade to Ruby 2.7+ or use a Ruby compiled with OpenSSL 1.1.0+"
33
+ end
34
+ raise e
35
+ end
36
+
37
+ @token = nil
38
+ @hostname = nil
39
+ @verify_ssl_certs = options.fetch(:verify_ssl_certs, true)
40
+ @custom_post_function = options[:custom_post_function]
41
+
42
+ # Set up logging
43
+ @logger = options[:logger] || Logger.new(STDOUT)
44
+ @logger.level = options[:log_level] || Logger::WARN
45
+
46
+ # Handle configuration
47
+ config = options[:config]
48
+ token = options[:token]
49
+
50
+ # Check environment variable if no config provided
51
+ if config.nil? && ENV['KSM_CONFIG']
52
+ config = Storage::InMemoryStorage.new(ENV['KSM_CONFIG'])
53
+ end
54
+
55
+ # If we have config, check if it's already initialized
56
+ if config
57
+ @config = config
58
+ # Check if already bound (has client ID and app key)
59
+ if @config.get_string(ConfigKeys::KEY_CLIENT_ID) && @config.get_bytes(ConfigKeys::KEY_APP_KEY)
60
+ @logger.debug("Using existing credentials from config")
61
+ elsif token
62
+ # Config exists but not bound, use token to bind
63
+ @logger.debug("Config provided but not bound, using token to initialize")
64
+ process_token_binding(token, options[:hostname])
65
+ else
66
+ @logger.warn("Config provided but no credentials found and no token provided")
67
+ end
68
+ elsif token
69
+ # No config provided, create new one with token
70
+ @logger.debug("No config provided, creating new one with token")
71
+ process_token_binding(token, options[:hostname])
72
+ @config ||= Storage::InMemoryStorage.new
73
+ else
74
+ # No config and no token
75
+ raise Errors::KeeperError.new("Either token or initialized config must be provided")
76
+ end
77
+
78
+ # Override hostname if provided
79
+ if options[:hostname]
80
+ @hostname = options[:hostname]
81
+ @config.save_string(ConfigKeys::KEY_HOSTNAME, @hostname)
82
+ else
83
+ @hostname = @config.get_string(ConfigKeys::KEY_HOSTNAME) || KeeperGlobals::DEFAULT_SERVER
84
+ end
85
+
86
+ # Cache configuration
87
+ @cache = {}
88
+ @cache_expiry = {}
89
+ end
90
+
91
+ # Get secrets with optional filtering
92
+ def get_secrets(uids = nil, full_response: false)
93
+ uids = [uids] if uids.is_a?(String)
94
+
95
+ query_options = Dto::QueryOptions.new(records: uids, folders: nil)
96
+ get_secrets_with_options(query_options, full_response: full_response)
97
+ end
98
+
99
+ # Get secrets with query options
100
+ def get_secrets_with_options(query_options = nil, full_response: false)
101
+ records_resp = fetch_and_decrypt_secrets(query_options)
102
+
103
+ # If just bound, fetch again
104
+ if records_resp.just_bound
105
+ records_resp = fetch_and_decrypt_secrets(query_options)
106
+ end
107
+
108
+ # Log warnings
109
+ records_resp.warnings&.each { |warning| @logger.warn(warning) }
110
+
111
+ # Log bad records/folders
112
+ if records_resp.errors&.any?
113
+ records_resp.errors.each do |error|
114
+ @logger.error("Error: #{error}")
115
+ end
116
+ end
117
+
118
+ full_response ? records_resp : (records_resp.records || [])
119
+ end
120
+
121
+ # Get all folders
122
+ def get_folders
123
+ fetch_and_decrypt_folders
124
+ end
125
+
126
+ # Fetch and decrypt folders from dedicated endpoint
127
+ def fetch_and_decrypt_folders
128
+ # Prepare payload for get_folders endpoint (no filters)
129
+ payload = prepare_get_payload(nil)
130
+
131
+ # Make request to get_folders endpoint
132
+ response_json = post_query('get_folders', payload)
133
+ response_dict = JSON.parse(response_json)
134
+
135
+ # Get app key for decryption
136
+ app_key_str = @config.get_string(ConfigKeys::KEY_APP_KEY)
137
+
138
+ # If we have app key directly (one-time token binding), use it
139
+ if app_key_str && !app_key_str.empty?
140
+ app_key = Utils.base64_to_bytes(app_key_str)
141
+ else
142
+ # Otherwise decrypt it using client key
143
+ app_key_encrypted = Utils.base64_to_bytes(@config.get_string(ConfigKeys::KEY_ENCRYPTED_APP_KEY))
144
+ client_key = Utils.base64_to_bytes(@config.get_string(ConfigKeys::KEY_CLIENT_KEY))
145
+ app_key = Crypto.decrypt_aes_gcm(app_key_encrypted, client_key)
146
+ end
147
+
148
+ # Decrypt folders - need to handle them in order for shared folder keys
149
+ folders = []
150
+ response_folders = response_dict['folders'] || []
151
+
152
+ response_folders.each do |encrypted_folder|
153
+ begin
154
+ folder_uid = encrypted_folder['folderUid']
155
+ folder_parent = encrypted_folder['parent']
156
+
157
+ # Decrypt folder key based on whether it has a parent
158
+ if !folder_parent || folder_parent.empty?
159
+ # Root folder - decrypt with app key
160
+ folder_key_encrypted = Utils.base64_to_bytes(encrypted_folder['folderKey'])
161
+ folder_key = Crypto.decrypt_aes_gcm(folder_key_encrypted, app_key)
162
+ else
163
+ # Child folder - decrypt with parent's shared folder key
164
+ shared_folder_key = get_shared_folder_key(folders, response_folders, folder_parent)
165
+ unless shared_folder_key
166
+ @logger.error("Cannot find shared folder key for parent #{folder_parent}")
167
+ next
168
+ end
169
+ folder_key_encrypted = Utils.base64_to_bytes(encrypted_folder['folderKey'])
170
+ folder_key = Crypto.decrypt_aes_cbc(folder_key_encrypted, shared_folder_key)
171
+ end
172
+
173
+ # Decrypt folder data if present
174
+ folder_name = ''
175
+ if encrypted_folder['data'] && !encrypted_folder['data'].empty?
176
+ data_encrypted = Utils.base64_to_bytes(encrypted_folder['data'])
177
+ data_json = Crypto.decrypt_aes_cbc(data_encrypted, folder_key)
178
+ data = JSON.parse(data_json)
179
+ folder_name = data['name'] || ''
180
+ end
181
+
182
+ # Create folder object
183
+ folder = Dto::KeeperFolder.new(
184
+ 'folderUid' => folder_uid,
185
+ 'name' => folder_name,
186
+ 'folderKey' => folder_key,
187
+ 'parent' => folder_parent,
188
+ 'records' => []
189
+ )
190
+
191
+ folders << folder
192
+ rescue => e
193
+ @logger.error("Failed to decrypt folder #{encrypted_folder['folderUid']}: #{e.message}")
194
+ end
195
+ end
196
+
197
+ folders
198
+ end
199
+
200
+ # Get secrets by title
201
+ def get_secrets_by_title(title)
202
+ records = get_secrets
203
+ records.select { |r| r.title == title }
204
+ end
205
+
206
+ # Get first secret by title
207
+ def get_secret_by_title(title)
208
+ get_secrets_by_title(title).first
209
+ end
210
+
211
+ # Create a new secret
212
+ def create_secret(record_data, options = nil)
213
+ options ||= Dto::CreateOptions.new
214
+
215
+ # Validate folder UID is provided
216
+ raise ArgumentError, "folder_uid is required to create a record" unless options.folder_uid
217
+
218
+ # Get folders from dedicated endpoint to find folder key
219
+ folders = get_folders
220
+
221
+ # Find the folder
222
+ folder = folders.find { |f| f.uid == options.folder_uid }
223
+ raise Error, "Folder #{options.folder_uid} not found or not accessible" unless folder
224
+
225
+ # Get folder key
226
+ folder_key = folder.folder_key
227
+ raise Error, "Unable to create record - folder key for #{options.folder_uid} is missing" unless folder_key
228
+
229
+ # Generate UIDs and keys
230
+ record_uid = Utils.generate_uid
231
+ record_key = Crypto.generate_encryption_key_bytes
232
+
233
+ # Prepare record data
234
+ record = if record_data.is_a?(Dto::KeeperRecord)
235
+ record_data.to_h
236
+ else
237
+ record_data
238
+ end
239
+
240
+ # Encrypt record data
241
+ encrypted_data = Crypto.encrypt_aes_gcm(
242
+ Utils.dict_to_json(record),
243
+ record_key
244
+ )
245
+
246
+ # Prepare payload
247
+ payload = prepare_create_payload(
248
+ record_uid: record_uid,
249
+ record_key: record_key,
250
+ folder_uid: options.folder_uid,
251
+ folder_key: folder_key,
252
+ data: encrypted_data
253
+ )
254
+
255
+ # Send request
256
+ response = post_query('create_secret', payload)
257
+
258
+ # Return created record UID
259
+ record_uid
260
+ end
261
+
262
+ # Update existing secret
263
+ def update_secret(record, transaction_type: 'general')
264
+ # Handle both record object and hash
265
+ if record.is_a?(Dto::KeeperRecord)
266
+ record_uid = record.uid
267
+ record_data = record.to_h
268
+ else
269
+ record_uid = record['uid'] || record[:uid]
270
+ record_data = record
271
+ end
272
+
273
+ raise ArgumentError, 'Record UID is required' unless record_uid
274
+
275
+ # Get existing record to get the key
276
+ existing = get_secrets([record_uid]).first
277
+ raise RecordNotFoundError, "Record #{record_uid} not found" unless existing
278
+
279
+ # Prepare payload
280
+ payload = prepare_update_payload(
281
+ record_uid: record_uid,
282
+ data: record_data,
283
+ revision: existing.revision,
284
+ transaction_type: transaction_type
285
+ )
286
+
287
+ # Send request
288
+ post_query('update_secret', payload)
289
+
290
+ # If rotation, complete transaction
291
+ if transaction_type == 'rotation'
292
+ complete_payload = Dto::CompleteTransactionPayload.new
293
+ complete_payload.client_version = KeeperGlobals.client_version
294
+ complete_payload.client_id = @config.get_string(ConfigKeys::KEY_CLIENT_ID)
295
+ complete_payload.record_uid = record_uid
296
+
297
+ post_query('complete_transaction', complete_payload)
298
+ end
299
+
300
+ true
301
+ end
302
+
303
+ # Delete secrets
304
+ def delete_secret(record_uids)
305
+ record_uids = [record_uids] if record_uids.is_a?(String)
306
+
307
+ payload = prepare_delete_payload(record_uids)
308
+ response = post_query('delete_secret', payload)
309
+
310
+ result = JSON.parse(response)
311
+ result['records']
312
+ end
313
+
314
+ # Get notation value
315
+ def get_notation(notation_uri)
316
+ parser = Notation::Parser.new(self)
317
+ parser.parse(notation_uri)
318
+ end
319
+
320
+ # Create folder
321
+ def create_folder(folder_name, parent_uid: nil)
322
+ folder_uid = Utils.generate_uid
323
+ folder_key = Crypto.generate_encryption_key_bytes
324
+
325
+ folder_data = {
326
+ 'name' => folder_name,
327
+ 'folderType' => 'user_folder'
328
+ }
329
+
330
+ encrypted_data = Crypto.encrypt_aes_gcm(
331
+ Utils.dict_to_json(folder_data),
332
+ folder_key
333
+ )
334
+
335
+ payload = prepare_create_folder_payload(
336
+ folder_uid: folder_uid,
337
+ folder_key: folder_key,
338
+ data: encrypted_data,
339
+ parent_uid: parent_uid
340
+ )
341
+
342
+ post_query('create_folder', payload)
343
+ folder_uid
344
+ end
345
+
346
+ # Update folder
347
+ def update_folder(folder_uid, folder_name)
348
+ folder_data = {
349
+ 'name' => folder_name
350
+ }
351
+
352
+ payload = prepare_update_folder_payload(
353
+ folder_uid: folder_uid,
354
+ data: folder_data
355
+ )
356
+
357
+ post_query('update_folder', payload)
358
+ true
359
+ end
360
+
361
+ # Delete folders
362
+ def delete_folder(folder_uids, force: false)
363
+ folder_uids = [folder_uids] if folder_uids.is_a?(String)
364
+
365
+ payload = prepare_delete_folder_payload(folder_uids, force)
366
+ response = post_query('delete_folder', payload)
367
+
368
+ result = JSON.parse(response)
369
+ result['folders']
370
+ end
371
+
372
+ # Get folder hierarchy manager
373
+ def folder_manager
374
+ folders = get_folders
375
+ FolderManager.new(folders)
376
+ end
377
+
378
+ # Get folder path (convenience method)
379
+ def get_folder_path(folder_uid)
380
+ folder_manager.get_folder_path(folder_uid)
381
+ end
382
+
383
+ # Find folder by name (convenience method)
384
+ def find_folder_by_name(name, parent_uid: nil)
385
+ folder_manager.find_folder_by_name(name, parent_uid: parent_uid)
386
+ end
387
+
388
+ # Upload file
389
+ def upload_file(owner_record_uid, file_data, file_name, file_title = nil)
390
+ file_title ||= file_name
391
+
392
+ # Generate file record
393
+ file_uid = Utils.generate_uid
394
+ file_key = Crypto.generate_encryption_key_bytes
395
+
396
+ # Encrypt file data
397
+ encrypted_file = Crypto.encrypt_aes_gcm(file_data, file_key)
398
+
399
+ # Create file record
400
+ file_record = {
401
+ 'fileUid' => file_uid,
402
+ 'name' => file_name,
403
+ 'title' => file_title,
404
+ 'size' => file_data.bytesize,
405
+ 'mimeType' => 'application/octet-stream'
406
+ }
407
+
408
+ # Prepare payload
409
+ payload = prepare_file_upload_payload(
410
+ file_record_uid: file_uid,
411
+ file_record_key: file_key,
412
+ file_record_data: file_record,
413
+ owner_record_uid: owner_record_uid,
414
+ file_size: encrypted_file.bytesize
415
+ )
416
+
417
+ # Get upload URL
418
+ response = post_query('request_upload', payload)
419
+ upload_result = JSON.parse(response)
420
+
421
+ # Upload file
422
+ upload_file_function(
423
+ upload_result['url'],
424
+ upload_result['parameters'],
425
+ encrypted_file
426
+ )
427
+
428
+ file_uid
429
+ end
430
+
431
+ # Download file from record's file data
432
+ def download_file(file_data)
433
+ # Extract file metadata (already decrypted)
434
+ file_uid = file_data['fileUid']
435
+ file_url = file_data['url']
436
+ file_name = file_data['name'] || file_data['title'] || 'unnamed'
437
+
438
+ unless file_url
439
+ raise Error, "No download URL available for file #{file_uid}"
440
+ end
441
+
442
+ # The file key should already be decrypted (base64 encoded)
443
+ file_key = Utils.base64_to_bytes(file_data['fileKey'])
444
+
445
+ # Download the encrypted file content
446
+ encrypted_content = download_encrypted_file(file_url)
447
+
448
+ # Decrypt the file content with the file key
449
+ decrypted_content = Crypto.decrypt_aes_gcm(encrypted_content, file_key)
450
+
451
+ # Return file info and data
452
+ {
453
+ 'name' => file_name,
454
+ 'title' => file_data['title'] || file_name,
455
+ 'type' => file_data['type'],
456
+ 'size' => file_data['size'] || decrypted_content.bytesize,
457
+ 'data' => decrypted_content
458
+ }
459
+ end
460
+
461
+ # Get file metadata from server
462
+ def get_file_data(file_uid)
463
+ payload = prepare_get_payload(nil)
464
+ payload.file_uids = [file_uid]
465
+
466
+ response = post_query('get_files', payload)
467
+ response_dict = JSON.parse(response)
468
+
469
+ if response_dict['files'] && !response_dict['files'].empty?
470
+ file_data = response_dict['files'].first
471
+
472
+ # Decrypt file metadata
473
+ # Get app key for decryption
474
+ app_key_str = @config.get_string(ConfigKeys::KEY_APP_KEY)
475
+ if app_key_str && !app_key_str.empty?
476
+ app_key = Utils.base64_to_bytes(app_key_str)
477
+ else
478
+ # Decrypt app key with client key
479
+ app_key_encrypted = Utils.base64_to_bytes(@config.get_string(ConfigKeys::KEY_ENCRYPTED_APP_KEY))
480
+ client_key = get_client_key
481
+ app_key = Crypto.decrypt_aes_gcm(app_key_encrypted, client_key)
482
+ end
483
+
484
+ encrypted_data = Utils.base64_to_bytes(file_data['data'])
485
+ decrypted_json = Crypto.decrypt_aes_gcm(encrypted_data, app_key)
486
+
487
+ JSON.parse(decrypted_json).merge('fileKey' => file_data['fileKey'])
488
+ else
489
+ raise Error, "File not found: #{file_uid}"
490
+ end
491
+ end
492
+
493
+ # Download encrypted file from URL
494
+ def download_encrypted_file(url)
495
+ uri = URI(url)
496
+ response = Net::HTTP.get_response(uri)
497
+
498
+ if response.code == '200'
499
+ response.body
500
+ else
501
+ raise Error, "Failed to download file: #{response.code} #{response.message}"
502
+ end
503
+ end
504
+
505
+ private
506
+
507
+ # Process token binding
508
+ def process_token_binding(token, hostname = nil)
509
+ # Parse token
510
+ token = token.strip
511
+ token_parts = token.split(':')
512
+
513
+ # Modern format: REGION:BASE64_TOKEN
514
+ if token_parts.length >= 2
515
+ region = token_parts[0].upcase
516
+ @hostname = KeeperGlobals::KEEPER_SERVERS[region] || KeeperGlobals::DEFAULT_SERVER
517
+ @token = token_parts[1..].join(':')
518
+ else
519
+ # Legacy format
520
+ @token = token
521
+ @hostname = hostname || KeeperGlobals::DEFAULT_SERVER
522
+ end
523
+
524
+ # Bind the one-time token
525
+ bound_config = bind_one_time_token(@token, @hostname)
526
+
527
+ # Merge bound config into existing config if present
528
+ if @config
529
+ # Copy all values from bound config to existing config
530
+ bound_config.instance_variable_get(:@config).each do |key, value|
531
+ if value.is_a?(String)
532
+ @config.save_string(key, value)
533
+ else
534
+ @config.save_bytes(key, value)
535
+ end
536
+ end
537
+ else
538
+ @config = bound_config
539
+ end
540
+ end
541
+
542
+ # Bind one-time token
543
+ def bind_one_time_token(token, hostname)
544
+ storage = Storage::InMemoryStorage.new
545
+
546
+ # Generate EC key pair
547
+ keys = Crypto.generate_ecc_keys
548
+
549
+ # Convert token to bytes and create client ID hash
550
+ token_bytes = Utils.url_safe_str_to_bytes(token)
551
+ client_id_hash = OpenSSL::HMAC.digest(
552
+ 'SHA512',
553
+ token_bytes,
554
+ 'KEEPER_SECRETS_MANAGER_CLIENT_ID'
555
+ )
556
+ client_id = Utils.bytes_to_base64(client_id_hash)
557
+
558
+ # Store configuration
559
+ storage.save_string(ConfigKeys::KEY_HOSTNAME, hostname)
560
+ storage.save_string(ConfigKeys::KEY_SERVER_PUBLIC_KEY_ID, DEFAULT_KEY_ID)
561
+ storage.save_string(ConfigKeys::KEY_CLIENT_KEY, token)
562
+ storage.save_bytes(ConfigKeys::KEY_PRIVATE_KEY, keys[:private_key_bytes])
563
+ storage.save_string(ConfigKeys::KEY_CLIENT_ID, client_id)
564
+
565
+ # Prepare binding payload
566
+ payload = Dto::GetPayload.new
567
+ payload.client_version = KeeperGlobals.client_version
568
+ payload.client_id = client_id
569
+ payload.public_key = keys[:public_key_str]
570
+
571
+ # Send binding request
572
+ response = post_query('get_secret', payload, storage)
573
+ response_dict = JSON.parse(response)
574
+
575
+ # Process binding response
576
+ if response_dict['encryptedAppKey']
577
+ # Decrypt app key
578
+ encrypted_app_key = Utils.url_safe_str_to_bytes(response_dict['encryptedAppKey'])
579
+ client_key_bytes = Utils.url_safe_str_to_bytes(token)
580
+
581
+ app_key = Crypto.decrypt_aes_gcm(encrypted_app_key, client_key_bytes)
582
+ storage.save_bytes(ConfigKeys::KEY_APP_KEY, app_key)
583
+
584
+ # Store app owner public key if present
585
+ if response_dict['appOwnerPublicKey']
586
+ owner_key = Utils.url_safe_str_to_bytes(response_dict['appOwnerPublicKey'])
587
+ storage.save_bytes(ConfigKeys::KEY_OWNER_PUBLIC_KEY, owner_key)
588
+ end
589
+
590
+ # Clean up client key after successful binding
591
+ storage.delete(ConfigKeys::KEY_CLIENT_KEY)
592
+ else
593
+ raise Errors::KeeperError.new("Failed to bind one-time token - no encrypted app key in response")
594
+ end
595
+
596
+ storage
597
+ end
598
+
599
+ # Fetch and decrypt secrets
600
+ def fetch_and_decrypt_secrets(query_options = nil)
601
+ payload = prepare_get_payload(query_options)
602
+
603
+ response = post_query('get_secret', payload)
604
+ response_dict = JSON.parse(response)
605
+
606
+ # Decrypt app key if present (during token binding)
607
+ if response_dict['encryptedAppKey']
608
+ encrypted_app_key = Utils.url_safe_str_to_bytes(response_dict['encryptedAppKey'])
609
+ client_key = Utils.url_safe_str_to_bytes(@config.get_string(ConfigKeys::KEY_CLIENT_KEY))
610
+
611
+ # Decrypt app key using AES with client key (the original token)
612
+ app_key = Crypto.decrypt_aes_gcm(encrypted_app_key, client_key)
613
+ @config.save_bytes(ConfigKeys::KEY_APP_KEY, app_key)
614
+
615
+ # Clean up client key after successful binding
616
+ @config.delete(ConfigKeys::KEY_CLIENT_KEY)
617
+
618
+ # Store app owner public key if present
619
+ if response_dict['appOwnerPublicKey']
620
+ owner_key = Utils.url_safe_str_to_bytes(response_dict['appOwnerPublicKey'])
621
+ @config.save_bytes(ConfigKeys::KEY_OWNER_PUBLIC_KEY, owner_key)
622
+ end
623
+
624
+ # Set just bound flag
625
+ just_bound = true
626
+ else
627
+ just_bound = false
628
+ end
629
+
630
+ # Get app key
631
+ app_key = @config.get_bytes(ConfigKeys::KEY_APP_KEY)
632
+ raise Error, 'No app key available' unless app_key
633
+
634
+ # Decrypt records
635
+ records = []
636
+ if response_dict['records']
637
+ response_dict['records'].each do |encrypted_record|
638
+ begin
639
+ record = decrypt_record(encrypted_record, app_key)
640
+ records << record
641
+ rescue => e
642
+ @logger.error("Failed to decrypt record: #{e.message}")
643
+ end
644
+ end
645
+ end
646
+
647
+ # Decrypt folders - need to handle them in order for shared folder keys
648
+ folders = []
649
+ response_folders = response_dict['folders'] || []
650
+
651
+ # First pass - decrypt folders in order
652
+ response_folders.each do |encrypted_folder|
653
+ begin
654
+ folder = decrypt_folder(encrypted_folder, app_key, folders, response_folders)
655
+ if folder
656
+ folders << folder
657
+ # Add folder's records to the main records list
658
+ records.concat(folder.records) if folder.records && !folder.records.empty?
659
+ end
660
+ rescue => e
661
+ @logger.error("Failed to decrypt folder: #{e.message}")
662
+ end
663
+ end
664
+
665
+ # Build response
666
+ response = Dto::SecretsManagerResponse.new(
667
+ records: records,
668
+ folders: folders,
669
+ warnings: response_dict['warnings']
670
+ )
671
+
672
+ response.just_bound = just_bound if response.respond_to?(:just_bound=)
673
+ response
674
+ end
675
+
676
+ # Decrypt record
677
+ def decrypt_record(encrypted_record, app_key)
678
+ record_uid = encrypted_record['recordUid']
679
+ record_key_encrypted = Utils.base64_to_bytes(encrypted_record['recordKey'])
680
+ data_encrypted = Utils.base64_to_bytes(encrypted_record['data'])
681
+
682
+ # Decrypt record key
683
+ record_key = Crypto.decrypt_aes_gcm(record_key_encrypted, app_key)
684
+
685
+ # Decrypt data
686
+ data_json = Crypto.decrypt_aes_gcm(data_encrypted, record_key)
687
+ data = JSON.parse(data_json)
688
+
689
+ # Decrypt files if present
690
+ decrypted_files = []
691
+ if encrypted_record['files']
692
+ encrypted_record['files'].each do |file|
693
+ begin
694
+ # Decrypt file key with record key
695
+ file_key_encrypted = Utils.base64_to_bytes(file['fileKey'])
696
+ file_key = Crypto.decrypt_aes_gcm(file_key_encrypted, record_key)
697
+
698
+ # Decrypt file metadata with file key
699
+ if file['data']
700
+ file_data_encrypted = Utils.base64_to_bytes(file['data'])
701
+ file_metadata_json = Crypto.decrypt_aes_gcm(file_data_encrypted, file_key)
702
+ file_metadata = JSON.parse(file_metadata_json)
703
+ else
704
+ file_metadata = {}
705
+ end
706
+
707
+ # Create decrypted file object
708
+ decrypted_file = {
709
+ 'fileUid' => file['fileUid'],
710
+ 'fileKey' => Utils.bytes_to_base64(file_key), # Store decrypted key
711
+ 'url' => file['url'],
712
+ 'thumbnailUrl' => file['thumbnailUrl'],
713
+ 'name' => file_metadata['name'],
714
+ 'title' => file_metadata['title'] || file_metadata['name'],
715
+ 'type' => file_metadata['type'],
716
+ 'size' => file_metadata['size'],
717
+ 'lastModified' => file_metadata['lastModified']
718
+ }
719
+
720
+ decrypted_files << decrypted_file
721
+ rescue => e
722
+ @logger&.error("Failed to decrypt file #{file['fileUid']}: #{e.message}")
723
+ end
724
+ end
725
+ end
726
+
727
+ # Create record object
728
+ record = Dto::KeeperRecord.new(
729
+ 'recordUid' => record_uid,
730
+ 'data' => data,
731
+ 'revision' => encrypted_record['revision'],
732
+ 'files' => decrypted_files
733
+ )
734
+
735
+ # Store record key for later use (e.g., file downloads)
736
+ record.instance_variable_set(:@record_key, record_key)
737
+ record.define_singleton_method(:record_key) { @record_key }
738
+
739
+ record
740
+ end
741
+
742
+ # Get shared folder key by traversing up the folder hierarchy
743
+ def get_shared_folder_key(folders, response_folders, parent_uid)
744
+ while parent_uid
745
+ # Find parent folder in response
746
+ parent_folder = response_folders.find { |f| f['folderUid'] == parent_uid }
747
+ return nil unless parent_folder
748
+
749
+ # If parent has no parent, it's the shared folder root
750
+ if !parent_folder['parent'] || parent_folder['parent'].empty?
751
+ # Find the decrypted folder object
752
+ shared_folder = folders.find { |f| f.uid == parent_uid }
753
+ return shared_folder&.folder_key
754
+ end
755
+
756
+ # Continue up the hierarchy
757
+ parent_uid = parent_folder['parent']
758
+ end
759
+
760
+ nil
761
+ end
762
+
763
+ # Decrypt folder
764
+ def decrypt_folder(encrypted_folder, app_key, existing_folders = [], response_folders = [])
765
+ folder_uid = encrypted_folder['folderUid']
766
+ folder_parent = encrypted_folder['parent']
767
+
768
+ @logger.debug("Decrypting folder #{folder_uid}, parent: #{folder_parent || 'none'}")
769
+
770
+ # Determine the decryption key to use
771
+ decryption_key = if !folder_parent || folder_parent.empty?
772
+ # Root folder - use app key
773
+ @logger.debug("Using app key for root folder #{folder_uid}")
774
+ app_key
775
+ else
776
+ # Child folder - use shared folder key
777
+ shared_folder_key = get_shared_folder_key(existing_folders, response_folders, folder_parent)
778
+ unless shared_folder_key
779
+ @logger.error("Cannot find shared folder key for parent #{folder_parent}")
780
+ return nil
781
+ end
782
+ @logger.debug("Using shared folder key from parent for folder #{folder_uid}")
783
+ shared_folder_key
784
+ end
785
+
786
+ # Some folders might not have encryption data
787
+ unless encrypted_folder['folderKey']
788
+ # Create a basic folder object without decrypted data
789
+ return Dto::KeeperFolder.new(
790
+ 'folderUid' => folder_uid,
791
+ 'folderKey' => nil,
792
+ 'data' => {},
793
+ 'name' => encrypted_folder['name'] || folder_uid,
794
+ 'parent' => folder_parent,
795
+ 'records' => []
796
+ )
797
+ end
798
+
799
+ # Decrypt folder key
800
+ folder_key_encrypted = Utils.base64_to_bytes(encrypted_folder['folderKey'])
801
+ folder_key = if !folder_parent || folder_parent.empty?
802
+ # Root folder key uses AES-GCM
803
+ Crypto.decrypt_aes_gcm(folder_key_encrypted, decryption_key)
804
+ else
805
+ # Child folder key uses AES-CBC
806
+ Crypto.decrypt_aes_cbc(folder_key_encrypted, decryption_key)
807
+ end
808
+
809
+ # Get folder name - either from encrypted data or direct field
810
+ folder_name = ''
811
+ folder_type = nil
812
+
813
+ # Check if there's a direct name field (unencrypted)
814
+ if encrypted_folder['name']
815
+ folder_name = encrypted_folder['name']
816
+ @logger.debug("Using direct name field for folder #{folder_uid}: #{folder_name}")
817
+ elsif encrypted_folder['data'] && !encrypted_folder['data'].empty?
818
+ # Decrypt folder data if present
819
+ begin
820
+ data_encrypted = Utils.base64_to_bytes(encrypted_folder['data'])
821
+ # Folder data always uses CBC
822
+ data_json = Crypto.decrypt_aes_cbc(data_encrypted, folder_key)
823
+ data = JSON.parse(data_json)
824
+ folder_name = data['name'] || ''
825
+ folder_type = data['folderType']
826
+ @logger.debug("Successfully decrypted folder #{folder_uid}: #{folder_name}")
827
+ rescue => e
828
+ @logger.error("Failed to decrypt folder data for #{folder_uid}: #{e.class} - #{e.message}")
829
+ @logger.debug("Backtrace: #{e.backtrace.first(3).join("\n")}")
830
+ end
831
+ else
832
+ @logger.debug("Folder #{folder_uid} has no name or data field - using UID as name")
833
+ folder_name = folder_uid
834
+ end
835
+
836
+ # Decrypt records in this folder
837
+ folder_records = []
838
+ if encrypted_folder['records']
839
+ encrypted_folder['records'].each do |encrypted_record|
840
+ begin
841
+ # Decrypt the record using folder key
842
+ record = decrypt_record(encrypted_record, folder_key)
843
+
844
+ # Set folder_uid on the record
845
+ record.folder_uid = folder_uid if record
846
+ folder_records << record if record
847
+ rescue => e
848
+ @logger.error("Failed to decrypt record in folder #{folder_uid}: #{e.message}")
849
+ end
850
+ end
851
+ end
852
+
853
+ # Create folder object
854
+ Dto::KeeperFolder.new(
855
+ 'folderUid' => folder_uid,
856
+ 'name' => folder_name,
857
+ 'folderType' => folder_type,
858
+ 'folderKey' => folder_key,
859
+ 'parent' => folder_parent,
860
+ 'records' => folder_records
861
+ )
862
+ end
863
+
864
+ # Prepare get payload
865
+ def prepare_get_payload(query_options = nil)
866
+ payload = Dto::GetPayload.new
867
+ payload.client_version = KeeperGlobals.client_version
868
+
869
+ # Client ID should be URL-safe base64
870
+ client_id_str = @config.get_string(ConfigKeys::KEY_CLIENT_ID)
871
+ payload.client_id = client_id_str
872
+
873
+ @logger.debug("Client ID for payload: #{client_id_str}")
874
+
875
+ # Public key is sent during initial binding only
876
+
877
+ if query_options
878
+ payload.requested_records = query_options.records_filter
879
+ payload.requested_folders = query_options.folders_filter
880
+ end
881
+
882
+ payload
883
+ end
884
+
885
+ # Prepare create payload
886
+ def prepare_create_payload(record_uid:, record_key:, folder_uid:, folder_key:, data:)
887
+ payload = Dto::CreatePayload.new
888
+ payload.client_version = KeeperGlobals.client_version
889
+ payload.client_id = @config.get_string(ConfigKeys::KEY_CLIENT_ID)
890
+ payload.record_uid = record_uid
891
+ payload.record_key = Utils.bytes_to_base64(record_key)
892
+ payload.folder_uid = folder_uid
893
+ payload.data = Utils.bytes_to_base64(data)
894
+
895
+ # Encrypt the record key with the folder key
896
+ if folder_key
897
+ folder_key_encrypted = Crypto.encrypt_aes_gcm(record_key, folder_key)
898
+ payload.folder_key = Utils.bytes_to_base64(folder_key_encrypted)
899
+ end
900
+
901
+ payload
902
+ end
903
+
904
+ # Other payload preparation methods...
905
+
906
+ # Post query to API
907
+ def post_query(path, payload, config = nil)
908
+ config ||= @config
909
+ server = get_server(@hostname)
910
+ url = "https://#{server}/api/rest/sm/v1/#{path}"
911
+
912
+ loop do
913
+ # Generate transmission key
914
+ key_id = config.get_string(ConfigKeys::KEY_SERVER_PUBLIC_KEY_ID) || DEFAULT_KEY_ID
915
+ transmission_key = generate_transmission_key(key_id)
916
+
917
+ # Encrypt and sign payload
918
+ encrypted_payload = encrypt_and_sign_payload(config, transmission_key, payload)
919
+
920
+ # Make request
921
+ if @custom_post_function && path == 'get_secret'
922
+ response = @custom_post_function.call(url, transmission_key, encrypted_payload, @verify_ssl_certs)
923
+ else
924
+ response = post_function(url, transmission_key, encrypted_payload)
925
+ end
926
+
927
+ # Handle response
928
+ if response.success?
929
+ # Decrypt response if present
930
+ if response.data && !response.data.empty?
931
+ return Crypto.decrypt_aes_gcm(response.data, transmission_key.key)
932
+ else
933
+ return response.data
934
+ end
935
+ else
936
+ handle_http_error(response, config)
937
+ end
938
+ end
939
+ end
940
+
941
+ # Generate transmission key
942
+ def generate_transmission_key(key_id)
943
+ # Get server public key
944
+ server_public_key_str = KeeperGlobals::KEEPER_PUBLIC_KEYS[key_id.to_s]
945
+ raise Error, "Unknown public key ID: #{key_id}" unless server_public_key_str
946
+
947
+ @logger.debug("Using server public key ID: #{key_id}")
948
+ @logger.debug("Server public key string: #{server_public_key_str[0..20]}...")
949
+
950
+ # Generate random key
951
+ key = Crypto.generate_encryption_key_bytes
952
+ @logger.debug("Generated transmission key: #{Utils.bytes_to_base64(key)[0..20]}...")
953
+
954
+ # Encrypt key with server public key
955
+ server_public_key = Crypto.url_safe_str_to_bytes(server_public_key_str)
956
+ @logger.debug("Server public key bytes length: #{server_public_key.bytesize}")
957
+
958
+ encrypted_key = Crypto.encrypt_ec(key, server_public_key)
959
+ @logger.debug("Encrypted key length: #{encrypted_key.bytesize}")
960
+
961
+ Dto::TransmissionKey.new(
962
+ public_key_id: key_id,
963
+ key: key,
964
+ encrypted_key: encrypted_key
965
+ )
966
+ end
967
+
968
+ # Encrypt and sign payload
969
+ def encrypt_and_sign_payload(config, transmission_key, payload)
970
+ # Convert payload to JSON
971
+ payload_json = payload.to_json
972
+
973
+ @logger.debug("Payload: #{payload_json}")
974
+
975
+ # Encrypt payload
976
+ encrypted_payload = Crypto.encrypt_aes_gcm(payload_json, transmission_key.key)
977
+
978
+ # Generate signature
979
+ signature_base = transmission_key.encrypted_key + encrypted_payload
980
+
981
+ # After binding, use ECDSA signature with private key (not HMAC)
982
+ private_key_bytes = config.get_bytes(ConfigKeys::KEY_PRIVATE_KEY)
983
+ if private_key_bytes
984
+ # Load private key
985
+ private_key = load_ec_private_key(private_key_bytes)
986
+
987
+ # Generate ECDSA signature
988
+ signature = Crypto.sign_ec(signature_base, private_key)
989
+ @logger.debug("Using ECDSA signature, length: #{signature.bytesize}")
990
+ else
991
+ # Fallback to HMAC with client key (for one-time token binding)
992
+ client_key = config.get_string(ConfigKeys::KEY_CLIENT_KEY)
993
+ if client_key
994
+ signature_key = Utils.base64_to_bytes(client_key)
995
+ signature = Crypto.generate_hmac(signature_key, signature_base)
996
+ @logger.debug("Using HMAC signature, length: #{signature.bytesize}")
997
+ else
998
+ raise Error, "No key available for signature"
999
+ end
1000
+ end
1001
+
1002
+ Dto::EncryptedPayload.new(
1003
+ encrypted_payload: encrypted_payload,
1004
+ signature: signature
1005
+ )
1006
+ end
1007
+
1008
+ # HTTP post function
1009
+ def post_function(url, transmission_key, encrypted_payload)
1010
+ uri = URI(url)
1011
+
1012
+ @logger.debug("POST URL: #{url}")
1013
+ @logger.debug("PublicKeyId header: #{transmission_key.public_key_id}")
1014
+ @logger.debug("TransmissionKey header: #{Utils.bytes_to_base64(transmission_key.encrypted_key)[0..50]}...")
1015
+ @logger.debug("TransmissionKey full base64 length: #{Utils.bytes_to_base64(transmission_key.encrypted_key).length}")
1016
+ @logger.debug("Signature header: #{Utils.bytes_to_base64(encrypted_payload.signature)[0..50]}...")
1017
+ @logger.debug("Request body length: #{encrypted_payload.encrypted_payload.bytesize} bytes")
1018
+
1019
+ request = Net::HTTP::Post.new(uri)
1020
+ request['Content-Type'] = 'application/octet-stream'
1021
+ request['PublicKeyId'] = transmission_key.public_key_id.to_s
1022
+ request['TransmissionKey'] = Utils.bytes_to_base64(transmission_key.encrypted_key)
1023
+ request['Authorization'] = "Signature #{Utils.bytes_to_base64(encrypted_payload.signature)}"
1024
+ request['Content-Length'] = encrypted_payload.encrypted_payload.bytesize.to_s
1025
+ request.body = encrypted_payload.encrypted_payload
1026
+
1027
+ http = Net::HTTP.new(uri.host, uri.port)
1028
+ http.use_ssl = true
1029
+ http.verify_mode = @verify_ssl_certs ? OpenSSL::SSL::VERIFY_PEER : OpenSSL::SSL::VERIFY_NONE
1030
+
1031
+ response = http.request(request)
1032
+
1033
+ @logger.debug("Response status: #{response.code}")
1034
+
1035
+ Dto::KSMHttpResponse.new(
1036
+ status_code: response.code.to_i,
1037
+ data: response.body,
1038
+ http_response: response
1039
+ )
1040
+ rescue => e
1041
+ raise NetworkError.new("HTTP request failed: #{e.message}")
1042
+ end
1043
+
1044
+ # Handle HTTP errors
1045
+ def handle_http_error(response, config = nil)
1046
+ begin
1047
+ error_data = JSON.parse(response.data)
1048
+ result_code = error_data['result_code'] || error_data['error']
1049
+ message = error_data['message']
1050
+
1051
+ @logger.debug("Server error response: #{error_data.inspect}")
1052
+
1053
+ # Handle specific errors
1054
+ case result_code
1055
+ when 'key'
1056
+ # Server wants different key
1057
+ key_id = error_data['key_id']
1058
+ @logger.info("Server requested key ID: #{key_id}")
1059
+ # Use passed config or fall back to instance config
1060
+ config_to_use = config || @config
1061
+ if config_to_use
1062
+ config_to_use.save_string(ConfigKeys::KEY_SERVER_PUBLIC_KEY_ID, key_id.to_s)
1063
+ end
1064
+ return # Retry
1065
+ when 'throttled'
1066
+ sleep_time = error_data['retry_after'] || 60
1067
+ @logger.warn("Request throttled, waiting #{sleep_time} seconds")
1068
+ sleep(sleep_time)
1069
+ return # Retry
1070
+ else
1071
+ raise ErrorFactory.from_server_response(result_code, message)
1072
+ end
1073
+ rescue JSON::ParserError
1074
+ raise NetworkError.new("Server error: HTTP #{response.status_code}",
1075
+ status_code: response.status_code,
1076
+ response_body: response.data)
1077
+ end
1078
+ end
1079
+
1080
+ # Get server hostname
1081
+ def get_server(hostname)
1082
+ return hostname if hostname.include?('.')
1083
+
1084
+ # Look up in server list
1085
+ KeeperGlobals::KEEPER_SERVERS[hostname.upcase] || hostname
1086
+ end
1087
+
1088
+ # Load EC private key from bytes
1089
+ def load_ec_private_key(private_key_bytes)
1090
+ # If it's already a key object, return it
1091
+ return private_key_bytes if private_key_bytes.is_a?(OpenSSL::PKey::EC)
1092
+
1093
+ @logger.debug("Loading private key, bytes length: #{private_key_bytes.bytesize}")
1094
+
1095
+ # Try to load as DER format first
1096
+ begin
1097
+ key = OpenSSL::PKey.read(private_key_bytes, nil)
1098
+ # Ensure it's an EC key
1099
+ if key.is_a?(OpenSSL::PKey::EC)
1100
+ @logger.debug("Successfully loaded EC private key from DER format")
1101
+ return key
1102
+ else
1103
+ raise "Not an EC key, got #{key.class}"
1104
+ end
1105
+ rescue => der_error
1106
+ @logger.debug("DER format failed: #{der_error.message}, trying raw bytes")
1107
+
1108
+ # If DER fails, it might be raw key bytes (32 bytes)
1109
+ if private_key_bytes.bytesize == 32
1110
+ begin
1111
+ # Create EC key from raw bytes (OpenSSL 3.0 compatible)
1112
+ group = OpenSSL::PKey::EC::Group.new('prime256v1')
1113
+
1114
+ # Generate key components
1115
+ private_key_bn = OpenSSL::BN.new(private_key_bytes, 2)
1116
+ public_key_point = group.generator.mul(private_key_bn)
1117
+
1118
+ # Create ASN1 sequence for the key
1119
+ asn1 = OpenSSL::ASN1::Sequence([
1120
+ OpenSSL::ASN1::Integer(1),
1121
+ OpenSSL::ASN1::OctetString(private_key_bytes),
1122
+ OpenSSL::ASN1::ObjectId('prime256v1', 0, :EXPLICIT),
1123
+ OpenSSL::ASN1::BitString(public_key_point.to_octet_string(:uncompressed), 1, :EXPLICIT)
1124
+ ])
1125
+
1126
+ # Create key from DER
1127
+ key = OpenSSL::PKey::EC.new(asn1.to_der)
1128
+
1129
+ @logger.debug("Successfully created EC key from raw bytes")
1130
+ return key
1131
+ rescue => raw_error
1132
+ @logger.debug("Raw bytes failed: #{raw_error.message}")
1133
+ raise CryptoError, "Failed to load private key: DER: #{der_error.message}, Raw: #{raw_error.message}"
1134
+ end
1135
+ else
1136
+ raise CryptoError, "Failed to load private key: #{der_error.message} (got #{private_key_bytes.bytesize} bytes)"
1137
+ end
1138
+ end
1139
+ end
1140
+
1141
+ # Other helper methods...
1142
+ def prepare_update_payload(record_uid:, data:, revision:, transaction_type:)
1143
+ payload = Dto::UpdatePayload.new
1144
+ payload.client_version = KeeperGlobals.client_version
1145
+ payload.client_id = @config.get_string(ConfigKeys::KEY_CLIENT_ID)
1146
+ payload.record_uid = record_uid
1147
+ payload.data = Utils.dict_to_json(data)
1148
+ payload.revision = revision
1149
+ payload.transaction_type = transaction_type
1150
+ payload
1151
+ end
1152
+
1153
+ def prepare_delete_payload(record_uids)
1154
+ payload = Dto::DeletePayload.new
1155
+ payload.client_version = KeeperGlobals.client_version
1156
+ payload.client_id = @config.get_string(ConfigKeys::KEY_CLIENT_ID)
1157
+ payload.record_uids = record_uids
1158
+ payload
1159
+ end
1160
+
1161
+ def prepare_create_folder_payload(folder_uid:, folder_key:, data:, parent_uid:)
1162
+ payload = Dto::CreateFolderPayload.new
1163
+ payload.client_version = KeeperGlobals.client_version
1164
+ payload.client_id = @config.get_string(ConfigKeys::KEY_CLIENT_ID)
1165
+ payload.folder_uid = folder_uid
1166
+ payload.data = Utils.bytes_to_base64(data)
1167
+ payload.parent_uid = parent_uid
1168
+
1169
+ # Handle shared folder key
1170
+ payload.shared_folder_key = Utils.bytes_to_base64(folder_key)
1171
+
1172
+ payload
1173
+ end
1174
+
1175
+ def prepare_update_folder_payload(folder_uid:, data:)
1176
+ payload = Dto::UpdateFolderPayload.new
1177
+ payload.client_version = KeeperGlobals.client_version
1178
+ payload.client_id = @config.get_string(ConfigKeys::KEY_CLIENT_ID)
1179
+ payload.folder_uid = folder_uid
1180
+ payload.data = Utils.dict_to_json(data)
1181
+ payload
1182
+ end
1183
+
1184
+ def prepare_delete_folder_payload(folder_uids, force)
1185
+ payload = Dto::DeleteFolderPayload.new
1186
+ payload.client_version = KeeperGlobals.client_version
1187
+ payload.client_id = @config.get_string(ConfigKeys::KEY_CLIENT_ID)
1188
+ payload.folder_uids = folder_uids
1189
+ payload.force_deletion = force
1190
+ payload
1191
+ end
1192
+
1193
+ def prepare_file_upload_payload(file_record_uid:, file_record_key:, file_record_data:, owner_record_uid:, file_size:)
1194
+ payload = Dto::FileUploadPayload.new
1195
+ payload.client_version = KeeperGlobals.client_version
1196
+ payload.client_id = @config.get_string(ConfigKeys::KEY_CLIENT_ID)
1197
+ payload.file_record_uid = file_record_uid
1198
+ payload.file_record_key = Utils.bytes_to_base64(file_record_key)
1199
+ payload.file_record_data = Utils.dict_to_json(file_record_data)
1200
+ payload.owner_record_uid = owner_record_uid
1201
+ payload.file_size = file_size
1202
+ payload
1203
+ end
1204
+
1205
+ def upload_file_function(url, parameters, encrypted_file_data)
1206
+ uri = URI(url)
1207
+
1208
+ # Use multipart form data
1209
+ # This is a simplified version - might need proper multipart handling
1210
+ request = Net::HTTP::Post.new(uri)
1211
+ request.set_form([['file', encrypted_file_data]], 'multipart/form-data')
1212
+
1213
+ # Add parameters
1214
+ parameters&.each do |key, value|
1215
+ request[key] = value
1216
+ end
1217
+
1218
+ http = Net::HTTP.new(uri.host, uri.port)
1219
+ http.use_ssl = true
1220
+
1221
+ response = http.request(request)
1222
+
1223
+ unless response.code.to_i == 200
1224
+ raise NetworkError, "File upload failed: HTTP #{response.code}"
1225
+ end
1226
+
1227
+ true
1228
+ end
1229
+ end
1230
+ end
1231
+ end