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
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:



21
22
23
24
25
26
27
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
# File 'lib/keeper_secrets_manager/core.rb', line 21

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

  # 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]

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

  # 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

  # 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

  # Cache configuration
  @cache = {}
  @cache_expiry = {}
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

#create_folder(folder_name, parent_uid: nil) ⇒ Object

Create folder

Raises:

  • (ArgumentError)


354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
# File 'lib/keeper_secrets_manager/core.rb', line 354

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

Create a new secret

Raises:

  • (ArgumentError)


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
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
# File 'lib/keeper_secrets_manager/core.rb', line 206

def create_secret(record_data, options = nil)
  options ||= Dto::CreateOptions.new

  # Validate folder UID is provided
  raise ArgumentError, 'folder_uid is required to create a record' unless options.folder_uid

  # Get folders from dedicated endpoint to find folder key
  folders = get_folders

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

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

  # Generate UIDs and keys
  record_uid = Utils.generate_uid
  record_key = Crypto.generate_encryption_key_bytes

  # Prepare record data
  record = if record_data.is_a?(Dto::KeeperRecord)
             record_data.to_h
           else
             record_data
           end

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

  # Prepare payload
  payload = prepare_create_payload(
    record_uid: record_uid,
    record_key: record_key,
    folder_uid: options.folder_uid,
    folder_key: folder_key,
    data: encrypted_data
  )

  # Send request
  response = post_query('create_secret', payload)

  # Return created record UID
  record_uid
end

#delete_folder(folder_uids, force: false) ⇒ Object

Delete folders



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

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)
  result['folders']
end

#delete_secret(record_uids) ⇒ Object

Delete secrets



337
338
339
340
341
342
343
344
345
# File 'lib/keeper_secrets_manager/core.rb', line 337

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)
  result['records']
end

#download_encrypted_file(url) ⇒ Object

Download encrypted file from URL



640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
# File 'lib/keeper_secrets_manager/core.rb', line 640

def download_encrypted_file(url)
  uri = URI(url)

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

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

  http = Net::HTTP.new(uri.host, uri.port)
  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:



580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
# File 'lib/keeper_secrets_manager/core.rb', line 580

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

#fetch_and_decrypt_foldersObject

Fetch and decrypt folders from dedicated endpoint



123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
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
# File 'lib/keeper_secrets_manager/core.rb', line 123

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)



479
480
481
# File 'lib/keeper_secrets_manager/core.rb', line 479

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



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

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

#get_file_data(file_uid) ⇒ Object

Get file metadata from server



608
609
610
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
# File 'lib/keeper_secrets_manager/core.rb', line 608

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)



474
475
476
# File 'lib/keeper_secrets_manager/core.rb', line 474

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

#get_foldersObject

Get all folders



118
119
120
# File 'lib/keeper_secrets_manager/core.rb', line 118

def get_folders
  fetch_and_decrypt_folders
end

#get_notation(notation_uri) ⇒ Object

Get notation value



348
349
350
351
# File 'lib/keeper_secrets_manager/core.rb', line 348

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

#get_secret_by_title(title) ⇒ Object

Get first secret by title



201
202
203
# File 'lib/keeper_secrets_manager/core.rb', line 201

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

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

Get secrets with optional filtering



90
91
92
93
94
95
# File 'lib/keeper_secrets_manager/core.rb', line 90

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

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

#get_secrets_by_title(title) ⇒ Object

Get secrets by title



195
196
197
198
# File 'lib/keeper_secrets_manager/core.rb', line 195

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



98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
# File 'lib/keeper_secrets_manager/core.rb', line 98

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

#update_folder(folder_uid, folder_name) ⇒ Object

Update folder

Raises:



427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
# File 'lib/keeper_secrets_manager/core.rb', line 427

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

Raises:

  • (ArgumentError)


257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
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
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
# File 'lib/keeper_secrets_manager/core.rb', line 257

def update_secret(record, transaction_type: 'general')
  # 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

  # Record key is already raw bytes (stored during decryption)
  # No conversion needed - use directly for encryption

  # Debug: Log record data before encryption
  @logger&.debug("update_secret: record_uid=#{record_uid}")
  @logger&.debug("update_secret: record_data keys=#{record_data.keys.inspect}")
  @logger&.debug("update_secret: record_data=#{record_data.inspect[0..200]}...")
  @logger&.debug("update_secret: record_key present=#{!record_key.nil?}, length=#{record_key&.bytesize}")

  # Encrypt record data with record key (same as create_secret)
  json_data = Utils.dict_to_json(record_data)
  @logger&.debug("update_secret: json_data length=#{json_data.bytesize}")
  @logger&.debug("update_secret: json_data=#{json_data[0..200]}...")

  encrypted_data = Crypto.encrypt_aes_gcm(json_data, record_key)
  @logger&.debug("update_secret: encrypted_data length=#{encrypted_data.bytesize}")
  @logger&.debug("update_secret: encrypted_data (base64)=#{Base64.strict_encode64(encrypted_data)[0..50]}...")

  # Prepare payload
  payload = prepare_update_payload(
    record_uid: record_uid,
    data: encrypted_data,
    revision: existing.revision,
    transaction_type: transaction_type
  )

  @logger&.debug("update_secret: payload revision=#{existing.revision}")
  @logger&.debug("update_secret: payload transaction_type=#{transaction_type}")

  # Send request
  @logger&.debug("update_secret: sending post_query to update_secret endpoint")
  response = post_query('update_secret', payload)
  @logger&.debug("update_secret: response received")
  @logger&.debug("update_secret: response class=#{response.class}")
  @logger&.debug("update_secret: response=#{response.inspect[0..500]}...")

  # Always finalize the update (required for changes to persist)
  # This applies to both 'general' and 'rotation' transaction types
  complete_payload = Dto::CompleteTransactionPayload.new
  complete_payload.client_version = KeeperGlobals.client_version
  complete_payload.client_id = @config.get_string(ConfigKeys::KEY_CLIENT_ID)
  complete_payload.record_uid = record_uid

  post_query('finalize_secret_update', complete_payload)

  # Update local record's revision to reflect server state
  # Since the server doesn't return the new revision in the response,
  # we need to refetch the record to get the actual revision
  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

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

Upload file

Raises:



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
547
548
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
577
# File 'lib/keeper_secrets_manager/core.rb', line 484

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