mecha_encrypt 0.1.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: 9fc649c1928e2410fbe5fd4b6ab9cb7affc54c1341e23b7366d4bc9350114992
4
+ data.tar.gz: fddc7bd16987dcba62498085db982b695f68b0096e46478bc5ce7902721ef869
5
+ SHA512:
6
+ metadata.gz: 6a694a96f6a20c6d041ea6e77cde060d842d3ad9ace9550308d54485326830f24268dad8a28a6e49910a0566c12796da85a082e85f89ca24ed58fb86c99604f0
7
+ data.tar.gz: d86de6527187ea372aa71aa038f9a10c3937ed40a16768d97ecb42900c58ed20d12d737c39dbf89c120a43d0f664fea477afb7e42e3951192f6e89ff82d99714
data/LICENSE ADDED
@@ -0,0 +1,19 @@
1
+ Copyright 2026 Jeff Lunt
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy of
4
+ this software and associated documentation files (the "Software"), to deal in
5
+ the Software without restriction, including without limitation the rights to
6
+ use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
7
+ of the Software, and to permit persons to whom the Software is furnished to do
8
+ so, subject to the following conditions:
9
+
10
+ The above copyright notice and this permission notice shall be included in all
11
+ copies or substantial portions of the Software.
12
+
13
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,123 @@
1
+ # MechaEncrypt
2
+
3
+ `mecha_encrypt` provides a closed-by-default database column encryption registry and auditing toolkit for Rails applications using `ActiveRecord::Encryption`. Every database table and column must be explicitly registered in `config/mecha_encrypt.yml` with its encryption status and justification.
4
+
5
+ ## Installation
6
+
7
+ Add this line to your application's `Gemfile`:
8
+
9
+ ```ruby
10
+ gem "mecha_encrypt"
11
+ ```
12
+
13
+ Or if developing locally:
14
+
15
+ ```ruby
16
+ gem "mecha_encrypt", path: "~/code/mecha_encrypt"
17
+ ```
18
+
19
+ And then execute:
20
+
21
+ ```bash
22
+ bundle install
23
+ ```
24
+
25
+ ## Usage
26
+
27
+ ### 1. Define `config/mecha_encrypt.yml`
28
+
29
+ Register all database tables and columns in `config/mecha_encrypt.yml`. Unencrypted columns require a meaningful explanation in `comment:` (minimum length enforced, placeholder strings rejected).
30
+
31
+ ```yaml
32
+ debug_colorization:
33
+ encrypted: :green
34
+ deterministic: :light_cyan
35
+ unencrypted: :light_purple
36
+ ignored: :yellow
37
+ violation: :light_red
38
+ unmapped: :dark_grey
39
+
40
+ ignored_tables:
41
+ - pattern: "solid_queue_*"
42
+ comment: "SolidQueue internal background job coordination tables"
43
+
44
+ tables:
45
+ email_accounts:
46
+ id:
47
+ encrypted: false
48
+ comment: "Primary key UUID identifier required for foreign key joins"
49
+ email_address:
50
+ encrypted: true
51
+ deterministic: true
52
+ comment: "User email address encrypted deterministically for lookups"
53
+ oauth_token:
54
+ encrypted: true
55
+ deterministic: false
56
+ comment: "OAuth access token encrypted with non-deterministic IV"
57
+ ```
58
+
59
+ ### 2. Configure the Initializer
60
+
61
+ Create `config/initializers/mecha_encrypt.rb`:
62
+
63
+ ```ruby
64
+ # config/initializers/mecha_encrypt.rb
65
+ require "mecha_encrypt"
66
+
67
+ MechaEncrypt.configure do |config|
68
+ config.config_path = Rails.root.join("config/mecha_encrypt.yml")
69
+ config.base_model_class = "ApplicationRecord"
70
+ config.min_comment_length = 10
71
+ end
72
+ ```
73
+
74
+ ### 3. Rake Tasks & Auditing
75
+
76
+ `mecha_encrypt` automatically registers Rake tasks via its Railtie:
77
+
78
+ ```bash
79
+ # Print all database tables and columns in alphabetical order with encryption status
80
+ bin/rails mecha_encrypt
81
+
82
+ # Statically verify schema parity, ActiveRecord::Encryption declarations, and comments
83
+ bin/rails mecha_encrypt:verify:static
84
+
85
+ # Run behavioral ciphertext round-trip and index safety tests
86
+ bin/rails mecha_encrypt:verify:behavioral
87
+
88
+ # Run both static and behavioral verification
89
+ bin/rails mecha_encrypt:verify
90
+ ```
91
+
92
+ ### 4. Dynamic Parallel Matrix Testing in Consuming Apps
93
+
94
+ `mecha_encrypt` ships with [`MechaEncrypt::MatrixTest`](lib/mecha_encrypt/matrix_test.rb), which dynamically generates individual Minitest test methods for each encrypted column (type serialization, envelope format, and determinism) and each table with encrypted columns (raw SQL ciphertext verification), running in parallel across Minitest workers.
95
+
96
+ In your consuming Rails application's `test/test_helper.rb`:
97
+
98
+ ```ruby
99
+ MechaEncrypt.configuration.configure_test_matrix(
100
+ setup: -> {
101
+ # Create persisted sample records for tables containing encrypted columns
102
+ @records_by_table = {
103
+ "email_accounts" => @account,
104
+ "email_messages" => @message
105
+ }
106
+ },
107
+ records: -> { @records_by_table }
108
+ )
109
+
110
+ require "mecha_encrypt/matrix_test" unless ENV["MECHA_MATRIX_STANDALONE"] == "1"
111
+ ```
112
+
113
+ ## Running Tests
114
+
115
+ To run the gem's standalone test suite:
116
+
117
+ ```bash
118
+ bundle exec rake test
119
+ ```
120
+
121
+ ## License
122
+
123
+ Copyright 2026 Jeff Lunt. Released under the [MIT License](LICENSE).
data/Rakefile ADDED
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rake/testtask"
4
+
5
+ Rake::TestTask.new(:test) do |t|
6
+ t.libs << "test"
7
+ t.libs << "lib"
8
+ t.test_files = FileList["test/**/*_test.rb"]
9
+ end
10
+
11
+ task default: :test
@@ -0,0 +1,307 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module MechaEncrypt
6
+ module Auditor
7
+ class Prober
8
+ CipherFailure = Struct.new(:table, :column, :model, :message, keyword_init: true)
9
+ IndexFailure = Struct.new(:table, :column, :index_name, :message, keyword_init: true)
10
+
11
+ Result = Struct.new(
12
+ :probed_columns_count,
13
+ :probed_records_count,
14
+ :cipher_failures,
15
+ :index_failures,
16
+ keyword_init: true
17
+ ) do
18
+ def success?
19
+ cipher_failures.empty? && index_failures.empty?
20
+ end
21
+
22
+ def failures
23
+ cipher_failures + index_failures
24
+ end
25
+ end
26
+
27
+ def initialize(registry: MechaEncrypt.registry, configuration: MechaEncrypt.configuration, connection: nil)
28
+ @registry = registry
29
+ @configuration = configuration
30
+ @connection = connection
31
+ end
32
+
33
+ # Verify that non-deterministic encrypted columns do not have broken B-tree / unique DB indexes
34
+ def probe_index_safety
35
+ index_failures = []
36
+ conn = db_connection
37
+
38
+ @registry.tables.each do |table_name, cols|
39
+ next unless conn.table_exists?(table_name)
40
+
41
+ indexes = conn.indexes(table_name)
42
+ cols.each_value do |entry|
43
+ next unless entry[:encrypted] && !entry[:deterministic]
44
+
45
+ col_name = entry[:column]
46
+ indexes.each do |idx|
47
+ covered = if idx.columns.is_a?(Array)
48
+ idx.columns.map(&:to_s).include?(col_name)
49
+ else
50
+ idx.columns.to_s.match?(/\b#{Regexp.escape(col_name)}\b/)
51
+ end
52
+
53
+ if covered
54
+ index_failures << IndexFailure.new(
55
+ table: table_name,
56
+ column: col_name,
57
+ index_name: idx.name,
58
+ message: "Non-deterministic encrypted column '#{table_name}.#{col_name}' is covered by DB index '#{idx.name}'. Non-deterministic encryption uses random IVs, breaking index lookups and uniqueness constraints."
59
+ )
60
+ end
61
+ end
62
+ end
63
+ end
64
+
65
+ Result.new(
66
+ probed_columns_count: @registry.encrypted_columns.size,
67
+ probed_records_count: 0,
68
+ cipher_failures: [],
69
+ index_failures: index_failures
70
+ )
71
+ end
72
+
73
+ # Verify type serialization, ciphertext envelope, round-trip decryption, and deterministic vs non-deterministic IV behavior
74
+ def probe_encryption_types
75
+ ensure_models_loaded!
76
+ cipher_failures = []
77
+ models_by_table = discover_models_by_table
78
+ probed_count = 0
79
+
80
+ @registry.encrypted_columns.each do |entry|
81
+ res = probe_single_column_type(entry, models_by_table: models_by_table)
82
+ probed_count += res.probed_columns_count
83
+ cipher_failures.concat(res.cipher_failures)
84
+ end
85
+
86
+ Result.new(
87
+ probed_columns_count: probed_count,
88
+ probed_records_count: 0,
89
+ cipher_failures: cipher_failures,
90
+ index_failures: []
91
+ )
92
+ end
93
+
94
+ def probe_single_column_type(entry, models_by_table: nil)
95
+ ensure_models_loaded!
96
+ models_map = models_by_table || discover_models_by_table
97
+ cipher_failures = []
98
+
99
+ table_name = entry[:table]
100
+ col_name = entry[:column]
101
+ model_klass = models_map[table_name]
102
+
103
+ if model_klass.nil?
104
+ cipher_failures << CipherFailure.new(
105
+ table: table_name,
106
+ column: col_name,
107
+ model: nil,
108
+ message: "No ActiveRecord model found for table '#{table_name}'"
109
+ )
110
+ else
111
+ attr_type = model_klass.type_for_attribute(col_name)
112
+ if !attr_type.respond_to?(:serialize) || !attr_type.respond_to?(:deserialize)
113
+ cipher_failures << CipherFailure.new(
114
+ table: table_name,
115
+ column: col_name,
116
+ model: model_klass.name,
117
+ message: "Attribute type for #{model_klass.name}##{col_name} does not support serialize/deserialize"
118
+ )
119
+ else
120
+ probe_plaintext = "mecha_secret_#{table_name}_#{col_name}_#{SecureRandom.hex(8)}"
121
+ cipher1 = attr_type.serialize(probe_plaintext)
122
+ cipher2 = attr_type.serialize(probe_plaintext)
123
+
124
+ if !cipher1.is_a?(String) || cipher1.include?(probe_plaintext) || !cipher1.start_with?("{\"p\":")
125
+ cipher_failures << CipherFailure.new(
126
+ table: table_name,
127
+ column: col_name,
128
+ model: model_klass.name,
129
+ message: "Serialized value for #{model_klass.name}##{col_name} is not valid ActiveRecord::Encryption ciphertext"
130
+ )
131
+ else
132
+ decrypted = attr_type.deserialize(cipher1)
133
+ if decrypted != probe_plaintext
134
+ cipher_failures << CipherFailure.new(
135
+ table: table_name,
136
+ column: col_name,
137
+ model: model_klass.name,
138
+ message: "Round-trip decryption failed for #{model_klass.name}##{col_name}: expected #{probe_plaintext.inspect}, got #{decrypted.inspect}"
139
+ )
140
+ end
141
+
142
+ if entry[:deterministic] && cipher1 != cipher2
143
+ cipher_failures << CipherFailure.new(
144
+ table: table_name,
145
+ column: col_name,
146
+ model: model_klass.name,
147
+ message: "Deterministic encrypted column #{model_klass.name}##{col_name} produced different ciphertexts for identical plaintext"
148
+ )
149
+ elsif !entry[:deterministic] && cipher1 == cipher2
150
+ cipher_failures << CipherFailure.new(
151
+ table: table_name,
152
+ column: col_name,
153
+ model: model_klass.name,
154
+ message: "Non-deterministic encrypted column #{model_klass.name}##{col_name} produced identical ciphertexts for identical plaintext (missing random IV)"
155
+ )
156
+ end
157
+ end
158
+ end
159
+ end
160
+
161
+ Result.new(
162
+ probed_columns_count: 1,
163
+ probed_records_count: 0,
164
+ cipher_failures: cipher_failures,
165
+ index_failures: []
166
+ )
167
+ end
168
+
169
+ # Verify raw SQL storage in PostgreSQL for persisted model records
170
+ def probe_persisted_records(records_by_table)
171
+ cipher_failures = []
172
+ probed_records = 0
173
+ normalized = normalize_records_map(records_by_table)
174
+
175
+ encrypted_by_table = @registry.encrypted_columns.group_by { |e| e[:table] }
176
+
177
+ encrypted_by_table.each_key do |table_name|
178
+ res = probe_single_table_persisted_record(table_name, normalized)
179
+ probed_records += res.probed_records_count
180
+ cipher_failures.concat(res.cipher_failures)
181
+ end
182
+
183
+ Result.new(
184
+ probed_columns_count: @registry.encrypted_columns.size,
185
+ probed_records_count: probed_records,
186
+ cipher_failures: cipher_failures,
187
+ index_failures: []
188
+ )
189
+ end
190
+
191
+ def probe_single_table_persisted_record(table_name, records_by_table)
192
+ cipher_failures = []
193
+ probed_records = 0
194
+ conn = db_connection
195
+ normalized = normalize_records_map(records_by_table)
196
+
197
+ entries = @registry.encrypted_columns.select { |e| e[:table] == table_name.to_s }
198
+ record = normalized[table_name.to_s]
199
+
200
+ if record.nil?
201
+ cipher_failures << CipherFailure.new(
202
+ table: table_name.to_s,
203
+ column: entries.map { |e| e[:column] }.join(", "),
204
+ model: nil,
205
+ message: "Missing test record for table '#{table_name}' with encrypted columns"
206
+ )
207
+ else
208
+ probed_records = 1
209
+ entries.each do |entry|
210
+ col_name = entry[:column]
211
+ plaintext_val = record.public_send(col_name).to_s
212
+
213
+ if plaintext_val.empty?
214
+ cipher_failures << CipherFailure.new(
215
+ table: table_name.to_s,
216
+ column: col_name,
217
+ model: record.class.name,
218
+ message: "Test record for #{record.class.name}##{col_name} has an empty plaintext value; cannot verify ciphertext storage"
219
+ )
220
+ next
221
+ end
222
+
223
+ sql = "SELECT #{conn.quote_column_name(col_name)} FROM #{conn.quote_table_name(table_name)} WHERE id = #{conn.quote(record.id)}"
224
+ raw_db_value = conn.select_value(sql).to_s
225
+
226
+ if raw_db_value.empty? || raw_db_value.include?(plaintext_val) || !raw_db_value.start_with?("{\"p\":")
227
+ cipher_failures << CipherFailure.new(
228
+ table: table_name.to_s,
229
+ column: col_name,
230
+ model: record.class.name,
231
+ message: "PLAINTEXT LEAK IN DB: Raw SQL value for #{table_name}.#{col_name} (id=#{record.id}) is not encrypted! Got: #{raw_db_value.inspect}"
232
+ )
233
+ end
234
+
235
+ reloaded_val = record.class.find(record.id).public_send(col_name).to_s
236
+ if reloaded_val != plaintext_val
237
+ cipher_failures << CipherFailure.new(
238
+ table: table_name.to_s,
239
+ column: col_name,
240
+ model: record.class.name,
241
+ message: "Reloaded value for #{table_name}.#{col_name} (id=#{record.id}) did not decrypt back to #{plaintext_val.inspect} (got #{reloaded_val.inspect})"
242
+ )
243
+ end
244
+ end
245
+ end
246
+
247
+ Result.new(
248
+ probed_columns_count: entries.size,
249
+ probed_records_count: probed_records,
250
+ cipher_failures: cipher_failures,
251
+ index_failures: []
252
+ )
253
+ end
254
+
255
+ def probe_all(records_by_table = nil)
256
+ idx_res = probe_index_safety
257
+ type_res = probe_encryption_types
258
+ rec_res = records_by_table ? probe_persisted_records(records_by_table) : nil
259
+
260
+ Result.new(
261
+ probed_columns_count: type_res.probed_columns_count,
262
+ probed_records_count: rec_res ? rec_res.probed_records_count : 0,
263
+ cipher_failures: type_res.cipher_failures + (rec_res ? rec_res.cipher_failures : []),
264
+ index_failures: idx_res.index_failures
265
+ )
266
+ end
267
+
268
+ private
269
+
270
+ def db_connection
271
+ @connection || ActiveRecord::Base.connection
272
+ end
273
+
274
+ def ensure_models_loaded!
275
+ if defined?(Rails) && Rails.respond_to?(:application) && Rails.application
276
+ Rails.application.eager_load!
277
+ end
278
+ end
279
+
280
+ def discover_models_by_table
281
+ base_class_name = @configuration.base_model_class.to_s
282
+ base_class = base_class_name.safe_constantize
283
+ return {} unless base_class
284
+
285
+ models = {}
286
+ base_class.descendants.reject(&:abstract_class?).each do |klass|
287
+ next unless klass.name.present? && !klass.name.include?("#<")
288
+ next unless klass.table_name.present?
289
+
290
+ models[klass.table_name] ||= klass
291
+ end
292
+ models
293
+ end
294
+
295
+ def normalize_records_map(records_hash)
296
+ records_hash.each_with_object({}) do |(k, v), h|
297
+ key = if k.is_a?(Class) && k.respond_to?(:table_name)
298
+ k.table_name.to_s
299
+ else
300
+ k.to_s
301
+ end
302
+ h[key] = v
303
+ end
304
+ end
305
+ end
306
+ end
307
+ end