cyphera 0.0.1.alpha.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: '092f5a2db55a53433ed97db72c86fb9f77a56dc15be2f96c343c36c06fb07ac2'
4
+ data.tar.gz: 289ca8aa1a02f678cc6ad267a103d0aa5a26c0ead54edce4d28bc8d30dbeb4db
5
+ SHA512:
6
+ metadata.gz: 75c5fe10c28c3d07559c084a9f73e5a2b8276afd700b00ecac5f3c408257ed48989fb16af488fa2e031701b4e30c4d956a9b9657e374c8a72d6db9d2b17ca3af
7
+ data.tar.gz: d5a63dd12e2751ac8741cc90628f2e28da9a4ca0438df0763a4e02d1c210e69b326b59213a14367d237798340c66c78b368eaa7e76ddae2c092acfe01c66be24
@@ -0,0 +1,212 @@
1
+ require 'json'
2
+ require 'openssl'
3
+
4
+ module Cyphera
5
+ ALPHABETS = {
6
+ 'digits' => '0123456789',
7
+ 'alpha_lower' => 'abcdefghijklmnopqrstuvwxyz',
8
+ 'alpha_upper' => 'ABCDEFGHIJKLMNOPQRSTUVWXYZ',
9
+ 'alpha' => 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ',
10
+ 'alphanumeric' => '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'
11
+ }.freeze
12
+
13
+ class Client
14
+ def self.load
15
+ env = ENV['CYPHERA_POLICY_FILE']
16
+ return from_file(env) if env && File.exist?(env)
17
+ return from_file('cyphera.json') if File.exist?('cyphera.json')
18
+ return from_file('/etc/cyphera/cyphera.json') if File.exist?('/etc/cyphera/cyphera.json')
19
+ raise 'No policy file found. Checked: CYPHERA_POLICY_FILE env, ./cyphera.json, /etc/cyphera/cyphera.json'
20
+ end
21
+
22
+ def self.from_file(path)
23
+ config = JSON.parse(File.read(path))
24
+ from_config(config)
25
+ end
26
+
27
+ def self.from_config(config)
28
+ new(config)
29
+ end
30
+
31
+ def protect(value, policy_name)
32
+ policy = get_policy(policy_name)
33
+ case policy['engine']
34
+ when 'ff1' then protect_fpe(value, policy, false)
35
+ when 'ff3' then protect_fpe(value, policy, true)
36
+ when 'mask' then protect_mask(value, policy)
37
+ when 'hash' then protect_hash(value, policy)
38
+ else raise ArgumentError, "Unknown engine: #{policy['engine']}"
39
+ end
40
+ end
41
+
42
+ def access(protected_value, policy_name = nil)
43
+ if policy_name
44
+ policy = get_policy(policy_name)
45
+ return access_fpe(protected_value, policy)
46
+ end
47
+
48
+ @tag_index.keys.sort_by { |t| -t.length }.each do |tag|
49
+ if protected_value.length > tag.length && protected_value.start_with?(tag)
50
+ policy = get_policy(@tag_index[tag])
51
+ return access_fpe(protected_value, policy)
52
+ end
53
+ end
54
+
55
+ raise ArgumentError, 'No matching tag found. Use access(value, policy_name) for untagged values.'
56
+ end
57
+
58
+ private
59
+
60
+ def initialize(config)
61
+ @policies = {}
62
+ @tag_index = {}
63
+ @keys = {}
64
+
65
+ (config['keys'] || {}).each do |name, val|
66
+ material = val.is_a?(String) ? val : val['material']
67
+ @keys[name] = [material].pack('H*')
68
+ end
69
+
70
+ (config['policies'] || {}).each do |name, pol|
71
+ tag_enabled = pol.fetch('tag_enabled', true)
72
+ tag = pol['tag']
73
+
74
+ if tag_enabled && (tag.nil? || tag.empty?)
75
+ raise ArgumentError, "Policy '#{name}' has tag_enabled=true but no tag specified"
76
+ end
77
+
78
+ if tag_enabled && tag
79
+ if @tag_index.key?(tag)
80
+ raise ArgumentError, "Tag collision: '#{tag}' used by both '#{@tag_index[tag]}' and '#{name}'"
81
+ end
82
+ @tag_index[tag] = name
83
+ end
84
+
85
+ @policies[name] = {
86
+ 'engine' => pol.fetch('engine', 'ff1'),
87
+ 'alphabet' => resolve_alphabet(pol['alphabet']),
88
+ 'key_ref' => pol['key_ref'],
89
+ 'tag' => tag,
90
+ 'tag_enabled' => tag_enabled,
91
+ 'pattern' => pol['pattern'],
92
+ 'algorithm' => pol.fetch('algorithm', 'sha256')
93
+ }
94
+ end
95
+ end
96
+
97
+ def get_policy(name)
98
+ @policies.fetch(name) { raise ArgumentError, "Unknown policy: #{name}" }
99
+ end
100
+
101
+ def resolve_key(key_ref)
102
+ raise ArgumentError, 'No key_ref in policy' if key_ref.nil? || key_ref.empty?
103
+ @keys.fetch(key_ref) { raise ArgumentError, "Unknown key: #{key_ref}" }
104
+ end
105
+
106
+ def resolve_alphabet(name)
107
+ return ALPHABETS['alphanumeric'] if name.nil? || name.empty?
108
+ ALPHABETS[name] || name
109
+ end
110
+
111
+ def protect_fpe(value, policy, is_ff3)
112
+ key = resolve_key(policy['key_ref'])
113
+ alphabet = policy['alphabet']
114
+ encryptable, positions, chars = extract_passthroughs(value, alphabet)
115
+ raise ArgumentError, 'No encryptable characters in input' if encryptable.empty?
116
+
117
+ encrypted = if is_ff3
118
+ FF3.new(key, "\x00" * 8, alphabet).encrypt(encryptable)
119
+ else
120
+ FF1.new(key, '', alphabet).encrypt(encryptable)
121
+ end
122
+
123
+ result = reinsert_passthroughs(encrypted, positions, chars)
124
+ if policy['tag_enabled'] && policy['tag']
125
+ policy['tag'] + result
126
+ else
127
+ result
128
+ end
129
+ end
130
+
131
+ def access_fpe(protected_value, policy)
132
+ unless %w[ff1 ff3].include?(policy['engine'])
133
+ raise ArgumentError, "Cannot reverse '#{policy['engine']}' — not reversible"
134
+ end
135
+
136
+ key = resolve_key(policy['key_ref'])
137
+ alphabet = policy['alphabet']
138
+
139
+ without_tag = protected_value
140
+ if policy['tag_enabled'] && policy['tag']
141
+ without_tag = protected_value[policy['tag'].length..]
142
+ end
143
+
144
+ encryptable, positions, chars = extract_passthroughs(without_tag, alphabet)
145
+
146
+ decrypted = if policy['engine'] == 'ff3'
147
+ FF3.new(key, "\x00" * 8, alphabet).decrypt(encryptable)
148
+ else
149
+ FF1.new(key, '', alphabet).decrypt(encryptable)
150
+ end
151
+
152
+ reinsert_passthroughs(decrypted, positions, chars)
153
+ end
154
+
155
+ def protect_mask(value, policy)
156
+ pattern = policy['pattern']
157
+ raise ArgumentError, "Mask policy requires 'pattern'" if pattern.nil? || pattern.empty?
158
+ len = value.length
159
+ case pattern
160
+ when 'last4', 'last_4' then ('*' * [0, len - 4].max) + value[[0, len - 4].max..]
161
+ when 'last2', 'last_2' then ('*' * [0, len - 2].max) + value[[0, len - 2].max..]
162
+ when 'first1', 'first_1' then value[0, [1, len].min] + ('*' * [0, len - 1].max)
163
+ when 'first3', 'first_3' then value[0, [3, len].min] + ('*' * [0, len - 3].max)
164
+ else '*' * len
165
+ end
166
+ end
167
+
168
+ def protect_hash(value, policy)
169
+ algo = policy['algorithm'].downcase.delete('-')
170
+ digest = case algo
171
+ when 'sha256' then 'SHA256'
172
+ when 'sha384' then 'SHA384'
173
+ when 'sha512' then 'SHA512'
174
+ else raise ArgumentError, "Unsupported hash algorithm: #{policy['algorithm']}"
175
+ end
176
+
177
+ if policy['key_ref'] && !policy['key_ref'].empty?
178
+ key = resolve_key(policy['key_ref'])
179
+ OpenSSL::HMAC.hexdigest(digest, key, value)
180
+ else
181
+ OpenSSL::Digest.hexdigest(digest, value)
182
+ end
183
+ end
184
+
185
+ def extract_passthroughs(value, alphabet)
186
+ encryptable = ''
187
+ positions = []
188
+ chars = []
189
+ value.each_char.with_index do |c, i|
190
+ if alphabet.include?(c)
191
+ encryptable << c
192
+ else
193
+ positions << i
194
+ chars << c
195
+ end
196
+ end
197
+ [encryptable, positions, chars]
198
+ end
199
+
200
+ def reinsert_passthroughs(encrypted, positions, chars)
201
+ result = encrypted.chars
202
+ positions.each_with_index do |pos, i|
203
+ if pos <= result.length
204
+ result.insert(pos, chars[i])
205
+ else
206
+ result << chars[i]
207
+ end
208
+ end
209
+ result.join
210
+ end
211
+ end
212
+ end
@@ -0,0 +1,173 @@
1
+ require 'openssl'
2
+
3
+ module Cyphera
4
+ class FF1
5
+ def initialize(key, tweak, alphabet = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ')
6
+ raise ArgumentError, "Key must be 16, 24, or 32 bytes" unless [16, 24, 32].include?(key.bytesize)
7
+ raise ArgumentError, "Alphabet must have >= 2 characters" if alphabet.length < 2
8
+
9
+ @key = key
10
+ @tweak = tweak
11
+ @alphabet = alphabet
12
+ @radix = alphabet.length
13
+ @char_map = {}
14
+ alphabet.each_char.with_index { |c, i| @char_map[c] = i }
15
+ end
16
+
17
+ def encrypt(plaintext)
18
+ digits = to_digits(plaintext)
19
+ result = ff1_encrypt(digits, @tweak)
20
+ from_digits(result)
21
+ end
22
+
23
+ def decrypt(ciphertext)
24
+ digits = to_digits(ciphertext)
25
+ result = ff1_decrypt(digits, @tweak)
26
+ from_digits(result)
27
+ end
28
+
29
+ private
30
+
31
+ def to_digits(s)
32
+ s.each_char.map { |c| @char_map.fetch(c) { raise ArgumentError, "Character '#{c}' not in alphabet" } }
33
+ end
34
+
35
+ def from_digits(d)
36
+ d.map { |i| @alphabet[i] }.join
37
+ end
38
+
39
+ def aes_ecb(block)
40
+ cipher = OpenSSL::Cipher::AES.new(@key.bytesize * 8, :ECB)
41
+ cipher.encrypt
42
+ cipher.padding = 0
43
+ cipher.key = @key
44
+ cipher.update(block) + cipher.final
45
+ end
46
+
47
+ def prf(data)
48
+ y = "\x00" * 16
49
+ (0...data.bytesize).step(16) do |off|
50
+ block = y.bytes.zip(data.byteslice(off, 16).bytes).map { |a, b| a ^ b }.pack('C*')
51
+ y = aes_ecb(block)
52
+ end
53
+ y
54
+ end
55
+
56
+ def expand_s(r, d)
57
+ blocks = (d + 15) / 16
58
+ out = r.dup
59
+ (1...blocks).each do |j|
60
+ x = ([0] * 12 + [(j >> 24) & 0xFF, (j >> 16) & 0xFF, (j >> 8) & 0xFF, j & 0xFF]).pack('C*')
61
+ # XOR with R (not previous block) per NIST SP 800-38G
62
+ x = x.bytes.zip(r.bytes).map { |a, b| a ^ b }.pack('C*')
63
+ out << aes_ecb(x)
64
+ end
65
+ out.byteslice(0, d)
66
+ end
67
+
68
+ def num(digits)
69
+ r = 0
70
+ digits.each { |d| r = r * @radix + d }
71
+ r
72
+ end
73
+
74
+ def str(n, len)
75
+ result = Array.new(len, 0)
76
+ (len - 1).downto(0) do |i|
77
+ result[i] = n % @radix
78
+ n /= @radix
79
+ end
80
+ result
81
+ end
82
+
83
+ def compute_b(v)
84
+ (Math.log2(@radix) * v / 8.0).ceil
85
+ end
86
+
87
+ def build_p(u, n, t)
88
+ [1, 2, 1, (@radix >> 16) & 0xFF, (@radix >> 8) & 0xFF, @radix & 0xFF, 10, u,
89
+ (n >> 24) & 0xFF, (n >> 16) & 0xFF, (n >> 8) & 0xFF, n & 0xFF,
90
+ (t >> 24) & 0xFF, (t >> 16) & 0xFF, (t >> 8) & 0xFF, t & 0xFF].pack('C*')
91
+ end
92
+
93
+ def build_q(t, i, num_bytes, b)
94
+ pad = (16 - ((t.bytesize + 1 + b) % 16)) % 16
95
+ q = t.dup
96
+ q << ("\x00" * pad)
97
+ q << [i].pack('C')
98
+ if num_bytes.bytesize < b
99
+ q << ("\x00" * (b - num_bytes.bytesize))
100
+ end
101
+ start = [0, num_bytes.bytesize - b].max
102
+ q << num_bytes.byteslice(start..)
103
+ q
104
+ end
105
+
106
+ def bigint_to_bytes(val, len)
107
+ hex = val.to_s(16)
108
+ hex = "0#{hex}" if hex.length.odd?
109
+ bytes = [hex].pack('H*')
110
+ if bytes.bytesize < len
111
+ bytes = ("\x00" * (len - bytes.bytesize)) + bytes
112
+ elsif bytes.bytesize > len
113
+ bytes = bytes.byteslice(-len, len)
114
+ end
115
+ bytes
116
+ end
117
+
118
+ def ff1_encrypt(pt, t)
119
+ n = pt.length
120
+ u = n / 2
121
+ v = n - u
122
+ a = pt[0...u]
123
+ b = pt[u..]
124
+
125
+ bval = compute_b(v)
126
+ d = 4 * ((bval + 3) / 4) + 4
127
+ p = build_p(u, n, t.bytesize)
128
+
129
+ 10.times do |i|
130
+ num_b = bigint_to_bytes(num(b), [bval, 1].max)
131
+ q = build_q(t, i, num_b, bval)
132
+ r = prf(p + q)
133
+ s = expand_s(r, d)
134
+ y = s.unpack1('H*').to_i(16)
135
+
136
+ m = i.even? ? u : v
137
+ c = (num(a) + y) % (@radix ** m)
138
+ a = b
139
+ b = str(c, m)
140
+ end
141
+
142
+ a + b
143
+ end
144
+
145
+ def ff1_decrypt(ct, t)
146
+ n = ct.length
147
+ u = n / 2
148
+ v = n - u
149
+ a = ct[0...u]
150
+ b = ct[u..]
151
+
152
+ bval = compute_b(v)
153
+ d = 4 * ((bval + 3) / 4) + 4
154
+ p = build_p(u, n, t.bytesize)
155
+
156
+ 9.downto(0) do |i|
157
+ num_a = bigint_to_bytes(num(a), [bval, 1].max)
158
+ q = build_q(t, i, num_a, bval)
159
+ r = prf(p + q)
160
+ s = expand_s(r, d)
161
+ y = s.unpack1('H*').to_i(16)
162
+
163
+ m = i.even? ? u : v
164
+ mod = @radix ** m
165
+ c = (num(b) - y) % mod
166
+ b = a
167
+ a = str(c, m)
168
+ end
169
+
170
+ a + b
171
+ end
172
+ end
173
+ end
@@ -0,0 +1,148 @@
1
+ require 'openssl'
2
+
3
+ module Cyphera
4
+ class FF3
5
+ def initialize(key, tweak, alphabet = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ')
6
+ raise ArgumentError, "Key must be 16, 24, or 32 bytes" unless [16, 24, 32].include?(key.bytesize)
7
+ raise ArgumentError, "Tweak must be exactly 8 bytes" unless tweak.bytesize == 8
8
+ raise ArgumentError, "Alphabet must have >= 2 characters" if alphabet.length < 2
9
+
10
+ @key = key.reverse
11
+ @tweak = tweak
12
+ @alphabet = alphabet
13
+ @radix = alphabet.length
14
+ @char_map = {}
15
+ alphabet.each_char.with_index { |c, i| @char_map[c] = i }
16
+ end
17
+
18
+ def encrypt(plaintext)
19
+ digits = to_digits(plaintext)
20
+ result = ff3_encrypt(digits)
21
+ from_digits(result)
22
+ end
23
+
24
+ def decrypt(ciphertext)
25
+ digits = to_digits(ciphertext)
26
+ result = ff3_decrypt(digits)
27
+ from_digits(result)
28
+ end
29
+
30
+ private
31
+
32
+ def to_digits(s)
33
+ s.each_char.map { |c| @char_map.fetch(c) { raise ArgumentError, "Character '#{c}' not in alphabet" } }
34
+ end
35
+
36
+ def from_digits(d)
37
+ d.map { |i| @alphabet[i] }.join
38
+ end
39
+
40
+ def aes_ecb(block)
41
+ cipher = OpenSSL::Cipher::AES.new(@key.bytesize * 8, :ECB)
42
+ cipher.encrypt
43
+ cipher.padding = 0
44
+ cipher.key = @key
45
+ cipher.update(block) + cipher.final
46
+ end
47
+
48
+ def num(digits)
49
+ r = 0
50
+ digits.each { |d| r = r * @radix + d }
51
+ r
52
+ end
53
+
54
+ def str(n, len)
55
+ result = Array.new(len, 0)
56
+ (len - 1).downto(0) do |i|
57
+ result[i] = n % @radix
58
+ n /= @radix
59
+ end
60
+ result
61
+ end
62
+
63
+ def calc_p(round, w, half)
64
+ inp = Array.new(16, 0)
65
+ inp[0] = w.getbyte(0)
66
+ inp[1] = w.getbyte(1)
67
+ inp[2] = w.getbyte(2)
68
+ inp[3] = w.getbyte(3) ^ round
69
+
70
+ rev_half = half.reverse
71
+ half_num = num(rev_half)
72
+ half_hex = half_num.to_s(16)
73
+ half_hex = "0#{half_hex}" if half_hex.length.odd?
74
+ half_bytes = [half_hex].pack('H*')
75
+ half_bytes = "\x00" if half_bytes.empty?
76
+
77
+ if half_bytes.bytesize <= 12
78
+ pos = 16 - half_bytes.bytesize
79
+ half_bytes.bytes.each_with_index { |b, k| inp[pos + k] = b }
80
+ else
81
+ start = half_bytes.bytesize - 12
82
+ 12.times { |k| inp[4 + k] = half_bytes.getbyte(start + k) }
83
+ end
84
+
85
+ rev_inp = inp.pack('C*').reverse
86
+ aes_out = aes_ecb(rev_inp)
87
+ rev_out = aes_out.reverse
88
+
89
+ rev_out.unpack1('H*').to_i(16)
90
+ end
91
+
92
+ def ff3_encrypt(pt)
93
+ n = pt.length
94
+ u = (n + 1) / 2
95
+ v = n - u
96
+ a = pt[0...u].dup
97
+ b = pt[u..].dup
98
+
99
+ 8.times do |i|
100
+ if i.even?
101
+ w = @tweak.byteslice(4, 4)
102
+ p = calc_p(i, w, b)
103
+ m = @radix ** u
104
+ a_num = num(a.reverse)
105
+ y = (a_num + p) % m
106
+ a = str(y, u).reverse
107
+ else
108
+ w = @tweak.byteslice(0, 4)
109
+ p = calc_p(i, w, a)
110
+ m = @radix ** v
111
+ b_num = num(b.reverse)
112
+ y = (b_num + p) % m
113
+ b = str(y, v).reverse
114
+ end
115
+ end
116
+
117
+ a + b
118
+ end
119
+
120
+ def ff3_decrypt(ct)
121
+ n = ct.length
122
+ u = (n + 1) / 2
123
+ v = n - u
124
+ a = ct[0...u].dup
125
+ b = ct[u..].dup
126
+
127
+ 7.downto(0) do |i|
128
+ if i.even?
129
+ w = @tweak.byteslice(4, 4)
130
+ p = calc_p(i, w, b)
131
+ m = @radix ** u
132
+ a_num = num(a.reverse)
133
+ y = (a_num - p) % m
134
+ a = str(y, u).reverse
135
+ else
136
+ w = @tweak.byteslice(0, 4)
137
+ p = calc_p(i, w, a)
138
+ m = @radix ** v
139
+ b_num = num(b.reverse)
140
+ y = (b_num - p) % m
141
+ b = str(y, v).reverse
142
+ end
143
+ end
144
+
145
+ a + b
146
+ end
147
+ end
148
+ end
data/lib/cyphera.rb ADDED
@@ -0,0 +1,6 @@
1
+ require_relative 'cyphera/ff1'
2
+ require_relative 'cyphera/ff3'
3
+ require_relative 'cyphera/cyphera'
4
+
5
+ module Cyphera
6
+ end
metadata ADDED
@@ -0,0 +1,48 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: cyphera
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1.alpha.1
5
+ platform: ruby
6
+ authors:
7
+ - Horizon Digital Engineering
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: Cyphera is an open-source data protection SDK for Ruby. Format-preserving
13
+ encryption (FF1/FF3), data masking, and hashing. Policy-driven protect/access API.
14
+ Cross-language compatible.
15
+ email: leslie.gutschow@horizondigital.dev
16
+ executables: []
17
+ extensions: []
18
+ extra_rdoc_files: []
19
+ files:
20
+ - lib/cyphera.rb
21
+ - lib/cyphera/cyphera.rb
22
+ - lib/cyphera/ff1.rb
23
+ - lib/cyphera/ff3.rb
24
+ homepage: https://cyphera.io
25
+ licenses:
26
+ - Apache-2.0
27
+ metadata:
28
+ source_code_uri: https://github.com/cyphera-labs/cyphera-ruby
29
+ homepage_uri: https://cyphera.io
30
+ rdoc_options: []
31
+ require_paths:
32
+ - lib
33
+ required_ruby_version: !ruby/object:Gem::Requirement
34
+ requirements:
35
+ - - ">="
36
+ - !ruby/object:Gem::Version
37
+ version: '3.0'
38
+ required_rubygems_version: !ruby/object:Gem::Requirement
39
+ requirements:
40
+ - - ">="
41
+ - !ruby/object:Gem::Version
42
+ version: '0'
43
+ requirements: []
44
+ rubygems_version: 4.0.6
45
+ specification_version: 4
46
+ summary: Data protection SDK — format-preserving encryption (FF1/FF3), data masking,
47
+ and hashing.
48
+ test_files: []