ff1 1.0.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 +7 -0
- data/CHANGELOG.md +65 -0
- data/DEMO.md +198 -0
- data/IMPLEMENTATION.md +354 -0
- data/LICENSE +21 -0
- data/README.md +168 -0
- data/examples/basic_usage.rb +90 -0
- data/lib/ff1/cipher.rb +353 -0
- data/lib/ff1/modes.rb +11 -0
- data/lib/ff1/version.rb +3 -0
- data/lib/ff1.rb +7 -0
- data/spec/ff1_spec.rb +190 -0
- data/spec/spec_helper.rb +14 -0
- metadata +84 -0
data/README.md
ADDED
@@ -0,0 +1,168 @@
|
|
1
|
+
# FF1 Format Preserving Encryption Gem
|
2
|
+
|
3
|
+
A Ruby implementation of the FF1 Format Preserving Encryption algorithm from NIST SP 800-38G.
|
4
|
+
|
5
|
+
## Overview
|
6
|
+
|
7
|
+
Format Preserving Encryption (FPE) allows you to encrypt data while maintaining its original format. This is particularly useful when you need to encrypt sensitive data like credit card numbers, social security numbers, or other structured data while keeping the same format for compatibility with existing systems.
|
8
|
+
|
9
|
+
The FF1 algorithm is one of two methods specified in NIST Special Publication 800-38G for Format-Preserving Encryption.
|
10
|
+
|
11
|
+
## Features
|
12
|
+
|
13
|
+
* Full implementation of NIST FF1 algorithm
|
14
|
+
* **Dual-mode operation**: Reversible and Irreversible encryption
|
15
|
+
* Support for any radix from 2 to 65, 536
|
16
|
+
* Tweak support for additional security
|
17
|
+
* GDPR "right to be forgotten" compliance
|
18
|
+
* Proper input validation and error handling
|
19
|
+
* Comprehensive test suite
|
20
|
+
* Thread-safe implementation
|
21
|
+
|
22
|
+
## Installation
|
23
|
+
|
24
|
+
Add this line to your application's Gemfile:
|
25
|
+
|
26
|
+
```ruby
|
27
|
+
gem 'ff1'
|
28
|
+
```
|
29
|
+
|
30
|
+
And then execute:
|
31
|
+
|
32
|
+
$ bundle install
|
33
|
+
|
34
|
+
Or install it yourself as:
|
35
|
+
|
36
|
+
$ gem install ff1
|
37
|
+
|
38
|
+
## Usage
|
39
|
+
|
40
|
+
### Basic Usage
|
41
|
+
|
42
|
+
```ruby
|
43
|
+
require 'ff1'
|
44
|
+
|
45
|
+
# Create a cipher with a 128-bit key for decimal numbers (radix 10)
|
46
|
+
key = "\x2B\x7E\x15\x16\x28\xAE\xD2\xA6\xAB\xF7\x15\x88\x09\xCF\x4F\x3C"
|
47
|
+
|
48
|
+
# Reversible mode (default) - can decrypt back to original
|
49
|
+
cipher = FF1::Cipher.new(key, 10, FF1::Modes::REVERSIBLE)
|
50
|
+
plaintext = "4111111111111111"
|
51
|
+
ciphertext = cipher.encrypt(plaintext)
|
52
|
+
decrypted = cipher.decrypt(ciphertext)
|
53
|
+
puts "#{plaintext} → #{ciphertext} → #{decrypted}"
|
54
|
+
# => "4111111111111111 → 8224999410799188 → 4111111111111111"
|
55
|
+
|
56
|
+
# Irreversible mode - cannot decrypt (for GDPR compliance)
|
57
|
+
secure_cipher = FF1::Cipher.new(key, 10, FF1::Modes::IRREVERSIBLE)
|
58
|
+
secure_ciphertext = secure_cipher.encrypt(plaintext)
|
59
|
+
# secure_cipher.decrypt(secure_ciphertext) # ❌ Raises error
|
60
|
+
puts "Securely deleted: #{plaintext} → #{secure_ciphertext}"
|
61
|
+
# => "Securely deleted: 4111111111111111 → 2858907702179518"
|
62
|
+
```
|
63
|
+
|
64
|
+
### Using Tweaks
|
65
|
+
|
66
|
+
Tweaks provide additional input to the encryption algorithm for enhanced security:
|
67
|
+
|
68
|
+
```ruby
|
69
|
+
cipher = FF1::Cipher.new(key, 10)
|
70
|
+
tweak = "user123"
|
71
|
+
|
72
|
+
plaintext = "1234567890"
|
73
|
+
ciphertext = cipher.encrypt(plaintext, tweak)
|
74
|
+
decrypted = cipher.decrypt(ciphertext, tweak)
|
75
|
+
```
|
76
|
+
|
77
|
+
### Different Radix Values
|
78
|
+
|
79
|
+
```ruby
|
80
|
+
# For hexadecimal data (radix 16)
|
81
|
+
hex_cipher = FF1::Cipher.new(key, 16)
|
82
|
+
hex_data = "ABCDEF123456"
|
83
|
+
encrypted_hex = hex_cipher.encrypt(hex_data)
|
84
|
+
|
85
|
+
# For binary data (radix 2)
|
86
|
+
binary_cipher = FF1::Cipher.new(key, 2)
|
87
|
+
binary_data = "1010101" # Must be long enough to meet domain requirements
|
88
|
+
encrypted_binary = binary_cipher.encrypt(binary_data)
|
89
|
+
```
|
90
|
+
|
91
|
+
### Key Requirements
|
92
|
+
|
93
|
+
The FF1 algorithm supports AES key lengths:
|
94
|
+
* 128-bit keys (16 bytes)
|
95
|
+
* 192-bit keys (24 bytes)
|
96
|
+
* 256-bit keys (32 bytes)
|
97
|
+
|
98
|
+
```ruby
|
99
|
+
# 128-bit key
|
100
|
+
key_128 = SecureRandom.bytes(16)
|
101
|
+
cipher_128 = FF1::Cipher.new(key_128, 10)
|
102
|
+
|
103
|
+
# 256-bit key
|
104
|
+
key_256 = SecureRandom.bytes(32)
|
105
|
+
cipher_256 = FF1::Cipher.new(key_256, 10)
|
106
|
+
```
|
107
|
+
|
108
|
+
## Domain Size Requirements
|
109
|
+
|
110
|
+
For security, the FF1 algorithm requires that `radix^length >= 100` . For better security, `radix^length >= 1,000,000` is recommended.
|
111
|
+
|
112
|
+
Examples:
|
113
|
+
* Decimal (radix 10): minimum 2 digits, recommended 7+ digits
|
114
|
+
* Hexadecimal (radix 16): minimum 2 digits, recommended 5+ digits
|
115
|
+
* Binary (radix 2): minimum 7 bits, recommended 20+ bits
|
116
|
+
|
117
|
+
## Error Handling
|
118
|
+
|
119
|
+
The gem provides comprehensive error handling:
|
120
|
+
|
121
|
+
```ruby
|
122
|
+
begin
|
123
|
+
cipher = FF1::Cipher.new(key, 10)
|
124
|
+
result = cipher.encrypt("123") # Too short
|
125
|
+
rescue FF1::Error => e
|
126
|
+
puts "Encryption error: #{e.message}"
|
127
|
+
end
|
128
|
+
```
|
129
|
+
|
130
|
+
Common errors:
|
131
|
+
* `FF1::Error`: Base error class for all FF1-related errors
|
132
|
+
* Invalid key length
|
133
|
+
* Invalid radix (must be 2-65536)
|
134
|
+
* Input too short or empty
|
135
|
+
* Invalid characters for the specified radix
|
136
|
+
* Domain size too small
|
137
|
+
|
138
|
+
## Security Considerations
|
139
|
+
|
140
|
+
1. **Key Management**: Use cryptographically secure random keys and protect them appropriately
|
141
|
+
2. **Domain Size**: Ensure your domain size meets the minimum requirements (preferably >= 1,000,000)
|
142
|
+
3. **Tweaks**: Use tweaks when possible for additional security
|
143
|
+
4. **Input Validation**: The gem validates inputs, but ensure your data meets the requirements
|
144
|
+
|
145
|
+
## Algorithm Details
|
146
|
+
|
147
|
+
This implementation follows NIST SP 800-38G specification for the FF1 algorithm:
|
148
|
+
* Uses 10 rounds of a Feistel network
|
149
|
+
* Supports any radix from 2 to 65, 536
|
150
|
+
* Uses AES as the underlying block cipher
|
151
|
+
* Implements proper padding and round function as specified
|
152
|
+
|
153
|
+
## Thread Safety
|
154
|
+
|
155
|
+
The FF1:: Cipher instances are thread-safe for encryption and decryption operations. However, you should not modify the cipher instance (key, radix) from multiple threads simultaneously.
|
156
|
+
|
157
|
+
## Contributing
|
158
|
+
|
159
|
+
Bug reports and pull requests are welcome on GitHub.
|
160
|
+
|
161
|
+
## License
|
162
|
+
|
163
|
+
The gem is available as open source under the [MIT License](https://opensource.org/licenses/MIT).
|
164
|
+
|
165
|
+
## References
|
166
|
+
|
167
|
+
* [NIST SP 800-38G: Recommendation for Block Cipher Modes of Operation: Methods for Format-Preserving Encryption](https://csrc.nist.gov/publications/detail/sp/800-38g/final)
|
168
|
+
* [Format-preserving encryption on Wikipedia](https://en.wikipedia.org/wiki/Format-preserving_encryption)
|
@@ -0,0 +1,90 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
require_relative '../lib/ff1'
|
4
|
+
require 'securerandom'
|
5
|
+
|
6
|
+
puts "FF1 Format Preserving Encryption - Basic Usage Examples"
|
7
|
+
puts "=" * 60
|
8
|
+
|
9
|
+
# Create a secure random key
|
10
|
+
key = SecureRandom.bytes(16) # 128-bit key
|
11
|
+
puts "Generated 128-bit key: #{key.unpack('H*').first}"
|
12
|
+
|
13
|
+
# Example 1: Credit Card Number Encryption
|
14
|
+
puts "\n1. Credit Card Number Encryption"
|
15
|
+
puts "-" * 40
|
16
|
+
cipher = FF1::Cipher.new(key, 10)
|
17
|
+
cc_number = "4111111111111111"
|
18
|
+
encrypted_cc = cipher.encrypt(cc_number)
|
19
|
+
decrypted_cc = cipher.decrypt(encrypted_cc)
|
20
|
+
|
21
|
+
puts "Original: #{cc_number}"
|
22
|
+
puts "Encrypted: #{encrypted_cc}"
|
23
|
+
puts "Decrypted: #{decrypted_cc}"
|
24
|
+
puts "Match: #{cc_number == decrypted_cc}"
|
25
|
+
|
26
|
+
# Example 2: Social Security Number with Tweak
|
27
|
+
puts "\n2. Social Security Number with Tweak"
|
28
|
+
puts "-" * 40
|
29
|
+
ssn = "123456789"
|
30
|
+
tweak = "user_id_12345"
|
31
|
+
encrypted_ssn = cipher.encrypt(ssn, tweak)
|
32
|
+
decrypted_ssn = cipher.decrypt(encrypted_ssn, tweak)
|
33
|
+
|
34
|
+
puts "Original: #{ssn}"
|
35
|
+
puts "Tweak: #{tweak}"
|
36
|
+
puts "Encrypted: #{encrypted_ssn}"
|
37
|
+
puts "Decrypted: #{decrypted_ssn}"
|
38
|
+
puts "Match: #{ssn == decrypted_ssn}"
|
39
|
+
|
40
|
+
# Example 3: Hexadecimal Data
|
41
|
+
puts "\n3. Hexadecimal Data Encryption"
|
42
|
+
puts "-" * 40
|
43
|
+
hex_cipher = FF1::Cipher.new(key, 16)
|
44
|
+
hex_data = "ABCDEF123456"
|
45
|
+
encrypted_hex = hex_cipher.encrypt(hex_data)
|
46
|
+
decrypted_hex = hex_cipher.decrypt(encrypted_hex)
|
47
|
+
|
48
|
+
puts "Original: #{hex_data}"
|
49
|
+
puts "Encrypted: #{encrypted_hex}"
|
50
|
+
puts "Decrypted: #{decrypted_hex}"
|
51
|
+
puts "Match: #{hex_data == decrypted_hex}"
|
52
|
+
|
53
|
+
# Example 4: Different Input Lengths
|
54
|
+
puts "\n4. Various Input Lengths"
|
55
|
+
puts "-" * 40
|
56
|
+
test_inputs = ["12345", "1234567890", "123456789012345"]
|
57
|
+
|
58
|
+
test_inputs.each do |input|
|
59
|
+
encrypted = cipher.encrypt(input)
|
60
|
+
decrypted = cipher.decrypt(encrypted)
|
61
|
+
puts "#{input} -> #{encrypted} -> #{decrypted} (#{input == decrypted ? 'OK' : 'FAIL'})"
|
62
|
+
end
|
63
|
+
|
64
|
+
# Example 5: Error Handling
|
65
|
+
puts "\n5. Error Handling Examples"
|
66
|
+
puts "-" * 40
|
67
|
+
|
68
|
+
# Too short input
|
69
|
+
begin
|
70
|
+
cipher.encrypt("1")
|
71
|
+
rescue FF1::Error => e
|
72
|
+
puts "Short input error: #{e.message}"
|
73
|
+
end
|
74
|
+
|
75
|
+
# Invalid character
|
76
|
+
begin
|
77
|
+
cipher.encrypt("123A") # 'A' is invalid for radix 10
|
78
|
+
rescue FF1::Error => e
|
79
|
+
puts "Invalid character error: #{e.message}"
|
80
|
+
end
|
81
|
+
|
82
|
+
# Invalid key length
|
83
|
+
begin
|
84
|
+
bad_key = "short"
|
85
|
+
FF1::Cipher.new(bad_key, 10)
|
86
|
+
rescue FF1::Error => e
|
87
|
+
puts "Invalid key error: #{e.message}"
|
88
|
+
end
|
89
|
+
|
90
|
+
puts "\nAll examples completed successfully!"
|
data/lib/ff1/cipher.rb
ADDED
@@ -0,0 +1,353 @@
|
|
1
|
+
require 'openssl'
|
2
|
+
require 'digest'
|
3
|
+
|
4
|
+
module FF1
|
5
|
+
class Cipher
|
6
|
+
ROUNDS = 10
|
7
|
+
|
8
|
+
def initialize(key, radix = 10, mode = Modes::REVERSIBLE)
|
9
|
+
@key = key
|
10
|
+
@radix = radix
|
11
|
+
@mode = mode
|
12
|
+
@irreversible_salt = generate_irreversible_salt if @mode == Modes::IRREVERSIBLE
|
13
|
+
validate_parameters
|
14
|
+
end
|
15
|
+
|
16
|
+
def encrypt(plaintext, tweak = '')
|
17
|
+
validate_input(plaintext)
|
18
|
+
|
19
|
+
if @mode == Modes::IRREVERSIBLE
|
20
|
+
irreversible_encrypt(plaintext, tweak)
|
21
|
+
else
|
22
|
+
reversible_encrypt(plaintext, tweak)
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
def decrypt(ciphertext, tweak = '')
|
27
|
+
if @mode == Modes::IRREVERSIBLE
|
28
|
+
raise FF1::Error, 'Cannot decrypt in irreversible mode - data is permanently transformed'
|
29
|
+
end
|
30
|
+
|
31
|
+
reversible_decrypt(ciphertext, tweak)
|
32
|
+
end
|
33
|
+
|
34
|
+
# Check if the cipher is in reversible mode
|
35
|
+
def reversible?
|
36
|
+
@mode == Modes::REVERSIBLE
|
37
|
+
end
|
38
|
+
|
39
|
+
# Check if the cipher is in irreversible mode
|
40
|
+
def irreversible?
|
41
|
+
@mode == Modes::IRREVERSIBLE
|
42
|
+
end
|
43
|
+
|
44
|
+
private
|
45
|
+
|
46
|
+
def reversible_encrypt(plaintext, tweak)
|
47
|
+
# Convert string to integer array
|
48
|
+
x = string_to_numeral_array(plaintext)
|
49
|
+
n = x.length
|
50
|
+
|
51
|
+
# Validate minimum domain size
|
52
|
+
domain_size = @radix**n
|
53
|
+
raise FF1::Error, "Domain size too small (#{domain_size} < 100)" if domain_size < 100
|
54
|
+
|
55
|
+
# Split into left and right
|
56
|
+
u = n / 2
|
57
|
+
v = n - u
|
58
|
+
a = x[0...u]
|
59
|
+
b = x[u..-1]
|
60
|
+
|
61
|
+
# FF1 encryption rounds
|
62
|
+
(0...ROUNDS).each do |i|
|
63
|
+
# Calculate round function on B
|
64
|
+
y = round_function(i, b, tweak)
|
65
|
+
|
66
|
+
# Convert y to integer modulo radix^u
|
67
|
+
y_int = byte_string_to_integer(y) % (@radix**u)
|
68
|
+
|
69
|
+
# Add A + y (mod radix^u)
|
70
|
+
a_int = numeral_array_to_integer(a, @radix)
|
71
|
+
result_int = (a_int + y_int) % (@radix**u)
|
72
|
+
|
73
|
+
# Convert back to numeral array with proper length
|
74
|
+
c = integer_to_numeral_array(result_int, @radix, u)
|
75
|
+
|
76
|
+
# Swap A and B for next round
|
77
|
+
a = b
|
78
|
+
b = c
|
79
|
+
u, v = v, u
|
80
|
+
end
|
81
|
+
|
82
|
+
# Convert back to string
|
83
|
+
numeral_array_to_string(a + b)
|
84
|
+
end
|
85
|
+
|
86
|
+
def irreversible_encrypt(plaintext, tweak)
|
87
|
+
# Step 1: Create a deterministic but irreversible transformation
|
88
|
+
# This maintains format and relationships but prevents decryption
|
89
|
+
|
90
|
+
# Convert string to integer array
|
91
|
+
x = string_to_numeral_array(plaintext)
|
92
|
+
n = x.length
|
93
|
+
|
94
|
+
# Validate minimum domain size
|
95
|
+
domain_size = @radix**n
|
96
|
+
raise FF1::Error, "Domain size too small (#{domain_size} < 100)" if domain_size < 100
|
97
|
+
|
98
|
+
# 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
|
101
|
+
irreversible_key = create_irreversible_key(plaintext, tweak)
|
102
|
+
|
103
|
+
# Use modified FF1 with irreversible key and additional entropy destruction
|
104
|
+
result = perform_irreversible_ff1(x, irreversible_key, tweak)
|
105
|
+
|
106
|
+
numeral_array_to_string(result)
|
107
|
+
end
|
108
|
+
|
109
|
+
def create_irreversible_key(plaintext, tweak)
|
110
|
+
# Create a one-way hash that includes:
|
111
|
+
# - Original key (for uniqueness per cipher instance)
|
112
|
+
# - Global irreversible salt (prevents rainbow table attacks)
|
113
|
+
# - Plaintext (makes each plaintext map consistently)
|
114
|
+
# - Tweak (maintains tweak-based differentiation)
|
115
|
+
|
116
|
+
hash_input = @key + @irreversible_salt + plaintext + tweak
|
117
|
+
hashed = Digest::SHA256.digest(hash_input)
|
118
|
+
|
119
|
+
# Return same length as original key for AES compatibility
|
120
|
+
hashed[0...@key.bytesize]
|
121
|
+
end
|
122
|
+
|
123
|
+
def perform_irreversible_ff1(x, irreversible_key, tweak)
|
124
|
+
n = x.length
|
125
|
+
u = n / 2
|
126
|
+
v = n - u
|
127
|
+
a = x[0...u]
|
128
|
+
b = x[u..-1]
|
129
|
+
|
130
|
+
# Use same FF1 structure but with irreversible key
|
131
|
+
# and entropy-destroying modifications
|
132
|
+
(0...ROUNDS).each do |i|
|
133
|
+
# Use irreversible key instead of original key
|
134
|
+
old_key = @key
|
135
|
+
@key = irreversible_key
|
136
|
+
|
137
|
+
y = round_function(i, b, tweak)
|
138
|
+
|
139
|
+
# Add extra entropy destruction by mixing in round number and salt
|
140
|
+
entropy_destroyer = Digest::SHA256.digest(@irreversible_salt + [i].pack('C'))[0...8]
|
141
|
+
y = xor_bytes(y[0...8], entropy_destroyer) + y[8..-1]
|
142
|
+
|
143
|
+
@key = old_key # Restore for next operations
|
144
|
+
|
145
|
+
y_int = byte_string_to_integer(y) % (@radix**u)
|
146
|
+
a_int = numeral_array_to_integer(a, @radix)
|
147
|
+
result_int = (a_int + y_int) % (@radix**u)
|
148
|
+
c = integer_to_numeral_array(result_int, @radix, u)
|
149
|
+
|
150
|
+
a = b
|
151
|
+
b = c
|
152
|
+
u, v = v, u
|
153
|
+
end
|
154
|
+
|
155
|
+
a + b
|
156
|
+
end
|
157
|
+
|
158
|
+
def generate_irreversible_salt
|
159
|
+
# Generate a unique salt for this cipher instance
|
160
|
+
# This prevents rainbow table attacks on irreversible mode
|
161
|
+
Digest::SHA256.digest(@key + 'irreversible_mode')[0...16]
|
162
|
+
end
|
163
|
+
|
164
|
+
def xor_bytes(bytes1, bytes2)
|
165
|
+
result = ''
|
166
|
+
[bytes1.bytesize, bytes2.bytesize].min.times do |i|
|
167
|
+
result += [(bytes1.getbyte(i) ^ bytes2.getbyte(i))].pack('C')
|
168
|
+
end
|
169
|
+
result
|
170
|
+
end
|
171
|
+
|
172
|
+
def reversible_decrypt(ciphertext, tweak)
|
173
|
+
validate_input(ciphertext)
|
174
|
+
|
175
|
+
# Convert string to integer array
|
176
|
+
x = string_to_numeral_array(ciphertext)
|
177
|
+
n = x.length
|
178
|
+
|
179
|
+
# Validate minimum domain size
|
180
|
+
domain_size = @radix**n
|
181
|
+
raise FF1::Error, "Domain size too small (#{domain_size} < 100)" if domain_size < 100
|
182
|
+
|
183
|
+
# Split into left and right
|
184
|
+
u = n / 2
|
185
|
+
v = n - u
|
186
|
+
a = x[0...u]
|
187
|
+
b = x[u..-1]
|
188
|
+
|
189
|
+
# FF1 decryption rounds (reverse order)
|
190
|
+
(ROUNDS - 1).downto(0) do |i|
|
191
|
+
# Swap A and B first (reverse of encryption)
|
192
|
+
u, v = v, u
|
193
|
+
a, b = b, a
|
194
|
+
|
195
|
+
# Calculate round function on B
|
196
|
+
y = round_function(i, b, tweak)
|
197
|
+
|
198
|
+
# Convert y to integer modulo radix^u
|
199
|
+
y_int = byte_string_to_integer(y) % (@radix**u)
|
200
|
+
|
201
|
+
# Subtract A - y (mod radix^u)
|
202
|
+
a_int = numeral_array_to_integer(a, @radix)
|
203
|
+
result_int = (a_int - y_int) % (@radix**u)
|
204
|
+
|
205
|
+
# Convert back to numeral array with proper length
|
206
|
+
a = integer_to_numeral_array(result_int, @radix, u)
|
207
|
+
end
|
208
|
+
|
209
|
+
# Convert back to string
|
210
|
+
numeral_array_to_string(a + b)
|
211
|
+
end
|
212
|
+
|
213
|
+
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
|
216
|
+
end
|
217
|
+
|
218
|
+
def validate_input(input)
|
219
|
+
raise FF1::Error, 'Input cannot be nil' if input.nil?
|
220
|
+
raise FF1::Error, 'Input cannot be empty' if input.empty?
|
221
|
+
raise FF1::Error, 'Input length must be at least 2' if input.length < 2
|
222
|
+
end
|
223
|
+
|
224
|
+
def round_function(round, b, tweak)
|
225
|
+
# 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
|
228
|
+
p += [0x0a].pack('C') # 10 rounds
|
229
|
+
p += [b.length].pack('C') # length of right side
|
230
|
+
p += [tweak.bytesize].pack('N') # tweak length in 4 bytes
|
231
|
+
|
232
|
+
# Pad P to 16 bytes
|
233
|
+
p = p.ljust(16, "\x00")
|
234
|
+
|
235
|
+
# Construct Q
|
236
|
+
q = tweak.dup
|
237
|
+
b.each { |digit| q += [digit].pack('C') }
|
238
|
+
q += [round].pack('C')
|
239
|
+
|
240
|
+
# Pad Q to multiple of 16 bytes
|
241
|
+
q += "\x00" while q.bytesize % 16 != 0
|
242
|
+
|
243
|
+
# Apply PRF using AES-CBC-MAC
|
244
|
+
aes_cbc_mac(p + q)
|
245
|
+
end
|
246
|
+
|
247
|
+
def aes_cbc_mac(data)
|
248
|
+
# Ensure data is multiple of 16 bytes
|
249
|
+
data += "\x00" while data.bytesize % 16 != 0
|
250
|
+
|
251
|
+
# AES-CBC-MAC with zero IV
|
252
|
+
cipher = OpenSSL::Cipher.new("AES-#{@key.bytesize * 8}-CBC")
|
253
|
+
cipher.encrypt
|
254
|
+
cipher.key = @key
|
255
|
+
cipher.iv = "\x00" * 16
|
256
|
+
cipher.padding = 0
|
257
|
+
|
258
|
+
result = "\x00" * 16
|
259
|
+
(data.bytesize / 16).times do |i|
|
260
|
+
block = data[i * 16, 16]
|
261
|
+
|
262
|
+
# XOR with previous result
|
263
|
+
xor_block = ''
|
264
|
+
16.times do |j|
|
265
|
+
xor_block += [(block.getbyte(j) ^ result.getbyte(j))].pack('C')
|
266
|
+
end
|
267
|
+
|
268
|
+
# Encrypt the XOR result
|
269
|
+
temp_cipher = OpenSSL::Cipher.new("AES-#{@key.bytesize * 8}-ECB")
|
270
|
+
temp_cipher.encrypt
|
271
|
+
temp_cipher.key = @key
|
272
|
+
temp_cipher.padding = 0
|
273
|
+
result = temp_cipher.update(xor_block) + temp_cipher.final
|
274
|
+
end
|
275
|
+
|
276
|
+
result
|
277
|
+
end
|
278
|
+
|
279
|
+
def string_to_numeral_array(str)
|
280
|
+
str.chars.map { |char| char_to_digit(char) }
|
281
|
+
end
|
282
|
+
|
283
|
+
def numeral_array_to_string(array)
|
284
|
+
array.map { |digit| digit_to_char(digit) }.join
|
285
|
+
end
|
286
|
+
|
287
|
+
def char_to_digit(char)
|
288
|
+
case @radix
|
289
|
+
when 2..10
|
290
|
+
digit = char.to_i
|
291
|
+
return digit if digit < @radix && char.match?(/\d/)
|
292
|
+
|
293
|
+
raise FF1::Error, "Invalid character '#{char}' for radix #{@radix}"
|
294
|
+
when 11..36
|
295
|
+
if char.match?(/\d/)
|
296
|
+
char.to_i
|
297
|
+
elsif char.match?(/[A-Z]/)
|
298
|
+
char.ord - 'A'.ord + 10
|
299
|
+
elsif char.match?(/[a-z]/)
|
300
|
+
char.ord - 'a'.ord + 10
|
301
|
+
else
|
302
|
+
raise FF1::Error, "Invalid character '#{char}' for radix #{@radix}"
|
303
|
+
end
|
304
|
+
else
|
305
|
+
# For higher radix values, use ASCII values
|
306
|
+
digit = char.ord
|
307
|
+
raise FF1::Error, "Invalid character '#{char}' for radix #{@radix}" if digit >= @radix
|
308
|
+
|
309
|
+
digit
|
310
|
+
end
|
311
|
+
end
|
312
|
+
|
313
|
+
def digit_to_char(digit)
|
314
|
+
case @radix
|
315
|
+
when 2..10
|
316
|
+
digit.to_s
|
317
|
+
when 11..36
|
318
|
+
if digit < 10
|
319
|
+
digit.to_s
|
320
|
+
else
|
321
|
+
(digit - 10 + 'A'.ord).chr
|
322
|
+
end
|
323
|
+
else
|
324
|
+
digit.chr
|
325
|
+
end
|
326
|
+
end
|
327
|
+
|
328
|
+
def numeral_array_to_integer(array, radix)
|
329
|
+
result = 0
|
330
|
+
array.each do |digit|
|
331
|
+
result = result * radix + digit
|
332
|
+
end
|
333
|
+
result
|
334
|
+
end
|
335
|
+
|
336
|
+
def integer_to_numeral_array(int, radix, length)
|
337
|
+
array = []
|
338
|
+
length.times do
|
339
|
+
array.unshift(int % radix)
|
340
|
+
int /= radix
|
341
|
+
end
|
342
|
+
array
|
343
|
+
end
|
344
|
+
|
345
|
+
def byte_string_to_integer(bytes)
|
346
|
+
result = 0
|
347
|
+
bytes.each_byte do |byte|
|
348
|
+
result = (result << 8) + byte
|
349
|
+
end
|
350
|
+
result
|
351
|
+
end
|
352
|
+
end
|
353
|
+
end
|
data/lib/ff1/modes.rb
ADDED
@@ -0,0 +1,11 @@
|
|
1
|
+
module FF1
|
2
|
+
# Encryption modes for different use cases
|
3
|
+
module Modes
|
4
|
+
# Normal reversible encryption - can decrypt back to original
|
5
|
+
REVERSIBLE = :reversible
|
6
|
+
|
7
|
+
# Irreversible encryption - maintains format and relationships but cannot decrypt
|
8
|
+
# Useful for GDPR "right to be forgotten" compliance
|
9
|
+
IRREVERSIBLE = :irreversible
|
10
|
+
end
|
11
|
+
end
|
data/lib/ff1/version.rb
ADDED