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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: '092f5a2db55a53433ed97db72c86fb9f77a56dc15be2f96c343c36c06fb07ac2'
4
- data.tar.gz: 289ca8aa1a02f678cc6ad267a103d0aa5a26c0ead54edce4d28bc8d30dbeb4db
3
+ metadata.gz: 644fcfada6f2f21f7154a8a7d640ff476459486e1d51e4824ed96e4a15214ae1
4
+ data.tar.gz: 0a7456e7734eeb10464333f952d32871d67c55bfd106f5d11d1fd339530a81bb
5
5
  SHA512:
6
- metadata.gz: 75c5fe10c28c3d07559c084a9f73e5a2b8276afd700b00ecac5f3c408257ed48989fb16af488fa2e031701b4e30c4d956a9b9657e374c8a72d6db9d2b17ca3af
7
- data.tar.gz: d5a63dd12e2751ac8741cc90628f2e28da9a4ca0438df0763a4e02d1c210e69b326b59213a14367d237798340c66c78b368eaa7e76ddae2c092acfe01c66be24
6
+ metadata.gz: 0a522a4bef3f86874e8b296c05cbe57ce806321be56e5bb140f802cc402a333231a5c51be9dca1b958413c1c614e181b0265a667e04415b5ccadc4dc3fc49053
7
+ data.tar.gz: f1d9ad1bcc5f173774f00a172cbed7ed37d6abf8031f339b39792df490e8f9da4dca3b50721abef8bfd7fa2d8bd9c0077a0ca17063b8674610e0c515b951fc69
@@ -12,11 +12,11 @@ module Cyphera
12
12
 
13
13
  class Client
14
14
  def self.load
15
- env = ENV['CYPHERA_POLICY_FILE']
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 policy file found. Checked: CYPHERA_POLICY_FILE env, ./cyphera.json, /etc/cyphera/cyphera.json'
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, 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']}"
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, policy_name = nil)
43
- if policy_name
44
- policy = get_policy(policy_name)
45
- return access_fpe(protected_value, policy)
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
- @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)
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 tag found. Use access(value, policy_name) for untagged values.'
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
- @policies = {}
62
- @tag_index = {}
72
+ @configurations = {}
73
+ @header_index = {}
63
74
  @keys = {}
64
75
 
65
76
  (config['keys'] || {}).each do |name, val|
66
- material = val.is_a?(String) ? val : val['material']
67
- @keys[name] = [material].pack('H*')
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['policies'] || {}).each do |name, pol|
71
- tag_enabled = pol.fetch('tag_enabled', true)
72
- tag = pol['tag']
88
+ (config['configurations'] || {}).each do |name, cfg|
89
+ header_enabled = cfg.fetch('header_enabled', true)
90
+ header = cfg['header']
73
91
 
74
- if tag_enabled && (tag.nil? || tag.empty?)
75
- raise ArgumentError, "Policy '#{name}' has tag_enabled=true but no tag specified"
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 tag_enabled && tag
79
- if @tag_index.key?(tag)
80
- raise ArgumentError, "Tag collision: '#{tag}' used by both '#{@tag_index[tag]}' and '#{name}'"
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
- @tag_index[tag] = name
100
+ @header_index[header] = name
83
101
  end
84
102
 
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')
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 get_policy(name)
98
- @policies.fetch(name) { raise ArgumentError, "Unknown policy: #{name}" }
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 policy' if key_ref.nil? || key_ref.empty?
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, policy, is_ff3)
112
- key = resolve_key(policy['key_ref'])
113
- alphabet = policy['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 policy['tag_enabled'] && policy['tag']
125
- policy['tag'] + result
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
- def access_fpe(protected_value, policy)
132
- unless %w[ff1 ff3].include?(policy['engine'])
133
- raise ArgumentError, "Cannot reverse '#{policy['engine']}' not reversible"
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(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
190
+ key = resolve_key(configuration['key_ref'])
191
+ alphabet = configuration['alphabet']
143
192
 
144
- encryptable, positions, chars = extract_passthroughs(without_tag, alphabet)
193
+ encryptable, positions, chars = extract_passthroughs(protected_value, alphabet)
145
194
 
146
- decrypted = if policy['engine'] == 'ff3'
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, policy)
156
- pattern = policy['pattern']
157
- raise ArgumentError, "Mask policy requires 'pattern'" if pattern.nil? || pattern.empty?
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, policy)
169
- algo = policy['algorithm'].downcase.delete('-')
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: #{policy['algorithm']}"
223
+ else raise ArgumentError, "Unsupported hash algorithm: #{configuration['algorithm']}"
175
224
  end
176
225
 
177
- if policy['key_ref'] && !policy['key_ref'].empty?
178
- key = resolve_key(policy['key_ref'])
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.1
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. Policy-driven protect/access API.
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.6
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.