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.
- checksums.yaml +7 -0
- data/.rspec +3 -0
- data/.ruby-version +1 -0
- data/CHANGELOG.md +49 -0
- data/Gemfile +13 -0
- data/LICENSE +21 -0
- data/README.md +305 -0
- data/Rakefile +30 -0
- data/examples/basic_usage.rb +139 -0
- data/examples/config_string_example.rb +99 -0
- data/examples/debug_secrets.rb +84 -0
- data/examples/demo_list_secrets.rb +182 -0
- data/examples/download_files.rb +100 -0
- data/examples/flexible_records_example.rb +94 -0
- data/examples/folder_hierarchy_demo.rb +109 -0
- data/examples/full_demo.rb +176 -0
- data/examples/my_test_standalone.rb +176 -0
- data/examples/simple_test.rb +162 -0
- data/examples/storage_examples.rb +126 -0
- data/lib/keeper_secrets_manager/config_keys.rb +27 -0
- data/lib/keeper_secrets_manager/core.rb +1231 -0
- data/lib/keeper_secrets_manager/crypto.rb +348 -0
- data/lib/keeper_secrets_manager/dto/payload.rb +152 -0
- data/lib/keeper_secrets_manager/dto.rb +221 -0
- data/lib/keeper_secrets_manager/errors.rb +79 -0
- data/lib/keeper_secrets_manager/field_types.rb +152 -0
- data/lib/keeper_secrets_manager/folder_manager.rb +114 -0
- data/lib/keeper_secrets_manager/keeper_globals.rb +59 -0
- data/lib/keeper_secrets_manager/notation.rb +354 -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 +196 -0
- data/lib/keeper_secrets_manager/version.rb +3 -0
- data/lib/keeper_secrets_manager.rb +38 -0
- 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
|