keeper_secrets_manager 17.0.3 → 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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +26 -15
- data/Gemfile +3 -3
- data/README.md +1 -1
- data/Rakefile +1 -1
- data/lib/keeper_secrets_manager/config_keys.rb +2 -2
- data/lib/keeper_secrets_manager/core.rb +594 -394
- data/lib/keeper_secrets_manager/crypto.rb +106 -113
- data/lib/keeper_secrets_manager/dto/payload.rb +4 -4
- data/lib/keeper_secrets_manager/dto.rb +50 -32
- data/lib/keeper_secrets_manager/errors.rb +13 -2
- data/lib/keeper_secrets_manager/field_types.rb +3 -3
- data/lib/keeper_secrets_manager/folder_manager.rb +25 -29
- data/lib/keeper_secrets_manager/keeper_globals.rb +9 -15
- data/lib/keeper_secrets_manager/notation.rb +99 -92
- data/lib/keeper_secrets_manager/notation_enhancements.rb +22 -24
- data/lib/keeper_secrets_manager/storage.rb +35 -36
- data/lib/keeper_secrets_manager/totp.rb +27 -27
- data/lib/keeper_secrets_manager/utils.rb +83 -17
- data/lib/keeper_secrets_manager/version.rb +2 -2
- data/lib/keeper_secrets_manager.rb +3 -3
- metadata +7 -18
- data/examples/basic_usage.rb +0 -139
- data/examples/config_string_example.rb +0 -99
- data/examples/debug_secrets.rb +0 -84
- data/examples/demo_list_secrets.rb +0 -182
- data/examples/download_files.rb +0 -100
- data/examples/flexible_records_example.rb +0 -94
- data/examples/folder_hierarchy_demo.rb +0 -109
- data/examples/full_demo.rb +0 -176
- data/examples/my_test_standalone.rb +0 -176
- data/examples/simple_test.rb +0 -162
- data/examples/storage_examples.rb +0 -126
|
@@ -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
|
-
|
|
7
|
-
|
|
8
|
-
#
|
|
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
|
-
#
|
|
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
|
-
|
|
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,
|
|
29
|
-
raise NotationError,
|
|
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,
|
|
72
|
-
|
|
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,
|
|
89
|
-
|
|
90
|
-
# Get field
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
#
|
|
103
|
-
if idx == -1 &&
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
119
|
-
|
|
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
|
-
|
|
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
|
-
|
|
133
|
-
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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']
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|