keeper_secrets_manager 17.0.4 → 17.1.0

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