keeper_secrets_manager 17.0.4 → 17.2.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 +100 -16
- data/Gemfile +6 -3
- data/README.md +75 -270
- data/Rakefile +1 -1
- 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 +4 -2
- data/lib/keeper_secrets_manager/core.rb +993 -452
- data/lib/keeper_secrets_manager/crypto.rb +101 -116
- data/lib/keeper_secrets_manager/dto/payload.rb +7 -6
- data/lib/keeper_secrets_manager/dto.rb +374 -38
- 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 +11 -17
- data/lib/keeper_secrets_manager/notation.rb +202 -93
- data/lib/keeper_secrets_manager/notation_enhancements.rb +24 -24
- data/lib/keeper_secrets_manager/storage.rb +38 -38
- data/lib/keeper_secrets_manager/totp.rb +27 -27
- data/lib/keeper_secrets_manager/utils.rb +85 -18
- data/lib/keeper_secrets_manager/version.rb +2 -2
- data/lib/keeper_secrets_manager.rb +11 -3
- metadata +39 -22
- data/DEVELOPER_SETUP.md +0 -0
- data/MANUAL_TESTING_GUIDE.md +0 -332
- data/RUBY_SDK_COMPLETE_DOCUMENTATION.md +0 -354
- data/RUBY_SDK_COMPREHENSIVE_SUMMARY.md +0 -192
- data/examples/01_quick_start.rb +0 -45
- data/examples/02_authentication.rb +0 -82
- data/examples/03_retrieve_secrets.rb +0 -81
- data/examples/04_create_update_delete.rb +0 -104
- data/examples/05_field_types.rb +0 -135
- data/examples/06_files.rb +0 -137
- data/examples/07_folders.rb +0 -145
- data/examples/08_notation.rb +0 -103
- data/examples/09_totp.rb +0 -100
- data/examples/README.md +0 -89
|
@@ -6,62 +6,62 @@ module KeeperSecretsManager
|
|
|
6
6
|
# Get value with enhanced functionality
|
|
7
7
|
# This method extends the basic parse method to handle special cases
|
|
8
8
|
def get_value(notation, options = {})
|
|
9
|
+
return nil if notation.nil? || !notation.is_a?(String) || notation.empty?
|
|
10
|
+
|
|
9
11
|
value = parse(notation)
|
|
10
|
-
|
|
12
|
+
|
|
11
13
|
# Check if we should process special types
|
|
12
14
|
return value unless options[:auto_process]
|
|
13
|
-
|
|
15
|
+
|
|
14
16
|
# Parse the notation to understand what we're dealing with
|
|
15
17
|
parsed = parse_notation(notation)
|
|
16
18
|
return value if parsed.length < 3
|
|
17
|
-
|
|
19
|
+
|
|
18
20
|
selector = parsed[2].text&.first
|
|
19
21
|
return value unless selector
|
|
20
|
-
|
|
22
|
+
|
|
21
23
|
case selector.downcase
|
|
22
24
|
when 'file'
|
|
23
25
|
# If it's a file and auto_download is enabled, download it
|
|
24
26
|
if options[:auto_download] && value.is_a?(Hash) && value['fileUid']
|
|
25
27
|
begin
|
|
26
28
|
file_data = @secrets_manager.download_file(value['fileUid'])
|
|
27
|
-
return file_data['data']
|
|
28
|
-
rescue => e
|
|
29
|
+
return file_data['data'] # Return file content
|
|
30
|
+
rescue StandardError => e
|
|
29
31
|
raise NotationError, "Failed to download file: #{e.message}"
|
|
30
32
|
end
|
|
31
33
|
end
|
|
32
|
-
|
|
34
|
+
|
|
33
35
|
when 'field'
|
|
34
36
|
# Check if it's a TOTP field
|
|
35
37
|
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
|
|
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
49
|
end
|
|
50
50
|
end
|
|
51
51
|
end
|
|
52
|
-
|
|
52
|
+
|
|
53
53
|
value
|
|
54
54
|
end
|
|
55
|
-
|
|
55
|
+
|
|
56
56
|
# Convenience method to get TOTP code directly
|
|
57
57
|
def get_totp_code(notation)
|
|
58
58
|
get_value(notation, auto_process: true, generate_totp_code: true)
|
|
59
59
|
end
|
|
60
|
-
|
|
60
|
+
|
|
61
61
|
# Convenience method to download file content directly
|
|
62
62
|
def download_file(notation)
|
|
63
63
|
get_value(notation, auto_process: true, auto_download: true)
|
|
64
64
|
end
|
|
65
65
|
end
|
|
66
66
|
end
|
|
67
|
-
end
|
|
67
|
+
end
|
|
@@ -6,18 +6,18 @@ module KeeperSecretsManager
|
|
|
6
6
|
module Storage
|
|
7
7
|
# Base storage interface
|
|
8
8
|
module KeyValueStorage
|
|
9
|
-
def get_string(
|
|
10
|
-
raise NotImplementedError,
|
|
9
|
+
def get_string(_key)
|
|
10
|
+
raise NotImplementedError, 'Subclass must implement get_string'
|
|
11
11
|
end
|
|
12
12
|
|
|
13
|
-
def save_string(
|
|
14
|
-
raise NotImplementedError,
|
|
13
|
+
def save_string(_key, _value)
|
|
14
|
+
raise NotImplementedError, 'Subclass must implement save_string'
|
|
15
15
|
end
|
|
16
16
|
|
|
17
17
|
def get_bytes(key)
|
|
18
18
|
data = get_string(key)
|
|
19
19
|
return nil unless data
|
|
20
|
-
|
|
20
|
+
|
|
21
21
|
# Handle both standard and URL-safe base64
|
|
22
22
|
begin
|
|
23
23
|
# First try standard base64
|
|
@@ -28,7 +28,7 @@ module KeeperSecretsManager
|
|
|
28
28
|
padding = 4 - (data.length % 4)
|
|
29
29
|
padding = 0 if padding == 4
|
|
30
30
|
Base64.urlsafe_decode64(data + '=' * padding)
|
|
31
|
-
rescue => e
|
|
31
|
+
rescue StandardError => e
|
|
32
32
|
# Last resort - try with decode64 which is more lenient
|
|
33
33
|
Base64.decode64(data)
|
|
34
34
|
end
|
|
@@ -39,8 +39,8 @@ module KeeperSecretsManager
|
|
|
39
39
|
save_string(key, Base64.strict_encode64(value))
|
|
40
40
|
end
|
|
41
41
|
|
|
42
|
-
def delete(
|
|
43
|
-
raise NotImplementedError,
|
|
42
|
+
def delete(_key)
|
|
43
|
+
raise NotImplementedError, 'Subclass must implement delete'
|
|
44
44
|
end
|
|
45
45
|
|
|
46
46
|
def contains?(key)
|
|
@@ -54,7 +54,7 @@ module KeeperSecretsManager
|
|
|
54
54
|
|
|
55
55
|
def initialize(config_data = nil)
|
|
56
56
|
@data = {}
|
|
57
|
-
|
|
57
|
+
|
|
58
58
|
# Initialize from JSON string, base64 string, or hash
|
|
59
59
|
if config_data
|
|
60
60
|
parsed = case config_data
|
|
@@ -70,7 +70,7 @@ module KeeperSecretsManager
|
|
|
70
70
|
else
|
|
71
71
|
{}
|
|
72
72
|
end
|
|
73
|
-
|
|
73
|
+
|
|
74
74
|
parsed.each { |k, v| @data[k.to_s] = v.to_s }
|
|
75
75
|
end
|
|
76
76
|
end
|
|
@@ -94,20 +94,20 @@ module KeeperSecretsManager
|
|
|
94
94
|
def to_json(*args)
|
|
95
95
|
@data.to_json(*args)
|
|
96
96
|
end
|
|
97
|
-
|
|
97
|
+
|
|
98
98
|
private
|
|
99
|
-
|
|
99
|
+
|
|
100
100
|
def is_base64?(str)
|
|
101
101
|
# Check if string is valid base64
|
|
102
102
|
return false if str.nil? || str.empty?
|
|
103
|
-
|
|
103
|
+
|
|
104
104
|
# Remove whitespace
|
|
105
105
|
str = str.strip
|
|
106
|
-
|
|
106
|
+
|
|
107
107
|
# Check if length is multiple of 4 (with padding) or can be padded to multiple of 4
|
|
108
108
|
# Also check if it only contains base64 characters
|
|
109
|
-
base64_regex =
|
|
110
|
-
|
|
109
|
+
base64_regex = %r{\A[A-Za-z0-9+/]*={0,2}\z}
|
|
110
|
+
|
|
111
111
|
str.match?(base64_regex) && (str.length % 4 == 0 || str.length % 4 == 2 || str.length % 4 == 3)
|
|
112
112
|
end
|
|
113
113
|
end
|
|
@@ -143,11 +143,11 @@ module KeeperSecretsManager
|
|
|
143
143
|
begin
|
|
144
144
|
content = File.read(@filename)
|
|
145
145
|
# Handle empty files
|
|
146
|
-
if content.strip.empty?
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
146
|
+
@data = if content.strip.empty?
|
|
147
|
+
{}
|
|
148
|
+
else
|
|
149
|
+
JSON.parse(content)
|
|
150
|
+
end
|
|
151
151
|
rescue JSON::ParserError => e
|
|
152
152
|
raise Error, "Failed to parse config file: #{e.message}"
|
|
153
153
|
end
|
|
@@ -157,19 +157,20 @@ module KeeperSecretsManager
|
|
|
157
157
|
def save_data
|
|
158
158
|
# Ensure directory exists
|
|
159
159
|
FileUtils.mkdir_p(File.dirname(@filename))
|
|
160
|
-
|
|
160
|
+
|
|
161
161
|
# Write atomically to avoid corruption
|
|
162
162
|
temp_file = "#{@filename}.tmp"
|
|
163
|
-
|
|
163
|
+
# Create temp file with secure permissions (0600)
|
|
164
|
+
File.open(temp_file, 'w', 0o600) do |f|
|
|
164
165
|
f.write(JSON.pretty_generate(@data))
|
|
165
166
|
end
|
|
166
|
-
|
|
167
|
+
|
|
167
168
|
# Move atomically
|
|
168
169
|
File.rename(temp_file, @filename)
|
|
169
|
-
|
|
170
|
-
#
|
|
171
|
-
File.chmod(
|
|
172
|
-
rescue => e
|
|
170
|
+
|
|
171
|
+
# Ensure final file has restrictive permissions (owner read/write only)
|
|
172
|
+
File.chmod(0o600, @filename)
|
|
173
|
+
rescue StandardError => e
|
|
173
174
|
raise Error, "Failed to save config file: #{e.message}"
|
|
174
175
|
end
|
|
175
176
|
end
|
|
@@ -186,12 +187,12 @@ module KeeperSecretsManager
|
|
|
186
187
|
ENV["#{@prefix}#{key.to_s.upcase}"]
|
|
187
188
|
end
|
|
188
189
|
|
|
189
|
-
def save_string(
|
|
190
|
-
raise Error,
|
|
190
|
+
def save_string(_key, _value)
|
|
191
|
+
raise Error, 'Environment storage is read-only'
|
|
191
192
|
end
|
|
192
193
|
|
|
193
|
-
def delete(
|
|
194
|
-
raise Error,
|
|
194
|
+
def delete(_key)
|
|
195
|
+
raise Error, 'Environment storage is read-only'
|
|
195
196
|
end
|
|
196
197
|
end
|
|
197
198
|
|
|
@@ -208,11 +209,9 @@ module KeeperSecretsManager
|
|
|
208
209
|
|
|
209
210
|
def get_string(key)
|
|
210
211
|
key_str = key.to_s
|
|
211
|
-
|
|
212
|
+
|
|
212
213
|
# Check cache validity
|
|
213
|
-
if @cache.key?(key_str) && !expired?(key_str)
|
|
214
|
-
return @cache[key_str]
|
|
215
|
-
end
|
|
214
|
+
return @cache[key_str] if @cache.key?(key_str) && !expired?(key_str)
|
|
216
215
|
|
|
217
216
|
# Fetch from base storage
|
|
218
217
|
value = @base_storage.get_string(key)
|
|
@@ -220,7 +219,7 @@ module KeeperSecretsManager
|
|
|
220
219
|
@cache[key_str] = value
|
|
221
220
|
@timestamps[key_str] = Time.now
|
|
222
221
|
end
|
|
223
|
-
|
|
222
|
+
|
|
224
223
|
value
|
|
225
224
|
end
|
|
226
225
|
|
|
@@ -247,8 +246,9 @@ module KeeperSecretsManager
|
|
|
247
246
|
|
|
248
247
|
def expired?(key)
|
|
249
248
|
return true unless @timestamps[key]
|
|
249
|
+
|
|
250
250
|
Time.now - @timestamps[key] > @ttl_seconds
|
|
251
251
|
end
|
|
252
252
|
end
|
|
253
253
|
end
|
|
254
|
-
end
|
|
254
|
+
end
|
|
@@ -12,7 +12,7 @@ module KeeperSecretsManager
|
|
|
12
12
|
'SHA256' => OpenSSL::Digest::SHA256,
|
|
13
13
|
'SHA512' => OpenSSL::Digest::SHA512
|
|
14
14
|
}.freeze
|
|
15
|
-
|
|
15
|
+
|
|
16
16
|
# Generate a TOTP code
|
|
17
17
|
# @param secret [String] Base32 encoded secret
|
|
18
18
|
# @param time [Time] Time to generate code for (default: current time)
|
|
@@ -23,45 +23,45 @@ module KeeperSecretsManager
|
|
|
23
23
|
def self.generate_code(secret, time: Time.now, algorithm: 'SHA1', digits: 6, period: 30)
|
|
24
24
|
# Validate inputs
|
|
25
25
|
raise ArgumentError, "Invalid algorithm: #{algorithm}" unless ALGORITHMS.key?(algorithm)
|
|
26
|
-
raise ArgumentError,
|
|
27
|
-
raise ArgumentError,
|
|
28
|
-
|
|
26
|
+
raise ArgumentError, 'Digits must be 6 or 8' unless [6, 8].include?(digits)
|
|
27
|
+
raise ArgumentError, 'Period must be positive' unless period.positive?
|
|
28
|
+
|
|
29
29
|
# Decode base32 secret
|
|
30
30
|
key = Base32.decode(secret.upcase.tr(' ', ''))
|
|
31
|
-
|
|
31
|
+
|
|
32
32
|
# Calculate time counter
|
|
33
33
|
counter = (time.to_i / period).floor
|
|
34
|
-
|
|
34
|
+
|
|
35
35
|
# Convert counter to 8-byte string (big-endian)
|
|
36
36
|
counter_bytes = [counter].pack('Q>')
|
|
37
|
-
|
|
37
|
+
|
|
38
38
|
# Generate HMAC
|
|
39
39
|
digest = ALGORITHMS[algorithm].new
|
|
40
40
|
hmac = OpenSSL::HMAC.digest(digest, key, counter_bytes)
|
|
41
|
-
|
|
41
|
+
|
|
42
42
|
# Extract dynamic binary code
|
|
43
43
|
offset = hmac[-1].ord & 0x0f
|
|
44
44
|
code = (hmac[offset].ord & 0x7f) << 24 |
|
|
45
45
|
(hmac[offset + 1].ord & 0xff) << 16 |
|
|
46
46
|
(hmac[offset + 2].ord & 0xff) << 8 |
|
|
47
47
|
(hmac[offset + 3].ord & 0xff)
|
|
48
|
-
|
|
48
|
+
|
|
49
49
|
# Generate final OTP value
|
|
50
|
-
otp = code % (10
|
|
51
|
-
|
|
50
|
+
otp = code % (10**digits)
|
|
51
|
+
|
|
52
52
|
# Pad with leading zeros if necessary
|
|
53
53
|
otp.to_s.rjust(digits, '0')
|
|
54
54
|
end
|
|
55
|
-
|
|
55
|
+
|
|
56
56
|
# Parse TOTP URL (otpauth://totp/...)
|
|
57
57
|
# @param url [String] TOTP URL
|
|
58
58
|
# @return [Hash] Parsed components
|
|
59
59
|
def self.parse_url(url)
|
|
60
60
|
uri = URI(url)
|
|
61
|
-
|
|
62
|
-
raise ArgumentError,
|
|
63
|
-
raise ArgumentError,
|
|
64
|
-
|
|
61
|
+
|
|
62
|
+
raise ArgumentError, 'Invalid TOTP URL scheme' unless uri.scheme == 'otpauth'
|
|
63
|
+
raise ArgumentError, 'Invalid TOTP URL type' unless uri.host == 'totp'
|
|
64
|
+
|
|
65
65
|
# Extract label (issuer:account or just account)
|
|
66
66
|
path = uri.path[1..-1] # Remove leading /
|
|
67
67
|
if path.include?(':')
|
|
@@ -70,10 +70,10 @@ module KeeperSecretsManager
|
|
|
70
70
|
account = path
|
|
71
71
|
issuer = nil
|
|
72
72
|
end
|
|
73
|
-
|
|
73
|
+
|
|
74
74
|
# Parse query parameters
|
|
75
75
|
params = URI.decode_www_form(uri.query || '').to_h
|
|
76
|
-
|
|
76
|
+
|
|
77
77
|
{
|
|
78
78
|
'account' => URI.decode_www_form_component(account || ''),
|
|
79
79
|
'issuer' => issuer ? URI.decode_www_form_component(issuer) : params['issuer'],
|
|
@@ -83,7 +83,7 @@ module KeeperSecretsManager
|
|
|
83
83
|
'period' => (params['period'] || '30').to_i
|
|
84
84
|
}
|
|
85
85
|
end
|
|
86
|
-
|
|
86
|
+
|
|
87
87
|
# Generate TOTP URL
|
|
88
88
|
# @param account [String] Account name (e.g., email)
|
|
89
89
|
# @param secret [String] Base32 encoded secret
|
|
@@ -94,20 +94,20 @@ module KeeperSecretsManager
|
|
|
94
94
|
# @return [String] TOTP URL
|
|
95
95
|
def self.generate_url(account, secret, issuer: nil, algorithm: 'SHA1', digits: 6, period: 30)
|
|
96
96
|
label = issuer ? "#{issuer}:#{account}" : account
|
|
97
|
-
|
|
97
|
+
|
|
98
98
|
params = {
|
|
99
99
|
'secret' => secret,
|
|
100
100
|
'algorithm' => algorithm,
|
|
101
101
|
'digits' => digits,
|
|
102
102
|
'period' => period
|
|
103
103
|
}
|
|
104
|
-
|
|
104
|
+
|
|
105
105
|
params['issuer'] = issuer if issuer
|
|
106
|
-
|
|
106
|
+
|
|
107
107
|
query = URI.encode_www_form(params)
|
|
108
108
|
"otpauth://totp/#{URI.encode_www_form_component(label)}?#{query}"
|
|
109
109
|
end
|
|
110
|
-
|
|
110
|
+
|
|
111
111
|
# Validate a TOTP code
|
|
112
112
|
# @param secret [String] Base32 encoded secret
|
|
113
113
|
# @param code [String] Code to validate
|
|
@@ -122,13 +122,13 @@ module KeeperSecretsManager
|
|
|
122
122
|
(-window..window).each do |offset|
|
|
123
123
|
test_time = time + (offset * period)
|
|
124
124
|
test_code = generate_code(secret, time: test_time, algorithm: algorithm, digits: digits, period: period)
|
|
125
|
-
|
|
125
|
+
|
|
126
126
|
return true if test_code == code
|
|
127
127
|
end
|
|
128
|
-
|
|
128
|
+
|
|
129
129
|
false
|
|
130
130
|
end
|
|
131
|
-
|
|
131
|
+
|
|
132
132
|
# Generate a random secret suitable for TOTP
|
|
133
133
|
# @param length [Integer] Number of bytes (default: 20 for 160 bits)
|
|
134
134
|
# @return [String] Base32 encoded secret
|
|
@@ -137,4 +137,4 @@ module KeeperSecretsManager
|
|
|
137
137
|
Base32.encode(random_bytes).delete('=')
|
|
138
138
|
end
|
|
139
139
|
end
|
|
140
|
-
end
|
|
140
|
+
end
|
|
@@ -35,6 +35,7 @@ module KeeperSecretsManager
|
|
|
35
35
|
|
|
36
36
|
# Base64 decode
|
|
37
37
|
def base64_to_bytes(str)
|
|
38
|
+
raise Error, 'base64_to_bytes: received nil' if str.nil?
|
|
38
39
|
Base64.strict_decode64(str)
|
|
39
40
|
rescue ArgumentError => e
|
|
40
41
|
raise Error, "Invalid base64: #{e.message}"
|
|
@@ -42,7 +43,7 @@ module KeeperSecretsManager
|
|
|
42
43
|
|
|
43
44
|
# URL-safe base64 encode (with padding)
|
|
44
45
|
def url_safe_str_to_bytes(str)
|
|
45
|
-
|
|
46
|
+
raise Error, 'url_safe_str_to_bytes: received nil' if str.nil?
|
|
46
47
|
str += '=' * (4 - str.length % 4) if str.length % 4 != 0
|
|
47
48
|
Base64.urlsafe_decode64(str)
|
|
48
49
|
end
|
|
@@ -67,6 +68,75 @@ module KeeperSecretsManager
|
|
|
67
68
|
generate_random_bytes(16)
|
|
68
69
|
end
|
|
69
70
|
|
|
71
|
+
# Generate a cryptographically secure random password
|
|
72
|
+
#
|
|
73
|
+
# @param length [Integer] Total password length (default: 64)
|
|
74
|
+
# @param lowercase [Integer] Minimum number of lowercase letters (default: 0)
|
|
75
|
+
# @param uppercase [Integer] Minimum number of uppercase letters (default: 0)
|
|
76
|
+
# @param digits [Integer] Minimum number of digit characters (default: 0)
|
|
77
|
+
# @param special_characters [Integer] Minimum number of special characters (default: 0)
|
|
78
|
+
# @return [String] Generated password
|
|
79
|
+
# @raise [ArgumentError] If parameters are invalid or minimums exceed length
|
|
80
|
+
#
|
|
81
|
+
# @example Generate a default 64-character password
|
|
82
|
+
# password = KeeperSecretsManager::Utils.generate_password
|
|
83
|
+
# # => "Xk9$mP2...64 chars total"
|
|
84
|
+
#
|
|
85
|
+
# @example Generate a 32-character password with specific requirements
|
|
86
|
+
# password = KeeperSecretsManager::Utils.generate_password(
|
|
87
|
+
# length: 32,
|
|
88
|
+
# lowercase: 2,
|
|
89
|
+
# uppercase: 2,
|
|
90
|
+
# digits: 2,
|
|
91
|
+
# special_characters: 2
|
|
92
|
+
# )
|
|
93
|
+
# # => "aB12$...32 chars with at least 2 of each type"
|
|
94
|
+
#
|
|
95
|
+
# @example Use with record update
|
|
96
|
+
# record = secrets_manager.get_secrets(['RECORD_UID']).first
|
|
97
|
+
# record.password = KeeperSecretsManager::Utils.generate_password(length: 20)
|
|
98
|
+
# secrets_manager.update_secret(record)
|
|
99
|
+
def generate_password(length: 64, lowercase: 0, uppercase: 0, digits: 0, special_characters: 0)
|
|
100
|
+
# Validate inputs
|
|
101
|
+
raise ArgumentError, 'Length must be positive' if length <= 0
|
|
102
|
+
raise ArgumentError, 'Character counts must be non-negative' if [lowercase, uppercase, digits, special_characters].any?(&:negative?)
|
|
103
|
+
|
|
104
|
+
total_minimums = lowercase + uppercase + digits + special_characters
|
|
105
|
+
raise ArgumentError, "Sum of character minimums (#{total_minimums}) cannot exceed password length (#{length})" if total_minimums > length
|
|
106
|
+
|
|
107
|
+
# Character sets
|
|
108
|
+
lowercase_chars = 'abcdefghijklmnopqrstuvwxyz'
|
|
109
|
+
uppercase_chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
|
|
110
|
+
digit_chars = '0123456789'
|
|
111
|
+
special_chars = '!@#$%^&*()_+-=[]{}|;:,.<>?'
|
|
112
|
+
|
|
113
|
+
# Build password character array
|
|
114
|
+
password_chars = []
|
|
115
|
+
|
|
116
|
+
# Add minimum required characters from each category
|
|
117
|
+
lowercase.times { password_chars << lowercase_chars[SecureRandom.random_number(lowercase_chars.length)] }
|
|
118
|
+
uppercase.times { password_chars << uppercase_chars[SecureRandom.random_number(uppercase_chars.length)] }
|
|
119
|
+
digits.times { password_chars << digit_chars[SecureRandom.random_number(digit_chars.length)] }
|
|
120
|
+
special_characters.times { password_chars << special_chars[SecureRandom.random_number(special_chars.length)] }
|
|
121
|
+
|
|
122
|
+
# Fill remaining length with random characters from all categories
|
|
123
|
+
remaining = length - total_minimums
|
|
124
|
+
all_chars = lowercase_chars + uppercase_chars + digit_chars + special_chars
|
|
125
|
+
|
|
126
|
+
remaining.times do
|
|
127
|
+
password_chars << all_chars[SecureRandom.random_number(all_chars.length)]
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Shuffle using Fisher-Yates algorithm with SecureRandom for cryptographic security
|
|
131
|
+
# This ensures minimum characters aren't clustered at the beginning
|
|
132
|
+
(password_chars.length - 1).downto(1) do |i|
|
|
133
|
+
j = SecureRandom.random_number(i + 1)
|
|
134
|
+
password_chars[i], password_chars[j] = password_chars[j], password_chars[i]
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
password_chars.join
|
|
138
|
+
end
|
|
139
|
+
|
|
70
140
|
# Get current time in milliseconds
|
|
71
141
|
def now_milliseconds
|
|
72
142
|
(Time.now.to_f * 1000).to_i
|
|
@@ -75,7 +145,7 @@ module KeeperSecretsManager
|
|
|
75
145
|
# Convert string to boolean
|
|
76
146
|
def strtobool(val)
|
|
77
147
|
return val if val.is_a?(TrueClass) || val.is_a?(FalseClass)
|
|
78
|
-
|
|
148
|
+
|
|
79
149
|
val_str = val.to_s.downcase.strip
|
|
80
150
|
case val_str
|
|
81
151
|
when 'true', '1', 'yes', 'y', 'on'
|
|
@@ -94,7 +164,7 @@ module KeeperSecretsManager
|
|
|
94
164
|
|
|
95
165
|
# Deep merge hashes
|
|
96
166
|
def deep_merge(hash1, hash2)
|
|
97
|
-
hash1.merge(hash2) do |
|
|
167
|
+
hash1.merge(hash2) do |_key, old_val, new_val|
|
|
98
168
|
if old_val.is_a?(Hash) && new_val.is_a?(Hash)
|
|
99
169
|
deep_merge(old_val, new_val)
|
|
100
170
|
else
|
|
@@ -112,10 +182,9 @@ module KeeperSecretsManager
|
|
|
112
182
|
|
|
113
183
|
# Convert snake_case to camelCase
|
|
114
184
|
def snake_to_camel(str, capitalize_first = false)
|
|
115
|
-
|
|
185
|
+
str.split('_').map.with_index do |word, i|
|
|
116
186
|
i == 0 && !capitalize_first ? word : word.capitalize
|
|
117
187
|
end.join
|
|
118
|
-
result
|
|
119
188
|
end
|
|
120
189
|
|
|
121
190
|
# Safe integer conversion
|
|
@@ -135,10 +204,10 @@ module KeeperSecretsManager
|
|
|
135
204
|
# Parse server URL from hostname
|
|
136
205
|
def get_server_url(hostname, use_ssl = true)
|
|
137
206
|
return nil if blank?(hostname)
|
|
138
|
-
|
|
207
|
+
|
|
139
208
|
# Remove protocol if present
|
|
140
209
|
hostname = hostname.sub(%r{^https?://}, '')
|
|
141
|
-
|
|
210
|
+
|
|
142
211
|
# Build URL
|
|
143
212
|
protocol = use_ssl ? 'https' : 'http'
|
|
144
213
|
"#{protocol}://#{hostname}"
|
|
@@ -151,13 +220,13 @@ module KeeperSecretsManager
|
|
|
151
220
|
parts = token_or_hostname.split(':')
|
|
152
221
|
return parts[0].upcase if parts.length >= 2
|
|
153
222
|
end
|
|
154
|
-
|
|
223
|
+
|
|
155
224
|
# Check if hostname matches a known region
|
|
156
225
|
hostname = token_or_hostname.to_s.downcase
|
|
157
226
|
KeeperGlobals::KEEPER_SERVERS.each do |region, server|
|
|
158
227
|
return region if hostname.include?(server)
|
|
159
228
|
end
|
|
160
|
-
|
|
229
|
+
|
|
161
230
|
# Default to US
|
|
162
231
|
'US'
|
|
163
232
|
end
|
|
@@ -165,12 +234,12 @@ module KeeperSecretsManager
|
|
|
165
234
|
# Validate UID format
|
|
166
235
|
def valid_uid?(uid)
|
|
167
236
|
return false if blank?(uid)
|
|
168
|
-
|
|
237
|
+
|
|
169
238
|
# UIDs are base64url encoded 16-byte values
|
|
170
239
|
begin
|
|
171
240
|
bytes = url_safe_str_to_bytes(uid)
|
|
172
241
|
bytes.length == 16
|
|
173
|
-
rescue
|
|
242
|
+
rescue StandardError
|
|
174
243
|
false
|
|
175
244
|
end
|
|
176
245
|
end
|
|
@@ -180,17 +249,15 @@ module KeeperSecretsManager
|
|
|
180
249
|
attempt = 0
|
|
181
250
|
begin
|
|
182
251
|
yield
|
|
183
|
-
rescue => e
|
|
252
|
+
rescue StandardError => e
|
|
184
253
|
attempt += 1
|
|
185
|
-
if attempt >= max_attempts
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
delay = [base_delay * (2 ** (attempt - 1)), max_delay].min
|
|
254
|
+
raise e if attempt >= max_attempts
|
|
255
|
+
|
|
256
|
+
delay = [base_delay * (2**(attempt - 1)), max_delay].min
|
|
190
257
|
sleep(delay)
|
|
191
258
|
retry
|
|
192
259
|
end
|
|
193
260
|
end
|
|
194
261
|
end
|
|
195
262
|
end
|
|
196
|
-
end
|
|
263
|
+
end
|
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
module KeeperSecretsManager
|
|
2
|
-
VERSION = '17.0
|
|
3
|
-
end
|
|
2
|
+
VERSION = '17.2.0'.freeze
|
|
3
|
+
end
|
|
@@ -8,6 +8,8 @@ require 'keeper_secrets_manager/storage'
|
|
|
8
8
|
require 'keeper_secrets_manager/dto'
|
|
9
9
|
require 'keeper_secrets_manager/field_types'
|
|
10
10
|
require 'keeper_secrets_manager/notation'
|
|
11
|
+
require 'keeper_secrets_manager/notation_enhancements'
|
|
12
|
+
require 'keeper_secrets_manager/cache'
|
|
11
13
|
require 'keeper_secrets_manager/core'
|
|
12
14
|
require 'keeper_secrets_manager/folder_manager'
|
|
13
15
|
|
|
@@ -24,15 +26,21 @@ module KeeperSecretsManager
|
|
|
24
26
|
def self.new(options = {})
|
|
25
27
|
Core::SecretsManager.new(options)
|
|
26
28
|
end
|
|
27
|
-
|
|
29
|
+
|
|
28
30
|
# Convenience method to create from token
|
|
29
31
|
def self.from_token(token, options = {})
|
|
30
32
|
Core::SecretsManager.new(options.merge(token: token))
|
|
31
33
|
end
|
|
32
|
-
|
|
34
|
+
|
|
35
|
+
# Convenience method to create from base64 config string
|
|
36
|
+
def self.from_config(config_base64, options = {})
|
|
37
|
+
storage = Storage::InMemoryStorage.new(config_base64)
|
|
38
|
+
Core::SecretsManager.new(options.merge(config: storage))
|
|
39
|
+
end
|
|
40
|
+
|
|
33
41
|
# Convenience method to create from config file
|
|
34
42
|
def self.from_file(filename, options = {})
|
|
35
43
|
storage = Storage::FileStorage.new(filename)
|
|
36
44
|
Core::SecretsManager.new(options.merge(config: storage))
|
|
37
45
|
end
|
|
38
|
-
end
|
|
46
|
+
end
|