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,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
|