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