aesx 0.1.2

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 (7) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +156 -0
  4. data/lib/aesx.rb +391 -0
  5. data/lib/compression.rb +129 -0
  6. data/test_aesx.rb +429 -0
  7. metadata +104 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 1e8c7c99e1f1da63ccfe84212082b558d0b265a4ce48deba8d47793bb43559ce
4
+ data.tar.gz: 312f5a067623b83db638bfa13b2dee0c8b8632d2cd1f2ba991db814249f39ebb
5
+ SHA512:
6
+ metadata.gz: 14b31c2cef73d6fadf793edf21d1b142edc0df8aa1db7f5d7d4d82704e5e1c162f2db71d9841f9b36366a6e720f8315dcd4c801e08e3eba13737817bb2527d3e
7
+ data.tar.gz: 30174c3791681e8d36c2bf5617d817e1d86f55d4e193f9ba9e01f0a4376ceb6beaa574cfa17b71c3642b50921250478243a68e43a2b297ea6a5aee80d5f6f2b2
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Tom Lahti
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,156 @@
1
+ # AESX
2
+
3
+ A lightweight encryption library that provides an extended version of the popular AES gem interface with modern ciphers. The default cipher is AES-256-GCM. Other than the output formats being slightly extended to accommodate GCM authentication tags and compression indicators, this is a drop-in replacement for the AES gem. The API of that gem is fully implemented. AESX adds a binary format which is more efficiently stored than base64, and compression.
4
+
5
+ Security-wise, GCM ciphers provide tampering prevention and data integrity automatically. When using AESX, a regular password-style key of any length can be provided and a cryptographically secure encryption key will be generated using a key derivation function.
6
+ ## Installation
7
+
8
+ Add this line to your application's Gemfile:
9
+
10
+ ```ruby
11
+ gem 'aesx'
12
+ ```
13
+
14
+ And then execute:
15
+
16
+ ```
17
+ $ bundle install
18
+ ```
19
+
20
+ Or install it yourself:
21
+
22
+ ```
23
+ $ gem install aesx
24
+ ```
25
+
26
+ ## Usage
27
+
28
+ ### Basic Encryption and Decryption
29
+
30
+ ```ruby
31
+ require 'aesx'
32
+
33
+ # Encrypt with a key
34
+ key = AESX.key
35
+ encrypted = AESX.encrypt("Secret message", key)
36
+
37
+ # Decrypt
38
+ decrypted = AESX.decrypt(encrypted, key)
39
+ ```
40
+
41
+ ### Using Different Ciphers
42
+
43
+ ```ruby
44
+ # List available ciphers
45
+ puts AESX.cipher_list
46
+
47
+ # Use a specific cipher
48
+ key = AESX.key(cipher: 'CHACHA20-POLY1305')
49
+ encrypted = AESX.encrypt("Secret message", key, cipher: 'CHACHA20-POLY1305')
50
+ decrypted = AESX.decrypt(encrypted, key, cipher: 'CHACHA20-POLY1305')
51
+ ```
52
+
53
+ ### Compression
54
+
55
+ ```ruby
56
+ # Compression is enabled by default
57
+ encrypted = AESX.encrypt("Large content to encrypt", key)
58
+
59
+ # Disable compression
60
+ encrypted = AESX.encrypt("Data to encrypt", key, compression: false)
61
+
62
+ # Specify a compression algorithm
63
+ encrypted = AESX.encrypt("Large content to encrypt", key, compression: :zstd)
64
+ encrypted = AESX.encrypt("Large content to encrypt", key, compression: :snappy)
65
+ encrypted = AESX.encrypt("Large content to encrypt", key, compression: :lz4)
66
+ ```
67
+
68
+ #### Compression Information
69
+
70
+ ```ruby
71
+ # Check what compression algorithms are available (what gems loaded)
72
+ AESX.available_compression # => [:zstd, :snappy, :lz4]
73
+
74
+ # Check the default compression algorithm
75
+ AESX.default_compression # => :zstd
76
+ ```
77
+
78
+ Available compression algorithms:
79
+ - `:zstd` - High compression ratio with good speed (default if available)
80
+ - `:snappy` - Fast compression with moderate ratio
81
+ - `:lz4` - Very fast compression with lower ratio
82
+
83
+ AESX attempts to load zstd, then snappy, then lz4. The first one that loads successfully is the default. If none of them load, compression is disabled. Install gems to have compression available:
84
+
85
+ | **Compression** | **Ruby** gem | **JRuby** gem |
86
+ |:---------------:|:------------:|:--------------:|
87
+ | :zstd | zstd-ruby | zstandard-ruby |
88
+ | :snappy | snappy | jruby-snappy |
89
+ | :lz4 | lz4-ruby | jruby-lz4 |
90
+ ### Output Formats
91
+
92
+ AESX supports multiple output formats:
93
+
94
+ ```ruby
95
+ # Base64 encoded string (default)
96
+ encrypted = AESX.encrypt("Secret message", key, format: :base_64)
97
+
98
+ # Raw binary output
99
+ encrypted = AESX.encrypt("Secret message", key, format: :binary)
100
+
101
+ # Array of components [iv, ciphertext, auth_tag, compression_algorithm]
102
+ encrypted = AESX.encrypt("Secret message", key, format: :plain)
103
+ ```
104
+
105
+ ### Advanced Usage
106
+
107
+ You don’t have to supply the cipher with every operation. You can instead create an object configured for a particular cipher, and then reuse that object for multiple operations.
108
+
109
+ ```ruby
110
+ # Create an AESX object for multiple operations
111
+ cipher = AESX::AESX.new(key, {
112
+ cipher: 'AES-192-GCM',
113
+ padding: true,
114
+ compression: :snappy,
115
+ auth_data: "additional authentication data" # for GCM mode
116
+ })
117
+
118
+ encrypted1 = cipher.encrypt("Message 1")
119
+ encrypted2 = cipher.encrypt("Message 2")
120
+ ```
121
+
122
+ ## Supported Ciphers
123
+
124
+ - AES-128/192/256-GCM
125
+ - AES-128/192/256-CTR
126
+ - ARIA-128/192/256-CTR[^1]
127
+ - SM4-CTR[^2]
128
+ - SM4-GCM[^2]
129
+ - CHACHA20-POLY1305
130
+
131
+ The actual list depends on your OpenSSL version. Use `AESX.cipher_list` to see available ciphers.
132
+
133
+ [^1]: ARIA is a block cipher developed by South Korean cryptographers and is widely used in South Korea, particularly in government and financial systems.
134
+
135
+ [^2]: SM4 is the Chinese national standard block cipher algorithm and is commonly used within China in government and regulated industries.
136
+
137
+ ## Cross-Platform Compatibility
138
+
139
+ The compression information is stored as part of the encrypted data, so files encrypted on one system can be decrypted on another, even if different compression libraries are available. If the required compression algorithm is not available during decryption, a clear error message will be displayed. Compression can also be completely disabled.
140
+
141
+ ## Notes on Security
142
+
143
+ - GCM and CHACHA20-POLY1305 provide authenticated encryption, meaning they detect tampering with the encrypted data
144
+ - When using CTR mode, no authentication is provided
145
+ - You can use AESX to provide a secure random key (generated with `AESX.key`)
146
+ - **CRITICAL SECURITY WARNING**: Never store the encryption key with the encrypted data
147
+ - Storing key and encrypted data together compromises all encryption
148
+ - Manage keys separately using secure key management practices
149
+ - When providing your own key via a password, key derivation uses PBKDF2 with a deterministic salt derived from the input
150
+ - Ensures consistent key stretching across systems
151
+ - Requires OpenSSL >= 1.0.0
152
+ - The same input/password will always produce the same derived key
153
+
154
+ ## License
155
+
156
+ This library is available as open source under the terms of the MIT License.
data/lib/aesx.rb ADDED
@@ -0,0 +1,391 @@
1
+ # Copyright (c) 2025 Tom Lahti
2
+ # MIT License
3
+
4
+ require 'openssl'
5
+ require 'base64'
6
+ require 'digest'
7
+ require_relative 'compression'
8
+
9
+ # AESX - AES eXtended encryption library
10
+ #
11
+ # A lightweight encryption library that provides an extended version of
12
+ # the popular AES gem interface with modern ciphers. The default cipher
13
+ # is AES-256-GCM.
14
+ #
15
+ # @example Basic usage
16
+ # key = AESX.key
17
+ # encrypted = AESX.encrypt("Secret message", key)
18
+ # decrypted = AESX.decrypt(encrypted, key)
19
+ #
20
+ # @example Using different ciphers
21
+ # key = AESX.key(cipher: 'CHACHA20-POLY1305')
22
+ # encrypted = AESX.encrypt("Secret message", key, cipher: 'CHACHA20-POLY1305')
23
+ #
24
+ # @example With compression
25
+ # encrypted = AESX.encrypt("Large message", key, compression: :zstd)
26
+ #
27
+ module AESX
28
+
29
+ # Mapping of cipher names to [key_length, iv_length]
30
+ CIPHER_SPECS = {
31
+ 'AES-128-CTR' => [16, 16],
32
+ 'AES-192-CTR' => [24, 16],
33
+ 'AES-256-CTR' => [32, 16],
34
+ 'AES-128-GCM' => [16, 12],
35
+ 'AES-192-GCM' => [24, 12],
36
+ 'AES-256-GCM' => [32, 12],
37
+ 'ARIA-128-CTR' => [16, 16],
38
+ 'ARIA-192-CTR' => [24, 16],
39
+ 'ARIA-256-CTR' => [32, 16],
40
+ 'SM4-CTR' => [16, 16],
41
+ 'SM4-GCM' => [16, 12],
42
+ 'CHACHA20-POLY1305' => [32, 12]
43
+ }.freeze
44
+
45
+ class << self
46
+
47
+ # Returns a list of supported ciphers available in the current OpenSSL installation
48
+ #
49
+ # @return [Array<String>] List of available cipher names
50
+ def cipher_list
51
+ openssl_ciphers = OpenSSL::Cipher.ciphers.map(&:upcase)
52
+ CIPHER_SPECS.keys & openssl_ciphers
53
+ end
54
+
55
+ # Encrypts plaintext using the specified key and options
56
+ #
57
+ # @param plaintext [String] The data to encrypt
58
+ # @param key [String] The encryption key
59
+ # @param opts [Hash] Options for encryption
60
+ # @option opts [Symbol] :format (:base_64) Output format - :base_64, :binary, or :plain
61
+ # @option opts [String] :cipher ('AES-256-GCM') Cipher to use
62
+ # @option opts [String] :iv (random) Initialization vector
63
+ # @option opts [Boolean, Integer] :padding (true) Enable padding
64
+ # @option opts [String] :auth_data ('') Additional authentication data for GCM mode
65
+ # @option opts [Boolean, Symbol] :compression (default algorithm) Compression option
66
+ #
67
+ # @return [String, Array] Encrypted data in the specified format
68
+ def encrypt(plaintext, key, opts={})
69
+ cipher = AESX.new(key, opts)
70
+ cipher.encrypt(plaintext)
71
+ end
72
+
73
+ # Decrypts ciphertext using the specified key and options
74
+ #
75
+ # @param ciphertext [String, Array] The encrypted data to decrypt
76
+ # @param key [String] The encryption key
77
+ # @param opts [Hash] Options for decryption (most are auto-detected)
78
+ # @option opts [Symbol] :format (auto-detected) Input format
79
+ # @option opts [String] :cipher ('AES-256-GCM') Cipher to use
80
+ # @option opts [Boolean, Integer] :padding (true) Enable padding
81
+ # @option opts [String] :auth_data ('') Additional authentication data for GCM mode
82
+ #
83
+ # @return [String] The decrypted plaintext
84
+ # @raise [OpenSSL::Cipher::CipherError] If decryption fails (wrong key, tampered data)
85
+ def decrypt(ciphertext, key, opts={})
86
+ cipher = AESX.new(key, opts)
87
+ cipher.decrypt(ciphertext)
88
+ end
89
+
90
+ # Generates a random key of appropriate length for the specified cipher
91
+ #
92
+ # @param length [Integer, nil] Key length in bits, or nil to use cipher default
93
+ # @param format [Symbol] Output format - :plain or :base_64
94
+ # @param cipher [String] Cipher to determine key length
95
+ #
96
+ # @return [String] A random key in the specified format
97
+ def key(length = nil, format = :plain, cipher: 'AES-256-GCM')
98
+ key_length = length ? length / 8 : CIPHER_SPECS[cipher.upcase][0]
99
+ key = OpenSSL::Random.random_bytes(key_length)
100
+ format == :base_64 ? Base64.encode64(key).chomp : key
101
+ end
102
+
103
+ # Generates a random initialization vector of appropriate length for the specified cipher
104
+ #
105
+ # @param format [Symbol] Output format - :plain or :base_64
106
+ # @param cipher [String] Cipher to determine IV length
107
+ #
108
+ # @return [String] A random IV in the specified format
109
+ def iv(format = :plain, cipher: 'AES-256-GCM')
110
+ iv_length = CIPHER_SPECS[cipher.upcase][1]
111
+ iv = OpenSSL::Random.random_bytes(iv_length)
112
+ format == :base_64 ? Base64.encode64(iv).chomp : iv
113
+ end
114
+
115
+ # Returns the default compression algorithm
116
+ #
117
+ # @return [Symbol, nil] The symbol representing the default algorithm, or nil if none available
118
+ def default_compression
119
+ AESCompression.default_algorithm
120
+ end
121
+
122
+ # Returns an array of available compression algorithms
123
+ #
124
+ # @return [Array<Symbol>] Symbols representing available compression algorithms
125
+ def available_compression
126
+ AESCompression.available_algorithms
127
+ end
128
+
129
+ end
130
+
131
+ # Main AESX class for encryption and decryption operations
132
+ class AESX
133
+ attr :key, :iv, :cipher, :auth_tag, :options
134
+
135
+ # Creates a new AESX cipher instance
136
+ #
137
+ # @param key [String] The encryption key
138
+ # @param opts [Hash] Options for the cipher
139
+ # @option opts [Symbol] :format (:base_64) Default output format
140
+ # @option opts [String] :cipher ('AES-256-GCM') Cipher to use
141
+ # @option opts [String] :iv (random) Initialization vector
142
+ # @option opts [Boolean, Integer] :padding (true) Enable padding
143
+ # @option opts [String] :auth_data ('') Additional authentication data for GCM mode
144
+ # @option opts [Boolean, Symbol] :compression (default algorithm) Compression option
145
+ #
146
+ # @raise [ArgumentError] If an unsupported cipher is specified
147
+ def initialize(key, opts={})
148
+ # allow laziness
149
+ if opts.key?(:compress)
150
+ opts[:compression] = opts.delete(:compress)
151
+ end
152
+ @options = {
153
+ format: :base_64, # Default output format for encryption
154
+ cipher: "AES-256-GCM", # GCM mode
155
+ iv: nil, # IV will be generated if not passed
156
+ padding: true, # OpenSSL padding support
157
+ auth_data: "", # additional authenication data (AAD)
158
+ compression: AESCompression.default_algorithm # Default to the default algorithm
159
+ }.merge!(opts)
160
+
161
+ unless ::AESX.cipher_list.include?(@options[:cipher].upcase)
162
+ raise ArgumentError, "Unsupported cipher #{@options[:cipher]}. Supported ciphers: #{::AESX.cipher_list.join(', ')}"
163
+ end
164
+
165
+ @key = normalize_key(key, @options[:cipher])
166
+ @iv = @options[:iv] || ::AESX.iv(cipher: @options[:cipher])
167
+
168
+ case @options[:padding]
169
+ when true
170
+ @options[:padding] = 1
171
+ when false
172
+ @options[:padding] = 0
173
+ end
174
+
175
+ @cipher = OpenSSL::Cipher.new(@options[:cipher])
176
+ end
177
+
178
+ # Encrypts plaintext using the configured cipher and options
179
+ #
180
+ # @param plaintext [String] The data to encrypt
181
+ # @param opts [Hash] Options to override instance defaults
182
+ # @option opts [Symbol] :format Output format override
183
+ # @option opts [String] :iv Override the instance IV
184
+ # @option opts [Boolean, Symbol] :compression Compression override
185
+ #
186
+ # @return [String, Array] Encrypted data in the specified format
187
+ def encrypt(plaintext, opts = {})
188
+ @cipher.encrypt
189
+ @cipher.key = @key
190
+ iv = opts[:iv] || @iv
191
+ @cipher.iv = iv
192
+ @cipher.padding = @options[:padding]
193
+ @cipher.auth_data = @options[:auth_data] unless @options[:cipher] =~ /CTR/i
194
+
195
+ # Apply compression if enabled
196
+ compressed_data = plaintext
197
+ compression_algorithm = nil
198
+
199
+ # Get compression option from opts or fallback to options
200
+ compression = opts.key?(:compression) ? opts[:compression] : @options[:compression]
201
+
202
+ # If compression is a symbol or truthy value (but not true), use it as the algorithm
203
+ if compression.is_a?(Symbol) || (compression && compression != true)
204
+ # Check if specified algorithm is available
205
+ if compression.is_a?(Symbol) && !AESCompression.algorithm_available?(compression)
206
+ raise ArgumentError, "Compression algorithm '#{compression}' is not available. Installed algorithms: #{AESCompression.available_algorithms.join(', ')}"
207
+ end
208
+ compressed_data, compression_algorithm = AESCompression.compress(plaintext, compression)
209
+ # If compression is true or nil, use default algorithm
210
+ elsif compression.nil? || compression == true
211
+ compressed_data, compression_algorithm = AESCompression.compress(plaintext, AESCompression.default_algorithm)
212
+ # Otherwise, no compression (compression == false)
213
+ end
214
+
215
+ ciphertext = @cipher.update(compressed_data) + @cipher.final
216
+ auth_tag = @cipher.auth_tag unless @options[:cipher] =~ /CTR/i
217
+
218
+ fmt = opts[:format] || @options[:format]
219
+ case fmt
220
+ when :base_64
221
+ iv_b64 = Base64.encode64(iv).chomp
222
+ ciphertext_b64 = Base64.encode64(ciphertext).chomp
223
+ auth_tag_b64 = auth_tag ? Base64.encode64(auth_tag).chomp : nil
224
+
225
+ # Add compression flag
226
+ comp_flag = compression_algorithm ? AESCompression::ALGORITHM_IDS[compression_algorithm].to_s : "0"
227
+
228
+ if auth_tag_b64
229
+ result = "#{iv_b64}$#{ciphertext_b64}$#{auth_tag_b64}$#{comp_flag}"
230
+ else
231
+ result = "#{iv_b64}$#{ciphertext_b64}$$#{comp_flag}" # Empty auth_tag field for CTR mode
232
+ end
233
+ result.force_encoding(Encoding::US_ASCII)
234
+ when :binary
235
+ # IV length has a range of 7-16, which we can get into 3 bits
236
+ # auth_tag length is 0-16, variable in CCM
237
+ auth_tag_size = auth_tag ? auth_tag.bytesize : 0
238
+ packed_lengths = ((iv.bytesize - 7) << 5) | (auth_tag_size & 0x1F)
239
+
240
+ # Add a second byte for compression algorithm
241
+ compression_byte = AESCompression::ALGORITHM_IDS[compression_algorithm] || 0
242
+
243
+ if auth_tag
244
+ pack_format = "CC a#{iv.bytesize} a* a#{auth_tag.bytesize}"
245
+ [packed_lengths, compression_byte, iv, ciphertext, auth_tag].pack(pack_format)
246
+ else
247
+ pack_format = "CC a#{iv.bytesize} a*"
248
+ [packed_lengths, compression_byte, iv, ciphertext].pack(pack_format)
249
+ end
250
+ else
251
+ [iv, ciphertext, auth_tag, compression_algorithm]
252
+ end
253
+ end
254
+
255
+ # Decrypts ciphertext using the configured cipher and options
256
+ #
257
+ # @param encrypted_data [String, Array] The encrypted data to decrypt
258
+ # @param opts [Hash] Options to override instance defaults
259
+ # @option opts [Symbol] :format Format override (auto-detected if not specified)
260
+ # @option opts [Boolean, Integer] :padding Padding override
261
+ # @option opts [String] :auth_data Authentication data override for GCM mode
262
+ #
263
+ # @return [String] The decrypted plaintext
264
+ # @raise [OpenSSL::Cipher::CipherError] If decryption fails
265
+ # @raise [RuntimeError] If decompression fails or algorithm is unavailable
266
+ def decrypt(encrypted_data, opts = {})
267
+ # ignore provided opts[:format] and auto-detect
268
+ compression_algorithm = nil
269
+
270
+ case encrypted_data
271
+ when Array
272
+ opts[:format] = :plain
273
+ iv, ciphertext, auth_tag, compression_algorithm = encrypted_data
274
+ else
275
+ opts[:format] = :binary
276
+
277
+ # unless it's Base64 encoded?
278
+ parts = encrypted_data.split('$')
279
+ if parts.size.between?(3, 4)
280
+ all_base64 = parts.all? { |str| str.nil? || str.empty? || str =~ /^[A-Za-z0-9+\/=]*$/ }
281
+ if all_base64
282
+ opts[:format] = :base_64
283
+ end
284
+ end
285
+ end
286
+
287
+ case opts[:format]
288
+ when :base_64
289
+ parts = encrypted_data.split('$')
290
+ iv_b64 = parts[0]
291
+ ciphertext_b64 = parts[1]
292
+ auth_tag_b64 = parts[2] if parts.size >= 3 && !parts[2].nil? && !parts[2].empty?
293
+ compression_code = parts[3] if parts.size >= 4
294
+
295
+ iv = Base64.decode64(iv_b64)
296
+ ciphertext = Base64.decode64(ciphertext_b64)
297
+ auth_tag = auth_tag_b64 ? Base64.decode64(auth_tag_b64) : nil
298
+
299
+ # Determine compression algorithm from the code
300
+ if compression_code && compression_code != "0"
301
+ algorithm_id = compression_code.to_i
302
+ compression_algorithm = AESCompression::ID_TO_ALGORITHM[algorithm_id]
303
+ end
304
+ when :binary
305
+ # Extract the first byte which contains IV and auth tag lengths
306
+ lengths = encrypted_data.unpack1('C')
307
+
308
+ # Extract the second byte which contains compression info
309
+ compression_byte = encrypted_data.unpack('CC')[1]
310
+
311
+ # Calculate IV length and auth tag length
312
+ iv_len = ((lengths >> 5) & 0x07) + 7
313
+ tag_len = lengths & 0x1F
314
+
315
+ # Extract IV, ciphertext, and auth tag
316
+ iv = encrypted_data[2, iv_len] # 2 bytes of header now
317
+
318
+ if tag_len > 0
319
+ auth_tag = encrypted_data[-tag_len, tag_len]
320
+ # Ciphertext starts after header and IV, ends before auth tag
321
+ ciphertext = encrypted_data[(2 + iv_len)...-tag_len]
322
+ else
323
+ auth_tag = nil
324
+ ciphertext = encrypted_data[(2 + iv_len)..]
325
+ end
326
+
327
+ # Get compression algorithm
328
+ compression_algorithm = AESCompression::ID_TO_ALGORITHM[compression_byte] if compression_byte != 0
329
+ else
330
+ iv, ciphertext, auth_tag, compression_algorithm = encrypted_data
331
+ end
332
+
333
+ @cipher.decrypt
334
+ @cipher.key = @key
335
+ @cipher.iv = iv
336
+ unless @options[:cipher] =~ /CTR/i
337
+ @cipher.auth_tag = auth_tag if auth_tag
338
+ @cipher.auth_data = opts[:auth_data] || @options[:auth_data]
339
+ end
340
+ @cipher.padding = opts[:padding] || @options[:padding]
341
+
342
+ decrypted_data = @cipher.update(ciphertext) + @cipher.final
343
+
344
+ # Apply decompression if data was compressed
345
+ if compression_algorithm
346
+ begin
347
+ decrypted_data = AESCompression.decompress(decrypted_data, compression_algorithm)
348
+ rescue => e
349
+ raise "Error decompressing data: #{e.message}. Algorithm #{compression_algorithm} may not be installed."
350
+ end
351
+ end
352
+
353
+ decrypted_data
354
+ end
355
+
356
+ alias_method :random_iv, :iv
357
+ alias_method :random_key, :key
358
+
359
+ # Normalizes an encryption key to the correct length and format
360
+ #
361
+ # Requires OpenSSL >= 1.0.0 for PBKDF2 key derivation support.
362
+ #
363
+ # If the key is already the correct length, it's returned as-is.
364
+ # If it's a hex string, it's converted to binary.
365
+ # For other keys, PBKDF2 is used for deterministic key derivation
366
+ #
367
+ # @param key [String] The encryption key to normalize
368
+ # @param cipher [String] Cipher to determine required key length (default: 'AES-256-GCM')
369
+ # @param iterations [Integer] Number of iterations for PBKDF2 key derivation (default: 10000)
370
+ #
371
+ # @return [String] Normalized key of the correct length for the cipher
372
+ # @raise [RuntimeError] If OpenSSL version is less than 1.0.0
373
+ # @api private
374
+ def normalize_key(key, cipher = 'AES-256-GCM', iterations: 10000)
375
+ key_length = CIPHER_SPECS[cipher.upcase][0]
376
+ return key if key.bytesize == key_length
377
+
378
+ if key.match?(/\A[0-9a-fA-F]+\z/) # is it a hex string?
379
+ key = key.unpack('a2' * key_length).map { |x| x.hex }.pack('c' * key_length)
380
+ else
381
+ # Derive salt deterministically from the key
382
+ salt = Digest::SHA256.digest(key)[0,16]
383
+ # Use PBKDF2 for key derivation
384
+ key = OpenSSL::PKCS5.pbkdf2_hmac(key,salt,iterations,key_length,OpenSSL::Digest::SHA256.new)
385
+ end
386
+
387
+ key
388
+ end
389
+
390
+ end
391
+ end
@@ -0,0 +1,129 @@
1
+ # Copyright (c) 2025 Tom Lahti
2
+ # MIT License
3
+
4
+ module AESCompression
5
+ @algorithms = {}
6
+ @default_algorithm = nil
7
+
8
+ # Compression algorithm identifiers for serialization
9
+ ALGORITHM_IDS = {
10
+ nil => 0, # No compression
11
+ :zstd => 1,
12
+ :snappy => 2,
13
+ :lz4 => 3
14
+ }.freeze
15
+
16
+ ID_TO_ALGORITHM = ALGORITHM_IDS.invert.freeze
17
+
18
+ # Try to load zstd with platform-specific support
19
+ begin
20
+ if defined?(JRUBY_VERSION)
21
+ require 'zstandard-ruby'
22
+ @algorithms[:zstd] = {
23
+ compress: ->(data) { Zstandard.compress(data) },
24
+ decompress: ->(data) { Zstandard.decompress(data) }
25
+ }
26
+ @default_algorithm = :zstd
27
+ else
28
+ require 'zstd-ruby'
29
+ @algorithms[:zstd] = {
30
+ compress: ->(data) { Zstd.compress(data) },
31
+ decompress: ->(data) { Zstd.decompress(data) }
32
+ }
33
+ @default_algorithm = :zstd
34
+ end
35
+ rescue LoadError
36
+ # zstd not available
37
+ end
38
+
39
+ # Try to load snappy with platform-specific support
40
+ begin
41
+ if defined?(JRUBY_VERSION)
42
+ require 'jruby-snappy'
43
+ @algorithms[:snappy] = {
44
+ compress: ->(data) { Snappy.deflate(data) },
45
+ decompress: ->(data) { Snappy.inflate(data) }
46
+ }
47
+ else
48
+ require 'snappy'
49
+ @algorithms[:snappy] = {
50
+ compress: ->(data) { Snappy.deflate(data) },
51
+ decompress: ->(data) { Snappy.inflate(data) }
52
+ }
53
+ end
54
+ @default_algorithm ||= :snappy
55
+ rescue LoadError
56
+ # snappy not available
57
+ end
58
+
59
+ # Try to load lz4 with platform-specific support
60
+ begin
61
+ if defined?(JRUBY_VERSION)
62
+ require 'jruby-lz4'
63
+ @algorithms[:lz4] = {
64
+ compress: ->(data) { LZ4::compress(data) },
65
+ decompress: ->(data) { LZ4::uncompress(data, data.bytesize * 3) } # Estimate output size
66
+ }
67
+ else
68
+ require 'lz4-ruby'
69
+ @algorithms[:lz4] = {
70
+ compress: ->(data) { LZ4.compress(data) },
71
+ decompress: ->(data) { LZ4.decompress(data) }
72
+ }
73
+ end
74
+ @default_algorithm ||= :lz4
75
+ rescue LoadError
76
+ # lz4 not available
77
+ end
78
+
79
+ def self.available_algorithms
80
+ @algorithms.keys
81
+ end
82
+
83
+ def self.algorithm_available?(algorithm)
84
+ @algorithms.key?(algorithm)
85
+ end
86
+
87
+ def self.default_algorithm
88
+ @default_algorithm
89
+ end
90
+
91
+ def self.compress(data, algorithm = nil)
92
+ return [data, nil] unless data && !data.empty?
93
+
94
+ algorithm ||= @default_algorithm
95
+ return [data, nil] unless algorithm && @algorithms[algorithm]
96
+
97
+ begin
98
+ compressed = @algorithms[algorithm][:compress].call(data)
99
+ [compressed, algorithm]
100
+ rescue => e
101
+ # Fallback to uncompressed data on error
102
+ [data, nil]
103
+ end
104
+ end
105
+
106
+ def self.decompress(data, algorithm)
107
+ return data unless data && !data.empty? && algorithm
108
+
109
+ # Check if algorithm is a valid symbol we recognize
110
+ unless ID_TO_ALGORITHM.values.include?(algorithm)
111
+ raise "Unknown compression algorithm identifier: #{algorithm}"
112
+ end
113
+
114
+ # Check if the algorithm is available
115
+ unless @algorithms[algorithm]
116
+ raise "Compression algorithm #{algorithm} required but not available. Please install the required gem."
117
+ end
118
+
119
+ begin
120
+ @algorithms[algorithm][:decompress].call(data)
121
+ rescue => e
122
+ raise "Error decompressing data: #{e.message}. The #{algorithm} library may not be installed correctly."
123
+ end
124
+ end
125
+
126
+ def self.enabled?
127
+ !@algorithms.empty?
128
+ end
129
+ end
data/test_aesx.rb ADDED
@@ -0,0 +1,429 @@
1
+ #!/usr/local/bin/ruby
2
+
3
+ # Copyright (c) 2025 Tom Lahti
4
+ # MIT License
5
+
6
+ require 'minitest/autorun'
7
+ require 'aesx'
8
+ require 'digest'
9
+ require 'fileutils'
10
+ require 'securerandom'
11
+
12
+ class TestAESX < Minitest::Test
13
+ def setup
14
+ @key = SecureRandom.hex(32)
15
+ @plaintext = "This is a test message!"
16
+ end
17
+
18
+ def test_key_normalization_with_hex_key
19
+ hex_key = "a3dcb4b56faed1b20f43aee7e20b40513bf6c5f764c831b95372e142ebff4236"
20
+ cipher = AESX::AESX.new(hex_key)
21
+ normalized_key = cipher.instance_variable_get(:@key)
22
+ assert_equal 32, normalized_key.bytesize, "The normalized key should be 32 bytes."
23
+ end
24
+
25
+ def test_key_normalization_with_short_hex_key
26
+ short_hex_key = "a3dcb4b56faed1b20f43aee7e20b40"
27
+ cipher = AESX::AESX.new(short_hex_key)
28
+ normalized_key = cipher.instance_variable_get(:@key)
29
+ assert_equal 32, normalized_key.bytesize, "The normalized key should be padded to 32 bytes."
30
+ end
31
+
32
+ def test_key_normalization_with_long_hex_key
33
+ long_hex_key = "a3dcb4b56faed1b20f43aee7e20b40513bf6c5f764c831b95372e142ebff4236a3dcb4b56faed1b20f43aee7e20b4051"
34
+ cipher = AESX::AESX.new(long_hex_key)
35
+ normalized_key = cipher.instance_variable_get(:@key)
36
+ assert_equal 32, normalized_key.bytesize, "The normalized key should be truncated to 32 bytes."
37
+ end
38
+
39
+ def test_key_normalization_with_non_hex_key
40
+ password_key = "my password"
41
+ cipher = AESX::AESX.new(password_key)
42
+ normalized_key = cipher.instance_variable_get(:@key)
43
+ assert_equal 32, normalized_key.bytesize, "The SHA-256 hashed key should be 32 bytes."
44
+ end
45
+
46
+ def test_encrypt_decrypt_with_base64_format
47
+ encrypted = AESX.encrypt(@plaintext, @key, format: :base_64)
48
+ decrypted = AESX.decrypt(encrypted, @key, format: :base_64)
49
+
50
+ assert_equal @plaintext, decrypted, "Decrypted text should match the original plaintext."
51
+ end
52
+
53
+ def test_encrypt_decrypt_with_binary_format
54
+ encrypted = AESX.encrypt(@plaintext, @key, format: :binary)
55
+ decrypted = AESX.decrypt(encrypted, @key, format: :binary)
56
+
57
+ assert_equal @plaintext, decrypted, "Decrypted text should match the original plaintext."
58
+ end
59
+
60
+ def test_encrypt_decrypt_with_plain_format
61
+ encrypted = AESX.encrypt(@plaintext, @key, format: :plain)
62
+ decrypted = AESX.decrypt(encrypted, @key, format: :plain)
63
+
64
+ assert_equal @plaintext, decrypted, "Decrypted text should match the original plaintext."
65
+ end
66
+
67
+ def test_encrypt_decrypt_with_random_iv
68
+ encrypted = AESX.encrypt(@plaintext, @key, format: :base_64)
69
+ decrypted = AESX.decrypt(encrypted, @key, format: :base_64)
70
+
71
+ assert_equal @plaintext, decrypted, "Decrypted text should match the original plaintext when using a random IV."
72
+ end
73
+
74
+ def test_encrypt_decrypt_with_32_byte_key
75
+ key_32_bytes = "a" * 32 # exactly 32 bytes
76
+ encrypted = AESX.encrypt(@plaintext, key_32_bytes, format: :base_64)
77
+ decrypted = AESX.decrypt(encrypted, key_32_bytes, format: :base_64)
78
+
79
+ assert_equal @plaintext, decrypted, "Decrypted text should match the original plaintext when using a 32-byte key."
80
+ end
81
+
82
+ def test_invalid_cipher_text
83
+ # Generate a valid encrypted string first
84
+ valid_encrypted = AESX.encrypt("test", @key, format: :base_64)
85
+ # Corrupt the ciphertext portion
86
+ parts = valid_encrypted.split('$')
87
+ parts[1] = "invalidciphertext"
88
+ invalid_encrypted = parts.join('$')
89
+
90
+ assert_raises(OpenSSL::Cipher::CipherError) do
91
+ AESX.decrypt(invalid_encrypted, @key, format: :base_64)
92
+ end
93
+ end
94
+
95
+ def test_invalid_decryption_format
96
+ invalid_encrypted = "invalidcipherdata"
97
+ assert_raises(ArgumentError) do
98
+ AESX.decrypt(invalid_encrypted, @key, format: :base_64)
99
+ end
100
+ end
101
+
102
+ def test_decrypt_with_invalid_key
103
+ invalid_key = "invalidkey123456" # Invalid key that won't match
104
+ encrypted = AESX.encrypt(@plaintext, @key, format: :base_64)
105
+
106
+ # Try to decrypt with an invalid key
107
+ decrypted = nil
108
+ assert_raises(OpenSSL::Cipher::CipherError) do
109
+ decrypted = AESX.decrypt(encrypted, invalid_key, format: :base_64)
110
+ end
111
+
112
+ refute_equal @plaintext, decrypted, "Decryption with an invalid key should not yield the original plaintext."
113
+ end
114
+
115
+ def test_key_generation_default
116
+ key = AESX.key
117
+ assert_equal 32, key.bytesize, "Default key should be 32 bytes."
118
+ end
119
+
120
+ def test_key_generation_with_length
121
+ key = AESX.key(128)
122
+ assert_equal 16, key.bytesize, "128-bit key should be 16 bytes."
123
+ end
124
+
125
+ def test_key_generation_with_base64
126
+ key = AESX.key(256, :base_64)
127
+ decoded = Base64.decode64(key)
128
+ assert_equal 32, decoded.bytesize, "Base64 key should decode to 32 bytes."
129
+ end
130
+
131
+ def test_iv_generation_default
132
+ iv = AESX.iv
133
+ assert_equal 12, iv.bytesize, "Default IV should be 12 bytes."
134
+ end
135
+
136
+ def test_iv_generation_with_base64
137
+ iv = AESX.iv(:base_64)
138
+ decoded = Base64.decode64(iv)
139
+ assert_equal 12, decoded.bytesize, "Base64 IV should decode to 12 bytes."
140
+ end
141
+
142
+ def test_random_iv_and_key_methods
143
+ aes = AESX::AESX.new("testkey" * 4) # 32-byte key
144
+ iv = aes.random_iv
145
+ key = aes.random_key
146
+ assert_equal 12, iv.bytesize, "Random IV should be 12 bytes."
147
+ assert_equal 32, key.bytesize, "Random key should be 32 bytes."
148
+ end
149
+
150
+ def test_encrypt_decrypt_with_short_aes
151
+ plaintext = "Secret message"
152
+ cipher = 'AES-128-GCM'
153
+ key = AESX.key(cipher: cipher)
154
+
155
+ encrypted = AESX.encrypt(plaintext, key, cipher: cipher)
156
+ decrypted = AESX.decrypt(encrypted, key, cipher: cipher)
157
+
158
+ assert_equal plaintext, decrypted, "Decrypted text should match original with custom cipher."
159
+ end
160
+
161
+ def test_encrypt_decrypt_with_ctr
162
+ plaintext = "Secret message"
163
+ cipher = 'AES-256-CTR'
164
+ key = AESX.key(cipher: cipher)
165
+
166
+ encrypted = AESX.encrypt(plaintext, key, cipher: cipher)
167
+ decrypted = AESX.decrypt(encrypted, key, cipher: cipher)
168
+
169
+ assert_equal plaintext, decrypted, "Decrypted text should match original with custom cipher."
170
+ end
171
+
172
+ def test_encrypt_decrypt_with_aria
173
+ plaintext = "Secret message"
174
+ cipher = 'ARIA-256-CTR'
175
+ key = AESX.key(cipher: cipher)
176
+
177
+ encrypted = AESX.encrypt(plaintext, key, cipher: cipher)
178
+ decrypted = AESX.decrypt(encrypted, key, cipher: cipher)
179
+
180
+ assert_equal plaintext, decrypted, "Decrypted text should match original with custom cipher."
181
+ end
182
+
183
+ def test_encrypt_decrypt_with_sm4
184
+ plaintext = "Secret message"
185
+ cipher = 'SM4-CTR'
186
+ key = AESX.key(cipher: cipher)
187
+
188
+ encrypted = AESX.encrypt(plaintext, key, cipher: cipher)
189
+ decrypted = AESX.decrypt(encrypted, key, cipher: cipher)
190
+
191
+ assert_equal plaintext, decrypted, "Decrypted text should match original with custom cipher."
192
+ end
193
+
194
+ def test_encrypt_decrypt_with_chacha
195
+ plaintext = "Secret message"
196
+ cipher = 'chacha20-poly1305'
197
+ key = AESX.key(cipher: cipher)
198
+
199
+ encrypted = AESX.encrypt(plaintext, key, cipher: cipher)
200
+ decrypted = AESX.decrypt(encrypted, key, cipher: cipher)
201
+
202
+ assert_equal plaintext, decrypted, "Decrypted text should match original with custom cipher."
203
+ end
204
+
205
+ def test_disable_compression_with_all_formats
206
+ # Create a larger string to better demonstrate compression
207
+ large_plaintext = "This is a test message with repetitive content. " * 100
208
+
209
+ # Test with each format
210
+ [:base_64, :binary, :plain].each do |format|
211
+ encrypted = AESX.encrypt(large_plaintext, @key, format: format, compression: false)
212
+ decrypted = AESX.decrypt(encrypted, @key)
213
+
214
+ assert_equal large_plaintext, decrypted, "Decryption should match original plaintext with format #{format} and compression disabled"
215
+ end
216
+ end
217
+
218
+ def test_zstd_compression_with_all_formats
219
+ # Skip if zstd is not available
220
+ skip "Zstd compression not available" unless AESCompression.algorithm_available?(:zstd)
221
+
222
+ large_plaintext = "This is a test message with repetitive content. " * 100
223
+
224
+ # Test with each format
225
+ [:base_64, :binary, :plain].each do |format|
226
+ encrypted = AESX.encrypt(large_plaintext, @key, format: format, compression: :zstd)
227
+ decrypted = AESX.decrypt(encrypted, @key)
228
+
229
+ assert_equal large_plaintext, decrypted, "Decryption should match original plaintext with format #{format} and zstd compression"
230
+ end
231
+ end
232
+
233
+ def test_snappy_compression_with_all_formats
234
+ # Skip if snappy is not available
235
+ skip "Snappy compression not available" unless AESCompression.algorithm_available?(:snappy)
236
+
237
+ large_plaintext = "This is a test message with repetitive content. " * 100
238
+
239
+ # Test with each format
240
+ [:base_64, :binary, :plain].each do |format|
241
+ encrypted = AESX.encrypt(large_plaintext, @key, format: format, compression: :snappy)
242
+ decrypted = AESX.decrypt(encrypted, @key)
243
+
244
+ assert_equal large_plaintext, decrypted, "Decryption should match original plaintext with format #{format} and snappy compression"
245
+ end
246
+ end
247
+
248
+ def test_lz4_compression_with_all_formats
249
+ # Skip if lz4 is not available
250
+ skip "LZ4 compression not available" unless AESCompression.algorithm_available?(:lz4)
251
+
252
+ large_plaintext = "This is a test message with repetitive content. " * 100
253
+
254
+ # Test with each format
255
+ [:base_64, :binary, :plain].each do |format|
256
+ encrypted = AESX.encrypt(large_plaintext, @key, format: format, compression: :lz4)
257
+ decrypted = AESX.decrypt(encrypted, @key)
258
+
259
+ assert_equal large_plaintext, decrypted, "Decryption should match original plaintext with format #{format} and lz4 compression"
260
+ end
261
+ end
262
+
263
+ def test_default_compression_with_all_formats
264
+ # Skip if no compression algorithms are available
265
+ skip "No compression algorithms available" unless AESCompression.default_algorithm
266
+
267
+ large_plaintext = "This is a test message with repetitive content. " * 100
268
+
269
+ # Test with each format
270
+ [:base_64, :binary, :plain].each do |format|
271
+ # Using default compression (nil or not specified)
272
+ encrypted = AESX.encrypt(large_plaintext, @key, format: format)
273
+ decrypted = AESX.decrypt(encrypted, @key)
274
+
275
+ assert_equal large_plaintext, decrypted, "Decryption should match original plaintext with format #{format} and default compression"
276
+ end
277
+ end
278
+
279
+ def test_invalid_compression_algorithm
280
+ assert_raises(ArgumentError) do
281
+ AESX.encrypt("test", @key, compression: :invalid_algorithm)
282
+ end
283
+ end
284
+
285
+ def test_cross_format_compatibility
286
+ # Skip if no compression algorithms are available
287
+ skip "No compression algorithms available" unless AESCompression.default_algorithm
288
+
289
+ large_plaintext = "This is a test message with repetitive content. " * 50
290
+
291
+ # Test encrypting with one format and decrypting with auto-detection
292
+ encrypted_base64 = AESX.encrypt(large_plaintext, @key, format: :base_64)
293
+ encrypted_binary = AESX.encrypt(large_plaintext, @key, format: :binary)
294
+ encrypted_plain = AESX.encrypt(large_plaintext, @key, format: :plain)
295
+
296
+ # Decrypt all without specifying format (should auto-detect)
297
+ decrypted_from_base64 = AESX.decrypt(encrypted_base64, @key)
298
+ decrypted_from_binary = AESX.decrypt(encrypted_binary, @key)
299
+ decrypted_from_plain = AESX.decrypt(encrypted_plain, @key)
300
+
301
+ assert_equal large_plaintext, decrypted_from_base64, "Should correctly decrypt base64 format with auto-detection"
302
+ assert_equal large_plaintext, decrypted_from_binary, "Should correctly decrypt binary format with auto-detection"
303
+ assert_equal large_plaintext, decrypted_from_plain, "Should correctly decrypt plain format with auto-detection"
304
+ end
305
+
306
+ def test_compression_algorithm_persistence
307
+ # Skip if zstd is not available
308
+ skip "Zstd compression not available" unless AESCompression.algorithm_available?(:zstd)
309
+
310
+ large_plaintext = "This is a test message with repetitive content. " * 100
311
+
312
+ # Encrypt with a specific algorithm
313
+ encrypted = AESX.encrypt(large_plaintext, @key, compression: :zstd)
314
+
315
+ # Create a new instance with different default settings
316
+ different_default = AESX::AESX.new(@key, compression: false)
317
+
318
+ # It should still decrypt correctly by reading the embedded algorithm info
319
+ decrypted = different_default.decrypt(encrypted)
320
+
321
+ assert_equal large_plaintext, decrypted, "Should decrypt correctly even when instance defaults differ from encryption settings"
322
+ end
323
+
324
+ def test_cross_platform_compatibility
325
+ # This test simulates what happens when data is encrypted on one system
326
+ # and decrypted on another with different compression libraries available
327
+
328
+ # Skip if no compression algorithms are available
329
+ skip "No compression algorithms available" unless AESCompression.default_algorithm
330
+
331
+ large_plaintext = "This is a test message with repetitive content. " * 100
332
+
333
+ # Save the original state
334
+ orig_algorithms = AESCompression.instance_variable_get(:@algorithms).dup
335
+ orig_default = AESCompression.instance_variable_get(:@default_algorithm)
336
+
337
+ begin
338
+ # First, encrypt with zstd
339
+ if AESCompression.algorithm_available?(:zstd)
340
+ encrypted = AESX.encrypt(large_plaintext, @key, compression: :zstd)
341
+
342
+ # Now simulate a system that only has snappy
343
+ AESCompression.instance_variable_set(:@algorithms,
344
+ orig_algorithms.select { |k, _| k == :snappy })
345
+ AESCompression.instance_variable_set(:@default_algorithm, :snappy)
346
+
347
+ # It should still decrypt correctly by reading the embedded algorithm info
348
+ # and using the correct decompression method or raising a clear error
349
+ if AESCompression.algorithm_available?(:zstd)
350
+ decrypted = AESX.decrypt(encrypted, @key)
351
+ assert_equal large_plaintext, decrypted, "Should decompress with the correct algorithm"
352
+ else
353
+ assert_raises(RuntimeError) do
354
+ AESX.decrypt(encrypted, @key)
355
+ end
356
+ end
357
+ end
358
+ ensure
359
+ # Restore original state
360
+ AESCompression.instance_variable_set(:@algorithms, orig_algorithms)
361
+ AESCompression.instance_variable_set(:@default_algorithm, orig_default)
362
+ end
363
+ end
364
+
365
+ def test_decrypt_with_unavailable_algorithm
366
+ # Create encrypted data with a default algorithm
367
+ large_plaintext = "This is a test message with repetitive content. " * 50
368
+ encrypted = AESX.encrypt(large_plaintext, @key)
369
+
370
+ # Save original state
371
+ orig_algorithms = AESCompression.instance_variable_get(:@algorithms).dup
372
+
373
+ begin
374
+ # For base64 format, modify the compression flag
375
+ parts = encrypted.split('$')
376
+
377
+ # Determine an algorithm that isn't currently loaded
378
+ # We'll use ID 3 for lz4 if it's not available, otherwise 2 for snappy
379
+ algorithm_id = AESCompression.algorithm_available?(:lz4) ? 2 : 3
380
+
381
+ # Ensure the algorithm we're testing isn't available
382
+ AESCompression.instance_variable_set(:@algorithms,
383
+ orig_algorithms.reject { |k, _| k == AESCompression::ID_TO_ALGORITHM[algorithm_id] })
384
+
385
+ # Set the compression flag to our chosen algorithm
386
+ parts[3] = algorithm_id.to_s
387
+ modified_encrypted = parts.join('$')
388
+
389
+ # Try to decrypt with the modified compression flag
390
+ assert_raises(RuntimeError) do
391
+ AESX.decrypt(modified_encrypted, @key)
392
+ end
393
+ ensure
394
+ # Restore original algorithms
395
+ AESCompression.instance_variable_set(:@algorithms, orig_algorithms)
396
+ end
397
+ end
398
+
399
+ def test_advanced_usage_non_default_cipher
400
+ key = AESX.key(cipher: 'CHACHA20-POLY1305')
401
+
402
+ # Create an AESX object with non-default cipher
403
+ cipher = AESX::AESX.new(key, {
404
+ cipher: 'CHACHA20-POLY1305',
405
+ padding: true,
406
+ compression: :zstd,
407
+ auth_data: "additional authentication data"
408
+ })
409
+
410
+ # Test encryption
411
+ message1 = "Message 1"
412
+ message2 = "Message 2"
413
+
414
+ encrypted1 = cipher.encrypt(message1)
415
+ encrypted2 = cipher.encrypt(message2)
416
+
417
+ # Verify successful encryption
418
+ refute_nil encrypted1
419
+ refute_nil encrypted2
420
+
421
+ # Verify decryption
422
+ decrypted1 = cipher.decrypt(encrypted1)
423
+ decrypted2 = cipher.decrypt(encrypted2)
424
+
425
+ assert_equal message1, decrypted1
426
+ assert_equal message2, decrypted2
427
+ end
428
+
429
+ end
metadata ADDED
@@ -0,0 +1,104 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: aesx
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.2
5
+ platform: ruby
6
+ authors:
7
+ - Tom lahti
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 2025-02-28 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: openssl
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '2.0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '2.0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: zstd-ruby
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '0'
33
+ type: :development
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: snappy
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '0'
47
+ type: :development
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '0'
54
+ - !ruby/object:Gem::Dependency
55
+ name: lz4-ruby
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: '0'
61
+ type: :development
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: '0'
68
+ description: Provides almost the same interface as the AES gem, but with modern ciphers
69
+ and compression. The default cipher is AES-256-GCM. See the README for details.
70
+ email:
71
+ - uidzip@gmail.com
72
+ executables: []
73
+ extensions: []
74
+ extra_rdoc_files: []
75
+ files:
76
+ - LICENSE
77
+ - README.md
78
+ - lib/aesx.rb
79
+ - lib/compression.rb
80
+ - test_aesx.rb
81
+ homepage: https://github.com/uidzip/aesx
82
+ licenses:
83
+ - MIT
84
+ metadata:
85
+ source_code_uri: https://github.com/uidzip/aesx
86
+ bug_tracker_uri: https://github.com/uidzip/aesx/issues
87
+ rdoc_options: []
88
+ require_paths:
89
+ - lib
90
+ required_ruby_version: !ruby/object:Gem::Requirement
91
+ requirements:
92
+ - - ">="
93
+ - !ruby/object:Gem::Version
94
+ version: '3.0'
95
+ required_rubygems_version: !ruby/object:Gem::Requirement
96
+ requirements:
97
+ - - ">="
98
+ - !ruby/object:Gem::Version
99
+ version: '0'
100
+ requirements: []
101
+ rubygems_version: 3.6.5
102
+ specification_version: 4
103
+ summary: A lightweight encryption library, in the style of the AES gem
104
+ test_files: []