keeper_secrets_manager 17.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.rspec +3 -0
- data/.ruby-version +1 -0
- data/CHANGELOG.md +139 -0
- data/Gemfile +16 -0
- data/LICENSE +21 -0
- data/README.md +113 -0
- data/Rakefile +30 -0
- data/bin/console +47 -0
- data/keeper_secrets_manager.gemspec +36 -0
- data/lib/keeper_secrets_manager/cache.rb +139 -0
- data/lib/keeper_secrets_manager/config_keys.rb +29 -0
- data/lib/keeper_secrets_manager/core.rb +1781 -0
- data/lib/keeper_secrets_manager/crypto.rb +333 -0
- data/lib/keeper_secrets_manager/dto/payload.rb +153 -0
- data/lib/keeper_secrets_manager/dto.rb +557 -0
- data/lib/keeper_secrets_manager/errors.rb +90 -0
- data/lib/keeper_secrets_manager/field_types.rb +152 -0
- data/lib/keeper_secrets_manager/folder_manager.rb +110 -0
- data/lib/keeper_secrets_manager/keeper_globals.rb +53 -0
- data/lib/keeper_secrets_manager/notation.rb +463 -0
- data/lib/keeper_secrets_manager/notation_enhancements.rb +67 -0
- data/lib/keeper_secrets_manager/storage.rb +254 -0
- data/lib/keeper_secrets_manager/totp.rb +140 -0
- data/lib/keeper_secrets_manager/utils.rb +263 -0
- data/lib/keeper_secrets_manager/version.rb +3 -0
- data/lib/keeper_secrets_manager.rb +46 -0
- metadata +102 -0
|
@@ -0,0 +1,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 StandardError => 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 = %r{\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
|
+
@data = if content.strip.empty?
|
|
147
|
+
{}
|
|
148
|
+
else
|
|
149
|
+
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
|
+
# Create temp file with secure permissions (0600)
|
|
164
|
+
File.open(temp_file, 'w', 0o600) do |f|
|
|
165
|
+
f.write(JSON.pretty_generate(@data))
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Move atomically
|
|
169
|
+
File.rename(temp_file, @filename)
|
|
170
|
+
|
|
171
|
+
# Ensure final file has restrictive permissions (owner read/write only)
|
|
172
|
+
File.chmod(0o600, @filename)
|
|
173
|
+
rescue StandardError => e
|
|
174
|
+
raise Error, "Failed to save config file: #{e.message}"
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Environment-based storage (read-only)
|
|
179
|
+
class EnvironmentStorage
|
|
180
|
+
include KeyValueStorage
|
|
181
|
+
|
|
182
|
+
def initialize(prefix = 'KSM_')
|
|
183
|
+
@prefix = prefix
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def get_string(key)
|
|
187
|
+
ENV["#{@prefix}#{key.to_s.upcase}"]
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def save_string(_key, _value)
|
|
191
|
+
raise Error, 'Environment storage is read-only'
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def delete(_key)
|
|
195
|
+
raise Error, 'Environment storage is read-only'
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# Cacheable storage wrapper
|
|
200
|
+
class CachingStorage
|
|
201
|
+
include KeyValueStorage
|
|
202
|
+
|
|
203
|
+
def initialize(base_storage, ttl_seconds = 600)
|
|
204
|
+
@base_storage = base_storage
|
|
205
|
+
@ttl_seconds = ttl_seconds
|
|
206
|
+
@cache = {}
|
|
207
|
+
@timestamps = {}
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def get_string(key)
|
|
211
|
+
key_str = key.to_s
|
|
212
|
+
|
|
213
|
+
# Check cache validity
|
|
214
|
+
return @cache[key_str] if @cache.key?(key_str) && !expired?(key_str)
|
|
215
|
+
|
|
216
|
+
# Fetch from base storage
|
|
217
|
+
value = @base_storage.get_string(key)
|
|
218
|
+
if value
|
|
219
|
+
@cache[key_str] = value
|
|
220
|
+
@timestamps[key_str] = Time.now
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
value
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def save_string(key, value)
|
|
227
|
+
key_str = key.to_s
|
|
228
|
+
@base_storage.save_string(key, value)
|
|
229
|
+
@cache[key_str] = value.to_s
|
|
230
|
+
@timestamps[key_str] = Time.now
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def delete(key)
|
|
234
|
+
key_str = key.to_s
|
|
235
|
+
@base_storage.delete(key)
|
|
236
|
+
@cache.delete(key_str)
|
|
237
|
+
@timestamps.delete(key_str)
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def clear_cache
|
|
241
|
+
@cache.clear
|
|
242
|
+
@timestamps.clear
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
private
|
|
246
|
+
|
|
247
|
+
def expired?(key)
|
|
248
|
+
return true unless @timestamps[key]
|
|
249
|
+
|
|
250
|
+
Time.now - @timestamps[key] > @ttl_seconds
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
end
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# TOTP (Time-based One-Time Password) implementation
|
|
2
|
+
# Compliant with RFC 6238
|
|
3
|
+
|
|
4
|
+
require 'base32'
|
|
5
|
+
require 'openssl'
|
|
6
|
+
require 'uri'
|
|
7
|
+
|
|
8
|
+
module KeeperSecretsManager
|
|
9
|
+
class TOTP
|
|
10
|
+
ALGORITHMS = {
|
|
11
|
+
'SHA1' => OpenSSL::Digest::SHA1,
|
|
12
|
+
'SHA256' => OpenSSL::Digest::SHA256,
|
|
13
|
+
'SHA512' => OpenSSL::Digest::SHA512
|
|
14
|
+
}.freeze
|
|
15
|
+
|
|
16
|
+
# Generate a TOTP code
|
|
17
|
+
# @param secret [String] Base32 encoded secret
|
|
18
|
+
# @param time [Time] Time to generate code for (default: current time)
|
|
19
|
+
# @param algorithm [String] Hash algorithm: SHA1, SHA256, or SHA512
|
|
20
|
+
# @param digits [Integer] Number of digits (6 or 8)
|
|
21
|
+
# @param period [Integer] Time period in seconds
|
|
22
|
+
# @return [String] TOTP code
|
|
23
|
+
def self.generate_code(secret, time: Time.now, algorithm: 'SHA1', digits: 6, period: 30)
|
|
24
|
+
# Validate inputs
|
|
25
|
+
raise ArgumentError, "Invalid algorithm: #{algorithm}" unless ALGORITHMS.key?(algorithm)
|
|
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
|
+
# Decode base32 secret
|
|
30
|
+
key = Base32.decode(secret.upcase.tr(' ', ''))
|
|
31
|
+
|
|
32
|
+
# Calculate time counter
|
|
33
|
+
counter = (time.to_i / period).floor
|
|
34
|
+
|
|
35
|
+
# Convert counter to 8-byte string (big-endian)
|
|
36
|
+
counter_bytes = [counter].pack('Q>')
|
|
37
|
+
|
|
38
|
+
# Generate HMAC
|
|
39
|
+
digest = ALGORITHMS[algorithm].new
|
|
40
|
+
hmac = OpenSSL::HMAC.digest(digest, key, counter_bytes)
|
|
41
|
+
|
|
42
|
+
# Extract dynamic binary code
|
|
43
|
+
offset = hmac[-1].ord & 0x0f
|
|
44
|
+
code = (hmac[offset].ord & 0x7f) << 24 |
|
|
45
|
+
(hmac[offset + 1].ord & 0xff) << 16 |
|
|
46
|
+
(hmac[offset + 2].ord & 0xff) << 8 |
|
|
47
|
+
(hmac[offset + 3].ord & 0xff)
|
|
48
|
+
|
|
49
|
+
# Generate final OTP value
|
|
50
|
+
otp = code % (10**digits)
|
|
51
|
+
|
|
52
|
+
# Pad with leading zeros if necessary
|
|
53
|
+
otp.to_s.rjust(digits, '0')
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Parse TOTP URL (otpauth://totp/...)
|
|
57
|
+
# @param url [String] TOTP URL
|
|
58
|
+
# @return [Hash] Parsed components
|
|
59
|
+
def self.parse_url(url)
|
|
60
|
+
uri = URI(url)
|
|
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
|
+
# Extract label (issuer:account or just account)
|
|
66
|
+
path = uri.path[1..-1] # Remove leading /
|
|
67
|
+
if path.include?(':')
|
|
68
|
+
issuer, account = path.split(':', 2)
|
|
69
|
+
else
|
|
70
|
+
account = path
|
|
71
|
+
issuer = nil
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Parse query parameters
|
|
75
|
+
params = URI.decode_www_form(uri.query || '').to_h
|
|
76
|
+
|
|
77
|
+
{
|
|
78
|
+
'account' => URI.decode_www_form_component(account || ''),
|
|
79
|
+
'issuer' => issuer ? URI.decode_www_form_component(issuer) : params['issuer'],
|
|
80
|
+
'secret' => params['secret'],
|
|
81
|
+
'algorithm' => params['algorithm'] || 'SHA1',
|
|
82
|
+
'digits' => (params['digits'] || '6').to_i,
|
|
83
|
+
'period' => (params['period'] || '30').to_i
|
|
84
|
+
}
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Generate TOTP URL
|
|
88
|
+
# @param account [String] Account name (e.g., email)
|
|
89
|
+
# @param secret [String] Base32 encoded secret
|
|
90
|
+
# @param issuer [String] Service name
|
|
91
|
+
# @param algorithm [String] Hash algorithm
|
|
92
|
+
# @param digits [Integer] Number of digits
|
|
93
|
+
# @param period [Integer] Time period
|
|
94
|
+
# @return [String] TOTP URL
|
|
95
|
+
def self.generate_url(account, secret, issuer: nil, algorithm: 'SHA1', digits: 6, period: 30)
|
|
96
|
+
label = issuer ? "#{issuer}:#{account}" : account
|
|
97
|
+
|
|
98
|
+
params = {
|
|
99
|
+
'secret' => secret,
|
|
100
|
+
'algorithm' => algorithm,
|
|
101
|
+
'digits' => digits,
|
|
102
|
+
'period' => period
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
params['issuer'] = issuer if issuer
|
|
106
|
+
|
|
107
|
+
query = URI.encode_www_form(params)
|
|
108
|
+
"otpauth://totp/#{URI.encode_www_form_component(label)}?#{query}"
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Validate a TOTP code
|
|
112
|
+
# @param secret [String] Base32 encoded secret
|
|
113
|
+
# @param code [String] Code to validate
|
|
114
|
+
# @param time [Time] Time to validate against
|
|
115
|
+
# @param window [Integer] Number of periods to check before/after
|
|
116
|
+
# @param algorithm [String] Hash algorithm
|
|
117
|
+
# @param digits [Integer] Number of digits
|
|
118
|
+
# @param period [Integer] Time period
|
|
119
|
+
# @return [Boolean] True if code is valid
|
|
120
|
+
def self.validate_code(secret, code, time: Time.now, window: 1, algorithm: 'SHA1', digits: 6, period: 30)
|
|
121
|
+
# Check current time and window
|
|
122
|
+
(-window..window).each do |offset|
|
|
123
|
+
test_time = time + (offset * period)
|
|
124
|
+
test_code = generate_code(secret, time: test_time, algorithm: algorithm, digits: digits, period: period)
|
|
125
|
+
|
|
126
|
+
return true if test_code == code
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
false
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Generate a random secret suitable for TOTP
|
|
133
|
+
# @param length [Integer] Number of bytes (default: 20 for 160 bits)
|
|
134
|
+
# @return [String] Base32 encoded secret
|
|
135
|
+
def self.generate_secret(length: 20)
|
|
136
|
+
random_bytes = OpenSSL::Random.random_bytes(length)
|
|
137
|
+
Base32.encode(random_bytes).delete('=')
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
require 'json'
|
|
2
|
+
require 'base64'
|
|
3
|
+
require 'securerandom'
|
|
4
|
+
require 'time'
|
|
5
|
+
|
|
6
|
+
module KeeperSecretsManager
|
|
7
|
+
module Utils
|
|
8
|
+
class << self
|
|
9
|
+
# Convert string to bytes
|
|
10
|
+
def string_to_bytes(str)
|
|
11
|
+
str.b
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Convert bytes to string
|
|
15
|
+
def bytes_to_string(bytes)
|
|
16
|
+
bytes.force_encoding('UTF-8')
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Convert hash/object to JSON string
|
|
20
|
+
def dict_to_json(obj)
|
|
21
|
+
JSON.generate(obj)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Parse JSON string to hash
|
|
25
|
+
def json_to_dict(json_str)
|
|
26
|
+
JSON.parse(json_str)
|
|
27
|
+
rescue JSON::ParserError => e
|
|
28
|
+
raise Error, "Invalid JSON: #{e.message}"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Base64 encode
|
|
32
|
+
def bytes_to_base64(bytes)
|
|
33
|
+
Base64.strict_encode64(bytes)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Base64 decode
|
|
37
|
+
def base64_to_bytes(str)
|
|
38
|
+
raise Error, 'base64_to_bytes: received nil' if str.nil?
|
|
39
|
+
Base64.strict_decode64(str)
|
|
40
|
+
rescue ArgumentError => e
|
|
41
|
+
raise Error, "Invalid base64: #{e.message}"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# URL-safe base64 encode (with padding)
|
|
45
|
+
def url_safe_str_to_bytes(str)
|
|
46
|
+
raise Error, 'url_safe_str_to_bytes: received nil' if str.nil?
|
|
47
|
+
str += '=' * (4 - str.length % 4) if str.length % 4 != 0
|
|
48
|
+
Base64.urlsafe_decode64(str)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# URL-safe base64 decode (without padding)
|
|
52
|
+
def bytes_to_url_safe_str(bytes)
|
|
53
|
+
Base64.urlsafe_encode64(bytes).delete('=')
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Generate random bytes
|
|
57
|
+
def generate_random_bytes(length)
|
|
58
|
+
SecureRandom.random_bytes(length)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Generate UID (16 random bytes)
|
|
62
|
+
def generate_uid
|
|
63
|
+
bytes_to_url_safe_str(generate_random_bytes(16))
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Generate UID bytes
|
|
67
|
+
def generate_uid_bytes
|
|
68
|
+
generate_random_bytes(16)
|
|
69
|
+
end
|
|
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
|
+
|
|
140
|
+
# Get current time in milliseconds
|
|
141
|
+
def now_milliseconds
|
|
142
|
+
(Time.now.to_f * 1000).to_i
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Convert string to boolean
|
|
146
|
+
def strtobool(val)
|
|
147
|
+
return val if val.is_a?(TrueClass) || val.is_a?(FalseClass)
|
|
148
|
+
|
|
149
|
+
val_str = val.to_s.downcase.strip
|
|
150
|
+
case val_str
|
|
151
|
+
when 'true', '1', 'yes', 'y', 'on'
|
|
152
|
+
true
|
|
153
|
+
when 'false', '0', 'no', 'n', 'off', ''
|
|
154
|
+
false
|
|
155
|
+
else
|
|
156
|
+
raise ArgumentError, "Invalid boolean value: #{val}"
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Check if string is blank
|
|
161
|
+
def blank?(str)
|
|
162
|
+
str.nil? || str.strip.empty?
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Deep merge hashes
|
|
166
|
+
def deep_merge(hash1, hash2)
|
|
167
|
+
hash1.merge(hash2) do |_key, old_val, new_val|
|
|
168
|
+
if old_val.is_a?(Hash) && new_val.is_a?(Hash)
|
|
169
|
+
deep_merge(old_val, new_val)
|
|
170
|
+
else
|
|
171
|
+
new_val
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Convert camelCase to snake_case
|
|
177
|
+
def camel_to_snake(str)
|
|
178
|
+
str.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
|
|
179
|
+
.gsub(/([a-z\d])([A-Z])/, '\1_\2')
|
|
180
|
+
.downcase
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Convert snake_case to camelCase
|
|
184
|
+
def snake_to_camel(str, capitalize_first = false)
|
|
185
|
+
str.split('_').map.with_index do |word, i|
|
|
186
|
+
i == 0 && !capitalize_first ? word : word.capitalize
|
|
187
|
+
end.join
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# Safe integer conversion
|
|
191
|
+
def to_int(val, default = nil)
|
|
192
|
+
Integer(val)
|
|
193
|
+
rescue ArgumentError, TypeError
|
|
194
|
+
default
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# URL join
|
|
198
|
+
def url_join(*parts)
|
|
199
|
+
parts.map { |part| part.to_s.gsub(%r{^/+|/+$}, '') }
|
|
200
|
+
.reject(&:empty?)
|
|
201
|
+
.join('/')
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# Parse server URL from hostname
|
|
205
|
+
def get_server_url(hostname, use_ssl = true)
|
|
206
|
+
return nil if blank?(hostname)
|
|
207
|
+
|
|
208
|
+
# Remove protocol if present
|
|
209
|
+
hostname = hostname.sub(%r{^https?://}, '')
|
|
210
|
+
|
|
211
|
+
# Build URL
|
|
212
|
+
protocol = use_ssl ? 'https' : 'http'
|
|
213
|
+
"#{protocol}://#{hostname}"
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# Extract region from token or hostname
|
|
217
|
+
def extract_region(token_or_hostname)
|
|
218
|
+
# Check if it's a token with region prefix
|
|
219
|
+
if token_or_hostname&.include?(':')
|
|
220
|
+
parts = token_or_hostname.split(':')
|
|
221
|
+
return parts[0].upcase if parts.length >= 2
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# Check if hostname matches a known region
|
|
225
|
+
hostname = token_or_hostname.to_s.downcase
|
|
226
|
+
KeeperGlobals::KEEPER_SERVERS.each do |region, server|
|
|
227
|
+
return region if hostname.include?(server)
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# Default to US
|
|
231
|
+
'US'
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# Validate UID format
|
|
235
|
+
def valid_uid?(uid)
|
|
236
|
+
return false if blank?(uid)
|
|
237
|
+
|
|
238
|
+
# UIDs are base64url encoded 16-byte values
|
|
239
|
+
begin
|
|
240
|
+
bytes = url_safe_str_to_bytes(uid)
|
|
241
|
+
bytes.length == 16
|
|
242
|
+
rescue StandardError
|
|
243
|
+
false
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# Retry with exponential backoff
|
|
248
|
+
def retry_with_backoff(max_attempts: 3, base_delay: 1, max_delay: 60)
|
|
249
|
+
attempt = 0
|
|
250
|
+
begin
|
|
251
|
+
yield
|
|
252
|
+
rescue StandardError => e
|
|
253
|
+
attempt += 1
|
|
254
|
+
raise e if attempt >= max_attempts
|
|
255
|
+
|
|
256
|
+
delay = [base_delay * (2**(attempt - 1)), max_delay].min
|
|
257
|
+
sleep(delay)
|
|
258
|
+
retry
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
require 'keeper_secrets_manager/version'
|
|
2
|
+
require 'keeper_secrets_manager/errors'
|
|
3
|
+
require 'keeper_secrets_manager/config_keys'
|
|
4
|
+
require 'keeper_secrets_manager/keeper_globals'
|
|
5
|
+
require 'keeper_secrets_manager/utils'
|
|
6
|
+
require 'keeper_secrets_manager/crypto'
|
|
7
|
+
require 'keeper_secrets_manager/storage'
|
|
8
|
+
require 'keeper_secrets_manager/dto'
|
|
9
|
+
require 'keeper_secrets_manager/field_types'
|
|
10
|
+
require 'keeper_secrets_manager/notation'
|
|
11
|
+
require 'keeper_secrets_manager/notation_enhancements'
|
|
12
|
+
require 'keeper_secrets_manager/cache'
|
|
13
|
+
require 'keeper_secrets_manager/core'
|
|
14
|
+
require 'keeper_secrets_manager/folder_manager'
|
|
15
|
+
|
|
16
|
+
# Optional TOTP support (only load if base32 gem is available)
|
|
17
|
+
begin
|
|
18
|
+
require 'keeper_secrets_manager/totp'
|
|
19
|
+
rescue LoadError => e
|
|
20
|
+
# TOTP support not available without base32 gem
|
|
21
|
+
# This is optional functionality
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
module KeeperSecretsManager
|
|
25
|
+
# Main entry point for the SDK
|
|
26
|
+
def self.new(options = {})
|
|
27
|
+
Core::SecretsManager.new(options)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Convenience method to create from token
|
|
31
|
+
def self.from_token(token, options = {})
|
|
32
|
+
Core::SecretsManager.new(options.merge(token: token))
|
|
33
|
+
end
|
|
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
|
+
|
|
41
|
+
# Convenience method to create from config file
|
|
42
|
+
def self.from_file(filename, options = {})
|
|
43
|
+
storage = Storage::FileStorage.new(filename)
|
|
44
|
+
Core::SecretsManager.new(options.merge(config: storage))
|
|
45
|
+
end
|
|
46
|
+
end
|