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