Class: KeeperSecretsManager::Core::SecretsManager

Inherits:
Object
  • Object
show all
Defined in:
lib/keeper_secrets_manager/core.rb

Constant Summary collapse

NOTATION_PREFIX =
'keeper'.freeze
DEFAULT_KEY_ID =
'7'.freeze
MAX_THROTTLE_RETRIES =

Throttle retry (KSM-876 / KSM-883). The backend throttles HTTP 403 "error":"throttled" per clientId+endpoint (100 requests / 10s window; memcached TTL 10s that resets on every request, so the counter only clears after 10s of silence).

5
BASE_THROTTLE_DELAY_SEC =

1s safety margin over the backend's 10s memcached TTL

11
MAX_THROTTLE_DELAY_SEC =

cap on server-supplied retry_after (BASE * 2**4)

176
INFLATE_REF_TYPES =

Field types that can be inflated

{
  'addressRef' => ['address'],
  'cardRef' => %w[paymentCard text pinCode addressRef]
}.freeze

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(options = {}) ⇒ SecretsManager

Returns a new instance of SecretsManager.

Raises:



28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
# File 'lib/keeper_secrets_manager/core.rb', line 28

def initialize(options = {})
  # Check Ruby version
  raise Error, 'KSM SDK requires Ruby 3.1 or greater' if RUBY_VERSION < '3.1'

  # Check AES-GCM support
  begin
    OpenSSL::Cipher.new('AES-256-GCM')
  rescue RuntimeError => e
    if e.message.include?('unsupported cipher')
      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+"
    end

    raise e
  end

  @token = nil
  @hostname = nil
  @verify_ssl_certs = options.fetch(:verify_ssl_certs, true)
  @custom_post_function = options[:custom_post_function]

  # optional custom server public key overrides (isolated deployments).
  # Precedence, highest first: these programmatic params > OTT segments > pre-existing config.
  @server_public_key_override    = options[:server_public_key]
  @server_public_key_id_override = options[:server_public_key_id]

  # Set up proxy configuration
  # Priority: explicit proxy_url parameter > HTTPS_PROXY env var > no proxy
  @proxy_url = options[:proxy_url] || ENV['HTTPS_PROXY'] || ENV['https_proxy']

  if @proxy_url
    begin
      proxy_uri = URI.parse(@proxy_url)
      unless proxy_uri.is_a?(URI::HTTP) && !proxy_uri.host.to_s.empty?
        raise ArgumentError,
              "Invalid proxy_url '#{@proxy_url}': must be a valid http or https URL with a host (e.g., http://proxy.example.com:8080)"
      end
    rescue URI::InvalidURIError => e
      raise ArgumentError, "Invalid proxy_url '#{@proxy_url}': #{e.message}"
    end
  end

  # Set up logging
  @logger = options[:logger] || Logger.new(STDOUT)
  @logger.level = options[:log_level] || Logger::WARN

  @logger.debug("Proxy configuration: #{@proxy_url ? @proxy_url : 'none'}") if @proxy_url

  # Handle configuration
  config = options[:config]
  token = options[:token]

  # Check environment variable if no config provided
  config = Storage::InMemoryStorage.new(ENV['KSM_CONFIG']) if config.nil? && ENV['KSM_CONFIG']

  # If we have config, check if it's already initialized
  if config
    @config = config
    # Check if already bound (has client ID and app key)
    if @config.get_string(ConfigKeys::KEY_CLIENT_ID) && @config.get_bytes(ConfigKeys::KEY_APP_KEY)
      @logger.debug('Using existing credentials from config')
    elsif token
      # Config exists but not bound, use token to bind
      @logger.debug('Config provided but not bound, using token to initialize')
      process_token_binding(token, options[:hostname])
    else
      @logger.warn('Config provided but no credentials found and no token provided')
    end
  elsif token
    # No config provided, create new one with token
    @logger.debug('No config provided, creating new one with token')
    process_token_binding(token, options[:hostname])
    @config ||= Storage::InMemoryStorage.new
  else
    # No config and no token
    raise Error, 'Either token or initialized config must be provided'
  end

  # programmatic custom key wins over token/config (applied once @config is set).
  @config.save_string(ConfigKeys::KEY_SERVER_PUBLIC_KEY, @server_public_key_override) if @server_public_key_override && !@server_public_key_override.empty?
  @config.save_string(ConfigKeys::KEY_SERVER_PUBLIC_KEY_ID, @server_public_key_id_override.to_s) if @server_public_key_id_override

  # if the configured key id is not in the built-in table AND no custom key backs it,
  # fall back to the default (mirrors Python). A persisted custom serverPublicKey is preserved.
  current_key_id = @config.get_string(ConfigKeys::KEY_SERVER_PUBLIC_KEY_ID)
  custom_server_key = @config.get_string(ConfigKeys::KEY_SERVER_PUBLIC_KEY)
  if current_key_id && !KeeperGlobals::KEEPER_PUBLIC_KEYS.key?(current_key_id) && (custom_server_key.nil? || custom_server_key.empty?)
    @logger.debug("Public key id #{current_key_id} unknown and no custom key present; using default #{DEFAULT_KEY_ID}")
    @config.save_string(ConfigKeys::KEY_SERVER_PUBLIC_KEY_ID, DEFAULT_KEY_ID)
  end

  # Override hostname if provided
  if options[:hostname]
    @hostname = options[:hostname]
    @config.save_string(ConfigKeys::KEY_HOSTNAME, @hostname)
  else
    @hostname = @config.get_string(ConfigKeys::KEY_HOSTNAME) || KeeperGlobals::DEFAULT_SERVER
  end

end

Instance Attribute Details

#configObject (readonly)

Returns the value of attribute config.



10
11
12
# File 'lib/keeper_secrets_manager/core.rb', line 10

def config
  @config
end

#hostnameObject (readonly)

Returns the value of attribute hostname.



10
11
12
# File 'lib/keeper_secrets_manager/core.rb', line 10

def hostname
  @hostname
end

#verify_ssl_certsObject (readonly)

Returns the value of attribute verify_ssl_certs.



10
11
12
# File 'lib/keeper_secrets_manager/core.rb', line 10

def verify_ssl_certs
  @verify_ssl_certs
end

Instance Method Details

#complete_transaction(record_uid, rollback: false) ⇒ Object

Complete transaction - commit or rollback Used after update_secret with transaction_type to finalize PAM rotation



375
376
377
378
379
380
381
382
383
384
385
386
387
388
# File 'lib/keeper_secrets_manager/core.rb', line 375

def complete_transaction(record_uid, rollback: false)
  @logger.debug("Completing transaction for record #{record_uid}, rollback: #{rollback}")

  # Prepare payload
  payload = prepare_complete_transaction_payload(record_uid)

  # Route to different endpoints based on rollback parameter
  endpoint = rollback ? 'rollback_secret_update' : 'finalize_secret_update'

  # Send request
  post_query(endpoint, payload)

  true
end

#create_folder(folder_name, parent_uid: nil) ⇒ Object

Create folder

Raises:

  • (ArgumentError)


476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
# File 'lib/keeper_secrets_manager/core.rb', line 476

def create_folder(folder_name, parent_uid: nil)
  raise ArgumentError, 'parent_uid is required to create a folder' unless parent_uid

  # Get folders to find parent's shared folder key
  folders = get_folders

  # Find parent folder
  parent_folder = folders.find { |f| f.uid == parent_uid }
  raise Error, "Parent folder #{parent_uid} not found" unless parent_folder

  # Determine if parent is a shared folder root (no parent_uid)
  is_shared_root = parent_folder.parent_uid.nil? || parent_folder.parent_uid.empty?

  # Find the shared folder root by traversing up the hierarchy
  if is_shared_root
    # Parent is the shared root, so new folder is at root level
    shared_folder_uid = parent_uid
    actual_parent_uid = nil  # nil for root-level folders
  else
    # Parent is a subfolder, traverse up to find shared root
    shared_folder_uid = parent_uid
    current_folder = parent_folder

    while current_folder.parent_uid && !current_folder.parent_uid.empty?
      parent = folders.find { |f| f.uid == current_folder.parent_uid }
      break unless parent

      shared_folder_uid = current_folder.parent_uid
      current_folder = parent
    end

    actual_parent_uid = parent_uid  # Subfolder creation
  end

  # Get shared folder's key (the root folder's key)
  shared_folder = folders.find { |f| f.uid == shared_folder_uid }
  raise Error, "Shared folder #{shared_folder_uid} not found" unless shared_folder

  shared_folder_key = shared_folder.folder_key
  raise Error, "Shared folder key missing for #{shared_folder_uid}" unless shared_folder_key

  # Generate new folder UID and key
  folder_uid = Utils.generate_uid
  folder_key = Crypto.generate_encryption_key_bytes

  # Prepare folder data
  folder_data = {
    'name' => folder_name
  }

  # Encrypt folder data with NEW folder's key using AES-CBC
  encrypted_data = Crypto.encrypt_aes_cbc(
    Utils.dict_to_json(folder_data),
    folder_key
  )

  # Encrypt folder key with SHARED folder's key using AES-CBC
  encrypted_folder_key = Crypto.encrypt_aes_cbc(folder_key, shared_folder_key)

  # Prepare payload
  payload = prepare_create_folder_payload(
    folder_uid: folder_uid,
    shared_folder_uid: shared_folder_uid,
    encrypted_folder_key: encrypted_folder_key,
    data: encrypted_data,
    parent_uid: actual_parent_uid  # nil for root, subfolder UID for nested
  )

  post_query('create_folder', payload)
  folder_uid
end

#create_secret(record_data, options = nil) ⇒ Object



276
277
278
279
280
281
282
283
284
285
# File 'lib/keeper_secrets_manager/core.rb', line 276

def create_secret(record_data, options = nil)
  create_options = if options.is_a?(Dto::CreateOptions)
                     options
                   elsif options.is_a?(String)
                     Dto::CreateOptions.new(folder_uid: options)
                   else
                     Dto::CreateOptions.new
                   end
  create_secret_with_options(create_options, record_data)
end

#create_secret_with_options(create_options, record_data, folders: nil) ⇒ Object

Raises:

  • (ArgumentError)


245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
# File 'lib/keeper_secrets_manager/core.rb', line 245

def create_secret_with_options(create_options, record_data, folders: nil)
  raise ArgumentError, 'folder_uid is required to create a record' unless create_options&.folder_uid

  folders ||= get_folders

  folder = folders.find { |f| f.uid == create_options.folder_uid }
  raise Error, "Folder #{create_options.folder_uid} not found or not accessible" unless folder

  folder_key = folder.folder_key
  raise Error, "Unable to create record - folder key for #{create_options.folder_uid} is missing" unless folder_key

  record_uid = Utils.generate_uid
  record_key = Crypto.generate_encryption_key_bytes

  record = record_data.is_a?(Dto::KeeperRecord) ? record_data.to_h : record_data

  encrypted_data = Crypto.encrypt_aes_gcm(Utils.dict_to_json(record), record_key)

  payload = prepare_create_payload(
    record_uid:    record_uid,
    record_key:    record_key,
    folder_uid:    create_options.folder_uid,
    folder_key:    folder_key,
    data:          encrypted_data,
    subfolder_uid: create_options.subfolder_uid
  )

  post_query('create_secret', payload)
  record_uid
end

#delete_folder(folder_uids, force: false) ⇒ Object

Delete folders



579
580
581
582
583
584
585
586
587
588
589
590
591
592
# File 'lib/keeper_secrets_manager/core.rb', line 579

def delete_folder(folder_uids, force: false)
  folder_uids = [folder_uids] if folder_uids.is_a?(String)

  payload = prepare_delete_folder_payload(folder_uids, force)
  response = post_query('delete_folder', payload)

  result = JSON.parse(response)
  folders = result['folders'] || []
  folders.each do |f|
    next if f['responseCode'] == 'ok'
    @logger.error("Failed to delete folder #{f['folderUid']}: #{f['responseCode']} #{f['errorMessage']}")
  end
  folders
end

#delete_secret(record_uids) ⇒ Object

Delete secrets



391
392
393
394
395
396
397
398
399
400
401
402
403
404
# File 'lib/keeper_secrets_manager/core.rb', line 391

def delete_secret(record_uids)
  record_uids = [record_uids] if record_uids.is_a?(String)

  payload = prepare_delete_payload(record_uids)
  response = post_query('delete_secret', payload)

  result = JSON.parse(response)
  records = result['records'] || []
  records.each do |r|
    next if r['responseCode'] == 'ok'
    @logger.error("Failed to delete record #{r['recordUid']}: #{r['responseCode']} #{r['errorMessage']}")
  end
  records
end

#download_encrypted_file(url) ⇒ Object

Download encrypted file from URL



815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
# File 'lib/keeper_secrets_manager/core.rb', line 815

def download_encrypted_file(url)
  uri = URI(url)

  @logger.debug("Downloading file from URL: #{url}")

  request = Net::HTTP::Get.new(uri)

  http = create_http_client(uri)
  configure_http_ssl(http)

  response = http.request(request)

  @logger.debug("Download response status: #{response.code}")

  if response.code == '200'
    response.body
  else
    raise Error, "Failed to download file: #{response.code} #{response.message}"
  end
end

#download_file(file_data) ⇒ Object

Download file from record's file data

Raises:



728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
# File 'lib/keeper_secrets_manager/core.rb', line 728

def download_file(file_data)
  # Extract file metadata (already decrypted)
  file_uid = file_data['fileUid']
  file_url = file_data['url']
  file_name = file_data['name'] || file_data['title'] || 'unnamed'

  raise Error, "No download URL available for file #{file_uid}" unless file_url

  # The file key should already be decrypted (base64 encoded)
  file_key = Utils.base64_to_bytes(file_data['fileKey'])

  # Download the encrypted file content
  encrypted_content = download_encrypted_file(file_url)

  # Decrypt the file content with the file key
  decrypted_content = Crypto.decrypt_aes_gcm(encrypted_content, file_key)

  # Return file info and data
  {
    'name' => file_name,
    'title' => file_data['title'] || file_name,
    'type' => file_data['type'],
    'size' => file_data['size'] || decrypted_content.bytesize,
    'data' => decrypted_content
  }
end

#download_thumbnail(file_data) ⇒ Object

Download file thumbnail

Raises:

  • (ArgumentError)


756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
# File 'lib/keeper_secrets_manager/core.rb', line 756

def download_thumbnail(file_data)
  if file_data.is_a?(Dto::KeeperFile)
    file_uid      = file_data.uid
    thumbnail_url = file_data.thumbnail_url
    file_key_str  = file_data.file_key
  else
    file_uid      = file_data['fileUid'] || file_data['uid']
    thumbnail_url = file_data['thumbnailUrl'] || file_data['thumbnail_url']
    file_key_str  = file_data['fileKey'] || file_data['file_key']
  end

  raise ArgumentError, 'File UID is required' unless file_uid
  raise Error, "No thumbnail URL available for file #{file_uid}" unless thumbnail_url
  raise Error, "File key not available for #{file_uid}" unless file_key_str

  file_key = Utils.base64_to_bytes(file_key_str)
  encrypted_content = download_encrypted_file(thumbnail_url)
  decrypted_content = Crypto.decrypt_aes_gcm(encrypted_content, file_key)

  {
    'file_uid' => file_uid,
    'data' => decrypted_content,
    'size' => decrypted_content.bytesize
  }
end

#fetch_and_decrypt_foldersObject

Fetch and decrypt folders from dedicated endpoint



163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
# File 'lib/keeper_secrets_manager/core.rb', line 163

def fetch_and_decrypt_folders
  # Prepare payload for get_folders endpoint (no filters)
  payload = prepare_get_payload(nil)

  # Make request to get_folders endpoint
  response_json = post_query('get_folders', payload)
  response_dict = JSON.parse(response_json)

  # Get app key for decryption
  app_key_str = @config.get_string(ConfigKeys::KEY_APP_KEY)

  # If we have app key directly (one-time token binding), use it
  if app_key_str && !app_key_str.empty?
    app_key = Utils.base64_to_bytes(app_key_str)
  else
    # Otherwise decrypt it using client key
    app_key_encrypted = Utils.base64_to_bytes(@config.get_string(ConfigKeys::KEY_ENCRYPTED_APP_KEY))
    client_key = Utils.base64_to_bytes(@config.get_string(ConfigKeys::KEY_CLIENT_KEY))
    app_key = Crypto.decrypt_aes_gcm(app_key_encrypted, client_key)
  end

  # Decrypt folders - need to handle them in order for shared folder keys
  folders = []
  response_folders = response_dict['folders'] || []

  response_folders.each do |encrypted_folder|
    folder_uid = encrypted_folder['folderUid']
    folder_parent = encrypted_folder['parent']

    # Decrypt folder key based on whether it has a parent
    if !folder_parent || folder_parent.empty?
      # Root folder - decrypt with app key
      folder_key_encrypted = Utils.base64_to_bytes(encrypted_folder['folderKey'])
      folder_key = Crypto.decrypt_aes_gcm(folder_key_encrypted, app_key)
    else
      # Child folder - decrypt with parent's shared folder key
      shared_folder_key = get_shared_folder_key(folders, response_folders, folder_parent)
      unless shared_folder_key
        @logger.error("Cannot find shared folder key for parent #{folder_parent}")
        next
      end
      folder_key_encrypted = Utils.base64_to_bytes(encrypted_folder['folderKey'])
      folder_key = Crypto.decrypt_aes_cbc(folder_key_encrypted, shared_folder_key)
    end

    # Decrypt folder data if present
    folder_name = ''
    if encrypted_folder['data'] && !encrypted_folder['data'].empty?
      data_encrypted = Utils.base64_to_bytes(encrypted_folder['data'])
      data_json = Crypto.decrypt_aes_cbc(data_encrypted, folder_key)
      data = JSON.parse(data_json)
      folder_name = data['name'] || ''
    end

    # Create folder object
    folder = Dto::KeeperFolder.new(
      'folderUid' => folder_uid,
      'name' => folder_name,
      'folderKey' => folder_key,
      'parent' => folder_parent,
      'records' => []
    )

    folders << folder
  rescue StandardError => e
    @logger.error("Failed to decrypt folder #{encrypted_folder['folderUid']}: #{e.message}")
  end

  folders
end

#find_folder_by_name(name, parent_uid: nil) ⇒ Object

Find folder by name (convenience method)



606
607
608
# File 'lib/keeper_secrets_manager/core.rb', line 606

def find_folder_by_name(name, parent_uid: nil)
  folder_manager.find_folder_by_name(name, parent_uid: parent_uid)
end

#folder_managerObject

Get folder hierarchy manager



595
596
597
598
# File 'lib/keeper_secrets_manager/core.rb', line 595

def folder_manager
  folders = get_folders
  FolderManager.new(folders)
end

#get_file_data(file_uid) ⇒ Object

Get file metadata from server



783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
# File 'lib/keeper_secrets_manager/core.rb', line 783

def get_file_data(file_uid)
  payload = prepare_get_payload(nil)
  payload.file_uids = [file_uid]

  response = post_query('get_files', payload)
  response_dict = JSON.parse(response)

  if response_dict['files'] && !response_dict['files'].empty?
    file_data = response_dict['files'].first

    # Decrypt file metadata
    # Get app key for decryption
    app_key_str = @config.get_string(ConfigKeys::KEY_APP_KEY)
    if app_key_str && !app_key_str.empty?
      app_key = Utils.base64_to_bytes(app_key_str)
    else
      # Decrypt app key with client key
      app_key_encrypted = Utils.base64_to_bytes(@config.get_string(ConfigKeys::KEY_ENCRYPTED_APP_KEY))
      client_key = get_client_key
      app_key = Crypto.decrypt_aes_gcm(app_key_encrypted, client_key)
    end

    encrypted_data = Utils.base64_to_bytes(file_data['data'])
    decrypted_json = Crypto.decrypt_aes_gcm(encrypted_data, app_key)

    JSON.parse(decrypted_json).merge('fileKey' => file_data['fileKey'])
  else
    raise Error, "File not found: #{file_uid}"
  end
end

#get_folder_path(folder_uid) ⇒ Object

Get folder path (convenience method)



601
602
603
# File 'lib/keeper_secrets_manager/core.rb', line 601

def get_folder_path(folder_uid)
  folder_manager.get_folder_path(folder_uid)
end

#get_foldersObject

Get all folders



158
159
160
# File 'lib/keeper_secrets_manager/core.rb', line 158

def get_folders
  fetch_and_decrypt_folders
end

#get_inflate_ref_types(field_type) ⇒ Object



406
407
408
# File 'lib/keeper_secrets_manager/core.rb', line 406

def get_inflate_ref_types(field_type)
  INFLATE_REF_TYPES.fetch(field_type, [])
end

#get_notation(notation_uri) ⇒ Object

Get notation value



448
449
450
451
# File 'lib/keeper_secrets_manager/core.rb', line 448

def get_notation(notation_uri)
  parser = Notation::Parser.new(self)
  parser.parse(notation_uri)
end

#get_notation_results(notation_uri) ⇒ Object



453
454
455
# File 'lib/keeper_secrets_manager/core.rb', line 453

def get_notation_results(notation_uri)
  Notation::Parser.new(self).get_notation_results(notation_uri)
end

#get_secret_by_title(title) ⇒ Object

Get first secret by title



241
242
243
# File 'lib/keeper_secrets_manager/core.rb', line 241

def get_secret_by_title(title)
  get_secrets_by_title(title).first
end

#get_secrets(uids = nil, full_response: false, request_links: false) ⇒ Object

Get secrets with optional filtering



130
131
132
133
134
135
# File 'lib/keeper_secrets_manager/core.rb', line 130

def get_secrets(uids = nil, full_response: false, request_links: false)
  uids = [uids] if uids.is_a?(String)

  query_options = Dto::QueryOptions.new(records: uids, folders: nil, request_links: request_links)
  get_secrets_with_options(query_options, full_response: full_response)
end

#get_secrets_by_title(title) ⇒ Object

Get secrets by title



235
236
237
238
# File 'lib/keeper_secrets_manager/core.rb', line 235

def get_secrets_by_title(title)
  records = get_secrets
  records.select { |r| r.title == title }
end

#get_secrets_with_options(query_options = nil, full_response: false) ⇒ Object

Get secrets with query options



138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
# File 'lib/keeper_secrets_manager/core.rb', line 138

def get_secrets_with_options(query_options = nil, full_response: false)
  records_resp = fetch_and_decrypt_secrets(query_options)

  # If just bound, fetch again
  records_resp = fetch_and_decrypt_secrets(query_options) if records_resp.just_bound

  # Log warnings
  records_resp.warnings&.each { |warning| @logger.warn(warning) }

  # Log bad records/folders
  if records_resp.errors&.any?
    records_resp.errors.each do |error|
      @logger.error("Error: #{error}")
    end
  end

  full_response ? records_resp : (records_resp.records || [])
end

#inflate_field_value(uids, replace_fields) ⇒ Object



410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
# File 'lib/keeper_secrets_manager/core.rb', line 410

def inflate_field_value(uids, replace_fields)
  records = get_secrets(uids)
  lookup  = records.each_with_object({}) { |r, h| h[r.uid] = r }

  uids.filter_map do |uid|
    record    = lookup[uid]
    next nil unless record

    new_value = nil
    replace_fields.each do |field_type|
      field = record.get_field(field_type)
      next unless field

      raw_values = field['value'] || []
      next if raw_values.empty?

      real_value = raw_values.first

      if INFLATE_REF_TYPES.key?(field_type)
        inflated   = inflate_field_value([real_value], INFLATE_REF_TYPES[field_type])
        real_value = inflated.first unless inflated.empty?
      end

      label = field['label'] || field_type
      if new_value.nil?
        new_value = real_value
      elsif new_value.is_a?(Hash) && real_value.is_a?(Hash)
        new_value[label] = real_value
      else
        new_value = { label => new_value, field_type => real_value }
      end
    end

    new_value
  end
end

#save(record, transaction_type: nil, links_to_remove: nil) ⇒ Object



368
369
370
371
# File 'lib/keeper_secrets_manager/core.rb', line 368

def save(record, transaction_type: nil, links_to_remove: nil)
  update_options = Dto::UpdateOptions.new(transaction_type: transaction_type, links_to_remove: links_to_remove)
  save_with_options(record, update_options)
end

#save_with_options(record, update_options = nil) ⇒ Object

Raises:

  • (ArgumentError)


340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
# File 'lib/keeper_secrets_manager/core.rb', line 340

def save_with_options(record, update_options = nil)
  if record.is_a?(Dto::KeeperRecord)
    record_uid  = record.uid
    record_data = record.to_h
    record_key  = record.record_key
    revision    = record.revision
  else
    record_uid  = record['uid'] || record[:uid]
    record_data = record
    record_key  = record['record_key'] || record[:record_key]
    revision    = record['revision']   || record[:revision] || 0
  end

  raise ArgumentError, 'Record UID is required' unless record_uid
  raise Error, "Record key not available for #{record_uid} - record must be obtained via get_secrets" unless record_key

  payload = prepare_update_payload(
    record_uid:     record_uid,
    record_data:    record_data,
    record_key:     record_key,
    revision:       revision,
    update_options: update_options
  )

  post_query('update_secret', payload)
  true
end

#try_get_notation(notation_uri) ⇒ Object

Get notation value without raising exceptions (convenience method) Returns empty array if notation is invalid or record not found



467
468
469
470
471
472
473
# File 'lib/keeper_secrets_manager/core.rb', line 467

def try_get_notation(notation_uri)
  parser = Notation::Parser.new(self)
  Array(parser.parse(notation_uri))
rescue NotationError, RecordNotFoundError, StandardError => e
  @logger.debug("try_get_notation failed for '#{notation_uri}': #{e.message}")
  []
end

#try_get_notation_results(notation_uri) ⇒ Object

Like get_notation_results but never raises; logs errors and returns [].



458
459
460
461
462
463
# File 'lib/keeper_secrets_manager/core.rb', line 458

def try_get_notation_results(notation_uri)
  get_notation_results(notation_uri)
rescue NotationError, RecordNotFoundError, StandardError => e
  @logger.error("try_get_notation_results failed for '#{notation_uri}': #{e.message}")
  []
end

#update_folder(folder_uid, folder_name) ⇒ Object

Update folder

Raises:



549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
# File 'lib/keeper_secrets_manager/core.rb', line 549

def update_folder(folder_uid, folder_name)
  # Get folders to find the folder's key
  folders = get_folders
  folder = folders.find { |f| f.uid == folder_uid }
  raise Error, "Folder #{folder_uid} not found" unless folder

  folder_key = folder.folder_key
  raise Error, "Folder key missing for #{folder_uid}" unless folder_key

  # Prepare folder data
  folder_data = {
    'name' => folder_name
  }

  # Encrypt folder data with folder's key using AES-CBC
  encrypted_data = Crypto.encrypt_aes_cbc(
    Utils.dict_to_json(folder_data),
    folder_key
  )

  payload = prepare_update_folder_payload(
    folder_uid: folder_uid,
    data: encrypted_data
  )

  post_query('update_folder', payload)
  true
end

#update_secret(record, transaction_type: 'general') ⇒ Object

Update existing secret (convenience wrapper)



322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
# File 'lib/keeper_secrets_manager/core.rb', line 322

def update_secret(record, transaction_type: 'general')
  update_options = Dto::UpdateOptions.new(transaction_type: transaction_type)
  update_secret_with_options(record, update_options)

  uid = record.is_a?(Dto::KeeperRecord) ? record.uid : (record['uid'] || record[:uid])
  complete_transaction(uid) if uid

  if record.is_a?(Dto::KeeperRecord)
    updated_record = get_secrets([record.uid]).first
    if updated_record
      record.revision = updated_record.revision
      @logger&.debug("update_secret: updated local revision to #{record.revision}")
    end
  end

  true
end

#update_secret_with_options(record, update_options = nil) ⇒ Object

Update existing secret with UpdateOptions

Raises:

  • (ArgumentError)


288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
# File 'lib/keeper_secrets_manager/core.rb', line 288

def update_secret_with_options(record, update_options = nil)
  # Handle both record object and hash
  if record.is_a?(Dto::KeeperRecord)
    record_uid = record.uid
    record_data = record.to_h
  else
    record_uid = record['uid'] || record[:uid]
    record_data = record
  end

  raise ArgumentError, 'Record UID is required' unless record_uid

  # Get existing record to get the key
  existing = get_secrets([record_uid]).first
  raise RecordNotFoundError, "Record #{record_uid} not found" unless existing

  # Get record key for encryption
  record_key = existing.record_key
  raise Error, "Record key not available for #{record_uid}" unless record_key

  # Prepare payload (handles UpdateOptions internally)
  payload = prepare_update_payload(
    record_uid: record_uid,
    record_data: record_data,
    record_key: record_key,
    revision: existing.revision,
    update_options: update_options
  )

  # Send request
  post_query('update_secret', payload)
end

#upload_file(owner_record_uid, file_data, file_name, file_title = nil) ⇒ Object

Upload file

Raises:



611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
# File 'lib/keeper_secrets_manager/core.rb', line 611

def upload_file(owner_record_uid, file_data, file_name, file_title = nil)
  file_title ||= file_name

  # Fetch the owner record (decrypted) to get current state
  owner_records = get_secrets([owner_record_uid])
  raise Error, "Owner record #{owner_record_uid} not found" if owner_records.empty?

  owner_record = owner_records.first
  owner_revision = owner_record.revision

  # Get owner record data as hash for manipulation
  owner_record_data = owner_record.to_h

  # Get the record_key (stored during decryption)
  owner_record_key = owner_record.record_key
  raise Error, "Record key not available for owner record #{owner_record_uid}" unless owner_record_key

  # Get owner record's public key from storage (app owner public key)
  owner_public_key = @config.get_string(ConfigKeys::KEY_OWNER_PUBLIC_KEY)
  raise Error, "Owner public key not found in config - application may need re-binding" unless owner_public_key

  owner_public_key_bytes = Utils.url_safe_str_to_bytes(owner_public_key)

  # Generate file record UID and key
  file_uid = Utils.generate_uid
  file_key = Crypto.generate_encryption_key_bytes

  # Encrypt file data with file key
  encrypted_file = Crypto.encrypt_aes_gcm(file_data, file_key)

  # Create file record metadata
  file_record = {
    'name' => file_name,
    'size' => file_data.bytesize,
    'title' => file_title,
    'lastModified' => (Time.now.to_f * 1000).to_i,
    'type' => 'application/octet-stream'
  }

  # Encrypt file record metadata with file key
  file_record_json = Utils.dict_to_json(file_record)
  file_record_bytes = file_record_json.bytes
  encrypted_file_record = Crypto.encrypt_aes_gcm(file_record_bytes.pack('C*'), file_key)

  # Encrypt file record key with owner's public key (ECIES)
  encrypted_file_record_key = Crypto.encrypt_ec(file_key, owner_public_key_bytes)

  # Encrypt file record key with owner record key (for linkKey)
  encrypted_link_key = Crypto.encrypt_aes_gcm(file_key, owner_record_key)

  # Add fileRef to owner record's fields
  fields = owner_record_data['fields'] || []

  file_ref_field = fields.find { |f| f['type'] == 'fileRef' }
  if file_ref_field
    file_ref_field['value'] ||= []
    file_ref_field['value'] << file_uid
  else
    fields << { 'type' => 'fileRef', 'value' => [file_uid] }
  end

  # Update owner record data
  owner_record_data['fields'] = fields
  owner_record_json = Utils.dict_to_json(owner_record_data)
  owner_record_bytes = owner_record_json.bytes.pack('C*')

  # Encrypt updated owner record with its record key
  encrypted_owner_record_data = Crypto.encrypt_aes_gcm(owner_record_bytes, owner_record_key)

  # Prepare payload
  payload = prepare_file_upload_payload(
    file_record_uid: file_uid,
    file_record_key: encrypted_file_record_key,
    file_record_data: encrypted_file_record,
    owner_record_uid: owner_record_uid,
    owner_record_data: encrypted_owner_record_data,
    owner_record_revision: owner_revision,
    link_key: encrypted_link_key,
    file_size: encrypted_file.bytesize
  )

  # Get upload URL
  response = post_query('add_file', payload)
  upload_result = JSON.parse(response)

  # Upload file
  upload_file_function(
    upload_result['url'],
    upload_result['parameters'],
    encrypted_file
  )

  file_uid
end

#upload_file_from_path(owner_record_uid, file_path, file_title: nil) ⇒ Object

Upload file from disk path (convenience method) Reads file from disk and uploads to specified record

Raises:

  • (ArgumentError)


708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
# File 'lib/keeper_secrets_manager/core.rb', line 708

def upload_file_from_path(owner_record_uid, file_path, file_title: nil)
  raise ArgumentError, "File not found: #{file_path}" unless File.exist?(file_path)
  raise ArgumentError, "Path is a directory: #{file_path}" if File.directory?(file_path)

  # Read file data
  file_data = File.binread(file_path)

  # Extract filename from path
  file_name = File.basename(file_path)

  # Use file_title if provided, otherwise use filename
  file_title ||= file_name

  @logger.debug("Uploading file from path: #{file_path} (#{file_data.bytesize} bytes)")

  # Delegate to existing upload_file method
  upload_file(owner_record_uid, file_data, file_name, file_title)
end