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.
@@ -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
- # Add padding if needed
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
- # Maybe it's CBC encrypted?
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
- # Only include custom if it has entries (server doesn't send empty arrays)
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
- # Client version prefix - 'mb' for Ruby SDK
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}17.91.0"
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? || notation.empty?
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
- File.open(temp_file, 'w') do |f|
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
- # Set restrictive permissions (owner read/write only)
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}"