keeper_secrets_manager 17.0.3

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 +7 -0
  2. data/.rspec +3 -0
  3. data/.ruby-version +1 -0
  4. data/CHANGELOG.md +49 -0
  5. data/Gemfile +13 -0
  6. data/LICENSE +21 -0
  7. data/README.md +305 -0
  8. data/Rakefile +30 -0
  9. data/examples/basic_usage.rb +139 -0
  10. data/examples/config_string_example.rb +99 -0
  11. data/examples/debug_secrets.rb +84 -0
  12. data/examples/demo_list_secrets.rb +182 -0
  13. data/examples/download_files.rb +100 -0
  14. data/examples/flexible_records_example.rb +94 -0
  15. data/examples/folder_hierarchy_demo.rb +109 -0
  16. data/examples/full_demo.rb +176 -0
  17. data/examples/my_test_standalone.rb +176 -0
  18. data/examples/simple_test.rb +162 -0
  19. data/examples/storage_examples.rb +126 -0
  20. data/lib/keeper_secrets_manager/config_keys.rb +27 -0
  21. data/lib/keeper_secrets_manager/core.rb +1231 -0
  22. data/lib/keeper_secrets_manager/crypto.rb +348 -0
  23. data/lib/keeper_secrets_manager/dto/payload.rb +152 -0
  24. data/lib/keeper_secrets_manager/dto.rb +221 -0
  25. data/lib/keeper_secrets_manager/errors.rb +79 -0
  26. data/lib/keeper_secrets_manager/field_types.rb +152 -0
  27. data/lib/keeper_secrets_manager/folder_manager.rb +114 -0
  28. data/lib/keeper_secrets_manager/keeper_globals.rb +59 -0
  29. data/lib/keeper_secrets_manager/notation.rb +354 -0
  30. data/lib/keeper_secrets_manager/notation_enhancements.rb +67 -0
  31. data/lib/keeper_secrets_manager/storage.rb +254 -0
  32. data/lib/keeper_secrets_manager/totp.rb +140 -0
  33. data/lib/keeper_secrets_manager/utils.rb +196 -0
  34. data/lib/keeper_secrets_manager/version.rb +3 -0
  35. data/lib/keeper_secrets_manager.rb +38 -0
  36. metadata +82 -0
@@ -0,0 +1,354 @@
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
+ # Parse notation and return value
15
+ def parse(notation)
16
+ return nil if notation.nil? || notation.empty?
17
+
18
+ # Parse notation URI
19
+ parsed = parse_notation(notation)
20
+
21
+ # Validate we have minimum required sections
22
+ raise NotationError, "Invalid notation: #{notation}" if parsed.length < 3
23
+
24
+ # Extract components
25
+ record_token = parsed[1].text&.first
26
+ 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
+
31
+ # Get record
32
+ records = @secrets_manager.get_secrets([record_token])
33
+
34
+ # If not found by UID, try by title
35
+ if records.empty?
36
+ all_records = @secrets_manager.get_secrets
37
+ records = all_records.select { |r| r.title == record_token }
38
+ end
39
+
40
+ raise NotationError, "Multiple records match '#{record_token}'" if records.size > 1
41
+ raise NotationError, "No records match '#{record_token}'" if records.empty?
42
+
43
+ record = records.first
44
+
45
+ # Extract parameters
46
+ parameter = parsed[2].parameter&.first
47
+ index1 = parsed[2].index1&.first
48
+ index2 = parsed[2].index2&.first
49
+
50
+ # Process selector
51
+ case selector.downcase
52
+ when 'type'
53
+ record.type
54
+ when 'title'
55
+ record.title
56
+ when 'notes'
57
+ record.notes
58
+ when 'file'
59
+ handle_file_selector(record, parameter, record_token)
60
+ when 'field', 'custom_field'
61
+ handle_field_selector(record, selector, parameter, index1, index2, parsed[2])
62
+ else
63
+ raise NotationError, "Invalid selector: #{selector}"
64
+ end
65
+ end
66
+
67
+ private
68
+
69
+ # Handle file selector
70
+ 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
+
74
+ # Find matching file
75
+ files = record.files.select do |f|
76
+ parameter == f.name || parameter == f.title || parameter == f.uid
77
+ end
78
+
79
+ raise NotationError, "No files match '#{parameter}'" if files.empty?
80
+ raise NotationError, "Multiple files match '#{parameter}'" if files.size > 1
81
+
82
+ # Return file object (downloading would be handled by the caller)
83
+ files.first
84
+ end
85
+
86
+ # Handle field selector
87
+ 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
+
94
+ raise NotationError, "Field '#{parameter}' not found" unless field
95
+
96
+ # Get field values
97
+ values = field['value'] || []
98
+
99
+ # Handle index1
100
+ 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
+ end
112
+
113
+ # Apply index1
114
+ values = [values[idx]] if idx >= 0
115
+
116
+ # 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] == '[]' &&
122
+ (index2.nil? || index2.empty?)
123
+ return values
124
+ 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
+
130
+ # Handle index2 (property access)
131
+ full_obj_value = parsed_section.index2.nil? ||
132
+ parsed_section.index2[1] == '' ||
133
+ parsed_section.index2[1] == '[]'
134
+
135
+ if full_obj_value
136
+ idx >= 0 ? values.first : values
137
+ elsif values.first.is_a?(Hash)
138
+ obj_property = index2
139
+ if values.first.key?(obj_property)
140
+ values.first[obj_property]
141
+ else
142
+ raise NotationError, "Property '#{obj_property}' not found"
143
+ end
144
+ else
145
+ raise NotationError, "Cannot extract property from non-object value"
146
+ end
147
+ end
148
+
149
+ # Parse index value
150
+ def parse_index(index_str)
151
+ return -1 if index_str.nil? || index_str.empty?
152
+
153
+ begin
154
+ Integer(index_str)
155
+ rescue ArgumentError
156
+ -1
157
+ end
158
+ end
159
+
160
+ # Parse notation URI into sections
161
+ def parse_notation(notation)
162
+ # Handle base64 encoded notation
163
+ unless notation.include?('/')
164
+ begin
165
+ decoded = Base64.urlsafe_decode64(notation)
166
+ notation = decoded.force_encoding('UTF-8')
167
+ rescue
168
+ raise NotationError, "Invalid notation format"
169
+ end
170
+ end
171
+
172
+ # Parse sections
173
+ prefix = parse_section(notation, 'prefix', 0)
174
+ pos = prefix.present? ? prefix.end_pos + 1 : 0
175
+
176
+ record = parse_section(notation, 'record', pos)
177
+ pos = record.present? ? record.end_pos + 1 : notation.length
178
+
179
+ selector = parse_section(notation, 'selector', pos)
180
+ pos = selector.present? ? selector.end_pos + 1 : notation.length
181
+
182
+ footer = parse_section(notation, 'footer', pos)
183
+
184
+ [prefix, record, selector, footer]
185
+ end
186
+
187
+ # Parse a section of the notation
188
+ def parse_section(notation, section_name, pos)
189
+ result = NotationSection.new(section_name)
190
+ result.start_pos = pos
191
+
192
+ case section_name.downcase
193
+ when 'prefix'
194
+ # Check for keeper:// prefix
195
+ prefix = "#{Core::SecretsManager::NOTATION_PREFIX}://"
196
+ if notation.downcase.start_with?(prefix.downcase)
197
+ result.present = true
198
+ result.start_pos = 0
199
+ result.end_pos = prefix.length - 1
200
+ result.text = [notation[0...prefix.length], notation[0...prefix.length]]
201
+ end
202
+
203
+ when 'footer'
204
+ # Footer is anything after the last section
205
+ if pos < notation.length
206
+ result.present = true
207
+ result.start_pos = pos
208
+ result.end_pos = notation.length - 1
209
+ result.text = [notation[pos..], notation[pos..]]
210
+ end
211
+
212
+ when 'record'
213
+ # Record is required - parse until '/' with escaping
214
+ if pos < notation.length
215
+ parsed = parse_subsection(notation, pos, '/', true)
216
+ if parsed
217
+ result.present = true
218
+ result.start_pos = pos
219
+ result.end_pos = pos + parsed[1].length - 1
220
+ result.text = parsed
221
+ end
222
+ end
223
+
224
+ when 'selector'
225
+ # Selector is required
226
+ if pos < notation.length
227
+ parsed = parse_subsection(notation, pos, '/', false)
228
+ if parsed
229
+ result.present = true
230
+ result.start_pos = pos
231
+ result.end_pos = pos + parsed[1].length - 1
232
+ result.text = parsed
233
+
234
+ # Check for long selectors that have parameters
235
+ if %w[field custom_field file].include?(parsed[0].downcase)
236
+ # Parse parameter (field type/label or filename)
237
+ param_parsed = parse_subsection(notation, result.end_pos + 1, '[', true)
238
+ if param_parsed
239
+ result.parameter = param_parsed
240
+ plen = param_parsed[1].length
241
+ plen -= 1 if param_parsed[1].end_with?('[') && !param_parsed[1].end_with?('\\[')
242
+ result.end_pos += plen
243
+
244
+ # Parse index1 [N] or []
245
+ index1_parsed = parse_subsection(notation, result.end_pos + 1, '[]', true)
246
+ if index1_parsed
247
+ result.index1 = index1_parsed
248
+ result.end_pos += index1_parsed[1].length
249
+
250
+ # Parse index2 [property]
251
+ index2_parsed = parse_subsection(notation, result.end_pos + 1, '[]', true)
252
+ if index2_parsed
253
+ result.index2 = index2_parsed
254
+ result.end_pos += index2_parsed[1].length
255
+ end
256
+ end
257
+ end
258
+ end
259
+ end
260
+ end
261
+
262
+ else
263
+ raise NotationError, "Unknown section: #{section_name}"
264
+ end
265
+
266
+ result
267
+ end
268
+
269
+ # Parse subsection with delimiters and escaping
270
+ def parse_subsection(text, pos, delimiters, escaped = false)
271
+ 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
+
277
+ token = ''
278
+ raw = ''
279
+
280
+ while pos < text.length
281
+ if escaped && text[pos] == ESCAPE_CHAR
282
+ # Handle escape sequence
283
+ if pos + 1 >= text.length || !ESCAPE_CHARS.include?(text[pos + 1])
284
+ raise NotationError, "Incorrect escape sequence at position #{pos}"
285
+ end
286
+
287
+ token += text[pos + 1]
288
+ raw += text[pos, 2]
289
+ pos += 2
290
+ else
291
+ raw += text[pos]
292
+
293
+ if delimiters.length == 1
294
+ # Single delimiter
295
+ break if text[pos] == delimiters[0]
296
+ token += text[pos]
297
+ else
298
+ # Two delimiters (for brackets)
299
+ if raw[0] != delimiters[0]
300
+ raise NotationError, "Index sections must start with '['"
301
+ end
302
+
303
+ if raw.length > 1 && text[pos] == delimiters[0]
304
+ raise NotationError, "Index sections do not allow extra '[' inside"
305
+ end
306
+
307
+ if !delimiters.include?(text[pos])
308
+ token += text[pos]
309
+ elsif text[pos] == delimiters[1]
310
+ break
311
+ end
312
+ end
313
+
314
+ pos += 1
315
+ end
316
+ end
317
+
318
+ # Validate brackets are properly closed
319
+ if delimiters.length == 2
320
+ if raw.length < 2 || raw[0] != delimiters[0] || raw[-1] != delimiters[1]
321
+ raise NotationError, "Index sections must be enclosed in '[' and ']'"
322
+ end
323
+
324
+ if escaped && raw[-2] == ESCAPE_CHAR
325
+ raise NotationError, "Index sections must be enclosed in '[' and ']'"
326
+ end
327
+ end
328
+
329
+ [token, raw]
330
+ end
331
+
332
+ # Notation section data class
333
+ class NotationSection
334
+ attr_accessor :section, :present, :start_pos, :end_pos,
335
+ :text, :parameter, :index1, :index2
336
+
337
+ def initialize(section_name)
338
+ @section = section_name
339
+ @present = false
340
+ @start_pos = -1
341
+ @end_pos = -1
342
+ @text = nil
343
+ @parameter = nil
344
+ @index1 = nil
345
+ @index2 = nil
346
+ end
347
+
348
+ def present?
349
+ @present
350
+ end
351
+ end
352
+ end
353
+ end
354
+ 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
+ value = parse(notation)
10
+
11
+ # Check if we should process special types
12
+ return value unless options[:auto_process]
13
+
14
+ # Parse the notation to understand what we're dealing with
15
+ parsed = parse_notation(notation)
16
+ return value if parsed.length < 3
17
+
18
+ selector = parsed[2].text&.first
19
+ return value unless selector
20
+
21
+ case selector.downcase
22
+ when 'file'
23
+ # If it's a file and auto_download is enabled, download it
24
+ if options[:auto_download] && value.is_a?(Hash) && value['fileUid']
25
+ begin
26
+ file_data = @secrets_manager.download_file(value['fileUid'])
27
+ return file_data['data'] # Return file content
28
+ rescue => e
29
+ raise NotationError, "Failed to download file: #{e.message}"
30
+ end
31
+ end
32
+
33
+ when 'field'
34
+ # Check if it's a TOTP field
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
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
@@ -0,0 +1,254 @@
1
+ require 'json'
2
+ require 'base64'
3
+ require 'fileutils'
4
+
5
+ module KeeperSecretsManager
6
+ module Storage
7
+ # Base storage interface
8
+ module KeyValueStorage
9
+ def get_string(key)
10
+ raise NotImplementedError, "Subclass must implement get_string"
11
+ end
12
+
13
+ def save_string(key, value)
14
+ raise NotImplementedError, "Subclass must implement save_string"
15
+ end
16
+
17
+ def get_bytes(key)
18
+ data = get_string(key)
19
+ return nil unless data
20
+
21
+ # Handle both standard and URL-safe base64
22
+ begin
23
+ # First try standard base64
24
+ Base64.strict_decode64(data)
25
+ rescue ArgumentError
26
+ begin
27
+ # Try URL-safe base64 with padding
28
+ padding = 4 - (data.length % 4)
29
+ padding = 0 if padding == 4
30
+ Base64.urlsafe_decode64(data + '=' * padding)
31
+ rescue => e
32
+ # Last resort - try with decode64 which is more lenient
33
+ Base64.decode64(data)
34
+ end
35
+ end
36
+ end
37
+
38
+ def save_bytes(key, value)
39
+ save_string(key, Base64.strict_encode64(value))
40
+ end
41
+
42
+ def delete(key)
43
+ raise NotImplementedError, "Subclass must implement delete"
44
+ end
45
+
46
+ def contains?(key)
47
+ !get_string(key).nil?
48
+ end
49
+ end
50
+
51
+ # In-memory storage implementation
52
+ class InMemoryStorage
53
+ include KeyValueStorage
54
+
55
+ def initialize(config_data = nil)
56
+ @data = {}
57
+
58
+ # Initialize from JSON string, base64 string, or hash
59
+ if config_data
60
+ parsed = case config_data
61
+ when String
62
+ # Check if it's base64 encoded
63
+ if is_base64?(config_data)
64
+ JSON.parse(Base64.decode64(config_data))
65
+ else
66
+ JSON.parse(config_data)
67
+ end
68
+ when Hash
69
+ config_data
70
+ else
71
+ {}
72
+ end
73
+
74
+ parsed.each { |k, v| @data[k.to_s] = v.to_s }
75
+ end
76
+ end
77
+
78
+ def get_string(key)
79
+ @data[key.to_s]
80
+ end
81
+
82
+ def save_string(key, value)
83
+ @data[key.to_s] = value.to_s
84
+ end
85
+
86
+ def delete(key)
87
+ @data.delete(key.to_s)
88
+ end
89
+
90
+ def to_h
91
+ @data.dup
92
+ end
93
+
94
+ def to_json(*args)
95
+ @data.to_json(*args)
96
+ end
97
+
98
+ private
99
+
100
+ def is_base64?(str)
101
+ # Check if string is valid base64
102
+ return false if str.nil? || str.empty?
103
+
104
+ # Remove whitespace
105
+ str = str.strip
106
+
107
+ # Check if length is multiple of 4 (with padding) or can be padded to multiple of 4
108
+ # Also check if it only contains base64 characters
109
+ base64_regex = /\A[A-Za-z0-9+\/]*={0,2}\z/
110
+
111
+ str.match?(base64_regex) && (str.length % 4 == 0 || str.length % 4 == 2 || str.length % 4 == 3)
112
+ end
113
+ end
114
+
115
+ # File-based storage implementation
116
+ class FileStorage
117
+ include KeyValueStorage
118
+
119
+ def initialize(filename = 'keeper_config.json')
120
+ @filename = File.expand_path(filename)
121
+ @data = {}
122
+ load_data
123
+ end
124
+
125
+ def get_string(key)
126
+ @data[key.to_s]
127
+ end
128
+
129
+ def save_string(key, value)
130
+ @data[key.to_s] = value.to_s
131
+ save_data
132
+ end
133
+
134
+ def delete(key)
135
+ @data.delete(key.to_s)
136
+ save_data
137
+ end
138
+
139
+ private
140
+
141
+ def load_data
142
+ if File.exist?(@filename)
143
+ begin
144
+ content = File.read(@filename)
145
+ # Handle empty files
146
+ if content.strip.empty?
147
+ @data = {}
148
+ else
149
+ @data = JSON.parse(content)
150
+ end
151
+ rescue JSON::ParserError => e
152
+ raise Error, "Failed to parse config file: #{e.message}"
153
+ end
154
+ end
155
+ end
156
+
157
+ def save_data
158
+ # Ensure directory exists
159
+ FileUtils.mkdir_p(File.dirname(@filename))
160
+
161
+ # Write atomically to avoid corruption
162
+ temp_file = "#{@filename}.tmp"
163
+ File.open(temp_file, 'w') do |f|
164
+ f.write(JSON.pretty_generate(@data))
165
+ end
166
+
167
+ # Move atomically
168
+ File.rename(temp_file, @filename)
169
+
170
+ # Set restrictive permissions (owner read/write only)
171
+ File.chmod(0600, @filename)
172
+ rescue => e
173
+ raise Error, "Failed to save config file: #{e.message}"
174
+ end
175
+ end
176
+
177
+ # Environment-based storage (read-only)
178
+ class EnvironmentStorage
179
+ include KeyValueStorage
180
+
181
+ def initialize(prefix = 'KSM_')
182
+ @prefix = prefix
183
+ end
184
+
185
+ def get_string(key)
186
+ ENV["#{@prefix}#{key.to_s.upcase}"]
187
+ end
188
+
189
+ def save_string(key, value)
190
+ raise Error, "Environment storage is read-only"
191
+ end
192
+
193
+ def delete(key)
194
+ raise Error, "Environment storage is read-only"
195
+ end
196
+ end
197
+
198
+ # Cacheable storage wrapper
199
+ class CachingStorage
200
+ include KeyValueStorage
201
+
202
+ def initialize(base_storage, ttl_seconds = 600)
203
+ @base_storage = base_storage
204
+ @ttl_seconds = ttl_seconds
205
+ @cache = {}
206
+ @timestamps = {}
207
+ end
208
+
209
+ def get_string(key)
210
+ key_str = key.to_s
211
+
212
+ # Check cache validity
213
+ if @cache.key?(key_str) && !expired?(key_str)
214
+ return @cache[key_str]
215
+ end
216
+
217
+ # Fetch from base storage
218
+ value = @base_storage.get_string(key)
219
+ if value
220
+ @cache[key_str] = value
221
+ @timestamps[key_str] = Time.now
222
+ end
223
+
224
+ value
225
+ end
226
+
227
+ def save_string(key, value)
228
+ key_str = key.to_s
229
+ @base_storage.save_string(key, value)
230
+ @cache[key_str] = value.to_s
231
+ @timestamps[key_str] = Time.now
232
+ end
233
+
234
+ def delete(key)
235
+ key_str = key.to_s
236
+ @base_storage.delete(key)
237
+ @cache.delete(key_str)
238
+ @timestamps.delete(key_str)
239
+ end
240
+
241
+ def clear_cache
242
+ @cache.clear
243
+ @timestamps.clear
244
+ end
245
+
246
+ private
247
+
248
+ def expired?(key)
249
+ return true unless @timestamps[key]
250
+ Time.now - @timestamps[key] > @ttl_seconds
251
+ end
252
+ end
253
+ end
254
+ end