secure_db_fields 0.1.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.
Files changed (47) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +98 -0
  4. data/Rakefile +16 -0
  5. data/db_deployment/mysql/Makefile +62 -0
  6. data/db_deployment/mysql/README.md +33 -0
  7. data/db_deployment/mysql/bin/common +199 -0
  8. data/db_deployment/mysql/bin/disable +40 -0
  9. data/db_deployment/mysql/bin/doctor +57 -0
  10. data/db_deployment/mysql/bin/enable +56 -0
  11. data/db_deployment/mysql/bin/install +64 -0
  12. data/db_deployment/mysql/bin/status +11 -0
  13. data/db_deployment/mysql/bin/uninstall +46 -0
  14. data/db_deployment/mysql/bin/verify +27 -0
  15. data/db_deployment/mysql/sql/examples.sql +20 -0
  16. data/db_deployment/mysql/sql/install.sql +20 -0
  17. data/db_deployment/mysql/sql/uninstall.sql +8 -0
  18. data/docs/final_decision.md +202 -0
  19. data/docs/implementation_notes.md +63 -0
  20. data/docs/ruby_27_mysql_57.md +33 -0
  21. data/examples/clients_phone_migration.sql +6 -0
  22. data/examples/keys.env.example +6 -0
  23. data/examples/ruby_usage.rb +15 -0
  24. data/exe/secure_db_fields +49 -0
  25. data/ext/secure_db_fields/extconf.rb +21 -0
  26. data/ext/secure_db_fields/secure_db_fields_core.c +723 -0
  27. data/ext/secure_db_fields/secure_db_fields_core.h +140 -0
  28. data/ext/secure_db_fields/secure_db_fields_ext.c +426 -0
  29. data/lib/secure_db_fields/active_record.rb +58 -0
  30. data/lib/secure_db_fields/crypto.rb +115 -0
  31. data/lib/secure_db_fields/db_deployment.rb +285 -0
  32. data/lib/secure_db_fields/keys.rb +69 -0
  33. data/lib/secure_db_fields/phone.rb +17 -0
  34. data/lib/secure_db_fields/version.rb +5 -0
  35. data/lib/secure_db_fields.rb +8 -0
  36. data/mysql_udf/Makefile +25 -0
  37. data/mysql_udf/README.md +51 -0
  38. data/mysql_udf/bin/install +11 -0
  39. data/mysql_udf/bin/uninstall +4 -0
  40. data/mysql_udf/bin/verify +3 -0
  41. data/mysql_udf/sql/examples.sql +20 -0
  42. data/mysql_udf/sql/install.sql +20 -0
  43. data/mysql_udf/sql/uninstall.sql +8 -0
  44. data/mysql_udf/src/mysql_udf_abi_57.h +34 -0
  45. data/mysql_udf/src/secure_db_fields_mysql.c +513 -0
  46. data/mysql_udf/test/run_e2e.sh +216 -0
  47. metadata +137 -0
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SecureDBFields
4
+ module Crypto
5
+ module_function
6
+
7
+ ENVELOPE_MAGIC = "MCEN".b
8
+ KEY_BYTES = 32
9
+ BIDX_BYTES = 32
10
+
11
+ def encrypt(value, key:, aad:, key_id: 1)
12
+ raise ArgumentError, "value must not be nil" if value.nil?
13
+ Native.encrypt(value.to_s, assert_key!(key), aad.to_s, Integer(key_id))
14
+ end
15
+
16
+ def decrypt(envelope, key:, aad:)
17
+ raise ArgumentError, "envelope must not be nil" if envelope.nil?
18
+ Native.decrypt(envelope.to_s, assert_key!(key), aad.to_s)
19
+ end
20
+
21
+ def key_id(envelope)
22
+ Native.key_id(envelope.to_s)
23
+ end
24
+
25
+ def valid_envelope?(envelope)
26
+ return false if envelope.nil?
27
+ Native.valid_envelope?(envelope.to_s)
28
+ end
29
+
30
+ def blind_index(value, key:)
31
+ raise ArgumentError, "value must not be nil" if value.nil?
32
+ Native.blind_index(value.to_s, assert_key!(key))
33
+ end
34
+
35
+ def phone_blind_index(e164, key:)
36
+ Native.phone_blind_index(e164.to_s, assert_key!(key))
37
+ end
38
+
39
+ def phone_prefix_blind_index(e164, prefix_digits:, key:)
40
+ Native.phone_prefix_blind_index(e164.to_s, Integer(prefix_digits), assert_key!(key))
41
+ end
42
+
43
+ def encrypt_raw(value, key, aad, key_id = 1)
44
+ Native.encrypt(value, key, aad, key_id)
45
+ end
46
+
47
+ def decrypt_raw(envelope, key, aad)
48
+ Native.decrypt(envelope, key, aad)
49
+ end
50
+
51
+ def blind_index_raw(value, key)
52
+ Native.blind_index(value, key)
53
+ end
54
+
55
+ def phone_blind_index_raw(e164, key)
56
+ Native.phone_blind_index(e164, key)
57
+ end
58
+
59
+ def phone_prefix_blind_index_raw(e164, prefix_digits, key)
60
+ Native.phone_prefix_blind_index(e164, prefix_digits, key)
61
+ end
62
+
63
+ def encrypt_many(values, key:, aads:, key_id: 1)
64
+ Native.encrypt_many(values, assert_key!(key), aads, Integer(key_id))
65
+ end
66
+
67
+ def decrypt_many(envelopes, key:, aads:)
68
+ Native.decrypt_many(envelopes, assert_key!(key), aads)
69
+ end
70
+
71
+ def blind_index_many(values, key:)
72
+ Native.blind_index_many(values, assert_key!(key))
73
+ end
74
+
75
+ def blind_index_many_packed(values, key:)
76
+ Native.blind_index_many_packed(values, assert_key!(key))
77
+ end
78
+
79
+ def phone_blind_index_many(e164_values, key:)
80
+ Native.phone_blind_index_many(e164_values, assert_key!(key))
81
+ end
82
+
83
+ def phone_blind_index_many_packed(e164_values, key:)
84
+ Native.phone_blind_index_many_packed(e164_values, assert_key!(key))
85
+ end
86
+
87
+ def phone_prefix_blind_index_many(e164_values, prefix_digits:, key:)
88
+ Native.phone_prefix_blind_index_many(e164_values, Integer(prefix_digits), assert_key!(key))
89
+ end
90
+
91
+ def phone_prefix_blind_index_many_packed(e164_values, prefix_digits:, key:)
92
+ Native.phone_prefix_blind_index_many_packed(e164_values, Integer(prefix_digits), assert_key!(key))
93
+ end
94
+
95
+ def hex_decode_key(hex)
96
+ Native.hex_decode_key(hex.to_s)
97
+ end
98
+
99
+ def assert_key!(key)
100
+ key = key.to_s
101
+ raise KeyError, "key must be exactly #{KEY_BYTES} bytes" unless key.bytesize == KEY_BYTES
102
+ key
103
+ end
104
+
105
+ def aad(table, column, secure_row_uid)
106
+ uid = secure_row_uid.to_s
107
+ raise ArgumentError, "secure_row_uid must be 16 bytes" unless uid.bytesize == 16
108
+ table = table.to_s.b
109
+ column = column.to_s.b
110
+ raise ArgumentError, "table name is too long" if table.bytesize > 0xffffffff
111
+ raise ArgumentError, "column name is too long" if column.bytesize > 0xffffffff
112
+ "SDF1".b + [table.bytesize].pack("N") + table + [column.bytesize].pack("N") + column + uid.b
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,285 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "fileutils"
5
+ require "optparse"
6
+ require "rubygems/package"
7
+ require "securerandom"
8
+ require "stringio"
9
+ require "tmpdir"
10
+ require "zlib"
11
+ require_relative "version"
12
+
13
+ module SecureDBFields
14
+ module DBDeployment
15
+ ROOT = File.expand_path("../..", __dir__)
16
+ BUNDLE_FORMAT_VERSION = 1
17
+ MYSQL_TARGET = "mysql"
18
+
19
+ class Error < StandardError; end
20
+
21
+ class CLI
22
+ def self.run(argv)
23
+ new.run(argv)
24
+ end
25
+
26
+ def run(argv)
27
+ command = argv.shift
28
+ case command
29
+ when "package"
30
+ PackageCommand.new.run(argv)
31
+ when "view"
32
+ ViewCommand.new.run(argv)
33
+ when "help", "--help", "-h", nil
34
+ puts help
35
+ 0
36
+ else
37
+ raise OptionParser::InvalidOption, "unknown db command: #{command.inspect}"
38
+ end
39
+ rescue OptionParser::ParseError, Error => e
40
+ warn "secure_db_fields db: #{e.message}"
41
+ 2
42
+ rescue SystemCallError, IOError => e
43
+ warn "secure_db_fields db: #{e.message}"
44
+ 1
45
+ end
46
+
47
+ private
48
+
49
+ def help
50
+ <<~TEXT
51
+ Usage:
52
+ secure_db_fields db package mysql [--output PATH] [--force]
53
+
54
+ secure_db_fields db view mysql --table DB.TABLE --field NAME:ENC_COLUMN \\
55
+ --uid-column secure_row_uid --view DB.VIEW --columns ID,CREATED_AT \\
56
+ [--as NAME] [--output PATH]
57
+
58
+ `package` creates a self-contained source bundle for the DBA. The DBA
59
+ extracts it on the database host and runs `make doctor`, `sudo make
60
+ install`, `make enable`, then `make status`.
61
+
62
+ `view` emits an admin readable view. It is for projection/display only;
63
+ indexed search must use physical bidx columns and helper procedures or
64
+ query templates.
65
+ TEXT
66
+ end
67
+ end
68
+
69
+ class PackageCommand
70
+ def run(argv)
71
+ target = argv.shift
72
+ raise OptionParser::MissingArgument, "TARGET must be mysql" if target.nil?
73
+ raise OptionParser::InvalidArgument, "TARGET must be mysql" unless target == MYSQL_TARGET
74
+
75
+ options = { output: nil, force: false }
76
+ parser = OptionParser.new do |opts|
77
+ opts.banner = "Usage: secure_db_fields db package mysql [OPTIONS]"
78
+ opts.on("-o", "--output PATH", "write the deployment archive to PATH") { |value| options[:output] = value }
79
+ opts.on("-f", "--force", "overwrite an existing archive") { options[:force] = true }
80
+ opts.on("-h", "--help", "show this help") { puts opts; return 0 }
81
+ end
82
+ parser.parse!(argv)
83
+ raise OptionParser::InvalidOption, argv.join(" ") unless argv.empty?
84
+
85
+ output = options[:output] || "secure_db_fields-mysql-#{SecureDBFields::VERSION}.tar.gz"
86
+ DeploymentBundle.new(output, force: options[:force]).write!
87
+ puts File.expand_path(output)
88
+ 0
89
+ end
90
+ end
91
+
92
+ class ViewCommand
93
+ IDENTIFIER = /\A[A-Za-z_][A-Za-z0-9_]*\z/.freeze
94
+ QUALIFIED_IDENTIFIER = /\A[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)?\z/.freeze
95
+
96
+ def run(argv)
97
+ target = argv.shift
98
+ raise OptionParser::MissingArgument, "TARGET must be mysql" if target.nil?
99
+ raise OptionParser::InvalidArgument, "TARGET must be mysql" unless target == MYSQL_TARGET
100
+
101
+ options = { columns: [], output: nil, as: nil, uid_column: "secure_row_uid" }
102
+ parser = OptionParser.new do |opts|
103
+ opts.banner = "Usage: secure_db_fields db view mysql --table DB.TABLE --field NAME:ENC_COLUMN --view DB.VIEW [OPTIONS]"
104
+ opts.on("--table NAME", "source table, optionally database-qualified") { |value| options[:table] = value }
105
+ opts.on("--field SPEC", "plaintext:encrypted_column, e.g. phone:phone_enc") { |value| options[:field] = value }
106
+ opts.on("--uid-column NAME", "row UID column for AAD (default: secure_row_uid)") { |value| options[:uid_column] = value }
107
+ opts.on("--view NAME", "destination view, optionally database-qualified") { |value| options[:view] = value }
108
+ opts.on("--columns LIST", "comma-separated plain columns to expose") { |value| options[:columns] = value.split(",").map(&:strip).reject(&:empty?) }
109
+ opts.on("--as NAME", "decrypted column alias (default: plaintext field name)") { |value| options[:as] = value }
110
+ opts.on("-o", "--output PATH", "write SQL to PATH instead of stdout") { |value| options[:output] = value }
111
+ opts.on("-h", "--help", "show this help") { puts opts; return 0 }
112
+ end
113
+ parser.parse!(argv)
114
+ raise OptionParser::InvalidOption, argv.join(" ") unless argv.empty?
115
+
116
+ sql = ReadableView.new(options).to_sql
117
+ if options[:output]
118
+ write_text_atomically(options[:output], sql)
119
+ puts File.expand_path(options[:output])
120
+ else
121
+ $stdout.write(sql)
122
+ end
123
+ 0
124
+ end
125
+
126
+ private
127
+
128
+ def write_text_atomically(path, contents)
129
+ directory = File.dirname(File.expand_path(path))
130
+ FileUtils.mkdir_p(directory)
131
+ temporary = File.join(directory, "secure-db-fields-view-#{Process.pid}-#{SecureRandom.hex(8)}.tmp")
132
+ File.open(temporary, "wb", 0o644) { |file| file.write(contents) }
133
+ File.rename(temporary, path)
134
+ ensure
135
+ File.delete(temporary) if defined?(temporary) && File.exist?(temporary)
136
+ end
137
+ end
138
+
139
+ class ReadableView
140
+ IDENTIFIER = ViewCommand::IDENTIFIER
141
+ QUALIFIED_IDENTIFIER = ViewCommand::QUALIFIED_IDENTIFIER
142
+
143
+ def initialize(options)
144
+ @table = qualified_identifier!(options.fetch(:table), "--table")
145
+ @view = qualified_identifier!(options.fetch(:view), "--view")
146
+ @uid_column = identifier!(options.fetch(:uid_column), "--uid-column")
147
+ field = options.fetch(:field)
148
+ @plain_field, @encrypted_column = parse_field(field)
149
+ @alias = identifier!(options[:as] || @plain_field, "--as")
150
+ @columns = Array(options.fetch(:columns)).map { |column| identifier!(column, "--columns") }
151
+ @aad_table = @table.split(".").last
152
+ rescue KeyError => e
153
+ raise OptionParser::MissingArgument, e.key.to_s
154
+ end
155
+
156
+ def to_sql
157
+ select_list = (@columns.map { |column| " `#{column}`" } + [decrypt_expression]).join(",\n")
158
+ <<~SQL
159
+ CREATE OR REPLACE ALGORITHM=MERGE VIEW #{quote_qualified(@view)} AS
160
+ SELECT
161
+ #{select_list}
162
+ FROM #{quote_qualified(@table)};
163
+ SQL
164
+ end
165
+
166
+ private
167
+
168
+ def decrypt_expression
169
+ " secure_db_fields_decrypt_field(`#{@encrypted_column}`, '#{sql_literal(@aad_table)}', '#{sql_literal(@plain_field)}', `#{@uid_column}`) AS `#{@alias}`"
170
+ end
171
+
172
+ def parse_field(field)
173
+ unless field && field.include?(":")
174
+ raise OptionParser::MissingArgument, "--field plaintext:encrypted_column"
175
+ end
176
+ plain, encrypted = field.split(":", 2)
177
+ [identifier!(plain, "--field plaintext"), identifier!(encrypted, "--field encrypted_column")]
178
+ end
179
+
180
+ def identifier!(value, flag)
181
+ text = value.to_s
182
+ raise OptionParser::InvalidArgument, "#{flag} must be a SQL identifier" unless IDENTIFIER.match?(text)
183
+ text
184
+ end
185
+
186
+ def qualified_identifier!(value, flag)
187
+ text = value.to_s
188
+ raise OptionParser::InvalidArgument, "#{flag} must be a SQL identifier or DB.TABLE" unless QUALIFIED_IDENTIFIER.match?(text)
189
+ text
190
+ end
191
+
192
+ def quote_qualified(name)
193
+ name.split(".").map { |part| "`#{part}`" }.join(".")
194
+ end
195
+
196
+ def sql_literal(value)
197
+ value.gsub("'", "''")
198
+ end
199
+ end
200
+
201
+ class DeploymentBundle
202
+ BUNDLE_FILES = [
203
+ ["db_deployment/mysql/Makefile", "Makefile"],
204
+ ["db_deployment/mysql/README.md", "README.md"],
205
+ ["db_deployment/mysql/bin/common", "bin/common", 0o755],
206
+ ["db_deployment/mysql/bin/doctor", "bin/doctor", 0o755],
207
+ ["db_deployment/mysql/bin/install", "bin/install", 0o755],
208
+ ["db_deployment/mysql/bin/enable", "bin/enable", 0o755],
209
+ ["db_deployment/mysql/bin/disable", "bin/disable", 0o755],
210
+ ["db_deployment/mysql/bin/status", "bin/status", 0o755],
211
+ ["db_deployment/mysql/bin/verify", "bin/verify", 0o755],
212
+ ["db_deployment/mysql/bin/uninstall", "bin/uninstall", 0o755],
213
+ ["db_deployment/mysql/sql/install.sql", "sql/install.sql"],
214
+ ["db_deployment/mysql/sql/uninstall.sql", "sql/uninstall.sql"],
215
+ ["db_deployment/mysql/sql/examples.sql", "sql/examples.sql"],
216
+ ["mysql_udf/src/secure_db_fields_mysql.c", "src/secure_db_fields_mysql.c"],
217
+ ["mysql_udf/src/mysql_udf_abi_57.h", "src/mysql_udf_abi_57.h"],
218
+ ["ext/secure_db_fields/secure_db_fields_core.c", "src/secure_db_fields_core.c"],
219
+ ["ext/secure_db_fields/secure_db_fields_core.h", "src/secure_db_fields_core.h"]
220
+ ].freeze
221
+
222
+ attr_reader :output
223
+
224
+ def initialize(output, force: false)
225
+ @output = output
226
+ @force = force
227
+ end
228
+
229
+ def write!
230
+ path = File.expand_path(output)
231
+ raise Error, "#{path} already exists (use --force)" if File.exist?(path) && !@force
232
+
233
+ FileUtils.mkdir_p(File.dirname(path))
234
+ tmp = File.join(File.dirname(path), "secure-db-fields-db-bundle-#{Process.pid}-#{SecureRandom.hex(8)}.tar.gz.tmp")
235
+ Zlib::GzipWriter.open(tmp) do |gz|
236
+ Gem::Package::TarWriter.new(gz) do |tar|
237
+ BUNDLE_FILES.each do |src, dst, mode|
238
+ add_file(tar, src, dst, mode || 0o644)
239
+ end
240
+ add_text(tar, "VERSION", SecureDBFields::VERSION + "\n")
241
+ add_text(tar, "BUNDLE_FORMAT", BUNDLE_FORMAT_VERSION.to_s + "\n")
242
+ add_text(tar, "SHA256SUMS", checksums)
243
+ end
244
+ end
245
+ File.rename(tmp, path)
246
+ ensure
247
+ File.delete(tmp) if defined?(tmp) && File.exist?(tmp)
248
+ end
249
+
250
+ def bundle_name
251
+ "secure_db_fields-mysql-#{SecureDBFields::VERSION}"
252
+ end
253
+
254
+ private
255
+
256
+ def add_file(tar, src, dst, mode)
257
+ full = File.join(ROOT, src)
258
+ raise Error, "missing bundle source: #{src}" unless File.file?(full)
259
+ data = File.binread(full)
260
+ add_bytes(tar, File.join(bundle_name, dst), data, mode)
261
+ end
262
+
263
+ def add_text(tar, dst, data)
264
+ add_bytes(tar, File.join(bundle_name, dst), data, 0o644)
265
+ end
266
+
267
+ def add_bytes(tar, name, data, mode)
268
+ entry = StringIO.new(data)
269
+ tar.add_file_simple(name, mode, data.bytesize) { |io| IO.copy_stream(entry, io) }
270
+ end
271
+
272
+ def checksums
273
+ entries = BUNDLE_FILES.map do |src, dst, _mode|
274
+ [dst, File.binread(File.join(ROOT, src))]
275
+ end
276
+ entries << ["VERSION", SecureDBFields::VERSION + "\n"]
277
+ entries << ["BUNDLE_FORMAT", BUNDLE_FORMAT_VERSION.to_s + "\n"]
278
+
279
+ entries.sort_by(&:first).map do |dst, data|
280
+ "#{Digest::SHA256.hexdigest(data)} #{dst}"
281
+ end.join("\n") + "\n"
282
+ end
283
+ end
284
+ end
285
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SecureDBFields
4
+ class Keyring
5
+ DEFAULT_PATH = "/etc/secure_db_fields/keys.env"
6
+
7
+ attr_reader :path
8
+
9
+ def initialize(path = ENV.fetch("SECURE_DB_FIELDS_KEY_FILE", DEFAULT_PATH))
10
+ @path = path
11
+ @values = parse_file(path)
12
+ end
13
+
14
+ def encryption_key(key_id = 1)
15
+ fetch_hex_key("SDF_ENC_KEY_#{Integer(key_id)}_HEX") ||
16
+ (Integer(key_id) == 1 ? fetch_hex_key("SDF_ENC_KEY_HEX") : nil) ||
17
+ raise(KeyError, "missing encryption key for key_id=#{key_id}")
18
+ end
19
+
20
+ def blind_index_key(domain = nil)
21
+ if domain && !domain.to_s.empty?
22
+ name = "SDF_BIDX_#{normalize_domain(domain)}_KEY_HEX"
23
+ fetch_hex_key(name) || raise(KeyError, "missing blind-index key #{name}")
24
+ else
25
+ fetch_hex_key("SDF_BIDX_KEY_HEX") || raise(KeyError, "missing SDF_BIDX_KEY_HEX")
26
+ end
27
+ end
28
+
29
+ def active_key_id
30
+ value = @values["SDF_ACTIVE_KEY_ID"]
31
+ value ? Integer(value) : 1
32
+ end
33
+
34
+ private
35
+
36
+ def normalize_domain(domain)
37
+ domain.to_s.upcase.gsub(/[^A-Z0-9_]/, "_")
38
+ end
39
+
40
+ def fetch_hex_key(name)
41
+ @values[name]
42
+ end
43
+
44
+ def parse_file(path)
45
+ validate_file_permissions!(path)
46
+ values = {}
47
+ File.readlines(path, chomp: true).each do |line|
48
+ stripped = line.strip
49
+ next if stripped.empty? || stripped.start_with?("#")
50
+ key, value = stripped.split("=", 2)
51
+ next unless key && value
52
+ name = key.strip
53
+ text = value.strip
54
+ values[name] = name.end_with?("_HEX") ? Crypto.hex_decode_key(text) : text
55
+ end
56
+ values.freeze
57
+ rescue Errno::ENOENT
58
+ raise KeyError, "key file not found: #{path}"
59
+ end
60
+
61
+ def validate_file_permissions!(path)
62
+ info = File.lstat(path)
63
+ raise KeyError, "key file must not be a symlink: #{path}" if info.symlink?
64
+ stat = File.stat(path)
65
+ raise KeyError, "key file must be a regular file: #{path}" unless stat.file?
66
+ raise KeyError, "key file permissions are too open: #{path}" unless (stat.mode & 0o027).zero?
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SecureDBFields
4
+ module Phone
5
+ module_function
6
+
7
+ def canonical_e164?(value)
8
+ Native.e164?(value.to_s)
9
+ end
10
+
11
+ def assert_canonical_e164!(value)
12
+ str = value.to_s
13
+ raise ArgumentError, "phone must be canonical E.164, e.g. +77771234567" unless canonical_e164?(str)
14
+ str
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SecureDBFields
4
+ VERSION = "0.1.1"
5
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "secure_db_fields/version"
4
+ require_relative "secure_db_fields/secure_db_fields"
5
+ require_relative "secure_db_fields/crypto"
6
+ require_relative "secure_db_fields/keys"
7
+ require_relative "secure_db_fields/phone"
8
+ require_relative "secure_db_fields/active_record"
@@ -0,0 +1,25 @@
1
+ CC ?= cc
2
+ CFLAGS ?= -O2 -fPIC -Wall -Wextra -Wformat -Wformat-security -fstack-protector-strong -D_FORTIFY_SOURCE=2 -std=c99
3
+ LDFLAGS ?= -shared
4
+ LIBS ?= -lcrypto
5
+ MYSQL_CFLAGS ?= $(shell mysql_config --cflags 2>/dev/null)
6
+ MYSQL_LIBDIR ?= $(shell mysql_config --plugindir 2>/dev/null)
7
+
8
+ SRC = src/secure_db_fields_mysql.c ../ext/secure_db_fields/secure_db_fields_core.c
9
+ OUT = secure_db_fields_mysql.so
10
+
11
+ all: $(OUT)
12
+
13
+ $(OUT): $(SRC)
14
+ $(CC) $(CFLAGS) $(MYSQL_CFLAGS) -I../ext/secure_db_fields -Isrc $(LDFLAGS) -o $@ $(SRC) $(LIBS)
15
+
16
+ abi57:
17
+ $(CC) $(CFLAGS) -DSDF_MYSQL_UDF_ABI_57 -I../ext/secure_db_fields -Isrc $(LDFLAGS) -o $(OUT) $(SRC) $(LIBS)
18
+
19
+ install: $(OUT)
20
+ @if [ -z "$(MYSQL_LIBDIR)" ]; then echo "mysql_config --plugindir failed"; exit 1; fi
21
+ install -m 0755 $(OUT) $(MYSQL_LIBDIR)/$(OUT)
22
+
23
+ clean:
24
+ rm -f $(OUT) *.o
25
+ .PHONY: all abi57 install clean
@@ -0,0 +1,51 @@
1
+ # secure_db_fields MySQL UDF
2
+
3
+ This UDF is the DB-side reader/search-token helper for MySQL 5.7. It is intentionally
4
+ narrow: decrypt for admin projections and compute blind-index tokens for declared
5
+ search modes. It does not normalize phone input; pass canonical E.164 values.
6
+
7
+ ## Compatibility
8
+
9
+ Target: MySQL 5.7. The package ships a minimal 5.7 ABI shim for build environments where `mysql_config` or MySQL headers are unavailable. Do not use MySQL 8-only functional-index designs with this UDF; bidx values must be physical columns populated by Ruby/backfill.
10
+
11
+ ## Build
12
+
13
+ ```bash
14
+ cd mysql_udf
15
+ make
16
+ sudo make install
17
+ mysql < sql/install.sql
18
+ ```
19
+
20
+ If `mysql_config` is not available, build with the minimal MySQL 5.7 ABI shim:
21
+
22
+ ```bash
23
+ make abi57
24
+ sudo install -m 0755 secure_db_fields_mysql.so /path/to/mysql/plugin_dir/
25
+ ```
26
+
27
+ ## Keys
28
+
29
+ The UDF reads keys from `/etc/secure_db_fields/keys.env` or from
30
+ `SECURE_DB_FIELDS_KEY_FILE` when that environment variable is visible to mysqld.
31
+ Never pass keys as SQL arguments.
32
+
33
+ Example:
34
+
35
+ ```env
36
+ SDF_ACTIVE_KEY_ID=1
37
+ SDF_ENC_KEY_1_HEX=0000000000000000000000000000000000000000000000000000000000000001
38
+ SDF_BIDX_PHONE_KEY_HEX=0000000000000000000000000000000000000000000000000000000000000002
39
+ SDF_BIDX_PHONE_P7_KEY_HEX=0000000000000000000000000000000000000000000000000000000000000003
40
+ ```
41
+
42
+ ## Functions
43
+
44
+ - `secure_db_fields_version()`
45
+ - `secure_db_fields_is_valid_envelope(blob)`
46
+ - `secure_db_fields_envelope_key_id(blob)`
47
+ - `secure_db_fields_decrypt(blob, aad)`
48
+ - `secure_db_fields_decrypt_field(blob, table_name, column_name, row_uid)`
49
+ - `secure_db_fields_bidx(value, domain)`
50
+ - `secure_phone_bidx(e164)`
51
+ - `secure_phone_prefix_bidx(e164, prefix_digits)`
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ cd "$(dirname "$0")/.."
4
+ make
5
+ plugin_dir="${MYSQL_PLUGIN_DIR:-$(mysql_config --plugindir 2>/dev/null || true)}"
6
+ if [[ -z "${plugin_dir}" ]]; then
7
+ echo "Set MYSQL_PLUGIN_DIR or install mysql_config" >&2
8
+ exit 1
9
+ fi
10
+ sudo install -m 0755 secure_db_fields_mysql.so "${plugin_dir}/secure_db_fields_mysql.so"
11
+ mysql ${MYSQL_ARGS:-} < sql/install.sql
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ cd "$(dirname "$0")/.."
4
+ mysql ${MYSQL_ARGS:-} < sql/uninstall.sql
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ mysql ${MYSQL_ARGS:-} -e "SELECT secure_db_fields_version() AS version;"
@@ -0,0 +1,20 @@
1
+ ALTER TABLE clients
2
+ ADD COLUMN secure_row_uid BINARY(16) NULL,
3
+ ADD COLUMN phone_enc VARBINARY(512) NULL,
4
+ ADD COLUMN phone_bidx BINARY(32) NULL,
5
+ ADD INDEX idx_clients_phone_bidx (phone_bidx);
6
+
7
+
8
+ CREATE OR REPLACE VIEW admin_clients_readable AS
9
+ SELECT
10
+ id,
11
+ secure_db_fields_decrypt_field(phone_enc, 'clients', 'phone', secure_row_uid) AS phone,
12
+ created_at
13
+ FROM clients;
14
+
15
+ SELECT
16
+ id,
17
+ secure_db_fields_decrypt_field(phone_enc, 'clients', 'phone', secure_row_uid) AS phone,
18
+ created_at
19
+ FROM clients
20
+ WHERE phone_bidx = secure_phone_bidx('+77771234567');
@@ -0,0 +1,20 @@
1
+
2
+ DROP FUNCTION IF EXISTS secure_db_fields_version;
3
+ DROP FUNCTION IF EXISTS secure_db_fields_is_valid_envelope;
4
+ DROP FUNCTION IF EXISTS secure_db_fields_envelope_key_id;
5
+ DROP FUNCTION IF EXISTS secure_db_fields_decrypt;
6
+ DROP FUNCTION IF EXISTS secure_db_fields_decrypt_field;
7
+ DROP FUNCTION IF EXISTS secure_db_fields_bidx;
8
+ DROP FUNCTION IF EXISTS secure_phone_bidx;
9
+ DROP FUNCTION IF EXISTS secure_phone_prefix_bidx;
10
+
11
+ CREATE FUNCTION secure_db_fields_version RETURNS STRING SONAME 'secure_db_fields_mysql.so';
12
+ CREATE FUNCTION secure_db_fields_is_valid_envelope RETURNS INTEGER SONAME 'secure_db_fields_mysql.so';
13
+ CREATE FUNCTION secure_db_fields_envelope_key_id RETURNS INTEGER SONAME 'secure_db_fields_mysql.so';
14
+ CREATE FUNCTION secure_db_fields_decrypt RETURNS STRING SONAME 'secure_db_fields_mysql.so';
15
+ CREATE FUNCTION secure_db_fields_decrypt_field RETURNS STRING SONAME 'secure_db_fields_mysql.so';
16
+ CREATE FUNCTION secure_db_fields_bidx RETURNS STRING SONAME 'secure_db_fields_mysql.so';
17
+ CREATE FUNCTION secure_phone_bidx RETURNS STRING SONAME 'secure_db_fields_mysql.so';
18
+ CREATE FUNCTION secure_phone_prefix_bidx RETURNS STRING SONAME 'secure_db_fields_mysql.so';
19
+
20
+ SELECT secure_db_fields_version();
@@ -0,0 +1,8 @@
1
+ DROP FUNCTION IF EXISTS secure_phone_prefix_bidx;
2
+ DROP FUNCTION IF EXISTS secure_phone_bidx;
3
+ DROP FUNCTION IF EXISTS secure_db_fields_bidx;
4
+ DROP FUNCTION IF EXISTS secure_db_fields_decrypt_field;
5
+ DROP FUNCTION IF EXISTS secure_db_fields_decrypt;
6
+ DROP FUNCTION IF EXISTS secure_db_fields_envelope_key_id;
7
+ DROP FUNCTION IF EXISTS secure_db_fields_is_valid_envelope;
8
+ DROP FUNCTION IF EXISTS secure_db_fields_version;