keeper_secrets_manager 17.2.1
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 +139 -0
- data/Gemfile +16 -0
- data/LICENSE +21 -0
- data/README.md +113 -0
- data/Rakefile +30 -0
- data/bin/console +47 -0
- data/keeper_secrets_manager.gemspec +36 -0
- data/lib/keeper_secrets_manager/cache.rb +139 -0
- data/lib/keeper_secrets_manager/config_keys.rb +29 -0
- data/lib/keeper_secrets_manager/core.rb +1781 -0
- data/lib/keeper_secrets_manager/crypto.rb +333 -0
- data/lib/keeper_secrets_manager/dto/payload.rb +153 -0
- data/lib/keeper_secrets_manager/dto.rb +557 -0
- data/lib/keeper_secrets_manager/errors.rb +90 -0
- data/lib/keeper_secrets_manager/field_types.rb +152 -0
- data/lib/keeper_secrets_manager/folder_manager.rb +110 -0
- data/lib/keeper_secrets_manager/keeper_globals.rb +53 -0
- data/lib/keeper_secrets_manager/notation.rb +463 -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 +263 -0
- data/lib/keeper_secrets_manager/version.rb +3 -0
- data/lib/keeper_secrets_manager.rb +46 -0
- metadata +102 -0
|
@@ -0,0 +1,1781 @@
|
|
|
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
|
+
# Throttle retry (KSM-876 / KSM-883). The backend throttles HTTP 403 {"error":"throttled"}
|
|
16
|
+
# per clientId+endpoint (100 requests / 10s window; memcached TTL 10s that resets on every
|
|
17
|
+
# request, so the counter only clears after 10s of silence).
|
|
18
|
+
MAX_THROTTLE_RETRIES = 5
|
|
19
|
+
BASE_THROTTLE_DELAY_SEC = 11 # 1s safety margin over the backend's 10s memcached TTL
|
|
20
|
+
MAX_THROTTLE_DELAY_SEC = 176 # cap on server-supplied retry_after (BASE * 2**4)
|
|
21
|
+
|
|
22
|
+
# Field types that can be inflated
|
|
23
|
+
INFLATE_REF_TYPES = {
|
|
24
|
+
'addressRef' => ['address'],
|
|
25
|
+
'cardRef' => %w[paymentCard text pinCode addressRef]
|
|
26
|
+
}.freeze
|
|
27
|
+
|
|
28
|
+
def initialize(options = {})
|
|
29
|
+
# Check Ruby version
|
|
30
|
+
raise Error, 'KSM SDK requires Ruby 3.1 or greater' if RUBY_VERSION < '3.1'
|
|
31
|
+
|
|
32
|
+
# Check AES-GCM support
|
|
33
|
+
begin
|
|
34
|
+
OpenSSL::Cipher.new('AES-256-GCM')
|
|
35
|
+
rescue RuntimeError => e
|
|
36
|
+
if e.message.include?('unsupported cipher')
|
|
37
|
+
raise Error,
|
|
38
|
+
"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+"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
raise e
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
@token = nil
|
|
45
|
+
@hostname = nil
|
|
46
|
+
@verify_ssl_certs = options.fetch(:verify_ssl_certs, true)
|
|
47
|
+
@custom_post_function = options[:custom_post_function]
|
|
48
|
+
|
|
49
|
+
# optional custom server public key overrides (isolated deployments).
|
|
50
|
+
# Precedence, highest first: these programmatic params > OTT segments > pre-existing config.
|
|
51
|
+
@server_public_key_override = options[:server_public_key]
|
|
52
|
+
@server_public_key_id_override = options[:server_public_key_id]
|
|
53
|
+
|
|
54
|
+
# Set up proxy configuration
|
|
55
|
+
# Priority: explicit proxy_url parameter > HTTPS_PROXY env var > no proxy
|
|
56
|
+
@proxy_url = options[:proxy_url] || ENV['HTTPS_PROXY'] || ENV['https_proxy']
|
|
57
|
+
|
|
58
|
+
if @proxy_url
|
|
59
|
+
begin
|
|
60
|
+
proxy_uri = URI.parse(@proxy_url)
|
|
61
|
+
unless proxy_uri.is_a?(URI::HTTP) && !proxy_uri.host.to_s.empty?
|
|
62
|
+
raise ArgumentError,
|
|
63
|
+
"Invalid proxy_url '#{@proxy_url}': must be a valid http or https URL with a host (e.g., http://proxy.example.com:8080)"
|
|
64
|
+
end
|
|
65
|
+
rescue URI::InvalidURIError => e
|
|
66
|
+
raise ArgumentError, "Invalid proxy_url '#{@proxy_url}': #{e.message}"
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Set up logging
|
|
71
|
+
@logger = options[:logger] || Logger.new(STDOUT)
|
|
72
|
+
@logger.level = options[:log_level] || Logger::WARN
|
|
73
|
+
|
|
74
|
+
@logger.debug("Proxy configuration: #{@proxy_url ? @proxy_url : 'none'}") if @proxy_url
|
|
75
|
+
|
|
76
|
+
# Handle configuration
|
|
77
|
+
config = options[:config]
|
|
78
|
+
token = options[:token]
|
|
79
|
+
|
|
80
|
+
# Check environment variable if no config provided
|
|
81
|
+
config = Storage::InMemoryStorage.new(ENV['KSM_CONFIG']) if config.nil? && ENV['KSM_CONFIG']
|
|
82
|
+
|
|
83
|
+
# If we have config, check if it's already initialized
|
|
84
|
+
if config
|
|
85
|
+
@config = config
|
|
86
|
+
# Check if already bound (has client ID and app key)
|
|
87
|
+
if @config.get_string(ConfigKeys::KEY_CLIENT_ID) && @config.get_bytes(ConfigKeys::KEY_APP_KEY)
|
|
88
|
+
@logger.debug('Using existing credentials from config')
|
|
89
|
+
elsif token
|
|
90
|
+
# Config exists but not bound, use token to bind
|
|
91
|
+
@logger.debug('Config provided but not bound, using token to initialize')
|
|
92
|
+
process_token_binding(token, options[:hostname])
|
|
93
|
+
else
|
|
94
|
+
@logger.warn('Config provided but no credentials found and no token provided')
|
|
95
|
+
end
|
|
96
|
+
elsif token
|
|
97
|
+
# No config provided, create new one with token
|
|
98
|
+
@logger.debug('No config provided, creating new one with token')
|
|
99
|
+
process_token_binding(token, options[:hostname])
|
|
100
|
+
@config ||= Storage::InMemoryStorage.new
|
|
101
|
+
else
|
|
102
|
+
# No config and no token
|
|
103
|
+
raise Error, 'Either token or initialized config must be provided'
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# programmatic custom key wins over token/config (applied once @config is set).
|
|
107
|
+
@config.save_string(ConfigKeys::KEY_SERVER_PUBLIC_KEY, @server_public_key_override) if @server_public_key_override && !@server_public_key_override.empty?
|
|
108
|
+
@config.save_string(ConfigKeys::KEY_SERVER_PUBLIC_KEY_ID, @server_public_key_id_override.to_s) if @server_public_key_id_override
|
|
109
|
+
|
|
110
|
+
# if the configured key id is not in the built-in table AND no custom key backs it,
|
|
111
|
+
# fall back to the default (mirrors Python). A persisted custom serverPublicKey is preserved.
|
|
112
|
+
current_key_id = @config.get_string(ConfigKeys::KEY_SERVER_PUBLIC_KEY_ID)
|
|
113
|
+
custom_server_key = @config.get_string(ConfigKeys::KEY_SERVER_PUBLIC_KEY)
|
|
114
|
+
if current_key_id && !KeeperGlobals::KEEPER_PUBLIC_KEYS.key?(current_key_id) && (custom_server_key.nil? || custom_server_key.empty?)
|
|
115
|
+
@logger.debug("Public key id #{current_key_id} unknown and no custom key present; using default #{DEFAULT_KEY_ID}")
|
|
116
|
+
@config.save_string(ConfigKeys::KEY_SERVER_PUBLIC_KEY_ID, DEFAULT_KEY_ID)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Override hostname if provided
|
|
120
|
+
if options[:hostname]
|
|
121
|
+
@hostname = options[:hostname]
|
|
122
|
+
@config.save_string(ConfigKeys::KEY_HOSTNAME, @hostname)
|
|
123
|
+
else
|
|
124
|
+
@hostname = @config.get_string(ConfigKeys::KEY_HOSTNAME) || KeeperGlobals::DEFAULT_SERVER
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Get secrets with optional filtering
|
|
130
|
+
def get_secrets(uids = nil, full_response: false, request_links: false)
|
|
131
|
+
uids = [uids] if uids.is_a?(String)
|
|
132
|
+
|
|
133
|
+
query_options = Dto::QueryOptions.new(records: uids, folders: nil, request_links: request_links)
|
|
134
|
+
get_secrets_with_options(query_options, full_response: full_response)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Get secrets with query options
|
|
138
|
+
def get_secrets_with_options(query_options = nil, full_response: false)
|
|
139
|
+
records_resp = fetch_and_decrypt_secrets(query_options)
|
|
140
|
+
|
|
141
|
+
# If just bound, fetch again
|
|
142
|
+
records_resp = fetch_and_decrypt_secrets(query_options) if records_resp.just_bound
|
|
143
|
+
|
|
144
|
+
# Log warnings
|
|
145
|
+
records_resp.warnings&.each { |warning| @logger.warn(warning) }
|
|
146
|
+
|
|
147
|
+
# Log bad records/folders
|
|
148
|
+
if records_resp.errors&.any?
|
|
149
|
+
records_resp.errors.each do |error|
|
|
150
|
+
@logger.error("Error: #{error}")
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
full_response ? records_resp : (records_resp.records || [])
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Get all folders
|
|
158
|
+
def get_folders
|
|
159
|
+
fetch_and_decrypt_folders
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Fetch and decrypt folders from dedicated endpoint
|
|
163
|
+
def fetch_and_decrypt_folders
|
|
164
|
+
# Prepare payload for get_folders endpoint (no filters)
|
|
165
|
+
payload = prepare_get_payload(nil)
|
|
166
|
+
|
|
167
|
+
# Make request to get_folders endpoint
|
|
168
|
+
response_json = post_query('get_folders', payload)
|
|
169
|
+
response_dict = JSON.parse(response_json)
|
|
170
|
+
|
|
171
|
+
# Get app key for decryption
|
|
172
|
+
app_key_str = @config.get_string(ConfigKeys::KEY_APP_KEY)
|
|
173
|
+
|
|
174
|
+
# If we have app key directly (one-time token binding), use it
|
|
175
|
+
if app_key_str && !app_key_str.empty?
|
|
176
|
+
app_key = Utils.base64_to_bytes(app_key_str)
|
|
177
|
+
else
|
|
178
|
+
# Otherwise decrypt it using client key
|
|
179
|
+
app_key_encrypted = Utils.base64_to_bytes(@config.get_string(ConfigKeys::KEY_ENCRYPTED_APP_KEY))
|
|
180
|
+
client_key = Utils.base64_to_bytes(@config.get_string(ConfigKeys::KEY_CLIENT_KEY))
|
|
181
|
+
app_key = Crypto.decrypt_aes_gcm(app_key_encrypted, client_key)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Decrypt folders - need to handle them in order for shared folder keys
|
|
185
|
+
folders = []
|
|
186
|
+
response_folders = response_dict['folders'] || []
|
|
187
|
+
|
|
188
|
+
response_folders.each do |encrypted_folder|
|
|
189
|
+
folder_uid = encrypted_folder['folderUid']
|
|
190
|
+
folder_parent = encrypted_folder['parent']
|
|
191
|
+
|
|
192
|
+
# Decrypt folder key based on whether it has a parent
|
|
193
|
+
if !folder_parent || folder_parent.empty?
|
|
194
|
+
# Root folder - decrypt with app key
|
|
195
|
+
folder_key_encrypted = Utils.base64_to_bytes(encrypted_folder['folderKey'])
|
|
196
|
+
folder_key = Crypto.decrypt_aes_gcm(folder_key_encrypted, app_key)
|
|
197
|
+
else
|
|
198
|
+
# Child folder - decrypt with parent's shared folder key
|
|
199
|
+
shared_folder_key = get_shared_folder_key(folders, response_folders, folder_parent)
|
|
200
|
+
unless shared_folder_key
|
|
201
|
+
@logger.error("Cannot find shared folder key for parent #{folder_parent}")
|
|
202
|
+
next
|
|
203
|
+
end
|
|
204
|
+
folder_key_encrypted = Utils.base64_to_bytes(encrypted_folder['folderKey'])
|
|
205
|
+
folder_key = Crypto.decrypt_aes_cbc(folder_key_encrypted, shared_folder_key)
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# Decrypt folder data if present
|
|
209
|
+
folder_name = ''
|
|
210
|
+
if encrypted_folder['data'] && !encrypted_folder['data'].empty?
|
|
211
|
+
data_encrypted = Utils.base64_to_bytes(encrypted_folder['data'])
|
|
212
|
+
data_json = Crypto.decrypt_aes_cbc(data_encrypted, folder_key)
|
|
213
|
+
data = JSON.parse(data_json)
|
|
214
|
+
folder_name = data['name'] || ''
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# Create folder object
|
|
218
|
+
folder = Dto::KeeperFolder.new(
|
|
219
|
+
'folderUid' => folder_uid,
|
|
220
|
+
'name' => folder_name,
|
|
221
|
+
'folderKey' => folder_key,
|
|
222
|
+
'parent' => folder_parent,
|
|
223
|
+
'records' => []
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
folders << folder
|
|
227
|
+
rescue StandardError => e
|
|
228
|
+
@logger.error("Failed to decrypt folder #{encrypted_folder['folderUid']}: #{e.message}")
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
folders
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# Get secrets by title
|
|
235
|
+
def get_secrets_by_title(title)
|
|
236
|
+
records = get_secrets
|
|
237
|
+
records.select { |r| r.title == title }
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# Get first secret by title
|
|
241
|
+
def get_secret_by_title(title)
|
|
242
|
+
get_secrets_by_title(title).first
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def create_secret_with_options(create_options, record_data, folders: nil)
|
|
246
|
+
raise ArgumentError, 'folder_uid is required to create a record' unless create_options&.folder_uid
|
|
247
|
+
|
|
248
|
+
folders ||= get_folders
|
|
249
|
+
|
|
250
|
+
folder = folders.find { |f| f.uid == create_options.folder_uid }
|
|
251
|
+
raise Error, "Folder #{create_options.folder_uid} not found or not accessible" unless folder
|
|
252
|
+
|
|
253
|
+
folder_key = folder.folder_key
|
|
254
|
+
raise Error, "Unable to create record - folder key for #{create_options.folder_uid} is missing" unless folder_key
|
|
255
|
+
|
|
256
|
+
record_uid = Utils.generate_uid
|
|
257
|
+
record_key = Crypto.generate_encryption_key_bytes
|
|
258
|
+
|
|
259
|
+
record = record_data.is_a?(Dto::KeeperRecord) ? record_data.to_h : record_data
|
|
260
|
+
|
|
261
|
+
encrypted_data = Crypto.encrypt_aes_gcm(Utils.dict_to_json(record), record_key)
|
|
262
|
+
|
|
263
|
+
payload = prepare_create_payload(
|
|
264
|
+
record_uid: record_uid,
|
|
265
|
+
record_key: record_key,
|
|
266
|
+
folder_uid: create_options.folder_uid,
|
|
267
|
+
folder_key: folder_key,
|
|
268
|
+
data: encrypted_data,
|
|
269
|
+
subfolder_uid: create_options.subfolder_uid
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
post_query('create_secret', payload)
|
|
273
|
+
record_uid
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def create_secret(record_data, options = nil)
|
|
277
|
+
create_options = if options.is_a?(Dto::CreateOptions)
|
|
278
|
+
options
|
|
279
|
+
elsif options.is_a?(String)
|
|
280
|
+
Dto::CreateOptions.new(folder_uid: options)
|
|
281
|
+
else
|
|
282
|
+
Dto::CreateOptions.new
|
|
283
|
+
end
|
|
284
|
+
create_secret_with_options(create_options, record_data)
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
# Update existing secret with UpdateOptions
|
|
288
|
+
def update_secret_with_options(record, update_options = nil)
|
|
289
|
+
# Handle both record object and hash
|
|
290
|
+
if record.is_a?(Dto::KeeperRecord)
|
|
291
|
+
record_uid = record.uid
|
|
292
|
+
record_data = record.to_h
|
|
293
|
+
else
|
|
294
|
+
record_uid = record['uid'] || record[:uid]
|
|
295
|
+
record_data = record
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
raise ArgumentError, 'Record UID is required' unless record_uid
|
|
299
|
+
|
|
300
|
+
# Get existing record to get the key
|
|
301
|
+
existing = get_secrets([record_uid]).first
|
|
302
|
+
raise RecordNotFoundError, "Record #{record_uid} not found" unless existing
|
|
303
|
+
|
|
304
|
+
# Get record key for encryption
|
|
305
|
+
record_key = existing.record_key
|
|
306
|
+
raise Error, "Record key not available for #{record_uid}" unless record_key
|
|
307
|
+
|
|
308
|
+
# Prepare payload (handles UpdateOptions internally)
|
|
309
|
+
payload = prepare_update_payload(
|
|
310
|
+
record_uid: record_uid,
|
|
311
|
+
record_data: record_data,
|
|
312
|
+
record_key: record_key,
|
|
313
|
+
revision: existing.revision,
|
|
314
|
+
update_options: update_options
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
# Send request
|
|
318
|
+
post_query('update_secret', payload)
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
# Update existing secret (convenience wrapper)
|
|
322
|
+
def update_secret(record, transaction_type: 'general')
|
|
323
|
+
update_options = Dto::UpdateOptions.new(transaction_type: transaction_type)
|
|
324
|
+
update_secret_with_options(record, update_options)
|
|
325
|
+
|
|
326
|
+
uid = record.is_a?(Dto::KeeperRecord) ? record.uid : (record['uid'] || record[:uid])
|
|
327
|
+
complete_transaction(uid) if uid
|
|
328
|
+
|
|
329
|
+
if record.is_a?(Dto::KeeperRecord)
|
|
330
|
+
updated_record = get_secrets([record.uid]).first
|
|
331
|
+
if updated_record
|
|
332
|
+
record.revision = updated_record.revision
|
|
333
|
+
@logger&.debug("update_secret: updated local revision to #{record.revision}")
|
|
334
|
+
end
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
true
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
def save_with_options(record, update_options = nil)
|
|
341
|
+
if record.is_a?(Dto::KeeperRecord)
|
|
342
|
+
record_uid = record.uid
|
|
343
|
+
record_data = record.to_h
|
|
344
|
+
record_key = record.record_key
|
|
345
|
+
revision = record.revision
|
|
346
|
+
else
|
|
347
|
+
record_uid = record['uid'] || record[:uid]
|
|
348
|
+
record_data = record
|
|
349
|
+
record_key = record['record_key'] || record[:record_key]
|
|
350
|
+
revision = record['revision'] || record[:revision] || 0
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
raise ArgumentError, 'Record UID is required' unless record_uid
|
|
354
|
+
raise Error, "Record key not available for #{record_uid} - record must be obtained via get_secrets" unless record_key
|
|
355
|
+
|
|
356
|
+
payload = prepare_update_payload(
|
|
357
|
+
record_uid: record_uid,
|
|
358
|
+
record_data: record_data,
|
|
359
|
+
record_key: record_key,
|
|
360
|
+
revision: revision,
|
|
361
|
+
update_options: update_options
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
post_query('update_secret', payload)
|
|
365
|
+
true
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
def save(record, transaction_type: nil, links_to_remove: nil)
|
|
369
|
+
update_options = Dto::UpdateOptions.new(transaction_type: transaction_type, links_to_remove: links_to_remove)
|
|
370
|
+
save_with_options(record, update_options)
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
# Complete transaction - commit or rollback
|
|
374
|
+
# Used after update_secret with transaction_type to finalize PAM rotation
|
|
375
|
+
def complete_transaction(record_uid, rollback: false)
|
|
376
|
+
@logger.debug("Completing transaction for record #{record_uid}, rollback: #{rollback}")
|
|
377
|
+
|
|
378
|
+
# Prepare payload
|
|
379
|
+
payload = prepare_complete_transaction_payload(record_uid)
|
|
380
|
+
|
|
381
|
+
# Route to different endpoints based on rollback parameter
|
|
382
|
+
endpoint = rollback ? 'rollback_secret_update' : 'finalize_secret_update'
|
|
383
|
+
|
|
384
|
+
# Send request
|
|
385
|
+
post_query(endpoint, payload)
|
|
386
|
+
|
|
387
|
+
true
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
# Delete secrets
|
|
391
|
+
def delete_secret(record_uids)
|
|
392
|
+
record_uids = [record_uids] if record_uids.is_a?(String)
|
|
393
|
+
|
|
394
|
+
payload = prepare_delete_payload(record_uids)
|
|
395
|
+
response = post_query('delete_secret', payload)
|
|
396
|
+
|
|
397
|
+
result = JSON.parse(response)
|
|
398
|
+
records = result['records'] || []
|
|
399
|
+
records.each do |r|
|
|
400
|
+
next if r['responseCode'] == 'ok'
|
|
401
|
+
@logger.error("Failed to delete record #{r['recordUid']}: #{r['responseCode']} #{r['errorMessage']}")
|
|
402
|
+
end
|
|
403
|
+
records
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
def get_inflate_ref_types(field_type)
|
|
407
|
+
INFLATE_REF_TYPES.fetch(field_type, [])
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
def inflate_field_value(uids, replace_fields)
|
|
411
|
+
records = get_secrets(uids)
|
|
412
|
+
lookup = records.each_with_object({}) { |r, h| h[r.uid] = r }
|
|
413
|
+
|
|
414
|
+
uids.filter_map do |uid|
|
|
415
|
+
record = lookup[uid]
|
|
416
|
+
next nil unless record
|
|
417
|
+
|
|
418
|
+
new_value = nil
|
|
419
|
+
replace_fields.each do |field_type|
|
|
420
|
+
field = record.get_field(field_type)
|
|
421
|
+
next unless field
|
|
422
|
+
|
|
423
|
+
raw_values = field['value'] || []
|
|
424
|
+
next if raw_values.empty?
|
|
425
|
+
|
|
426
|
+
real_value = raw_values.first
|
|
427
|
+
|
|
428
|
+
if INFLATE_REF_TYPES.key?(field_type)
|
|
429
|
+
inflated = inflate_field_value([real_value], INFLATE_REF_TYPES[field_type])
|
|
430
|
+
real_value = inflated.first unless inflated.empty?
|
|
431
|
+
end
|
|
432
|
+
|
|
433
|
+
label = field['label'] || field_type
|
|
434
|
+
if new_value.nil?
|
|
435
|
+
new_value = real_value
|
|
436
|
+
elsif new_value.is_a?(Hash) && real_value.is_a?(Hash)
|
|
437
|
+
new_value[label] = real_value
|
|
438
|
+
else
|
|
439
|
+
new_value = { label => new_value, field_type => real_value }
|
|
440
|
+
end
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
new_value
|
|
444
|
+
end
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
# Get notation value
|
|
448
|
+
def get_notation(notation_uri)
|
|
449
|
+
parser = Notation::Parser.new(self)
|
|
450
|
+
parser.parse(notation_uri)
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
def get_notation_results(notation_uri)
|
|
454
|
+
Notation::Parser.new(self).get_notation_results(notation_uri)
|
|
455
|
+
end
|
|
456
|
+
|
|
457
|
+
# Like get_notation_results but never raises; logs errors and returns [].
|
|
458
|
+
def try_get_notation_results(notation_uri)
|
|
459
|
+
get_notation_results(notation_uri)
|
|
460
|
+
rescue NotationError, RecordNotFoundError, StandardError => e
|
|
461
|
+
@logger.error("try_get_notation_results failed for '#{notation_uri}': #{e.message}")
|
|
462
|
+
[]
|
|
463
|
+
end
|
|
464
|
+
|
|
465
|
+
# Get notation value without raising exceptions (convenience method)
|
|
466
|
+
# Returns empty array if notation is invalid or record not found
|
|
467
|
+
def try_get_notation(notation_uri)
|
|
468
|
+
parser = Notation::Parser.new(self)
|
|
469
|
+
Array(parser.parse(notation_uri))
|
|
470
|
+
rescue NotationError, RecordNotFoundError, StandardError => e
|
|
471
|
+
@logger.debug("try_get_notation failed for '#{notation_uri}': #{e.message}")
|
|
472
|
+
[]
|
|
473
|
+
end
|
|
474
|
+
|
|
475
|
+
# Create folder
|
|
476
|
+
def create_folder(folder_name, parent_uid: nil)
|
|
477
|
+
raise ArgumentError, 'parent_uid is required to create a folder' unless parent_uid
|
|
478
|
+
|
|
479
|
+
# Get folders to find parent's shared folder key
|
|
480
|
+
folders = get_folders
|
|
481
|
+
|
|
482
|
+
# Find parent folder
|
|
483
|
+
parent_folder = folders.find { |f| f.uid == parent_uid }
|
|
484
|
+
raise Error, "Parent folder #{parent_uid} not found" unless parent_folder
|
|
485
|
+
|
|
486
|
+
# Determine if parent is a shared folder root (no parent_uid)
|
|
487
|
+
is_shared_root = parent_folder.parent_uid.nil? || parent_folder.parent_uid.empty?
|
|
488
|
+
|
|
489
|
+
# Find the shared folder root by traversing up the hierarchy
|
|
490
|
+
if is_shared_root
|
|
491
|
+
# Parent is the shared root, so new folder is at root level
|
|
492
|
+
shared_folder_uid = parent_uid
|
|
493
|
+
actual_parent_uid = nil # nil for root-level folders
|
|
494
|
+
else
|
|
495
|
+
# Parent is a subfolder, traverse up to find shared root
|
|
496
|
+
shared_folder_uid = parent_uid
|
|
497
|
+
current_folder = parent_folder
|
|
498
|
+
|
|
499
|
+
while current_folder.parent_uid && !current_folder.parent_uid.empty?
|
|
500
|
+
parent = folders.find { |f| f.uid == current_folder.parent_uid }
|
|
501
|
+
break unless parent
|
|
502
|
+
|
|
503
|
+
shared_folder_uid = current_folder.parent_uid
|
|
504
|
+
current_folder = parent
|
|
505
|
+
end
|
|
506
|
+
|
|
507
|
+
actual_parent_uid = parent_uid # Subfolder creation
|
|
508
|
+
end
|
|
509
|
+
|
|
510
|
+
# Get shared folder's key (the root folder's key)
|
|
511
|
+
shared_folder = folders.find { |f| f.uid == shared_folder_uid }
|
|
512
|
+
raise Error, "Shared folder #{shared_folder_uid} not found" unless shared_folder
|
|
513
|
+
|
|
514
|
+
shared_folder_key = shared_folder.folder_key
|
|
515
|
+
raise Error, "Shared folder key missing for #{shared_folder_uid}" unless shared_folder_key
|
|
516
|
+
|
|
517
|
+
# Generate new folder UID and key
|
|
518
|
+
folder_uid = Utils.generate_uid
|
|
519
|
+
folder_key = Crypto.generate_encryption_key_bytes
|
|
520
|
+
|
|
521
|
+
# Prepare folder data
|
|
522
|
+
folder_data = {
|
|
523
|
+
'name' => folder_name
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
# Encrypt folder data with NEW folder's key using AES-CBC
|
|
527
|
+
encrypted_data = Crypto.encrypt_aes_cbc(
|
|
528
|
+
Utils.dict_to_json(folder_data),
|
|
529
|
+
folder_key
|
|
530
|
+
)
|
|
531
|
+
|
|
532
|
+
# Encrypt folder key with SHARED folder's key using AES-CBC
|
|
533
|
+
encrypted_folder_key = Crypto.encrypt_aes_cbc(folder_key, shared_folder_key)
|
|
534
|
+
|
|
535
|
+
# Prepare payload
|
|
536
|
+
payload = prepare_create_folder_payload(
|
|
537
|
+
folder_uid: folder_uid,
|
|
538
|
+
shared_folder_uid: shared_folder_uid,
|
|
539
|
+
encrypted_folder_key: encrypted_folder_key,
|
|
540
|
+
data: encrypted_data,
|
|
541
|
+
parent_uid: actual_parent_uid # nil for root, subfolder UID for nested
|
|
542
|
+
)
|
|
543
|
+
|
|
544
|
+
post_query('create_folder', payload)
|
|
545
|
+
folder_uid
|
|
546
|
+
end
|
|
547
|
+
|
|
548
|
+
# Update folder
|
|
549
|
+
def update_folder(folder_uid, folder_name)
|
|
550
|
+
# Get folders to find the folder's key
|
|
551
|
+
folders = get_folders
|
|
552
|
+
folder = folders.find { |f| f.uid == folder_uid }
|
|
553
|
+
raise Error, "Folder #{folder_uid} not found" unless folder
|
|
554
|
+
|
|
555
|
+
folder_key = folder.folder_key
|
|
556
|
+
raise Error, "Folder key missing for #{folder_uid}" unless folder_key
|
|
557
|
+
|
|
558
|
+
# Prepare folder data
|
|
559
|
+
folder_data = {
|
|
560
|
+
'name' => folder_name
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
# Encrypt folder data with folder's key using AES-CBC
|
|
564
|
+
encrypted_data = Crypto.encrypt_aes_cbc(
|
|
565
|
+
Utils.dict_to_json(folder_data),
|
|
566
|
+
folder_key
|
|
567
|
+
)
|
|
568
|
+
|
|
569
|
+
payload = prepare_update_folder_payload(
|
|
570
|
+
folder_uid: folder_uid,
|
|
571
|
+
data: encrypted_data
|
|
572
|
+
)
|
|
573
|
+
|
|
574
|
+
post_query('update_folder', payload)
|
|
575
|
+
true
|
|
576
|
+
end
|
|
577
|
+
|
|
578
|
+
# Delete folders
|
|
579
|
+
def delete_folder(folder_uids, force: false)
|
|
580
|
+
folder_uids = [folder_uids] if folder_uids.is_a?(String)
|
|
581
|
+
|
|
582
|
+
payload = prepare_delete_folder_payload(folder_uids, force)
|
|
583
|
+
response = post_query('delete_folder', payload)
|
|
584
|
+
|
|
585
|
+
result = JSON.parse(response)
|
|
586
|
+
folders = result['folders'] || []
|
|
587
|
+
folders.each do |f|
|
|
588
|
+
next if f['responseCode'] == 'ok'
|
|
589
|
+
@logger.error("Failed to delete folder #{f['folderUid']}: #{f['responseCode']} #{f['errorMessage']}")
|
|
590
|
+
end
|
|
591
|
+
folders
|
|
592
|
+
end
|
|
593
|
+
|
|
594
|
+
# Get folder hierarchy manager
|
|
595
|
+
def folder_manager
|
|
596
|
+
folders = get_folders
|
|
597
|
+
FolderManager.new(folders)
|
|
598
|
+
end
|
|
599
|
+
|
|
600
|
+
# Get folder path (convenience method)
|
|
601
|
+
def get_folder_path(folder_uid)
|
|
602
|
+
folder_manager.get_folder_path(folder_uid)
|
|
603
|
+
end
|
|
604
|
+
|
|
605
|
+
# Find folder by name (convenience method)
|
|
606
|
+
def find_folder_by_name(name, parent_uid: nil)
|
|
607
|
+
folder_manager.find_folder_by_name(name, parent_uid: parent_uid)
|
|
608
|
+
end
|
|
609
|
+
|
|
610
|
+
# Upload file
|
|
611
|
+
def upload_file(owner_record_uid, file_data, file_name, file_title = nil)
|
|
612
|
+
file_title ||= file_name
|
|
613
|
+
|
|
614
|
+
# Fetch the owner record (decrypted) to get current state
|
|
615
|
+
owner_records = get_secrets([owner_record_uid])
|
|
616
|
+
raise Error, "Owner record #{owner_record_uid} not found" if owner_records.empty?
|
|
617
|
+
|
|
618
|
+
owner_record = owner_records.first
|
|
619
|
+
owner_revision = owner_record.revision
|
|
620
|
+
|
|
621
|
+
# Get owner record data as hash for manipulation
|
|
622
|
+
owner_record_data = owner_record.to_h
|
|
623
|
+
|
|
624
|
+
# Get the record_key (stored during decryption)
|
|
625
|
+
owner_record_key = owner_record.record_key
|
|
626
|
+
raise Error, "Record key not available for owner record #{owner_record_uid}" unless owner_record_key
|
|
627
|
+
|
|
628
|
+
# Get owner record's public key from storage (app owner public key)
|
|
629
|
+
owner_public_key = @config.get_string(ConfigKeys::KEY_OWNER_PUBLIC_KEY)
|
|
630
|
+
raise Error, "Owner public key not found in config - application may need re-binding" unless owner_public_key
|
|
631
|
+
|
|
632
|
+
owner_public_key_bytes = Utils.url_safe_str_to_bytes(owner_public_key)
|
|
633
|
+
|
|
634
|
+
# Generate file record UID and key
|
|
635
|
+
file_uid = Utils.generate_uid
|
|
636
|
+
file_key = Crypto.generate_encryption_key_bytes
|
|
637
|
+
|
|
638
|
+
# Encrypt file data with file key
|
|
639
|
+
encrypted_file = Crypto.encrypt_aes_gcm(file_data, file_key)
|
|
640
|
+
|
|
641
|
+
# Create file record metadata
|
|
642
|
+
file_record = {
|
|
643
|
+
'name' => file_name,
|
|
644
|
+
'size' => file_data.bytesize,
|
|
645
|
+
'title' => file_title,
|
|
646
|
+
'lastModified' => (Time.now.to_f * 1000).to_i,
|
|
647
|
+
'type' => 'application/octet-stream'
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
# Encrypt file record metadata with file key
|
|
651
|
+
file_record_json = Utils.dict_to_json(file_record)
|
|
652
|
+
file_record_bytes = file_record_json.bytes
|
|
653
|
+
encrypted_file_record = Crypto.encrypt_aes_gcm(file_record_bytes.pack('C*'), file_key)
|
|
654
|
+
|
|
655
|
+
# Encrypt file record key with owner's public key (ECIES)
|
|
656
|
+
encrypted_file_record_key = Crypto.encrypt_ec(file_key, owner_public_key_bytes)
|
|
657
|
+
|
|
658
|
+
# Encrypt file record key with owner record key (for linkKey)
|
|
659
|
+
encrypted_link_key = Crypto.encrypt_aes_gcm(file_key, owner_record_key)
|
|
660
|
+
|
|
661
|
+
# Add fileRef to owner record's fields
|
|
662
|
+
fields = owner_record_data['fields'] || []
|
|
663
|
+
|
|
664
|
+
file_ref_field = fields.find { |f| f['type'] == 'fileRef' }
|
|
665
|
+
if file_ref_field
|
|
666
|
+
file_ref_field['value'] ||= []
|
|
667
|
+
file_ref_field['value'] << file_uid
|
|
668
|
+
else
|
|
669
|
+
fields << { 'type' => 'fileRef', 'value' => [file_uid] }
|
|
670
|
+
end
|
|
671
|
+
|
|
672
|
+
# Update owner record data
|
|
673
|
+
owner_record_data['fields'] = fields
|
|
674
|
+
owner_record_json = Utils.dict_to_json(owner_record_data)
|
|
675
|
+
owner_record_bytes = owner_record_json.bytes.pack('C*')
|
|
676
|
+
|
|
677
|
+
# Encrypt updated owner record with its record key
|
|
678
|
+
encrypted_owner_record_data = Crypto.encrypt_aes_gcm(owner_record_bytes, owner_record_key)
|
|
679
|
+
|
|
680
|
+
# Prepare payload
|
|
681
|
+
payload = prepare_file_upload_payload(
|
|
682
|
+
file_record_uid: file_uid,
|
|
683
|
+
file_record_key: encrypted_file_record_key,
|
|
684
|
+
file_record_data: encrypted_file_record,
|
|
685
|
+
owner_record_uid: owner_record_uid,
|
|
686
|
+
owner_record_data: encrypted_owner_record_data,
|
|
687
|
+
owner_record_revision: owner_revision,
|
|
688
|
+
link_key: encrypted_link_key,
|
|
689
|
+
file_size: encrypted_file.bytesize
|
|
690
|
+
)
|
|
691
|
+
|
|
692
|
+
# Get upload URL
|
|
693
|
+
response = post_query('add_file', payload)
|
|
694
|
+
upload_result = JSON.parse(response)
|
|
695
|
+
|
|
696
|
+
# Upload file
|
|
697
|
+
upload_file_function(
|
|
698
|
+
upload_result['url'],
|
|
699
|
+
upload_result['parameters'],
|
|
700
|
+
encrypted_file
|
|
701
|
+
)
|
|
702
|
+
|
|
703
|
+
file_uid
|
|
704
|
+
end
|
|
705
|
+
|
|
706
|
+
# Upload file from disk path (convenience method)
|
|
707
|
+
# Reads file from disk and uploads to specified record
|
|
708
|
+
def upload_file_from_path(owner_record_uid, file_path, file_title: nil)
|
|
709
|
+
raise ArgumentError, "File not found: #{file_path}" unless File.exist?(file_path)
|
|
710
|
+
raise ArgumentError, "Path is a directory: #{file_path}" if File.directory?(file_path)
|
|
711
|
+
|
|
712
|
+
# Read file data
|
|
713
|
+
file_data = File.binread(file_path)
|
|
714
|
+
|
|
715
|
+
# Extract filename from path
|
|
716
|
+
file_name = File.basename(file_path)
|
|
717
|
+
|
|
718
|
+
# Use file_title if provided, otherwise use filename
|
|
719
|
+
file_title ||= file_name
|
|
720
|
+
|
|
721
|
+
@logger.debug("Uploading file from path: #{file_path} (#{file_data.bytesize} bytes)")
|
|
722
|
+
|
|
723
|
+
# Delegate to existing upload_file method
|
|
724
|
+
upload_file(owner_record_uid, file_data, file_name, file_title)
|
|
725
|
+
end
|
|
726
|
+
|
|
727
|
+
# Download file from record's file data
|
|
728
|
+
def download_file(file_data)
|
|
729
|
+
# Extract file metadata (already decrypted)
|
|
730
|
+
file_uid = file_data['fileUid']
|
|
731
|
+
file_url = file_data['url']
|
|
732
|
+
file_name = file_data['name'] || file_data['title'] || 'unnamed'
|
|
733
|
+
|
|
734
|
+
raise Error, "No download URL available for file #{file_uid}" unless file_url
|
|
735
|
+
|
|
736
|
+
# The file key should already be decrypted (base64 encoded)
|
|
737
|
+
file_key = Utils.base64_to_bytes(file_data['fileKey'])
|
|
738
|
+
|
|
739
|
+
# Download the encrypted file content
|
|
740
|
+
encrypted_content = download_encrypted_file(file_url)
|
|
741
|
+
|
|
742
|
+
# Decrypt the file content with the file key
|
|
743
|
+
decrypted_content = Crypto.decrypt_aes_gcm(encrypted_content, file_key)
|
|
744
|
+
|
|
745
|
+
# Return file info and data
|
|
746
|
+
{
|
|
747
|
+
'name' => file_name,
|
|
748
|
+
'title' => file_data['title'] || file_name,
|
|
749
|
+
'type' => file_data['type'],
|
|
750
|
+
'size' => file_data['size'] || decrypted_content.bytesize,
|
|
751
|
+
'data' => decrypted_content
|
|
752
|
+
}
|
|
753
|
+
end
|
|
754
|
+
|
|
755
|
+
# Download file thumbnail
|
|
756
|
+
def download_thumbnail(file_data)
|
|
757
|
+
if file_data.is_a?(Dto::KeeperFile)
|
|
758
|
+
file_uid = file_data.uid
|
|
759
|
+
thumbnail_url = file_data.thumbnail_url
|
|
760
|
+
file_key_str = file_data.file_key
|
|
761
|
+
else
|
|
762
|
+
file_uid = file_data['fileUid'] || file_data['uid']
|
|
763
|
+
thumbnail_url = file_data['thumbnailUrl'] || file_data['thumbnail_url']
|
|
764
|
+
file_key_str = file_data['fileKey'] || file_data['file_key']
|
|
765
|
+
end
|
|
766
|
+
|
|
767
|
+
raise ArgumentError, 'File UID is required' unless file_uid
|
|
768
|
+
raise Error, "No thumbnail URL available for file #{file_uid}" unless thumbnail_url
|
|
769
|
+
raise Error, "File key not available for #{file_uid}" unless file_key_str
|
|
770
|
+
|
|
771
|
+
file_key = Utils.base64_to_bytes(file_key_str)
|
|
772
|
+
encrypted_content = download_encrypted_file(thumbnail_url)
|
|
773
|
+
decrypted_content = Crypto.decrypt_aes_gcm(encrypted_content, file_key)
|
|
774
|
+
|
|
775
|
+
{
|
|
776
|
+
'file_uid' => file_uid,
|
|
777
|
+
'data' => decrypted_content,
|
|
778
|
+
'size' => decrypted_content.bytesize
|
|
779
|
+
}
|
|
780
|
+
end
|
|
781
|
+
|
|
782
|
+
# Get file metadata from server
|
|
783
|
+
def get_file_data(file_uid)
|
|
784
|
+
payload = prepare_get_payload(nil)
|
|
785
|
+
payload.file_uids = [file_uid]
|
|
786
|
+
|
|
787
|
+
response = post_query('get_files', payload)
|
|
788
|
+
response_dict = JSON.parse(response)
|
|
789
|
+
|
|
790
|
+
if response_dict['files'] && !response_dict['files'].empty?
|
|
791
|
+
file_data = response_dict['files'].first
|
|
792
|
+
|
|
793
|
+
# Decrypt file metadata
|
|
794
|
+
# Get app key for decryption
|
|
795
|
+
app_key_str = @config.get_string(ConfigKeys::KEY_APP_KEY)
|
|
796
|
+
if app_key_str && !app_key_str.empty?
|
|
797
|
+
app_key = Utils.base64_to_bytes(app_key_str)
|
|
798
|
+
else
|
|
799
|
+
# Decrypt app key with client key
|
|
800
|
+
app_key_encrypted = Utils.base64_to_bytes(@config.get_string(ConfigKeys::KEY_ENCRYPTED_APP_KEY))
|
|
801
|
+
client_key = get_client_key
|
|
802
|
+
app_key = Crypto.decrypt_aes_gcm(app_key_encrypted, client_key)
|
|
803
|
+
end
|
|
804
|
+
|
|
805
|
+
encrypted_data = Utils.base64_to_bytes(file_data['data'])
|
|
806
|
+
decrypted_json = Crypto.decrypt_aes_gcm(encrypted_data, app_key)
|
|
807
|
+
|
|
808
|
+
JSON.parse(decrypted_json).merge('fileKey' => file_data['fileKey'])
|
|
809
|
+
else
|
|
810
|
+
raise Error, "File not found: #{file_uid}"
|
|
811
|
+
end
|
|
812
|
+
end
|
|
813
|
+
|
|
814
|
+
# Download encrypted file from URL
|
|
815
|
+
def download_encrypted_file(url)
|
|
816
|
+
uri = URI(url)
|
|
817
|
+
|
|
818
|
+
@logger.debug("Downloading file from URL: #{url}")
|
|
819
|
+
|
|
820
|
+
request = Net::HTTP::Get.new(uri)
|
|
821
|
+
|
|
822
|
+
http = create_http_client(uri)
|
|
823
|
+
configure_http_ssl(http)
|
|
824
|
+
|
|
825
|
+
response = http.request(request)
|
|
826
|
+
|
|
827
|
+
@logger.debug("Download response status: #{response.code}")
|
|
828
|
+
|
|
829
|
+
if response.code == '200'
|
|
830
|
+
response.body
|
|
831
|
+
else
|
|
832
|
+
raise Error, "Failed to download file: #{response.code} #{response.message}"
|
|
833
|
+
end
|
|
834
|
+
end
|
|
835
|
+
|
|
836
|
+
private
|
|
837
|
+
|
|
838
|
+
# Process token binding
|
|
839
|
+
def process_token_binding(token, hostname = nil)
|
|
840
|
+
# Parse token
|
|
841
|
+
token = token.strip
|
|
842
|
+
# -1 keeps trailing empties so "IL5:key:20:" yields 4 parts and the empty serverPublicKey
|
|
843
|
+
# segment is rejected, instead of default split() dropping it and looking like 3 parts.
|
|
844
|
+
token_parts = token.split(':', -1)
|
|
845
|
+
|
|
846
|
+
il5_key_id = nil
|
|
847
|
+
il5_server_public_key = nil
|
|
848
|
+
|
|
849
|
+
# Modern format: REGION:BASE64_TOKEN
|
|
850
|
+
if token_parts.length >= 2
|
|
851
|
+
region = token_parts[0].upcase
|
|
852
|
+
@hostname = KeeperGlobals::KEEPER_SERVERS[region] || KeeperGlobals::DEFAULT_SERVER
|
|
853
|
+
|
|
854
|
+
if region == 'IL5' && token_parts.length > 2
|
|
855
|
+
# IL5 dynamic-key OTT: IL5:clientKey:serverPublicKeyId:serverPublicKey
|
|
856
|
+
unless token_parts.length == 4
|
|
857
|
+
raise Error, 'Malformed IL5 one-time token: expected exactly 4 colon-separated ' \
|
|
858
|
+
"segments 'IL5:clientKey:keyId:serverPublicKey', got #{token_parts.length}"
|
|
859
|
+
end
|
|
860
|
+
il5_key_id = token_parts[2]
|
|
861
|
+
il5_server_public_key = token_parts[3]
|
|
862
|
+
if il5_key_id.to_s.empty? || il5_server_public_key.to_s.empty?
|
|
863
|
+
raise Error, 'Malformed IL5 one-time token: keyId and serverPublicKey segments must be non-empty'
|
|
864
|
+
end
|
|
865
|
+
begin
|
|
866
|
+
Utils.url_safe_str_to_bytes(il5_server_public_key)
|
|
867
|
+
rescue StandardError
|
|
868
|
+
raise Error, 'Malformed IL5 one-time token: serverPublicKey segment is not valid url-safe base64'
|
|
869
|
+
end
|
|
870
|
+
@token = token_parts[1]
|
|
871
|
+
else
|
|
872
|
+
@token = token_parts[1..].join(':')
|
|
873
|
+
end
|
|
874
|
+
else
|
|
875
|
+
# Legacy format
|
|
876
|
+
@token = token
|
|
877
|
+
@hostname = hostname || KeeperGlobals::DEFAULT_SERVER
|
|
878
|
+
end
|
|
879
|
+
|
|
880
|
+
# Precedence: programmatic override > OTT segment.
|
|
881
|
+
effective_key_id = @server_public_key_id_override || il5_key_id
|
|
882
|
+
effective_public_key = @server_public_key_override || il5_server_public_key
|
|
883
|
+
|
|
884
|
+
# Bind the one-time token
|
|
885
|
+
bound_config = bind_one_time_token(@token, @hostname, effective_key_id, effective_public_key)
|
|
886
|
+
|
|
887
|
+
# Merge bound config into existing config if present
|
|
888
|
+
if @config
|
|
889
|
+
# Copy all values from bound config to existing config
|
|
890
|
+
bound_data = bound_config.instance_variable_get(:@data)
|
|
891
|
+
bound_data&.each do |key, value|
|
|
892
|
+
if value.is_a?(String)
|
|
893
|
+
@config.save_string(key, value)
|
|
894
|
+
else
|
|
895
|
+
@config.save_bytes(key, value)
|
|
896
|
+
end
|
|
897
|
+
end
|
|
898
|
+
else
|
|
899
|
+
@config = bound_config
|
|
900
|
+
end
|
|
901
|
+
end
|
|
902
|
+
|
|
903
|
+
# Bind one-time token
|
|
904
|
+
def bind_one_time_token(token, hostname, key_id = nil, server_public_key = nil)
|
|
905
|
+
storage = Storage::InMemoryStorage.new
|
|
906
|
+
|
|
907
|
+
# Generate EC key pair
|
|
908
|
+
keys = Crypto.generate_ecc_keys
|
|
909
|
+
|
|
910
|
+
# Convert token to bytes and create client ID hash
|
|
911
|
+
token_bytes = Utils.url_safe_str_to_bytes(token)
|
|
912
|
+
client_id_hash = OpenSSL::HMAC.digest(
|
|
913
|
+
'SHA512',
|
|
914
|
+
token_bytes,
|
|
915
|
+
'KEEPER_SECRETS_MANAGER_CLIENT_ID'
|
|
916
|
+
)
|
|
917
|
+
client_id = Utils.bytes_to_base64(client_id_hash)
|
|
918
|
+
|
|
919
|
+
# Store configuration
|
|
920
|
+
storage.save_string(ConfigKeys::KEY_HOSTNAME, hostname)
|
|
921
|
+
storage.save_string(ConfigKeys::KEY_SERVER_PUBLIC_KEY_ID, (key_id || DEFAULT_KEY_ID).to_s)
|
|
922
|
+
# persist the dynamic (IL5) server public key so it is used for binding AND all
|
|
923
|
+
# later requests, and survives restart via the saved config.
|
|
924
|
+
storage.save_string(ConfigKeys::KEY_SERVER_PUBLIC_KEY, server_public_key) if server_public_key && !server_public_key.empty?
|
|
925
|
+
storage.save_string(ConfigKeys::KEY_CLIENT_KEY, token)
|
|
926
|
+
storage.save_bytes(ConfigKeys::KEY_PRIVATE_KEY, keys[:private_key_bytes])
|
|
927
|
+
storage.save_string(ConfigKeys::KEY_CLIENT_ID, client_id)
|
|
928
|
+
|
|
929
|
+
# Prepare binding payload
|
|
930
|
+
payload = Dto::GetPayload.new
|
|
931
|
+
payload.client_version = KeeperGlobals.client_version
|
|
932
|
+
payload.client_id = client_id
|
|
933
|
+
payload.public_key = keys[:public_key_str]
|
|
934
|
+
|
|
935
|
+
# Send binding request
|
|
936
|
+
response = post_query('get_secret', payload, storage)
|
|
937
|
+
response_dict = JSON.parse(response)
|
|
938
|
+
|
|
939
|
+
# Process binding response
|
|
940
|
+
if response_dict['encryptedAppKey']
|
|
941
|
+
# Decrypt app key
|
|
942
|
+
encrypted_app_key = Utils.url_safe_str_to_bytes(response_dict['encryptedAppKey'])
|
|
943
|
+
client_key_bytes = Utils.url_safe_str_to_bytes(token)
|
|
944
|
+
|
|
945
|
+
app_key = Crypto.decrypt_aes_gcm(encrypted_app_key, client_key_bytes)
|
|
946
|
+
storage.save_bytes(ConfigKeys::KEY_APP_KEY, app_key)
|
|
947
|
+
|
|
948
|
+
# Store app owner public key if present
|
|
949
|
+
if response_dict['appOwnerPublicKey']
|
|
950
|
+
owner_key = Utils.url_safe_str_to_bytes(response_dict['appOwnerPublicKey'])
|
|
951
|
+
storage.save_bytes(ConfigKeys::KEY_OWNER_PUBLIC_KEY, owner_key)
|
|
952
|
+
end
|
|
953
|
+
|
|
954
|
+
# Clean up client key after successful binding
|
|
955
|
+
storage.delete(ConfigKeys::KEY_CLIENT_KEY)
|
|
956
|
+
else
|
|
957
|
+
raise Error, 'Failed to bind one-time token - no encrypted app key in response'
|
|
958
|
+
end
|
|
959
|
+
|
|
960
|
+
storage
|
|
961
|
+
end
|
|
962
|
+
|
|
963
|
+
# Fetch and decrypt secrets
|
|
964
|
+
def fetch_and_decrypt_secrets(query_options = nil)
|
|
965
|
+
payload = prepare_get_payload(query_options)
|
|
966
|
+
|
|
967
|
+
response = post_query('get_secret', payload)
|
|
968
|
+
response_dict = JSON.parse(response)
|
|
969
|
+
|
|
970
|
+
# Decrypt app key if present (during token binding)
|
|
971
|
+
if response_dict['encryptedAppKey']
|
|
972
|
+
encrypted_app_key = Utils.url_safe_str_to_bytes(response_dict['encryptedAppKey'])
|
|
973
|
+
client_key = Utils.url_safe_str_to_bytes(@config.get_string(ConfigKeys::KEY_CLIENT_KEY))
|
|
974
|
+
|
|
975
|
+
# Decrypt app key using AES with client key (the original token)
|
|
976
|
+
app_key = Crypto.decrypt_aes_gcm(encrypted_app_key, client_key)
|
|
977
|
+
@config.save_bytes(ConfigKeys::KEY_APP_KEY, app_key)
|
|
978
|
+
|
|
979
|
+
# Clean up client key after successful binding
|
|
980
|
+
@config.delete(ConfigKeys::KEY_CLIENT_KEY)
|
|
981
|
+
|
|
982
|
+
# Store app owner public key if present
|
|
983
|
+
if response_dict['appOwnerPublicKey']
|
|
984
|
+
owner_key = Utils.url_safe_str_to_bytes(response_dict['appOwnerPublicKey'])
|
|
985
|
+
@config.save_bytes(ConfigKeys::KEY_OWNER_PUBLIC_KEY, owner_key)
|
|
986
|
+
end
|
|
987
|
+
|
|
988
|
+
# Set just bound flag
|
|
989
|
+
just_bound = true
|
|
990
|
+
else
|
|
991
|
+
just_bound = false
|
|
992
|
+
end
|
|
993
|
+
|
|
994
|
+
# Get app key
|
|
995
|
+
app_key = @config.get_bytes(ConfigKeys::KEY_APP_KEY)
|
|
996
|
+
raise Error, 'No app key available' unless app_key
|
|
997
|
+
|
|
998
|
+
# Decrypt records
|
|
999
|
+
records = []
|
|
1000
|
+
if response_dict['records']
|
|
1001
|
+
response_dict['records'].each do |encrypted_record|
|
|
1002
|
+
record = decrypt_record(encrypted_record, app_key)
|
|
1003
|
+
records << record
|
|
1004
|
+
rescue StandardError => e
|
|
1005
|
+
@logger.error("Failed to decrypt record: #{e.message}")
|
|
1006
|
+
end
|
|
1007
|
+
end
|
|
1008
|
+
|
|
1009
|
+
# Decrypt folders - need to handle them in order for shared folder keys
|
|
1010
|
+
folders = []
|
|
1011
|
+
response_folders = response_dict['folders'] || []
|
|
1012
|
+
|
|
1013
|
+
# First pass - decrypt folders in order
|
|
1014
|
+
response_folders.each do |encrypted_folder|
|
|
1015
|
+
folder = decrypt_folder(encrypted_folder, app_key, folders, response_folders)
|
|
1016
|
+
if folder
|
|
1017
|
+
folders << folder
|
|
1018
|
+
# Add folder's records to the main records list
|
|
1019
|
+
records.concat(folder.records) if folder.records && !folder.records.empty?
|
|
1020
|
+
end
|
|
1021
|
+
rescue StandardError => e
|
|
1022
|
+
@logger.error("Failed to decrypt folder: #{e.message}")
|
|
1023
|
+
end
|
|
1024
|
+
|
|
1025
|
+
# Build response
|
|
1026
|
+
response = Dto::SecretsManagerResponse.new(
|
|
1027
|
+
records: records,
|
|
1028
|
+
folders: folders,
|
|
1029
|
+
warnings: response_dict['warnings']
|
|
1030
|
+
)
|
|
1031
|
+
|
|
1032
|
+
response.just_bound = just_bound if response.respond_to?(:just_bound=)
|
|
1033
|
+
response
|
|
1034
|
+
end
|
|
1035
|
+
|
|
1036
|
+
# Decrypt record
|
|
1037
|
+
def decrypt_record(encrypted_record, app_key)
|
|
1038
|
+
record_uid = encrypted_record['recordUid']
|
|
1039
|
+
record_key_encrypted = Utils.base64_to_bytes(encrypted_record['recordKey'])
|
|
1040
|
+
data_encrypted = Utils.base64_to_bytes(encrypted_record['data'])
|
|
1041
|
+
|
|
1042
|
+
# Decrypt record key
|
|
1043
|
+
record_key = Crypto.decrypt_aes_gcm(record_key_encrypted, app_key)
|
|
1044
|
+
|
|
1045
|
+
# Decrypt data
|
|
1046
|
+
data_json = Crypto.decrypt_aes_gcm(data_encrypted, record_key)
|
|
1047
|
+
data = JSON.parse(data_json)
|
|
1048
|
+
|
|
1049
|
+
# Decrypt files if present
|
|
1050
|
+
decrypted_files = []
|
|
1051
|
+
if encrypted_record['files']
|
|
1052
|
+
encrypted_record['files'].each do |file|
|
|
1053
|
+
# Decrypt file key with record key
|
|
1054
|
+
file_key_encrypted = Utils.base64_to_bytes(file['fileKey'])
|
|
1055
|
+
file_key = Crypto.decrypt_aes_gcm(file_key_encrypted, record_key)
|
|
1056
|
+
|
|
1057
|
+
# Decrypt file metadata with file key
|
|
1058
|
+
if file['data']
|
|
1059
|
+
file_data_encrypted = Utils.base64_to_bytes(file['data'])
|
|
1060
|
+
file_metadata_json = Crypto.decrypt_aes_gcm(file_data_encrypted, file_key)
|
|
1061
|
+
file_metadata = JSON.parse(file_metadata_json)
|
|
1062
|
+
else
|
|
1063
|
+
file_metadata = {}
|
|
1064
|
+
end
|
|
1065
|
+
|
|
1066
|
+
# Create decrypted file object
|
|
1067
|
+
decrypted_file = {
|
|
1068
|
+
'fileUid' => file['fileUid'],
|
|
1069
|
+
'fileKey' => Utils.bytes_to_base64(file_key), # Store decrypted key
|
|
1070
|
+
'url' => file['url'],
|
|
1071
|
+
'thumbnailUrl' => file['thumbnailUrl'],
|
|
1072
|
+
'name' => file_metadata['name'],
|
|
1073
|
+
'title' => file_metadata['title'] || file_metadata['name'],
|
|
1074
|
+
'type' => file_metadata['type'],
|
|
1075
|
+
'size' => file_metadata['size'],
|
|
1076
|
+
'lastModified' => file_metadata['lastModified']
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
decrypted_files << decrypted_file
|
|
1080
|
+
rescue StandardError => e
|
|
1081
|
+
@logger&.error("Failed to decrypt file #{file['fileUid']}: #{e.message}")
|
|
1082
|
+
end
|
|
1083
|
+
end
|
|
1084
|
+
|
|
1085
|
+
# Create record object
|
|
1086
|
+
record = Dto::KeeperRecord.new(
|
|
1087
|
+
'recordUid' => record_uid,
|
|
1088
|
+
'folderUid' => encrypted_record['folderUid'],
|
|
1089
|
+
'innerFolderUid' => encrypted_record['innerFolderUid'],
|
|
1090
|
+
'isEditable' => encrypted_record['isEditable'],
|
|
1091
|
+
'data' => data,
|
|
1092
|
+
'revision' => encrypted_record['revision'],
|
|
1093
|
+
'files' => decrypted_files,
|
|
1094
|
+
'links' => encrypted_record['links'] || []
|
|
1095
|
+
)
|
|
1096
|
+
|
|
1097
|
+
# Store record key for later use (e.g., file downloads)
|
|
1098
|
+
record.instance_variable_set(:@record_key, record_key)
|
|
1099
|
+
record.define_singleton_method(:record_key) { @record_key }
|
|
1100
|
+
|
|
1101
|
+
record
|
|
1102
|
+
end
|
|
1103
|
+
|
|
1104
|
+
# Get shared folder key by traversing up the folder hierarchy
|
|
1105
|
+
def get_shared_folder_key(folders, response_folders, parent_uid)
|
|
1106
|
+
while parent_uid
|
|
1107
|
+
# Find parent folder in response
|
|
1108
|
+
parent_folder = response_folders.find { |f| f['folderUid'] == parent_uid }
|
|
1109
|
+
return nil unless parent_folder
|
|
1110
|
+
|
|
1111
|
+
# If parent has no parent, it's the shared folder root
|
|
1112
|
+
if !parent_folder['parent'] || parent_folder['parent'].empty?
|
|
1113
|
+
# Find the decrypted folder object
|
|
1114
|
+
shared_folder = folders.find { |f| f.uid == parent_uid }
|
|
1115
|
+
return shared_folder&.folder_key
|
|
1116
|
+
end
|
|
1117
|
+
|
|
1118
|
+
# Continue up the hierarchy
|
|
1119
|
+
parent_uid = parent_folder['parent']
|
|
1120
|
+
end
|
|
1121
|
+
|
|
1122
|
+
nil
|
|
1123
|
+
end
|
|
1124
|
+
|
|
1125
|
+
# Decrypt folder
|
|
1126
|
+
def decrypt_folder(encrypted_folder, app_key, existing_folders = [], response_folders = [])
|
|
1127
|
+
folder_uid = encrypted_folder['folderUid']
|
|
1128
|
+
folder_parent = encrypted_folder['parent']
|
|
1129
|
+
|
|
1130
|
+
@logger.debug("Decrypting folder #{folder_uid}, parent: #{folder_parent || 'none'}")
|
|
1131
|
+
|
|
1132
|
+
# Determine the decryption key to use
|
|
1133
|
+
decryption_key = if !folder_parent || folder_parent.empty?
|
|
1134
|
+
# Root folder - use app key
|
|
1135
|
+
@logger.debug("Using app key for root folder #{folder_uid}")
|
|
1136
|
+
app_key
|
|
1137
|
+
else
|
|
1138
|
+
# Child folder - use shared folder key
|
|
1139
|
+
shared_folder_key = get_shared_folder_key(existing_folders, response_folders, folder_parent)
|
|
1140
|
+
unless shared_folder_key
|
|
1141
|
+
@logger.error("Cannot find shared folder key for parent #{folder_parent}")
|
|
1142
|
+
return nil
|
|
1143
|
+
end
|
|
1144
|
+
@logger.debug("Using shared folder key from parent for folder #{folder_uid}")
|
|
1145
|
+
shared_folder_key
|
|
1146
|
+
end
|
|
1147
|
+
|
|
1148
|
+
# Some folders might not have encryption data
|
|
1149
|
+
unless encrypted_folder['folderKey']
|
|
1150
|
+
# Create a basic folder object without decrypted data
|
|
1151
|
+
return Dto::KeeperFolder.new(
|
|
1152
|
+
'folderUid' => folder_uid,
|
|
1153
|
+
'folderKey' => nil,
|
|
1154
|
+
'data' => {},
|
|
1155
|
+
'name' => encrypted_folder['name'] || folder_uid,
|
|
1156
|
+
'parent' => folder_parent,
|
|
1157
|
+
'records' => []
|
|
1158
|
+
)
|
|
1159
|
+
end
|
|
1160
|
+
|
|
1161
|
+
# Decrypt folder key
|
|
1162
|
+
folder_key_encrypted = Utils.base64_to_bytes(encrypted_folder['folderKey'])
|
|
1163
|
+
folder_key = if !folder_parent || folder_parent.empty?
|
|
1164
|
+
# Root folder key uses AES-GCM
|
|
1165
|
+
Crypto.decrypt_aes_gcm(folder_key_encrypted, decryption_key)
|
|
1166
|
+
else
|
|
1167
|
+
# Child folder key uses AES-CBC
|
|
1168
|
+
Crypto.decrypt_aes_cbc(folder_key_encrypted, decryption_key)
|
|
1169
|
+
end
|
|
1170
|
+
|
|
1171
|
+
# Get folder name - either from encrypted data or direct field
|
|
1172
|
+
folder_name = ''
|
|
1173
|
+
folder_type = nil
|
|
1174
|
+
|
|
1175
|
+
# Check if there's a direct name field (unencrypted)
|
|
1176
|
+
if encrypted_folder['name']
|
|
1177
|
+
folder_name = encrypted_folder['name']
|
|
1178
|
+
@logger.debug("Using direct name field for folder #{folder_uid}: #{folder_name}")
|
|
1179
|
+
elsif encrypted_folder['data'] && !encrypted_folder['data'].empty?
|
|
1180
|
+
# Decrypt folder data if present
|
|
1181
|
+
begin
|
|
1182
|
+
data_encrypted = Utils.base64_to_bytes(encrypted_folder['data'])
|
|
1183
|
+
# Folder data always uses CBC
|
|
1184
|
+
data_json = Crypto.decrypt_aes_cbc(data_encrypted, folder_key)
|
|
1185
|
+
data = JSON.parse(data_json)
|
|
1186
|
+
folder_name = data['name'] || ''
|
|
1187
|
+
folder_type = data['folderType']
|
|
1188
|
+
@logger.debug("Successfully decrypted folder #{folder_uid}: #{folder_name}")
|
|
1189
|
+
rescue StandardError => e
|
|
1190
|
+
@logger.error("Failed to decrypt folder data for #{folder_uid}: #{e.class} - #{e.message}")
|
|
1191
|
+
@logger.debug("Backtrace: #{e.backtrace.first(3).join("\n")}")
|
|
1192
|
+
end
|
|
1193
|
+
else
|
|
1194
|
+
@logger.debug("Folder #{folder_uid} has no name or data field - using UID as name")
|
|
1195
|
+
folder_name = folder_uid
|
|
1196
|
+
end
|
|
1197
|
+
|
|
1198
|
+
# Decrypt records in this folder
|
|
1199
|
+
folder_records = []
|
|
1200
|
+
if encrypted_folder['records']
|
|
1201
|
+
encrypted_folder['records'].each do |encrypted_record|
|
|
1202
|
+
# Decrypt the record using folder key
|
|
1203
|
+
record = decrypt_record(encrypted_record, folder_key)
|
|
1204
|
+
|
|
1205
|
+
# Set folder_uid on the record
|
|
1206
|
+
record.folder_uid = folder_uid if record
|
|
1207
|
+
folder_records << record if record
|
|
1208
|
+
rescue StandardError => e
|
|
1209
|
+
@logger.error("Failed to decrypt record in folder #{folder_uid}: #{e.message}")
|
|
1210
|
+
end
|
|
1211
|
+
end
|
|
1212
|
+
|
|
1213
|
+
# Create folder object
|
|
1214
|
+
Dto::KeeperFolder.new(
|
|
1215
|
+
'folderUid' => folder_uid,
|
|
1216
|
+
'name' => folder_name,
|
|
1217
|
+
'folderType' => folder_type,
|
|
1218
|
+
'folderKey' => folder_key,
|
|
1219
|
+
'parent' => folder_parent,
|
|
1220
|
+
'records' => folder_records
|
|
1221
|
+
)
|
|
1222
|
+
end
|
|
1223
|
+
|
|
1224
|
+
# Prepare get payload
|
|
1225
|
+
def prepare_get_payload(query_options = nil)
|
|
1226
|
+
payload = Dto::GetPayload.new
|
|
1227
|
+
payload.client_version = KeeperGlobals.client_version
|
|
1228
|
+
|
|
1229
|
+
# Client ID should be URL-safe base64
|
|
1230
|
+
client_id_str = @config.get_string(ConfigKeys::KEY_CLIENT_ID)
|
|
1231
|
+
payload.client_id = client_id_str
|
|
1232
|
+
|
|
1233
|
+
@logger.debug("Client ID for payload: #{client_id_str}")
|
|
1234
|
+
|
|
1235
|
+
# Public key is sent during initial binding only
|
|
1236
|
+
|
|
1237
|
+
if query_options
|
|
1238
|
+
payload.requested_records = query_options.records_filter
|
|
1239
|
+
payload.requested_folders = query_options.folders_filter
|
|
1240
|
+
payload.request_links = query_options.request_links if query_options.request_links
|
|
1241
|
+
end
|
|
1242
|
+
|
|
1243
|
+
payload
|
|
1244
|
+
end
|
|
1245
|
+
|
|
1246
|
+
# Prepare create payload
|
|
1247
|
+
def prepare_create_payload(record_uid:, record_key:, folder_uid:, folder_key:, data:, subfolder_uid: nil)
|
|
1248
|
+
owner_public_key = @config.get_string(ConfigKeys::KEY_OWNER_PUBLIC_KEY)
|
|
1249
|
+
unless owner_public_key && !owner_public_key.empty?
|
|
1250
|
+
raise Error, 'Unable to create record - owner key is missing. Looks like application was created ' \
|
|
1251
|
+
'using out of date client (Web Vault or Commander)'
|
|
1252
|
+
end
|
|
1253
|
+
|
|
1254
|
+
owner_public_key_bytes = Utils.url_safe_str_to_bytes(owner_public_key)
|
|
1255
|
+
record_key_encrypted = Crypto.encrypt_ec(record_key, owner_public_key_bytes)
|
|
1256
|
+
|
|
1257
|
+
payload = Dto::CreatePayload.new
|
|
1258
|
+
payload.client_version = KeeperGlobals.client_version
|
|
1259
|
+
payload.client_id = @config.get_string(ConfigKeys::KEY_CLIENT_ID)
|
|
1260
|
+
payload.record_uid = record_uid
|
|
1261
|
+
payload.record_key = Utils.bytes_to_base64(record_key_encrypted)
|
|
1262
|
+
payload.folder_uid = folder_uid
|
|
1263
|
+
payload.sub_folder_uid = subfolder_uid
|
|
1264
|
+
payload.data = Utils.bytes_to_base64(data)
|
|
1265
|
+
|
|
1266
|
+
# Encrypt the record key with the folder key
|
|
1267
|
+
if folder_key
|
|
1268
|
+
folder_key_encrypted = Crypto.encrypt_aes_gcm(record_key, folder_key)
|
|
1269
|
+
payload.folder_key = Utils.bytes_to_base64(folder_key_encrypted)
|
|
1270
|
+
end
|
|
1271
|
+
|
|
1272
|
+
payload
|
|
1273
|
+
end
|
|
1274
|
+
|
|
1275
|
+
# Other payload preparation methods...
|
|
1276
|
+
|
|
1277
|
+
# Post query to API
|
|
1278
|
+
def post_query(path, payload, config = nil)
|
|
1279
|
+
config ||= @config
|
|
1280
|
+
server = get_server(@hostname)
|
|
1281
|
+
url = "https://#{server}/api/rest/sm/v1/#{path}"
|
|
1282
|
+
|
|
1283
|
+
throttle_attempt = 0
|
|
1284
|
+
loop do
|
|
1285
|
+
# Generate transmission key
|
|
1286
|
+
key_id = config.get_string(ConfigKeys::KEY_SERVER_PUBLIC_KEY_ID) || DEFAULT_KEY_ID
|
|
1287
|
+
# a custom (IL5) server public key in config overrides the built-in key table.
|
|
1288
|
+
custom_public_key = config.get_string(ConfigKeys::KEY_SERVER_PUBLIC_KEY)
|
|
1289
|
+
transmission_key = generate_transmission_key(key_id, custom_public_key)
|
|
1290
|
+
|
|
1291
|
+
# Encrypt and sign payload
|
|
1292
|
+
encrypted_payload = encrypt_and_sign_payload(config, transmission_key, payload)
|
|
1293
|
+
|
|
1294
|
+
# Make request
|
|
1295
|
+
# Use custom post function for read-only operations (get_secret, get_folders)
|
|
1296
|
+
# This enables caching for disaster recovery
|
|
1297
|
+
response = if @custom_post_function && (path == 'get_secret' || path == 'get_folders')
|
|
1298
|
+
@custom_post_function.call(url, transmission_key, encrypted_payload, @verify_ssl_certs)
|
|
1299
|
+
else
|
|
1300
|
+
post_function(url, transmission_key, encrypted_payload)
|
|
1301
|
+
end
|
|
1302
|
+
|
|
1303
|
+
# Handle response
|
|
1304
|
+
if response.success?
|
|
1305
|
+
# Decrypt response if present
|
|
1306
|
+
if response.data && !response.data.empty?
|
|
1307
|
+
return Crypto.decrypt_aes_gcm(response.data, transmission_key.key)
|
|
1308
|
+
else
|
|
1309
|
+
return response.data
|
|
1310
|
+
end
|
|
1311
|
+
else
|
|
1312
|
+
# Throttle retry with exponential backoff + jitter (KSM-876 / KSM-883). Checked before
|
|
1313
|
+
# handle_http_error (which still drives key-rotation retry), and gated on the 403 status
|
|
1314
|
+
# so a non-403 response carrying a {"error":"throttled"} body is not retried.
|
|
1315
|
+
retry_after = parse_throttle(response)
|
|
1316
|
+
if response.status_code == 403 && !retry_after.nil?
|
|
1317
|
+
if throttle_attempt >= MAX_THROTTLE_RETRIES
|
|
1318
|
+
raise ThrottledError.new('throttled',
|
|
1319
|
+
"Request throttled by Keeper backend; exhausted #{MAX_THROTTLE_RETRIES} retries")
|
|
1320
|
+
end
|
|
1321
|
+
delay = throttle_delay(throttle_attempt, retry_after)
|
|
1322
|
+
@logger.warn("Request throttled (attempt #{throttle_attempt + 1}/#{MAX_THROTTLE_RETRIES}); " \
|
|
1323
|
+
"retrying in #{delay.round(1)}s")
|
|
1324
|
+
sleep(delay)
|
|
1325
|
+
throttle_attempt += 1
|
|
1326
|
+
next
|
|
1327
|
+
end
|
|
1328
|
+
|
|
1329
|
+
handle_http_error(response, config)
|
|
1330
|
+
end
|
|
1331
|
+
end
|
|
1332
|
+
end
|
|
1333
|
+
|
|
1334
|
+
# Generate transmission key
|
|
1335
|
+
def generate_transmission_key(key_id, custom_public_key = nil)
|
|
1336
|
+
# Get server public key. A custom (e.g. IL5) key overrides the built-in table,
|
|
1337
|
+
# which only covers ids 1..18 - IL5 uses id 20 supplied out-of-band via the OTT/config.
|
|
1338
|
+
if custom_public_key && !custom_public_key.empty?
|
|
1339
|
+
server_public_key_str = custom_public_key
|
|
1340
|
+
else
|
|
1341
|
+
server_public_key_str = KeeperGlobals::KEEPER_PUBLIC_KEYS[key_id.to_s]
|
|
1342
|
+
raise Error, "Unknown public key ID: #{key_id}" unless server_public_key_str
|
|
1343
|
+
end
|
|
1344
|
+
|
|
1345
|
+
@logger.debug("Using server public key ID: #{key_id}")
|
|
1346
|
+
@logger.debug("Server public key string: #{server_public_key_str[0..20]}...")
|
|
1347
|
+
|
|
1348
|
+
# Generate random key
|
|
1349
|
+
key = Crypto.generate_encryption_key_bytes
|
|
1350
|
+
@logger.debug("Generated transmission key: #{Utils.bytes_to_base64(key)[0..20]}...")
|
|
1351
|
+
|
|
1352
|
+
# Encrypt key with server public key
|
|
1353
|
+
server_public_key = Utils.url_safe_str_to_bytes(server_public_key_str)
|
|
1354
|
+
@logger.debug("Server public key bytes length: #{server_public_key.bytesize}")
|
|
1355
|
+
|
|
1356
|
+
encrypted_key = Crypto.encrypt_ec(key, server_public_key)
|
|
1357
|
+
@logger.debug("Encrypted key length: #{encrypted_key.bytesize}")
|
|
1358
|
+
|
|
1359
|
+
Dto::TransmissionKey.new(
|
|
1360
|
+
public_key_id: key_id,
|
|
1361
|
+
key: key,
|
|
1362
|
+
encrypted_key: encrypted_key
|
|
1363
|
+
)
|
|
1364
|
+
end
|
|
1365
|
+
|
|
1366
|
+
# Encrypt and sign payload
|
|
1367
|
+
def encrypt_and_sign_payload(config, transmission_key, payload)
|
|
1368
|
+
# Convert payload to JSON
|
|
1369
|
+
payload_json = payload.to_json
|
|
1370
|
+
|
|
1371
|
+
@logger.debug("Payload: #{payload_json}")
|
|
1372
|
+
|
|
1373
|
+
# Encrypt payload
|
|
1374
|
+
encrypted_payload = Crypto.encrypt_aes_gcm(payload_json, transmission_key.key)
|
|
1375
|
+
|
|
1376
|
+
# Generate signature
|
|
1377
|
+
signature_base = transmission_key.encrypted_key + encrypted_payload
|
|
1378
|
+
|
|
1379
|
+
# After binding, use ECDSA signature with private key (not HMAC)
|
|
1380
|
+
private_key_bytes = config.get_bytes(ConfigKeys::KEY_PRIVATE_KEY)
|
|
1381
|
+
if private_key_bytes
|
|
1382
|
+
# Load private key
|
|
1383
|
+
private_key = load_ec_private_key(private_key_bytes)
|
|
1384
|
+
|
|
1385
|
+
# Generate ECDSA signature
|
|
1386
|
+
signature = Crypto.sign_ec(signature_base, private_key)
|
|
1387
|
+
@logger.debug("Using ECDSA signature, length: #{signature.bytesize}")
|
|
1388
|
+
else
|
|
1389
|
+
# Fallback to HMAC with client key (for one-time token binding)
|
|
1390
|
+
client_key = config.get_string(ConfigKeys::KEY_CLIENT_KEY)
|
|
1391
|
+
if client_key
|
|
1392
|
+
signature_key = Utils.base64_to_bytes(client_key)
|
|
1393
|
+
signature = Crypto.generate_hmac(signature_key, signature_base)
|
|
1394
|
+
@logger.debug("Using HMAC signature, length: #{signature.bytesize}")
|
|
1395
|
+
else
|
|
1396
|
+
raise Error, 'No key available for signature'
|
|
1397
|
+
end
|
|
1398
|
+
end
|
|
1399
|
+
|
|
1400
|
+
Dto::EncryptedPayload.new(
|
|
1401
|
+
encrypted_payload: encrypted_payload,
|
|
1402
|
+
signature: signature
|
|
1403
|
+
)
|
|
1404
|
+
end
|
|
1405
|
+
|
|
1406
|
+
# Create Net::HTTP instance with proxy support
|
|
1407
|
+
# Configures proxy if @proxy_url is set
|
|
1408
|
+
def create_http_client(uri)
|
|
1409
|
+
if @proxy_url
|
|
1410
|
+
# Parse proxy URL
|
|
1411
|
+
proxy_uri = URI(@proxy_url)
|
|
1412
|
+
|
|
1413
|
+
# Create HTTP client with proxy
|
|
1414
|
+
http = Net::HTTP.new(
|
|
1415
|
+
uri.host,
|
|
1416
|
+
uri.port,
|
|
1417
|
+
proxy_uri.host,
|
|
1418
|
+
proxy_uri.port,
|
|
1419
|
+
proxy_uri.user,
|
|
1420
|
+
proxy_uri.password
|
|
1421
|
+
)
|
|
1422
|
+
|
|
1423
|
+
@logger.debug("Using HTTP proxy: #{proxy_uri.host}:#{proxy_uri.port}")
|
|
1424
|
+
@logger.debug("Proxy authentication: #{proxy_uri.user ? 'yes' : 'no'}")
|
|
1425
|
+
else
|
|
1426
|
+
# Create HTTP client without proxy
|
|
1427
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
1428
|
+
end
|
|
1429
|
+
|
|
1430
|
+
http
|
|
1431
|
+
end
|
|
1432
|
+
|
|
1433
|
+
# Configure SSL for HTTP connection
|
|
1434
|
+
# Sets up certificate store and verification mode
|
|
1435
|
+
def configure_http_ssl(http)
|
|
1436
|
+
http.use_ssl = true
|
|
1437
|
+
|
|
1438
|
+
if @verify_ssl_certs
|
|
1439
|
+
http.verify_mode = OpenSSL::SSL::VERIFY_PEER
|
|
1440
|
+
|
|
1441
|
+
# Set up certificate store with system defaults
|
|
1442
|
+
# This ensures we use the system's trusted CA certificates
|
|
1443
|
+
store = OpenSSL::X509::Store.new
|
|
1444
|
+
store.set_default_paths
|
|
1445
|
+
|
|
1446
|
+
# Note: We don't enable CRL checking by default because:
|
|
1447
|
+
# 1. CRL endpoints may be temporarily unreachable
|
|
1448
|
+
# 2. Many enterprise networks block CRL endpoints
|
|
1449
|
+
# 3. Certificate chain validation still provides strong security
|
|
1450
|
+
# If strict CRL checking is needed, it can be added via configuration option
|
|
1451
|
+
http.cert_store = store
|
|
1452
|
+
|
|
1453
|
+
@logger.debug('SSL verification enabled with system certificate store')
|
|
1454
|
+
@logger.debug("OpenSSL version: #{OpenSSL::OPENSSL_VERSION}")
|
|
1455
|
+
else
|
|
1456
|
+
http.verify_mode = OpenSSL::SSL::VERIFY_NONE
|
|
1457
|
+
@logger.warn('SSL verification disabled - not recommended for production')
|
|
1458
|
+
end
|
|
1459
|
+
end
|
|
1460
|
+
|
|
1461
|
+
# HTTP post function
|
|
1462
|
+
def post_function(url, transmission_key, encrypted_payload)
|
|
1463
|
+
uri = URI(url)
|
|
1464
|
+
|
|
1465
|
+
@logger.debug("POST URL: #{url}")
|
|
1466
|
+
@logger.debug("PublicKeyId header: #{transmission_key.public_key_id}")
|
|
1467
|
+
@logger.debug("TransmissionKey header: #{Utils.bytes_to_base64(transmission_key.encrypted_key)[0..50]}...")
|
|
1468
|
+
@logger.debug("TransmissionKey full base64 length: #{Utils.bytes_to_base64(transmission_key.encrypted_key).length}")
|
|
1469
|
+
@logger.debug("Signature header: #{Utils.bytes_to_base64(encrypted_payload.signature)[0..50]}...")
|
|
1470
|
+
@logger.debug("Request body length: #{encrypted_payload.encrypted_payload.bytesize} bytes")
|
|
1471
|
+
|
|
1472
|
+
request = Net::HTTP::Post.new(uri)
|
|
1473
|
+
request['Content-Type'] = 'application/octet-stream'
|
|
1474
|
+
request['PublicKeyId'] = transmission_key.public_key_id.to_s
|
|
1475
|
+
request['TransmissionKey'] = Utils.bytes_to_base64(transmission_key.encrypted_key)
|
|
1476
|
+
request['Authorization'] = "Signature #{Utils.bytes_to_base64(encrypted_payload.signature)}"
|
|
1477
|
+
request['Content-Length'] = encrypted_payload.encrypted_payload.bytesize.to_s
|
|
1478
|
+
request.body = encrypted_payload.encrypted_payload
|
|
1479
|
+
|
|
1480
|
+
http = create_http_client(uri)
|
|
1481
|
+
configure_http_ssl(http)
|
|
1482
|
+
|
|
1483
|
+
response = http.request(request)
|
|
1484
|
+
|
|
1485
|
+
@logger.debug("Response status: #{response.code}")
|
|
1486
|
+
@logger.debug("Response body length: #{response.body&.bytesize || 0} bytes")
|
|
1487
|
+
@logger.debug("Response body (first 100 bytes): #{response.body&.[](0..100).inspect}") if response.body && !response.body.empty?
|
|
1488
|
+
|
|
1489
|
+
Dto::KSMHttpResponse.new(
|
|
1490
|
+
status_code: response.code.to_i,
|
|
1491
|
+
data: response.body,
|
|
1492
|
+
http_response: response
|
|
1493
|
+
)
|
|
1494
|
+
rescue StandardError => e
|
|
1495
|
+
raise NetworkError, "HTTP request failed: #{e.message}"
|
|
1496
|
+
end
|
|
1497
|
+
|
|
1498
|
+
# Handle HTTP errors
|
|
1499
|
+
def handle_http_error(response, config = nil)
|
|
1500
|
+
error_data = JSON.parse(response.data)
|
|
1501
|
+
result_code = error_data['result_code'] || error_data['error']
|
|
1502
|
+
message = error_data['message']
|
|
1503
|
+
|
|
1504
|
+
@logger.debug("Server error response: #{error_data.inspect}")
|
|
1505
|
+
|
|
1506
|
+
# Handle specific errors
|
|
1507
|
+
case result_code
|
|
1508
|
+
when 'key'
|
|
1509
|
+
# Server wants different key
|
|
1510
|
+
key_id = error_data['key_id']
|
|
1511
|
+
# Use passed config or fall back to instance config
|
|
1512
|
+
config_to_use = config || @config
|
|
1513
|
+
custom_key = config_to_use&.get_string(ConfigKeys::KEY_SERVER_PUBLIC_KEY)
|
|
1514
|
+
|
|
1515
|
+
# when a custom (IL5) server public key is configured, the server rejecting it
|
|
1516
|
+
# cannot be fixed by switching to a built-in key, and retrying would loop on the same key.
|
|
1517
|
+
# Fail fast with actionable remediation instead.
|
|
1518
|
+
if custom_key && !custom_key.empty?
|
|
1519
|
+
raise Error, "Server rejected the custom server public key (requested key ID #{key_id.inspect}). " \
|
|
1520
|
+
'The IL5 server public key may have rotated; update your IL5 KSM configuration ' \
|
|
1521
|
+
'(serverPublicKey / serverPublicKeyId), e.g. by redeeming a freshly issued one-time token.'
|
|
1522
|
+
end
|
|
1523
|
+
|
|
1524
|
+
raise Error, 'The public key is blank from the server' if key_id.nil?
|
|
1525
|
+
unless KeeperGlobals::KEEPER_PUBLIC_KEYS.key?(key_id.to_s)
|
|
1526
|
+
raise Error, "The public key at #{key_id} does not exist in the SDK"
|
|
1527
|
+
end
|
|
1528
|
+
|
|
1529
|
+
@logger.info("Server requested key ID: #{key_id}")
|
|
1530
|
+
config_to_use.save_string(ConfigKeys::KEY_SERVER_PUBLIC_KEY_ID, key_id.to_s) if config_to_use
|
|
1531
|
+
nil # Retry
|
|
1532
|
+
else
|
|
1533
|
+
raise ErrorFactory.from_server_response(result_code, message)
|
|
1534
|
+
end
|
|
1535
|
+
rescue JSON::ParserError
|
|
1536
|
+
raise NetworkError.new("Server error: HTTP #{response.status_code}",
|
|
1537
|
+
status_code: response.status_code,
|
|
1538
|
+
response_body: response.data)
|
|
1539
|
+
end
|
|
1540
|
+
|
|
1541
|
+
# Returns the throttle retry_after (>= 0) when +response+ is a backend throttle error
|
|
1542
|
+
# (result_code/error == "throttled"), otherwise nil so the caller falls through to
|
|
1543
|
+
# handle_http_error. Non-JSON / non-object bodies return nil. (KSM-876 / KSM-883)
|
|
1544
|
+
def parse_throttle(response)
|
|
1545
|
+
data = JSON.parse(response.data)
|
|
1546
|
+
return nil unless data.is_a?(Hash)
|
|
1547
|
+
|
|
1548
|
+
result_code = data['result_code'] || data['error']
|
|
1549
|
+
return nil unless result_code == 'throttled'
|
|
1550
|
+
|
|
1551
|
+
retry_after = begin
|
|
1552
|
+
Float(data['retry_after'])
|
|
1553
|
+
rescue ArgumentError, TypeError
|
|
1554
|
+
0.0
|
|
1555
|
+
end
|
|
1556
|
+
retry_after.negative? ? 0.0 : retry_after
|
|
1557
|
+
rescue JSON::ParserError
|
|
1558
|
+
nil
|
|
1559
|
+
end
|
|
1560
|
+
|
|
1561
|
+
# Backoff delay (seconds) for a 0-based attempt: retry_after when > 0, otherwise exponential
|
|
1562
|
+
# backoff (BASE_THROTTLE_DELAY_SEC * 2**attempt -> 11, 22, 44, 88, 176s). A 0 to +25% jitter
|
|
1563
|
+
# is applied (one-sided so delay is always >= floor, preventing a retry before the backend's
|
|
1564
|
+
# 10s memcached window expires).
|
|
1565
|
+
def throttle_delay(attempt, retry_after = 0)
|
|
1566
|
+
base = retry_after.to_f.positive? ? retry_after.to_f : BASE_THROTTLE_DELAY_SEC * (2**attempt)
|
|
1567
|
+
base = MAX_THROTTLE_DELAY_SEC if base > MAX_THROTTLE_DELAY_SEC
|
|
1568
|
+
delay = base + base * rand(0.0..0.25)
|
|
1569
|
+
delay.negative? ? 0.0 : delay
|
|
1570
|
+
end
|
|
1571
|
+
|
|
1572
|
+
# Get server hostname
|
|
1573
|
+
def get_server(hostname)
|
|
1574
|
+
return hostname if hostname.include?('.')
|
|
1575
|
+
|
|
1576
|
+
# Look up in server list
|
|
1577
|
+
KeeperGlobals::KEEPER_SERVERS[hostname.upcase] || hostname
|
|
1578
|
+
end
|
|
1579
|
+
|
|
1580
|
+
# Load EC private key from bytes
|
|
1581
|
+
def load_ec_private_key(private_key_bytes)
|
|
1582
|
+
# If it's already a key object, return it
|
|
1583
|
+
return private_key_bytes if private_key_bytes.is_a?(OpenSSL::PKey::EC)
|
|
1584
|
+
|
|
1585
|
+
@logger.debug("Loading private key, bytes length: #{private_key_bytes.bytesize}")
|
|
1586
|
+
|
|
1587
|
+
# Try to load as DER format first
|
|
1588
|
+
begin
|
|
1589
|
+
key = OpenSSL::PKey.read(private_key_bytes, nil)
|
|
1590
|
+
# Ensure it's an EC key
|
|
1591
|
+
if key.is_a?(OpenSSL::PKey::EC)
|
|
1592
|
+
@logger.debug('Successfully loaded EC private key from DER format')
|
|
1593
|
+
key
|
|
1594
|
+
else
|
|
1595
|
+
raise "Not an EC key, got #{key.class}"
|
|
1596
|
+
end
|
|
1597
|
+
rescue StandardError => e
|
|
1598
|
+
@logger.debug("DER format failed: #{e.message}, trying raw bytes")
|
|
1599
|
+
|
|
1600
|
+
# If DER fails, it might be raw key bytes (32 bytes)
|
|
1601
|
+
if private_key_bytes.bytesize == 32
|
|
1602
|
+
begin
|
|
1603
|
+
# Create EC key from raw bytes (OpenSSL 3.0 compatible)
|
|
1604
|
+
group = OpenSSL::PKey::EC::Group.new('prime256v1')
|
|
1605
|
+
|
|
1606
|
+
# Generate key components
|
|
1607
|
+
private_key_bn = OpenSSL::BN.new(private_key_bytes, 2)
|
|
1608
|
+
public_key_point = group.generator.mul(private_key_bn)
|
|
1609
|
+
|
|
1610
|
+
# Create ASN1 sequence for the key
|
|
1611
|
+
asn1 = OpenSSL::ASN1::Sequence([
|
|
1612
|
+
OpenSSL::ASN1::Integer(1),
|
|
1613
|
+
OpenSSL::ASN1::OctetString(private_key_bytes),
|
|
1614
|
+
OpenSSL::ASN1::ObjectId('prime256v1', 0, :EXPLICIT),
|
|
1615
|
+
OpenSSL::ASN1::BitString(
|
|
1616
|
+
public_key_point.to_octet_string(:uncompressed), 1, :EXPLICIT
|
|
1617
|
+
)
|
|
1618
|
+
])
|
|
1619
|
+
|
|
1620
|
+
# Create key from DER
|
|
1621
|
+
key = OpenSSL::PKey::EC.new(asn1.to_der)
|
|
1622
|
+
|
|
1623
|
+
@logger.debug('Successfully created EC key from raw bytes')
|
|
1624
|
+
key
|
|
1625
|
+
rescue StandardError => raw_error
|
|
1626
|
+
@logger.debug("Raw bytes failed: #{raw_error.message}")
|
|
1627
|
+
raise CryptoError, "Failed to load private key: DER: #{e.message}, Raw: #{raw_error.message}"
|
|
1628
|
+
end
|
|
1629
|
+
else
|
|
1630
|
+
raise CryptoError,
|
|
1631
|
+
"Failed to load private key: #{e.message} (got #{private_key_bytes.bytesize} bytes)"
|
|
1632
|
+
end
|
|
1633
|
+
end
|
|
1634
|
+
end
|
|
1635
|
+
|
|
1636
|
+
# Other helper methods...
|
|
1637
|
+
def prepare_update_payload(record_uid:, record_data:, record_key:, revision:, update_options: nil)
|
|
1638
|
+
payload = Dto::UpdatePayload.new
|
|
1639
|
+
payload.client_version = KeeperGlobals.client_version
|
|
1640
|
+
payload.client_id = @config.get_string(ConfigKeys::KEY_CLIENT_ID)
|
|
1641
|
+
payload.record_uid = record_uid
|
|
1642
|
+
payload.revision = revision
|
|
1643
|
+
|
|
1644
|
+
if update_options
|
|
1645
|
+
# nil clears UpdatePayload's 'general' default (BasePayload#to_h skips nil fields).
|
|
1646
|
+
payload.transaction_type = update_options.transaction_type
|
|
1647
|
+
|
|
1648
|
+
# Handle links_to_remove
|
|
1649
|
+
if update_options.links_to_remove && !update_options.links_to_remove.empty?
|
|
1650
|
+
payload.links2_remove = update_options.links_to_remove
|
|
1651
|
+
|
|
1652
|
+
# Filter fileRef field values - remove specified link UIDs from record data
|
|
1653
|
+
# This modifies the data hash before encryption (matches Python SDK behavior)
|
|
1654
|
+
fileref_field = record_data['fields']&.find { |f| f['type'] == 'fileRef' }
|
|
1655
|
+
if fileref_field && fileref_field['value'].is_a?(Array)
|
|
1656
|
+
original_values = fileref_field['value']
|
|
1657
|
+
filtered_values = original_values.reject { |uid| update_options.links_to_remove.include?(uid) }
|
|
1658
|
+
fileref_field['value'] = filtered_values if filtered_values.length != original_values.length
|
|
1659
|
+
end
|
|
1660
|
+
end
|
|
1661
|
+
end
|
|
1662
|
+
# When update_options is nil: UpdatePayload keeps its default 'general' from #initialize.
|
|
1663
|
+
|
|
1664
|
+
# Encrypt record data
|
|
1665
|
+
json_data = Utils.dict_to_json(record_data)
|
|
1666
|
+
encrypted_data = Crypto.encrypt_aes_gcm(json_data, record_key)
|
|
1667
|
+
payload.data = Utils.bytes_to_base64(encrypted_data)
|
|
1668
|
+
|
|
1669
|
+
payload
|
|
1670
|
+
end
|
|
1671
|
+
|
|
1672
|
+
def prepare_delete_payload(record_uids)
|
|
1673
|
+
payload = Dto::DeletePayload.new
|
|
1674
|
+
payload.client_version = KeeperGlobals.client_version
|
|
1675
|
+
payload.client_id = @config.get_string(ConfigKeys::KEY_CLIENT_ID)
|
|
1676
|
+
payload.record_uids = record_uids
|
|
1677
|
+
payload
|
|
1678
|
+
end
|
|
1679
|
+
|
|
1680
|
+
def prepare_complete_transaction_payload(record_uid)
|
|
1681
|
+
payload = Dto::CompleteTransactionPayload.new
|
|
1682
|
+
payload.client_version = KeeperGlobals.client_version
|
|
1683
|
+
payload.client_id = @config.get_string(ConfigKeys::KEY_CLIENT_ID)
|
|
1684
|
+
payload.record_uid = record_uid
|
|
1685
|
+
payload
|
|
1686
|
+
end
|
|
1687
|
+
|
|
1688
|
+
def prepare_create_folder_payload(folder_uid:, shared_folder_uid:, encrypted_folder_key:, data:, parent_uid:)
|
|
1689
|
+
payload = Dto::CreateFolderPayload.new
|
|
1690
|
+
payload.client_version = KeeperGlobals.client_version
|
|
1691
|
+
payload.client_id = @config.get_string(ConfigKeys::KEY_CLIENT_ID)
|
|
1692
|
+
payload.folder_uid = folder_uid
|
|
1693
|
+
payload.shared_folder_uid = shared_folder_uid
|
|
1694
|
+
payload.data = Utils.bytes_to_url_safe_str(data)
|
|
1695
|
+
payload.parent_uid = parent_uid
|
|
1696
|
+
|
|
1697
|
+
# Use encrypted folder key (already encrypted with shared folder's key using AES-CBC)
|
|
1698
|
+
payload.shared_folder_key = Utils.bytes_to_url_safe_str(encrypted_folder_key)
|
|
1699
|
+
|
|
1700
|
+
payload
|
|
1701
|
+
end
|
|
1702
|
+
|
|
1703
|
+
def prepare_update_folder_payload(folder_uid:, data:)
|
|
1704
|
+
payload = Dto::UpdateFolderPayload.new
|
|
1705
|
+
payload.client_version = KeeperGlobals.client_version
|
|
1706
|
+
payload.client_id = @config.get_string(ConfigKeys::KEY_CLIENT_ID)
|
|
1707
|
+
payload.folder_uid = folder_uid
|
|
1708
|
+
payload.data = Utils.bytes_to_url_safe_str(data)
|
|
1709
|
+
payload
|
|
1710
|
+
end
|
|
1711
|
+
|
|
1712
|
+
def prepare_delete_folder_payload(folder_uids, force)
|
|
1713
|
+
payload = Dto::DeleteFolderPayload.new
|
|
1714
|
+
payload.client_version = KeeperGlobals.client_version
|
|
1715
|
+
payload.client_id = @config.get_string(ConfigKeys::KEY_CLIENT_ID)
|
|
1716
|
+
payload.folder_uids = folder_uids
|
|
1717
|
+
payload.force_deletion = force
|
|
1718
|
+
payload
|
|
1719
|
+
end
|
|
1720
|
+
|
|
1721
|
+
def prepare_file_upload_payload(file_record_uid:, file_record_key:, file_record_data:, owner_record_uid:, owner_record_data:, owner_record_revision:, link_key:, file_size:)
|
|
1722
|
+
payload = Dto::FileUploadPayload.new
|
|
1723
|
+
payload.client_version = KeeperGlobals.client_version
|
|
1724
|
+
payload.client_id = @config.get_string(ConfigKeys::KEY_CLIENT_ID)
|
|
1725
|
+
payload.file_record_uid = file_record_uid
|
|
1726
|
+
payload.file_record_key = Utils.bytes_to_base64(file_record_key)
|
|
1727
|
+
payload.file_record_data = Utils.bytes_to_base64(file_record_data)
|
|
1728
|
+
payload.owner_record_uid = owner_record_uid
|
|
1729
|
+
payload.owner_record_data = Utils.bytes_to_base64(owner_record_data)
|
|
1730
|
+
payload.owner_record_revision = owner_record_revision
|
|
1731
|
+
payload.link_key = Utils.bytes_to_base64(link_key)
|
|
1732
|
+
payload.file_size = file_size
|
|
1733
|
+
payload
|
|
1734
|
+
end
|
|
1735
|
+
|
|
1736
|
+
def upload_file_function(url, parameters, encrypted_file_data)
|
|
1737
|
+
uri = URI(url)
|
|
1738
|
+
|
|
1739
|
+
# Parse parameters if it's a JSON string
|
|
1740
|
+
params_hash = parameters.is_a?(String) ? JSON.parse(parameters) : parameters
|
|
1741
|
+
|
|
1742
|
+
# Build multipart form data
|
|
1743
|
+
# Form data should include both the file and the parameters
|
|
1744
|
+
form_data = params_hash.merge({ 'file' => encrypted_file_data })
|
|
1745
|
+
|
|
1746
|
+
request = Net::HTTP::Post.new(uri)
|
|
1747
|
+
|
|
1748
|
+
# Set form with file upload
|
|
1749
|
+
# The file field needs to be handled as binary data
|
|
1750
|
+
boundary = "----RubyMultipartPost#{rand(1000000)}"
|
|
1751
|
+
body = []
|
|
1752
|
+
|
|
1753
|
+
# Add regular form fields first
|
|
1754
|
+
params_hash.each do |key, value|
|
|
1755
|
+
body << "--#{boundary}\r\n"
|
|
1756
|
+
body << "Content-Disposition: form-data; name=\"#{key}\"\r\n\r\n"
|
|
1757
|
+
body << "#{value}\r\n"
|
|
1758
|
+
end
|
|
1759
|
+
|
|
1760
|
+
# Add file field
|
|
1761
|
+
body << "--#{boundary}\r\n"
|
|
1762
|
+
body << "Content-Disposition: form-data; name=\"file\"; filename=\"file\"\r\n"
|
|
1763
|
+
body << "Content-Type: application/octet-stream\r\n\r\n"
|
|
1764
|
+
body << encrypted_file_data
|
|
1765
|
+
body << "\r\n--#{boundary}--\r\n"
|
|
1766
|
+
|
|
1767
|
+
request.body = body.join
|
|
1768
|
+
request['Content-Type'] = "multipart/form-data; boundary=#{boundary}"
|
|
1769
|
+
|
|
1770
|
+
http = create_http_client(uri)
|
|
1771
|
+
configure_http_ssl(http)
|
|
1772
|
+
|
|
1773
|
+
response = http.request(request)
|
|
1774
|
+
|
|
1775
|
+
raise NetworkError, "File upload failed: HTTP #{response.code} - #{response.body}" unless response.code.to_i.between?(200, 299)
|
|
1776
|
+
|
|
1777
|
+
true
|
|
1778
|
+
end
|
|
1779
|
+
end
|
|
1780
|
+
end
|
|
1781
|
+
end
|