keeper_secrets_manager 17.0.4 → 17.1.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 +26 -15
- data/Gemfile +3 -3
- data/README.md +1 -1
- data/Rakefile +1 -1
- data/lib/keeper_secrets_manager/config_keys.rb +2 -2
- data/lib/keeper_secrets_manager/core.rb +594 -394
- data/lib/keeper_secrets_manager/crypto.rb +106 -113
- data/lib/keeper_secrets_manager/dto/payload.rb +4 -4
- data/lib/keeper_secrets_manager/dto.rb +50 -32
- 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 +9 -15
- data/lib/keeper_secrets_manager/notation.rb +99 -92
- data/lib/keeper_secrets_manager/notation_enhancements.rb +22 -24
- data/lib/keeper_secrets_manager/storage.rb +35 -36
- data/lib/keeper_secrets_manager/totp.rb +27 -27
- data/lib/keeper_secrets_manager/utils.rb +83 -17
- data/lib/keeper_secrets_manager/version.rb +2 -2
- data/lib/keeper_secrets_manager.rb +3 -3
- metadata +7 -21
- 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
|
@@ -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
|
|
|
@@ -50,38 +50,40 @@ module KeeperSecretsManager
|
|
|
50
50
|
# Generate private key bytes
|
|
51
51
|
private_key_bytes = generate_encryption_key_bytes
|
|
52
52
|
private_key_str = bytes_to_url_safe_str(private_key_bytes)
|
|
53
|
-
|
|
53
|
+
|
|
54
54
|
# Create EC key from private key bytes
|
|
55
55
|
private_key_bn = OpenSSL::BN.new(private_key_bytes, 2)
|
|
56
|
-
|
|
56
|
+
|
|
57
57
|
# OpenSSL 3.0 compatibility - use ASN1 sequence to create key
|
|
58
58
|
group = OpenSSL::PKey::EC::Group.new('prime256v1')
|
|
59
|
-
|
|
59
|
+
|
|
60
60
|
# Generate public key point
|
|
61
61
|
public_key_point = group.generator.mul(private_key_bn)
|
|
62
|
-
|
|
62
|
+
|
|
63
63
|
# Create ASN1 sequence for the key
|
|
64
64
|
asn1 = OpenSSL::ASN1::Sequence([
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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,
|
|
69
|
+
:EXPLICIT)
|
|
70
|
+
])
|
|
71
|
+
|
|
71
72
|
# Create key from DER
|
|
72
73
|
key = OpenSSL::PKey::EC.new(asn1.to_der)
|
|
73
|
-
|
|
74
|
+
|
|
74
75
|
# Get public key bytes (uncompressed format)
|
|
75
76
|
public_key_bytes = key.public_key.to_octet_string(:uncompressed)
|
|
76
77
|
public_key_str = bytes_to_url_safe_str(public_key_bytes)
|
|
77
|
-
|
|
78
|
+
|
|
78
79
|
# Also store the EC key in DER format for compatibility
|
|
79
80
|
private_key_der = key.to_der
|
|
80
|
-
|
|
81
|
+
|
|
81
82
|
{
|
|
82
83
|
private_key_str: private_key_str,
|
|
83
84
|
public_key_str: public_key_str,
|
|
84
|
-
private_key_bytes:
|
|
85
|
+
private_key_bytes: private_key_bytes, # Use raw 32 bytes
|
|
86
|
+
private_key_der: private_key_der, # Also provide DER format
|
|
85
87
|
public_key_bytes: public_key_bytes,
|
|
86
88
|
private_key_obj: key
|
|
87
89
|
}
|
|
@@ -89,63 +91,59 @@ module KeeperSecretsManager
|
|
|
89
91
|
|
|
90
92
|
# Encrypt with AES-GCM or fallback to CBC
|
|
91
93
|
def encrypt_aes_gcm(data, key)
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
raise e
|
|
115
|
-
end
|
|
94
|
+
cipher = OpenSSL::Cipher.new('AES-256-GCM')
|
|
95
|
+
cipher.encrypt
|
|
96
|
+
|
|
97
|
+
# Generate random IV
|
|
98
|
+
iv = generate_random_bytes(GCM_IV_LENGTH)
|
|
99
|
+
cipher.iv = iv
|
|
100
|
+
cipher.key = key
|
|
101
|
+
|
|
102
|
+
# Encrypt data
|
|
103
|
+
encrypted = cipher.update(data) + cipher.final
|
|
104
|
+
|
|
105
|
+
# Get authentication tag
|
|
106
|
+
tag = cipher.auth_tag(GCM_TAG_LENGTH)
|
|
107
|
+
|
|
108
|
+
# Combine IV + encrypted + tag
|
|
109
|
+
iv + encrypted + tag
|
|
110
|
+
rescue RuntimeError => e
|
|
111
|
+
if e.message.include?('unsupported cipher')
|
|
112
|
+
# Fallback to AES-CBC for older Ruby/OpenSSL
|
|
113
|
+
encrypt_aes_cbc(data, key)
|
|
114
|
+
else
|
|
115
|
+
raise e
|
|
116
116
|
end
|
|
117
117
|
end
|
|
118
118
|
|
|
119
119
|
# Decrypt with AES-GCM or fallback to CBC
|
|
120
120
|
def decrypt_aes_gcm(encrypted_data, key)
|
|
121
|
+
# Try GCM first
|
|
122
|
+
# Extract components
|
|
123
|
+
iv = encrypted_data[0...GCM_IV_LENGTH]
|
|
124
|
+
tag = encrypted_data[-GCM_TAG_LENGTH..]
|
|
125
|
+
ciphertext = encrypted_data[GCM_IV_LENGTH...-GCM_TAG_LENGTH]
|
|
126
|
+
|
|
127
|
+
cipher = OpenSSL::Cipher.new('AES-256-GCM')
|
|
128
|
+
cipher.decrypt
|
|
129
|
+
cipher.iv = iv
|
|
130
|
+
cipher.key = key
|
|
131
|
+
cipher.auth_tag = tag
|
|
132
|
+
|
|
133
|
+
cipher.update(ciphertext) + cipher.final
|
|
134
|
+
rescue RuntimeError => e
|
|
135
|
+
if e.message.include?('unsupported cipher')
|
|
136
|
+
# Fallback to AES-CBC
|
|
137
|
+
decrypt_aes_cbc(encrypted_data, key)
|
|
138
|
+
else
|
|
139
|
+
raise e
|
|
140
|
+
end
|
|
141
|
+
rescue OpenSSL::Cipher::CipherError => e
|
|
142
|
+
# Maybe it's CBC encrypted?
|
|
121
143
|
begin
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
|
144
|
+
decrypt_aes_cbc(encrypted_data, key)
|
|
145
|
+
rescue StandardError
|
|
146
|
+
raise DecryptionError, "Failed to decrypt data: #{e.message}"
|
|
149
147
|
end
|
|
150
148
|
end
|
|
151
149
|
|
|
@@ -153,15 +151,14 @@ module KeeperSecretsManager
|
|
|
153
151
|
def encrypt_aes_cbc(data, key, iv = nil)
|
|
154
152
|
cipher = OpenSSL::Cipher.new('AES-256-CBC')
|
|
155
153
|
cipher.encrypt
|
|
156
|
-
|
|
154
|
+
|
|
157
155
|
iv ||= generate_random_bytes(BLOCK_SIZE)
|
|
158
156
|
cipher.iv = iv
|
|
159
157
|
cipher.key = key
|
|
160
|
-
|
|
161
|
-
#
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
158
|
+
|
|
159
|
+
# OpenSSL handles PKCS7 padding automatically in cipher.final
|
|
160
|
+
encrypted = cipher.update(data) + cipher.final
|
|
161
|
+
|
|
165
162
|
# Return IV + encrypted
|
|
166
163
|
iv + encrypted
|
|
167
164
|
end
|
|
@@ -171,16 +168,16 @@ module KeeperSecretsManager
|
|
|
171
168
|
# Extract IV
|
|
172
169
|
iv = encrypted_data[0...BLOCK_SIZE]
|
|
173
170
|
ciphertext = encrypted_data[BLOCK_SIZE..]
|
|
174
|
-
|
|
171
|
+
|
|
175
172
|
cipher = OpenSSL::Cipher.new('AES-256-CBC')
|
|
176
173
|
cipher.decrypt
|
|
177
174
|
cipher.iv = iv
|
|
178
175
|
cipher.key = key
|
|
179
|
-
|
|
176
|
+
|
|
177
|
+
# OpenSSL handles PKCS7 padding removal automatically in cipher.final
|
|
180
178
|
decrypted = cipher.update(ciphertext) + cipher.final
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
unpad_data(decrypted)
|
|
179
|
+
|
|
180
|
+
decrypted
|
|
184
181
|
rescue OpenSSL::Cipher::CipherError => e
|
|
185
182
|
raise DecryptionError, "Failed to decrypt data: #{e.message}"
|
|
186
183
|
end
|
|
@@ -195,18 +192,16 @@ module KeeperSecretsManager
|
|
|
195
192
|
# Remove PKCS7 padding
|
|
196
193
|
def unpad_data(data)
|
|
197
194
|
return data if data.empty?
|
|
198
|
-
|
|
195
|
+
|
|
199
196
|
pad_len = data[-1].ord
|
|
200
|
-
|
|
197
|
+
|
|
201
198
|
# Validate padding
|
|
202
199
|
if pad_len > 0 && pad_len <= BLOCK_SIZE && pad_len <= data.length
|
|
203
200
|
# Check if all padding bytes are the same
|
|
204
201
|
padding = data[-pad_len..]
|
|
205
|
-
if padding.bytes.all? { |b| b == pad_len }
|
|
206
|
-
return data[0...-pad_len]
|
|
207
|
-
end
|
|
202
|
+
return data[0...-pad_len] if padding.bytes.all? { |b| b == pad_len }
|
|
208
203
|
end
|
|
209
|
-
|
|
204
|
+
|
|
210
205
|
data
|
|
211
206
|
end
|
|
212
207
|
|
|
@@ -214,21 +209,21 @@ module KeeperSecretsManager
|
|
|
214
209
|
def generate_hmac(key, data)
|
|
215
210
|
OpenSSL::HMAC.digest('SHA512', key, data)
|
|
216
211
|
end
|
|
217
|
-
|
|
212
|
+
|
|
218
213
|
# Generate ECDSA signature
|
|
219
214
|
def sign_ec(data, private_key)
|
|
220
215
|
# Use SHA256 for ECDSA signature
|
|
221
|
-
digest = OpenSSL::Digest
|
|
216
|
+
digest = OpenSSL::Digest.new('SHA256')
|
|
222
217
|
private_key.sign(digest, data)
|
|
223
218
|
end
|
|
224
219
|
|
|
225
220
|
# Verify HMAC signature
|
|
226
221
|
def verify_hmac(key, data, signature)
|
|
227
222
|
expected = generate_hmac(key, data)
|
|
228
|
-
|
|
223
|
+
|
|
229
224
|
# Constant time comparison
|
|
230
225
|
return false unless expected.bytesize == signature.bytesize
|
|
231
|
-
|
|
226
|
+
|
|
232
227
|
result = 0
|
|
233
228
|
expected.bytes.zip(signature.bytes) { |a, b| result |= a ^ b }
|
|
234
229
|
result == 0
|
|
@@ -237,14 +232,14 @@ module KeeperSecretsManager
|
|
|
237
232
|
# Load private key from DER format
|
|
238
233
|
def load_private_key_der(der_bytes, password = nil)
|
|
239
234
|
OpenSSL::PKey.read(der_bytes, password)
|
|
240
|
-
rescue => e
|
|
235
|
+
rescue StandardError => e
|
|
241
236
|
raise CryptoError, "Failed to load private key: #{e.message}"
|
|
242
237
|
end
|
|
243
238
|
|
|
244
239
|
# Load public key from DER format
|
|
245
240
|
def load_public_key_der(der_bytes)
|
|
246
241
|
OpenSSL::PKey.read(der_bytes)
|
|
247
|
-
rescue => e
|
|
242
|
+
rescue StandardError => e
|
|
248
243
|
raise CryptoError, "Failed to load public key: #{e.message}"
|
|
249
244
|
end
|
|
250
245
|
|
|
@@ -262,20 +257,20 @@ module KeeperSecretsManager
|
|
|
262
257
|
def encrypt_ec(data, public_key_bytes)
|
|
263
258
|
# Load public key
|
|
264
259
|
public_key = load_ec_public_key(public_key_bytes)
|
|
265
|
-
|
|
260
|
+
|
|
266
261
|
# Generate ephemeral key pair
|
|
267
262
|
ephemeral = OpenSSL::PKey::EC.generate('prime256v1')
|
|
268
|
-
|
|
263
|
+
|
|
269
264
|
# Perform ECDH to get shared secret
|
|
270
265
|
# The shared secret is computed using ECDH between ephemeral private key and server public key
|
|
271
266
|
shared_secret = ephemeral.dh_compute_key(public_key.public_key)
|
|
272
|
-
|
|
267
|
+
|
|
273
268
|
# Derive encryption key using SHA256
|
|
274
269
|
encryption_key = OpenSSL::Digest::SHA256.digest(shared_secret)
|
|
275
|
-
|
|
270
|
+
|
|
276
271
|
# Encrypt data with AES-GCM
|
|
277
272
|
encrypted_data = encrypt_aes_gcm(data, encryption_key)
|
|
278
|
-
|
|
273
|
+
|
|
279
274
|
# Return ephemeral public key + encrypted data
|
|
280
275
|
ephemeral_public = ephemeral.public_key.to_octet_string(:uncompressed)
|
|
281
276
|
ephemeral_public + encrypted_data
|
|
@@ -286,17 +281,17 @@ module KeeperSecretsManager
|
|
|
286
281
|
# Extract ephemeral public key (65 bytes for uncompressed)
|
|
287
282
|
ephemeral_public_bytes = encrypted_data[0...65]
|
|
288
283
|
ciphertext = encrypted_data[65..]
|
|
289
|
-
|
|
284
|
+
|
|
290
285
|
# Create EC key with ephemeral public key
|
|
291
286
|
group = OpenSSL::PKey::EC::Group.new('prime256v1')
|
|
292
287
|
ephemeral_point = OpenSSL::PKey::EC::Point.new(group, ephemeral_public_bytes)
|
|
293
|
-
|
|
288
|
+
|
|
294
289
|
# Compute shared secret using ECDH
|
|
295
290
|
shared_secret = private_key.dh_compute_key(ephemeral_point)
|
|
296
|
-
|
|
291
|
+
|
|
297
292
|
# Derive decryption key
|
|
298
293
|
decryption_key = OpenSSL::Digest::SHA256.digest(shared_secret)
|
|
299
|
-
|
|
294
|
+
|
|
300
295
|
# Decrypt data
|
|
301
296
|
decrypt_aes_gcm(ciphertext, decryption_key)
|
|
302
297
|
end
|
|
@@ -307,31 +302,29 @@ module KeeperSecretsManager
|
|
|
307
302
|
def load_ec_public_key(public_key_bytes)
|
|
308
303
|
# If the bytes are longer than 65, it might be DER encoded
|
|
309
304
|
# Extract the raw point bytes (last 65 bytes)
|
|
310
|
-
if public_key_bytes.bytesize > 65
|
|
311
|
-
|
|
312
|
-
end
|
|
313
|
-
|
|
305
|
+
public_key_bytes = public_key_bytes[-65..-1] if public_key_bytes.bytesize > 65
|
|
306
|
+
|
|
314
307
|
# For OpenSSL 3.0+, we need to create the key differently
|
|
315
308
|
begin
|
|
316
309
|
# Try the OpenSSL 3.0+ way first
|
|
317
310
|
group = OpenSSL::PKey::EC::Group.new('prime256v1')
|
|
318
311
|
point = OpenSSL::PKey::EC::Point.new(group, public_key_bytes)
|
|
319
|
-
|
|
312
|
+
|
|
320
313
|
# Create key from point directly using ASN1
|
|
321
314
|
asn1 = OpenSSL::ASN1::Sequence([
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
315
|
+
OpenSSL::ASN1::Sequence([
|
|
316
|
+
OpenSSL::ASN1::ObjectId('id-ecPublicKey'),
|
|
317
|
+
OpenSSL::ASN1::ObjectId('prime256v1')
|
|
318
|
+
]),
|
|
319
|
+
OpenSSL::ASN1::BitString(public_key_bytes)
|
|
320
|
+
])
|
|
321
|
+
|
|
329
322
|
OpenSSL::PKey::EC.new(asn1.to_der)
|
|
330
|
-
rescue => e
|
|
323
|
+
rescue StandardError => e
|
|
331
324
|
# Fall back to old method for older OpenSSL
|
|
332
325
|
group = OpenSSL::PKey::EC::Group.new('prime256v1')
|
|
333
326
|
point = OpenSSL::PKey::EC::Point.new(group, public_key_bytes)
|
|
334
|
-
|
|
327
|
+
|
|
335
328
|
key = OpenSSL::PKey::EC.new(group)
|
|
336
329
|
key.public_key = point
|
|
337
330
|
key
|
|
@@ -345,4 +338,4 @@ module KeeperSecretsManager
|
|
|
345
338
|
end
|
|
346
339
|
end
|
|
347
340
|
end
|
|
348
|
-
end
|
|
341
|
+
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?
|
|
@@ -47,7 +47,7 @@ module KeeperSecretsManager
|
|
|
47
47
|
|
|
48
48
|
# Create record payload
|
|
49
49
|
class CreatePayload < BasePayload
|
|
50
|
-
attr_accessor :record_uid, :record_key, :folder_uid, :folder_key,
|
|
50
|
+
attr_accessor :record_uid, :record_key, :folder_uid, :folder_key,
|
|
51
51
|
:data, :sub_folder_uid
|
|
52
52
|
|
|
53
53
|
def initialize
|
|
@@ -87,7 +87,7 @@ module KeeperSecretsManager
|
|
|
87
87
|
# File upload payload
|
|
88
88
|
class FileUploadPayload < BasePayload
|
|
89
89
|
attr_accessor :file_record_uid, :file_record_key, :file_record_data,
|
|
90
|
-
:owner_record_uid, :owner_record_data, :link_key, :file_size
|
|
90
|
+
:owner_record_uid, :owner_record_data, :owner_record_revision, :link_key, :file_size
|
|
91
91
|
|
|
92
92
|
def initialize
|
|
93
93
|
super()
|
|
@@ -149,4 +149,4 @@ module KeeperSecretsManager
|
|
|
149
149
|
end
|
|
150
150
|
end
|
|
151
151
|
end
|
|
152
|
-
end
|
|
152
|
+
end
|
|
@@ -7,6 +7,7 @@ module KeeperSecretsManager
|
|
|
7
7
|
# Base class for dynamic record handling
|
|
8
8
|
class KeeperRecord
|
|
9
9
|
attr_accessor :uid, :title, :type, :fields, :custom, :notes, :folder_uid, :data, :revision, :files
|
|
10
|
+
attr_reader :record_key # Internal - stores decrypted record key (bytes) for file upload operations
|
|
10
11
|
|
|
11
12
|
def initialize(attrs = {})
|
|
12
13
|
if attrs.is_a?(Hash)
|
|
@@ -14,7 +15,7 @@ module KeeperSecretsManager
|
|
|
14
15
|
@uid = attrs['recordUid'] || attrs['uid'] || attrs[:uid]
|
|
15
16
|
@folder_uid = attrs['folderUid'] || attrs['folder_uid'] || attrs[:folder_uid]
|
|
16
17
|
@revision = attrs['revision'] || attrs[:revision] || 0
|
|
17
|
-
|
|
18
|
+
|
|
18
19
|
# Handle encrypted data or direct attributes
|
|
19
20
|
if attrs['data']
|
|
20
21
|
data = attrs['data'].is_a?(String) ? JSON.parse(attrs['data']) : attrs['data']
|
|
@@ -30,70 +31,87 @@ module KeeperSecretsManager
|
|
|
30
31
|
@custom = attrs['custom'] || attrs[:custom] || []
|
|
31
32
|
@notes = attrs['notes'] || attrs[:notes] || ''
|
|
32
33
|
end
|
|
33
|
-
|
|
34
|
+
|
|
34
35
|
@files = attrs['files'] || attrs[:files] || []
|
|
35
36
|
@data = attrs
|
|
36
37
|
end
|
|
37
|
-
|
|
38
|
+
|
|
38
39
|
# Ensure fields are always arrays of hashes
|
|
39
40
|
normalize_fields!
|
|
40
41
|
end
|
|
41
42
|
|
|
42
43
|
# Convert to hash for API submission
|
|
44
|
+
# This should match the structure of the decrypted 'data' field from server
|
|
45
|
+
# (does NOT include uid, revision, folder_uid - those are in the outer payload)
|
|
43
46
|
def to_h
|
|
44
|
-
{
|
|
45
|
-
'uid' => uid,
|
|
47
|
+
result = {
|
|
46
48
|
'title' => title,
|
|
47
49
|
'type' => type,
|
|
48
|
-
'fields' => fields
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
50
|
+
'fields' => fields
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
# Only include custom if it has entries (server doesn't send empty arrays)
|
|
54
|
+
result['custom'] = custom if custom && !custom.empty?
|
|
55
|
+
|
|
56
|
+
# Only include notes if present
|
|
57
|
+
result['notes'] = notes if notes && !notes.empty?
|
|
58
|
+
|
|
59
|
+
result
|
|
53
60
|
end
|
|
54
61
|
|
|
55
|
-
# Find field by type or label
|
|
56
|
-
def get_field(type_or_label
|
|
57
|
-
|
|
58
|
-
|
|
62
|
+
# Find field by type or label (searches both fields and custom arrays)
|
|
63
|
+
def get_field(type_or_label)
|
|
64
|
+
# Search in fields first
|
|
65
|
+
field = fields.find { |f| f['type'] == type_or_label || f['label'] == type_or_label }
|
|
66
|
+
return field if field
|
|
67
|
+
|
|
68
|
+
# Search in custom fields
|
|
69
|
+
custom.find { |f| f['type'] == type_or_label || f['label'] == type_or_label }
|
|
59
70
|
end
|
|
60
71
|
|
|
61
72
|
# Get field value (always returns array)
|
|
62
|
-
def get_field_value(type_or_label
|
|
63
|
-
field = get_field(type_or_label
|
|
73
|
+
def get_field_value(type_or_label)
|
|
74
|
+
field = get_field(type_or_label)
|
|
64
75
|
field ? field['value'] || [] : []
|
|
65
76
|
end
|
|
66
77
|
|
|
67
78
|
# Get single field value (first element)
|
|
68
|
-
def get_field_value_single(type_or_label
|
|
69
|
-
values = get_field_value(type_or_label
|
|
79
|
+
def get_field_value_single(type_or_label)
|
|
80
|
+
values = get_field_value(type_or_label)
|
|
70
81
|
values.first
|
|
71
82
|
end
|
|
72
83
|
|
|
73
84
|
# Add or update field
|
|
74
|
-
def set_field(type, value, label = nil
|
|
75
|
-
field_array = custom_field ? @custom : @fields
|
|
76
|
-
|
|
85
|
+
def set_field(type, value, label = nil)
|
|
77
86
|
# Ensure value is an array
|
|
78
87
|
value = [value] unless value.is_a?(Array)
|
|
79
|
-
|
|
80
|
-
# Find existing field
|
|
81
|
-
existing =
|
|
82
|
-
|
|
88
|
+
|
|
89
|
+
# Find existing field in both arrays
|
|
90
|
+
existing = @fields.find { |f| f['type'] == type || (label && f['label'] == label) }
|
|
91
|
+
existing ||= @custom.find { |f| f['type'] == type || (label && f['label'] == label) }
|
|
92
|
+
|
|
83
93
|
if existing
|
|
84
94
|
existing['value'] = value
|
|
85
95
|
existing['label'] = label if label
|
|
86
96
|
else
|
|
87
97
|
new_field = { 'type' => type, 'value' => value }
|
|
88
98
|
new_field['label'] = label if label
|
|
89
|
-
|
|
99
|
+
|
|
100
|
+
# Decide which array to add to:
|
|
101
|
+
# - If it has a label, it's a custom field
|
|
102
|
+
# - If it's not a common field type, it's likely custom
|
|
103
|
+
if label || !common_field_types.include?(type)
|
|
104
|
+
@custom << new_field
|
|
105
|
+
else
|
|
106
|
+
@fields << new_field
|
|
107
|
+
end
|
|
90
108
|
end
|
|
91
109
|
end
|
|
92
110
|
|
|
93
111
|
# Dynamic field access methods
|
|
94
112
|
def method_missing(method, *args, &block)
|
|
95
113
|
method_name = method.to_s
|
|
96
|
-
|
|
114
|
+
|
|
97
115
|
# Handle setters
|
|
98
116
|
if method_name.end_with?('=')
|
|
99
117
|
field_name = method_name.chomp('=')
|
|
@@ -120,18 +138,18 @@ module KeeperSecretsManager
|
|
|
120
138
|
|
|
121
139
|
def normalize_field_array(fields)
|
|
122
140
|
return [] unless fields.is_a?(Array)
|
|
123
|
-
|
|
141
|
+
|
|
124
142
|
fields.map do |field|
|
|
125
143
|
next field if field.is_a?(Hash)
|
|
126
|
-
|
|
144
|
+
|
|
127
145
|
# Convert to hash if needed
|
|
128
146
|
field.to_h
|
|
129
147
|
end
|
|
130
148
|
end
|
|
131
149
|
|
|
132
150
|
def common_field_types
|
|
133
|
-
%w[login password url fileRef oneTimeCode name phone email address
|
|
134
|
-
paymentCard bankAccount birthDate secureNote sshKey host
|
|
151
|
+
%w[login password url fileRef oneTimeCode name phone email address
|
|
152
|
+
paymentCard bankAccount birthDate secureNote sshKey host
|
|
135
153
|
databaseType script passkey]
|
|
136
154
|
end
|
|
137
155
|
end
|
|
@@ -218,4 +236,4 @@ module KeeperSecretsManager
|
|
|
218
236
|
end
|
|
219
237
|
end
|
|
220
238
|
end
|
|
221
|
-
end
|
|
239
|
+
end
|
|
@@ -7,6 +7,7 @@ module KeeperSecretsManager
|
|
|
7
7
|
|
|
8
8
|
# Authentication/authorization errors
|
|
9
9
|
class AuthenticationError < Error; end
|
|
10
|
+
|
|
10
11
|
class AccessDeniedError < AuthenticationError; end
|
|
11
12
|
|
|
12
13
|
# API/network errors
|
|
@@ -22,7 +23,9 @@ module KeeperSecretsManager
|
|
|
22
23
|
|
|
23
24
|
# Crypto errors
|
|
24
25
|
class CryptoError < Error; end
|
|
26
|
+
|
|
25
27
|
class DecryptionError < CryptoError; end
|
|
28
|
+
|
|
26
29
|
class EncryptionError < CryptoError; end
|
|
27
30
|
|
|
28
31
|
# Notation errors
|
|
@@ -30,7 +33,9 @@ module KeeperSecretsManager
|
|
|
30
33
|
|
|
31
34
|
# Record errors
|
|
32
35
|
class RecordError < Error; end
|
|
36
|
+
|
|
33
37
|
class RecordNotFoundError < RecordError; end
|
|
38
|
+
|
|
34
39
|
class RecordValidationError < RecordError; end
|
|
35
40
|
|
|
36
41
|
# Server errors
|
|
@@ -46,13 +51,19 @@ module KeeperSecretsManager
|
|
|
46
51
|
|
|
47
52
|
# Specific server error types
|
|
48
53
|
class InvalidClientVersionError < ServerError; end
|
|
54
|
+
|
|
49
55
|
class InvalidTokenError < ServerError; end
|
|
56
|
+
|
|
50
57
|
class BadRequestError < ServerError; end
|
|
58
|
+
|
|
51
59
|
class RecordUidNotFoundError < ServerError; end
|
|
60
|
+
|
|
52
61
|
class FolderUidNotFoundError < ServerError; end
|
|
62
|
+
|
|
53
63
|
class AccessViolationError < ServerError; end
|
|
64
|
+
|
|
54
65
|
class ThrottledError < ServerError; end
|
|
55
|
-
|
|
66
|
+
|
|
56
67
|
# Error factory
|
|
57
68
|
class ErrorFactory
|
|
58
69
|
def self.from_server_response(result_code, message = nil)
|
|
@@ -76,4 +87,4 @@ module KeeperSecretsManager
|
|
|
76
87
|
end
|
|
77
88
|
end
|
|
78
89
|
end
|
|
79
|
-
end
|
|
90
|
+
end
|
|
@@ -9,7 +9,7 @@ module KeeperSecretsManager
|
|
|
9
9
|
@label = label
|
|
10
10
|
@required = required
|
|
11
11
|
@privacy_screen = privacy_screen
|
|
12
|
-
|
|
12
|
+
|
|
13
13
|
# Ensure value is always an array
|
|
14
14
|
@value = value.is_a?(Array) ? value : [value]
|
|
15
15
|
end
|
|
@@ -103,7 +103,7 @@ module KeeperSecretsManager
|
|
|
103
103
|
when String
|
|
104
104
|
(Date.parse(date).to_time.to_f * 1000).to_i
|
|
105
105
|
else
|
|
106
|
-
raise ArgumentError,
|
|
106
|
+
raise ArgumentError, 'Invalid date format'
|
|
107
107
|
end
|
|
108
108
|
Field.new(type: 'birthDate', value: timestamp, label: label)
|
|
109
109
|
end
|
|
@@ -149,4 +149,4 @@ module KeeperSecretsManager
|
|
|
149
149
|
end
|
|
150
150
|
end
|
|
151
151
|
end
|
|
152
|
-
end
|
|
152
|
+
end
|