ff1 1.0.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +29 -0
- data/DUAL_MODE.md +288 -0
- data/README.md +28 -0
- data/examples/basic_usage.rb +83 -18
- data/lib/ff1/cipher.rb +190 -24
- data/lib/ff1/modes.rb +23 -4
- data/lib/ff1/version.rb +5 -2
- data/lib/ff1.rb +26 -1
- data/spec/ff1_spec.rb +212 -71
- data/spec/spec_helper.rb +3 -1
- metadata +16 -6
data/spec/ff1_spec.rb
CHANGED
@@ -1,7 +1,9 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
require 'spec_helper'
|
2
4
|
|
3
5
|
RSpec.describe FF1 do
|
4
|
-
it
|
6
|
+
it 'has a version number' do
|
5
7
|
expect(FF1::VERSION).not_to be nil
|
6
8
|
end
|
7
9
|
|
@@ -9,182 +11,321 @@ RSpec.describe FF1 do
|
|
9
11
|
let(:key) { "\x2B\x7E\x15\x16\x28\xAE\xD2\xA6\xAB\xF7\x15\x88\x09\xCF\x4F\x3C" }
|
10
12
|
let(:cipher) { FF1::Cipher.new(key, 10) }
|
11
13
|
|
12
|
-
describe
|
13
|
-
it
|
14
|
+
describe '#initialize' do
|
15
|
+
it 'accepts a valid 128-bit key' do
|
14
16
|
expect { FF1::Cipher.new(key, 10) }.not_to raise_error
|
15
17
|
end
|
16
18
|
|
17
|
-
it
|
19
|
+
it 'accepts a valid 192-bit key' do
|
18
20
|
key_192 = "\x2B" * 24
|
19
21
|
expect { FF1::Cipher.new(key_192, 10) }.not_to raise_error
|
20
22
|
end
|
21
23
|
|
22
|
-
it
|
24
|
+
it 'accepts a valid 256-bit key' do
|
23
25
|
key_256 = "\x2B" * 32
|
24
26
|
expect { FF1::Cipher.new(key_256, 10) }.not_to raise_error
|
25
27
|
end
|
26
28
|
|
27
|
-
it
|
29
|
+
it 'rejects invalid key lengths' do
|
28
30
|
invalid_key = "\x2B" * 15
|
29
|
-
expect
|
31
|
+
expect do
|
32
|
+
FF1::Cipher.new(invalid_key, 10)
|
33
|
+
end.to raise_error(FF1::Error, 'Invalid key length - must be 16, 24, or 32 bytes')
|
30
34
|
end
|
31
35
|
|
32
|
-
it
|
33
|
-
expect { FF1::Cipher.new(key, 1) }.to raise_error(FF1::Error,
|
34
|
-
expect
|
36
|
+
it 'rejects invalid radix values' do
|
37
|
+
expect { FF1::Cipher.new(key, 1) }.to raise_error(FF1::Error, 'Invalid radix - must be between 2 and 65536')
|
38
|
+
expect do
|
39
|
+
FF1::Cipher.new(key, 65_537)
|
40
|
+
end.to raise_error(FF1::Error, 'Invalid radix - must be between 2 and 65536')
|
35
41
|
end
|
36
42
|
|
37
|
-
it
|
43
|
+
it 'accepts valid radix values' do
|
38
44
|
expect { FF1::Cipher.new(key, 2) }.not_to raise_error
|
39
45
|
expect { FF1::Cipher.new(key, 16) }.not_to raise_error
|
40
|
-
expect { FF1::Cipher.new(key,
|
46
|
+
expect { FF1::Cipher.new(key, 65_536) }.not_to raise_error
|
41
47
|
end
|
42
48
|
end
|
43
49
|
|
44
|
-
describe
|
45
|
-
it
|
46
|
-
plaintext =
|
50
|
+
describe '#encrypt and #decrypt' do
|
51
|
+
it 'encrypts and decrypts correctly for numeric data' do
|
52
|
+
plaintext = '1234567890'
|
47
53
|
ciphertext = cipher.encrypt(plaintext)
|
48
|
-
|
54
|
+
|
49
55
|
expect(ciphertext).not_to eq(plaintext)
|
50
56
|
expect(ciphertext.length).to eq(plaintext.length)
|
51
57
|
expect(cipher.decrypt(ciphertext)).to eq(plaintext)
|
52
58
|
end
|
53
59
|
|
54
|
-
it
|
55
|
-
plaintext =
|
60
|
+
it 'preserves the format of the data' do
|
61
|
+
plaintext = '9876543210'
|
56
62
|
ciphertext = cipher.encrypt(plaintext)
|
57
|
-
|
63
|
+
|
58
64
|
expect(ciphertext.length).to eq(plaintext.length)
|
59
65
|
ciphertext.each_char do |char|
|
60
66
|
expect(char).to match(/[0-9]/)
|
61
67
|
end
|
62
68
|
end
|
63
69
|
|
64
|
-
it
|
65
|
-
plaintext1 =
|
66
|
-
plaintext2 =
|
67
|
-
|
70
|
+
it 'produces different ciphertext for different plaintexts' do
|
71
|
+
plaintext1 = '1234567890'
|
72
|
+
plaintext2 = '0987654321'
|
73
|
+
|
68
74
|
ciphertext1 = cipher.encrypt(plaintext1)
|
69
75
|
ciphertext2 = cipher.encrypt(plaintext2)
|
70
|
-
|
76
|
+
|
71
77
|
expect(ciphertext1).not_to eq(ciphertext2)
|
72
78
|
end
|
73
79
|
|
74
|
-
it
|
75
|
-
plaintext =
|
76
|
-
tweak =
|
77
|
-
|
80
|
+
it 'works with tweaks' do
|
81
|
+
plaintext = '1234567890'
|
82
|
+
tweak = 'test'
|
83
|
+
|
78
84
|
ciphertext_with_tweak = cipher.encrypt(plaintext, tweak)
|
79
85
|
ciphertext_without_tweak = cipher.encrypt(plaintext)
|
80
|
-
|
86
|
+
|
81
87
|
expect(ciphertext_with_tweak).not_to eq(ciphertext_without_tweak)
|
82
88
|
expect(cipher.decrypt(ciphertext_with_tweak, tweak)).to eq(plaintext)
|
83
89
|
end
|
84
90
|
|
85
|
-
it
|
91
|
+
it 'handles hexadecimal data with radix 16' do
|
86
92
|
hex_cipher = FF1::Cipher.new(key, 16)
|
87
|
-
plaintext =
|
88
|
-
|
93
|
+
plaintext = 'ABCDEF123456'
|
94
|
+
|
89
95
|
ciphertext = hex_cipher.encrypt(plaintext)
|
90
96
|
expect(hex_cipher.decrypt(ciphertext)).to eq(plaintext)
|
91
|
-
|
97
|
+
|
92
98
|
ciphertext.each_char do |char|
|
93
99
|
expect(char).to match(/[0-9A-F]/)
|
94
100
|
end
|
95
101
|
end
|
96
102
|
|
97
|
-
it
|
98
|
-
expect { cipher.encrypt(
|
103
|
+
it 'raises error for input too short' do
|
104
|
+
expect { cipher.encrypt('1') }.to raise_error(FF1::Error, 'Input length must be at least 2')
|
99
105
|
end
|
100
106
|
|
101
|
-
it
|
102
|
-
expect { cipher.encrypt(
|
107
|
+
it 'raises error for empty input' do
|
108
|
+
expect { cipher.encrypt('') }.to raise_error(FF1::Error, 'Input cannot be empty')
|
103
109
|
end
|
104
110
|
|
105
|
-
it
|
106
|
-
expect { cipher.encrypt(nil) }.to raise_error(FF1::Error,
|
111
|
+
it 'raises error for nil input' do
|
112
|
+
expect { cipher.encrypt(nil) }.to raise_error(FF1::Error, 'Input cannot be nil')
|
107
113
|
end
|
108
114
|
|
109
|
-
it
|
110
|
-
expect { cipher.encrypt(
|
115
|
+
it 'raises error for invalid characters' do
|
116
|
+
expect { cipher.encrypt('12A4') }.to raise_error(FF1::Error, /Invalid character/)
|
111
117
|
end
|
112
118
|
|
113
|
-
it
|
119
|
+
it 'raises error for domain too small' do
|
114
120
|
# Create a cipher with radix 2 and input "10" -> 2^2 = 4 < 100
|
115
121
|
binary_cipher = FF1::Cipher.new(key, 2)
|
116
|
-
short_plaintext =
|
122
|
+
short_plaintext = '10'
|
117
123
|
expect { binary_cipher.encrypt(short_plaintext) }.to raise_error(FF1::Error, /Domain size too small/)
|
118
124
|
end
|
119
125
|
|
120
|
-
it
|
121
|
-
plaintext =
|
126
|
+
it 'handles minimum valid domain size' do
|
127
|
+
plaintext = '12345' # 10^5 = 100,000 > 100
|
122
128
|
ciphertext = cipher.encrypt(plaintext)
|
123
129
|
expect(cipher.decrypt(ciphertext)).to eq(plaintext)
|
124
130
|
end
|
125
131
|
end
|
126
132
|
|
127
|
-
describe
|
128
|
-
it
|
129
|
-
plaintext =
|
130
|
-
|
133
|
+
describe 'consistency tests' do
|
134
|
+
it 'encryption is deterministic' do
|
135
|
+
plaintext = '1234567890'
|
136
|
+
|
131
137
|
ciphertext1 = cipher.encrypt(plaintext)
|
132
138
|
ciphertext2 = cipher.encrypt(plaintext)
|
133
|
-
|
139
|
+
|
134
140
|
expect(ciphertext1).to eq(ciphertext2)
|
135
141
|
end
|
136
142
|
|
137
|
-
it
|
138
|
-
plaintext =
|
143
|
+
it 'decryption is deterministic' do
|
144
|
+
plaintext = '1234567890'
|
139
145
|
ciphertext = cipher.encrypt(plaintext)
|
140
|
-
|
146
|
+
|
141
147
|
decrypted1 = cipher.decrypt(ciphertext)
|
142
148
|
decrypted2 = cipher.decrypt(ciphertext)
|
143
|
-
|
149
|
+
|
144
150
|
expect(decrypted1).to eq(decrypted2)
|
145
151
|
expect(decrypted1).to eq(plaintext)
|
146
152
|
end
|
147
153
|
|
148
|
-
it
|
149
|
-
[
|
150
|
-
next if plaintext.length < 3
|
151
|
-
|
154
|
+
it 'handles various input lengths' do
|
155
|
+
%w[123 1234 12345 123456 1234567 12345678 123456789 1234567890].each do |plaintext|
|
156
|
+
next if plaintext.length < 3 # Skip too short inputs
|
157
|
+
|
152
158
|
ciphertext = cipher.encrypt(plaintext)
|
153
159
|
decrypted = cipher.decrypt(ciphertext)
|
154
|
-
|
160
|
+
|
155
161
|
expect(decrypted).to eq(plaintext), "Failed for input: #{plaintext}"
|
156
162
|
expect(ciphertext.length).to eq(plaintext.length), "Length mismatch for: #{plaintext}"
|
157
163
|
end
|
158
164
|
end
|
159
165
|
end
|
160
166
|
|
161
|
-
describe
|
162
|
-
it
|
167
|
+
describe 'different radix support' do
|
168
|
+
it 'works with radix 2 (binary)' do
|
163
169
|
binary_cipher = FF1::Cipher.new(key, 2)
|
164
|
-
#
|
170
|
+
# NOTE: For radix 2, we need longer inputs to meet domain size requirements
|
165
171
|
# 2^7 = 128 > 100
|
166
|
-
plaintext =
|
167
|
-
|
172
|
+
plaintext = '1010101'
|
173
|
+
|
168
174
|
ciphertext = binary_cipher.encrypt(plaintext)
|
169
175
|
expect(binary_cipher.decrypt(ciphertext)).to eq(plaintext)
|
170
|
-
|
176
|
+
|
171
177
|
ciphertext.each_char do |char|
|
172
178
|
expect(char).to match(/[01]/)
|
173
179
|
end
|
174
180
|
end
|
175
181
|
|
176
|
-
it
|
182
|
+
it 'works with radix 36' do
|
177
183
|
alpha_cipher = FF1::Cipher.new(key, 36)
|
178
184
|
# For testing, we'll use a simple numeric string that fits within radix 36
|
179
|
-
|
180
|
-
|
185
|
+
|
181
186
|
# Convert to proper format for radix 36 (0-9, then continue with character codes)
|
182
187
|
# For simplicity in this test, we'll just use numbers
|
183
|
-
plaintext =
|
184
|
-
|
188
|
+
plaintext = '123456'
|
189
|
+
|
185
190
|
ciphertext = alpha_cipher.encrypt(plaintext)
|
186
191
|
expect(alpha_cipher.decrypt(ciphertext)).to eq(plaintext)
|
187
192
|
end
|
188
193
|
end
|
194
|
+
|
195
|
+
describe 'text encryption support' do
|
196
|
+
it 'encrypts and decrypts simple text' do
|
197
|
+
text = 'Hello, World!'
|
198
|
+
encrypted = cipher.encrypt_text(text)
|
199
|
+
decrypted = cipher.decrypt_text(encrypted)
|
200
|
+
|
201
|
+
expect(decrypted).to eq(text)
|
202
|
+
expect(encrypted).not_to eq(text)
|
203
|
+
expect(encrypted).to match(%r{\A[A-Za-z0-9+/]+=*\z}) # base64 format
|
204
|
+
end
|
205
|
+
|
206
|
+
it 'handles text with special characters' do
|
207
|
+
text = "Special chars: !@#$%^&*()_+={[}]|\\:;\"'<,>.?/~`"
|
208
|
+
encrypted = cipher.encrypt_text(text)
|
209
|
+
decrypted = cipher.decrypt_text(encrypted)
|
210
|
+
|
211
|
+
expect(decrypted).to eq(text)
|
212
|
+
end
|
213
|
+
|
214
|
+
it 'handles Unicode text including emojis' do
|
215
|
+
text = 'Unicode: Héllo Wörld! 🌍🚀💻🔐'
|
216
|
+
encrypted = cipher.encrypt_text(text)
|
217
|
+
decrypted = cipher.decrypt_text(encrypted)
|
218
|
+
|
219
|
+
expect(decrypted).to eq(text)
|
220
|
+
end
|
221
|
+
|
222
|
+
it 'handles long text' do
|
223
|
+
text = 'This is a very long piece of text that contains multiple sentences and should test the ability of the FF1 cipher to handle large text inputs while maintaining all the security properties of format-preserving encryption. ' * 10
|
224
|
+
encrypted = cipher.encrypt_text(text)
|
225
|
+
decrypted = cipher.decrypt_text(encrypted)
|
226
|
+
|
227
|
+
expect(decrypted).to eq(text)
|
228
|
+
expect(text.length).to be > 1000 # Ensure it's actually long
|
229
|
+
end
|
230
|
+
|
231
|
+
it 'works with tweaks for text' do
|
232
|
+
text = 'Secret message'
|
233
|
+
tweak = 'context_123'
|
234
|
+
|
235
|
+
encrypted_with_tweak = cipher.encrypt_text(text, tweak)
|
236
|
+
encrypted_without_tweak = cipher.encrypt_text(text)
|
237
|
+
|
238
|
+
expect(encrypted_with_tweak).not_to eq(encrypted_without_tweak)
|
239
|
+
expect(cipher.decrypt_text(encrypted_with_tweak, tweak)).to eq(text)
|
240
|
+
end
|
241
|
+
|
242
|
+
it 'handles minimum length text (single character)' do
|
243
|
+
text = 'A'
|
244
|
+
encrypted = cipher.encrypt_text(text)
|
245
|
+
decrypted = cipher.decrypt_text(encrypted)
|
246
|
+
|
247
|
+
expect(decrypted).to eq(text)
|
248
|
+
end
|
249
|
+
|
250
|
+
it 'handles empty text edge case' do
|
251
|
+
expect { cipher.encrypt_text('') }.to raise_error(FF1::Error, 'Text cannot be empty')
|
252
|
+
expect { cipher.encrypt_text(nil) }.to raise_error(FF1::Error, 'Text cannot be nil')
|
253
|
+
end
|
254
|
+
|
255
|
+
it 'handles invalid base64 in decrypt_text' do
|
256
|
+
invalid_b64 = 'not-valid-base64!'
|
257
|
+
expect { cipher.decrypt_text(invalid_b64) }.to raise_error(FF1::Error, /Invalid base64/)
|
258
|
+
end
|
259
|
+
|
260
|
+
it 'text encryption is deterministic' do
|
261
|
+
text = 'Deterministic test'
|
262
|
+
|
263
|
+
encrypted1 = cipher.encrypt_text(text)
|
264
|
+
encrypted2 = cipher.encrypt_text(text)
|
265
|
+
|
266
|
+
expect(encrypted1).to eq(encrypted2)
|
267
|
+
end
|
268
|
+
|
269
|
+
it 'produces different ciphertext for different texts' do
|
270
|
+
text1 = 'First message'
|
271
|
+
text2 = 'Second message'
|
272
|
+
|
273
|
+
encrypted1 = cipher.encrypt_text(text1)
|
274
|
+
encrypted2 = cipher.encrypt_text(text2)
|
275
|
+
|
276
|
+
expect(encrypted1).not_to eq(encrypted2)
|
277
|
+
end
|
278
|
+
|
279
|
+
context 'with irreversible mode' do
|
280
|
+
let(:irreversible_cipher) { FF1::Cipher.new(key, 10, FF1::Modes::IRREVERSIBLE) }
|
281
|
+
|
282
|
+
it 'encrypts text in irreversible mode' do
|
283
|
+
text = 'Sensitive data to be deleted'
|
284
|
+
encrypted = irreversible_cipher.encrypt_text(text)
|
285
|
+
|
286
|
+
expect(encrypted).not_to eq(text)
|
287
|
+
expect(encrypted).to match(%r{\A[A-Za-z0-9+/]+=*\z}) # base64 format
|
288
|
+
end
|
289
|
+
|
290
|
+
it 'prevents decryption in irreversible mode' do
|
291
|
+
text = 'Cannot decrypt this'
|
292
|
+
encrypted = irreversible_cipher.encrypt_text(text)
|
293
|
+
|
294
|
+
expect { irreversible_cipher.decrypt_text(encrypted) }.to raise_error(
|
295
|
+
FF1::Error, 'Cannot decrypt text in irreversible mode - data is permanently transformed'
|
296
|
+
)
|
297
|
+
end
|
298
|
+
|
299
|
+
it 'produces consistent ciphertext for same plaintext in irreversible mode' do
|
300
|
+
text = 'Consistent irreversible'
|
301
|
+
|
302
|
+
encrypted1 = irreversible_cipher.encrypt_text(text)
|
303
|
+
encrypted2 = irreversible_cipher.encrypt_text(text)
|
304
|
+
|
305
|
+
expect(encrypted1).to eq(encrypted2)
|
306
|
+
end
|
307
|
+
end
|
308
|
+
|
309
|
+
context 'with different UTF-8 encodings' do
|
310
|
+
it 'handles text with different UTF-8 byte sequences' do
|
311
|
+
# Test various UTF-8 characters
|
312
|
+
texts = [
|
313
|
+
'ASCII only',
|
314
|
+
'Latin-1: café résumé',
|
315
|
+
'Cyrillic: Привет мир',
|
316
|
+
'Asian: 你好世界 こんにちは 안녕하세요',
|
317
|
+
'Math symbols: ∑∞≠±×÷',
|
318
|
+
'Emojis: 👋🌟💫✨🎉',
|
319
|
+
'Mixed: Hello 世界! 🌍'
|
320
|
+
]
|
321
|
+
|
322
|
+
texts.each do |text|
|
323
|
+
encrypted = cipher.encrypt_text(text)
|
324
|
+
decrypted = cipher.decrypt_text(encrypted)
|
325
|
+
expect(decrypted).to eq(text), "Failed for text: #{text}"
|
326
|
+
end
|
327
|
+
end
|
328
|
+
end
|
329
|
+
end
|
189
330
|
end
|
190
|
-
end
|
331
|
+
end
|
data/spec/spec_helper.rb
CHANGED
metadata
CHANGED
@@ -1,10 +1,10 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: ff1
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 1.
|
4
|
+
version: 1.1.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
|
-
-
|
7
|
+
- Ahmed Abdellatif
|
8
8
|
autorequire:
|
9
9
|
bindir: exe
|
10
10
|
cert_chain: []
|
@@ -38,8 +38,11 @@ dependencies:
|
|
38
38
|
- - "~>"
|
39
39
|
- !ruby/object:Gem::Version
|
40
40
|
version: '3.0'
|
41
|
-
description: A Ruby implementation of the FF1 Format Preserving Encryption algorithm
|
42
|
-
from NIST SP 800-38G
|
41
|
+
description: "A Ruby implementation of the FF1 Format Preserving Encryption algorithm
|
42
|
+
from NIST SP 800-38G.\nFeatures dual-mode operation: reversible encryption for active
|
43
|
+
data and irreversible encryption \nfor GDPR compliance and secure data deletion
|
44
|
+
while maintaining format and relationships.\nNow includes full UTF-8 text encryption
|
45
|
+
support for arbitrary strings while preserving FF1 security properties.\n"
|
43
46
|
email:
|
44
47
|
- ahmed.abdelatife@gmail.com
|
45
48
|
executables: []
|
@@ -48,6 +51,7 @@ extra_rdoc_files: []
|
|
48
51
|
files:
|
49
52
|
- CHANGELOG.md
|
50
53
|
- DEMO.md
|
54
|
+
- DUAL_MODE.md
|
51
55
|
- IMPLEMENTATION.md
|
52
56
|
- LICENSE
|
53
57
|
- README.md
|
@@ -61,7 +65,12 @@ files:
|
|
61
65
|
homepage: https://github.com/a-abdellatif98/ff1
|
62
66
|
licenses:
|
63
67
|
- MIT
|
64
|
-
metadata:
|
68
|
+
metadata:
|
69
|
+
bug_tracker_uri: https://github.com/a-abdellatif98/ff1/issues
|
70
|
+
changelog_uri: https://github.com/a-abdellatif98/ff1/blob/main/CHANGELOG.md
|
71
|
+
documentation_uri: https://github.com/a-abdellatif98/ff1/blob/main/README.md
|
72
|
+
homepage_uri: https://github.com/a-abdellatif98/ff1
|
73
|
+
source_code_uri: https://github.com/a-abdellatif98/ff1
|
65
74
|
post_install_message:
|
66
75
|
rdoc_options: []
|
67
76
|
require_paths:
|
@@ -80,5 +89,6 @@ requirements: []
|
|
80
89
|
rubygems_version: 3.4.1
|
81
90
|
signing_key:
|
82
91
|
specification_version: 4
|
83
|
-
summary: FF1 Format Preserving Encryption
|
92
|
+
summary: NIST-compliant FF1 Format Preserving Encryption with dual-mode operation
|
93
|
+
and full text support
|
84
94
|
test_files: []
|