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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +156 -0
- data/lib/aesx.rb +391 -0
- data/lib/compression.rb +129 -0
- data/test_aesx.rb +429 -0
- 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
|
data/lib/compression.rb
ADDED
@@ -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: []
|