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.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +98 -0
- data/Rakefile +16 -0
- data/db_deployment/mysql/Makefile +62 -0
- data/db_deployment/mysql/README.md +33 -0
- data/db_deployment/mysql/bin/common +199 -0
- data/db_deployment/mysql/bin/disable +40 -0
- data/db_deployment/mysql/bin/doctor +57 -0
- data/db_deployment/mysql/bin/enable +56 -0
- data/db_deployment/mysql/bin/install +64 -0
- data/db_deployment/mysql/bin/status +11 -0
- data/db_deployment/mysql/bin/uninstall +46 -0
- data/db_deployment/mysql/bin/verify +27 -0
- data/db_deployment/mysql/sql/examples.sql +20 -0
- data/db_deployment/mysql/sql/install.sql +20 -0
- data/db_deployment/mysql/sql/uninstall.sql +8 -0
- data/docs/final_decision.md +202 -0
- data/docs/implementation_notes.md +63 -0
- data/docs/ruby_27_mysql_57.md +33 -0
- data/examples/clients_phone_migration.sql +6 -0
- data/examples/keys.env.example +6 -0
- data/examples/ruby_usage.rb +15 -0
- data/exe/secure_db_fields +49 -0
- data/ext/secure_db_fields/extconf.rb +21 -0
- data/ext/secure_db_fields/secure_db_fields_core.c +723 -0
- data/ext/secure_db_fields/secure_db_fields_core.h +140 -0
- data/ext/secure_db_fields/secure_db_fields_ext.c +426 -0
- data/lib/secure_db_fields/active_record.rb +58 -0
- data/lib/secure_db_fields/crypto.rb +115 -0
- data/lib/secure_db_fields/db_deployment.rb +285 -0
- data/lib/secure_db_fields/keys.rb +69 -0
- data/lib/secure_db_fields/phone.rb +17 -0
- data/lib/secure_db_fields/version.rb +5 -0
- data/lib/secure_db_fields.rb +8 -0
- data/mysql_udf/Makefile +25 -0
- data/mysql_udf/README.md +51 -0
- data/mysql_udf/bin/install +11 -0
- data/mysql_udf/bin/uninstall +4 -0
- data/mysql_udf/bin/verify +3 -0
- data/mysql_udf/sql/examples.sql +20 -0
- data/mysql_udf/sql/install.sql +20 -0
- data/mysql_udf/sql/uninstall.sql +8 -0
- data/mysql_udf/src/mysql_udf_abi_57.h +34 -0
- data/mysql_udf/src/secure_db_fields_mysql.c +513 -0
- data/mysql_udf/test/run_e2e.sh +216 -0
- 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,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"
|
data/mysql_udf/Makefile
ADDED
|
@@ -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
|
data/mysql_udf/README.md
ADDED
|
@@ -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,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;
|