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