keeper_secrets_manager 17.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.rspec +3 -0
- data/.ruby-version +1 -0
- data/CHANGELOG.md +139 -0
- data/Gemfile +16 -0
- data/LICENSE +21 -0
- data/README.md +113 -0
- data/Rakefile +30 -0
- data/bin/console +47 -0
- data/keeper_secrets_manager.gemspec +36 -0
- data/lib/keeper_secrets_manager/cache.rb +139 -0
- data/lib/keeper_secrets_manager/config_keys.rb +29 -0
- data/lib/keeper_secrets_manager/core.rb +1781 -0
- data/lib/keeper_secrets_manager/crypto.rb +333 -0
- data/lib/keeper_secrets_manager/dto/payload.rb +153 -0
- data/lib/keeper_secrets_manager/dto.rb +557 -0
- data/lib/keeper_secrets_manager/errors.rb +90 -0
- data/lib/keeper_secrets_manager/field_types.rb +152 -0
- data/lib/keeper_secrets_manager/folder_manager.rb +110 -0
- data/lib/keeper_secrets_manager/keeper_globals.rb +53 -0
- data/lib/keeper_secrets_manager/notation.rb +463 -0
- data/lib/keeper_secrets_manager/notation_enhancements.rb +67 -0
- data/lib/keeper_secrets_manager/storage.rb +254 -0
- data/lib/keeper_secrets_manager/totp.rb +140 -0
- data/lib/keeper_secrets_manager/utils.rb +263 -0
- data/lib/keeper_secrets_manager/version.rb +3 -0
- data/lib/keeper_secrets_manager.rb +46 -0
- metadata +102 -0
|
@@ -0,0 +1,557 @@
|
|
|
1
|
+
require 'json'
|
|
2
|
+
require_relative 'dto/payload'
|
|
3
|
+
require_relative 'utils'
|
|
4
|
+
require_relative 'crypto'
|
|
5
|
+
|
|
6
|
+
module KeeperSecretsManager
|
|
7
|
+
module Dto
|
|
8
|
+
# Base class for dynamic record handling
|
|
9
|
+
class KeeperRecord
|
|
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
|
|
12
|
+
|
|
13
|
+
def initialize(attrs = {})
|
|
14
|
+
if attrs.is_a?(Hash)
|
|
15
|
+
# Support both raw API response and user-friendly creation
|
|
16
|
+
@uid = attrs['recordUid'] || attrs['uid'] || attrs[:uid]
|
|
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]
|
|
19
|
+
@revision = attrs['revision'] || attrs[:revision] || 0
|
|
20
|
+
|
|
21
|
+
# Handle encrypted data or direct attributes
|
|
22
|
+
if attrs['data']
|
|
23
|
+
data = attrs['data'].is_a?(String) ? JSON.parse(attrs['data']) : attrs['data']
|
|
24
|
+
@title = data['title'] || ''
|
|
25
|
+
@type = data['type'] || 'login'
|
|
26
|
+
@fields = data['fields'] || []
|
|
27
|
+
@custom = data['custom'] || []
|
|
28
|
+
@notes = data['notes'] || ''
|
|
29
|
+
else
|
|
30
|
+
@title = attrs['title'] || attrs[:title] || ''
|
|
31
|
+
@type = attrs['type'] || attrs[:type] || 'login'
|
|
32
|
+
@fields = attrs['fields'] || attrs[:fields] || []
|
|
33
|
+
@custom = attrs['custom'] || attrs[:custom] || []
|
|
34
|
+
@notes = attrs['notes'] || attrs[:notes] || ''
|
|
35
|
+
end
|
|
36
|
+
|
|
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
|
+
|
|
51
|
+
@data = attrs
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Ensure fields are always arrays of hashes
|
|
55
|
+
normalize_fields!
|
|
56
|
+
end
|
|
57
|
+
|
|
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)
|
|
61
|
+
def to_h
|
|
62
|
+
result = {
|
|
63
|
+
'title' => title,
|
|
64
|
+
'type' => type,
|
|
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
|
|
74
|
+
end
|
|
75
|
+
|
|
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 }
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Get field value (always returns array)
|
|
87
|
+
def get_field_value(type_or_label)
|
|
88
|
+
field = get_field(type_or_label)
|
|
89
|
+
field ? field['value'] || [] : []
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Get single field value (first element)
|
|
93
|
+
def get_field_value_single(type_or_label)
|
|
94
|
+
values = get_field_value(type_or_label)
|
|
95
|
+
values.first
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Add or update field
|
|
99
|
+
def set_field(type, value, label = nil)
|
|
100
|
+
# Ensure value is an array
|
|
101
|
+
value = [value] unless value.is_a?(Array)
|
|
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
|
+
|
|
107
|
+
if existing
|
|
108
|
+
existing['value'] = value
|
|
109
|
+
existing['label'] = label if label
|
|
110
|
+
else
|
|
111
|
+
new_field = { 'type' => type, 'value' => value }
|
|
112
|
+
new_field['label'] = label if label
|
|
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)
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Dynamic field access methods
|
|
142
|
+
def method_missing(method, *args, &block)
|
|
143
|
+
method_name = method.to_s
|
|
144
|
+
|
|
145
|
+
# Handle setters
|
|
146
|
+
if method_name.end_with?('=')
|
|
147
|
+
field_name = method_name.chomp('=')
|
|
148
|
+
set_field(field_name, args.first)
|
|
149
|
+
# Handle getters
|
|
150
|
+
elsif common_field_types.include?(method_name)
|
|
151
|
+
get_field_value_single(method_name)
|
|
152
|
+
else
|
|
153
|
+
super
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def respond_to_missing?(method, include_private = false)
|
|
158
|
+
method_name = method.to_s.chomp('=')
|
|
159
|
+
common_field_types.include?(method_name) || super
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
private
|
|
163
|
+
|
|
164
|
+
def normalize_fields!
|
|
165
|
+
@fields = normalize_field_array(@fields)
|
|
166
|
+
@custom = normalize_field_array(@custom)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def normalize_field_array(fields)
|
|
170
|
+
return [] unless fields.is_a?(Array)
|
|
171
|
+
|
|
172
|
+
fields.map do |field|
|
|
173
|
+
next field if field.is_a?(Hash)
|
|
174
|
+
|
|
175
|
+
# Convert to hash if needed
|
|
176
|
+
field.to_h
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def common_field_types
|
|
181
|
+
%w[login password url fileRef oneTimeCode name phone email address
|
|
182
|
+
paymentCard bankAccount birthDate secureNote sshKey host
|
|
183
|
+
databaseType script passkey]
|
|
184
|
+
end
|
|
185
|
+
end
|
|
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
|
+
|
|
460
|
+
# Folder representation
|
|
461
|
+
class KeeperFolder
|
|
462
|
+
attr_accessor :uid, :name, :parent_uid, :folder_type, :folder_key, :records
|
|
463
|
+
|
|
464
|
+
def initialize(attrs = {})
|
|
465
|
+
@uid = attrs['folderUid'] || attrs['uid'] || attrs[:uid]
|
|
466
|
+
@name = attrs['name'] || attrs[:name]
|
|
467
|
+
@parent_uid = attrs['parentUid'] || attrs['parent_uid'] || attrs[:parent_uid] || attrs['parent']
|
|
468
|
+
@folder_type = attrs['folderType'] || attrs['folder_type'] || attrs[:folder_type] || 'user_folder'
|
|
469
|
+
@folder_key = attrs['folderKey'] || attrs['folder_key'] || attrs[:folder_key]
|
|
470
|
+
@records = attrs['records'] || attrs[:records] || []
|
|
471
|
+
end
|
|
472
|
+
|
|
473
|
+
def to_h
|
|
474
|
+
{
|
|
475
|
+
'folderUid' => uid,
|
|
476
|
+
'name' => name,
|
|
477
|
+
'parentUid' => parent_uid,
|
|
478
|
+
'folderType' => folder_type
|
|
479
|
+
}.compact
|
|
480
|
+
end
|
|
481
|
+
end
|
|
482
|
+
|
|
483
|
+
# File attachment representation
|
|
484
|
+
class KeeperFile
|
|
485
|
+
attr_accessor :uid, :name, :title, :mime_type, :size, :data, :url, :thumbnail_url, :last_modified, :file_key
|
|
486
|
+
|
|
487
|
+
def initialize(attrs = {})
|
|
488
|
+
@uid = attrs['fileUid'] || attrs['uid'] || attrs[:uid]
|
|
489
|
+
@name = attrs['name'] || attrs[:name]
|
|
490
|
+
@title = attrs['title'] || attrs[:title] || @name
|
|
491
|
+
@mime_type = attrs['mimeType'] || attrs['mime_type'] || attrs[:mime_type]
|
|
492
|
+
@size = attrs['size'] || attrs[:size]
|
|
493
|
+
@data = attrs['data'] || attrs[:data]
|
|
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]
|
|
498
|
+
end
|
|
499
|
+
|
|
500
|
+
def to_h
|
|
501
|
+
{
|
|
502
|
+
'fileUid' => uid,
|
|
503
|
+
'name' => name,
|
|
504
|
+
'title' => title,
|
|
505
|
+
'mimeType' => mime_type,
|
|
506
|
+
'size' => size
|
|
507
|
+
}.compact
|
|
508
|
+
end
|
|
509
|
+
end
|
|
510
|
+
|
|
511
|
+
# Response wrapper
|
|
512
|
+
class SecretsManagerResponse
|
|
513
|
+
attr_accessor :records, :folders, :app_data, :warnings, :errors, :just_bound, :expires_on
|
|
514
|
+
|
|
515
|
+
def initialize(attrs = {})
|
|
516
|
+
@records = attrs[:records] || []
|
|
517
|
+
@folders = attrs[:folders] || []
|
|
518
|
+
@app_data = attrs[:app_data] || {}
|
|
519
|
+
@warnings = attrs[:warnings] || []
|
|
520
|
+
@errors = attrs[:errors] || []
|
|
521
|
+
@just_bound = attrs[:just_bound] || false
|
|
522
|
+
@expires_on = attrs[:expires_on]
|
|
523
|
+
end
|
|
524
|
+
end
|
|
525
|
+
|
|
526
|
+
# Query options
|
|
527
|
+
class QueryOptions
|
|
528
|
+
attr_accessor :records_filter, :folders_filter, :request_links
|
|
529
|
+
|
|
530
|
+
def initialize(records: nil, folders: nil, request_links: nil)
|
|
531
|
+
@records_filter = records
|
|
532
|
+
@folders_filter = folders
|
|
533
|
+
@request_links = request_links
|
|
534
|
+
end
|
|
535
|
+
end
|
|
536
|
+
|
|
537
|
+
# Create options
|
|
538
|
+
class CreateOptions
|
|
539
|
+
attr_accessor :folder_uid, :subfolder_uid
|
|
540
|
+
|
|
541
|
+
def initialize(folder_uid: nil, subfolder_uid: nil)
|
|
542
|
+
@folder_uid = folder_uid
|
|
543
|
+
@subfolder_uid = subfolder_uid
|
|
544
|
+
end
|
|
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
|
|
556
|
+
end
|
|
557
|
+
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
module KeeperSecretsManager
|
|
2
|
+
# Base error class for all KSM errors
|
|
3
|
+
class Error < StandardError; end
|
|
4
|
+
|
|
5
|
+
# Configuration errors
|
|
6
|
+
class ConfigurationError < Error; end
|
|
7
|
+
|
|
8
|
+
# Authentication/authorization errors
|
|
9
|
+
class AuthenticationError < Error; end
|
|
10
|
+
|
|
11
|
+
class AccessDeniedError < AuthenticationError; end
|
|
12
|
+
|
|
13
|
+
# API/network errors
|
|
14
|
+
class NetworkError < Error
|
|
15
|
+
attr_reader :status_code, :response_body
|
|
16
|
+
|
|
17
|
+
def initialize(message, status_code: nil, response_body: nil)
|
|
18
|
+
super(message)
|
|
19
|
+
@status_code = status_code
|
|
20
|
+
@response_body = response_body
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Crypto errors
|
|
25
|
+
class CryptoError < Error; end
|
|
26
|
+
|
|
27
|
+
class DecryptionError < CryptoError; end
|
|
28
|
+
|
|
29
|
+
class EncryptionError < CryptoError; end
|
|
30
|
+
|
|
31
|
+
# Notation errors
|
|
32
|
+
class NotationError < Error; end
|
|
33
|
+
|
|
34
|
+
# Record errors
|
|
35
|
+
class RecordError < Error; end
|
|
36
|
+
|
|
37
|
+
class RecordNotFoundError < RecordError; end
|
|
38
|
+
|
|
39
|
+
class RecordValidationError < RecordError; end
|
|
40
|
+
|
|
41
|
+
# Server errors
|
|
42
|
+
class ServerError < Error
|
|
43
|
+
attr_reader :result_code, :message
|
|
44
|
+
|
|
45
|
+
def initialize(result_code, message = nil)
|
|
46
|
+
@result_code = result_code
|
|
47
|
+
@message = message || "Server error: #{result_code}"
|
|
48
|
+
super(@message)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Specific server error types
|
|
53
|
+
class InvalidClientVersionError < ServerError; end
|
|
54
|
+
|
|
55
|
+
class InvalidTokenError < ServerError; end
|
|
56
|
+
|
|
57
|
+
class BadRequestError < ServerError; end
|
|
58
|
+
|
|
59
|
+
class RecordUidNotFoundError < ServerError; end
|
|
60
|
+
|
|
61
|
+
class FolderUidNotFoundError < ServerError; end
|
|
62
|
+
|
|
63
|
+
class AccessViolationError < ServerError; end
|
|
64
|
+
|
|
65
|
+
class ThrottledError < ServerError; end
|
|
66
|
+
|
|
67
|
+
# Error factory
|
|
68
|
+
class ErrorFactory
|
|
69
|
+
def self.from_server_response(result_code, message = nil)
|
|
70
|
+
case result_code
|
|
71
|
+
when 'invalid_client_version'
|
|
72
|
+
InvalidClientVersionError.new(result_code, message)
|
|
73
|
+
when 'invalid_client', 'invalid_token'
|
|
74
|
+
InvalidTokenError.new(result_code, message)
|
|
75
|
+
when 'bad_request'
|
|
76
|
+
BadRequestError.new(result_code, message)
|
|
77
|
+
when 'record_uid_not_found'
|
|
78
|
+
RecordUidNotFoundError.new(result_code, message)
|
|
79
|
+
when 'folder_uid_not_found'
|
|
80
|
+
FolderUidNotFoundError.new(result_code, message)
|
|
81
|
+
when 'access_violation'
|
|
82
|
+
AccessViolationError.new(result_code, message)
|
|
83
|
+
when 'throttled'
|
|
84
|
+
ThrottledError.new(result_code, message)
|
|
85
|
+
else
|
|
86
|
+
ServerError.new(result_code, message)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|