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
|
@@ -1,20 +1,23 @@
|
|
|
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
|
|
11
|
+
attr_reader :record_key # Internal - stores decrypted record key (bytes) for file upload operations
|
|
10
12
|
|
|
11
13
|
def initialize(attrs = {})
|
|
12
14
|
if attrs.is_a?(Hash)
|
|
13
15
|
# Support both raw API response and user-friendly creation
|
|
14
16
|
@uid = attrs['recordUid'] || attrs['uid'] || attrs[:uid]
|
|
15
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]
|
|
16
19
|
@revision = attrs['revision'] || attrs[:revision] || 0
|
|
17
|
-
|
|
20
|
+
|
|
18
21
|
# Handle encrypted data or direct attributes
|
|
19
22
|
if attrs['data']
|
|
20
23
|
data = attrs['data'].is_a?(String) ? JSON.parse(attrs['data']) : attrs['data']
|
|
@@ -30,70 +33,115 @@ module KeeperSecretsManager
|
|
|
30
33
|
@custom = attrs['custom'] || attrs[:custom] || []
|
|
31
34
|
@notes = attrs['notes'] || attrs[:notes] || ''
|
|
32
35
|
end
|
|
33
|
-
|
|
36
|
+
|
|
34
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
|
+
|
|
35
51
|
@data = attrs
|
|
36
52
|
end
|
|
37
|
-
|
|
53
|
+
|
|
38
54
|
# Ensure fields are always arrays of hashes
|
|
39
55
|
normalize_fields!
|
|
40
56
|
end
|
|
41
57
|
|
|
42
58
|
# Convert to hash for API submission
|
|
59
|
+
# This should match the structure of the decrypted 'data' field from server
|
|
60
|
+
# (does NOT include uid, revision, folder_uid - those are in the outer payload)
|
|
43
61
|
def to_h
|
|
44
|
-
{
|
|
45
|
-
'uid' => uid,
|
|
62
|
+
result = {
|
|
46
63
|
'title' => title,
|
|
47
64
|
'type' => type,
|
|
48
|
-
'fields' => fields
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
65
|
+
'fields' => fields
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
result['custom'] = custom unless custom.nil?
|
|
69
|
+
|
|
70
|
+
# Only include notes if present
|
|
71
|
+
result['notes'] = notes if notes && !notes.empty?
|
|
72
|
+
|
|
73
|
+
result
|
|
53
74
|
end
|
|
54
75
|
|
|
55
|
-
# Find field by type or label
|
|
56
|
-
def get_field(type_or_label
|
|
57
|
-
|
|
58
|
-
|
|
76
|
+
# Find field by type or label (searches both fields and custom arrays)
|
|
77
|
+
def get_field(type_or_label)
|
|
78
|
+
# Search in fields first
|
|
79
|
+
field = fields.find { |f| f['type'] == type_or_label || f['label'] == type_or_label }
|
|
80
|
+
return field if field
|
|
81
|
+
|
|
82
|
+
# Search in custom fields
|
|
83
|
+
custom.find { |f| f['type'] == type_or_label || f['label'] == type_or_label }
|
|
59
84
|
end
|
|
60
85
|
|
|
61
86
|
# Get field value (always returns array)
|
|
62
|
-
def get_field_value(type_or_label
|
|
63
|
-
field = get_field(type_or_label
|
|
87
|
+
def get_field_value(type_or_label)
|
|
88
|
+
field = get_field(type_or_label)
|
|
64
89
|
field ? field['value'] || [] : []
|
|
65
90
|
end
|
|
66
91
|
|
|
67
92
|
# Get single field value (first element)
|
|
68
|
-
def get_field_value_single(type_or_label
|
|
69
|
-
values = get_field_value(type_or_label
|
|
93
|
+
def get_field_value_single(type_or_label)
|
|
94
|
+
values = get_field_value(type_or_label)
|
|
70
95
|
values.first
|
|
71
96
|
end
|
|
72
97
|
|
|
73
98
|
# Add or update field
|
|
74
|
-
def set_field(type, value, label = nil
|
|
75
|
-
field_array = custom_field ? @custom : @fields
|
|
76
|
-
|
|
99
|
+
def set_field(type, value, label = nil)
|
|
77
100
|
# Ensure value is an array
|
|
78
101
|
value = [value] unless value.is_a?(Array)
|
|
79
|
-
|
|
80
|
-
# Find existing field
|
|
81
|
-
existing =
|
|
82
|
-
|
|
102
|
+
|
|
103
|
+
# Find existing field in both arrays
|
|
104
|
+
existing = @fields.find { |f| f['type'] == type || (label && f['label'] == label) }
|
|
105
|
+
existing ||= @custom.find { |f| f['type'] == type || (label && f['label'] == label) }
|
|
106
|
+
|
|
83
107
|
if existing
|
|
84
108
|
existing['value'] = value
|
|
85
109
|
existing['label'] = label if label
|
|
86
110
|
else
|
|
87
111
|
new_field = { 'type' => type, 'value' => value }
|
|
88
112
|
new_field['label'] = label if label
|
|
89
|
-
|
|
113
|
+
|
|
114
|
+
# Decide which array to add to:
|
|
115
|
+
# - If it has a label, it's a custom field
|
|
116
|
+
# - If it's not a common field type, it's likely custom
|
|
117
|
+
if label || !common_field_types.include?(type)
|
|
118
|
+
@custom << new_field
|
|
119
|
+
else
|
|
120
|
+
@fields << new_field
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
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)
|
|
90
138
|
end
|
|
91
139
|
end
|
|
92
140
|
|
|
93
141
|
# Dynamic field access methods
|
|
94
142
|
def method_missing(method, *args, &block)
|
|
95
143
|
method_name = method.to_s
|
|
96
|
-
|
|
144
|
+
|
|
97
145
|
# Handle setters
|
|
98
146
|
if method_name.end_with?('=')
|
|
99
147
|
field_name = method_name.chomp('=')
|
|
@@ -120,22 +168,295 @@ module KeeperSecretsManager
|
|
|
120
168
|
|
|
121
169
|
def normalize_field_array(fields)
|
|
122
170
|
return [] unless fields.is_a?(Array)
|
|
123
|
-
|
|
171
|
+
|
|
124
172
|
fields.map do |field|
|
|
125
173
|
next field if field.is_a?(Hash)
|
|
126
|
-
|
|
174
|
+
|
|
127
175
|
# Convert to hash if needed
|
|
128
176
|
field.to_h
|
|
129
177
|
end
|
|
130
178
|
end
|
|
131
179
|
|
|
132
180
|
def common_field_types
|
|
133
|
-
%w[login password url fileRef oneTimeCode name phone email address
|
|
134
|
-
paymentCard bankAccount birthDate secureNote sshKey host
|
|
181
|
+
%w[login password url fileRef oneTimeCode name phone email address
|
|
182
|
+
paymentCard bankAccount birthDate secureNote sshKey host
|
|
135
183
|
databaseType script passkey]
|
|
136
184
|
end
|
|
137
185
|
end
|
|
138
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
|
+
|
|
139
460
|
# Folder representation
|
|
140
461
|
class KeeperFolder
|
|
141
462
|
attr_accessor :uid, :name, :parent_uid, :folder_type, :folder_key, :records
|
|
@@ -161,7 +482,7 @@ module KeeperSecretsManager
|
|
|
161
482
|
|
|
162
483
|
# File attachment representation
|
|
163
484
|
class KeeperFile
|
|
164
|
-
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
|
|
165
486
|
|
|
166
487
|
def initialize(attrs = {})
|
|
167
488
|
@uid = attrs['fileUid'] || attrs['uid'] || attrs[:uid]
|
|
@@ -171,6 +492,9 @@ module KeeperSecretsManager
|
|
|
171
492
|
@size = attrs['size'] || attrs[:size]
|
|
172
493
|
@data = attrs['data'] || attrs[:data]
|
|
173
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]
|
|
174
498
|
end
|
|
175
499
|
|
|
176
500
|
def to_h
|
|
@@ -186,7 +510,7 @@ module KeeperSecretsManager
|
|
|
186
510
|
|
|
187
511
|
# Response wrapper
|
|
188
512
|
class SecretsManagerResponse
|
|
189
|
-
attr_accessor :records, :folders, :app_data, :warnings, :errors, :just_bound
|
|
513
|
+
attr_accessor :records, :folders, :app_data, :warnings, :errors, :just_bound, :expires_on
|
|
190
514
|
|
|
191
515
|
def initialize(attrs = {})
|
|
192
516
|
@records = attrs[:records] || []
|
|
@@ -195,16 +519,18 @@ module KeeperSecretsManager
|
|
|
195
519
|
@warnings = attrs[:warnings] || []
|
|
196
520
|
@errors = attrs[:errors] || []
|
|
197
521
|
@just_bound = attrs[:just_bound] || false
|
|
522
|
+
@expires_on = attrs[:expires_on]
|
|
198
523
|
end
|
|
199
524
|
end
|
|
200
525
|
|
|
201
526
|
# Query options
|
|
202
527
|
class QueryOptions
|
|
203
|
-
attr_accessor :records_filter, :folders_filter
|
|
528
|
+
attr_accessor :records_filter, :folders_filter, :request_links
|
|
204
529
|
|
|
205
|
-
def initialize(records: nil, folders: nil)
|
|
530
|
+
def initialize(records: nil, folders: nil, request_links: nil)
|
|
206
531
|
@records_filter = records
|
|
207
532
|
@folders_filter = folders
|
|
533
|
+
@request_links = request_links
|
|
208
534
|
end
|
|
209
535
|
end
|
|
210
536
|
|
|
@@ -217,5 +543,15 @@ module KeeperSecretsManager
|
|
|
217
543
|
@subfolder_uid = subfolder_uid
|
|
218
544
|
end
|
|
219
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
|
|
220
556
|
end
|
|
221
|
-
end
|
|
557
|
+
end
|
|
@@ -7,6 +7,7 @@ module KeeperSecretsManager
|
|
|
7
7
|
|
|
8
8
|
# Authentication/authorization errors
|
|
9
9
|
class AuthenticationError < Error; end
|
|
10
|
+
|
|
10
11
|
class AccessDeniedError < AuthenticationError; end
|
|
11
12
|
|
|
12
13
|
# API/network errors
|
|
@@ -22,7 +23,9 @@ module KeeperSecretsManager
|
|
|
22
23
|
|
|
23
24
|
# Crypto errors
|
|
24
25
|
class CryptoError < Error; end
|
|
26
|
+
|
|
25
27
|
class DecryptionError < CryptoError; end
|
|
28
|
+
|
|
26
29
|
class EncryptionError < CryptoError; end
|
|
27
30
|
|
|
28
31
|
# Notation errors
|
|
@@ -30,7 +33,9 @@ module KeeperSecretsManager
|
|
|
30
33
|
|
|
31
34
|
# Record errors
|
|
32
35
|
class RecordError < Error; end
|
|
36
|
+
|
|
33
37
|
class RecordNotFoundError < RecordError; end
|
|
38
|
+
|
|
34
39
|
class RecordValidationError < RecordError; end
|
|
35
40
|
|
|
36
41
|
# Server errors
|
|
@@ -46,13 +51,19 @@ module KeeperSecretsManager
|
|
|
46
51
|
|
|
47
52
|
# Specific server error types
|
|
48
53
|
class InvalidClientVersionError < ServerError; end
|
|
54
|
+
|
|
49
55
|
class InvalidTokenError < ServerError; end
|
|
56
|
+
|
|
50
57
|
class BadRequestError < ServerError; end
|
|
58
|
+
|
|
51
59
|
class RecordUidNotFoundError < ServerError; end
|
|
60
|
+
|
|
52
61
|
class FolderUidNotFoundError < ServerError; end
|
|
62
|
+
|
|
53
63
|
class AccessViolationError < ServerError; end
|
|
64
|
+
|
|
54
65
|
class ThrottledError < ServerError; end
|
|
55
|
-
|
|
66
|
+
|
|
56
67
|
# Error factory
|
|
57
68
|
class ErrorFactory
|
|
58
69
|
def self.from_server_response(result_code, message = nil)
|
|
@@ -76,4 +87,4 @@ module KeeperSecretsManager
|
|
|
76
87
|
end
|
|
77
88
|
end
|
|
78
89
|
end
|
|
79
|
-
end
|
|
90
|
+
end
|
|
@@ -9,7 +9,7 @@ module KeeperSecretsManager
|
|
|
9
9
|
@label = label
|
|
10
10
|
@required = required
|
|
11
11
|
@privacy_screen = privacy_screen
|
|
12
|
-
|
|
12
|
+
|
|
13
13
|
# Ensure value is always an array
|
|
14
14
|
@value = value.is_a?(Array) ? value : [value]
|
|
15
15
|
end
|
|
@@ -103,7 +103,7 @@ module KeeperSecretsManager
|
|
|
103
103
|
when String
|
|
104
104
|
(Date.parse(date).to_time.to_f * 1000).to_i
|
|
105
105
|
else
|
|
106
|
-
raise ArgumentError,
|
|
106
|
+
raise ArgumentError, 'Invalid date format'
|
|
107
107
|
end
|
|
108
108
|
Field.new(type: 'birthDate', value: timestamp, label: label)
|
|
109
109
|
end
|
|
@@ -149,4 +149,4 @@ module KeeperSecretsManager
|
|
|
149
149
|
end
|
|
150
150
|
end
|
|
151
151
|
end
|
|
152
|
-
end
|
|
152
|
+
end
|