cyphera 0.0.1.alpha.1 → 0.0.1.alpha.2
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 +4 -4
- data/lib/cyphera/cyphera.rb +117 -68
- data/lib/cyphera/ff1.rb +2 -0
- data/lib/cyphera/ff3.rb +2 -0
- metadata +4 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 644fcfada6f2f21f7154a8a7d640ff476459486e1d51e4824ed96e4a15214ae1
|
|
4
|
+
data.tar.gz: 0a7456e7734eeb10464333f952d32871d67c55bfd106f5d11d1fd339530a81bb
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 0a522a4bef3f86874e8b296c05cbe57ce806321be56e5bb140f802cc402a333231a5c51be9dca1b958413c1c614e181b0265a667e04415b5ccadc4dc3fc49053
|
|
7
|
+
data.tar.gz: f1d9ad1bcc5f173774f00a172cbed7ed37d6abf8031f339b39792df490e8f9da4dca3b50721abef8bfd7fa2d8bd9c0077a0ca17063b8674610e0c515b951fc69
|
data/lib/cyphera/cyphera.rb
CHANGED
|
@@ -12,11 +12,11 @@ module Cyphera
|
|
|
12
12
|
|
|
13
13
|
class Client
|
|
14
14
|
def self.load
|
|
15
|
-
env = ENV['
|
|
15
|
+
env = ENV['CYPHERA_CONFIG_FILE']
|
|
16
16
|
return from_file(env) if env && File.exist?(env)
|
|
17
17
|
return from_file('cyphera.json') if File.exist?('cyphera.json')
|
|
18
18
|
return from_file('/etc/cyphera/cyphera.json') if File.exist?('/etc/cyphera/cyphera.json')
|
|
19
|
-
raise 'No
|
|
19
|
+
raise 'No configuration file found. Checked: CYPHERA_CONFIG_FILE env, ./cyphera.json, /etc/cyphera/cyphera.json'
|
|
20
20
|
end
|
|
21
21
|
|
|
22
22
|
def self.from_file(path)
|
|
@@ -28,89 +28,139 @@ module Cyphera
|
|
|
28
28
|
new(config)
|
|
29
29
|
end
|
|
30
30
|
|
|
31
|
-
def protect(value,
|
|
32
|
-
|
|
33
|
-
case
|
|
34
|
-
when 'ff1' then protect_fpe(value,
|
|
35
|
-
when 'ff3' then protect_fpe(value,
|
|
36
|
-
when 'mask' then protect_mask(value,
|
|
37
|
-
when 'hash' then protect_hash(value,
|
|
38
|
-
else raise ArgumentError, "Unknown engine: #{
|
|
31
|
+
def protect(value, configuration_name)
|
|
32
|
+
configuration = get_configuration(configuration_name)
|
|
33
|
+
case configuration['engine']
|
|
34
|
+
when 'ff1' then protect_fpe(value, configuration, false)
|
|
35
|
+
when 'ff3' then protect_fpe(value, configuration, true)
|
|
36
|
+
when 'mask' then protect_mask(value, configuration)
|
|
37
|
+
when 'hash' then protect_hash(value, configuration)
|
|
38
|
+
else raise ArgumentError, "Unknown engine: #{configuration['engine']}"
|
|
39
39
|
end
|
|
40
40
|
end
|
|
41
41
|
|
|
42
|
-
def access(protected_value,
|
|
43
|
-
if
|
|
44
|
-
|
|
45
|
-
|
|
42
|
+
def access(protected_value, configuration_name = nil)
|
|
43
|
+
if configuration_name
|
|
44
|
+
configuration = get_configuration(configuration_name)
|
|
45
|
+
if configuration['header_enabled']
|
|
46
|
+
raise ArgumentError,
|
|
47
|
+
"configuration '#{configuration_name}' has header_enabled=true; use access(value) — " \
|
|
48
|
+
'the header identifies the configuration. The two-arg form is for ' \
|
|
49
|
+
'header_enabled=false configurations only.'
|
|
50
|
+
end
|
|
51
|
+
return access_fpe(protected_value, configuration)
|
|
46
52
|
end
|
|
47
53
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
54
|
+
access_by_header(protected_value)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def access_by_header(protected_value)
|
|
58
|
+
@header_index.keys.sort_by { |h| -h.length }.each do |header|
|
|
59
|
+
if protected_value.length > header.length && protected_value.start_with?(header)
|
|
60
|
+
configuration = get_configuration(@header_index[header])
|
|
61
|
+
stripped = protected_value[header.length..]
|
|
62
|
+
return access_fpe(stripped, configuration)
|
|
52
63
|
end
|
|
53
64
|
end
|
|
54
65
|
|
|
55
|
-
raise ArgumentError, 'No matching
|
|
66
|
+
raise ArgumentError, 'No matching header found. Use access(value, configuration_name) for headerless values.'
|
|
56
67
|
end
|
|
57
68
|
|
|
58
69
|
private
|
|
59
70
|
|
|
60
71
|
def initialize(config)
|
|
61
|
-
@
|
|
62
|
-
@
|
|
72
|
+
@configurations = {}
|
|
73
|
+
@header_index = {}
|
|
63
74
|
@keys = {}
|
|
64
75
|
|
|
65
76
|
(config['keys'] || {}).each do |name, val|
|
|
66
|
-
|
|
67
|
-
|
|
77
|
+
if val.is_a?(String)
|
|
78
|
+
@keys[name] = [val].pack('H*')
|
|
79
|
+
elsif val['material']
|
|
80
|
+
@keys[name] = [val['material']].pack('H*')
|
|
81
|
+
elsif val['source']
|
|
82
|
+
@keys[name] = self.class.resolve_key_source(name, val)
|
|
83
|
+
else
|
|
84
|
+
raise ArgumentError, "Key '#{name}' must have either 'material' or 'source'"
|
|
85
|
+
end
|
|
68
86
|
end
|
|
69
87
|
|
|
70
|
-
(config['
|
|
71
|
-
|
|
72
|
-
|
|
88
|
+
(config['configurations'] || {}).each do |name, cfg|
|
|
89
|
+
header_enabled = cfg.fetch('header_enabled', true)
|
|
90
|
+
header = cfg['header']
|
|
73
91
|
|
|
74
|
-
if
|
|
75
|
-
raise ArgumentError, "
|
|
92
|
+
if header_enabled && (header.nil? || header.empty?)
|
|
93
|
+
raise ArgumentError, "Configuration '#{name}' has header_enabled=true but no header specified"
|
|
76
94
|
end
|
|
77
95
|
|
|
78
|
-
if
|
|
79
|
-
if @
|
|
80
|
-
raise ArgumentError, "
|
|
96
|
+
if header_enabled && header
|
|
97
|
+
if @header_index.key?(header)
|
|
98
|
+
raise ArgumentError, "Header collision: '#{header}' used by both '#{@header_index[header]}' and '#{name}'"
|
|
81
99
|
end
|
|
82
|
-
@
|
|
100
|
+
@header_index[header] = name
|
|
83
101
|
end
|
|
84
102
|
|
|
85
|
-
@
|
|
86
|
-
'engine' =>
|
|
87
|
-
'alphabet' => resolve_alphabet(
|
|
88
|
-
'key_ref' =>
|
|
89
|
-
'
|
|
90
|
-
'
|
|
91
|
-
'pattern' =>
|
|
92
|
-
'algorithm' =>
|
|
103
|
+
@configurations[name] = {
|
|
104
|
+
'engine' => cfg.fetch('engine', 'ff1'),
|
|
105
|
+
'alphabet' => resolve_alphabet(cfg['alphabet']),
|
|
106
|
+
'key_ref' => cfg['key_ref'],
|
|
107
|
+
'header' => header,
|
|
108
|
+
'header_enabled' => header_enabled,
|
|
109
|
+
'pattern' => cfg['pattern'],
|
|
110
|
+
'algorithm' => cfg.fetch('algorithm', 'sha256')
|
|
93
111
|
}
|
|
94
112
|
end
|
|
95
113
|
end
|
|
96
114
|
|
|
97
|
-
def
|
|
98
|
-
@
|
|
115
|
+
def get_configuration(name)
|
|
116
|
+
@configurations.fetch(name) { raise ArgumentError, "Unknown configuration: #{name}" }
|
|
99
117
|
end
|
|
100
118
|
|
|
101
119
|
def resolve_key(key_ref)
|
|
102
|
-
raise ArgumentError, 'No key_ref in
|
|
120
|
+
raise ArgumentError, 'No key_ref in configuration' if key_ref.nil? || key_ref.empty?
|
|
103
121
|
@keys.fetch(key_ref) { raise ArgumentError, "Unknown key: #{key_ref}" }
|
|
104
122
|
end
|
|
105
123
|
|
|
124
|
+
CLOUD_SOURCES = %w[aws-kms gcp-kms azure-kv vault].freeze
|
|
125
|
+
|
|
126
|
+
def self.resolve_key_source(name, config)
|
|
127
|
+
source = config['source']
|
|
128
|
+
|
|
129
|
+
case source
|
|
130
|
+
when 'env'
|
|
131
|
+
var_name = config['var'] or raise ArgumentError, "Key '#{name}': source 'env' requires 'var' field"
|
|
132
|
+
val = ENV[var_name] or raise ArgumentError, "Key '#{name}': environment variable '#{var_name}' is not set"
|
|
133
|
+
encoding = config['encoding'] || 'hex'
|
|
134
|
+
return encoding == 'base64' ? val.unpack1('m') : [val].pack('H*')
|
|
135
|
+
when 'file'
|
|
136
|
+
path = config['path'] or raise ArgumentError, "Key '#{name}': source 'file' requires 'path' field"
|
|
137
|
+
raw = File.read(path).strip
|
|
138
|
+
encoding = config['encoding'] || (path.end_with?('.b64', '.base64') ? 'base64' : 'hex')
|
|
139
|
+
return encoding == 'base64' ? raw.unpack1('m') : [raw].pack('H*')
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
if CLOUD_SOURCES.include?(source)
|
|
143
|
+
begin
|
|
144
|
+
require 'cyphera-keychain'
|
|
145
|
+
return CypheraKeychain.resolve(source, config)
|
|
146
|
+
rescue LoadError
|
|
147
|
+
raise LoadError,
|
|
148
|
+
"Key '#{name}' requires source '#{source}' but cyphera-keychain is not installed.\n" \
|
|
149
|
+
"Install it: gem install cyphera-keychain"
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
raise ArgumentError, "Key '#{name}': unknown source '#{source}'. Valid: env, file, #{CLOUD_SOURCES.join(', ')}"
|
|
154
|
+
end
|
|
155
|
+
|
|
106
156
|
def resolve_alphabet(name)
|
|
107
157
|
return ALPHABETS['alphanumeric'] if name.nil? || name.empty?
|
|
108
158
|
ALPHABETS[name] || name
|
|
109
159
|
end
|
|
110
160
|
|
|
111
|
-
def protect_fpe(value,
|
|
112
|
-
key = resolve_key(
|
|
113
|
-
alphabet =
|
|
161
|
+
def protect_fpe(value, configuration, is_ff3)
|
|
162
|
+
key = resolve_key(configuration['key_ref'])
|
|
163
|
+
alphabet = configuration['alphabet']
|
|
114
164
|
encryptable, positions, chars = extract_passthroughs(value, alphabet)
|
|
115
165
|
raise ArgumentError, 'No encryptable characters in input' if encryptable.empty?
|
|
116
166
|
|
|
@@ -121,29 +171,28 @@ module Cyphera
|
|
|
121
171
|
end
|
|
122
172
|
|
|
123
173
|
result = reinsert_passthroughs(encrypted, positions, chars)
|
|
124
|
-
if
|
|
125
|
-
|
|
174
|
+
if configuration['header_enabled'] && configuration['header']
|
|
175
|
+
configuration['header'] + result
|
|
126
176
|
else
|
|
127
177
|
result
|
|
128
178
|
end
|
|
129
179
|
end
|
|
130
180
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
181
|
+
# Reverses an FPE-protected value. Assumes the input is already
|
|
182
|
+
# header-stripped (or that the configuration has header_enabled=false).
|
|
183
|
+
# Callers: access() routes here after the header_enabled=false check;
|
|
184
|
+
# access_by_header() strips the header itself before calling.
|
|
185
|
+
def access_fpe(protected_value, configuration)
|
|
186
|
+
unless %w[ff1 ff3].include?(configuration['engine'])
|
|
187
|
+
raise ArgumentError, "Cannot reverse '#{configuration['engine']}' — not reversible"
|
|
134
188
|
end
|
|
135
189
|
|
|
136
|
-
key = resolve_key(
|
|
137
|
-
alphabet =
|
|
138
|
-
|
|
139
|
-
without_tag = protected_value
|
|
140
|
-
if policy['tag_enabled'] && policy['tag']
|
|
141
|
-
without_tag = protected_value[policy['tag'].length..]
|
|
142
|
-
end
|
|
190
|
+
key = resolve_key(configuration['key_ref'])
|
|
191
|
+
alphabet = configuration['alphabet']
|
|
143
192
|
|
|
144
|
-
encryptable, positions, chars = extract_passthroughs(
|
|
193
|
+
encryptable, positions, chars = extract_passthroughs(protected_value, alphabet)
|
|
145
194
|
|
|
146
|
-
decrypted = if
|
|
195
|
+
decrypted = if configuration['engine'] == 'ff3'
|
|
147
196
|
FF3.new(key, "\x00" * 8, alphabet).decrypt(encryptable)
|
|
148
197
|
else
|
|
149
198
|
FF1.new(key, '', alphabet).decrypt(encryptable)
|
|
@@ -152,9 +201,9 @@ module Cyphera
|
|
|
152
201
|
reinsert_passthroughs(decrypted, positions, chars)
|
|
153
202
|
end
|
|
154
203
|
|
|
155
|
-
def protect_mask(value,
|
|
156
|
-
pattern =
|
|
157
|
-
raise ArgumentError, "Mask
|
|
204
|
+
def protect_mask(value, configuration)
|
|
205
|
+
pattern = configuration['pattern']
|
|
206
|
+
raise ArgumentError, "Mask configuration requires 'pattern'" if pattern.nil? || pattern.empty?
|
|
158
207
|
len = value.length
|
|
159
208
|
case pattern
|
|
160
209
|
when 'last4', 'last_4' then ('*' * [0, len - 4].max) + value[[0, len - 4].max..]
|
|
@@ -165,17 +214,17 @@ module Cyphera
|
|
|
165
214
|
end
|
|
166
215
|
end
|
|
167
216
|
|
|
168
|
-
def protect_hash(value,
|
|
169
|
-
algo =
|
|
217
|
+
def protect_hash(value, configuration)
|
|
218
|
+
algo = configuration['algorithm'].downcase.delete('-')
|
|
170
219
|
digest = case algo
|
|
171
220
|
when 'sha256' then 'SHA256'
|
|
172
221
|
when 'sha384' then 'SHA384'
|
|
173
222
|
when 'sha512' then 'SHA512'
|
|
174
|
-
else raise ArgumentError, "Unsupported hash algorithm: #{
|
|
223
|
+
else raise ArgumentError, "Unsupported hash algorithm: #{configuration['algorithm']}"
|
|
175
224
|
end
|
|
176
225
|
|
|
177
|
-
if
|
|
178
|
-
key = resolve_key(
|
|
226
|
+
if configuration['key_ref'] && !configuration['key_ref'].empty?
|
|
227
|
+
key = resolve_key(configuration['key_ref'])
|
|
179
228
|
OpenSSL::HMAC.hexdigest(digest, key, value)
|
|
180
229
|
else
|
|
181
230
|
OpenSSL::Digest.hexdigest(digest, value)
|
data/lib/cyphera/ff1.rb
CHANGED
|
@@ -37,6 +37,8 @@ module Cyphera
|
|
|
37
37
|
end
|
|
38
38
|
|
|
39
39
|
def aes_ecb(block)
|
|
40
|
+
# NIST SP 800-38G requires AES-ECB as the PRF for FF1/FF3 Feistel rounds.
|
|
41
|
+
# This is single-block encryption used as a building block, not ECB mode applied to user data.
|
|
40
42
|
cipher = OpenSSL::Cipher::AES.new(@key.bytesize * 8, :ECB)
|
|
41
43
|
cipher.encrypt
|
|
42
44
|
cipher.padding = 0
|
data/lib/cyphera/ff3.rb
CHANGED
|
@@ -38,6 +38,8 @@ module Cyphera
|
|
|
38
38
|
end
|
|
39
39
|
|
|
40
40
|
def aes_ecb(block)
|
|
41
|
+
# NIST SP 800-38G requires AES-ECB as the PRF for FF1/FF3 Feistel rounds.
|
|
42
|
+
# This is single-block encryption used as a building block, not ECB mode applied to user data.
|
|
41
43
|
cipher = OpenSSL::Cipher::AES.new(@key.bytesize * 8, :ECB)
|
|
42
44
|
cipher.encrypt
|
|
43
45
|
cipher.padding = 0
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: cyphera
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.0.1.alpha.
|
|
4
|
+
version: 0.0.1.alpha.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Horizon Digital Engineering
|
|
@@ -10,8 +10,8 @@ cert_chain: []
|
|
|
10
10
|
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
11
|
dependencies: []
|
|
12
12
|
description: Cyphera is an open-source data protection SDK for Ruby. Format-preserving
|
|
13
|
-
encryption (FF1/FF3), data masking, and hashing.
|
|
14
|
-
Cross-language compatible.
|
|
13
|
+
encryption (FF1/FF3), data masking, and hashing. Configuration-driven protect/access
|
|
14
|
+
API. Cross-language compatible.
|
|
15
15
|
email: leslie.gutschow@horizondigital.dev
|
|
16
16
|
executables: []
|
|
17
17
|
extensions: []
|
|
@@ -41,7 +41,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
41
41
|
- !ruby/object:Gem::Version
|
|
42
42
|
version: '0'
|
|
43
43
|
requirements: []
|
|
44
|
-
rubygems_version: 4.0.
|
|
44
|
+
rubygems_version: 4.0.10
|
|
45
45
|
specification_version: 4
|
|
46
46
|
summary: Data protection SDK — format-preserving encryption (FF1/FF3), data masking,
|
|
47
47
|
and hashing.
|