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