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.
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 is in reversible mode
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 is in irreversible mode
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..-1]
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
- def irreversible_encrypt(plaintext, tweak)
87
- # Step 1: Create a deterministic but irreversible transformation
88
- # This maintains format and relationships but prevents decryption
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 same plaintext always maps to same ciphertext (maintains relationships)
100
- # but makes reverse computation impossible
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 additional entropy destruction
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
- hash_input = @key + @irreversible_salt + plaintext + tweak
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..-1]
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..-1]
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 + 'irreversible_mode')[0...16]
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 += [(bytes1.getbyte(i) ^ bytes2.getbyte(i))].pack('C')
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..-1]
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' if @key.bytesize != 16 && @key.bytesize != 24 && @key.bytesize != 32
215
- raise FF1::Error, 'Invalid radix' if @radix < 2 || @radix > 65_536
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..-1] # Take 3 bytes of radix
227
- p = "\x01\x02\x01" + radix_bytes # 1,2,1 followed by radix in 3 bytes
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 += [(block.getbyte(j) ^ result.getbyte(j))].pack('C')
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
- if char.match?(/\d/)
446
+ case char
447
+ when /\d/
296
448
  char.to_i
297
- elsif char.match?(/[A-Z]/)
449
+ when /[A-Z]/
298
450
  char.ord - 'A'.ord + 10
299
- elsif char.match?(/[a-z]/)
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 modes for different use cases
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 - can decrypt back to original
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 - maintains format and relationships but cannot decrypt
8
- # Useful for GDPR "right to be forgotten" compliance
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
@@ -1,3 +1,6 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module FF1
2
- VERSION = "1.0.0"
3
- end
4
+ # Current version of the FF1 gem
5
+ VERSION = '1.1.0'
6
+ end
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