ffxcodec 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +10 -0
- data/.travis.yml +3 -0
- data/Gemfile +4 -0
- data/LICENSE +10 -0
- data/README.md +104 -0
- data/Rakefile +8 -0
- data/bin/console +14 -0
- data/bin/setup +7 -0
- data/exe/ffxcodec +88 -0
- data/exe/ffxcodec-demo +35 -0
- data/ffxcodec.gemspec +25 -0
- data/lib/ffxcodec/core_ext/string.rb +40 -0
- data/lib/ffxcodec/encoder.rb +120 -0
- data/lib/ffxcodec/encrypt.rb +223 -0
- data/lib/ffxcodec/version.rb +3 -0
- data/lib/ffxcodec.rb +119 -0
- metadata +106 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: ba22c48b71bd085c4a3dce71253019c04e9d0779
|
4
|
+
data.tar.gz: 14ad93e6127517bc027501c512b108c6682b7479
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 459359ebd5bb8e2ee4aef9ff97e31731be15146faa70f511092a4a33ffb23b4e54dec54107b668f35f60e2150f4687b80f37641b04f32854b0c9979dc8e7fc54
|
7
|
+
data.tar.gz: 27b2a249060cff63d1dd17207a81e6065a0eea449c17465a19a15f5d0d8b6e5097d7002aef8ed2b183628fc331122fc16d363dab9d4764299eaeee1a4ea15471
|
data/.gitignore
ADDED
data/.travis.yml
ADDED
data/Gemfile
ADDED
data/LICENSE
ADDED
@@ -0,0 +1,10 @@
|
|
1
|
+
Copyright (c) 2016, J. Brandt Buckley
|
2
|
+
All rights reserved.
|
3
|
+
|
4
|
+
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
|
5
|
+
|
6
|
+
1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
|
7
|
+
|
8
|
+
2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
|
9
|
+
|
10
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
data/README.md
ADDED
@@ -0,0 +1,104 @@
|
|
1
|
+
# FFXCodec
|
2
|
+
|
3
|
+
Encodes two unsigned integers into a single, larger (32 or 64-bit) integer.
|
4
|
+
|
5
|
+
Optionally, it can encrypt/decrypt the resulting integer using an implementation of the AES-FFX [format-preserving cipher][1].
|
6
|
+
|
7
|
+
Has no external dependencies. Everything is done with the stdlib.
|
8
|
+
|
9
|
+
|
10
|
+
## Usage
|
11
|
+
|
12
|
+
Start by divvying up the 32 or 64 bits that will make up the resulting integer:
|
13
|
+
|
14
|
+
ffx = FFXCodec.new(16, 16) # divide 32-bit int equally
|
15
|
+
ffx = FFXCodec.new(40, 24) # divide 64-bit int into a 40 and 24-bit int
|
16
|
+
|
17
|
+
Then, encode and decode accordingly:
|
18
|
+
|
19
|
+
ffx.encode(1234567890, 4) #=> 165828720871684
|
20
|
+
ffx.decode(165828720871684) #=> [1234567890, 4]
|
21
|
+
|
22
|
+
Optionally, you can enable encryption by setting a `key` and `tweak`:
|
23
|
+
|
24
|
+
ffx.setup_encryption("2b7e151628aed2a6abf7158809cf4f3c", "9876543210")
|
25
|
+
ffx.encode(797980150281, 5427652) #=> 7692035069140451684
|
26
|
+
ffx.decode(7692035069140451684) #=> [797980150281, 5427652]
|
27
|
+
|
28
|
+
|
29
|
+
### Putting it all together
|
30
|
+
|
31
|
+
Example **without encryption** (40 and 24-bit integers into 64-bit):
|
32
|
+
|
33
|
+
ffx = FFXCodec.new(40, 24)
|
34
|
+
ffx.encode(1234567890, 4) #=> 165828720871684
|
35
|
+
ffx.decode(165828720871684) #=> [1234567890, 4]
|
36
|
+
|
37
|
+
Example **with encryption** (40 and 24-bit integers into 64-bit):
|
38
|
+
|
39
|
+
ffx = FFXCodec.new(40, 24)
|
40
|
+
ffx.setup_encryption("2b7e151628aed2a6abf7158809cf4f3c", "9876543210")
|
41
|
+
ffx.encode(797980150281, 5427652) #=> 7692035069140451684
|
42
|
+
ffx.decode(7692035069140451684) #=> [797980150281, 5427652]
|
43
|
+
|
44
|
+
|
45
|
+
## Installation
|
46
|
+
|
47
|
+
To install:
|
48
|
+
|
49
|
+
gem install ffxcodec
|
50
|
+
|
51
|
+
Add this line to your application's Gemfile:
|
52
|
+
|
53
|
+
```ruby
|
54
|
+
gem 'ffxcodec'
|
55
|
+
```
|
56
|
+
|
57
|
+
|
58
|
+
## FAQ
|
59
|
+
|
60
|
+
Q. Does this only work with unsigned integers?
|
61
|
+
|
62
|
+
A. It could be made to work with signed integers, but it wasn't built or tested with that use case in mind.
|
63
|
+
|
64
|
+
|
65
|
+
Q. What is a tweak?
|
66
|
+
|
67
|
+
A. It's kind of like a salt. The [initial FFX spec][2] has a good description.
|
68
|
+
|
69
|
+
|
70
|
+
## Alternatives
|
71
|
+
|
72
|
+
Encoding:
|
73
|
+
- "Mortonizing" / Z-Order Curve
|
74
|
+
|
75
|
+
Encryption:
|
76
|
+
- [BPS][3] (PDF)
|
77
|
+
- [Hasty Pudding cipher][4]
|
78
|
+
|
79
|
+
## Warning
|
80
|
+
|
81
|
+
The AES-FFX implementation is experimental. It was cooked up for this proof of concept.
|
82
|
+
|
83
|
+
The tests included are based on the NIST reference vectors, but the published vectors only cover radix 10 and 36.
|
84
|
+
|
85
|
+
Additionally, FFX is still a DRAFT specification. Thus, it cannot yet be considered cryptographically secure.
|
86
|
+
|
87
|
+
Don't use this for anything beyond basic obfuscation.
|
88
|
+
|
89
|
+
|
90
|
+
## Known Issues
|
91
|
+
|
92
|
+
- Assumes little-endian.
|
93
|
+
- Assumes 64-bit capable.
|
94
|
+
|
95
|
+
|
96
|
+
## Author
|
97
|
+
|
98
|
+
- J. Brandt Buckley <brandt@runlevel1.com>
|
99
|
+
|
100
|
+
|
101
|
+
[1]: https://en.wikipedia.org/wiki/Format-preserving_encryption
|
102
|
+
[2]: http://csrc.nist.gov/groups/ST/toolkit/BCM/documents/proposedmodes/ffx/ffx-spec.pdf
|
103
|
+
[3]: http://csrc.nist.gov/groups/ST/toolkit/BCM/documents/proposedmodes/bps/bps-spec.pdf
|
104
|
+
[4]: https://en.wikipedia.org/wiki/Hasty_Pudding_cipher
|
data/Rakefile
ADDED
data/bin/console
ADDED
@@ -0,0 +1,14 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
require "bundler/setup"
|
4
|
+
require "ffxcodec"
|
5
|
+
|
6
|
+
# You can add fixtures and/or initialization code here to make experimenting
|
7
|
+
# with your gem easier. You can also use a different console, if you like.
|
8
|
+
|
9
|
+
# (If you use this, don't forget to add pry to your Gemfile!)
|
10
|
+
# require "pry"
|
11
|
+
# Pry.start
|
12
|
+
|
13
|
+
require "irb"
|
14
|
+
IRB.start
|
data/bin/setup
ADDED
data/exe/ffxcodec
ADDED
@@ -0,0 +1,88 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
require "optparse"
|
4
|
+
require "ffxcodec"
|
5
|
+
|
6
|
+
examples = <<-EOF
|
7
|
+
Examples:
|
8
|
+
|
9
|
+
- Encode:
|
10
|
+
ffxcodec --alloc=40:24 1234567890 4
|
11
|
+
|
12
|
+
- Decode:
|
13
|
+
ffxcodec --alloc=40:24 20712612157194244
|
14
|
+
|
15
|
+
- Encrypted encode:
|
16
|
+
ffxcodec --alloc=40:24 --key=2b7e151628aed2a6abf7158809cf4f3c --tweak=FZNT4F22E5QA5QUM 1234567890 4
|
17
|
+
|
18
|
+
- Encrypted decode:
|
19
|
+
ffxcodec --alloc=40:24 --key=2b7e151628aed2a6abf7158809cf4f3c --tweak=FZNT4F22E5QA5QUM 5539580373534012574
|
20
|
+
EOF
|
21
|
+
|
22
|
+
options = {}
|
23
|
+
OptionParser.new do |opts|
|
24
|
+
opts.banner = "Usage: ffxcodec [options] [args]"
|
25
|
+
|
26
|
+
opts.on("-aLEFT:RIGHT", "--alloc LEFT:RIGHT", "Bit allocation - must add up to 32/64 (REQUIRED)") do |b|
|
27
|
+
left, right = b.split(":").map(&:to_i)
|
28
|
+
options[:l_alloc] = left
|
29
|
+
options[:r_alloc] = right
|
30
|
+
end
|
31
|
+
|
32
|
+
opts.on("-kKEY", "--key KEY", "Hex key to encrypt with (use with --tweak)") do |k|
|
33
|
+
options[:key] = k
|
34
|
+
end
|
35
|
+
|
36
|
+
opts.on("-tTWEAK", "--tweak TWEAK", "Tweak to encrypt with (use with --key)") do |t|
|
37
|
+
options[:tweak] = t
|
38
|
+
end
|
39
|
+
|
40
|
+
opts.on("-m", "--maximums", "Show max integers for the given allocation and exit") do
|
41
|
+
options[:maximum] = true
|
42
|
+
end
|
43
|
+
|
44
|
+
opts.on_tail("-h", "--help", "Show this message") do
|
45
|
+
puts opts
|
46
|
+
puts ""
|
47
|
+
puts examples
|
48
|
+
exit
|
49
|
+
end
|
50
|
+
end.parse!
|
51
|
+
|
52
|
+
if options[:l_alloc].nil? || options[:r_alloc].nil?
|
53
|
+
warn "Must provide left and right bit allocation like: --alloc=40:24"
|
54
|
+
exit 2
|
55
|
+
end
|
56
|
+
|
57
|
+
ffx = FFXCodec.new(options[:l_alloc], options[:r_alloc])
|
58
|
+
|
59
|
+
if options[:maximum]
|
60
|
+
l_max, r_max = ffx.maximums
|
61
|
+
puts "Left max: #{l_max}"
|
62
|
+
puts "Right max: #{r_max}"
|
63
|
+
exit
|
64
|
+
end
|
65
|
+
|
66
|
+
if options[:key] || options[:tweak]
|
67
|
+
if [options[:key], options[:tweak]].any?(&:nil?)
|
68
|
+
warn "Must use --key with --tweak to enable encryption"
|
69
|
+
exit 2
|
70
|
+
end
|
71
|
+
ffx.setup_encryption(options[:key], options[:tweak])
|
72
|
+
end
|
73
|
+
|
74
|
+
case ARGV.count
|
75
|
+
when 1
|
76
|
+
encoded = ARGV[0].to_i
|
77
|
+
userid, listid = ffx.decode(encoded)
|
78
|
+
puts "User ID: #{userid}"
|
79
|
+
puts "List ID: #{listid}"
|
80
|
+
when 2
|
81
|
+
userid = ARGV[0].to_i
|
82
|
+
listid = ARGV[1].to_i
|
83
|
+
encoded = ffx.encode(userid, listid)
|
84
|
+
puts "Encoded: #{encoded}"
|
85
|
+
else
|
86
|
+
warn "Wrong number of values provided to encode or decode."
|
87
|
+
exit 2
|
88
|
+
end
|
data/exe/ffxcodec-demo
ADDED
@@ -0,0 +1,35 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
require "ffxcodec"
|
4
|
+
require "pp"
|
5
|
+
|
6
|
+
a_val = 1234567890
|
7
|
+
b_val = 4
|
8
|
+
a_size = 40
|
9
|
+
b_size = 24
|
10
|
+
key = "2b7e151628aed2a6abf7158809cf4f3c"
|
11
|
+
tweak = "9876543210"
|
12
|
+
|
13
|
+
ffx = FFXCodec.new(a_size, b_size) # divide 64-bit int into a 40 and 24-bit int
|
14
|
+
|
15
|
+
unencrypted_encoded = ffx.encode(a_val, b_val)
|
16
|
+
unencrypted_decoded = ffx.decode(unencrypted_encoded)
|
17
|
+
|
18
|
+
ffx.setup_encryption(key, tweak)
|
19
|
+
|
20
|
+
encrypted_encoded = ffx.encode(a_val, b_val)
|
21
|
+
encrypted_decoded = ffx.decode(encrypted_encoded)
|
22
|
+
|
23
|
+
puts "Input A: #{a_val}"
|
24
|
+
puts "Input B: #{b_val}"
|
25
|
+
puts "Input A bit allocation: #{a_size}"
|
26
|
+
puts "Input B bit allocation: #{b_size}"
|
27
|
+
puts "Encoded Size (Type): #{ffx.size} bytes (#{ffx.bit_length}-bit unsigned integer)"
|
28
|
+
puts ""
|
29
|
+
puts "Without Encryption:"
|
30
|
+
puts "- Encoded: #{unencrypted_encoded}"
|
31
|
+
puts "- Decoded: #{unencrypted_decoded.inspect}"
|
32
|
+
puts ""
|
33
|
+
puts "With Encryption: (Key: #{key}, Tweak: #{tweak})"
|
34
|
+
puts "- Encoded: #{encrypted_encoded}"
|
35
|
+
puts "- Decoded: #{encrypted_decoded.inspect}"
|
data/ffxcodec.gemspec
ADDED
@@ -0,0 +1,25 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
lib = File.expand_path('../lib', __FILE__)
|
3
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
4
|
+
require 'ffxcodec/version'
|
5
|
+
|
6
|
+
Gem::Specification.new do |spec|
|
7
|
+
spec.name = 'ffxcodec'
|
8
|
+
spec.version = FFXCodec::VERSION
|
9
|
+
spec.authors = ['J. Brandt Buckley']
|
10
|
+
spec.email = ['brandt@runlevel1.com']
|
11
|
+
spec.license = 'BSD-2-Clause'
|
12
|
+
|
13
|
+
spec.summary = 'Encodes two integers into one with optional encryption'
|
14
|
+
spec.description = 'Encodes two unsigned integers into a single, larger (32 or 64-bit) integer with optional AES-FFX encryption.'
|
15
|
+
spec.homepage = 'https://github.com/brandt/ffxcodec'
|
16
|
+
|
17
|
+
spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
|
18
|
+
spec.bindir = "exe"
|
19
|
+
spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
|
20
|
+
spec.require_paths = ["lib"]
|
21
|
+
|
22
|
+
spec.add_development_dependency "bundler", "~> 1.8"
|
23
|
+
spec.add_development_dependency "rake", "~> 10.0"
|
24
|
+
spec.add_development_dependency "minitest"
|
25
|
+
end
|
@@ -0,0 +1,40 @@
|
|
1
|
+
class String
|
2
|
+
# XOR operation on String
|
3
|
+
#
|
4
|
+
# @param [String] other string to XOR with
|
5
|
+
# @raises [ArgumentError] if other string isn't the same length
|
6
|
+
# @return [String] result of XOR operation
|
7
|
+
# rubocop:disable AbcSize
|
8
|
+
def ^(other)
|
9
|
+
# rubocop:disable RedundantSelf, SignalException
|
10
|
+
b1 = self.unpack("C*")
|
11
|
+
b2 = other.unpack("C*")
|
12
|
+
raise ArgumentError, "Strings must be the same length" unless b1.size == b2.size
|
13
|
+
longest = [b1.length, b2.length].max
|
14
|
+
b1 = [0] * (longest - b1.length) + b1
|
15
|
+
b2 = [0] * (longest - b2.length) + b2
|
16
|
+
b1.zip(b2).map { |a, b| a ^ b }.pack("C*")
|
17
|
+
end
|
18
|
+
|
19
|
+
# Split down the middle into two parts (right-biased)
|
20
|
+
#
|
21
|
+
# @return [Array<String>] the original string split into two. If length was
|
22
|
+
# odd, then the second string will have an extra character.
|
23
|
+
def bisect
|
24
|
+
n = self.size
|
25
|
+
l = n / 2
|
26
|
+
[self[0...l], self[l...n]]
|
27
|
+
end
|
28
|
+
|
29
|
+
# Prepend zeroes until string is of the given length
|
30
|
+
#
|
31
|
+
# @note if string was already longer than the given length, no action taken
|
32
|
+
#
|
33
|
+
# @param [Integer] length we want the resulting string to be
|
34
|
+
# @return [String] prepended with '0's until the given length is reached
|
35
|
+
def prepad_zeros(length)
|
36
|
+
str = self
|
37
|
+
str.insert(0, '0') while str.length < length
|
38
|
+
str
|
39
|
+
end
|
40
|
+
end
|
@@ -0,0 +1,120 @@
|
|
1
|
+
class FFXCodec
|
2
|
+
# Encode two integers into one larger integer and decode back
|
3
|
+
class Encoder
|
4
|
+
# @return [Fixnum] size of encoded integer in bits (32 or 64)
|
5
|
+
attr_reader :size
|
6
|
+
|
7
|
+
# @return [Fixnum] maximum unsigned value representable by left integer
|
8
|
+
attr_reader :a_max
|
9
|
+
|
10
|
+
# @return [Fixnum] maximum unsigned value representable by right integer
|
11
|
+
attr_reader :b_max
|
12
|
+
|
13
|
+
# @param [Fixnum] a_size the number of bits allocated to the left integer
|
14
|
+
# @param [Fixnum] b_size the number of bits allocated to the right integer
|
15
|
+
def initialize(a_size = 32, b_size = 32)
|
16
|
+
@a_size = a_size
|
17
|
+
@b_size = b_size
|
18
|
+
@a_max, @b_max = maximums(a_size, b_size)
|
19
|
+
@size = a_size + b_size
|
20
|
+
check_size
|
21
|
+
end
|
22
|
+
|
23
|
+
# Combine two unsigned integers into a single, larger unsigned integer
|
24
|
+
#
|
25
|
+
# @param [Fixnum] a value to encode
|
26
|
+
# @param [Fixnum] b value to encode
|
27
|
+
#
|
28
|
+
# @example Encode 40 and 24-bit integers into a single 64-bit integer
|
29
|
+
# i = Encoder.new(40, 24)
|
30
|
+
# i.encode(1234567890, 4) #=> 20712612157194244
|
31
|
+
#
|
32
|
+
# @return [Fixnum, Bignum] encoded integer
|
33
|
+
def encode(a, b)
|
34
|
+
check_ab_bounds(a, b)
|
35
|
+
i = (a << @b_size) ^ b
|
36
|
+
interlace(i)
|
37
|
+
end
|
38
|
+
|
39
|
+
# Separate an unsigned integer into two smaller unsigned integers
|
40
|
+
#
|
41
|
+
# @example Decode an encoded 64-bit integer into 40 and 24-bit integers
|
42
|
+
# i = Encoder.new(40, 24)
|
43
|
+
# i.decode(20712612157194244) #=> [1234567890, 4]
|
44
|
+
#
|
45
|
+
# @param [Fixnum, Bignum] c encoded value to decode
|
46
|
+
# @return [Array<Fixnum>] decoded integers
|
47
|
+
def decode(c)
|
48
|
+
i = interlace(c)
|
49
|
+
a = i >> @b_size
|
50
|
+
b = (i ^ (a << @b_size))
|
51
|
+
[a, b]
|
52
|
+
end
|
53
|
+
|
54
|
+
private
|
55
|
+
|
56
|
+
# Interlace / deinterlace bytes
|
57
|
+
#
|
58
|
+
# Running this on a number toggles interlacing (a reorder of the bits).
|
59
|
+
#
|
60
|
+
# This reorders the bytes in an integer. We do this to avoid a possible
|
61
|
+
# reduction in the effectiveness of our encryption resulting from the fact
|
62
|
+
# that we encode input by concatenating the bits of two potentially equally
|
63
|
+
# sized integers and the way a maximally-balanced Feistel splits the input
|
64
|
+
# in half.
|
65
|
+
#
|
66
|
+
# Technically we only need to do this when encryption is enabled and the
|
67
|
+
# integer is evenly divided. However, this imparts almost no performance
|
68
|
+
# penalty. Ruby can perform ~1 million of these operations in 2 sec for
|
69
|
+
# random 64-bit values and 0.5 sec for random 32-bit values.
|
70
|
+
#
|
71
|
+
# @param [Fixnum, Bignum] n interlaced or uninterlaced value
|
72
|
+
# @return [Fixnum, Bignum] interlaced value if input wasn't interlaced
|
73
|
+
# @return [Fixnum, Bignum] deinterlaced value if input was interlaced
|
74
|
+
# rubocop:disable AbcSize
|
75
|
+
def interlace(n)
|
76
|
+
# rubocop:disable SpaceAroundOperators, MultilineOperationIndentation
|
77
|
+
if @size == 32
|
78
|
+
n & 0xFF0000FF | # 0, 3
|
79
|
+
(n >> 8) & 0x0000FF00 | # 1
|
80
|
+
(n << 8) & 0x00FF0000 # 2
|
81
|
+
else
|
82
|
+
n & 0xFF00FF0000FF00FF | # 0, 2, 5, 7
|
83
|
+
(n >> 40) & 0x000000000000FF00 | # 1
|
84
|
+
(n >> 8) & 0x00000000FF000000 | # 3
|
85
|
+
(n << 8) & 0x000000FF00000000 | # 4
|
86
|
+
(n << 40) & 0x00FF000000000000 # 6
|
87
|
+
end
|
88
|
+
end
|
89
|
+
|
90
|
+
# Calculate the maximum values representable in the given number of bits
|
91
|
+
#
|
92
|
+
# @param [Fixnum] a_size number of bits allocated to left integer
|
93
|
+
# @param [Fixnum] b_size number of bits allocated to right integer
|
94
|
+
# @return [Array<Fixnum>] maximum representable values for each integer
|
95
|
+
def maximums(a_size, b_size)
|
96
|
+
a_max = (1 << a_size) - 1
|
97
|
+
b_max = (1 << b_size) - 1
|
98
|
+
[a_max, b_max]
|
99
|
+
end
|
100
|
+
|
101
|
+
# @param [Fixnum] a left integer to be encoded
|
102
|
+
# @param [Fixnum] b right integer to be encoded
|
103
|
+
# @raise [ArgumentError] if the given values fall outside our maximums
|
104
|
+
# @return [void]
|
105
|
+
def check_ab_bounds(a, b)
|
106
|
+
if a > @a_max || a < 0
|
107
|
+
fail ArgumentError, "LHS #{@a_size}-bit value out of bounds: #{a}"
|
108
|
+
elsif b > @b_max || b < 0
|
109
|
+
fail ArgumentError, "RHS #{@b_size}-bit value out of bounds: #{b}"
|
110
|
+
end
|
111
|
+
end
|
112
|
+
|
113
|
+
# @raise [ArgumentError] if the combined bit count isn't 32 or 64 bits
|
114
|
+
# @return [void]
|
115
|
+
def check_size
|
116
|
+
return if @size == 32 || @size == 64
|
117
|
+
fail ArgumentError, "Combined size must be 32 or 64 bits"
|
118
|
+
end
|
119
|
+
end
|
120
|
+
end
|
@@ -0,0 +1,223 @@
|
|
1
|
+
require "openssl"
|
2
|
+
|
3
|
+
class FFXCodec
|
4
|
+
# Implementation of AES-FFX mode format-preserving encryption
|
5
|
+
#
|
6
|
+
# Cipher device encrypts integers where the resulting ciphertext has the same
|
7
|
+
# number of digits in the given base (radix).
|
8
|
+
#
|
9
|
+
# @note WARNING: This was cooked up as an experimental proof of concept.
|
10
|
+
# It hasn't been tested thoroughly and shouldn't be considered secure.
|
11
|
+
#
|
12
|
+
# @note Format-preserving != integer-size-preserving in base 10 (see below)
|
13
|
+
#
|
14
|
+
# The format-preserving characteristic of this cipher is best thought of as
|
15
|
+
# preserving the number of digits, not the integer size. For instance, in
|
16
|
+
# base 10, 4294967295 and 4294967296 would be considered to have the same
|
17
|
+
# format, but the first is a 32-bit unsigned integer and the second is 64.
|
18
|
+
#
|
19
|
+
# So given base 10 input that fits within a 32 or 64-bit integer, it's
|
20
|
+
# possible for the AES-FFX cipher to return a number that contains the same
|
21
|
+
# number of base 10 digits but exceeds the largest number that can be
|
22
|
+
# represented in 32 or 64 bits respectively.
|
23
|
+
#
|
24
|
+
# You can work around this by using radix 2 so that the cipher returns an
|
25
|
+
# equal number of bits. As with all modes, you must supply input as a
|
26
|
+
# stringified integer in the base you've specified.
|
27
|
+
#
|
28
|
+
# Be aware that when you convert between bases, leading zeros are sometimes
|
29
|
+
# dropped by the converter. You must supply the same number of digits to the
|
30
|
+
# decrypter as you did to the encrypter or you'll get a different value. The
|
31
|
+
# encrypt and decrypt methods prepend zeros until the input is is of the
|
32
|
+
# length specified during initialization.
|
33
|
+
#
|
34
|
+
class Encrypt
|
35
|
+
# @param [Fixnum] length of input
|
36
|
+
attr_accessor :length
|
37
|
+
|
38
|
+
# @note This is set to 10 by the spec. Don't change it unless you know
|
39
|
+
# what you're doing.
|
40
|
+
# @param [Fixnum] rounds of encryption / decryption to run input through
|
41
|
+
attr_accessor :rounds
|
42
|
+
|
43
|
+
# @return [Fixnum] radix of the input
|
44
|
+
attr_reader :radix
|
45
|
+
|
46
|
+
# @param [String] key for AES as a hexadecimal string
|
47
|
+
# @param [String] tweak for AES
|
48
|
+
# @param [Fixnum] length of the input
|
49
|
+
# @param [Fixnum] radix of the input
|
50
|
+
def initialize(key, tweak, length, radix = 10)
|
51
|
+
self.key = key
|
52
|
+
self.tweak = tweak
|
53
|
+
self.radix = radix
|
54
|
+
@length = length
|
55
|
+
@rounds = 10
|
56
|
+
end
|
57
|
+
|
58
|
+
# @param [String] key for AES as a hexadecimal string
|
59
|
+
def key=(key)
|
60
|
+
hexkey = [key].pack('H*')
|
61
|
+
fail ArgumentError, "key must be a 16-byte hexidecimal" if hexkey.length != 16
|
62
|
+
@key = hexkey
|
63
|
+
end
|
64
|
+
|
65
|
+
# @param [String] tweak tweak for AES
|
66
|
+
def tweak=(tweak)
|
67
|
+
fail ArgumentError, "tweak length must be under (2^32) - 1" if tweak.length > ((1 << 32) - 1)
|
68
|
+
@tweak = tweak
|
69
|
+
end
|
70
|
+
|
71
|
+
# @param [Fixnum] num radix of the input
|
72
|
+
def radix=(num)
|
73
|
+
fail ArgumentError, "radix must be between 2 and 2^16" if num > 65536
|
74
|
+
@radix = num
|
75
|
+
end
|
76
|
+
|
77
|
+
# Encrypt
|
78
|
+
#
|
79
|
+
# @param [String] input unencrypted, stringifed integer of base @radix
|
80
|
+
#
|
81
|
+
# @example Encrypt
|
82
|
+
# e = Encrypt.new("4fb450a9c27dd07f22ef56413432c94a", "FZNT4F22E5QA5QUM")
|
83
|
+
# e.encrypt(1234567890) #=> "1224011974"
|
84
|
+
#
|
85
|
+
# @return [Fixnum, Bignum] encrypted integer
|
86
|
+
def encrypt(input)
|
87
|
+
a, b = input.prepad_zeros(@length).bisect
|
88
|
+
0.upto(@rounds - 1) do |iter|
|
89
|
+
f = feistel_round(input.size, iter, b)
|
90
|
+
c = block_addition(a, f)
|
91
|
+
a = b
|
92
|
+
b = c
|
93
|
+
end
|
94
|
+
a + b
|
95
|
+
end
|
96
|
+
|
97
|
+
# Decrypt
|
98
|
+
#
|
99
|
+
# @param [String] input encrypted, stringifed integer of base @radix
|
100
|
+
#
|
101
|
+
# @example Decrypt
|
102
|
+
# e = Encrypt.new("4fb450a9c27dd07f22ef56413432c94a", "FZNT4F22E5QA5QUM")
|
103
|
+
# e.decrypt(1224011974) #=> "1234567890"
|
104
|
+
#
|
105
|
+
# @return [Fixnum, Bignum] unencrypted integer
|
106
|
+
def decrypt(input)
|
107
|
+
a, b = input.prepad_zeros(@length).bisect
|
108
|
+
(@rounds - 1).downto(0) do |iter|
|
109
|
+
c = b
|
110
|
+
b = a
|
111
|
+
f = feistel_round(input.size, iter, b)
|
112
|
+
lmin = [c.size, f.size].min
|
113
|
+
a = block_subtraction(lmin, c, f)
|
114
|
+
end
|
115
|
+
a + b
|
116
|
+
end
|
117
|
+
|
118
|
+
private
|
119
|
+
|
120
|
+
# Computes the block-wise radix addition of x and y
|
121
|
+
def block_addition(a, b)
|
122
|
+
sum = a.to_i(@radix) + b.to_i(@radix)
|
123
|
+
sum %= (@radix**a.size)
|
124
|
+
sum.to_s(@radix).prepad_zeros(a.size)
|
125
|
+
end
|
126
|
+
|
127
|
+
# Computes the block-wise radix subtraction of x and y
|
128
|
+
def block_subtraction(n, x, y)
|
129
|
+
diff = x.to_i(@radix) - y.to_i(@radix)
|
130
|
+
mod = @radix**n
|
131
|
+
block_diff = diff % mod
|
132
|
+
block_diff += mod if block_diff < 0
|
133
|
+
out = block_diff.to_s(@radix)
|
134
|
+
return out unless out.length < n
|
135
|
+
out.prepad_zeros(n)
|
136
|
+
end
|
137
|
+
|
138
|
+
def num_radix(str, length)
|
139
|
+
n = str.to_i(@radix)
|
140
|
+
n_bitcount = ('0' * (length * 8)) + n.to_s(2)
|
141
|
+
n_bitcount = n_bitcount[-(length * 8)..-1]
|
142
|
+
[n_bitcount].pack('B*')
|
143
|
+
end
|
144
|
+
|
145
|
+
def aes(block)
|
146
|
+
aes = OpenSSL::Cipher::Cipher.new('aes-128-ecb')
|
147
|
+
aes.encrypt
|
148
|
+
aes.key = @key
|
149
|
+
aes.update(block)
|
150
|
+
end
|
151
|
+
|
152
|
+
def cbc_mac(block)
|
153
|
+
fail "invalid block size" unless (block.size % 16 == 0)
|
154
|
+
y = "\0" * 16
|
155
|
+
i = 0
|
156
|
+
while i < block.size
|
157
|
+
x = block[i...(i + 16)]
|
158
|
+
y = aes(x ^ y)
|
159
|
+
i += 16
|
160
|
+
end
|
161
|
+
y
|
162
|
+
end
|
163
|
+
|
164
|
+
def byte_array_to_int(block)
|
165
|
+
block.bytes.inject(0) { |memo, b| (memo << 8) + b }
|
166
|
+
end
|
167
|
+
|
168
|
+
# Creates the first half of the IV
|
169
|
+
#
|
170
|
+
# Concatenated with Q in the feistel round.
|
171
|
+
#
|
172
|
+
# p <- [vers] | [method] | [addition] | [radix] | [rnds(n)] | [split(n)] | [n] | [t]
|
173
|
+
def generate_p(input_len)
|
174
|
+
vers = 1
|
175
|
+
method = 2
|
176
|
+
addition = 1
|
177
|
+
split_n = input_len / 2
|
178
|
+
[vers, method, addition].pack('CCC') +
|
179
|
+
[@radix].pack('N')[1..3] +
|
180
|
+
[@rounds].pack('C') +
|
181
|
+
[split_n].pack('C') +
|
182
|
+
[input_len].pack('N') +
|
183
|
+
[@tweak.length].pack('N')
|
184
|
+
end
|
185
|
+
|
186
|
+
# Creates the second half of the IV
|
187
|
+
#
|
188
|
+
# Concatenated with P in the feistel round.
|
189
|
+
#
|
190
|
+
# q <- tweak | [0]^((-t-b-1) mod 16) | [roundNum] | [numradix(B)]
|
191
|
+
def generate_q(b, blk_len, round)
|
192
|
+
round_num = [round].pack('C')
|
193
|
+
@tweak + "\0" * ((-@tweak.size - blk_len - 1) % 16) + round_num + num_radix(b, blk_len)
|
194
|
+
end
|
195
|
+
|
196
|
+
# Y <- first d+4 bytes of (Y | AESK(Y XOR [1]16) | AESK(Y XOR [2]16) | AESK(Y XOR [3]16)...)
|
197
|
+
def generate_y(blk_len, iv_p, iv_q)
|
198
|
+
d = 4 * (blk_len / 4.0).ceil
|
199
|
+
y = cbc_mac(iv_p + iv_q)
|
200
|
+
byte_array_to_int(y[0...(d + 4)])
|
201
|
+
end
|
202
|
+
|
203
|
+
# b <- ceil(ceil(beta * log_2(radix)) / 8)
|
204
|
+
def block_length(input_len)
|
205
|
+
beta = (input_len / 2.0).ceil
|
206
|
+
((beta * Math.log(@radix) / Math.log(2)).ceil / 8.0).ceil
|
207
|
+
end
|
208
|
+
|
209
|
+
# Runs the given block through the modified feistel network
|
210
|
+
def feistel_round(input_len, iter, b)
|
211
|
+
blk_len = block_length(input_len)
|
212
|
+
iv_p = generate_p(input_len)
|
213
|
+
iv_q = generate_q(b, blk_len, iter)
|
214
|
+
|
215
|
+
# z = y mod r^m
|
216
|
+
y = generate_y(blk_len, iv_p, iv_q)
|
217
|
+
m = (iter % 2).zero? ? (input_len / 2) : (input_len / 2.0).ceil
|
218
|
+
z = y % (@radix**m)
|
219
|
+
|
220
|
+
z.to_s(@radix).prepad_zeros(m)
|
221
|
+
end
|
222
|
+
end
|
223
|
+
end
|
data/lib/ffxcodec.rb
ADDED
@@ -0,0 +1,119 @@
|
|
1
|
+
require "ffxcodec/version"
|
2
|
+
require "ffxcodec/core_ext/string"
|
3
|
+
require "ffxcodec/encrypt"
|
4
|
+
require "ffxcodec/encoder"
|
5
|
+
|
6
|
+
# Encode / decode two integers into a single integer with optional encryption
|
7
|
+
#
|
8
|
+
# The resulting value is a single 32 or 64-bit unsigned integer (your choice),
|
9
|
+
# even when encrypted.
|
10
|
+
#
|
11
|
+
# Works by divvying up the bits of the single integer between the two component
|
12
|
+
# integers and running it through AES-FFX format-preserving cipher (optional).
|
13
|
+
class FFXCodec
|
14
|
+
# @param [Fixnum] a_size the number of bits allocated to the left integer
|
15
|
+
# @param [Fixnum] b_size the number of bits allocated to the right integer
|
16
|
+
def initialize(a_size, b_size)
|
17
|
+
@encoder = Encoder.new(a_size, b_size)
|
18
|
+
end
|
19
|
+
|
20
|
+
# Setup encryption
|
21
|
+
#
|
22
|
+
# Auto-enables encryption after encoding and decryption before decoding.
|
23
|
+
#
|
24
|
+
# @param [String] key for AES as a hexadecimal string
|
25
|
+
# @param [String] tweak for AES
|
26
|
+
# @return [void]
|
27
|
+
def setup_encryption(key, tweak)
|
28
|
+
@crypto = Encrypt.new(key, tweak, @encoder.size, 2)
|
29
|
+
end
|
30
|
+
|
31
|
+
# Turn off encryption
|
32
|
+
#
|
33
|
+
# @return [void]
|
34
|
+
def disable_encryption
|
35
|
+
@crypto = false
|
36
|
+
end
|
37
|
+
|
38
|
+
# Encode two integers into a single integer
|
39
|
+
#
|
40
|
+
# @param [Fixnum] a value to encode
|
41
|
+
# @param [Fixnum] b value to encode
|
42
|
+
#
|
43
|
+
# @example Encode 40 and 24-bit integers into an unencrypted 64-bit integer
|
44
|
+
# ffx = FFXCodec.new(40, 24)
|
45
|
+
# ffx.encode(1234567890, 4) #=> 165828720871684
|
46
|
+
#
|
47
|
+
# @example Encode 40 and 24-bit integers into an encrypted 64-bit integer
|
48
|
+
# ffx = FFXCodec.new(40, 24)
|
49
|
+
# ffx.setup_encryption("2b7e151628aed2a6abf7158809cf4f3c", "9876543210")
|
50
|
+
# ffx.encode(797980150281, 5427652) #=> 7692035069140451684
|
51
|
+
#
|
52
|
+
# @return [Fixnum, Bignum] encoded integer if encryption not setup
|
53
|
+
# @return [Fixnum, Bignum] encrypted encoded integer if encryption setup
|
54
|
+
def encode(a, b)
|
55
|
+
c = @encoder.encode(a, b)
|
56
|
+
@crypto ? encrypt(c) : c
|
57
|
+
end
|
58
|
+
|
59
|
+
# Decode an integer into its two component integers
|
60
|
+
#
|
61
|
+
# @note input will automatically be decrypted if encryption was setup
|
62
|
+
# @param [Fixnum, Bignum] c value to decode
|
63
|
+
#
|
64
|
+
# @example Decode unencrypted integer into component 40 and 24-bit integers
|
65
|
+
# ffx = FFXCodec.new(40, 24)
|
66
|
+
# ffx.decode(165828720871684) #=> [1234567890, 4]
|
67
|
+
#
|
68
|
+
# @example Decode encrypted integer into component 40 and 24-bit integers
|
69
|
+
# ffx = FFXCodec.new(40, 24)
|
70
|
+
# ffx.setup_encryption("2b7e151628aed2a6abf7158809cf4f3c", "9876543210")
|
71
|
+
# ffx.decode(7692035069140451684) #=> [797980150281, 5427652]
|
72
|
+
#
|
73
|
+
# @return [Array<Fixnum>] component integers
|
74
|
+
def decode(c)
|
75
|
+
input = @crypto ? decrypt(c) : c
|
76
|
+
@encoder.decode(input)
|
77
|
+
end
|
78
|
+
|
79
|
+
# Show maximum representable base 10 value for each field
|
80
|
+
#
|
81
|
+
# @example Maximums for a 32-bit integer split into 24 and 8-bit components
|
82
|
+
# ffx = FFXCodec.new(24, 8)
|
83
|
+
# ffx.maximums #=> [16777215, 255]
|
84
|
+
#
|
85
|
+
# @example Maximums for a 64-bit integer split into two 32-bit components
|
86
|
+
# ffx = FFXCodec.new(32, 32)
|
87
|
+
# ffx.maximums #=> [4294967295, 4294967295]
|
88
|
+
#
|
89
|
+
# @return [Array<Fixnum>] maximum representable component integers
|
90
|
+
def maximums
|
91
|
+
[@encoder.a_max, @encoder.b_max]
|
92
|
+
end
|
93
|
+
|
94
|
+
# Show size of the resulting integer in bytes
|
95
|
+
#
|
96
|
+
# @return [Fixnum] size of the combined integer in bytes
|
97
|
+
def size
|
98
|
+
@encoder.size / 8
|
99
|
+
end
|
100
|
+
|
101
|
+
# Show size of the resulting integer in bits
|
102
|
+
#
|
103
|
+
# @return [Fixnum] size of the combined integer in bits
|
104
|
+
def bit_length
|
105
|
+
@encoder.size
|
106
|
+
end
|
107
|
+
|
108
|
+
private
|
109
|
+
|
110
|
+
# @param [Fixnum, Bignum] value to encrypt
|
111
|
+
def encrypt(value)
|
112
|
+
@crypto.encrypt(value.to_s(2)).to_i(2)
|
113
|
+
end
|
114
|
+
|
115
|
+
# @param [Fixnum, Bignum] value to decrypt
|
116
|
+
def decrypt(value)
|
117
|
+
@crypto.decrypt(value.to_s(2)).to_i(2)
|
118
|
+
end
|
119
|
+
end
|
metadata
ADDED
@@ -0,0 +1,106 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: ffxcodec
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- J. Brandt Buckley
|
8
|
+
autorequire:
|
9
|
+
bindir: exe
|
10
|
+
cert_chain: []
|
11
|
+
date: 2016-02-26 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: bundler
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - "~>"
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '1.8'
|
20
|
+
type: :development
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - "~>"
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '1.8'
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: rake
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - "~>"
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '10.0'
|
34
|
+
type: :development
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - "~>"
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '10.0'
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: minitest
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - ">="
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: '0'
|
48
|
+
type: :development
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - ">="
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '0'
|
55
|
+
description: Encodes two unsigned integers into a single, larger (32 or 64-bit) integer
|
56
|
+
with optional AES-FFX encryption.
|
57
|
+
email:
|
58
|
+
- brandt@runlevel1.com
|
59
|
+
executables:
|
60
|
+
- ffxcodec
|
61
|
+
- ffxcodec-demo
|
62
|
+
extensions: []
|
63
|
+
extra_rdoc_files: []
|
64
|
+
files:
|
65
|
+
- ".gitignore"
|
66
|
+
- ".travis.yml"
|
67
|
+
- Gemfile
|
68
|
+
- LICENSE
|
69
|
+
- README.md
|
70
|
+
- Rakefile
|
71
|
+
- bin/console
|
72
|
+
- bin/setup
|
73
|
+
- exe/ffxcodec
|
74
|
+
- exe/ffxcodec-demo
|
75
|
+
- ffxcodec.gemspec
|
76
|
+
- lib/ffxcodec.rb
|
77
|
+
- lib/ffxcodec/core_ext/string.rb
|
78
|
+
- lib/ffxcodec/encoder.rb
|
79
|
+
- lib/ffxcodec/encrypt.rb
|
80
|
+
- lib/ffxcodec/version.rb
|
81
|
+
homepage: https://github.com/brandt/ffxcodec
|
82
|
+
licenses:
|
83
|
+
- BSD-2-Clause
|
84
|
+
metadata: {}
|
85
|
+
post_install_message:
|
86
|
+
rdoc_options: []
|
87
|
+
require_paths:
|
88
|
+
- lib
|
89
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
90
|
+
requirements:
|
91
|
+
- - ">="
|
92
|
+
- !ruby/object:Gem::Version
|
93
|
+
version: '0'
|
94
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
95
|
+
requirements:
|
96
|
+
- - ">="
|
97
|
+
- !ruby/object:Gem::Version
|
98
|
+
version: '0'
|
99
|
+
requirements: []
|
100
|
+
rubyforge_project:
|
101
|
+
rubygems_version: 2.4.8
|
102
|
+
signing_key:
|
103
|
+
specification_version: 4
|
104
|
+
summary: Encodes two integers into one with optional encryption
|
105
|
+
test_files: []
|
106
|
+
has_rdoc:
|