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.
@@ -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