ipcrypt2 1.0.0 → 1.0.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 417ba3249b6a04ea6f104538af68b7d393f73c3b4b12ca1ca88082b7788b7617
4
- data.tar.gz: e03947c5f01df8906342b8730a96f9f3280210951a6829206e9a37c28b5c6e70
3
+ metadata.gz: b076ab85b1294156c609b76570e2995b9c5f837659f7ad601184871545ccc343
4
+ data.tar.gz: 23016972e27094071e8e34fd8a26e28874dc83ff66ff174ef243f9191dc19d4a
5
5
  SHA512:
6
- metadata.gz: 258447b2adc9eb4df871cbbf69f30da0e327ecdf4e01be1ccd82150f7da58f4af816509109c3c3d054f7c67364f27829c4bee888d86999c0dec8cbac690751ab
7
- data.tar.gz: fca823b4fd42a7c09c5833d524bc38df494e30f0a623bb5b74e9f748bc9633612d1f3089a0bb42d88190991c1bb0e940219cf2fc0d2ede7391ee589164876245
6
+ metadata.gz: 7a04c6d8b2a2570a3dc85a7d3e8c2fbf0a81277b9b3ad4670ef56a9baacaa851f28c5eec8341cf3c704274df59a853b5a8880042414bb3c4dd17e4faef53fa34
7
+ data.tar.gz: d543f0d759ff31482d5d92fbd7a23b1514485f6fa7cc93232056c2bdfa6e95cc357d6f3db7e38447c3e1a4cb9cf69770a257d85672a02bb9047439dfe6e797b8
data/CHANGELOG.md CHANGED
@@ -5,6 +5,11 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.0.1] - 2025-09-10
9
+
10
+ ### Added
11
+ - Support for the PFX mode.
12
+
8
13
  ## [1.0.0] - 2025-08-30
9
14
 
10
15
  Initial public release.
data/README.md CHANGED
@@ -6,11 +6,13 @@ This gem provides privacy-preserving methods for storing, logging, and analyzing
6
6
 
7
7
  ## Features
8
8
 
9
- - **Three encryption modes:**
9
+ - **Four encryption modes:**
10
10
  - `ipcrypt-deterministic`: Deterministic encryption using AES-128 (same input always produces same output)
11
+ - `ipcrypt-pfx`: Prefix-preserving encryption that maintains network relationships while encrypting addresses
11
12
  - `ipcrypt-nd`: Non-deterministic encryption using KIASU-BC with 8-byte tweak
12
13
  - `ipcrypt-ndx`: Non-deterministic encryption using AES-XTS with 16-byte tweak
13
14
  - **Full IPv4 and IPv6 support** with automatic conversion to unified 16-byte format
15
+ - **Prefix preservation** with ipcrypt-pfx for network-level analytics while protecting individual addresses
14
16
  - **Secure implementations** using OpenSSL for cryptographic operations
15
17
  - **Comprehensive test suite** with official test vectors from the specification
16
18
  - **Ruby 2.6+ compatibility**
@@ -70,6 +72,24 @@ encrypted_data = IPCrypt::ND.encrypt(ip, key, tweak) # Specific tweak
70
72
  decrypted_ip = IPCrypt::ND.decrypt(encrypted_data, key)
71
73
  ```
72
74
 
75
+ ### Prefix-Preserving Encryption (ipcrypt-pfx)
76
+
77
+ ```ruby
78
+ require 'ipcrypt/pfx'
79
+
80
+ # 32-byte key (split into two AES-128 keys internally)
81
+ key = "0123456789abcdeffedcba98765432101032547698badcfeefcdab8967452301".scan(/../).map { |x| x.hex }.pack("C*")
82
+
83
+ # Encrypt IP addresses - addresses from the same network share encrypted prefix
84
+ ip1 = "10.0.0.1"
85
+ ip2 = "10.0.0.2"
86
+ encrypted_ip1 = IPCrypt::Pfx.encrypt(ip1, key) # "154.135.56.208"
87
+ encrypted_ip2 = IPCrypt::Pfx.encrypt(ip2, key) # "154.135.56.211" (same /24 prefix)
88
+
89
+ # Decrypt
90
+ decrypted_ip1 = IPCrypt::Pfx.decrypt(encrypted_ip1, key) # Returns "10.0.0.1"
91
+ ```
92
+
73
93
  ### Non-Deterministic Encryption with AES-XTS (ipcrypt-ndx)
74
94
 
75
95
  ```ruby
@@ -300,6 +320,7 @@ end
300
320
 
301
321
  2. **Choose the Right Mode**:
302
322
  - Use `deterministic` for logs and analytics where you need to correlate multiple requests from the same IP
323
+ - Use `pfx` for network-level analytics where you need to preserve subnet relationships while encrypting individual addresses
303
324
  - Use `nd` or `ndx` for storage where each encryption should be unique
304
325
 
305
326
  3. **Performance**: For high-traffic applications, consider caching the key parsing:
@@ -0,0 +1,252 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'ipaddr'
4
+ require 'openssl'
5
+
6
+ module IPCrypt
7
+ # Implementation of ipcrypt-pfx using AES-128 for prefix-preserving encryption
8
+ class Pfx
9
+ # Convert an IP address to its 16-byte representation
10
+ def self.ip_to_bytes(ip)
11
+ ip_addr = ip.is_a?(String) ? IPAddr.new(ip) : ip
12
+ if ip_addr.ipv4?
13
+ # Convert IPv4 to IPv4-mapped IPv6 format (::ffff:0:0/96)
14
+ bytes = [0] * 10 + [0xff, 0xff] + ip_addr.hton.bytes
15
+ bytes.pack('C*').force_encoding('BINARY')
16
+ else
17
+ ip_addr.hton.force_encoding('BINARY')
18
+ end
19
+ end
20
+
21
+ # Convert a 16-byte representation back to an IP address
22
+ def self.bytes_to_ip(bytes16)
23
+ raise InvalidDataError, 'Input must be 16 bytes' unless bytes16.length == 16
24
+
25
+ # Check for IPv4-mapped IPv6 format
26
+ zero_bytes = [0] * 10
27
+ ff_bytes = [255, 255]
28
+
29
+ if bytes16[0, 10].bytes == zero_bytes && bytes16[10, 2].bytes == ff_bytes
30
+ IPAddr.new_ntoh(bytes16[12, 4])
31
+ else
32
+ IPAddr.new_ntoh(bytes16)
33
+ end
34
+ end
35
+
36
+ # Check if a 16-byte array has the IPv4-mapped IPv6 prefix (::ffff:0:0/96)
37
+ def self.ipv4_mapped?(bytes16)
38
+ return false unless bytes16.length == 16
39
+
40
+ # Check for IPv4-mapped prefix: first 10 bytes are 0x00, bytes 10-11 are 0xFF
41
+ bytes16[0, 10].bytes == [0] * 10 && bytes16[10, 2].bytes == [255, 255]
42
+ end
43
+
44
+ # Extract bit at position from 16-byte array
45
+ # position: 0 = LSB of byte 15, 127 = MSB of byte 0
46
+ def self.get_bit(data, position)
47
+ byte_index = 15 - (position / 8)
48
+ bit_index = position % 8
49
+ (data.bytes[byte_index] >> bit_index) & 1
50
+ end
51
+
52
+ # Set bit at position in 16-byte array
53
+ # position: 0 = LSB of byte 15, 127 = MSB of byte 0
54
+ def self.set_bit(data, position, value)
55
+ byte_index = 15 - (position / 8)
56
+ bit_index = position % 8
57
+ bytes = data.bytes
58
+ bytes[byte_index] |= value << bit_index
59
+ bytes.pack('C*')
60
+ end
61
+
62
+ # Shift a 16-byte array one bit to the left
63
+ # The most significant bit is lost, and a zero bit is shifted in from the right
64
+ def self.shift_left_one_bit(data)
65
+ raise InvalidDataError, 'Input must be 16 bytes' unless data.length == 16
66
+
67
+ bytes = data.bytes
68
+ result = Array.new(16, 0)
69
+ carry = 0
70
+
71
+ # Process from least significant byte (byte 15) to most significant (byte 0)
72
+ 15.downto(0) do |i|
73
+ # Current byte shifted left by 1, with carry from previous byte
74
+ result[i] = ((bytes[i] << 1) | carry) & 0xFF
75
+ # Extract the bit that will be carried to the next byte
76
+ carry = (bytes[i] >> 7) & 1
77
+ end
78
+
79
+ result.pack('C*')
80
+ end
81
+
82
+ # Pad prefix for prefix_len_bits=0 (IPv6)
83
+ # Sets separator bit at position 0 (LSB of byte 15)
84
+ def self.pad_prefix_zero
85
+ padded = Array.new(16, 0)
86
+ padded[15] = 0x01 # Set bit at position 0 (LSB of byte 15)
87
+ padded.pack('C*')
88
+ end
89
+
90
+ # Pad prefix for prefix_len_bits=96 (IPv4)
91
+ # For IPv4, the data always has format: 00...00 ffff xxxx (IPv4-mapped)
92
+ # Result: 00000001 00...00 0000ffff (separator at pos 96, then 96 bits)
93
+ def self.pad_prefix_ninetysix(_data)
94
+ # The result is always the same for IPv4 addresses since they all have
95
+ # the same IPv4-mapped prefix (00...00 ffff)
96
+ padded = Array.new(16, 0)
97
+ padded[3] = 0x01 # Set bit at position 96 (bit 0 of byte 3)
98
+ padded[14] = 0xFF
99
+ padded[15] = 0xFF
100
+ padded.pack('C*')
101
+ end
102
+
103
+ # Encrypt an IP address using ipcrypt-pfx
104
+ def self.encrypt(ip, key)
105
+ raise InvalidKeyError, 'Key must be 32 bytes' unless key.length == 32
106
+
107
+ # Split the key into two AES-128 keys
108
+ k1 = key[0, 16]
109
+ k2 = key[16, 16]
110
+
111
+ # Check that K1 and K2 are different
112
+ raise InvalidKeyError, 'The two halves of the key must be different' if k1 == k2
113
+
114
+ # Convert IP to 16-byte representation
115
+ bytes16 = ip_to_bytes(ip)
116
+
117
+ # Initialize encrypted result with zeros
118
+ encrypted = Array.new(16, 0)
119
+
120
+ # Determine starting point
121
+ if ipv4_mapped?(bytes16)
122
+ prefix_start = 96
123
+ # If IPv4-mapped, copy the IPv4-mapped prefix
124
+ encrypted[0, 12] = bytes16[0, 12].bytes
125
+ else
126
+ prefix_start = 0
127
+ end
128
+
129
+ # Create AES cipher objects
130
+ cipher1 = OpenSSL::Cipher.new('AES-128-ECB')
131
+ cipher1.encrypt
132
+ cipher1.padding = 0
133
+ cipher1.key = k1
134
+
135
+ cipher2 = OpenSSL::Cipher.new('AES-128-ECB')
136
+ cipher2.encrypt
137
+ cipher2.padding = 0
138
+ cipher2.key = k2
139
+
140
+ # Initialize padded_prefix for the starting prefix length
141
+ padded_prefix = if ipv4_mapped?(bytes16)
142
+ pad_prefix_ninetysix(bytes16)
143
+ else # prefix_start == 0
144
+ pad_prefix_zero
145
+ end
146
+
147
+ # Process each bit position
148
+ (prefix_start...128).each do |prefix_len_bits|
149
+ # Compute pseudorandom function with dual AES encryption
150
+ e1 = cipher1.update(padded_prefix) + cipher1.final
151
+ e2 = cipher2.update(padded_prefix) + cipher2.final
152
+
153
+ # XOR the two encryptions
154
+ e = e1.bytes.zip(e2.bytes).map { |a, b| a ^ b }.pack('C*')
155
+ # We only need the least significant bit of byte 15
156
+ cipher_bit = e.bytes[15] & 1
157
+
158
+ # Extract the current bit from the original IP
159
+ current_bit_pos = 127 - prefix_len_bits
160
+
161
+ # Set the bit in the encrypted result
162
+ original_bit = get_bit(bytes16, current_bit_pos)
163
+ encrypted_bytes = encrypted.pack('C*')
164
+ encrypted_bytes = set_bit(encrypted_bytes, current_bit_pos, cipher_bit ^ original_bit)
165
+ encrypted = encrypted_bytes.bytes
166
+
167
+ # Prepare padded_prefix for next iteration
168
+ # Shift left by 1 bit and insert the next bit from bytes16
169
+ padded_prefix = shift_left_one_bit(padded_prefix)
170
+ padded_prefix = set_bit(padded_prefix, 0, original_bit)
171
+ end
172
+
173
+ bytes_to_ip(encrypted.pack('C*'))
174
+ end
175
+
176
+ # Decrypt an IP address using ipcrypt-pfx
177
+ def self.decrypt(encrypted_ip, key)
178
+ raise InvalidKeyError, 'Key must be 32 bytes' unless key.length == 32
179
+
180
+ # Split the key into two AES-128 keys
181
+ k1 = key[0, 16]
182
+ k2 = key[16, 16]
183
+
184
+ # Check that K1 and K2 are different
185
+ raise InvalidKeyError, 'The two halves of the key must be different' if k1 == k2
186
+
187
+ # Convert encrypted IP to 16-byte representation
188
+ encrypted_bytes = ip_to_bytes(encrypted_ip)
189
+
190
+ # Initialize decrypted result
191
+ decrypted = Array.new(16, 0)
192
+
193
+ # For decryption, we need to determine if this was originally IPv4-mapped
194
+ # IPv4-mapped addresses are encrypted with prefix_start=96
195
+ if ipv4_mapped?(encrypted_bytes)
196
+ prefix_start = 96
197
+ # If this was originally IPv4, set up the IPv4-mapped IPv6 prefix
198
+ decrypted[10] = 0xff
199
+ decrypted[11] = 0xff
200
+ else
201
+ prefix_start = 0
202
+ end
203
+
204
+ # Create AES cipher objects
205
+ cipher1 = OpenSSL::Cipher.new('AES-128-ECB')
206
+ cipher1.encrypt
207
+ cipher1.padding = 0
208
+ cipher1.key = k1
209
+
210
+ cipher2 = OpenSSL::Cipher.new('AES-128-ECB')
211
+ cipher2.encrypt
212
+ cipher2.padding = 0
213
+ cipher2.key = k2
214
+
215
+ # Initialize padded_prefix for the starting prefix length
216
+ padded_prefix = if prefix_start.zero?
217
+ pad_prefix_zero
218
+ else # prefix_start == 96
219
+ pad_prefix_ninetysix(decrypted.pack('C*'))
220
+ end
221
+
222
+ # Process each bit position
223
+ (prefix_start...128).each do |prefix_len_bits|
224
+ # Compute pseudorandom function with dual AES encryption
225
+ e1 = cipher1.update(padded_prefix) + cipher1.final
226
+ e2 = cipher2.update(padded_prefix) + cipher2.final
227
+
228
+ # XOR the two encryptions
229
+ e = e1.bytes.zip(e2.bytes).map { |a, b| a ^ b }.pack('C*')
230
+ # We only need the least significant bit of byte 15
231
+ cipher_bit = e.bytes[15] & 1
232
+
233
+ # Extract the current bit from the encrypted IP
234
+ current_bit_pos = 127 - prefix_len_bits
235
+
236
+ # Set the bit in the decrypted result
237
+ encrypted_bit = get_bit(encrypted_bytes, current_bit_pos)
238
+ original_bit = cipher_bit ^ encrypted_bit
239
+ decrypted_bytes = decrypted.pack('C*')
240
+ decrypted_bytes = set_bit(decrypted_bytes, current_bit_pos, original_bit)
241
+ decrypted = decrypted_bytes.bytes
242
+
243
+ # Prepare padded_prefix for next iteration
244
+ # Shift left by 1 bit and insert the next bit from decrypted
245
+ padded_prefix = shift_left_one_bit(padded_prefix)
246
+ padded_prefix = set_bit(padded_prefix, 0, original_bit)
247
+ end
248
+
249
+ bytes_to_ip(decrypted.pack('C*'))
250
+ end
251
+ end
252
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module IPCrypt
4
- VERSION = '1.0.0'
4
+ VERSION = '1.0.1'
5
5
  end
data/lib/ipcrypt.rb CHANGED
@@ -7,3 +7,9 @@ module IPCrypt
7
7
  class InvalidTweakError < Error; end
8
8
  class InvalidDataError < Error; end
9
9
  end
10
+
11
+ require_relative 'ipcrypt/deterministic'
12
+ require_relative 'ipcrypt/nd'
13
+ require_relative 'ipcrypt/ndx'
14
+ require_relative 'ipcrypt/pfx'
15
+ require_relative 'ipcrypt/version'
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ipcrypt2
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0
4
+ version: 1.0.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Frank Denis
@@ -67,12 +67,12 @@ files:
67
67
  - lib/ipcrypt/deterministic.rb
68
68
  - lib/ipcrypt/nd.rb
69
69
  - lib/ipcrypt/ndx.rb
70
+ - lib/ipcrypt/pfx.rb
70
71
  - lib/ipcrypt/version.rb
71
72
  homepage: https://github.com/jedisct1/ipcrypt-ruby
72
73
  licenses:
73
74
  - MIT
74
75
  metadata:
75
- homepage_uri: https://github.com/jedisct1/ipcrypt-ruby
76
76
  source_code_uri: https://github.com/jedisct1/ipcrypt-ruby
77
77
  changelog_uri: https://github.com/jedisct1/ipcrypt-ruby/blob/master/CHANGELOG.md
78
78
  bug_tracker_uri: https://github.com/jedisct1/ipcrypt-ruby/issues