ipcrypt2 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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 417ba3249b6a04ea6f104538af68b7d393f73c3b4b12ca1ca88082b7788b7617
4
+ data.tar.gz: e03947c5f01df8906342b8730a96f9f3280210951a6829206e9a37c28b5c6e70
5
+ SHA512:
6
+ metadata.gz: 258447b2adc9eb4df871cbbf69f30da0e327ecdf4e01be1ccd82150f7da58f4af816509109c3c3d054f7c67364f27829c4bee888d86999c0dec8cbac690751ab
7
+ data.tar.gz: fca823b4fd42a7c09c5833d524bc38df494e30f0a623bb5b74e9f748bc9633612d1f3089a0bb42d88190991c1bb0e940219cf2fc0d2ede7391ee589164876245
data/CHANGELOG.md ADDED
@@ -0,0 +1,10 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [1.0.0] - 2025-08-30
9
+
10
+ Initial public release.
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Frank Denis
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,353 @@
1
+ # IPCrypt2 Ruby Implementation
2
+
3
+ A Ruby implementation of the IPCrypt specification for encrypting and obfuscating IP addresses, as defined in the [IPCrypt IETF draft](https://datatracker.ietf.org/doc/draft-denis-ipcrypt/).
4
+
5
+ This gem provides privacy-preserving methods for storing, logging, and analyzing IP addresses while maintaining the ability to decrypt them when necessary.
6
+
7
+ ## Features
8
+
9
+ - **Three encryption modes:**
10
+ - `ipcrypt-deterministic`: Deterministic encryption using AES-128 (same input always produces same output)
11
+ - `ipcrypt-nd`: Non-deterministic encryption using KIASU-BC with 8-byte tweak
12
+ - `ipcrypt-ndx`: Non-deterministic encryption using AES-XTS with 16-byte tweak
13
+ - **Full IPv4 and IPv6 support** with automatic conversion to unified 16-byte format
14
+ - **Secure implementations** using OpenSSL for cryptographic operations
15
+ - **Comprehensive test suite** with official test vectors from the specification
16
+ - **Ruby 2.6+ compatibility**
17
+
18
+ ## Installation
19
+
20
+ Add this line to your application's Gemfile:
21
+
22
+ ```ruby
23
+ gem 'ipcrypt2'
24
+ ```
25
+
26
+ And then execute:
27
+
28
+ ```bash
29
+ $ bundle install
30
+ ```
31
+
32
+ Or install it yourself as:
33
+
34
+ ```bash
35
+ $ gem install ipcrypt2
36
+ ```
37
+
38
+ ## Usage
39
+
40
+ ### Deterministic Encryption (ipcrypt-deterministic)
41
+
42
+ ```ruby
43
+ require 'ipcrypt/deterministic'
44
+
45
+ # 16-byte key
46
+ key = "0123456789abcdeffedcba9876543210".scan(/../).map { |x| x.hex }.pack("C*")
47
+
48
+ # Encrypt an IP address
49
+ ip = "192.0.2.1"
50
+ encrypted_ip = IPCrypt::Deterministic.encrypt(ip, key)
51
+ decrypted_ip = IPCrypt::Deterministic.decrypt(encrypted_ip, key)
52
+ ```
53
+
54
+ ### Non-Deterministic Encryption with KIASU-BC (ipcrypt-nd)
55
+
56
+ ```ruby
57
+ require 'ipcrypt/nd'
58
+
59
+ # 16-byte key
60
+ key = "0123456789abcdeffedcba9876543210".scan(/../).map { |x| x.hex }.pack("C*")
61
+
62
+ # Encrypt an IP address (with optional tweak)
63
+ ip = "192.0.2.1"
64
+ encrypted_data = IPCrypt::ND.encrypt(ip, key) # Random tweak
65
+ # or
66
+ tweak = "08e0c289bff23b7c".scan(/../).map { |x| x.hex }.pack("C*")
67
+ encrypted_data = IPCrypt::ND.encrypt(ip, key, tweak) # Specific tweak
68
+
69
+ # Decrypt
70
+ decrypted_ip = IPCrypt::ND.decrypt(encrypted_data, key)
71
+ ```
72
+
73
+ ### Non-Deterministic Encryption with AES-XTS (ipcrypt-ndx)
74
+
75
+ ```ruby
76
+ require 'ipcrypt/ndx'
77
+
78
+ # 32-byte key
79
+ key = "0123456789abcdeffedcba98765432101032547698badcfeefcdab8967452301".scan(/../).map { |x| x.hex }.pack("C*")
80
+
81
+ # Encrypt an IP address
82
+ ip = "192.0.2.1"
83
+ encrypted_data = IPCrypt::NDX.encrypt(ip, key)
84
+
85
+ # Decrypt
86
+ decrypted_ip = IPCrypt::NDX.decrypt(encrypted_data, key)
87
+ ```
88
+
89
+ ## Framework Integration
90
+
91
+ ### Ruby on Rails
92
+
93
+ #### 1. Basic Setup
94
+
95
+ Add to your `Gemfile`:
96
+
97
+ ```ruby
98
+ gem 'ipcrypt2'
99
+ ```
100
+
101
+ Create an initializer `config/initializers/ipcrypt.rb`:
102
+
103
+ ```ruby
104
+ # Store your key securely using Rails credentials
105
+ Rails.application.config.ipcrypt_key = Rails.application.credentials.ipcrypt_key
106
+
107
+ # Or use environment variables
108
+ Rails.application.config.ipcrypt_key = ENV['IPCRYPT_KEY'].scan(/../).map { |x| x.hex }.pack("C*")
109
+ ```
110
+
111
+ #### 2. ActiveRecord Model Integration
112
+
113
+ ```ruby
114
+ class User < ApplicationRecord
115
+ # Store encrypted IP addresses in the database
116
+
117
+ def ip_address=(ip)
118
+ key = Rails.application.config.ipcrypt_key
119
+ self.encrypted_ip = IPCrypt::Deterministic.encrypt(ip, key)
120
+ end
121
+
122
+ def ip_address
123
+ return nil unless encrypted_ip.present?
124
+ key = Rails.application.config.ipcrypt_key
125
+ IPCrypt::Deterministic.decrypt(encrypted_ip, key)
126
+ end
127
+ end
128
+ ```
129
+
130
+ #### 3. Request Logging
131
+
132
+ Create a concern for controllers:
133
+
134
+ ```ruby
135
+ # app/controllers/concerns/ip_encryption.rb
136
+ module IpEncryption
137
+ extend ActiveSupport::Concern
138
+
139
+ included do
140
+ before_action :store_encrypted_ip
141
+ end
142
+
143
+ private
144
+
145
+ def store_encrypted_ip
146
+ return unless request.remote_ip.present?
147
+
148
+ key = Rails.application.config.ipcrypt_key
149
+ encrypted_ip = IPCrypt::Deterministic.encrypt(request.remote_ip, key)
150
+
151
+ # Store in session, database, or logs
152
+ session[:encrypted_ip] = encrypted_ip
153
+ end
154
+ end
155
+
156
+ # Use in controllers
157
+ class ApplicationController < ActionController::Base
158
+ include IpEncryption
159
+ end
160
+ ```
161
+
162
+ #### 4. Custom Logger
163
+
164
+ ```ruby
165
+ # config/application.rb
166
+ class EncryptedIpLogger < ActiveSupport::Logger
167
+ def add_ip(severity, ip_address, progname = nil)
168
+ key = Rails.application.config.ipcrypt_key
169
+ encrypted_ip = IPCrypt::Deterministic.encrypt(ip_address, key)
170
+ add(severity, "IP: #{encrypted_ip}", progname)
171
+ end
172
+ end
173
+
174
+ # Usage
175
+ Rails.logger.add_ip(Logger::INFO, request.remote_ip)
176
+ ```
177
+
178
+ ### Sinatra
179
+
180
+ ```ruby
181
+ require 'sinatra'
182
+ require 'ipcrypt/deterministic'
183
+
184
+ configure do
185
+ set :ipcrypt_key, ENV['IPCRYPT_KEY'].scan(/../).map { |x| x.hex }.pack("C*")
186
+ end
187
+
188
+ helpers do
189
+ def encrypt_ip(ip)
190
+ IPCrypt::Deterministic.encrypt(ip, settings.ipcrypt_key)
191
+ end
192
+
193
+ def decrypt_ip(encrypted_ip)
194
+ IPCrypt::Deterministic.decrypt(encrypted_ip, settings.ipcrypt_key)
195
+ end
196
+ end
197
+
198
+ before do
199
+ @encrypted_client_ip = encrypt_ip(request.ip)
200
+ end
201
+
202
+ get '/' do
203
+ "Your encrypted IP: #{@encrypted_client_ip}"
204
+ end
205
+ ```
206
+
207
+ ### Rack Middleware
208
+
209
+ Create middleware for any Rack-based application:
210
+
211
+ ```ruby
212
+ # lib/rack/ip_encryptor.rb
213
+ module Rack
214
+ class IpEncryptor
215
+ def initialize(app, key)
216
+ @app = app
217
+ @key = key
218
+ end
219
+
220
+ def call(env)
221
+ # Encrypt the client IP
222
+ if env['REMOTE_ADDR']
223
+ env['rack.encrypted_ip'] = IPCrypt::Deterministic.encrypt(env['REMOTE_ADDR'], @key)
224
+ end
225
+
226
+ @app.call(env)
227
+ end
228
+ end
229
+ end
230
+
231
+ # Usage in config.ru
232
+ require 'ipcrypt/deterministic'
233
+
234
+ key = ENV['IPCRYPT_KEY'].scan(/../).map { |x| x.hex }.pack("C*")
235
+ use Rack::IpEncryptor, key
236
+ run YourApp
237
+ ```
238
+
239
+ ### Hanami
240
+
241
+ ```ruby
242
+ # config/environment.rb
243
+ require 'ipcrypt/deterministic'
244
+
245
+ Hanami.configure do
246
+ # Store key in settings
247
+ settings.ipcrypt_key = ENV['IPCRYPT_KEY'].scan(/../).map { |x| x.hex }.pack("C*")
248
+ end
249
+
250
+ # app/actions/application.rb
251
+ module YourApp
252
+ module Actions
253
+ class Application < Hanami::Action
254
+ before :encrypt_client_ip
255
+
256
+ private
257
+
258
+ def encrypt_client_ip
259
+ return unless request.ip
260
+
261
+ key = Hanami.app.settings.ipcrypt_key
262
+ @encrypted_ip = IPCrypt::Deterministic.encrypt(request.ip, key)
263
+ end
264
+ end
265
+ end
266
+ end
267
+ ```
268
+
269
+ ### Grape API
270
+
271
+ ```ruby
272
+ require 'grape'
273
+ require 'ipcrypt/deterministic'
274
+
275
+ class API < Grape::API
276
+ helpers do
277
+ def ipcrypt_key
278
+ @key ||= ENV['IPCRYPT_KEY'].scan(/../).map { |x| x.hex }.pack("C*")
279
+ end
280
+
281
+ def encrypted_client_ip
282
+ IPCrypt::Deterministic.encrypt(request.ip, ipcrypt_key)
283
+ end
284
+ end
285
+
286
+ before do
287
+ # Log encrypted IP for each request
288
+ logger.info "Request from: #{encrypted_client_ip}"
289
+ end
290
+
291
+ get '/my-ip' do
292
+ { encrypted_ip: encrypted_client_ip }
293
+ end
294
+ end
295
+ ```
296
+
297
+ ## Best Practices
298
+
299
+ 1. **Key Management**: Never hardcode encryption keys. Use environment variables or secure credential stores like Rails credentials.
300
+
301
+ 2. **Choose the Right Mode**:
302
+ - Use `deterministic` for logs and analytics where you need to correlate multiple requests from the same IP
303
+ - Use `nd` or `ndx` for storage where each encryption should be unique
304
+
305
+ 3. **Performance**: For high-traffic applications, consider caching the key parsing:
306
+ ```ruby
307
+ class IpEncryptor
308
+ def self.key
309
+ @key ||= ENV['IPCRYPT_KEY'].scan(/../).map { |x| x.hex }.pack("C*")
310
+ end
311
+ end
312
+ ```
313
+
314
+ 4. **Database Storage**: Store encrypted IPs as binary or base64-encoded strings:
315
+ ```ruby
316
+ # Migration
317
+ add_column :users, :encrypted_ip, :binary
318
+
319
+ # Model
320
+ def ip_address=(ip)
321
+ self.encrypted_ip = IPCrypt::Deterministic.encrypt(ip, key)
322
+ end
323
+ ```
324
+
325
+ ## Development
326
+
327
+ After checking out the repo, run `bundle install` to install dependencies.
328
+
329
+ ### Running Tests
330
+
331
+ ```bash
332
+ # Run all tests
333
+ bundle exec rake test
334
+
335
+ # Run tests with verbose output
336
+ bundle exec rake test_verbose
337
+
338
+ # Run RuboCop linting
339
+ bundle exec rubocop
340
+
341
+ # Run both tests and linting (default task)
342
+ bundle exec rake
343
+ ```
344
+
345
+ ### Building the Gem
346
+
347
+ ```bash
348
+ # Build the gem
349
+ gem build ipcrypt2.gemspec
350
+
351
+ # Install locally for testing
352
+ gem install ./ipcrypt2-*.gem
353
+ ```
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'ipaddr'
4
+ require 'openssl'
5
+
6
+ module IPCrypt
7
+ # Implementation of ipcrypt-deterministic using AES-128
8
+ class Deterministic
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
+ # Encrypt an IP address using AES-128
37
+ def self.encrypt(ip, key)
38
+ raise InvalidKeyError, 'Key must be 16 bytes' unless key.length == 16
39
+
40
+ plaintext = ip_to_bytes(ip)
41
+ cipher = OpenSSL::Cipher.new('AES-128-ECB')
42
+ cipher.encrypt
43
+ cipher.padding = 0 # Disable padding for exact 16-byte blocks
44
+ cipher.key = key
45
+ ciphertext = cipher.update(plaintext) + cipher.final
46
+
47
+ bytes_to_ip(ciphertext)
48
+ end
49
+
50
+ # Decrypt an IP address using AES-128
51
+ def self.decrypt(ip, key)
52
+ raise InvalidKeyError, 'Key must be 16 bytes' unless key.length == 16
53
+
54
+ ciphertext = ip_to_bytes(ip)
55
+ cipher = OpenSSL::Cipher.new('AES-128-ECB')
56
+ cipher.decrypt
57
+ cipher.padding = 0 # Disable padding for exact 16-byte blocks
58
+ cipher.key = key
59
+ plaintext = cipher.update(ciphertext) + cipher.final
60
+
61
+ bytes_to_ip(plaintext)
62
+ end
63
+ end
64
+ end
data/lib/ipcrypt/nd.rb ADDED
@@ -0,0 +1,307 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'ipaddr'
4
+ require 'openssl'
5
+
6
+ module IPCrypt
7
+ # Implementation of ipcrypt-nd using KIASU-BC
8
+ class ND
9
+ # AES S-box and inverse S-box
10
+ SBOX = [
11
+ 0x63, 0x7c, 0x77, 0x7b, 0xf2, 0x6b, 0x6f, 0xc5, 0x30, 0x01, 0x67, 0x2b, 0xfe, 0xd7, 0xab, 0x76,
12
+ 0xca, 0x82, 0xc9, 0x7d, 0xfa, 0x59, 0x47, 0xf0, 0xad, 0xd4, 0xa2, 0xaf, 0x9c, 0xa4, 0x72, 0xc0,
13
+ 0xb7, 0xfd, 0x93, 0x26, 0x36, 0x3f, 0xf7, 0xcc, 0x34, 0xa5, 0xe5, 0xf1, 0x71, 0xd8, 0x31, 0x15,
14
+ 0x04, 0xc7, 0x23, 0xc3, 0x18, 0x96, 0x05, 0x9a, 0x07, 0x12, 0x80, 0xe2, 0xeb, 0x27, 0xb2, 0x75,
15
+ 0x09, 0x83, 0x2c, 0x1a, 0x1b, 0x6e, 0x5a, 0xa0, 0x52, 0x3b, 0xd6, 0xb3, 0x29, 0xe3, 0x2f, 0x84,
16
+ 0x53, 0xd1, 0x00, 0xed, 0x20, 0xfc, 0xb1, 0x5b, 0x6a, 0xcb, 0xbe, 0x39, 0x4a, 0x4c, 0x58, 0xcf,
17
+ 0xd0, 0xef, 0xaa, 0xfb, 0x43, 0x4d, 0x33, 0x85, 0x45, 0xf9, 0x02, 0x7f, 0x50, 0x3c, 0x9f, 0xa8,
18
+ 0x51, 0xa3, 0x40, 0x8f, 0x92, 0x9d, 0x38, 0xf5, 0xbc, 0xb6, 0xda, 0x21, 0x10, 0xff, 0xf3, 0xd2,
19
+ 0xcd, 0x0c, 0x13, 0xec, 0x5f, 0x97, 0x44, 0x17, 0xc4, 0xa7, 0x7e, 0x3d, 0x64, 0x5d, 0x19, 0x73,
20
+ 0x60, 0x81, 0x4f, 0xdc, 0x22, 0x2a, 0x90, 0x88, 0x46, 0xee, 0xb8, 0x14, 0xde, 0x5e, 0x0b, 0xdb,
21
+ 0xe0, 0x32, 0x3a, 0x0a, 0x49, 0x06, 0x24, 0x5c, 0xc2, 0xd3, 0xac, 0x62, 0x91, 0x95, 0xe4, 0x79,
22
+ 0xe7, 0xc8, 0x37, 0x6d, 0x8d, 0xd5, 0x4e, 0xa9, 0x6c, 0x56, 0xf4, 0xea, 0x65, 0x7a, 0xae, 0x08,
23
+ 0xba, 0x78, 0x25, 0x2e, 0x1c, 0xa6, 0xb4, 0xc6, 0xe8, 0xdd, 0x74, 0x1f, 0x4b, 0xbd, 0x8b, 0x8a,
24
+ 0x70, 0x3e, 0xb5, 0x66, 0x48, 0x03, 0xf6, 0x0e, 0x61, 0x35, 0x57, 0xb9, 0x86, 0xc1, 0x1d, 0x9e,
25
+ 0xe1, 0xf8, 0x98, 0x11, 0x69, 0xd9, 0x8e, 0x94, 0x9b, 0x1e, 0x87, 0xe9, 0xce, 0x55, 0x28, 0xdf,
26
+ 0x8c, 0xa1, 0x89, 0x0d, 0xbf, 0xe6, 0x42, 0x68, 0x41, 0x99, 0x2d, 0x0f, 0xb0, 0x54, 0xbb, 0x16
27
+ ].freeze
28
+
29
+ INV_SBOX = [
30
+ 0x52, 0x09, 0x6a, 0xd5, 0x30, 0x36, 0xa5, 0x38, 0xbf, 0x40, 0xa3, 0x9e, 0x81, 0xf3, 0xd7, 0xfb,
31
+ 0x7c, 0xe3, 0x39, 0x82, 0x9b, 0x2f, 0xff, 0x87, 0x34, 0x8e, 0x43, 0x44, 0xc4, 0xde, 0xe9, 0xcb,
32
+ 0x54, 0x7b, 0x94, 0x32, 0xa6, 0xc2, 0x23, 0x3d, 0xee, 0x4c, 0x95, 0x0b, 0x42, 0xfa, 0xc3, 0x4e,
33
+ 0x08, 0x2e, 0xa1, 0x66, 0x28, 0xd9, 0x24, 0xb2, 0x76, 0x5b, 0xa2, 0x49, 0x6d, 0x8b, 0xd1, 0x25,
34
+ 0x72, 0xf8, 0xf6, 0x64, 0x86, 0x68, 0x98, 0x16, 0xd4, 0xa4, 0x5c, 0xcc, 0x5d, 0x65, 0xb6, 0x92,
35
+ 0x6c, 0x70, 0x48, 0x50, 0xfd, 0xed, 0xb9, 0xda, 0x5e, 0x15, 0x46, 0x57, 0xa7, 0x8d, 0x9d, 0x84,
36
+ 0x90, 0xd8, 0xab, 0x00, 0x8c, 0xbc, 0xd3, 0x0a, 0xf7, 0xe4, 0x58, 0x05, 0xb8, 0xb3, 0x45, 0x06,
37
+ 0xd0, 0x2c, 0x1e, 0x8f, 0xca, 0x3f, 0x0f, 0x02, 0xc1, 0xaf, 0xbd, 0x03, 0x01, 0x13, 0x8a, 0x6b,
38
+ 0x3a, 0x91, 0x11, 0x41, 0x4f, 0x67, 0xdc, 0xea, 0x97, 0xf2, 0xcf, 0xce, 0xf0, 0xb4, 0xe6, 0x73,
39
+ 0x96, 0xac, 0x74, 0x22, 0xe7, 0xad, 0x35, 0x85, 0xe2, 0xf9, 0x37, 0xe8, 0x1c, 0x75, 0xdf, 0x6e,
40
+ 0x47, 0xf1, 0x1a, 0x71, 0x1d, 0x29, 0xc5, 0x89, 0x6f, 0xb7, 0x62, 0x0e, 0xaa, 0x18, 0xbe, 0x1b,
41
+ 0xfc, 0x56, 0x3e, 0x4b, 0xc6, 0xd2, 0x79, 0x20, 0x9a, 0xdb, 0xc0, 0xfe, 0x78, 0xcd, 0x5a, 0xf4,
42
+ 0x1f, 0xdd, 0xa8, 0x33, 0x88, 0x07, 0xc7, 0x31, 0xb1, 0x12, 0x10, 0x59, 0x27, 0x80, 0xec, 0x5f,
43
+ 0x60, 0x51, 0x7f, 0xa9, 0x19, 0xb5, 0x4a, 0x0d, 0x2d, 0xe5, 0x7a, 0x9f, 0x93, 0xc9, 0x9c, 0xef,
44
+ 0xa0, 0xe0, 0x3b, 0x4d, 0xae, 0x2a, 0xf5, 0xb0, 0xc8, 0xeb, 0xbb, 0x3c, 0x83, 0x53, 0x99, 0x61,
45
+ 0x17, 0x2b, 0x04, 0x7e, 0xba, 0x77, 0xd6, 0x26, 0xe1, 0x69, 0x14, 0x63, 0x55, 0x21, 0x0c, 0x7d
46
+ ].freeze
47
+
48
+ # AES round constants
49
+ RCON = [0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80, 0x1b, 0x36].freeze
50
+
51
+ # Precomputed multiplication tables for AES operations
52
+ MUL2 = (0..255).map do |x|
53
+ ((x << 1) & 0xFF) ^ (x & 0x80 != 0 ? 0x1B : 0)
54
+ end.freeze
55
+
56
+ MUL3 = (0..255).map do |x|
57
+ MUL2[x] ^ x
58
+ end.freeze
59
+
60
+ # Specialized GF multiplication by 0x09 (x^3 + 1)
61
+ def self.gf_mul_09(a)
62
+ # 0x09 = x^3 + 1 = MUL2[MUL2[MUL2[a]]] ^ a
63
+ MUL2[MUL2[MUL2[a]]] ^ a
64
+ end
65
+
66
+ # Specialized GF multiplication by 0x0B (x^3 + x + 1)
67
+ def self.gf_mul_0b(a)
68
+ # 0x0B = x^3 + x + 1 = MUL2[MUL2[MUL2[a]]] ^ MUL2[a] ^ a
69
+ MUL2[MUL2[MUL2[a]]] ^ MUL2[a] ^ a
70
+ end
71
+
72
+ # Specialized GF multiplication by 0x0D (x^3 + x^2 + 1)
73
+ def self.gf_mul_0d(a)
74
+ # 0x0D = x^3 + x^2 + 1 = MUL2[MUL2[MUL2[a]]] ^ MUL2[MUL2[a]] ^ a
75
+ MUL2[MUL2[MUL2[a]]] ^ MUL2[MUL2[a]] ^ a
76
+ end
77
+
78
+ # Specialized GF multiplication by 0x0E (x^3 + x^2 + x)
79
+ def self.gf_mul_0e(a)
80
+ # 0x0E = x^3 + x^2 + x = MUL2[MUL2[MUL2[a]]] ^ MUL2[MUL2[a]] ^ MUL2[a]
81
+ MUL2[MUL2[MUL2[a]]] ^ MUL2[MUL2[a]] ^ MUL2[a]
82
+ end
83
+
84
+ # Convert an IP address to its 16-byte representation
85
+ def self.ip_to_bytes(ip)
86
+ ip_addr = ip.is_a?(String) ? IPAddr.new(ip) : ip
87
+ if ip_addr.ipv4?
88
+ # Convert IPv4 to IPv4-mapped IPv6 format (::ffff:0:0/96)
89
+ bytes = [0] * 10 + [0xff, 0xff] + ip_addr.hton.bytes
90
+ bytes.pack('C*').force_encoding('BINARY')
91
+ else
92
+ ip_addr.hton.force_encoding('BINARY')
93
+ end
94
+ end
95
+
96
+ # Convert a 16-byte representation back to an IP address
97
+ def self.bytes_to_ip(bytes16)
98
+ raise InvalidDataError, 'Input must be 16 bytes' unless bytes16.length == 16
99
+
100
+ # Check for IPv4-mapped IPv6 format
101
+ zero_bytes = [0] * 10
102
+ ff_bytes = [255, 255]
103
+
104
+ if bytes16[0, 10].bytes == zero_bytes && bytes16[10, 2].bytes == ff_bytes
105
+ IPAddr.new_ntoh(bytes16[12, 4])
106
+ else
107
+ IPAddr.new_ntoh(bytes16)
108
+ end
109
+ end
110
+
111
+ # Rotate a 4-byte word
112
+ def self.rot_word(word)
113
+ word[1..] + word[0]
114
+ end
115
+
116
+ # Generate AES round keys
117
+ def self.expand_key(key)
118
+ raise InvalidKeyError, 'Key must be 16 bytes' unless key.length == 16
119
+
120
+ round_keys = [key]
121
+ 10.times do |i|
122
+ prev_key = round_keys.last
123
+ temp = prev_key[-4..]
124
+ temp = rot_word(temp)
125
+ temp = temp.bytes.map { |b| SBOX[b] }.pack('C*')
126
+ temp[0] = (temp[0].ord ^ RCON[i]).chr
127
+
128
+ new_key = ''
129
+ 4.times do |j|
130
+ word = prev_key[j * 4, 4]
131
+ word = if j.zero?
132
+ word.bytes.zip(temp.bytes).map { |a, b| a ^ b }.pack('C*')
133
+ else
134
+ word.bytes.zip(new_key[(j - 1) * 4, 4].bytes).map { |a, b| a ^ b }.pack('C*')
135
+ end
136
+ new_key += word
137
+ end
138
+ round_keys << new_key
139
+ end
140
+
141
+ round_keys
142
+ end
143
+
144
+ # Pad an 8-byte tweak to 16 bytes by placing each 2-byte pair at the start of each 4-byte group
145
+ def self.pad_tweak(tweak)
146
+ raise InvalidTweakError, 'Tweak must be 8 bytes' unless tweak.length == 8
147
+
148
+ padded_bytes = [0] * 16
149
+ 4.times do |i|
150
+ padded_bytes[i * 4] = tweak[i * 2].ord
151
+ padded_bytes[i * 4 + 1] = tweak[i * 2 + 1].ord
152
+ # padded_bytes[i * 4 + 2] and padded_bytes[i * 4 + 3] are already 0
153
+ end
154
+ padded_bytes.pack('C*').force_encoding('BINARY')
155
+ end
156
+
157
+ # Perform AES SubBytes operation
158
+ def self.sub_bytes(state)
159
+ state.bytes.map { |b| SBOX[b] }.pack('C*')
160
+ end
161
+
162
+ # Perform inverse AES SubBytes operation
163
+ def self.inv_sub_bytes(state)
164
+ state.bytes.map { |b| INV_SBOX[b] }.pack('C*')
165
+ end
166
+
167
+ # Perform AES ShiftRows operation
168
+ def self.shift_rows(state)
169
+ bytes = state.bytes
170
+ [
171
+ bytes[0], bytes[5], bytes[10], bytes[15], bytes[4], bytes[9], bytes[14], bytes[3],
172
+ bytes[8], bytes[13], bytes[2], bytes[7], bytes[12], bytes[1], bytes[6], bytes[11]
173
+ ].pack('C*')
174
+ end
175
+
176
+ # Perform inverse AES ShiftRows operation
177
+ def self.inv_shift_rows(state)
178
+ bytes = state.bytes
179
+ [
180
+ bytes[0], bytes[13], bytes[10], bytes[7], bytes[4], bytes[1], bytes[14], bytes[11],
181
+ bytes[8], bytes[5], bytes[2], bytes[15], bytes[12], bytes[9], bytes[6], bytes[3]
182
+ ].pack('C*')
183
+ end
184
+
185
+ # Perform AES MixColumns operation
186
+ def self.mix_columns(state)
187
+ new_state = []
188
+ 4.times do |i|
189
+ s = state[i * 4, 4].bytes
190
+ s0, s1, s2, s3 = s
191
+ new_state[i * 4] = MUL2[s0] ^ MUL3[s1] ^ s2 ^ s3
192
+ new_state[i * 4 + 1] = s0 ^ MUL2[s1] ^ MUL3[s2] ^ s3
193
+ new_state[i * 4 + 2] = s0 ^ s1 ^ MUL2[s2] ^ MUL3[s3]
194
+ new_state[i * 4 + 3] = MUL3[s0] ^ s1 ^ s2 ^ MUL2[s3]
195
+ end
196
+ new_state.pack('C*')
197
+ end
198
+
199
+ # Perform inverse AES MixColumns operation
200
+ def self.inv_mix_columns(state)
201
+ new_state = []
202
+ 4.times do |i|
203
+ col = state[i * 4, 4].bytes
204
+ result = [
205
+ gf_mul_0e(col[0]) ^ gf_mul_0b(col[1]) ^ gf_mul_0d(col[2]) ^ gf_mul_09(col[3]),
206
+ gf_mul_09(col[0]) ^ gf_mul_0e(col[1]) ^ gf_mul_0b(col[2]) ^ gf_mul_0d(col[3]),
207
+ gf_mul_0d(col[0]) ^ gf_mul_09(col[1]) ^ gf_mul_0e(col[2]) ^ gf_mul_0b(col[3]),
208
+ gf_mul_0b(col[0]) ^ gf_mul_0d(col[1]) ^ gf_mul_09(col[2]) ^ gf_mul_0e(col[3])
209
+ ]
210
+ new_state += result
211
+ end
212
+ new_state.pack('C*')
213
+ end
214
+
215
+ # Encrypt using KIASU-BC construction
216
+ def self.kiasu_bc_encrypt(key, tweak, plaintext)
217
+ raise InvalidKeyError, 'Key must be 16 bytes' unless key.length == 16
218
+ raise InvalidTweakError, 'Tweak must be 8 bytes' unless tweak.length == 8
219
+ raise InvalidDataError, 'Plaintext must be 16 bytes' unless plaintext.length == 16
220
+
221
+ round_keys = expand_key(key)
222
+ padded_tweak = pad_tweak(tweak)
223
+
224
+ # XOR plaintext with round key and padded tweak
225
+ state = plaintext.bytes.zip(round_keys[0].bytes, padded_tweak.bytes)
226
+ .map { |p, k, t| p ^ k ^ t }.pack('C*')
227
+
228
+ 9.times do |i|
229
+ state = sub_bytes(state)
230
+ state = shift_rows(state)
231
+ state = mix_columns(state)
232
+ # XOR with round key and padded tweak
233
+ state = state.bytes.zip(round_keys[i + 1].bytes, padded_tweak.bytes)
234
+ .map { |s, k, t| s ^ k ^ t }.pack('C*')
235
+ end
236
+
237
+ state = sub_bytes(state)
238
+ state = shift_rows(state)
239
+ # Final round - XOR with round key and padded tweak
240
+ state.bytes.zip(round_keys[10].bytes, padded_tweak.bytes)
241
+ .map { |s, k, t| s ^ k ^ t }.pack('C*')
242
+ end
243
+
244
+ # Decrypt using KIASU-BC construction
245
+ def self.kiasu_bc_decrypt(key, tweak, ciphertext)
246
+ raise InvalidKeyError, 'Key must be 16 bytes' unless key.length == 16
247
+ raise InvalidTweakError, 'Tweak must be 8 bytes' unless tweak.length == 8
248
+ raise InvalidDataError, 'Ciphertext must be 16 bytes' unless ciphertext.length == 16
249
+
250
+ round_keys = expand_key(key)
251
+ padded_tweak = pad_tweak(tweak)
252
+
253
+ # Initial operations
254
+ state = ciphertext.bytes.zip(round_keys[10].bytes, padded_tweak.bytes)
255
+ .map { |c, k, t| c ^ k ^ t }.pack('C*')
256
+ state = inv_shift_rows(state)
257
+ state = inv_sub_bytes(state)
258
+
259
+ 9.downto(1) do |i|
260
+ # XOR with round key and padded tweak
261
+ state = state.bytes.zip(round_keys[i].bytes, padded_tweak.bytes)
262
+ .map { |s, k, t| s ^ k ^ t }.pack('C*')
263
+ state = inv_mix_columns(state)
264
+ state = inv_shift_rows(state)
265
+ state = inv_sub_bytes(state)
266
+ end
267
+
268
+ # Final round - XOR with round key and padded tweak
269
+ state.bytes.zip(round_keys[0].bytes, padded_tweak.bytes)
270
+ .map { |s, k, t| s ^ k ^ t }.pack('C*')
271
+ end
272
+
273
+ # Encrypt an IP address using ipcrypt-nd
274
+ def self.encrypt(ip_address, key, tweak = nil)
275
+ # Convert IP to bytes
276
+ ip_bytes = ip_to_bytes(ip_address)
277
+
278
+ # Use provided tweak or generate random 8-byte tweak
279
+ if tweak.nil?
280
+ tweak = OpenSSL::Random.random_bytes(8)
281
+ elsif tweak.length != 8
282
+ raise InvalidTweakError, 'Tweak must be 8 bytes'
283
+ end
284
+
285
+ # Encrypt using KIASU-BC
286
+ ciphertext = kiasu_bc_encrypt(key, tweak, ip_bytes)
287
+
288
+ # Return tweak || ciphertext
289
+ tweak + ciphertext
290
+ end
291
+
292
+ # Decrypt an IP address using ipcrypt-nd
293
+ def self.decrypt(encrypted_data, key)
294
+ raise InvalidDataError, 'Encrypted data must be 24 bytes' unless encrypted_data.length == 24
295
+
296
+ # Split into tweak and ciphertext
297
+ tweak = encrypted_data[0, 8]
298
+ ciphertext = encrypted_data[8, 16]
299
+
300
+ # Decrypt using KIASU-BC
301
+ ip_bytes = kiasu_bc_decrypt(key, tweak, ciphertext)
302
+
303
+ # Convert back to IP address
304
+ bytes_to_ip(ip_bytes)
305
+ end
306
+ end
307
+ end
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'ipaddr'
4
+ require 'openssl'
5
+
6
+ module IPCrypt
7
+ # Implementation of ipcrypt-ndx using AES-XTS with a 16-byte tweak
8
+ class NDX
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
+ # Encrypt using AES-XTS construction
37
+ def self.aes_xts_encrypt(key, tweak, plaintext)
38
+ raise InvalidKeyError, 'Key must be 32 bytes' unless key.length == 32
39
+ raise InvalidTweakError, 'Tweak must be 16 bytes' unless tweak.length == 16
40
+ raise InvalidDataError, 'Plaintext must be 16 bytes' unless plaintext.length == 16
41
+
42
+ # Split key into two 16-byte keys
43
+ k1 = key[0, 16]
44
+ k2 = key[16, 16]
45
+
46
+ # Encrypt tweak with second key
47
+ cipher2 = OpenSSL::Cipher.new('AES-128-ECB')
48
+ cipher2.encrypt
49
+ cipher2.padding = 0 # Disable padding
50
+ cipher2.key = k2
51
+ et = cipher2.update(tweak) + cipher2.final
52
+
53
+ # XOR plaintext with encrypted tweak
54
+ xored = plaintext.bytes.zip(et.bytes).map { |a, b| a ^ b }.pack('C*')
55
+
56
+ # Encrypt with first key
57
+ cipher1 = OpenSSL::Cipher.new('AES-128-ECB')
58
+ cipher1.encrypt
59
+ cipher1.padding = 0 # Disable padding
60
+ cipher1.key = k1
61
+ encrypted = cipher1.update(xored) + cipher1.final
62
+
63
+ # XOR result with encrypted tweak
64
+ encrypted.bytes.zip(et.bytes).map { |a, b| a ^ b }.pack('C*')
65
+ end
66
+
67
+ # Decrypt using AES-XTS construction
68
+ def self.aes_xts_decrypt(key, tweak, ciphertext)
69
+ raise InvalidKeyError, 'Key must be 32 bytes' unless key.length == 32
70
+ raise InvalidTweakError, 'Tweak must be 16 bytes' unless tweak.length == 16
71
+ raise InvalidDataError, 'Ciphertext must be 16 bytes' unless ciphertext.length == 16
72
+
73
+ # Split key into two 16-byte keys
74
+ k1 = key[0, 16]
75
+ k2 = key[16, 16]
76
+
77
+ # Encrypt tweak with second key
78
+ cipher2 = OpenSSL::Cipher.new('AES-128-ECB')
79
+ cipher2.encrypt
80
+ cipher2.padding = 0 # Disable padding
81
+ cipher2.key = k2
82
+ et = cipher2.update(tweak) + cipher2.final
83
+
84
+ # XOR ciphertext with encrypted tweak
85
+ xored = ciphertext.bytes.zip(et.bytes).map { |a, b| a ^ b }.pack('C*')
86
+
87
+ # Decrypt with first key
88
+ cipher1 = OpenSSL::Cipher.new('AES-128-ECB')
89
+ cipher1.decrypt
90
+ cipher1.padding = 0 # Disable padding
91
+ cipher1.key = k1
92
+ decrypted = cipher1.update(xored) + cipher1.final
93
+
94
+ # XOR result with encrypted tweak
95
+ decrypted.bytes.zip(et.bytes).map { |a, b| a ^ b }.pack('C*')
96
+ end
97
+
98
+ # Encrypt an IP address using AES-XTS
99
+ def self.encrypt(ip, key)
100
+ raise InvalidKeyError, 'Key must be 32 bytes' unless key.length == 32
101
+
102
+ # Generate random 16-byte tweak
103
+ tweak = OpenSSL::Random.random_bytes(16)
104
+
105
+ # Convert IP to bytes and encrypt
106
+ plaintext = ip_to_bytes(ip)
107
+ ciphertext = aes_xts_encrypt(key, tweak, plaintext)
108
+
109
+ # Return tweak || ciphertext
110
+ tweak + ciphertext
111
+ end
112
+
113
+ # Decrypt a binary output using AES-XTS
114
+ def self.decrypt(binary_output, key)
115
+ raise InvalidKeyError, 'Key must be 32 bytes' unless key.length == 32
116
+ raise InvalidDataError, 'Binary output must be 32 bytes' unless binary_output.length == 32
117
+
118
+ # Split into tweak and ciphertext
119
+ tweak = binary_output[0, 16]
120
+ ciphertext = binary_output[16, 16]
121
+
122
+ # Decrypt and convert back to IP
123
+ plaintext = aes_xts_decrypt(key, tweak, ciphertext)
124
+ bytes_to_ip(plaintext)
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module IPCrypt
4
+ VERSION = '1.0.0'
5
+ end
data/lib/ipcrypt.rb ADDED
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Main module for IPCrypt implementations
4
+ module IPCrypt
5
+ class Error < StandardError; end
6
+ class InvalidKeyError < Error; end
7
+ class InvalidTweakError < Error; end
8
+ class InvalidDataError < Error; end
9
+ end
metadata ADDED
@@ -0,0 +1,97 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: ipcrypt2
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Frank Denis
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: minitest
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '5.0'
19
+ type: :development
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '5.0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: rake
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '13.0'
33
+ type: :development
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '13.0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: rubocop
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '1.0'
47
+ type: :development
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '1.0'
54
+ description: IPCrypt provides methods for encrypting and obfuscating IP addresses
55
+ for privacy-preserving storage, logging, and analytics. Implements deterministic,
56
+ non-deterministic (KIASU-BC), and XTS-based encryption.
57
+ email:
58
+ - fde@00f.net
59
+ executables: []
60
+ extensions: []
61
+ extra_rdoc_files: []
62
+ files:
63
+ - CHANGELOG.md
64
+ - LICENSE
65
+ - README.md
66
+ - lib/ipcrypt.rb
67
+ - lib/ipcrypt/deterministic.rb
68
+ - lib/ipcrypt/nd.rb
69
+ - lib/ipcrypt/ndx.rb
70
+ - lib/ipcrypt/version.rb
71
+ homepage: https://github.com/jedisct1/ipcrypt-ruby
72
+ licenses:
73
+ - MIT
74
+ metadata:
75
+ homepage_uri: https://github.com/jedisct1/ipcrypt-ruby
76
+ source_code_uri: https://github.com/jedisct1/ipcrypt-ruby
77
+ changelog_uri: https://github.com/jedisct1/ipcrypt-ruby/blob/master/CHANGELOG.md
78
+ bug_tracker_uri: https://github.com/jedisct1/ipcrypt-ruby/issues
79
+ documentation_uri: https://www.rubydoc.info/gems/ipcrypt2
80
+ rdoc_options: []
81
+ require_paths:
82
+ - lib
83
+ required_ruby_version: !ruby/object:Gem::Requirement
84
+ requirements:
85
+ - - ">="
86
+ - !ruby/object:Gem::Version
87
+ version: 2.6.0
88
+ required_rubygems_version: !ruby/object:Gem::Requirement
89
+ requirements:
90
+ - - ">="
91
+ - !ruby/object:Gem::Version
92
+ version: '0'
93
+ requirements: []
94
+ rubygems_version: 3.6.9
95
+ specification_version: 4
96
+ summary: Ruby implementation of IPCrypt for encrypting and obfuscating IP addresses
97
+ test_files: []