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,463 @@
1
+ require 'base64'
2
+
3
+ module KeeperSecretsManager
4
+ module Notation
5
+ # Parse and resolve keeper:// notation URIs
6
+ class Parser
7
+ ESCAPE_CHAR = '\\'.freeze
8
+ ESCAPE_CHARS = '/[]\\'.freeze # Characters that can be escaped
9
+
10
+ def initialize(secrets_manager)
11
+ @secrets_manager = secrets_manager
12
+ end
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
+
55
+ # Parse notation and return value
56
+ def parse(notation)
57
+ return nil if notation.nil?
58
+ raise NotationError, 'Invalid notation format: must be a string' unless notation.is_a?(String)
59
+ return nil if notation.empty?
60
+
61
+ # Parse notation URI
62
+ begin
63
+ parsed = parse_notation(notation)
64
+ rescue StandardError => e
65
+ raise NotationError, "Invalid notation format: #{e.message}"
66
+ end
67
+
68
+ # Validate we have minimum required sections
69
+ raise NotationError, "Invalid notation: #{notation}" if parsed.length < 3
70
+
71
+ # Extract components
72
+ record_token = parsed[1].text&.first
73
+ selector = parsed[2].text&.first
74
+
75
+ raise NotationError, 'Invalid notation: missing record' unless record_token
76
+ raise NotationError, 'Invalid notation: missing selector' unless selector
77
+
78
+ # Get record
79
+ records = @secrets_manager.get_secrets([record_token])
80
+
81
+ # If not found by UID, try by title
82
+ if records.empty?
83
+ all_records = @secrets_manager.get_secrets
84
+ records = all_records.select { |r| r.title == record_token }
85
+ end
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)
91
+ raise NotationError, "Multiple records match '#{record_token}'" if records.size > 1
92
+ raise NotationError, "No records match '#{record_token}'" if records.empty?
93
+
94
+ record = records.first
95
+
96
+ # Extract parameters
97
+ parameter = parsed[2].parameter&.first
98
+ index1 = parsed[2].index1&.first
99
+ index2 = parsed[2].index2&.first
100
+
101
+ # Process selector
102
+ case selector.downcase
103
+ when 'type'
104
+ record.type
105
+ when 'title'
106
+ record.title
107
+ when 'notes'
108
+ record.notes
109
+ when 'file'
110
+ handle_file_selector(record, parameter, record_token)
111
+ when 'field', 'custom_field'
112
+ handle_field_selector(record, selector, parameter, index1, index2, parsed[2])
113
+ else
114
+ raise NotationError, "Invalid selector: #{selector}"
115
+ end
116
+ end
117
+
118
+ private
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
+
178
+ # Handle file selector
179
+ def handle_file_selector(record, parameter, record_token)
180
+ raise NotationError, 'Missing required parameter: filename or file UID' unless parameter
181
+
182
+ if record.files.nil? || record.files.empty?
183
+ raise NotationError,
184
+ "Record #{record_token} has no file attachments"
185
+ end
186
+
187
+ # Find matching file
188
+ files = record.files.select do |f|
189
+ parameter == f.name || parameter == f.title || parameter == f.uid
190
+ end
191
+
192
+ raise NotationError, "No files match '#{parameter}'" if files.empty?
193
+ raise NotationError, "Multiple files match '#{parameter}'" if files.size > 1
194
+
195
+ # Return file object (downloading would be handled by the caller)
196
+ files.first
197
+ end
198
+
199
+ # Handle field selector
200
+ def handle_field_selector(record, selector, parameter, index1, index2, parsed_section)
201
+ raise NotationError, 'Missing required parameter for field' unless parameter
202
+
203
+ # Get field (works for both standard and custom fields)
204
+ field = record.get_field(parameter)
205
+
206
+ raise NotationError, "Field '#{parameter}' not found" unless field
207
+
208
+ # Get field values
209
+ values = field['value'] || []
210
+
211
+ # Handle index1
212
+ idx = parse_index(index1)
213
+
214
+ # If index1 is not a valid number but has a value, treat it as a property name
215
+ if idx == -1 && index1 && !index1.empty?
216
+ # index1 is a property name (e.g., [hostName])
217
+ if values.first.is_a?(Hash)
218
+ property = index1
219
+ if values.first.key?(property)
220
+ return values.first[property]
221
+ else
222
+ raise NotationError, "Property '#{property}' not found"
223
+ end
224
+ else
225
+ raise NotationError, 'Cannot extract property from non-object value'
226
+ end
227
+ end
228
+
229
+ raise NotationError, "Field index out of bounds: #{idx} >= #{values.size}" if idx >= values.size
230
+
231
+ # Apply index1
232
+ values = [values[idx]] if idx >= 0
233
+
234
+ # Handle legacy compatibility
235
+ return values.first if parsed_section.index1.nil? && parsed_section.index2.nil?
236
+
237
+ if parsed_section.index1 && parsed_section.index1[1] == '[]' &&
238
+ (index2.nil? || index2.empty?)
239
+ return values
240
+ end
241
+
242
+ return values.first[index2] if index1.to_s.empty? && !index2.to_s.empty? && values.first.is_a?(Hash)
243
+
244
+ # Handle index2 (property access)
245
+ full_obj_value = parsed_section.index2.nil? ||
246
+ parsed_section.index2[1] == '' ||
247
+ parsed_section.index2[1] == '[]'
248
+
249
+ if full_obj_value
250
+ idx >= 0 ? values.first : values
251
+ elsif values.first.is_a?(Hash)
252
+ obj_property = index2
253
+ if values.first.key?(obj_property)
254
+ values.first[obj_property]
255
+ else
256
+ raise NotationError, "Property '#{obj_property}' not found"
257
+ end
258
+ else
259
+ raise NotationError, 'Cannot extract property from non-object value'
260
+ end
261
+ end
262
+
263
+ # Parse index value
264
+ def parse_index(index_str)
265
+ return -1 if index_str.nil? || index_str.empty?
266
+
267
+ begin
268
+ Integer(index_str)
269
+ rescue ArgumentError
270
+ -1
271
+ end
272
+ end
273
+
274
+ # Parse notation URI into sections
275
+ def parse_notation(notation)
276
+ # Handle base64 encoded notation
277
+ unless notation.include?('/')
278
+ begin
279
+ decoded = Base64.urlsafe_decode64(notation)
280
+ notation = decoded.force_encoding('UTF-8')
281
+ rescue StandardError
282
+ raise NotationError, 'Invalid notation format'
283
+ end
284
+ end
285
+
286
+ # Parse sections
287
+ prefix = parse_section(notation, 'prefix', 0)
288
+ pos = prefix.present? ? prefix.end_pos + 1 : 0
289
+
290
+ record = parse_section(notation, 'record', pos)
291
+ pos = record.present? ? record.end_pos + 1 : notation.length
292
+
293
+ selector = parse_section(notation, 'selector', pos)
294
+ pos = selector.present? ? selector.end_pos + 1 : notation.length
295
+
296
+ footer = parse_section(notation, 'footer', pos)
297
+
298
+ [prefix, record, selector, footer]
299
+ end
300
+
301
+ # Parse a section of the notation
302
+ def parse_section(notation, section_name, pos)
303
+ result = NotationSection.new(section_name)
304
+ result.start_pos = pos
305
+
306
+ case section_name.downcase
307
+ when 'prefix'
308
+ # Check for keeper:// prefix
309
+ prefix = "#{Core::SecretsManager::NOTATION_PREFIX}://"
310
+ if notation.downcase.start_with?(prefix.downcase)
311
+ result.present = true
312
+ result.start_pos = 0
313
+ result.end_pos = prefix.length - 1
314
+ result.text = [notation[0...prefix.length], notation[0...prefix.length]]
315
+ end
316
+
317
+ when 'footer'
318
+ # Footer is anything after the last section
319
+ if pos < notation.length
320
+ result.present = true
321
+ result.start_pos = pos
322
+ result.end_pos = notation.length - 1
323
+ result.text = [notation[pos..], notation[pos..]]
324
+ end
325
+
326
+ when 'record'
327
+ # Record is required - parse until '/' with escaping
328
+ if pos < notation.length
329
+ parsed = parse_subsection(notation, pos, '/', true)
330
+ if parsed
331
+ result.present = true
332
+ result.start_pos = pos
333
+ result.end_pos = pos + parsed[1].length - 1
334
+ result.text = parsed
335
+ end
336
+ end
337
+
338
+ when 'selector'
339
+ # Selector is required
340
+ if pos < notation.length
341
+ parsed = parse_subsection(notation, pos, '/', false)
342
+ if parsed
343
+ result.present = true
344
+ result.start_pos = pos
345
+ result.end_pos = pos + parsed[1].length - 1
346
+ result.text = parsed
347
+
348
+ # Check for long selectors that have parameters
349
+ if %w[field custom_field file].include?(parsed[0].downcase)
350
+ # Parse parameter (field type/label or filename)
351
+ param_parsed = parse_subsection(notation, result.end_pos + 1, '[', true)
352
+ if param_parsed
353
+ result.parameter = param_parsed
354
+ plen = param_parsed[1].length
355
+ plen -= 1 if param_parsed[1].end_with?('[') && !param_parsed[1].end_with?('\\[')
356
+ result.end_pos += plen
357
+
358
+ # Parse index1 [N] or []
359
+ index1_parsed = parse_subsection(notation, result.end_pos + 1, '[]', true)
360
+ if index1_parsed
361
+ result.index1 = index1_parsed
362
+ result.end_pos += index1_parsed[1].length
363
+
364
+ # Parse index2 [property]
365
+ index2_parsed = parse_subsection(notation, result.end_pos + 1, '[]', true)
366
+ if index2_parsed
367
+ result.index2 = index2_parsed
368
+ result.end_pos += index2_parsed[1].length
369
+ end
370
+ end
371
+ end
372
+ end
373
+ end
374
+ end
375
+
376
+ else
377
+ raise NotationError, "Unknown section: #{section_name}"
378
+ end
379
+
380
+ result
381
+ end
382
+
383
+ # Parse subsection with delimiters and escaping
384
+ def parse_subsection(text, pos, delimiters, escaped = false)
385
+ return nil if text.nil? || text.empty? || pos < 0 || pos >= text.length
386
+
387
+ raise NotationError, 'Internal error: incorrect delimiters' if delimiters.nil? || delimiters.length > 2
388
+
389
+ token = ''
390
+ raw = ''
391
+
392
+ while pos < text.length
393
+ if escaped && text[pos] == ESCAPE_CHAR
394
+ # Handle escape sequence
395
+ if pos + 1 >= text.length || !ESCAPE_CHARS.include?(text[pos + 1])
396
+ raise NotationError, "Incorrect escape sequence at position #{pos}"
397
+ end
398
+
399
+ token += text[pos + 1]
400
+ raw += text[pos, 2]
401
+ pos += 2
402
+ else
403
+ raw += text[pos]
404
+
405
+ if delimiters.length == 1
406
+ # Single delimiter
407
+ break if text[pos] == delimiters[0]
408
+
409
+ token += text[pos]
410
+ else
411
+ # Two delimiters (for brackets)
412
+ raise NotationError, "Index sections must start with '['" if raw[0] != delimiters[0]
413
+
414
+ if raw.length > 1 && text[pos] == delimiters[0]
415
+ raise NotationError, "Index sections do not allow extra '[' inside"
416
+ end
417
+
418
+ if !delimiters.include?(text[pos])
419
+ token += text[pos]
420
+ elsif text[pos] == delimiters[1]
421
+ break
422
+ end
423
+ end
424
+
425
+ pos += 1
426
+ end
427
+ end
428
+
429
+ # Validate brackets are properly closed
430
+ if delimiters.length == 2
431
+ if raw.length < 2 || raw[0] != delimiters[0] || raw[-1] != delimiters[1]
432
+ raise NotationError, "Index sections must be enclosed in '[' and ']'"
433
+ end
434
+
435
+ raise NotationError, "Index sections must be enclosed in '[' and ']'" if escaped && raw[-2] == ESCAPE_CHAR
436
+ end
437
+
438
+ [token, raw]
439
+ end
440
+
441
+ # Notation section data class
442
+ class NotationSection
443
+ attr_accessor :section, :present, :start_pos, :end_pos,
444
+ :text, :parameter, :index1, :index2
445
+
446
+ def initialize(section_name)
447
+ @section = section_name
448
+ @present = false
449
+ @start_pos = -1
450
+ @end_pos = -1
451
+ @text = nil
452
+ @parameter = nil
453
+ @index1 = nil
454
+ @index2 = nil
455
+ end
456
+
457
+ def present?
458
+ @present
459
+ end
460
+ end
461
+ end
462
+ end
463
+ end
@@ -0,0 +1,67 @@
1
+ # Enhanced notation functionality for files and TOTP
2
+
3
+ module KeeperSecretsManager
4
+ module Notation
5
+ class Parser
6
+ # Get value with enhanced functionality
7
+ # This method extends the basic parse method to handle special cases
8
+ def get_value(notation, options = {})
9
+ return nil if notation.nil? || !notation.is_a?(String) || notation.empty?
10
+
11
+ value = parse(notation)
12
+
13
+ # Check if we should process special types
14
+ return value unless options[:auto_process]
15
+
16
+ # Parse the notation to understand what we're dealing with
17
+ parsed = parse_notation(notation)
18
+ return value if parsed.length < 3
19
+
20
+ selector = parsed[2].text&.first
21
+ return value unless selector
22
+
23
+ case selector.downcase
24
+ when 'file'
25
+ # If it's a file and auto_download is enabled, download it
26
+ if options[:auto_download] && value.is_a?(Hash) && value['fileUid']
27
+ begin
28
+ file_data = @secrets_manager.download_file(value['fileUid'])
29
+ return file_data['data'] # Return file content
30
+ rescue StandardError => e
31
+ raise NotationError, "Failed to download file: #{e.message}"
32
+ end
33
+ end
34
+
35
+ when 'field'
36
+ # Check if it's a TOTP field
37
+ parameter = parsed[2].parameter&.first
38
+ if parameter && parameter.downcase == 'onetimecode' && value.is_a?(String) && value.start_with?('otpauth://') && (options[:generate_totp_code])
39
+ begin
40
+ totp_params = TOTP.parse_url(value)
41
+ return TOTP.generate_code(
42
+ totp_params['secret'],
43
+ algorithm: totp_params['algorithm'],
44
+ digits: totp_params['digits'],
45
+ period: totp_params['period']
46
+ )
47
+ rescue StandardError => e
48
+ raise NotationError, "Failed to generate TOTP code: #{e.message}"
49
+ end
50
+ end
51
+ end
52
+
53
+ value
54
+ end
55
+
56
+ # Convenience method to get TOTP code directly
57
+ def get_totp_code(notation)
58
+ get_value(notation, auto_process: true, generate_totp_code: true)
59
+ end
60
+
61
+ # Convenience method to download file content directly
62
+ def download_file(notation)
63
+ get_value(notation, auto_process: true, auto_download: true)
64
+ end
65
+ end
66
+ end
67
+ end