keeper_secrets_manager 17.0.4 → 17.1.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.
Files changed (36) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +26 -15
  3. data/Gemfile +3 -3
  4. data/README.md +1 -1
  5. data/Rakefile +1 -1
  6. data/lib/keeper_secrets_manager/config_keys.rb +2 -2
  7. data/lib/keeper_secrets_manager/core.rb +594 -394
  8. data/lib/keeper_secrets_manager/crypto.rb +106 -113
  9. data/lib/keeper_secrets_manager/dto/payload.rb +4 -4
  10. data/lib/keeper_secrets_manager/dto.rb +50 -32
  11. data/lib/keeper_secrets_manager/errors.rb +13 -2
  12. data/lib/keeper_secrets_manager/field_types.rb +3 -3
  13. data/lib/keeper_secrets_manager/folder_manager.rb +25 -29
  14. data/lib/keeper_secrets_manager/keeper_globals.rb +9 -15
  15. data/lib/keeper_secrets_manager/notation.rb +99 -92
  16. data/lib/keeper_secrets_manager/notation_enhancements.rb +22 -24
  17. data/lib/keeper_secrets_manager/storage.rb +35 -36
  18. data/lib/keeper_secrets_manager/totp.rb +27 -27
  19. data/lib/keeper_secrets_manager/utils.rb +83 -17
  20. data/lib/keeper_secrets_manager/version.rb +2 -2
  21. data/lib/keeper_secrets_manager.rb +3 -3
  22. metadata +7 -21
  23. data/DEVELOPER_SETUP.md +0 -0
  24. data/MANUAL_TESTING_GUIDE.md +0 -332
  25. data/RUBY_SDK_COMPLETE_DOCUMENTATION.md +0 -354
  26. data/RUBY_SDK_COMPREHENSIVE_SUMMARY.md +0 -192
  27. data/examples/01_quick_start.rb +0 -45
  28. data/examples/02_authentication.rb +0 -82
  29. data/examples/03_retrieve_secrets.rb +0 -81
  30. data/examples/04_create_update_delete.rb +0 -104
  31. data/examples/05_field_types.rb +0 -135
  32. data/examples/06_files.rb +0 -137
  33. data/examples/07_folders.rb +0 -145
  34. data/examples/08_notation.rb +0 -103
  35. data/examples/09_totp.rb +0 -100
  36. data/examples/README.md +0 -89
@@ -3,72 +3,70 @@ module KeeperSecretsManager
3
3
  def initialize(folders)
4
4
  @folders = folders
5
5
  end
6
-
6
+
7
7
  # Build a hierarchical tree structure from flat folder list
8
8
  def build_folder_tree
9
9
  # Create a hash for quick lookup
10
10
  folder_map = {}
11
11
  @folders.each { |f| folder_map[f.uid] = f }
12
-
12
+
13
13
  # Find root folders (no parent) and build tree
14
14
  root_folders = []
15
15
  @folders.each do |folder|
16
- if folder.parent_uid.nil? || folder.parent_uid.empty?
17
- root_folders << build_node(folder, folder_map)
18
- end
16
+ root_folders << build_node(folder, folder_map) if folder.parent_uid.nil? || folder.parent_uid.empty?
19
17
  end
20
-
18
+
21
19
  root_folders
22
20
  end
23
-
21
+
24
22
  # Get folder path from root to given folder
25
23
  def get_folder_path(folder_uid)
26
24
  folder = @folders.find { |f| f.uid == folder_uid }
27
25
  return nil unless folder
28
-
26
+
29
27
  path = []
30
28
  current = folder
31
-
29
+
32
30
  # Walk up the tree
33
31
  while current
34
32
  path.unshift(current.name)
35
33
  current = @folders.find { |f| f.uid == current.parent_uid }
36
34
  end
37
-
35
+
38
36
  path.join('/')
39
37
  end
40
-
38
+
41
39
  # Get all ancestors of a folder (parent, grandparent, etc.)
42
40
  def get_ancestors(folder_uid)
43
41
  ancestors = []
44
42
  folder = @folders.find { |f| f.uid == folder_uid }
45
43
  return ancestors unless folder
46
-
44
+
47
45
  current_parent_uid = folder.parent_uid
48
46
  while current_parent_uid && !current_parent_uid.empty?
49
47
  parent = @folders.find { |f| f.uid == current_parent_uid }
50
48
  break unless parent
51
-
49
+
52
50
  ancestors << parent
53
51
  current_parent_uid = parent.parent_uid
54
52
  end
55
-
53
+
56
54
  ancestors
57
55
  end
58
-
56
+
59
57
  # Get all descendants of a folder (children, grandchildren, etc.)
60
58
  def get_descendants(folder_uid)
61
59
  descendants = []
62
60
  children = @folders.select { |f| f.parent_uid == folder_uid }
63
-
61
+
64
62
  children.each do |child|
65
63
  descendants << child
66
64
  descendants.concat(get_descendants(child.uid))
67
65
  end
68
-
66
+
69
67
  descendants
70
68
  end
71
-
69
+
72
70
  # Find folder by name (optionally within a parent)
73
71
  def find_folder_by_name(name, parent_uid: nil)
74
72
  if parent_uid
@@ -77,11 +75,11 @@ module KeeperSecretsManager
77
75
  @folders.find { |f| f.name == name }
78
76
  end
79
77
  end
80
-
78
+
81
79
  # Print folder tree to console
82
80
  def print_tree(folders = nil, indent = 0)
83
81
  folders ||= build_folder_tree
84
-
82
+
85
83
  folders.each do |node|
86
84
  puts "#{' ' * indent}├── #{node[:folder].name} (#{node[:folder].uid})"
87
85
  if node[:folder].records && !node[:folder].records.empty?
@@ -92,23 +90,21 @@ module KeeperSecretsManager
92
90
  print_tree(node[:children], indent + 4) if node[:children]
93
91
  end
94
92
  end
95
-
93
+
96
94
  private
97
-
95
+
98
96
  def build_node(folder, folder_map)
99
- node = {
97
+ node = {
100
98
  folder: folder,
101
99
  children: []
102
100
  }
103
-
101
+
104
102
  # Find children
105
103
  @folders.each do |f|
106
- if f.parent_uid == folder.uid
107
- node[:children] << build_node(f, folder_map)
108
- end
104
+ node[:children] << build_node(f, folder_map) if f.parent_uid == folder.uid
109
105
  end
110
-
106
+
111
107
  node
112
108
  end
113
109
  end
114
- end
110
+ end
@@ -2,20 +2,14 @@ require_relative 'version'
2
2
 
3
3
  module KeeperSecretsManager
4
4
  module KeeperGlobals
5
- # Client version prefix
6
- # **NOTE: 'mb' client version is NOT YET REGISTERED with Keeper servers!**
7
- # **TODO: Register 'mb' (Ruby) client with Keeper before production use**
8
- # **Currently using 'mr' which is already registered (likely Rust SDK)**
9
- CLIENT_VERSION_PREFIX = 'mr'.freeze # Should be 'mb' for Ruby, but using 'mr' temporarily
10
-
11
- # Get client version
5
+ # Client version prefix - 'mb' for Ruby SDK
6
+ CLIENT_VERSION_PREFIX = 'mr'.freeze
7
+
8
+ # Get client version dynamically from VERSION constant
12
9
  def self.client_version
13
- # Use standard version format matching other SDKs
14
- # Java: mj17.0.0, Python: mp16.x.x, JavaScript: ms16.x.x, Go: mg16.x.x
15
- # Ruby should be: mb17.0.0 (but not registered yet)
16
- "#{CLIENT_VERSION_PREFIX}17.0.0"
10
+ "#{CLIENT_VERSION_PREFIX}17.91.0"
17
11
  end
18
-
12
+
19
13
  # Keeper public keys by ID
20
14
  KEEPER_PUBLIC_KEYS = {
21
15
  '1' => 'BK9w6TZFxE6nFNbMfIpULCup2a8xc6w2tUTABjxny7yFmxW0dAEojwC6j6zb5nTlmb1dAx8nwo3qF7RPYGmloRM',
@@ -49,11 +43,11 @@ module KeeperSecretsManager
49
43
 
50
44
  # Default server (US)
51
45
  DEFAULT_SERVER = KEEPER_SERVERS['US'].freeze
52
-
46
+
53
47
  # Default public key ID
54
48
  DEFAULT_KEY_ID = '7'.freeze
55
-
49
+
56
50
  # Logger name
57
51
  LOGGER_NAME = 'keeper_secrets_manager'.freeze
58
52
  end
59
- end
53
+ end
@@ -14,39 +14,46 @@ module KeeperSecretsManager
14
14
  # Parse notation and return value
15
15
  def parse(notation)
16
16
  return nil if notation.nil? || notation.empty?
17
-
17
+
18
+ # Validate notation format before parsing
19
+ raise NotationError, 'Invalid notation format: must be a string' unless notation.is_a?(String)
20
+
18
21
  # Parse notation URI
19
- parsed = parse_notation(notation)
20
-
22
+ begin
23
+ parsed = parse_notation(notation)
24
+ rescue StandardError => e
25
+ raise NotationError, "Invalid notation format: #{e.message}"
26
+ end
27
+
21
28
  # Validate we have minimum required sections
22
29
  raise NotationError, "Invalid notation: #{notation}" if parsed.length < 3
23
-
30
+
24
31
  # Extract components
25
32
  record_token = parsed[1].text&.first
26
33
  selector = parsed[2].text&.first
27
-
28
- raise NotationError, "Invalid notation: missing record" unless record_token
29
- raise NotationError, "Invalid notation: missing selector" unless selector
30
-
34
+
35
+ raise NotationError, 'Invalid notation: missing record' unless record_token
36
+ raise NotationError, 'Invalid notation: missing selector' unless selector
37
+
31
38
  # Get record
32
39
  records = @secrets_manager.get_secrets([record_token])
33
-
40
+
34
41
  # If not found by UID, try by title
35
42
  if records.empty?
36
43
  all_records = @secrets_manager.get_secrets
37
44
  records = all_records.select { |r| r.title == record_token }
38
45
  end
39
-
46
+
40
47
  raise NotationError, "Multiple records match '#{record_token}'" if records.size > 1
41
48
  raise NotationError, "No records match '#{record_token}'" if records.empty?
42
-
49
+
43
50
  record = records.first
44
-
51
+
45
52
  # Extract parameters
46
53
  parameter = parsed[2].parameter&.first
47
54
  index1 = parsed[2].index1&.first
48
55
  index2 = parsed[2].index2&.first
49
-
56
+
50
57
  # Process selector
51
58
  case selector.downcase
52
59
  when 'type'
@@ -68,70 +75,75 @@ module KeeperSecretsManager
68
75
 
69
76
  # Handle file selector
70
77
  def handle_file_selector(record, parameter, record_token)
71
- raise NotationError, "Missing required parameter: filename or file UID" unless parameter
72
- raise NotationError, "Record #{record_token} has no file attachments" if record.files.nil? || record.files.empty?
73
-
78
+ raise NotationError, 'Missing required parameter: filename or file UID' unless parameter
79
+
80
+ if record.files.nil? || record.files.empty?
81
+ raise NotationError,
82
+ "Record #{record_token} has no file attachments"
83
+ end
84
+
74
85
  # Find matching file
75
86
  files = record.files.select do |f|
76
87
  parameter == f.name || parameter == f.title || parameter == f.uid
77
88
  end
78
-
89
+
79
90
  raise NotationError, "No files match '#{parameter}'" if files.empty?
80
91
  raise NotationError, "Multiple files match '#{parameter}'" if files.size > 1
81
-
92
+
82
93
  # Return file object (downloading would be handled by the caller)
83
94
  files.first
84
95
  end
85
96
 
86
97
  # Handle field selector
87
98
  def handle_field_selector(record, selector, parameter, index1, index2, parsed_section)
88
- raise NotationError, "Missing required parameter for field" unless parameter
89
-
90
- # Get field array
91
- custom_field = selector.downcase == 'custom_field'
92
- field = record.get_field(parameter, custom_field)
93
-
99
+ raise NotationError, 'Missing required parameter for field' unless parameter
100
+
101
+ # Get field (works for both standard and custom fields)
102
+ field = record.get_field(parameter)
103
+
94
104
  raise NotationError, "Field '#{parameter}' not found" unless field
95
-
105
+
96
106
  # Get field values
97
107
  values = field['value'] || []
98
-
108
+
99
109
  # Handle index1
100
110
  idx = parse_index(index1)
101
-
102
- # Validate index1 - only raise error if we have a non-empty, non-bracket value
103
- if idx == -1 && parsed_section.index1 &&
104
- parsed_section.index1[1] != '' && parsed_section.index1[1] != '[]' &&
105
- parsed_section.index1[0] != '' # Empty string parses to -1 which is valid
106
- raise NotationError, "Invalid field index: #{parsed_section.index1[0]}"
107
- end
108
-
109
- if idx >= values.size
110
- raise NotationError, "Field index out of bounds: #{idx} >= #{values.size}"
111
+
112
+ # If index1 is not a valid number but has a value, treat it as a property name
113
+ if idx == -1 && index1 && !index1.empty?
114
+ # index1 is a property name (e.g., [hostName])
115
+ if values.first.is_a?(Hash)
116
+ property = index1
117
+ if values.first.key?(property)
118
+ return values.first[property]
119
+ else
120
+ raise NotationError, "Property '#{property}' not found"
121
+ end
122
+ else
123
+ raise NotationError, 'Cannot extract property from non-object value'
124
+ end
111
125
  end
112
-
126
+
127
+ raise NotationError, "Field index out of bounds: #{idx} >= #{values.size}" if idx >= values.size
128
+
113
129
  # Apply index1
114
130
  values = [values[idx]] if idx >= 0
115
-
131
+
116
132
  # Handle legacy compatibility
117
- if parsed_section.index1.nil? && parsed_section.index2.nil?
118
- return values.first
119
- end
120
-
121
- if parsed_section.index1 && parsed_section.index1[1] == '[]' &&
133
+ return values.first if parsed_section.index1.nil? && parsed_section.index2.nil?
134
+
135
+ if parsed_section.index1 && parsed_section.index1[1] == '[]' &&
122
136
  (index2.nil? || index2.empty?)
123
137
  return values
124
138
  end
125
-
126
- if index1.to_s.empty? && !index2.to_s.empty?
127
- return values.first[index2] if values.first.is_a?(Hash)
128
- end
129
-
139
+
140
+ return values.first[index2] if index1.to_s.empty? && !index2.to_s.empty? && values.first.is_a?(Hash)
141
+
130
142
  # Handle index2 (property access)
131
- full_obj_value = parsed_section.index2.nil? ||
132
- parsed_section.index2[1] == '' ||
133
- parsed_section.index2[1] == '[]'
134
-
143
+ full_obj_value = parsed_section.index2.nil? ||
144
+ parsed_section.index2[1] == '' ||
145
+ parsed_section.index2[1] == '[]'
146
+
135
147
  if full_obj_value
136
148
  idx >= 0 ? values.first : values
137
149
  elsif values.first.is_a?(Hash)
@@ -142,14 +154,14 @@ module KeeperSecretsManager
142
154
  raise NotationError, "Property '#{obj_property}' not found"
143
155
  end
144
156
  else
145
- raise NotationError, "Cannot extract property from non-object value"
157
+ raise NotationError, 'Cannot extract property from non-object value'
146
158
  end
147
159
  end
148
160
 
149
161
  # Parse index value
150
162
  def parse_index(index_str)
151
163
  return -1 if index_str.nil? || index_str.empty?
152
-
164
+
153
165
  begin
154
166
  Integer(index_str)
155
167
  rescue ArgumentError
@@ -164,23 +176,23 @@ module KeeperSecretsManager
164
176
  begin
165
177
  decoded = Base64.urlsafe_decode64(notation)
166
178
  notation = decoded.force_encoding('UTF-8')
167
- rescue
168
- raise NotationError, "Invalid notation format"
179
+ rescue StandardError
180
+ raise NotationError, 'Invalid notation format'
169
181
  end
170
182
  end
171
-
183
+
172
184
  # Parse sections
173
185
  prefix = parse_section(notation, 'prefix', 0)
174
186
  pos = prefix.present? ? prefix.end_pos + 1 : 0
175
-
187
+
176
188
  record = parse_section(notation, 'record', pos)
177
189
  pos = record.present? ? record.end_pos + 1 : notation.length
178
-
190
+
179
191
  selector = parse_section(notation, 'selector', pos)
180
192
  pos = selector.present? ? selector.end_pos + 1 : notation.length
181
-
193
+
182
194
  footer = parse_section(notation, 'footer', pos)
183
-
195
+
184
196
  [prefix, record, selector, footer]
185
197
  end
186
198
 
@@ -188,7 +200,7 @@ module KeeperSecretsManager
188
200
  def parse_section(notation, section_name, pos)
189
201
  result = NotationSection.new(section_name)
190
202
  result.start_pos = pos
191
-
203
+
192
204
  case section_name.downcase
193
205
  when 'prefix'
194
206
  # Check for keeper:// prefix
@@ -199,7 +211,7 @@ module KeeperSecretsManager
199
211
  result.end_pos = prefix.length - 1
200
212
  result.text = [notation[0...prefix.length], notation[0...prefix.length]]
201
213
  end
202
-
214
+
203
215
  when 'footer'
204
216
  # Footer is anything after the last section
205
217
  if pos < notation.length
@@ -208,7 +220,7 @@ module KeeperSecretsManager
208
220
  result.end_pos = notation.length - 1
209
221
  result.text = [notation[pos..], notation[pos..]]
210
222
  end
211
-
223
+
212
224
  when 'record'
213
225
  # Record is required - parse until '/' with escaping
214
226
  if pos < notation.length
@@ -220,7 +232,7 @@ module KeeperSecretsManager
220
232
  result.text = parsed
221
233
  end
222
234
  end
223
-
235
+
224
236
  when 'selector'
225
237
  # Selector is required
226
238
  if pos < notation.length
@@ -230,7 +242,7 @@ module KeeperSecretsManager
230
242
  result.start_pos = pos
231
243
  result.end_pos = pos + parsed[1].length - 1
232
244
  result.text = parsed
233
-
245
+
234
246
  # Check for long selectors that have parameters
235
247
  if %w[field custom_field file].include?(parsed[0].downcase)
236
248
  # Parse parameter (field type/label or filename)
@@ -240,13 +252,13 @@ module KeeperSecretsManager
240
252
  plen = param_parsed[1].length
241
253
  plen -= 1 if param_parsed[1].end_with?('[') && !param_parsed[1].end_with?('\\[')
242
254
  result.end_pos += plen
243
-
255
+
244
256
  # Parse index1 [N] or []
245
257
  index1_parsed = parse_subsection(notation, result.end_pos + 1, '[]', true)
246
258
  if index1_parsed
247
259
  result.index1 = index1_parsed
248
260
  result.end_pos += index1_parsed[1].length
249
-
261
+
250
262
  # Parse index2 [property]
251
263
  index2_parsed = parse_subsection(notation, result.end_pos + 1, '[]', true)
252
264
  if index2_parsed
@@ -258,80 +270,75 @@ module KeeperSecretsManager
258
270
  end
259
271
  end
260
272
  end
261
-
273
+
262
274
  else
263
275
  raise NotationError, "Unknown section: #{section_name}"
264
276
  end
265
-
277
+
266
278
  result
267
279
  end
268
280
 
269
281
  # Parse subsection with delimiters and escaping
270
282
  def parse_subsection(text, pos, delimiters, escaped = false)
271
283
  return nil if text.nil? || text.empty? || pos < 0 || pos >= text.length
272
-
273
- if delimiters.nil? || delimiters.length > 2
274
- raise NotationError, "Internal error: incorrect delimiters"
275
- end
276
-
284
+
285
+ raise NotationError, 'Internal error: incorrect delimiters' if delimiters.nil? || delimiters.length > 2
286
+
277
287
  token = ''
278
288
  raw = ''
279
-
289
+
280
290
  while pos < text.length
281
291
  if escaped && text[pos] == ESCAPE_CHAR
282
292
  # Handle escape sequence
283
293
  if pos + 1 >= text.length || !ESCAPE_CHARS.include?(text[pos + 1])
284
294
  raise NotationError, "Incorrect escape sequence at position #{pos}"
285
295
  end
286
-
296
+
287
297
  token += text[pos + 1]
288
298
  raw += text[pos, 2]
289
299
  pos += 2
290
300
  else
291
301
  raw += text[pos]
292
-
302
+
293
303
  if delimiters.length == 1
294
304
  # Single delimiter
295
305
  break if text[pos] == delimiters[0]
306
+
296
307
  token += text[pos]
297
308
  else
298
309
  # Two delimiters (for brackets)
299
- if raw[0] != delimiters[0]
300
- raise NotationError, "Index sections must start with '['"
301
- end
302
-
310
+ raise NotationError, "Index sections must start with '['" if raw[0] != delimiters[0]
311
+
303
312
  if raw.length > 1 && text[pos] == delimiters[0]
304
313
  raise NotationError, "Index sections do not allow extra '[' inside"
305
314
  end
306
-
315
+
307
316
  if !delimiters.include?(text[pos])
308
317
  token += text[pos]
309
318
  elsif text[pos] == delimiters[1]
310
319
  break
311
320
  end
312
321
  end
313
-
322
+
314
323
  pos += 1
315
324
  end
316
325
  end
317
-
326
+
318
327
  # Validate brackets are properly closed
319
328
  if delimiters.length == 2
320
329
  if raw.length < 2 || raw[0] != delimiters[0] || raw[-1] != delimiters[1]
321
330
  raise NotationError, "Index sections must be enclosed in '[' and ']'"
322
331
  end
323
-
324
- if escaped && raw[-2] == ESCAPE_CHAR
325
- raise NotationError, "Index sections must be enclosed in '[' and ']'"
326
- end
332
+
333
+ raise NotationError, "Index sections must be enclosed in '[' and ']'" if escaped && raw[-2] == ESCAPE_CHAR
327
334
  end
328
-
335
+
329
336
  [token, raw]
330
337
  end
331
338
 
332
339
  # Notation section data class
333
340
  class NotationSection
334
- attr_accessor :section, :present, :start_pos, :end_pos,
341
+ attr_accessor :section, :present, :start_pos, :end_pos,
335
342
  :text, :parameter, :index1, :index2
336
343
 
337
344
  def initialize(section_name)
@@ -351,4 +358,4 @@ module KeeperSecretsManager
351
358
  end
352
359
  end
353
360
  end
354
- end
361
+ end
@@ -7,61 +7,59 @@ module KeeperSecretsManager
7
7
  # This method extends the basic parse method to handle special cases
8
8
  def get_value(notation, options = {})
9
9
  value = parse(notation)
10
-
10
+
11
11
  # Check if we should process special types
12
12
  return value unless options[:auto_process]
13
-
13
+
14
14
  # Parse the notation to understand what we're dealing with
15
15
  parsed = parse_notation(notation)
16
16
  return value if parsed.length < 3
17
-
17
+
18
18
  selector = parsed[2].text&.first
19
19
  return value unless selector
20
-
20
+
21
21
  case selector.downcase
22
22
  when 'file'
23
23
  # If it's a file and auto_download is enabled, download it
24
24
  if options[:auto_download] && value.is_a?(Hash) && value['fileUid']
25
25
  begin
26
26
  file_data = @secrets_manager.download_file(value['fileUid'])
27
- return file_data['data'] # Return file content
28
- rescue => e
27
+ return file_data['data'] # Return file content
28
+ rescue StandardError => e
29
29
  raise NotationError, "Failed to download file: #{e.message}"
30
30
  end
31
31
  end
32
-
32
+
33
33
  when 'field'
34
34
  # Check if it's a TOTP field
35
35
  parameter = parsed[2].parameter&.first
36
- if parameter && parameter.downcase == 'onetimecode' && value.is_a?(String) && value.start_with?('otpauth://')
37
- if options[:generate_totp_code]
38
- begin
39
- totp_params = TOTP.parse_url(value)
40
- return TOTP.generate_code(
41
- totp_params['secret'],
42
- algorithm: totp_params['algorithm'],
43
- digits: totp_params['digits'],
44
- period: totp_params['period']
45
- )
46
- rescue => e
47
- raise NotationError, "Failed to generate TOTP code: #{e.message}"
48
- end
36
+ if parameter && parameter.downcase == 'onetimecode' && value.is_a?(String) && value.start_with?('otpauth://') && (options[:generate_totp_code])
37
+ begin
38
+ totp_params = TOTP.parse_url(value)
39
+ return TOTP.generate_code(
40
+ totp_params['secret'],
41
+ algorithm: totp_params['algorithm'],
42
+ digits: totp_params['digits'],
43
+ period: totp_params['period']
44
+ )
45
+ rescue StandardError => e
46
+ raise NotationError, "Failed to generate TOTP code: #{e.message}"
49
47
  end
50
48
  end
51
49
  end
52
-
50
+
53
51
  value
54
52
  end
55
-
53
+
56
54
  # Convenience method to get TOTP code directly
57
55
  def get_totp_code(notation)
58
56
  get_value(notation, auto_process: true, generate_totp_code: true)
59
57
  end
60
-
58
+
61
59
  # Convenience method to download file content directly
62
60
  def download_file(notation)
63
61
  get_value(notation, auto_process: true, auto_download: true)
64
62
  end
65
63
  end
66
64
  end
67
- end
65
+ end