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,348 @@
1
+ require 'openssl'
2
+ require 'base64'
3
+ require 'securerandom'
4
+
5
+ module KeeperSecretsManager
6
+ module Crypto
7
+ # AES GCM constants
8
+ GCM_IV_LENGTH = 12
9
+ GCM_TAG_LENGTH = 16
10
+ AES_KEY_LENGTH = 32
11
+
12
+ # Block size for padding
13
+ BLOCK_SIZE = 16
14
+
15
+ class << self
16
+ # Generate random bytes
17
+ def generate_random_bytes(length)
18
+ SecureRandom.random_bytes(length)
19
+ end
20
+
21
+ # Generate encryption key (32 bytes)
22
+ def generate_encryption_key_bytes
23
+ generate_random_bytes(AES_KEY_LENGTH)
24
+ end
25
+
26
+ # Convert bytes to URL-safe base64 string (no padding)
27
+ def bytes_to_url_safe_str(bytes)
28
+ Base64.urlsafe_encode64(bytes).delete('=')
29
+ end
30
+
31
+ # Convert URL-safe base64 string to bytes
32
+ def url_safe_str_to_bytes(str)
33
+ # Add padding if needed
34
+ str += '=' * (4 - str.length % 4) if str.length % 4 != 0
35
+ Base64.urlsafe_decode64(str)
36
+ end
37
+
38
+ # Convert bytes to base64
39
+ def bytes_to_base64(bytes)
40
+ Base64.strict_encode64(bytes)
41
+ end
42
+
43
+ # Convert base64 to bytes
44
+ def base64_to_bytes(str)
45
+ Base64.strict_decode64(str)
46
+ end
47
+
48
+ # Generate ECC key pair
49
+ def generate_ecc_keys
50
+ # Generate private key bytes
51
+ private_key_bytes = generate_encryption_key_bytes
52
+ private_key_str = bytes_to_url_safe_str(private_key_bytes)
53
+
54
+ # Create EC key from private key bytes
55
+ private_key_bn = OpenSSL::BN.new(private_key_bytes, 2)
56
+
57
+ # OpenSSL 3.0 compatibility - use ASN1 sequence to create key
58
+ group = OpenSSL::PKey::EC::Group.new('prime256v1')
59
+
60
+ # Generate public key point
61
+ public_key_point = group.generator.mul(private_key_bn)
62
+
63
+ # Create ASN1 sequence for the key
64
+ asn1 = OpenSSL::ASN1::Sequence([
65
+ OpenSSL::ASN1::Integer(1),
66
+ OpenSSL::ASN1::OctetString(private_key_bytes),
67
+ OpenSSL::ASN1::ObjectId('prime256v1', 0, :EXPLICIT),
68
+ OpenSSL::ASN1::BitString(public_key_point.to_octet_string(:uncompressed), 1, :EXPLICIT)
69
+ ])
70
+
71
+ # Create key from DER
72
+ key = OpenSSL::PKey::EC.new(asn1.to_der)
73
+
74
+ # Get public key bytes (uncompressed format)
75
+ public_key_bytes = key.public_key.to_octet_string(:uncompressed)
76
+ public_key_str = bytes_to_url_safe_str(public_key_bytes)
77
+
78
+ # Also store the EC key in DER format for compatibility
79
+ private_key_der = key.to_der
80
+
81
+ {
82
+ private_key_str: private_key_str,
83
+ public_key_str: public_key_str,
84
+ private_key_bytes: private_key_der, # Store DER format
85
+ public_key_bytes: public_key_bytes,
86
+ private_key_obj: key
87
+ }
88
+ end
89
+
90
+ # Encrypt with AES-GCM or fallback to CBC
91
+ def encrypt_aes_gcm(data, key)
92
+ begin
93
+ cipher = OpenSSL::Cipher.new('AES-256-GCM')
94
+ cipher.encrypt
95
+
96
+ # Generate random IV
97
+ iv = generate_random_bytes(GCM_IV_LENGTH)
98
+ cipher.iv = iv
99
+ cipher.key = key
100
+
101
+ # Encrypt data
102
+ encrypted = cipher.update(data) + cipher.final
103
+
104
+ # Get authentication tag
105
+ tag = cipher.auth_tag(GCM_TAG_LENGTH)
106
+
107
+ # Combine IV + encrypted + tag
108
+ iv + encrypted + tag
109
+ rescue RuntimeError => e
110
+ if e.message.include?('unsupported cipher')
111
+ # Fallback to AES-CBC for older Ruby/OpenSSL
112
+ encrypt_aes_cbc(data, key)
113
+ else
114
+ raise e
115
+ end
116
+ end
117
+ end
118
+
119
+ # Decrypt with AES-GCM or fallback to CBC
120
+ def decrypt_aes_gcm(encrypted_data, key)
121
+ begin
122
+ # Try GCM first
123
+ # Extract components
124
+ iv = encrypted_data[0...GCM_IV_LENGTH]
125
+ tag = encrypted_data[-GCM_TAG_LENGTH..]
126
+ ciphertext = encrypted_data[GCM_IV_LENGTH...-GCM_TAG_LENGTH]
127
+
128
+ cipher = OpenSSL::Cipher.new('AES-256-GCM')
129
+ cipher.decrypt
130
+ cipher.iv = iv
131
+ cipher.key = key
132
+ cipher.auth_tag = tag
133
+
134
+ cipher.update(ciphertext) + cipher.final
135
+ rescue RuntimeError => e
136
+ if e.message.include?('unsupported cipher')
137
+ # Fallback to AES-CBC
138
+ decrypt_aes_cbc(encrypted_data, key)
139
+ else
140
+ raise e
141
+ end
142
+ rescue OpenSSL::Cipher::CipherError => e
143
+ # Maybe it's CBC encrypted?
144
+ begin
145
+ decrypt_aes_cbc(encrypted_data, key)
146
+ rescue
147
+ raise DecryptionError, "Failed to decrypt data: #{e.message}"
148
+ end
149
+ end
150
+ end
151
+
152
+ # Legacy AES-CBC encryption (for compatibility)
153
+ def encrypt_aes_cbc(data, key, iv = nil)
154
+ cipher = OpenSSL::Cipher.new('AES-256-CBC')
155
+ cipher.encrypt
156
+
157
+ iv ||= generate_random_bytes(BLOCK_SIZE)
158
+ cipher.iv = iv
159
+ cipher.key = key
160
+
161
+ # Apply PKCS7 padding
162
+ padded_data = pad_data(data)
163
+ encrypted = cipher.update(padded_data) + cipher.final
164
+
165
+ # Return IV + encrypted
166
+ iv + encrypted
167
+ end
168
+
169
+ # Legacy AES-CBC decryption
170
+ def decrypt_aes_cbc(encrypted_data, key)
171
+ # Extract IV
172
+ iv = encrypted_data[0...BLOCK_SIZE]
173
+ ciphertext = encrypted_data[BLOCK_SIZE..]
174
+
175
+ cipher = OpenSSL::Cipher.new('AES-256-CBC')
176
+ cipher.decrypt
177
+ cipher.iv = iv
178
+ cipher.key = key
179
+
180
+ decrypted = cipher.update(ciphertext) + cipher.final
181
+
182
+ # Remove padding
183
+ unpad_data(decrypted)
184
+ rescue OpenSSL::Cipher::CipherError => e
185
+ raise DecryptionError, "Failed to decrypt data: #{e.message}"
186
+ end
187
+
188
+ # PKCS7 padding
189
+ def pad_data(data)
190
+ data = data.b if data.is_a?(String)
191
+ pad_len = BLOCK_SIZE - (data.length % BLOCK_SIZE)
192
+ data + (pad_len.chr * pad_len).b
193
+ end
194
+
195
+ # Remove PKCS7 padding
196
+ def unpad_data(data)
197
+ return data if data.empty?
198
+
199
+ pad_len = data[-1].ord
200
+
201
+ # Validate padding
202
+ if pad_len > 0 && pad_len <= BLOCK_SIZE && pad_len <= data.length
203
+ # Check if all padding bytes are the same
204
+ padding = data[-pad_len..]
205
+ if padding.bytes.all? { |b| b == pad_len }
206
+ return data[0...-pad_len]
207
+ end
208
+ end
209
+
210
+ data
211
+ end
212
+
213
+ # Generate HMAC signature
214
+ def generate_hmac(key, data)
215
+ OpenSSL::HMAC.digest('SHA512', key, data)
216
+ end
217
+
218
+ # Generate ECDSA signature
219
+ def sign_ec(data, private_key)
220
+ # Use SHA256 for ECDSA signature
221
+ digest = OpenSSL::Digest::SHA256.new
222
+ private_key.sign(digest, data)
223
+ end
224
+
225
+ # Verify HMAC signature
226
+ def verify_hmac(key, data, signature)
227
+ expected = generate_hmac(key, data)
228
+
229
+ # Constant time comparison
230
+ return false unless expected.bytesize == signature.bytesize
231
+
232
+ result = 0
233
+ expected.bytes.zip(signature.bytes) { |a, b| result |= a ^ b }
234
+ result == 0
235
+ end
236
+
237
+ # Load private key from DER format
238
+ def load_private_key_der(der_bytes, password = nil)
239
+ OpenSSL::PKey.read(der_bytes, password)
240
+ rescue => e
241
+ raise CryptoError, "Failed to load private key: #{e.message}"
242
+ end
243
+
244
+ # Load public key from DER format
245
+ def load_public_key_der(der_bytes)
246
+ OpenSSL::PKey.read(der_bytes)
247
+ rescue => e
248
+ raise CryptoError, "Failed to load public key: #{e.message}"
249
+ end
250
+
251
+ # Export EC private key to DER
252
+ def export_private_key_der(ec_key)
253
+ ec_key.to_der
254
+ end
255
+
256
+ # Export EC public key to DER
257
+ def export_public_key_der(ec_key)
258
+ ec_key.public_key.to_der
259
+ end
260
+
261
+ # Encrypt with EC public key (ECIES-like)
262
+ def encrypt_ec(data, public_key_bytes)
263
+ # Load public key
264
+ public_key = load_ec_public_key(public_key_bytes)
265
+
266
+ # Generate ephemeral key pair
267
+ ephemeral = OpenSSL::PKey::EC.generate('prime256v1')
268
+
269
+ # Perform ECDH to get shared secret
270
+ # The shared secret is computed using ECDH between ephemeral private key and server public key
271
+ shared_secret = ephemeral.dh_compute_key(public_key.public_key)
272
+
273
+ # Derive encryption key using SHA256
274
+ encryption_key = OpenSSL::Digest::SHA256.digest(shared_secret)
275
+
276
+ # Encrypt data with AES-GCM
277
+ encrypted_data = encrypt_aes_gcm(data, encryption_key)
278
+
279
+ # Return ephemeral public key + encrypted data
280
+ ephemeral_public = ephemeral.public_key.to_octet_string(:uncompressed)
281
+ ephemeral_public + encrypted_data
282
+ end
283
+
284
+ # Decrypt with EC private key
285
+ def decrypt_ec(encrypted_data, private_key)
286
+ # Extract ephemeral public key (65 bytes for uncompressed)
287
+ ephemeral_public_bytes = encrypted_data[0...65]
288
+ ciphertext = encrypted_data[65..]
289
+
290
+ # Create EC key with ephemeral public key
291
+ group = OpenSSL::PKey::EC::Group.new('prime256v1')
292
+ ephemeral_point = OpenSSL::PKey::EC::Point.new(group, ephemeral_public_bytes)
293
+
294
+ # Compute shared secret using ECDH
295
+ shared_secret = private_key.dh_compute_key(ephemeral_point)
296
+
297
+ # Derive decryption key
298
+ decryption_key = OpenSSL::Digest::SHA256.digest(shared_secret)
299
+
300
+ # Decrypt data
301
+ decrypt_aes_gcm(ciphertext, decryption_key)
302
+ end
303
+
304
+ private
305
+
306
+ # Load EC public key from bytes
307
+ def load_ec_public_key(public_key_bytes)
308
+ # If the bytes are longer than 65, it might be DER encoded
309
+ # Extract the raw point bytes (last 65 bytes)
310
+ if public_key_bytes.bytesize > 65
311
+ public_key_bytes = public_key_bytes[-65..-1]
312
+ end
313
+
314
+ # For OpenSSL 3.0+, we need to create the key differently
315
+ begin
316
+ # Try the OpenSSL 3.0+ way first
317
+ group = OpenSSL::PKey::EC::Group.new('prime256v1')
318
+ point = OpenSSL::PKey::EC::Point.new(group, public_key_bytes)
319
+
320
+ # Create key from point directly using ASN1
321
+ asn1 = OpenSSL::ASN1::Sequence([
322
+ OpenSSL::ASN1::Sequence([
323
+ OpenSSL::ASN1::ObjectId("id-ecPublicKey"),
324
+ OpenSSL::ASN1::ObjectId("prime256v1")
325
+ ]),
326
+ OpenSSL::ASN1::BitString(public_key_bytes)
327
+ ])
328
+
329
+ OpenSSL::PKey::EC.new(asn1.to_der)
330
+ rescue => e
331
+ # Fall back to old method for older OpenSSL
332
+ group = OpenSSL::PKey::EC::Group.new('prime256v1')
333
+ point = OpenSSL::PKey::EC::Point.new(group, public_key_bytes)
334
+
335
+ key = OpenSSL::PKey::EC.new(group)
336
+ key.public_key = point
337
+ key
338
+ end
339
+ end
340
+
341
+ # Load EC public key from point bytes
342
+ def load_ec_public_key_from_bytes(point_bytes)
343
+ group = OpenSSL::PKey::EC::Group.new('prime256v1')
344
+ OpenSSL::PKey::EC::Point.new(group, point_bytes)
345
+ end
346
+ end
347
+ end
348
+ end
@@ -0,0 +1,152 @@
1
+ module KeeperSecretsManager
2
+ module Dto
3
+ # Transmission key for encrypted communication
4
+ class TransmissionKey
5
+ attr_accessor :public_key_id, :key, :encrypted_key
6
+
7
+ def initialize(public_key_id:, key:, encrypted_key:)
8
+ @public_key_id = public_key_id
9
+ @key = key
10
+ @encrypted_key = encrypted_key
11
+ end
12
+ end
13
+
14
+ # Base payload class
15
+ class BasePayload
16
+ attr_accessor :client_version, :client_id
17
+
18
+ def to_h
19
+ hash = {}
20
+ instance_variables.each do |var|
21
+ key = var.to_s.delete('@')
22
+ value = instance_variable_get(var)
23
+
24
+ # Convert Ruby snake_case to camelCase for API
25
+ api_key = Utils.snake_to_camel(key)
26
+ hash[api_key] = value unless value.nil?
27
+ end
28
+ hash
29
+ end
30
+
31
+ def to_json(*args)
32
+ to_h.to_json(*args)
33
+ end
34
+ end
35
+
36
+ # Get secrets payload
37
+ class GetPayload < BasePayload
38
+ attr_accessor :public_key, :requested_records, :requested_folders, :file_uids
39
+
40
+ def initialize
41
+ super()
42
+ @requested_records = nil
43
+ @requested_folders = nil
44
+ @file_uids = nil
45
+ end
46
+ end
47
+
48
+ # Create record payload
49
+ class CreatePayload < BasePayload
50
+ attr_accessor :record_uid, :record_key, :folder_uid, :folder_key,
51
+ :data, :sub_folder_uid
52
+
53
+ def initialize
54
+ super()
55
+ end
56
+ end
57
+
58
+ # Update record payload
59
+ class UpdatePayload < BasePayload
60
+ attr_accessor :record_uid, :data, :revision, :transaction_type
61
+
62
+ def initialize
63
+ super()
64
+ @transaction_type = 'general'
65
+ end
66
+ end
67
+
68
+ # Delete records payload
69
+ class DeletePayload < BasePayload
70
+ attr_accessor :record_uids
71
+
72
+ def initialize
73
+ super()
74
+ @record_uids = []
75
+ end
76
+ end
77
+
78
+ # Complete transaction payload
79
+ class CompleteTransactionPayload < BasePayload
80
+ attr_accessor :record_uid
81
+
82
+ def initialize
83
+ super()
84
+ end
85
+ end
86
+
87
+ # File upload payload
88
+ class FileUploadPayload < BasePayload
89
+ attr_accessor :file_record_uid, :file_record_key, :file_record_data,
90
+ :owner_record_uid, :owner_record_data, :link_key, :file_size
91
+
92
+ def initialize
93
+ super()
94
+ end
95
+ end
96
+
97
+ # Create folder payload
98
+ class CreateFolderPayload < BasePayload
99
+ attr_accessor :folder_uid, :shared_folder_uid, :shared_folder_key,
100
+ :data, :parent_uid
101
+
102
+ def initialize
103
+ super()
104
+ end
105
+ end
106
+
107
+ # Update folder payload
108
+ class UpdateFolderPayload < BasePayload
109
+ attr_accessor :folder_uid, :data
110
+
111
+ def initialize
112
+ super()
113
+ end
114
+ end
115
+
116
+ # Delete folder payload
117
+ class DeleteFolderPayload < BasePayload
118
+ attr_accessor :folder_uids, :force_deletion
119
+
120
+ def initialize
121
+ super()
122
+ @folder_uids = []
123
+ @force_deletion = false
124
+ end
125
+ end
126
+
127
+ # Encrypted payload wrapper
128
+ class EncryptedPayload
129
+ attr_accessor :encrypted_payload, :signature
130
+
131
+ def initialize(encrypted_payload:, signature:)
132
+ @encrypted_payload = encrypted_payload
133
+ @signature = signature
134
+ end
135
+ end
136
+
137
+ # HTTP response wrapper
138
+ class KSMHttpResponse
139
+ attr_accessor :status_code, :data, :http_response
140
+
141
+ def initialize(status_code:, data:, http_response: nil)
142
+ @status_code = status_code
143
+ @data = data
144
+ @http_response = http_response
145
+ end
146
+
147
+ def success?
148
+ status_code >= 200 && status_code < 300
149
+ end
150
+ end
151
+ end
152
+ end