keeper_secrets_manager 17.1.0 → 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 +83 -10
- data/Gemfile +3 -0
- data/README.md +75 -270
- 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 +2 -0
- data/lib/keeper_secrets_manager/core.rb +455 -114
- data/lib/keeper_secrets_manager/crypto.rb +3 -11
- data/lib/keeper_secrets_manager/dto/payload.rb +3 -2
- data/lib/keeper_secrets_manager/dto.rb +326 -8
- data/lib/keeper_secrets_manager/keeper_globals.rb +6 -6
- data/lib/keeper_secrets_manager/notation.rb +105 -3
- data/lib/keeper_secrets_manager/notation_enhancements.rb +2 -0
- data/lib/keeper_secrets_manager/storage.rb +3 -2
- data/lib/keeper_secrets_manager/utils.rb +2 -1
- data/lib/keeper_secrets_manager/version.rb +1 -1
- data/lib/keeper_secrets_manager.rb +8 -0
- metadata +34 -3
|
@@ -30,7 +30,7 @@ module KeeperSecretsManager
|
|
|
30
30
|
|
|
31
31
|
# Convert URL-safe base64 string to bytes
|
|
32
32
|
def url_safe_str_to_bytes(str)
|
|
33
|
-
|
|
33
|
+
raise CryptoError, 'url_safe_str_to_bytes: received nil' if str.nil?
|
|
34
34
|
str += '=' * (4 - str.length % 4) if str.length % 4 != 0
|
|
35
35
|
Base64.urlsafe_decode64(str)
|
|
36
36
|
end
|
|
@@ -42,6 +42,7 @@ module KeeperSecretsManager
|
|
|
42
42
|
|
|
43
43
|
# Convert base64 to bytes
|
|
44
44
|
def base64_to_bytes(str)
|
|
45
|
+
raise CryptoError, 'base64_to_bytes: received nil' if str.nil?
|
|
45
46
|
Base64.strict_decode64(str)
|
|
46
47
|
end
|
|
47
48
|
|
|
@@ -116,10 +117,7 @@ module KeeperSecretsManager
|
|
|
116
117
|
end
|
|
117
118
|
end
|
|
118
119
|
|
|
119
|
-
# Decrypt with AES-GCM or fallback to CBC
|
|
120
120
|
def decrypt_aes_gcm(encrypted_data, key)
|
|
121
|
-
# Try GCM first
|
|
122
|
-
# Extract components
|
|
123
121
|
iv = encrypted_data[0...GCM_IV_LENGTH]
|
|
124
122
|
tag = encrypted_data[-GCM_TAG_LENGTH..]
|
|
125
123
|
ciphertext = encrypted_data[GCM_IV_LENGTH...-GCM_TAG_LENGTH]
|
|
@@ -133,18 +131,12 @@ module KeeperSecretsManager
|
|
|
133
131
|
cipher.update(ciphertext) + cipher.final
|
|
134
132
|
rescue RuntimeError => e
|
|
135
133
|
if e.message.include?('unsupported cipher')
|
|
136
|
-
# Fallback to AES-CBC
|
|
137
134
|
decrypt_aes_cbc(encrypted_data, key)
|
|
138
135
|
else
|
|
139
136
|
raise e
|
|
140
137
|
end
|
|
141
138
|
rescue OpenSSL::Cipher::CipherError => e
|
|
142
|
-
|
|
143
|
-
begin
|
|
144
|
-
decrypt_aes_cbc(encrypted_data, key)
|
|
145
|
-
rescue StandardError
|
|
146
|
-
raise DecryptionError, "Failed to decrypt data: #{e.message}"
|
|
147
|
-
end
|
|
139
|
+
raise DecryptionError, "Failed to decrypt data: #{e.message}"
|
|
148
140
|
end
|
|
149
141
|
|
|
150
142
|
# Legacy AES-CBC encryption (for compatibility)
|
|
@@ -35,13 +35,14 @@ module KeeperSecretsManager
|
|
|
35
35
|
|
|
36
36
|
# Get secrets payload
|
|
37
37
|
class GetPayload < BasePayload
|
|
38
|
-
attr_accessor :public_key, :requested_records, :requested_folders, :file_uids
|
|
38
|
+
attr_accessor :public_key, :requested_records, :requested_folders, :file_uids, :request_links
|
|
39
39
|
|
|
40
40
|
def initialize
|
|
41
41
|
super()
|
|
42
42
|
@requested_records = nil
|
|
43
43
|
@requested_folders = nil
|
|
44
44
|
@file_uids = nil
|
|
45
|
+
@request_links = nil
|
|
45
46
|
end
|
|
46
47
|
end
|
|
47
48
|
|
|
@@ -57,7 +58,7 @@ module KeeperSecretsManager
|
|
|
57
58
|
|
|
58
59
|
# Update record payload
|
|
59
60
|
class UpdatePayload < BasePayload
|
|
60
|
-
attr_accessor :record_uid, :data, :revision, :transaction_type
|
|
61
|
+
attr_accessor :record_uid, :data, :revision, :transaction_type, :links2_remove
|
|
61
62
|
|
|
62
63
|
def initialize
|
|
63
64
|
super()
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
require 'json'
|
|
2
|
-
require 'ostruct'
|
|
3
2
|
require_relative 'dto/payload'
|
|
3
|
+
require_relative 'utils'
|
|
4
|
+
require_relative 'crypto'
|
|
4
5
|
|
|
5
6
|
module KeeperSecretsManager
|
|
6
7
|
module Dto
|
|
7
8
|
# Base class for dynamic record handling
|
|
8
9
|
class KeeperRecord
|
|
9
|
-
attr_accessor :uid, :title, :type, :fields, :custom, :notes, :folder_uid, :data, :revision, :files
|
|
10
|
+
attr_accessor :uid, :title, :type, :fields, :custom, :notes, :folder_uid, :inner_folder_uid, :data, :revision, :files, :links, :is_editable
|
|
10
11
|
attr_reader :record_key # Internal - stores decrypted record key (bytes) for file upload operations
|
|
11
12
|
|
|
12
13
|
def initialize(attrs = {})
|
|
@@ -14,6 +15,7 @@ module KeeperSecretsManager
|
|
|
14
15
|
# Support both raw API response and user-friendly creation
|
|
15
16
|
@uid = attrs['recordUid'] || attrs['uid'] || attrs[:uid]
|
|
16
17
|
@folder_uid = attrs['folderUid'] || attrs['folder_uid'] || attrs[:folder_uid]
|
|
18
|
+
@inner_folder_uid = attrs['innerFolderUid'] || attrs['inner_folder_uid'] || attrs[:inner_folder_uid]
|
|
17
19
|
@revision = attrs['revision'] || attrs[:revision] || 0
|
|
18
20
|
|
|
19
21
|
# Handle encrypted data or direct attributes
|
|
@@ -33,6 +35,19 @@ module KeeperSecretsManager
|
|
|
33
35
|
end
|
|
34
36
|
|
|
35
37
|
@files = attrs['files'] || attrs[:files] || []
|
|
38
|
+
@links = attrs['links'] || attrs[:links] || []
|
|
39
|
+
|
|
40
|
+
# Handle is_editable (can be false, so use has_key? check)
|
|
41
|
+
if attrs.key?('isEditable')
|
|
42
|
+
@is_editable = attrs['isEditable']
|
|
43
|
+
elsif attrs.key?('is_editable')
|
|
44
|
+
@is_editable = attrs['is_editable']
|
|
45
|
+
elsif attrs.key?(:is_editable)
|
|
46
|
+
@is_editable = attrs[:is_editable]
|
|
47
|
+
else
|
|
48
|
+
@is_editable = true # Default to true if not specified
|
|
49
|
+
end
|
|
50
|
+
|
|
36
51
|
@data = attrs
|
|
37
52
|
end
|
|
38
53
|
|
|
@@ -50,8 +65,7 @@ module KeeperSecretsManager
|
|
|
50
65
|
'fields' => fields
|
|
51
66
|
}
|
|
52
67
|
|
|
53
|
-
|
|
54
|
-
result['custom'] = custom if custom && !custom.empty?
|
|
68
|
+
result['custom'] = custom unless custom.nil?
|
|
55
69
|
|
|
56
70
|
# Only include notes if present
|
|
57
71
|
result['notes'] = notes if notes && !notes.empty?
|
|
@@ -108,6 +122,22 @@ module KeeperSecretsManager
|
|
|
108
122
|
end
|
|
109
123
|
end
|
|
110
124
|
|
|
125
|
+
# Return this record's linked-credential entries as typed KeeperRecordLink objects.
|
|
126
|
+
#
|
|
127
|
+
# Typed view over the raw `links` list (populated when secrets are fetched with
|
|
128
|
+
# QueryOptions(..., request_links: true)). The raw `links` list is left unchanged
|
|
129
|
+
# for backward compatibility; entries without a String recordUid are skipped.
|
|
130
|
+
def get_links
|
|
131
|
+
(links || []).each_with_object([]) do |link_dict, result|
|
|
132
|
+
next unless link_dict.is_a?(Hash)
|
|
133
|
+
|
|
134
|
+
record_uid = link_dict['recordUid']
|
|
135
|
+
next unless record_uid.is_a?(String) && !record_uid.empty?
|
|
136
|
+
|
|
137
|
+
result << KeeperRecordLink.new(link_dict)
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
111
141
|
# Dynamic field access methods
|
|
112
142
|
def method_missing(method, *args, &block)
|
|
113
143
|
method_name = method.to_s
|
|
@@ -154,6 +184,279 @@ module KeeperSecretsManager
|
|
|
154
184
|
end
|
|
155
185
|
end
|
|
156
186
|
|
|
187
|
+
# Typed view over a single linked-credential entry of a record (`record.links`).
|
|
188
|
+
#
|
|
189
|
+
# A link entry carries `recordUid`, optional base64 `data`, and an optional `path`
|
|
190
|
+
# discriminator. Observed payload shapes (verified against the live backend):
|
|
191
|
+
#
|
|
192
|
+
# - path "meta" (self-link, recordUid == owning record): plain base64 JSON with
|
|
193
|
+
# `allowedSettings` (rotation, connections, portForwards, sessionRecording,
|
|
194
|
+
# typescriptRecording, aiEnabled, aiSessionTerminate, remoteBrowserIsolation),
|
|
195
|
+
# plus `rotateOnTermination`, `version` and `no_update_services`.
|
|
196
|
+
# - path nil (credential link to another record): plain base64 JSON with
|
|
197
|
+
# `is_admin`, `is_launch_credential`, `is_iam_user`, `belongs_to` and
|
|
198
|
+
# `rotation_settings`; or no data at all (pure record reference).
|
|
199
|
+
# - path "ai_settings" / "jit_settings" (self-links): data is AES-256-GCM
|
|
200
|
+
# encrypted under the owning record's key - see #get_decrypted_data.
|
|
201
|
+
#
|
|
202
|
+
# Accessors never raise: parse, decode or decryption failures yield nil/false.
|
|
203
|
+
# The original link hash is kept untouched in `raw`, and #get_link_data returns the
|
|
204
|
+
# complete parsed payload, so fields unknown to this SDK version are preserved.
|
|
205
|
+
#
|
|
206
|
+
# Naming: Ruby predicates take a trailing `?` and drop the redundant `is_` prefix
|
|
207
|
+
# (house style), so the Python reference's is_admin_user/is_launch_credential/
|
|
208
|
+
# is_iam_user map to admin_user?/launch_credential?/iam_user? here.
|
|
209
|
+
class KeeperRecordLink
|
|
210
|
+
attr_reader :raw, :record_uid, :data, :path
|
|
211
|
+
|
|
212
|
+
def initialize(link_dict = {})
|
|
213
|
+
link_dict = {} unless link_dict.is_a?(Hash)
|
|
214
|
+
@raw = link_dict.dup
|
|
215
|
+
@record_uid = link_dict['recordUid']
|
|
216
|
+
@data = link_dict['data']
|
|
217
|
+
@path = link_dict['path']
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def to_s
|
|
221
|
+
"[KeeperRecordLink: record_uid=#{@record_uid}, path=#{@path}]"
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def admin_user?
|
|
225
|
+
boolean_value('is_admin')
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def launch_credential?
|
|
229
|
+
boolean_value('is_launch_credential')
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def iam_user?
|
|
233
|
+
boolean_value('is_iam_user')
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def belongs_to?
|
|
237
|
+
boolean_value('belongs_to')
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def no_update_services?
|
|
241
|
+
boolean_value('no_update_services')
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def allows_rotation?
|
|
245
|
+
boolean_value('rotation', true)
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def allows_connections?
|
|
249
|
+
boolean_value('connections', true)
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def allows_port_forwards?
|
|
253
|
+
boolean_value('portForwards', true)
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def allows_session_recording?
|
|
257
|
+
boolean_value('sessionRecording', true)
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def allows_typescript_recording?
|
|
261
|
+
boolean_value('typescriptRecording', true)
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def allows_remote_browser_isolation?
|
|
265
|
+
boolean_value('remoteBrowserIsolation', true)
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def ai_enabled?
|
|
269
|
+
boolean_value('aiEnabled', true)
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def ai_session_terminate?
|
|
273
|
+
boolean_value('aiSessionTerminate', true)
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def rotates_on_termination?
|
|
277
|
+
boolean_value('rotateOnTermination')
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
# The link data schema version (`version`) when it is an integer, else nil.
|
|
281
|
+
def get_link_data_version
|
|
282
|
+
int_value('version')
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
# The `allowedSettings` object from the link data (empty hash when absent).
|
|
286
|
+
def get_allowed_settings
|
|
287
|
+
parsed = parse_json_data
|
|
288
|
+
allowed = parsed ? parsed['allowedSettings'] : nil
|
|
289
|
+
allowed.is_a?(Hash) ? allowed : {}
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
# The `rotation_settings` object from the link data, or nil when absent.
|
|
293
|
+
def get_rotation_settings
|
|
294
|
+
parsed = parse_json_data
|
|
295
|
+
rotation = parsed ? parsed['rotation_settings'] : nil
|
|
296
|
+
rotation.is_a?(Hash) ? rotation : nil
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
# Base64-decode `data` to a string (for debugging/advanced use), or nil.
|
|
300
|
+
def get_decoded_data
|
|
301
|
+
return nil if @data.nil?
|
|
302
|
+
|
|
303
|
+
Utils.base64_to_bytes(@data).force_encoding('UTF-8').scrub
|
|
304
|
+
rescue StandardError
|
|
305
|
+
nil
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
# Whether the link has readable JSON data (vs. encrypted/binary data).
|
|
309
|
+
def has_readable_data?
|
|
310
|
+
decoded = get_decoded_data
|
|
311
|
+
!decoded.nil? && (decoded.start_with?('{') || decoded.start_with?('['))
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
# Whether this link's path indicates potentially encrypted data (currently
|
|
315
|
+
# ai_settings / jit_settings; other paths carry plain base64 JSON).
|
|
316
|
+
def might_be_encrypted?
|
|
317
|
+
%w[ai_settings jit_settings].include?(@path)
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
# Whether the data appears encrypted, by inspecting the actual content (non-JSON
|
|
321
|
+
# and mostly non-printable) rather than path naming conventions.
|
|
322
|
+
def has_encrypted_data?
|
|
323
|
+
decoded = get_decoded_data
|
|
324
|
+
return false if decoded.nil?
|
|
325
|
+
return false if decoded.start_with?('{') || decoded.start_with?('[')
|
|
326
|
+
|
|
327
|
+
!printable_text?(decoded)
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
# Decrypt the link data with the owning record's key (AES-256-GCM). record_key is
|
|
331
|
+
# the record's decrypted key bytes (record.record_key). Returns the decrypted
|
|
332
|
+
# string, or nil if data/key is missing or decryption fails.
|
|
333
|
+
def get_decrypted_data(record_key = nil)
|
|
334
|
+
return nil if @data.nil? || record_key.nil?
|
|
335
|
+
|
|
336
|
+
encrypted = Utils.base64_to_bytes(@data)
|
|
337
|
+
Crypto.decrypt_aes_gcm(encrypted, record_key).force_encoding('UTF-8').scrub
|
|
338
|
+
rescue StandardError
|
|
339
|
+
nil
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
# The complete link data payload, handling both plain and encrypted JSON. Plain
|
|
343
|
+
# base64 JSON parses without a key; encrypted data requires the owning record's
|
|
344
|
+
# key. Ciphertext can coincidentally start with "{" or "[", so a failed
|
|
345
|
+
# plain-JSON parse falls through to decryption rather than giving up. The returned
|
|
346
|
+
# hash preserves all fields sent by the server, including ones this SDK version
|
|
347
|
+
# doesn't know about yet.
|
|
348
|
+
def get_link_data(record_key = nil)
|
|
349
|
+
decoded = get_decoded_data
|
|
350
|
+
return nil if decoded.nil?
|
|
351
|
+
|
|
352
|
+
if decoded.start_with?('{') || decoded.start_with?('[')
|
|
353
|
+
parsed = parse_json_to_dict(decoded)
|
|
354
|
+
return parsed unless parsed.nil?
|
|
355
|
+
# Leading {/[ was coincidental ciphertext - fall through to decryption.
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
decrypted = get_decrypted_data(record_key)
|
|
359
|
+
return nil if decrypted.nil?
|
|
360
|
+
|
|
361
|
+
parse_json_to_dict(decrypted)
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
# PAM settings data from this link - only when path == "meta" (plain JSON today;
|
|
365
|
+
# the key is accepted for forward compatibility).
|
|
366
|
+
def get_meta_data(record_key = nil)
|
|
367
|
+
get_settings_for_path('meta', record_key)
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
# AI settings data from this link - only when path == "ai_settings" (encrypted
|
|
371
|
+
# under the owning record's key). Returns nil for any other path.
|
|
372
|
+
def get_ai_settings_data(record_key = nil)
|
|
373
|
+
return nil unless @path == 'ai_settings'
|
|
374
|
+
|
|
375
|
+
get_link_data(record_key)
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
# JIT settings data from this link - only when path == "jit_settings" (encrypted
|
|
379
|
+
# under the owning record's key). Returns nil for any other path.
|
|
380
|
+
def get_jit_settings_data(record_key = nil)
|
|
381
|
+
return nil unless @path == 'jit_settings'
|
|
382
|
+
|
|
383
|
+
get_link_data(record_key)
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
# Settings data for any path, current or future. Automatically detects whether the
|
|
387
|
+
# data is plain or encrypted and handles it appropriately. Returns nil when the
|
|
388
|
+
# path doesn't match or parsing fails.
|
|
389
|
+
def get_settings_for_path(settings_path, record_key = nil)
|
|
390
|
+
return nil unless @path == settings_path
|
|
391
|
+
|
|
392
|
+
get_link_data(record_key)
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
private
|
|
396
|
+
|
|
397
|
+
# Decode `data` and parse it as a JSON object, handling errors gracefully.
|
|
398
|
+
def parse_json_data
|
|
399
|
+
decoded = get_decoded_data
|
|
400
|
+
return nil if decoded.nil? || !(decoded.start_with?('{') || decoded.start_with?('['))
|
|
401
|
+
|
|
402
|
+
parsed = JSON.parse(decoded)
|
|
403
|
+
parsed.is_a?(Hash) ? parsed : nil
|
|
404
|
+
rescue JSON::ParserError
|
|
405
|
+
nil
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
# Read a strict boolean from the link data; missing or non-bool values are false.
|
|
409
|
+
# With check_allowed_settings the nested `allowedSettings` object is consulted when
|
|
410
|
+
# the key is absent at the top level (a top-level boolean wins).
|
|
411
|
+
def boolean_value(key, check_allowed_settings = false)
|
|
412
|
+
parsed = parse_json_data
|
|
413
|
+
return false if parsed.nil?
|
|
414
|
+
|
|
415
|
+
value = parsed[key]
|
|
416
|
+
return value if strict_boolean?(value)
|
|
417
|
+
|
|
418
|
+
if check_allowed_settings
|
|
419
|
+
allowed = parsed['allowedSettings']
|
|
420
|
+
if allowed.is_a?(Hash)
|
|
421
|
+
nested = allowed[key]
|
|
422
|
+
return nested if strict_boolean?(nested)
|
|
423
|
+
end
|
|
424
|
+
end
|
|
425
|
+
false
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
# Read a strict integer from the link data; strings and booleans yield nil.
|
|
429
|
+
def int_value(key)
|
|
430
|
+
parsed = parse_json_data
|
|
431
|
+
value = parsed ? parsed[key] : nil
|
|
432
|
+
return value if value.is_a?(Integer) && !strict_boolean?(value)
|
|
433
|
+
|
|
434
|
+
nil
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
def strict_boolean?(value)
|
|
438
|
+
value == true || value == false
|
|
439
|
+
end
|
|
440
|
+
|
|
441
|
+
# Parse a JSON string, returning a hash only for JSON objects.
|
|
442
|
+
def parse_json_to_dict(json_str)
|
|
443
|
+
parsed = JSON.parse(json_str)
|
|
444
|
+
parsed.is_a?(Hash) ? parsed : nil
|
|
445
|
+
rescue JSON::ParserError
|
|
446
|
+
nil
|
|
447
|
+
end
|
|
448
|
+
|
|
449
|
+
# Whether a string is mostly printable text (>90% of the first 100 chars), used to
|
|
450
|
+
# distinguish encrypted bytes from plain text.
|
|
451
|
+
def printable_text?(text)
|
|
452
|
+
return false if text.nil? || text.empty?
|
|
453
|
+
|
|
454
|
+
sample = text[0, 100]
|
|
455
|
+
printable = sample.each_char.count { |c| (c >= ' ' && c <= '~') || ["\n", "\r", "\t"].include?(c) }
|
|
456
|
+
(printable.to_f / sample.length) > 0.9
|
|
457
|
+
end
|
|
458
|
+
end
|
|
459
|
+
|
|
157
460
|
# Folder representation
|
|
158
461
|
class KeeperFolder
|
|
159
462
|
attr_accessor :uid, :name, :parent_uid, :folder_type, :folder_key, :records
|
|
@@ -179,7 +482,7 @@ module KeeperSecretsManager
|
|
|
179
482
|
|
|
180
483
|
# File attachment representation
|
|
181
484
|
class KeeperFile
|
|
182
|
-
attr_accessor :uid, :name, :title, :mime_type, :size, :data, :url
|
|
485
|
+
attr_accessor :uid, :name, :title, :mime_type, :size, :data, :url, :thumbnail_url, :last_modified, :file_key
|
|
183
486
|
|
|
184
487
|
def initialize(attrs = {})
|
|
185
488
|
@uid = attrs['fileUid'] || attrs['uid'] || attrs[:uid]
|
|
@@ -189,6 +492,9 @@ module KeeperSecretsManager
|
|
|
189
492
|
@size = attrs['size'] || attrs[:size]
|
|
190
493
|
@data = attrs['data'] || attrs[:data]
|
|
191
494
|
@url = attrs['url'] || attrs[:url]
|
|
495
|
+
@thumbnail_url = attrs['thumbnailUrl'] || attrs['thumbnail_url'] || attrs[:thumbnail_url]
|
|
496
|
+
@last_modified = attrs['lastModified'] || attrs['last_modified'] || attrs[:last_modified]
|
|
497
|
+
@file_key = attrs['fileKey'] || attrs['file_key'] || attrs[:file_key]
|
|
192
498
|
end
|
|
193
499
|
|
|
194
500
|
def to_h
|
|
@@ -204,7 +510,7 @@ module KeeperSecretsManager
|
|
|
204
510
|
|
|
205
511
|
# Response wrapper
|
|
206
512
|
class SecretsManagerResponse
|
|
207
|
-
attr_accessor :records, :folders, :app_data, :warnings, :errors, :just_bound
|
|
513
|
+
attr_accessor :records, :folders, :app_data, :warnings, :errors, :just_bound, :expires_on
|
|
208
514
|
|
|
209
515
|
def initialize(attrs = {})
|
|
210
516
|
@records = attrs[:records] || []
|
|
@@ -213,16 +519,18 @@ module KeeperSecretsManager
|
|
|
213
519
|
@warnings = attrs[:warnings] || []
|
|
214
520
|
@errors = attrs[:errors] || []
|
|
215
521
|
@just_bound = attrs[:just_bound] || false
|
|
522
|
+
@expires_on = attrs[:expires_on]
|
|
216
523
|
end
|
|
217
524
|
end
|
|
218
525
|
|
|
219
526
|
# Query options
|
|
220
527
|
class QueryOptions
|
|
221
|
-
attr_accessor :records_filter, :folders_filter
|
|
528
|
+
attr_accessor :records_filter, :folders_filter, :request_links
|
|
222
529
|
|
|
223
|
-
def initialize(records: nil, folders: nil)
|
|
530
|
+
def initialize(records: nil, folders: nil, request_links: nil)
|
|
224
531
|
@records_filter = records
|
|
225
532
|
@folders_filter = folders
|
|
533
|
+
@request_links = request_links
|
|
226
534
|
end
|
|
227
535
|
end
|
|
228
536
|
|
|
@@ -235,5 +543,15 @@ module KeeperSecretsManager
|
|
|
235
543
|
@subfolder_uid = subfolder_uid
|
|
236
544
|
end
|
|
237
545
|
end
|
|
546
|
+
|
|
547
|
+
# Update options
|
|
548
|
+
class UpdateOptions
|
|
549
|
+
attr_accessor :transaction_type, :links_to_remove
|
|
550
|
+
|
|
551
|
+
def initialize(transaction_type: 'general', links_to_remove: nil)
|
|
552
|
+
@transaction_type = transaction_type
|
|
553
|
+
@links_to_remove = links_to_remove || []
|
|
554
|
+
end
|
|
555
|
+
end
|
|
238
556
|
end
|
|
239
557
|
end
|
|
@@ -2,12 +2,10 @@ require_relative 'version'
|
|
|
2
2
|
|
|
3
3
|
module KeeperSecretsManager
|
|
4
4
|
module KeeperGlobals
|
|
5
|
-
|
|
6
|
-
CLIENT_VERSION_PREFIX = 'mr'.freeze
|
|
5
|
+
CLIENT_VERSION_PREFIX = 'mb'.freeze
|
|
7
6
|
|
|
8
|
-
# Get client version dynamically from VERSION constant
|
|
9
7
|
def self.client_version
|
|
10
|
-
"#{CLIENT_VERSION_PREFIX}
|
|
8
|
+
"#{CLIENT_VERSION_PREFIX}#{KeeperSecretsManager::VERSION}"
|
|
11
9
|
end
|
|
12
10
|
|
|
13
11
|
# Keeper public keys by ID
|
|
@@ -28,7 +26,8 @@ module KeeperSecretsManager
|
|
|
28
26
|
'14' => 'BJFF8j-dH7pDEw_U347w2CBM6xYM8Dk5fPPAktjib-opOqzvvbsER-WDHM4ONCSBf9O_obAHzCyygxmtpktDuiE',
|
|
29
27
|
'15' => 'BDKyWBvLbyZ-jMueORl3JwJnnEpCiZdN7yUvT0vOyjwpPBCDf6zfL4RWzvSkhAAFnwOni_1tQSl8dfXHbXqXsQ8',
|
|
30
28
|
'16' => 'BDXyZZnrl0tc2jdC5I61JjwkjK2kr7uet9tZjt8StTiJTAQQmnVOYBgbtP08PWDbecxnHghx3kJ8QXq1XE68y8c',
|
|
31
|
-
'17' => 'BFX68cb97m9_sweGdOVavFM3j5ot6gveg6xT4BtGahfGhKib-zdZyO9pwvv1cBda9ahkSzo1BQ4NVXp9qRyqVGU'
|
|
29
|
+
'17' => 'BFX68cb97m9_sweGdOVavFM3j5ot6gveg6xT4BtGahfGhKib-zdZyO9pwvv1cBda9ahkSzo1BQ4NVXp9qRyqVGU',
|
|
30
|
+
'18' => 'BNhngQqTT1bPKxGuB6FhbPTAeNVFl8PKGGSGo5W06xWIReutm6ix6JPivqnbvkydY-1uDQTr-5e6t70G01Bb5JA'
|
|
32
31
|
}.freeze
|
|
33
32
|
|
|
34
33
|
# Keeper servers by region
|
|
@@ -38,7 +37,8 @@ module KeeperSecretsManager
|
|
|
38
37
|
'AU' => 'keepersecurity.com.au',
|
|
39
38
|
'GOV' => 'govcloud.keepersecurity.us',
|
|
40
39
|
'JP' => 'keepersecurity.jp',
|
|
41
|
-
'CA' => 'keepersecurity.ca'
|
|
40
|
+
'CA' => 'keepersecurity.ca',
|
|
41
|
+
'IL5' => 'il5.keepersecurity.us'
|
|
42
42
|
}.freeze
|
|
43
43
|
|
|
44
44
|
# Default server (US)
|
|
@@ -11,12 +11,52 @@ module KeeperSecretsManager
|
|
|
11
11
|
@secrets_manager = secrets_manager
|
|
12
12
|
end
|
|
13
13
|
|
|
14
|
+
def get_notation_results(notation)
|
|
15
|
+
return [] if notation.nil? || !notation.is_a?(String) || notation.empty?
|
|
16
|
+
|
|
17
|
+
parsed = parse_notation(notation)
|
|
18
|
+
raise NotationError, "Invalid notation: #{notation}" if parsed.length < 3
|
|
19
|
+
|
|
20
|
+
record_token = parsed[1].text&.first
|
|
21
|
+
selector = parsed[2].text&.first
|
|
22
|
+
raise NotationError, 'Invalid notation: missing record' unless record_token
|
|
23
|
+
raise NotationError, 'Invalid notation: missing selector' unless selector
|
|
24
|
+
|
|
25
|
+
records = @secrets_manager.get_secrets([record_token])
|
|
26
|
+
if records.empty?
|
|
27
|
+
all = @secrets_manager.get_secrets
|
|
28
|
+
records = all.select { |r| r.title == record_token }
|
|
29
|
+
end
|
|
30
|
+
records = records.uniq { |r| r.uid } if records.size > 1
|
|
31
|
+
raise NotationError, "Multiple records match '#{record_token}'" if records.size > 1
|
|
32
|
+
raise NotationError, "No records match '#{record_token}'" if records.empty?
|
|
33
|
+
|
|
34
|
+
record = records.first
|
|
35
|
+
parameter = parsed[2].parameter&.first
|
|
36
|
+
index1 = parsed[2].index1&.first
|
|
37
|
+
index2 = parsed[2].index2&.first
|
|
38
|
+
|
|
39
|
+
case selector.downcase
|
|
40
|
+
when 'type'
|
|
41
|
+
record.type ? [record.type] : []
|
|
42
|
+
when 'title'
|
|
43
|
+
record.title ? [record.title] : []
|
|
44
|
+
when 'notes'
|
|
45
|
+
(record.notes && !record.notes.empty?) ? [record.notes] : []
|
|
46
|
+
when 'file'
|
|
47
|
+
notation_results_file(record, parameter, record_token)
|
|
48
|
+
when 'field', 'custom_field'
|
|
49
|
+
notation_results_field(record, parameter, index1, index2, parsed[2])
|
|
50
|
+
else
|
|
51
|
+
raise NotationError, "Invalid selector: #{selector}"
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
14
55
|
# Parse notation and return value
|
|
15
56
|
def parse(notation)
|
|
16
|
-
return nil if notation.nil?
|
|
17
|
-
|
|
18
|
-
# Validate notation format before parsing
|
|
57
|
+
return nil if notation.nil?
|
|
19
58
|
raise NotationError, 'Invalid notation format: must be a string' unless notation.is_a?(String)
|
|
59
|
+
return nil if notation.empty?
|
|
20
60
|
|
|
21
61
|
# Parse notation URI
|
|
22
62
|
begin
|
|
@@ -44,6 +84,10 @@ module KeeperSecretsManager
|
|
|
44
84
|
records = all_records.select { |r| r.title == record_token }
|
|
45
85
|
end
|
|
46
86
|
|
|
87
|
+
# Remove duplicate UIDs - shortcuts/linked records both shared to same KSM App
|
|
88
|
+
records = records.uniq { |r| r.uid } if records.size > 1
|
|
89
|
+
|
|
90
|
+
# Now check for genuine ambiguity (different records with same title)
|
|
47
91
|
raise NotationError, "Multiple records match '#{record_token}'" if records.size > 1
|
|
48
92
|
raise NotationError, "No records match '#{record_token}'" if records.empty?
|
|
49
93
|
|
|
@@ -73,6 +117,64 @@ module KeeperSecretsManager
|
|
|
73
117
|
|
|
74
118
|
private
|
|
75
119
|
|
|
120
|
+
def value_to_string(v)
|
|
121
|
+
v.is_a?(String) ? v : v.to_json
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Unlike handle_field_selector: no first-element shortcut when no index is given.
|
|
125
|
+
def notation_results_field(record, parameter, index1, index2, parsed_section)
|
|
126
|
+
raise NotationError, 'Missing required parameter for field' unless parameter
|
|
127
|
+
|
|
128
|
+
field = record.get_field(parameter)
|
|
129
|
+
raise NotationError, "Field '#{parameter}' not found" unless field
|
|
130
|
+
|
|
131
|
+
values = field['value'] || []
|
|
132
|
+
idx = parse_index(index1)
|
|
133
|
+
|
|
134
|
+
if idx == -1 && index1 && !index1.empty?
|
|
135
|
+
# index1 is a property name (e.g. [hostName])
|
|
136
|
+
if values.first.is_a?(Hash)
|
|
137
|
+
raise NotationError, "Property '#{index1}' not found" unless values.first.key?(index1)
|
|
138
|
+
|
|
139
|
+
return [value_to_string(values.first[index1])]
|
|
140
|
+
else
|
|
141
|
+
raise NotationError, 'Cannot extract property from non-object value'
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
raise NotationError, "Field index out of bounds: #{idx} >= #{values.size}" if idx != -1 && idx >= values.size
|
|
146
|
+
|
|
147
|
+
selected = idx >= 0 ? [values[idx]] : values
|
|
148
|
+
|
|
149
|
+
if index2 && !index2.empty? && index2 != '[]'
|
|
150
|
+
selected.filter_map do |v|
|
|
151
|
+
next nil unless v.is_a?(Hash) && v.key?(index2)
|
|
152
|
+
|
|
153
|
+
value_to_string(v[index2])
|
|
154
|
+
end
|
|
155
|
+
else
|
|
156
|
+
selected.map { |v| value_to_string(v) }
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def notation_results_file(record, parameter, record_token)
|
|
161
|
+
file = handle_file_selector(record, parameter, record_token)
|
|
162
|
+
|
|
163
|
+
file_hash = if file.is_a?(KeeperSecretsManager::Dto::KeeperFile)
|
|
164
|
+
{
|
|
165
|
+
'fileUid' => file.uid,
|
|
166
|
+
'url' => file.url,
|
|
167
|
+
'fileKey' => file.file_key,
|
|
168
|
+
'name' => file.name
|
|
169
|
+
}
|
|
170
|
+
else
|
|
171
|
+
file
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
content = @secrets_manager.download_file(file_hash)
|
|
175
|
+
[KeeperSecretsManager::Utils.bytes_to_url_safe_str(content['data'])]
|
|
176
|
+
end
|
|
177
|
+
|
|
76
178
|
# Handle file selector
|
|
77
179
|
def handle_file_selector(record, parameter, record_token)
|
|
78
180
|
raise NotationError, 'Missing required parameter: filename or file UID' unless parameter
|
|
@@ -6,6 +6,8 @@ module KeeperSecretsManager
|
|
|
6
6
|
# Get value with enhanced functionality
|
|
7
7
|
# This method extends the basic parse method to handle special cases
|
|
8
8
|
def get_value(notation, options = {})
|
|
9
|
+
return nil if notation.nil? || !notation.is_a?(String) || notation.empty?
|
|
10
|
+
|
|
9
11
|
value = parse(notation)
|
|
10
12
|
|
|
11
13
|
# Check if we should process special types
|
|
@@ -160,14 +160,15 @@ module KeeperSecretsManager
|
|
|
160
160
|
|
|
161
161
|
# Write atomically to avoid corruption
|
|
162
162
|
temp_file = "#{@filename}.tmp"
|
|
163
|
-
|
|
163
|
+
# Create temp file with secure permissions (0600)
|
|
164
|
+
File.open(temp_file, 'w', 0o600) do |f|
|
|
164
165
|
f.write(JSON.pretty_generate(@data))
|
|
165
166
|
end
|
|
166
167
|
|
|
167
168
|
# Move atomically
|
|
168
169
|
File.rename(temp_file, @filename)
|
|
169
170
|
|
|
170
|
-
#
|
|
171
|
+
# Ensure final file has restrictive permissions (owner read/write only)
|
|
171
172
|
File.chmod(0o600, @filename)
|
|
172
173
|
rescue StandardError => e
|
|
173
174
|
raise Error, "Failed to save config file: #{e.message}"
|