ff1 1.0.0 → 1.2.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/lib/ff1/cipher.rb
CHANGED
@@ -1,18 +1,39 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
require 'openssl'
|
2
4
|
require 'digest'
|
3
5
|
|
4
6
|
module FF1
|
7
|
+
# FF1 Format Preserving Encryption Cipher
|
8
|
+
#
|
9
|
+
# Implements NIST SP 800-38G FF1 algorithm with dual-mode operation:
|
10
|
+
# - Reversible mode: Standard FPE that can decrypt back to original
|
11
|
+
# - Irreversible mode: Secure deletion while maintaining format/relationships
|
12
|
+
#
|
5
13
|
class Cipher
|
6
14
|
ROUNDS = 10
|
7
15
|
|
16
|
+
# Initialize FF1 cipher
|
17
|
+
#
|
18
|
+
# @param key [String] Encryption key (16, 24, or 32 bytes for AES-128/192/256)
|
19
|
+
# @param radix [Integer] Base for the numeral system (2-65536)
|
20
|
+
# @param mode [Symbol] Encryption mode (:reversible or :irreversible)
|
21
|
+
#
|
8
22
|
def initialize(key, radix = 10, mode = Modes::REVERSIBLE)
|
9
23
|
@key = key
|
10
24
|
@radix = radix
|
11
25
|
@mode = mode
|
12
26
|
@irreversible_salt = generate_irreversible_salt if @mode == Modes::IRREVERSIBLE
|
27
|
+
|
13
28
|
validate_parameters
|
14
29
|
end
|
15
30
|
|
31
|
+
# Encrypt plaintext using FF1 algorithm
|
32
|
+
#
|
33
|
+
# @param plaintext [String] Data to encrypt (must meet domain size requirements)
|
34
|
+
# @param tweak [String] Additional data for encryption uniqueness (optional)
|
35
|
+
# @return [String] Encrypted data in same format as input
|
36
|
+
#
|
16
37
|
def encrypt(plaintext, tweak = '')
|
17
38
|
validate_input(plaintext)
|
18
39
|
|
@@ -23,6 +44,13 @@ module FF1
|
|
23
44
|
end
|
24
45
|
end
|
25
46
|
|
47
|
+
# Decrypt ciphertext back to original plaintext
|
48
|
+
#
|
49
|
+
# @param ciphertext [String] Encrypted data to decrypt
|
50
|
+
# @param tweak [String] Same tweak used during encryption
|
51
|
+
# @return [String] Original plaintext
|
52
|
+
# @raise [FF1::Error] If cipher is in irreversible mode
|
53
|
+
#
|
26
54
|
def decrypt(ciphertext, tweak = '')
|
27
55
|
if @mode == Modes::IRREVERSIBLE
|
28
56
|
raise FF1::Error, 'Cannot decrypt in irreversible mode - data is permanently transformed'
|
@@ -31,18 +59,106 @@ module FF1
|
|
31
59
|
reversible_decrypt(ciphertext, tweak)
|
32
60
|
end
|
33
61
|
|
34
|
-
# Check if the cipher
|
62
|
+
# Check if the cipher can decrypt data
|
63
|
+
# @return [Boolean] true if in reversible mode
|
35
64
|
def reversible?
|
36
65
|
@mode == Modes::REVERSIBLE
|
37
66
|
end
|
38
67
|
|
39
|
-
# Check if the cipher
|
68
|
+
# Check if the cipher cannot decrypt data
|
69
|
+
# @return [Boolean] true if in irreversible mode
|
40
70
|
def irreversible?
|
41
71
|
@mode == Modes::IRREVERSIBLE
|
42
72
|
end
|
43
73
|
|
74
|
+
# Encrypt arbitrary text using UTF-8 encoding
|
75
|
+
#
|
76
|
+
# @param text [String] Text to encrypt (any UTF-8 string)
|
77
|
+
# @param tweak [String] Additional data for encryption uniqueness (optional)
|
78
|
+
# @return [String] Encrypted text as base64-encoded string
|
79
|
+
#
|
80
|
+
def encrypt_text(text, tweak = '')
|
81
|
+
raise FF1::Error, 'Text cannot be nil' if text.nil?
|
82
|
+
raise FF1::Error, 'Text cannot be empty' if text.empty?
|
83
|
+
|
84
|
+
# Convert text to bytes and then to radix-256 representation
|
85
|
+
bytes = text.dup.force_encoding('UTF-8').bytes
|
86
|
+
|
87
|
+
# Use radix 256 for full byte support
|
88
|
+
old_radix = @radix
|
89
|
+
@radix = 256
|
90
|
+
|
91
|
+
begin
|
92
|
+
# Convert bytes to string representation for FF1
|
93
|
+
byte_string = bytes.map(&:chr).join
|
94
|
+
|
95
|
+
# Ensure minimum length for domain size (256^2 = 65536 > 100)
|
96
|
+
byte_string += "\x00" * (2 - byte_string.length) if byte_string.length < 2
|
97
|
+
|
98
|
+
# Encrypt using standard FF1
|
99
|
+
encrypted_bytes = if @mode == Modes::IRREVERSIBLE
|
100
|
+
irreversible_encrypt(byte_string, tweak)
|
101
|
+
else
|
102
|
+
reversible_encrypt(byte_string, tweak)
|
103
|
+
end
|
104
|
+
|
105
|
+
# Return as base64 for safe text representation
|
106
|
+
require 'base64'
|
107
|
+
Base64.strict_encode64(encrypted_bytes)
|
108
|
+
ensure
|
109
|
+
@radix = old_radix
|
110
|
+
end
|
111
|
+
end
|
112
|
+
|
113
|
+
# Decrypt text back to original UTF-8 string
|
114
|
+
#
|
115
|
+
# @param encrypted_text [String] Base64-encoded encrypted text
|
116
|
+
# @param tweak [String] Same tweak used during encryption
|
117
|
+
# @return [String] Original UTF-8 text
|
118
|
+
# @raise [FF1::Error] If cipher is in irreversible mode
|
119
|
+
#
|
120
|
+
def decrypt_text(encrypted_text, tweak = '')
|
121
|
+
if @mode == Modes::IRREVERSIBLE
|
122
|
+
raise FF1::Error, 'Cannot decrypt text in irreversible mode - data is permanently transformed'
|
123
|
+
end
|
124
|
+
|
125
|
+
raise FF1::Error, 'Encrypted text cannot be nil' if encrypted_text.nil?
|
126
|
+
raise FF1::Error, 'Encrypted text cannot be empty' if encrypted_text.empty?
|
127
|
+
|
128
|
+
# Decode from base64
|
129
|
+
require 'base64'
|
130
|
+
begin
|
131
|
+
encrypted_bytes = Base64.strict_decode64(encrypted_text)
|
132
|
+
rescue ArgumentError => e
|
133
|
+
raise FF1::Error, "Invalid base64 encrypted text: #{e.message}"
|
134
|
+
end
|
135
|
+
|
136
|
+
# Use radix 256 for full byte support
|
137
|
+
old_radix = @radix
|
138
|
+
@radix = 256
|
139
|
+
|
140
|
+
begin
|
141
|
+
# Decrypt using standard FF1
|
142
|
+
decrypted_bytes = reversible_decrypt(encrypted_bytes, tweak)
|
143
|
+
|
144
|
+
# Remove any null padding that was added for minimum length
|
145
|
+
decrypted_bytes = decrypted_bytes.sub(/\x00+\z/, '')
|
146
|
+
|
147
|
+
# Convert back to UTF-8 string
|
148
|
+
decrypted_bytes.dup.force_encoding('UTF-8')
|
149
|
+
ensure
|
150
|
+
@radix = old_radix
|
151
|
+
end
|
152
|
+
end
|
153
|
+
|
44
154
|
private
|
45
155
|
|
156
|
+
# =========================================================================
|
157
|
+
# REVERSIBLE MODE METHODS
|
158
|
+
# Standard FF1 encryption that can be decrypted back to original
|
159
|
+
# =========================================================================
|
160
|
+
|
161
|
+
# Perform standard FF1 encryption (reversible)
|
46
162
|
def reversible_encrypt(plaintext, tweak)
|
47
163
|
# Convert string to integer array
|
48
164
|
x = string_to_numeral_array(plaintext)
|
@@ -56,7 +172,7 @@ module FF1
|
|
56
172
|
u = n / 2
|
57
173
|
v = n - u
|
58
174
|
a = x[0...u]
|
59
|
-
b = x[u
|
175
|
+
b = x[u..]
|
60
176
|
|
61
177
|
# FF1 encryption rounds
|
62
178
|
(0...ROUNDS).each do |i|
|
@@ -83,10 +199,13 @@ module FF1
|
|
83
199
|
numeral_array_to_string(a + b)
|
84
200
|
end
|
85
201
|
|
86
|
-
|
87
|
-
|
88
|
-
|
202
|
+
# =========================================================================
|
203
|
+
# IRREVERSIBLE MODE METHODS
|
204
|
+
# Modified FF1 that cannot be decrypted - for GDPR compliance
|
205
|
+
# =========================================================================
|
89
206
|
|
207
|
+
# Perform irreversible FF1 encryption (cannot be decrypted)
|
208
|
+
def irreversible_encrypt(plaintext, tweak)
|
90
209
|
# Convert string to integer array
|
91
210
|
x = string_to_numeral_array(plaintext)
|
92
211
|
n = x.length
|
@@ -96,16 +215,18 @@ module FF1
|
|
96
215
|
raise FF1::Error, "Domain size too small (#{domain_size} < 100)" if domain_size < 100
|
97
216
|
|
98
217
|
# Create irreversible key by hashing original key + salt + plaintext
|
99
|
-
# This ensures
|
100
|
-
#
|
218
|
+
# This ensures:
|
219
|
+
# 1. Same plaintext always maps to same ciphertext (maintains relationships)
|
220
|
+
# 2. Reverse computation is mathematically impossible
|
101
221
|
irreversible_key = create_irreversible_key(plaintext, tweak)
|
102
222
|
|
103
|
-
# Use modified FF1 with irreversible key and
|
223
|
+
# Use modified FF1 with irreversible key and entropy destruction
|
104
224
|
result = perform_irreversible_ff1(x, irreversible_key, tweak)
|
105
225
|
|
106
226
|
numeral_array_to_string(result)
|
107
227
|
end
|
108
228
|
|
229
|
+
# Create one-way irreversible key from inputs
|
109
230
|
def create_irreversible_key(plaintext, tweak)
|
110
231
|
# Create a one-way hash that includes:
|
111
232
|
# - Original key (for uniqueness per cipher instance)
|
@@ -113,7 +234,13 @@ module FF1
|
|
113
234
|
# - Plaintext (makes each plaintext map consistently)
|
114
235
|
# - Tweak (maintains tweak-based differentiation)
|
115
236
|
|
116
|
-
|
237
|
+
# Ensure all inputs have compatible encodings
|
238
|
+
key_binary = @key.dup.force_encoding('BINARY')
|
239
|
+
salt_binary = @irreversible_salt.dup.force_encoding('BINARY')
|
240
|
+
plaintext_binary = plaintext.dup.force_encoding('BINARY')
|
241
|
+
tweak_binary = tweak.dup.force_encoding('BINARY')
|
242
|
+
|
243
|
+
hash_input = key_binary + salt_binary + plaintext_binary + tweak_binary
|
117
244
|
hashed = Digest::SHA256.digest(hash_input)
|
118
245
|
|
119
246
|
# Return same length as original key for AES compatibility
|
@@ -125,7 +252,7 @@ module FF1
|
|
125
252
|
u = n / 2
|
126
253
|
v = n - u
|
127
254
|
a = x[0...u]
|
128
|
-
b = x[u
|
255
|
+
b = x[u..]
|
129
256
|
|
130
257
|
# Use same FF1 structure but with irreversible key
|
131
258
|
# and entropy-destroying modifications
|
@@ -138,7 +265,7 @@ module FF1
|
|
138
265
|
|
139
266
|
# Add extra entropy destruction by mixing in round number and salt
|
140
267
|
entropy_destroyer = Digest::SHA256.digest(@irreversible_salt + [i].pack('C'))[0...8]
|
141
|
-
y = xor_bytes(y[0...8], entropy_destroyer) + y[8
|
268
|
+
y = xor_bytes(y[0...8], entropy_destroyer) + y[8..]
|
142
269
|
|
143
270
|
@key = old_key # Restore for next operations
|
144
271
|
|
@@ -158,13 +285,13 @@ module FF1
|
|
158
285
|
def generate_irreversible_salt
|
159
286
|
# Generate a unique salt for this cipher instance
|
160
287
|
# This prevents rainbow table attacks on irreversible mode
|
161
|
-
Digest::SHA256.digest(@key
|
288
|
+
Digest::SHA256.digest("#{@key}irreversible_mode")[0...16]
|
162
289
|
end
|
163
290
|
|
164
291
|
def xor_bytes(bytes1, bytes2)
|
165
292
|
result = ''
|
166
293
|
[bytes1.bytesize, bytes2.bytesize].min.times do |i|
|
167
|
-
result += [
|
294
|
+
result += [bytes1.getbyte(i) ^ bytes2.getbyte(i)].pack('C')
|
168
295
|
end
|
169
296
|
result
|
170
297
|
end
|
@@ -184,7 +311,7 @@ module FF1
|
|
184
311
|
u = n / 2
|
185
312
|
v = n - u
|
186
313
|
a = x[0...u]
|
187
|
-
b = x[u
|
314
|
+
b = x[u..]
|
188
315
|
|
189
316
|
# FF1 decryption rounds (reverse order)
|
190
317
|
(ROUNDS - 1).downto(0) do |i|
|
@@ -210,21 +337,37 @@ module FF1
|
|
210
337
|
numeral_array_to_string(a + b)
|
211
338
|
end
|
212
339
|
|
340
|
+
# =========================================================================
|
341
|
+
# VALIDATION METHODS
|
342
|
+
# Input and parameter validation for security
|
343
|
+
# =========================================================================
|
344
|
+
|
345
|
+
# Validate cipher initialization parameters
|
213
346
|
def validate_parameters
|
214
|
-
raise FF1::Error, 'Invalid key length
|
215
|
-
|
347
|
+
raise FF1::Error, 'Invalid key length - must be 16, 24, or 32 bytes' unless [16, 24, 32].include?(@key.bytesize)
|
348
|
+
|
349
|
+
return if (2..65_536).cover?(@radix)
|
350
|
+
|
351
|
+
raise FF1::Error, 'Invalid radix - must be between 2 and 65536'
|
216
352
|
end
|
217
353
|
|
354
|
+
# Validate input data for encryption/decryption
|
218
355
|
def validate_input(input)
|
219
356
|
raise FF1::Error, 'Input cannot be nil' if input.nil?
|
220
357
|
raise FF1::Error, 'Input cannot be empty' if input.empty?
|
221
358
|
raise FF1::Error, 'Input length must be at least 2' if input.length < 2
|
222
359
|
end
|
223
360
|
|
361
|
+
# =========================================================================
|
362
|
+
# CORE FF1 ALGORITHM METHODS
|
363
|
+
# Implementation of NIST SP 800-38G FF1 specification
|
364
|
+
# =========================================================================
|
365
|
+
|
366
|
+
# FF1 round function - core of the algorithm
|
224
367
|
def round_function(round, b, tweak)
|
225
368
|
# Construct P according to FF1 spec
|
226
|
-
radix_bytes = [@radix].pack('N')[1
|
227
|
-
p = "\
|
369
|
+
radix_bytes = [@radix].pack('N')[1..] # Take 3 bytes of radix
|
370
|
+
p = "\u0001\u0002\u0001#{radix_bytes}" # 1,2,1 followed by radix in 3 bytes
|
228
371
|
p += [0x0a].pack('C') # 10 rounds
|
229
372
|
p += [b.length].pack('C') # length of right side
|
230
373
|
p += [tweak.bytesize].pack('N') # tweak length in 4 bytes
|
@@ -262,7 +405,7 @@ module FF1
|
|
262
405
|
# XOR with previous result
|
263
406
|
xor_block = ''
|
264
407
|
16.times do |j|
|
265
|
-
xor_block += [
|
408
|
+
xor_block += [block.getbyte(j) ^ result.getbyte(j)].pack('C')
|
266
409
|
end
|
267
410
|
|
268
411
|
# Encrypt the XOR result
|
@@ -276,14 +419,22 @@ module FF1
|
|
276
419
|
result
|
277
420
|
end
|
278
421
|
|
422
|
+
# =========================================================================
|
423
|
+
# CHARACTER CONVERSION METHODS
|
424
|
+
# Handle different radix systems (binary, decimal, hex, alphanumeric)
|
425
|
+
# =========================================================================
|
426
|
+
|
427
|
+
# Convert string to array of numeric values based on radix
|
279
428
|
def string_to_numeral_array(str)
|
280
429
|
str.chars.map { |char| char_to_digit(char) }
|
281
430
|
end
|
282
431
|
|
432
|
+
# Convert array of numeric values back to string based on radix
|
283
433
|
def numeral_array_to_string(array)
|
284
434
|
array.map { |digit| digit_to_char(digit) }.join
|
285
435
|
end
|
286
436
|
|
437
|
+
# Convert character to digit based on current radix
|
287
438
|
def char_to_digit(char)
|
288
439
|
case @radix
|
289
440
|
when 2..10
|
@@ -292,17 +443,21 @@ module FF1
|
|
292
443
|
|
293
444
|
raise FF1::Error, "Invalid character '#{char}' for radix #{@radix}"
|
294
445
|
when 11..36
|
295
|
-
|
446
|
+
case char
|
447
|
+
when /\d/
|
296
448
|
char.to_i
|
297
|
-
|
449
|
+
when /[A-Z]/
|
298
450
|
char.ord - 'A'.ord + 10
|
299
|
-
|
451
|
+
when /[a-z]/
|
300
452
|
char.ord - 'a'.ord + 10
|
301
453
|
else
|
302
454
|
raise FF1::Error, "Invalid character '#{char}' for radix #{@radix}"
|
303
455
|
end
|
456
|
+
when 256
|
457
|
+
# Special case for text encryption - use byte values directly
|
458
|
+
char.ord
|
304
459
|
else
|
305
|
-
# For higher radix values, use ASCII values
|
460
|
+
# For other higher radix values, use ASCII values with validation
|
306
461
|
digit = char.ord
|
307
462
|
raise FF1::Error, "Invalid character '#{char}' for radix #{@radix}" if digit >= @radix
|
308
463
|
|
@@ -320,11 +475,20 @@ module FF1
|
|
320
475
|
else
|
321
476
|
(digit - 10 + 'A'.ord).chr
|
322
477
|
end
|
478
|
+
when 256
|
479
|
+
# Special case for text encryption - convert byte values back to characters
|
480
|
+
digit.chr
|
323
481
|
else
|
324
482
|
digit.chr
|
325
483
|
end
|
326
484
|
end
|
327
485
|
|
486
|
+
# =========================================================================
|
487
|
+
# MATHEMATICAL UTILITY METHODS
|
488
|
+
# Number base conversions and modular arithmetic
|
489
|
+
# =========================================================================
|
490
|
+
|
491
|
+
# Convert numeral array to integer using specified radix
|
328
492
|
def numeral_array_to_integer(array, radix)
|
329
493
|
result = 0
|
330
494
|
array.each do |digit|
|
@@ -333,6 +497,7 @@ module FF1
|
|
333
497
|
result
|
334
498
|
end
|
335
499
|
|
500
|
+
# Convert integer to numeral array with specified radix and length
|
336
501
|
def integer_to_numeral_array(int, radix, length)
|
337
502
|
array = []
|
338
503
|
length.times do
|
@@ -342,6 +507,7 @@ module FF1
|
|
342
507
|
array
|
343
508
|
end
|
344
509
|
|
510
|
+
# Convert byte string to integer (big-endian)
|
345
511
|
def byte_string_to_integer(bytes)
|
346
512
|
result = 0
|
347
513
|
bytes.each_byte do |byte|
|
data/lib/ff1/modes.rb
CHANGED
@@ -1,11 +1,30 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
module FF1
|
2
|
-
# Encryption
|
4
|
+
# FF1 Encryption Modes
|
5
|
+
#
|
6
|
+
# Provides two distinct modes of operation for different security requirements:
|
7
|
+
# - Reversible: Standard encryption that can be decrypted
|
8
|
+
# - Irreversible: Secure "deletion" while maintaining data relationships
|
9
|
+
#
|
3
10
|
module Modes
|
4
|
-
# Normal reversible encryption
|
11
|
+
# Normal reversible encryption mode
|
12
|
+
#
|
13
|
+
# - Can decrypt back to original plaintext
|
14
|
+
# - Use for active business data that needs to be accessed
|
15
|
+
# - Standard FF1 algorithm implementation
|
16
|
+
# - Perfect for payment processing, customer service, reporting
|
17
|
+
#
|
5
18
|
REVERSIBLE = :reversible
|
6
19
|
|
7
|
-
# Irreversible encryption
|
8
|
-
#
|
20
|
+
# Irreversible encryption mode
|
21
|
+
#
|
22
|
+
# - Cannot decrypt back to original (mathematically impossible)
|
23
|
+
# - Maintains format and data relationships
|
24
|
+
# - Use for GDPR "right to be forgotten" compliance
|
25
|
+
# - Perfect for secure data deletion, employee termination, account closure
|
26
|
+
# - Even with the key, original data cannot be recovered
|
27
|
+
#
|
9
28
|
IRREVERSIBLE = :irreversible
|
10
29
|
end
|
11
30
|
end
|
data/lib/ff1/version.rb
CHANGED
data/lib/ff1.rb
CHANGED
@@ -1,7 +1,32 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
require_relative 'ff1/version'
|
2
4
|
require_relative 'ff1/modes'
|
3
5
|
require_relative 'ff1/cipher'
|
4
6
|
|
7
|
+
# FF1 Format Preserving Encryption
|
8
|
+
#
|
9
|
+
# A Ruby implementation of NIST SP 800-38G FF1 algorithm with dual-mode operation.
|
10
|
+
# Provides both reversible encryption (standard FPE) and irreversible encryption
|
11
|
+
# (secure deletion while maintaining format and relationships).
|
12
|
+
#
|
13
|
+
# @example Basic Usage
|
14
|
+
# key = SecureRandom.bytes(32)
|
15
|
+
#
|
16
|
+
# # Reversible mode (can decrypt)
|
17
|
+
# cipher = FF1::Cipher.new(key, 10, FF1::Modes::REVERSIBLE)
|
18
|
+
# encrypted = cipher.encrypt("1234567890")
|
19
|
+
# decrypted = cipher.decrypt(encrypted) # => "1234567890"
|
20
|
+
#
|
21
|
+
# # Irreversible mode (cannot decrypt - for GDPR compliance)
|
22
|
+
# secure_cipher = FF1::Cipher.new(key, 10, FF1::Modes::IRREVERSIBLE)
|
23
|
+
# secured = secure_cipher.encrypt("1234567890")
|
24
|
+
# # secure_cipher.decrypt(secured) # Raises FF1::Error
|
25
|
+
#
|
26
|
+
# @see https://csrc.nist.gov/publications/detail/sp/800-38g/final NIST SP 800-38G
|
27
|
+
# @see https://rubygems.org/gems/ff1 RubyGems Page
|
28
|
+
#
|
5
29
|
module FF1
|
30
|
+
# Base error class for all FF1-related errors
|
6
31
|
class Error < StandardError; end
|
7
|
-
end
|
32
|
+
end
|