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.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +100 -16
  3. data/Gemfile +6 -3
  4. data/README.md +75 -270
  5. data/Rakefile +1 -1
  6. data/bin/console +47 -0
  7. data/keeper_secrets_manager.gemspec +36 -0
  8. data/lib/keeper_secrets_manager/cache.rb +139 -0
  9. data/lib/keeper_secrets_manager/config_keys.rb +4 -2
  10. data/lib/keeper_secrets_manager/core.rb +993 -452
  11. data/lib/keeper_secrets_manager/crypto.rb +101 -116
  12. data/lib/keeper_secrets_manager/dto/payload.rb +7 -6
  13. data/lib/keeper_secrets_manager/dto.rb +374 -38
  14. data/lib/keeper_secrets_manager/errors.rb +13 -2
  15. data/lib/keeper_secrets_manager/field_types.rb +3 -3
  16. data/lib/keeper_secrets_manager/folder_manager.rb +25 -29
  17. data/lib/keeper_secrets_manager/keeper_globals.rb +11 -17
  18. data/lib/keeper_secrets_manager/notation.rb +202 -93
  19. data/lib/keeper_secrets_manager/notation_enhancements.rb +24 -24
  20. data/lib/keeper_secrets_manager/storage.rb +38 -38
  21. data/lib/keeper_secrets_manager/totp.rb +27 -27
  22. data/lib/keeper_secrets_manager/utils.rb +85 -18
  23. data/lib/keeper_secrets_manager/version.rb +2 -2
  24. data/lib/keeper_secrets_manager.rb +11 -3
  25. metadata +39 -22
  26. data/DEVELOPER_SETUP.md +0 -0
  27. data/MANUAL_TESTING_GUIDE.md +0 -332
  28. data/RUBY_SDK_COMPLETE_DOCUMENTATION.md +0 -354
  29. data/RUBY_SDK_COMPREHENSIVE_SUMMARY.md +0 -192
  30. data/examples/01_quick_start.rb +0 -45
  31. data/examples/02_authentication.rb +0 -82
  32. data/examples/03_retrieve_secrets.rb +0 -81
  33. data/examples/04_create_update_delete.rb +0 -104
  34. data/examples/05_field_types.rb +0 -135
  35. data/examples/06_files.rb +0 -137
  36. data/examples/07_folders.rb +0 -145
  37. data/examples/08_notation.rb +0 -103
  38. data/examples/09_totp.rb +0 -100
  39. data/examples/README.md +0 -89
@@ -8,7 +8,7 @@ module KeeperSecretsManager
8
8
  GCM_IV_LENGTH = 12
9
9
  GCM_TAG_LENGTH = 16
10
10
  AES_KEY_LENGTH = 32
11
-
11
+
12
12
  # Block size for padding
13
13
  BLOCK_SIZE = 16
14
14
 
@@ -30,7 +30,7 @@ module KeeperSecretsManager
30
30
 
31
31
  # Convert URL-safe base64 string to bytes
32
32
  def url_safe_str_to_bytes(str)
33
- # Add padding if needed
33
+ raise CryptoError, 'url_safe_str_to_bytes: received nil' if str.nil?
34
34
  str += '=' * (4 - str.length % 4) if str.length % 4 != 0
35
35
  Base64.urlsafe_decode64(str)
36
36
  end
@@ -42,6 +42,7 @@ module KeeperSecretsManager
42
42
 
43
43
  # Convert base64 to bytes
44
44
  def base64_to_bytes(str)
45
+ raise CryptoError, 'base64_to_bytes: received nil' if str.nil?
45
46
  Base64.strict_decode64(str)
46
47
  end
47
48
 
@@ -50,38 +51,40 @@ module KeeperSecretsManager
50
51
  # Generate private key bytes
51
52
  private_key_bytes = generate_encryption_key_bytes
52
53
  private_key_str = bytes_to_url_safe_str(private_key_bytes)
53
-
54
+
54
55
  # Create EC key from private key bytes
55
56
  private_key_bn = OpenSSL::BN.new(private_key_bytes, 2)
56
-
57
+
57
58
  # OpenSSL 3.0 compatibility - use ASN1 sequence to create key
58
59
  group = OpenSSL::PKey::EC::Group.new('prime256v1')
59
-
60
+
60
61
  # Generate public key point
61
62
  public_key_point = group.generator.mul(private_key_bn)
62
-
63
+
63
64
  # Create ASN1 sequence for the key
64
65
  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
-
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
+
71
73
  # Create key from DER
72
74
  key = OpenSSL::PKey::EC.new(asn1.to_der)
73
-
75
+
74
76
  # Get public key bytes (uncompressed format)
75
77
  public_key_bytes = key.public_key.to_octet_string(:uncompressed)
76
78
  public_key_str = bytes_to_url_safe_str(public_key_bytes)
77
-
79
+
78
80
  # Also store the EC key in DER format for compatibility
79
81
  private_key_der = key.to_der
80
-
82
+
81
83
  {
82
84
  private_key_str: private_key_str,
83
85
  public_key_str: public_key_str,
84
- private_key_bytes: private_key_der, # Store DER format
86
+ private_key_bytes: private_key_bytes, # Use raw 32 bytes
87
+ private_key_der: private_key_der, # Also provide DER format
85
88
  public_key_bytes: public_key_bytes,
86
89
  private_key_obj: key
87
90
  }
@@ -89,79 +92,65 @@ module KeeperSecretsManager
89
92
 
90
93
  # Encrypt with AES-GCM or fallback to CBC
91
94
  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
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
116
117
  end
117
118
  end
118
119
 
119
- # Decrypt with AES-GCM or fallback to CBC
120
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
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
149
137
  end
138
+ rescue OpenSSL::Cipher::CipherError => e
139
+ raise DecryptionError, "Failed to decrypt data: #{e.message}"
150
140
  end
151
141
 
152
142
  # Legacy AES-CBC encryption (for compatibility)
153
143
  def encrypt_aes_cbc(data, key, iv = nil)
154
144
  cipher = OpenSSL::Cipher.new('AES-256-CBC')
155
145
  cipher.encrypt
156
-
146
+
157
147
  iv ||= generate_random_bytes(BLOCK_SIZE)
158
148
  cipher.iv = iv
159
149
  cipher.key = key
160
-
161
- # Apply PKCS7 padding
162
- padded_data = pad_data(data)
163
- encrypted = cipher.update(padded_data) + cipher.final
164
-
150
+
151
+ # OpenSSL handles PKCS7 padding automatically in cipher.final
152
+ encrypted = cipher.update(data) + cipher.final
153
+
165
154
  # Return IV + encrypted
166
155
  iv + encrypted
167
156
  end
@@ -171,16 +160,16 @@ module KeeperSecretsManager
171
160
  # Extract IV
172
161
  iv = encrypted_data[0...BLOCK_SIZE]
173
162
  ciphertext = encrypted_data[BLOCK_SIZE..]
174
-
163
+
175
164
  cipher = OpenSSL::Cipher.new('AES-256-CBC')
176
165
  cipher.decrypt
177
166
  cipher.iv = iv
178
167
  cipher.key = key
179
-
168
+
169
+ # OpenSSL handles PKCS7 padding removal automatically in cipher.final
180
170
  decrypted = cipher.update(ciphertext) + cipher.final
181
-
182
- # Remove padding
183
- unpad_data(decrypted)
171
+
172
+ decrypted
184
173
  rescue OpenSSL::Cipher::CipherError => e
185
174
  raise DecryptionError, "Failed to decrypt data: #{e.message}"
186
175
  end
@@ -195,18 +184,16 @@ module KeeperSecretsManager
195
184
  # Remove PKCS7 padding
196
185
  def unpad_data(data)
197
186
  return data if data.empty?
198
-
187
+
199
188
  pad_len = data[-1].ord
200
-
189
+
201
190
  # Validate padding
202
191
  if pad_len > 0 && pad_len <= BLOCK_SIZE && pad_len <= data.length
203
192
  # Check if all padding bytes are the same
204
193
  padding = data[-pad_len..]
205
- if padding.bytes.all? { |b| b == pad_len }
206
- return data[0...-pad_len]
207
- end
194
+ return data[0...-pad_len] if padding.bytes.all? { |b| b == pad_len }
208
195
  end
209
-
196
+
210
197
  data
211
198
  end
212
199
 
@@ -214,21 +201,21 @@ module KeeperSecretsManager
214
201
  def generate_hmac(key, data)
215
202
  OpenSSL::HMAC.digest('SHA512', key, data)
216
203
  end
217
-
204
+
218
205
  # Generate ECDSA signature
219
206
  def sign_ec(data, private_key)
220
207
  # Use SHA256 for ECDSA signature
221
- digest = OpenSSL::Digest::SHA256.new
208
+ digest = OpenSSL::Digest.new('SHA256')
222
209
  private_key.sign(digest, data)
223
210
  end
224
211
 
225
212
  # Verify HMAC signature
226
213
  def verify_hmac(key, data, signature)
227
214
  expected = generate_hmac(key, data)
228
-
215
+
229
216
  # Constant time comparison
230
217
  return false unless expected.bytesize == signature.bytesize
231
-
218
+
232
219
  result = 0
233
220
  expected.bytes.zip(signature.bytes) { |a, b| result |= a ^ b }
234
221
  result == 0
@@ -237,14 +224,14 @@ module KeeperSecretsManager
237
224
  # Load private key from DER format
238
225
  def load_private_key_der(der_bytes, password = nil)
239
226
  OpenSSL::PKey.read(der_bytes, password)
240
- rescue => e
227
+ rescue StandardError => e
241
228
  raise CryptoError, "Failed to load private key: #{e.message}"
242
229
  end
243
230
 
244
231
  # Load public key from DER format
245
232
  def load_public_key_der(der_bytes)
246
233
  OpenSSL::PKey.read(der_bytes)
247
- rescue => e
234
+ rescue StandardError => e
248
235
  raise CryptoError, "Failed to load public key: #{e.message}"
249
236
  end
250
237
 
@@ -262,20 +249,20 @@ module KeeperSecretsManager
262
249
  def encrypt_ec(data, public_key_bytes)
263
250
  # Load public key
264
251
  public_key = load_ec_public_key(public_key_bytes)
265
-
252
+
266
253
  # Generate ephemeral key pair
267
254
  ephemeral = OpenSSL::PKey::EC.generate('prime256v1')
268
-
255
+
269
256
  # Perform ECDH to get shared secret
270
257
  # The shared secret is computed using ECDH between ephemeral private key and server public key
271
258
  shared_secret = ephemeral.dh_compute_key(public_key.public_key)
272
-
259
+
273
260
  # Derive encryption key using SHA256
274
261
  encryption_key = OpenSSL::Digest::SHA256.digest(shared_secret)
275
-
262
+
276
263
  # Encrypt data with AES-GCM
277
264
  encrypted_data = encrypt_aes_gcm(data, encryption_key)
278
-
265
+
279
266
  # Return ephemeral public key + encrypted data
280
267
  ephemeral_public = ephemeral.public_key.to_octet_string(:uncompressed)
281
268
  ephemeral_public + encrypted_data
@@ -286,17 +273,17 @@ module KeeperSecretsManager
286
273
  # Extract ephemeral public key (65 bytes for uncompressed)
287
274
  ephemeral_public_bytes = encrypted_data[0...65]
288
275
  ciphertext = encrypted_data[65..]
289
-
276
+
290
277
  # Create EC key with ephemeral public key
291
278
  group = OpenSSL::PKey::EC::Group.new('prime256v1')
292
279
  ephemeral_point = OpenSSL::PKey::EC::Point.new(group, ephemeral_public_bytes)
293
-
280
+
294
281
  # Compute shared secret using ECDH
295
282
  shared_secret = private_key.dh_compute_key(ephemeral_point)
296
-
283
+
297
284
  # Derive decryption key
298
285
  decryption_key = OpenSSL::Digest::SHA256.digest(shared_secret)
299
-
286
+
300
287
  # Decrypt data
301
288
  decrypt_aes_gcm(ciphertext, decryption_key)
302
289
  end
@@ -307,31 +294,29 @@ module KeeperSecretsManager
307
294
  def load_ec_public_key(public_key_bytes)
308
295
  # If the bytes are longer than 65, it might be DER encoded
309
296
  # 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
-
297
+ public_key_bytes = public_key_bytes[-65..-1] if public_key_bytes.bytesize > 65
298
+
314
299
  # For OpenSSL 3.0+, we need to create the key differently
315
300
  begin
316
301
  # Try the OpenSSL 3.0+ way first
317
302
  group = OpenSSL::PKey::EC::Group.new('prime256v1')
318
303
  point = OpenSSL::PKey::EC::Point.new(group, public_key_bytes)
319
-
304
+
320
305
  # Create key from point directly using ASN1
321
306
  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
-
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
+
329
314
  OpenSSL::PKey::EC.new(asn1.to_der)
330
- rescue => e
315
+ rescue StandardError => e
331
316
  # Fall back to old method for older OpenSSL
332
317
  group = OpenSSL::PKey::EC::Group.new('prime256v1')
333
318
  point = OpenSSL::PKey::EC::Point.new(group, public_key_bytes)
334
-
319
+
335
320
  key = OpenSSL::PKey::EC.new(group)
336
321
  key.public_key = point
337
322
  key
@@ -345,4 +330,4 @@ module KeeperSecretsManager
345
330
  end
346
331
  end
347
332
  end
348
- end
333
+ end
@@ -20,7 +20,7 @@ module KeeperSecretsManager
20
20
  instance_variables.each do |var|
21
21
  key = var.to_s.delete('@')
22
22
  value = instance_variable_get(var)
23
-
23
+
24
24
  # Convert Ruby snake_case to camelCase for API
25
25
  api_key = Utils.snake_to_camel(key)
26
26
  hash[api_key] = value unless value.nil?
@@ -35,19 +35,20 @@ module KeeperSecretsManager
35
35
 
36
36
  # Get secrets payload
37
37
  class GetPayload < BasePayload
38
- attr_accessor :public_key, :requested_records, :requested_folders, :file_uids
38
+ attr_accessor :public_key, :requested_records, :requested_folders, :file_uids, :request_links
39
39
 
40
40
  def initialize
41
41
  super()
42
42
  @requested_records = nil
43
43
  @requested_folders = nil
44
44
  @file_uids = nil
45
+ @request_links = nil
45
46
  end
46
47
  end
47
48
 
48
49
  # Create record payload
49
50
  class CreatePayload < BasePayload
50
- attr_accessor :record_uid, :record_key, :folder_uid, :folder_key,
51
+ attr_accessor :record_uid, :record_key, :folder_uid, :folder_key,
51
52
  :data, :sub_folder_uid
52
53
 
53
54
  def initialize
@@ -57,7 +58,7 @@ module KeeperSecretsManager
57
58
 
58
59
  # Update record payload
59
60
  class UpdatePayload < BasePayload
60
- attr_accessor :record_uid, :data, :revision, :transaction_type
61
+ attr_accessor :record_uid, :data, :revision, :transaction_type, :links2_remove
61
62
 
62
63
  def initialize
63
64
  super()
@@ -87,7 +88,7 @@ module KeeperSecretsManager
87
88
  # File upload payload
88
89
  class FileUploadPayload < BasePayload
89
90
  attr_accessor :file_record_uid, :file_record_key, :file_record_data,
90
- :owner_record_uid, :owner_record_data, :link_key, :file_size
91
+ :owner_record_uid, :owner_record_data, :owner_record_revision, :link_key, :file_size
91
92
 
92
93
  def initialize
93
94
  super()
@@ -149,4 +150,4 @@ module KeeperSecretsManager
149
150
  end
150
151
  end
151
152
  end
152
- end
153
+ end