bzync-nextsql 0.1.1 → 0.2.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 +4 -4
- data/README.md +16 -0
- data/lib/nextsql/client.rb +72 -4
- data/lib/nextsql/field_encryption.rb +823 -0
- data/lib/nextsql/pool.rb +215 -0
- data/lib/nextsql/version.rb +1 -1
- data/lib/nextsql.rb +7 -0
- metadata +6 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0146d77d1a64f774c074a7675c2c10be3cb9c00b65e1e60fead33c7baf5d4b48
|
|
4
|
+
data.tar.gz: 6490a676adbdc20b430a45eebd356d155d5f882ab76aab8b94af4a6302cbe5cf
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6d7e2921ed7f87454c6d65893d06178a9f776dc451b04e0258005f8a7d210f870f4fc9fc94e6cf9ad69a650b43a58b0bc22814844772ab96c7e968b31181bf06
|
|
7
|
+
data.tar.gz: 7cc3a24342dbaacb69cb828c6fc7214b618dce91eb6ca4c832019d681e5f7915da4c5f8456039805189fe4eaeb80f9ce5cbec231a912014ff5d18cb78b9d6042
|
data/README.md
CHANGED
|
@@ -31,7 +31,23 @@ end
|
|
|
31
31
|
Plaintext connections are allowed only on loopback. For an HA cluster with
|
|
32
32
|
follower-read routing, use `NextSQL.connect_cluster`.
|
|
33
33
|
|
|
34
|
+
### Connection Pooling & Statement Caching
|
|
35
|
+
|
|
36
|
+
```ruby
|
|
37
|
+
pool = NextSQL.create_pool(cfg, min_size: 2, max_size: 10, statement_cache_size: 50)
|
|
38
|
+
begin
|
|
39
|
+
pool.with do |conn|
|
|
40
|
+
res = conn.exec("SELECT * FROM users WHERE status = $1", ["active"])
|
|
41
|
+
end
|
|
42
|
+
ensure
|
|
43
|
+
pool.close
|
|
44
|
+
end
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Parameterized queries automatically reuse cached server-side prepared statements via LRU eviction policy.
|
|
48
|
+
|
|
34
49
|
- Full driver docs: <https://nextsql.bzync.com/docs/drivers>
|
|
35
50
|
- Wire protocol: <https://github.com/bzync/nextsql/blob/master/docs/protocol.md>
|
|
51
|
+
- Client-side field encryption: <https://nextsql.bzync.com/docs/client-encryption>
|
|
36
52
|
|
|
37
53
|
MIT licensed.
|
data/lib/nextsql/client.rb
CHANGED
|
@@ -10,6 +10,7 @@ require "ipaddr"
|
|
|
10
10
|
|
|
11
11
|
require_relative "protocol"
|
|
12
12
|
require_relative "errors"
|
|
13
|
+
require_relative "field_encryption"
|
|
13
14
|
|
|
14
15
|
module NextSQL
|
|
15
16
|
# TLS options for a remote connection. +ca+ is PEM text/bytes; omit both
|
|
@@ -24,12 +25,14 @@ module NextSQL
|
|
|
24
25
|
end
|
|
25
26
|
|
|
26
27
|
Config = Struct.new(:address, :nodes, :database, :realm, :user, :password, :key, :key_version, :tls,
|
|
27
|
-
:insecure_no_tls, :read_consistency, :max_staleness_ms, :timeout,
|
|
28
|
+
:insecure_no_tls, :read_consistency, :max_staleness_ms, :timeout, :field_keys,
|
|
29
|
+
:statement_cache_size, keyword_init: true) do
|
|
28
30
|
def initialize(**kwargs)
|
|
29
31
|
defaults = {
|
|
30
32
|
address: "", nodes: [], database: "", realm: "", user: "", password: "",
|
|
31
33
|
key: nil, key_version: 1, tls: nil, insecure_no_tls: false,
|
|
32
|
-
read_consistency: Protocol::READ_STRONG, max_staleness_ms: 0, timeout: 60.0
|
|
34
|
+
read_consistency: Protocol::READ_STRONG, max_staleness_ms: 0, timeout: 60.0,
|
|
35
|
+
field_keys: nil, statement_cache_size: 50
|
|
33
36
|
}
|
|
34
37
|
super(defaults.merge(kwargs))
|
|
35
38
|
end
|
|
@@ -95,6 +98,7 @@ module NextSQL
|
|
|
95
98
|
raise Error.new("invalid_argument", "plaintext is only allowed on loopback")
|
|
96
99
|
end
|
|
97
100
|
raise Error.new("invalid_argument", "user is required") if cfg.user.to_s.empty?
|
|
101
|
+
raise Error.new("invalid_argument", "statement_cache_size must be >= 0") if cfg.statement_cache_size && cfg.statement_cache_size < 0
|
|
98
102
|
end
|
|
99
103
|
|
|
100
104
|
LEADING_WS_RE = /\A[ \t\r\n(]+/.freeze
|
|
@@ -175,6 +179,8 @@ module NextSQL
|
|
|
175
179
|
@sock = self.class.dial(cfg)
|
|
176
180
|
@secret = "".b
|
|
177
181
|
@busy = false
|
|
182
|
+
@statement_cache_size = cfg.statement_cache_size || 0
|
|
183
|
+
@stmt_cache = {}
|
|
178
184
|
begin
|
|
179
185
|
handshake
|
|
180
186
|
set_read_consistency(cfg.read_consistency, cfg.max_staleness_ms) if cfg.read_consistency != Protocol::READ_STRONG
|
|
@@ -203,6 +209,30 @@ module NextSQL
|
|
|
203
209
|
st
|
|
204
210
|
end
|
|
205
211
|
|
|
212
|
+
def encrypt_field(table, column, field_type, value)
|
|
213
|
+
raise Error.new("invalid_argument", "field_keys is required for client encryption") unless @cfg.field_keys
|
|
214
|
+
|
|
215
|
+
FieldEncryption.encrypt(@cfg.field_keys, @cfg.database.to_s.empty? ? "default" : @cfg.database, table, column, field_type, value)
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def decrypt_field(table, column, expected_type, ciphertext)
|
|
219
|
+
raise Error.new("invalid_argument", "field_keys is required for client encryption") unless @cfg.field_keys
|
|
220
|
+
|
|
221
|
+
FieldEncryption.decrypt(@cfg.field_keys, @cfg.database.to_s.empty? ? "default" : @cfg.database, table, column, expected_type, ciphertext)
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def encrypt_field_deterministic(table, column, field_type, value)
|
|
225
|
+
raise Error.new("invalid_argument", "field_keys is required for client encryption") unless @cfg.field_keys
|
|
226
|
+
|
|
227
|
+
FieldEncryption.encrypt_deterministic(@cfg.field_keys, @cfg.database.to_s.empty? ? "default" : @cfg.database, table, column, field_type, value)
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def decrypt_field_deterministic(table, column, expected_type, ciphertext)
|
|
231
|
+
raise Error.new("invalid_argument", "field_keys is required for client encryption") unless @cfg.field_keys
|
|
232
|
+
|
|
233
|
+
FieldEncryption.decrypt_deterministic(@cfg.field_keys, @cfg.database.to_s.empty? ? "default" : @cfg.database, table, column, expected_type, ciphertext)
|
|
234
|
+
end
|
|
235
|
+
|
|
206
236
|
def exec(sql, params = [])
|
|
207
237
|
query(sql, params).collect
|
|
208
238
|
end
|
|
@@ -211,6 +241,29 @@ module NextSQL
|
|
|
211
241
|
raise Error.new("unavailable", "connection closed") if @sock.nil?
|
|
212
242
|
raise Error.new("conflict", "connection is busy") if @busy
|
|
213
243
|
|
|
244
|
+
if !params.empty? && @statement_cache_size > 0
|
|
245
|
+
stmt_id = @stmt_cache.delete(sql)
|
|
246
|
+
if stmt_id.nil?
|
|
247
|
+
stmt_id = prepare_id(sql)
|
|
248
|
+
if @stmt_cache.size >= @statement_cache_size
|
|
249
|
+
_evicted_sql, evicted_id = @stmt_cache.shift
|
|
250
|
+
begin
|
|
251
|
+
close_statement(evicted_id)
|
|
252
|
+
rescue StandardError
|
|
253
|
+
nil
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
@stmt_cache[sql] = stmt_id
|
|
258
|
+
|
|
259
|
+
begin
|
|
260
|
+
return execute_prepared(stmt_id, params)
|
|
261
|
+
rescue StandardError
|
|
262
|
+
@stmt_cache.delete(sql)
|
|
263
|
+
raise
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
|
|
214
267
|
@busy = true
|
|
215
268
|
begin
|
|
216
269
|
write_frame(Protocol::TYPE_QUERY, Protocol.encode_query(sql, params))
|
|
@@ -241,7 +294,7 @@ module NextSQL
|
|
|
241
294
|
end
|
|
242
295
|
end
|
|
243
296
|
|
|
244
|
-
def
|
|
297
|
+
def prepare_id(sql)
|
|
245
298
|
raise Error.new("conflict", "connection is busy") if @busy
|
|
246
299
|
|
|
247
300
|
write_frame(Protocol::TYPE_PREPARE, Protocol.u32bytes(sql.b, Protocol::MAX_SQL))
|
|
@@ -251,7 +304,11 @@ module NextSQL
|
|
|
251
304
|
|
|
252
305
|
stmt_id = Protocol.u32(payload, 0)
|
|
253
306
|
expect_ready
|
|
254
|
-
|
|
307
|
+
stmt_id
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
def prepare(sql)
|
|
311
|
+
Statement.new(self, prepare_id(sql))
|
|
255
312
|
end
|
|
256
313
|
|
|
257
314
|
def execute_prepared(stmt_id, params)
|
|
@@ -300,6 +357,17 @@ module NextSQL
|
|
|
300
357
|
def close
|
|
301
358
|
return if @sock.nil?
|
|
302
359
|
|
|
360
|
+
if @stmt_cache
|
|
361
|
+
@stmt_cache.each_value do |stmt_id|
|
|
362
|
+
begin
|
|
363
|
+
close_statement(stmt_id)
|
|
364
|
+
rescue StandardError
|
|
365
|
+
nil
|
|
366
|
+
end
|
|
367
|
+
end
|
|
368
|
+
@stmt_cache.clear
|
|
369
|
+
end
|
|
370
|
+
|
|
303
371
|
begin
|
|
304
372
|
write_frame(Protocol::TYPE_TERMINATE, "")
|
|
305
373
|
rescue Error
|
|
@@ -0,0 +1,823 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "base64"
|
|
4
|
+
require "openssl"
|
|
5
|
+
require "securerandom"
|
|
6
|
+
require "bigdecimal"
|
|
7
|
+
require "json"
|
|
8
|
+
|
|
9
|
+
require_relative "protocol"
|
|
10
|
+
require_relative "errors"
|
|
11
|
+
|
|
12
|
+
module NextSQL
|
|
13
|
+
FieldType = Struct.new(:kind, :precision, :scale, :vec_elem, keyword_init: true) do
|
|
14
|
+
def self.decimal(precision, scale)
|
|
15
|
+
if precision < 1 || precision > 38 || scale.negative? || scale > precision
|
|
16
|
+
raise Error.new("invalid_argument", "unsupported client-encrypted type")
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
new(kind: Protocol::KIND_DECIMAL, precision: precision, scale: scale, vec_elem: 0)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
FieldType::UUID = FieldType.new(kind: Protocol::KIND_UUID, precision: 0, scale: 0, vec_elem: 0).freeze
|
|
24
|
+
FieldType::STRING = FieldType.new(kind: Protocol::KIND_STRING, precision: 0, scale: 0, vec_elem: 0).freeze
|
|
25
|
+
FieldType::TEXT = FieldType.new(kind: Protocol::KIND_TEXT, precision: 0, scale: 0, vec_elem: 0).freeze
|
|
26
|
+
FieldType::BLOB = FieldType.new(kind: Protocol::KIND_BLOB, precision: 0, scale: 0, vec_elem: 0).freeze
|
|
27
|
+
FieldType::TIMESTAMPTZ = FieldType.new(kind: Protocol::KIND_TIMESTAMPTZ, precision: 0, scale: 0, vec_elem: 0).freeze
|
|
28
|
+
FieldType::JSON = FieldType.new(kind: Protocol::KIND_JSON, precision: 0, scale: 0, vec_elem: 0).freeze
|
|
29
|
+
FieldType::BOOL = FieldType.new(kind: Protocol::KIND_BOOL, precision: 0, scale: 0, vec_elem: 0).freeze
|
|
30
|
+
FieldType::INT8 = FieldType.new(kind: Protocol::KIND_INT8, precision: 0, scale: 0, vec_elem: 0).freeze
|
|
31
|
+
FieldType::INT16 = FieldType.new(kind: Protocol::KIND_INT16, precision: 0, scale: 0, vec_elem: 0).freeze
|
|
32
|
+
FieldType::INT32 = FieldType.new(kind: Protocol::KIND_INT32, precision: 0, scale: 0, vec_elem: 0).freeze
|
|
33
|
+
FieldType::INT64 = FieldType.new(kind: Protocol::KIND_INT64, precision: 0, scale: 0, vec_elem: 0).freeze
|
|
34
|
+
FieldType::UINT8 = FieldType.new(kind: Protocol::KIND_UINT8, precision: 0, scale: 0, vec_elem: 0).freeze
|
|
35
|
+
FieldType::UINT16 = FieldType.new(kind: Protocol::KIND_UINT16, precision: 0, scale: 0, vec_elem: 0).freeze
|
|
36
|
+
FieldType::UINT32 = FieldType.new(kind: Protocol::KIND_UINT32, precision: 0, scale: 0, vec_elem: 0).freeze
|
|
37
|
+
FieldType::UINT64 = FieldType.new(kind: Protocol::KIND_UINT64, precision: 0, scale: 0, vec_elem: 0).freeze
|
|
38
|
+
|
|
39
|
+
FieldKey = Struct.new(:id, :material, keyword_init: true) do
|
|
40
|
+
def initialize(id:, material:)
|
|
41
|
+
FieldEncryption.validate_key_id!(id)
|
|
42
|
+
material_bytes = material.b
|
|
43
|
+
if material_bytes.bytesize != FieldEncryption::KEY_SIZE || material_bytes == ("\x00".b * FieldEncryption::KEY_SIZE)
|
|
44
|
+
raise Error.new("invalid_argument", "invalid field key")
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
super(id: id, material: material_bytes)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
FieldKeyInfo = Struct.new(:id, :created, :current, :revoked, keyword_init: true)
|
|
52
|
+
|
|
53
|
+
class MemoryFieldKeyring
|
|
54
|
+
def initialize(current, overlap = [])
|
|
55
|
+
@keys = {}
|
|
56
|
+
@revoked = {}
|
|
57
|
+
all_keys = [current] + (overlap || [])
|
|
58
|
+
if all_keys.length > FieldEncryption::MAX_KEYS
|
|
59
|
+
raise Error.new("invalid_argument", "too many field keys")
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
seen = {}
|
|
63
|
+
all_keys.each do |k|
|
|
64
|
+
raise Error.new("invalid_argument", "invalid field key") unless k.is_a?(FieldKey)
|
|
65
|
+
raise Error.new("invalid_argument", "duplicate field key id") if seen[k.id]
|
|
66
|
+
|
|
67
|
+
seen[k.id] = true
|
|
68
|
+
@keys[k.id] = k
|
|
69
|
+
end
|
|
70
|
+
@current_id = current.id
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def current_field_key(_database, _table, _column)
|
|
74
|
+
k = @keys[@current_id]
|
|
75
|
+
raise Error.new("crypto", "current field key unavailable") unless k
|
|
76
|
+
|
|
77
|
+
k
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def field_key(_database, _table, _column, key_id)
|
|
81
|
+
if @revoked[key_id] || !@keys.key?(key_id)
|
|
82
|
+
raise Error.new("crypto", "field key unavailable or revoked")
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
@keys[key_id]
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def rotate(key)
|
|
89
|
+
raise Error.new("invalid_argument", "invalid field key") unless key.is_a?(FieldKey)
|
|
90
|
+
raise Error.new("conflict", "cannot reuse a revoked field key id") if @revoked[key.id]
|
|
91
|
+
|
|
92
|
+
if !@keys.key?(key.id) && @keys.length >= FieldEncryption::MAX_KEYS
|
|
93
|
+
raise Error.new("exhausted", "field key limit reached")
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
@keys[key.id] = key
|
|
97
|
+
@current_id = key.id
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def revoke(key_id)
|
|
101
|
+
if !@keys.key?(key_id) && !@revoked.key?(key_id)
|
|
102
|
+
raise Error.new("not_found", "unknown field key id")
|
|
103
|
+
end
|
|
104
|
+
if key_id == @current_id
|
|
105
|
+
raise Error.new("conflict", "cannot revoke the current field key")
|
|
106
|
+
end
|
|
107
|
+
return if @revoked[key_id]
|
|
108
|
+
|
|
109
|
+
@revoked[key_id] = true
|
|
110
|
+
@keys.delete(key_id)
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
class FileFieldKeyring
|
|
115
|
+
FileRecord = Struct.new(:id, :created, :current, :revoked, :material, keyword_init: true)
|
|
116
|
+
|
|
117
|
+
attr_reader :path
|
|
118
|
+
|
|
119
|
+
def initialize(path, records)
|
|
120
|
+
@path = path
|
|
121
|
+
@records = records
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
class << self
|
|
125
|
+
def create(path, current)
|
|
126
|
+
raise Error.new("already_exists", "keyring file exists") if File.exist?(path)
|
|
127
|
+
raise Error.new("invalid_argument", "invalid field key") unless current.is_a?(FieldKey)
|
|
128
|
+
|
|
129
|
+
rec = FileRecord.new(
|
|
130
|
+
id: current.id,
|
|
131
|
+
created: Time.now.utc,
|
|
132
|
+
current: true,
|
|
133
|
+
revoked: false,
|
|
134
|
+
material: current.material
|
|
135
|
+
)
|
|
136
|
+
kr = new(path, [rec])
|
|
137
|
+
kr.send(:persist!)
|
|
138
|
+
kr
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def open(path)
|
|
142
|
+
raise Error.new("not_found", "keyring file not found") unless File.exist?(path)
|
|
143
|
+
|
|
144
|
+
raw = File.binread(path)
|
|
145
|
+
records = decode_keyring(raw)
|
|
146
|
+
new(path, records)
|
|
147
|
+
rescue Error
|
|
148
|
+
raise
|
|
149
|
+
rescue StandardError => e
|
|
150
|
+
raise Error.new("io", "failed to read keyring: #{e.message}")
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def decode_keyring(raw)
|
|
154
|
+
raise Error.new("invalid_format", "truncated keyring") if raw.bytesize < 8
|
|
155
|
+
raise Error.new("invalid_format", "bad keyring magic") if raw[0, 4] != FieldEncryption::KEYRING_MAGIC
|
|
156
|
+
|
|
157
|
+
ver = raw[4, 2].unpack1("v")
|
|
158
|
+
count = raw[6, 2].unpack1("v")
|
|
159
|
+
raise Error.new("invalid_format", "unsupported keyring version") if ver != FieldEncryption::KEYRING_VERSION
|
|
160
|
+
raise Error.new("invalid_format", "keyring key count exceeds limit") if count > FieldEncryption::MAX_KEYS
|
|
161
|
+
|
|
162
|
+
off = 8
|
|
163
|
+
records = []
|
|
164
|
+
seen = {}
|
|
165
|
+
current_count = 0
|
|
166
|
+
|
|
167
|
+
count.times do
|
|
168
|
+
raise Error.new("invalid_format", "truncated keyring record") if off >= raw.bytesize
|
|
169
|
+
|
|
170
|
+
id_len = raw.getbyte(off)
|
|
171
|
+
off += 1
|
|
172
|
+
if id_len < 1 || id_len > FieldEncryption::MAX_KEY_ID || off + id_len + 8 + 1 + FieldEncryption::KEY_SIZE > raw.bytesize
|
|
173
|
+
raise Error.new("invalid_format", "invalid field key id length or truncated record")
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
id_str = raw[off, id_len].force_encoding("utf-8")
|
|
177
|
+
off += id_len
|
|
178
|
+
unless FieldEncryption.valid_key_id?(id_str) && !seen[id_str]
|
|
179
|
+
raise Error.new("invalid_format", "invalid or duplicate field key id")
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
seen[id_str] = true
|
|
183
|
+
|
|
184
|
+
created_sec = raw[off, 8].unpack1("Q<")
|
|
185
|
+
flags = raw.getbyte(off + 8)
|
|
186
|
+
off += 9
|
|
187
|
+
material = raw[off, FieldEncryption::KEY_SIZE]
|
|
188
|
+
off += FieldEncryption::KEY_SIZE
|
|
189
|
+
|
|
190
|
+
is_current = (flags & FieldEncryption::FLAG_CURRENT) != 0
|
|
191
|
+
is_revoked = (flags & FieldEncryption::FLAG_REVOKED) != 0
|
|
192
|
+
|
|
193
|
+
raise Error.new("invalid_format", "current key cannot be revoked") if is_current && is_revoked
|
|
194
|
+
|
|
195
|
+
if is_revoked && material != ("\x00".b * FieldEncryption::KEY_SIZE)
|
|
196
|
+
raise Error.new("invalid_format", "revoked key has non-zero material")
|
|
197
|
+
end
|
|
198
|
+
if !is_revoked && material == ("\x00".b * FieldEncryption::KEY_SIZE)
|
|
199
|
+
raise Error.new("invalid_format", "unrevoked key has all-zero material")
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
current_count += 1 if is_current
|
|
203
|
+
|
|
204
|
+
records << FileRecord.new(
|
|
205
|
+
id: id_str,
|
|
206
|
+
created: Time.at(created_sec).utc,
|
|
207
|
+
current: is_current,
|
|
208
|
+
revoked: is_revoked,
|
|
209
|
+
material: material
|
|
210
|
+
)
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
if count.positive? && current_count != 1
|
|
214
|
+
raise Error.new("invalid_format", "keyring must contain exactly one current key")
|
|
215
|
+
end
|
|
216
|
+
if off != raw.bytesize
|
|
217
|
+
raise Error.new("invalid_format", "trailing bytes after keyring records")
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
records
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def current_field_key(_database, _table, _column)
|
|
225
|
+
rec = @records.find { |r| r.current && !r.revoked }
|
|
226
|
+
raise Error.new("crypto", "current field key unavailable") unless rec
|
|
227
|
+
|
|
228
|
+
FieldKey.new(id: rec.id, material: rec.material)
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def field_key(_database, _table, _column, key_id)
|
|
232
|
+
rec = @records.find { |r| r.id == key_id }
|
|
233
|
+
if !rec || rec.revoked
|
|
234
|
+
raise Error.new("crypto", "field key unavailable or revoked")
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
FieldKey.new(id: rec.id, material: rec.material)
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def rotate(key)
|
|
241
|
+
raise Error.new("invalid_argument", "invalid field key") unless key.is_a?(FieldKey)
|
|
242
|
+
|
|
243
|
+
idx = @records.find_index { |r| r.id == key.id }
|
|
244
|
+
if idx
|
|
245
|
+
raise Error.new("conflict", "cannot reuse a revoked field key id") if @records[idx].revoked
|
|
246
|
+
else
|
|
247
|
+
if @records.length >= FieldEncryption::MAX_KEYS
|
|
248
|
+
raise Error.new("exhausted", "field key limit reached")
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
@records << FileRecord.new(
|
|
252
|
+
id: key.id,
|
|
253
|
+
created: Time.now.utc,
|
|
254
|
+
current: false,
|
|
255
|
+
revoked: false,
|
|
256
|
+
material: key.material
|
|
257
|
+
)
|
|
258
|
+
idx = @records.length - 1
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
@records.each { |r| r.current = false }
|
|
262
|
+
@records[idx].current = true
|
|
263
|
+
@records[idx].material = key.material
|
|
264
|
+
persist!
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def revoke(key_id)
|
|
268
|
+
idx = @records.find_index { |r| r.id == key_id }
|
|
269
|
+
raise Error.new("not_found", "unknown field key id") unless idx
|
|
270
|
+
if @records[idx].current
|
|
271
|
+
raise Error.new("conflict", "cannot revoke the current field key")
|
|
272
|
+
end
|
|
273
|
+
return if @records[idx].revoked
|
|
274
|
+
|
|
275
|
+
@records[idx].revoked = true
|
|
276
|
+
@records[idx].material = "\x00".b * FieldEncryption::KEY_SIZE
|
|
277
|
+
persist!
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def reload
|
|
281
|
+
raw = File.binread(@path)
|
|
282
|
+
@records = self.class.decode_keyring(raw)
|
|
283
|
+
rescue Error
|
|
284
|
+
raise
|
|
285
|
+
rescue StandardError => e
|
|
286
|
+
raise Error.new("io", "failed to reload keyring: #{e.message}")
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
def list
|
|
290
|
+
@records.map do |r|
|
|
291
|
+
FieldKeyInfo.new(id: r.id, created: r.created, current: r.current, revoked: r.revoked)
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
private
|
|
296
|
+
|
|
297
|
+
def persist!
|
|
298
|
+
raw = encode_keyring
|
|
299
|
+
tmp = "#{@path}.tmp.#{Process.pid}.#{SecureRandom.hex(4)}"
|
|
300
|
+
File.open(tmp, File::WRONLY | File::CREAT | File::EXCL, 0o600) do |f|
|
|
301
|
+
f.binmode
|
|
302
|
+
f.write(raw)
|
|
303
|
+
f.flush
|
|
304
|
+
f.fsync
|
|
305
|
+
end
|
|
306
|
+
File.rename(tmp, @path)
|
|
307
|
+
rescue StandardError => e
|
|
308
|
+
File.unlink(tmp) if File.exist?(tmp)
|
|
309
|
+
raise Error.new("io", "failed to persist keyring: #{e.message}")
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
def encode_keyring
|
|
313
|
+
buf = +"".b
|
|
314
|
+
buf << FieldEncryption::KEYRING_MAGIC
|
|
315
|
+
buf << [FieldEncryption::KEYRING_VERSION].pack("v")
|
|
316
|
+
buf << [@records.length].pack("v")
|
|
317
|
+
|
|
318
|
+
@records.each do |r|
|
|
319
|
+
id_b = r.id.encode("utf-8").b
|
|
320
|
+
buf << [id_b.bytesize].pack("C")
|
|
321
|
+
buf << id_b
|
|
322
|
+
buf << [r.created.to_i].pack("Q<")
|
|
323
|
+
flags = 0
|
|
324
|
+
flags |= FieldEncryption::FLAG_CURRENT if r.current
|
|
325
|
+
flags |= FieldEncryption::FLAG_REVOKED if r.revoked
|
|
326
|
+
buf << [flags].pack("C")
|
|
327
|
+
buf << r.material
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
buf
|
|
331
|
+
end
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
module FieldEncryption
|
|
335
|
+
PREFIX = "NSCE1."
|
|
336
|
+
DETERMINISTIC_PREFIX = "NSCE2."
|
|
337
|
+
VERSION = 1
|
|
338
|
+
DETERMINISTIC_VERSION = 2
|
|
339
|
+
SUITE_AES_256_GCM = 1
|
|
340
|
+
SUITE_AES_SIV = 2
|
|
341
|
+
KEY_SIZE = 32
|
|
342
|
+
MAX_KEY_ID = 64
|
|
343
|
+
MAX_KEYS = 64
|
|
344
|
+
MAX_PLAINTEXT = 1 << 20
|
|
345
|
+
NONCE_SIZE = 12
|
|
346
|
+
TAG_SIZE = 16
|
|
347
|
+
|
|
348
|
+
KEYRING_MAGIC = "NSFK".b
|
|
349
|
+
KEYRING_VERSION = 1
|
|
350
|
+
FLAG_CURRENT = 1 << 0
|
|
351
|
+
FLAG_REVOKED = 1 << 1
|
|
352
|
+
|
|
353
|
+
KEY_ID_RE = /\A[A-Za-z0-9._-]+\z/
|
|
354
|
+
|
|
355
|
+
class << self
|
|
356
|
+
def generate_field_key(id)
|
|
357
|
+
validate_key_id!(id)
|
|
358
|
+
FieldKey.new(id: id, material: SecureRandom.random_bytes(KEY_SIZE))
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
def valid_key_id?(id)
|
|
362
|
+
id.is_a?(String) && id.length >= 1 && id.length <= MAX_KEY_ID && KEY_ID_RE.match?(id)
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
def validate_key_id!(id)
|
|
366
|
+
raise Error.new("invalid_argument", "invalid field key id") unless valid_key_id?(id)
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
def encrypt(provider, database, table, column, field_type, value)
|
|
370
|
+
return nil if value.nil?
|
|
371
|
+
|
|
372
|
+
validate_field_type!(field_type)
|
|
373
|
+
plain = encode_scalar(field_type, value)
|
|
374
|
+
if plain.bytesize > MAX_PLAINTEXT
|
|
375
|
+
raise Error.new("exhausted", "plaintext exceeds field limit")
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
key = provider.current_field_key(database, table, column)
|
|
379
|
+
public_header = build_public_header(VERSION, SUITE_AES_256_GCM, key.id, field_type)
|
|
380
|
+
nonce = SecureRandom.random_bytes(NONCE_SIZE)
|
|
381
|
+
aad = build_aad(PREFIX, database, table, column, public_header)
|
|
382
|
+
|
|
383
|
+
cipher = OpenSSL::Cipher.new("aes-256-gcm")
|
|
384
|
+
cipher.encrypt
|
|
385
|
+
cipher.key = key.material
|
|
386
|
+
cipher.iv = nonce
|
|
387
|
+
cipher.auth_data = aad
|
|
388
|
+
ct = cipher.update(plain) + cipher.final
|
|
389
|
+
tag = cipher.auth_tag
|
|
390
|
+
|
|
391
|
+
body = public_header + nonce + ct + tag
|
|
392
|
+
PREFIX + Base64.urlsafe_encode64(body, padding: false)
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
def decrypt(provider, database, table, column, expected_type, ciphertext)
|
|
396
|
+
return nil if ciphertext.nil?
|
|
397
|
+
|
|
398
|
+
validate_field_type!(expected_type)
|
|
399
|
+
parsed = inspect_field(ciphertext)
|
|
400
|
+
if parsed[:mode] != "randomized"
|
|
401
|
+
raise Error.new("invalid_format", "deterministic ciphertext requires decrypt_field_deterministic")
|
|
402
|
+
end
|
|
403
|
+
if parsed[:type] != expected_type
|
|
404
|
+
raise Error.new("invalid_format", "encrypted logical type mismatch")
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
key = provider.field_key(database, table, column, parsed[:key_id])
|
|
408
|
+
body = parsed[:body]
|
|
409
|
+
header_len = parsed[:header_length]
|
|
410
|
+
public_header = body[0...(header_len - NONCE_SIZE)]
|
|
411
|
+
nonce = body[(header_len - NONCE_SIZE)...header_len]
|
|
412
|
+
ct_and_tag = body[header_len..]
|
|
413
|
+
tag = ct_and_tag[-TAG_SIZE..]
|
|
414
|
+
ct = ct_and_tag[0...-TAG_SIZE]
|
|
415
|
+
aad = build_aad(PREFIX, database, table, column, public_header)
|
|
416
|
+
|
|
417
|
+
cipher = OpenSSL::Cipher.new("aes-256-gcm")
|
|
418
|
+
cipher.decrypt
|
|
419
|
+
cipher.key = key.material
|
|
420
|
+
cipher.iv = nonce
|
|
421
|
+
cipher.auth_tag = tag
|
|
422
|
+
cipher.auth_data = aad
|
|
423
|
+
begin
|
|
424
|
+
plain = cipher.update(ct) + cipher.final
|
|
425
|
+
rescue OpenSSL::Cipher::CipherError
|
|
426
|
+
raise Error.new("crypto", "ciphertext authentication failed")
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
if plain.bytesize > MAX_PLAINTEXT
|
|
430
|
+
raise Error.new("invalid_format", "plaintext exceeds field limit")
|
|
431
|
+
end
|
|
432
|
+
|
|
433
|
+
decode_scalar(expected_type, plain)
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
def encrypt_deterministic(provider, database, table, column, field_type, value)
|
|
437
|
+
return nil if value.nil?
|
|
438
|
+
|
|
439
|
+
validate_field_type!(field_type)
|
|
440
|
+
plain = encode_scalar(field_type, value)
|
|
441
|
+
if plain.bytesize > MAX_PLAINTEXT
|
|
442
|
+
raise Error.new("exhausted", "plaintext exceeds field limit")
|
|
443
|
+
end
|
|
444
|
+
|
|
445
|
+
key = provider.current_field_key(database, table, column)
|
|
446
|
+
public_header = build_public_header(DETERMINISTIC_VERSION, SUITE_AES_SIV, key.id, field_type)
|
|
447
|
+
aad = build_aad(DETERMINISTIC_PREFIX, database, table, column, public_header)
|
|
448
|
+
|
|
449
|
+
derived = hkdf_sha256(key.material, "NextSQL NSCE2 AES-SIV v2".b, 32)
|
|
450
|
+
k1 = derived[0, 16]
|
|
451
|
+
k2 = derived[16, 16]
|
|
452
|
+
|
|
453
|
+
tag = s2v(k1, aad, plain)
|
|
454
|
+
cipher_bytes = ctr_crypt(k2, tag, plain)
|
|
455
|
+
|
|
456
|
+
body = public_header + tag + cipher_bytes
|
|
457
|
+
DETERMINISTIC_PREFIX + Base64.urlsafe_encode64(body, padding: false)
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
def decrypt_deterministic(provider, database, table, column, expected_type, ciphertext)
|
|
461
|
+
return nil if ciphertext.nil?
|
|
462
|
+
|
|
463
|
+
validate_field_type!(expected_type)
|
|
464
|
+
parsed = inspect_field(ciphertext)
|
|
465
|
+
if parsed[:mode] != "deterministic"
|
|
466
|
+
raise Error.new("invalid_format", "randomized ciphertext requires decrypt_field")
|
|
467
|
+
end
|
|
468
|
+
if parsed[:type] != expected_type
|
|
469
|
+
raise Error.new("invalid_format", "encrypted logical type mismatch")
|
|
470
|
+
end
|
|
471
|
+
|
|
472
|
+
key = provider.field_key(database, table, column, parsed[:key_id])
|
|
473
|
+
body = parsed[:body]
|
|
474
|
+
header_len = parsed[:header_length]
|
|
475
|
+
public_header = body[0...header_len]
|
|
476
|
+
tag = body[header_len, 16]
|
|
477
|
+
ct = body[(header_len + 16)..]
|
|
478
|
+
|
|
479
|
+
aad = build_aad(DETERMINISTIC_PREFIX, database, table, column, public_header)
|
|
480
|
+
derived = hkdf_sha256(key.material, "NextSQL NSCE2 AES-SIV v2".b, 32)
|
|
481
|
+
k1 = derived[0, 16]
|
|
482
|
+
k2 = derived[16, 16]
|
|
483
|
+
|
|
484
|
+
plain = ctr_crypt(k2, tag, ct)
|
|
485
|
+
expected_tag = s2v(k1, aad, plain)
|
|
486
|
+
unless OpenSSL.fixed_length_secure_compare(tag, expected_tag)
|
|
487
|
+
raise Error.new("crypto", "ciphertext authentication failed")
|
|
488
|
+
end
|
|
489
|
+
|
|
490
|
+
if plain.bytesize > MAX_PLAINTEXT
|
|
491
|
+
raise Error.new("invalid_format", "plaintext exceeds field limit")
|
|
492
|
+
end
|
|
493
|
+
|
|
494
|
+
decode_scalar(expected_type, plain)
|
|
495
|
+
end
|
|
496
|
+
|
|
497
|
+
def inspect_field(ciphertext)
|
|
498
|
+
raise Error.new("invalid_format", "invalid client ciphertext") unless ciphertext.is_a?(String)
|
|
499
|
+
|
|
500
|
+
if ciphertext.start_with?(PREFIX)
|
|
501
|
+
prefix = PREFIX
|
|
502
|
+
version = VERSION
|
|
503
|
+
suite = SUITE_AES_256_GCM
|
|
504
|
+
nonce_size = NONCE_SIZE
|
|
505
|
+
mode = "randomized"
|
|
506
|
+
elsif ciphertext.start_with?(DETERMINISTIC_PREFIX)
|
|
507
|
+
prefix = DETERMINISTIC_PREFIX
|
|
508
|
+
version = DETERMINISTIC_VERSION
|
|
509
|
+
suite = SUITE_AES_SIV
|
|
510
|
+
nonce_size = 0
|
|
511
|
+
mode = "deterministic"
|
|
512
|
+
else
|
|
513
|
+
raise Error.new("invalid_format", "invalid client ciphertext prefix")
|
|
514
|
+
end
|
|
515
|
+
|
|
516
|
+
encoded = ciphertext[prefix.length..]
|
|
517
|
+
max_encoded = ((MAX_PLAINTEXT + 101) * 4 / 3) + 4
|
|
518
|
+
if encoded.nil? || encoded.empty? || encoded.length > max_encoded || encoded.length % 4 == 1 || !encoded.match?(/\A[A-Za-z0-9_-]+\z/)
|
|
519
|
+
raise Error.new("invalid_format", "client ciphertext length out of range")
|
|
520
|
+
end
|
|
521
|
+
|
|
522
|
+
padded = encoded + ("=" * ((4 - (encoded.length % 4)) % 4))
|
|
523
|
+
begin
|
|
524
|
+
body = Base64.urlsafe_decode64(padded)
|
|
525
|
+
rescue StandardError
|
|
526
|
+
raise Error.new("invalid_format", "invalid client ciphertext encoding")
|
|
527
|
+
end
|
|
528
|
+
|
|
529
|
+
min_len = 3 + 1 + 6 + nonce_size + TAG_SIZE
|
|
530
|
+
if body.bytesize < min_len || body.getbyte(0) != version || body.getbyte(1) != suite
|
|
531
|
+
raise Error.new("invalid_format", "unsupported or truncated client ciphertext")
|
|
532
|
+
end
|
|
533
|
+
|
|
534
|
+
key_id_len = body.getbyte(2)
|
|
535
|
+
if key_id_len < 1 || key_id_len > MAX_KEY_ID || body.bytesize < 3 + key_id_len + 6 + nonce_size + TAG_SIZE
|
|
536
|
+
raise Error.new("invalid_format", "invalid field key id length")
|
|
537
|
+
end
|
|
538
|
+
|
|
539
|
+
key_id = body[3, key_id_len].force_encoding("utf-8")
|
|
540
|
+
raise Error.new("invalid_format", "invalid field key id") unless valid_key_id?(key_id)
|
|
541
|
+
|
|
542
|
+
off = 3 + key_id_len
|
|
543
|
+
kind = body.getbyte(off)
|
|
544
|
+
precision = body[off + 1, 2].unpack1("v")
|
|
545
|
+
scale = body[off + 3, 2].unpack1("v")
|
|
546
|
+
vec_elem = body.getbyte(off + 5)
|
|
547
|
+
ft = FieldType.new(kind: kind, precision: precision, scale: scale, vec_elem: vec_elem)
|
|
548
|
+
validate_field_type!(ft)
|
|
549
|
+
|
|
550
|
+
header_len = off + 6 + nonce_size
|
|
551
|
+
{
|
|
552
|
+
key_id: key_id,
|
|
553
|
+
type: ft,
|
|
554
|
+
body: body,
|
|
555
|
+
header_length: header_len,
|
|
556
|
+
mode: mode,
|
|
557
|
+
}
|
|
558
|
+
end
|
|
559
|
+
|
|
560
|
+
# --- internal helpers ---
|
|
561
|
+
|
|
562
|
+
def validate_field_type!(ft)
|
|
563
|
+
if ft.kind == Protocol::KIND_DECIMAL
|
|
564
|
+
if ft.precision < 1 || ft.precision > 38 || ft.scale.negative? || ft.scale > ft.precision
|
|
565
|
+
raise Error.new("invalid_argument", "unsupported client-encrypted type")
|
|
566
|
+
end
|
|
567
|
+
return
|
|
568
|
+
end
|
|
569
|
+
|
|
570
|
+
valid = [
|
|
571
|
+
Protocol::KIND_UUID, Protocol::KIND_STRING, Protocol::KIND_TEXT, Protocol::KIND_BLOB,
|
|
572
|
+
Protocol::KIND_TIMESTAMPTZ, Protocol::KIND_JSON, Protocol::KIND_BOOL,
|
|
573
|
+
Protocol::KIND_INT8, Protocol::KIND_INT16, Protocol::KIND_INT32, Protocol::KIND_INT64,
|
|
574
|
+
Protocol::KIND_UINT8, Protocol::KIND_UINT16, Protocol::KIND_UINT32, Protocol::KIND_UINT64,
|
|
575
|
+
]
|
|
576
|
+
if !valid.include?(ft.kind) || ft.precision != 0 || ft.scale != 0 || ft.vec_elem != 0
|
|
577
|
+
raise Error.new("invalid_argument", "unsupported client-encrypted type")
|
|
578
|
+
end
|
|
579
|
+
end
|
|
580
|
+
|
|
581
|
+
def build_public_header(version, suite, key_id, field_type)
|
|
582
|
+
key_id_b = key_id.encode("utf-8").b
|
|
583
|
+
[version, suite, key_id_b.bytesize].pack("CCC") +
|
|
584
|
+
key_id_b +
|
|
585
|
+
[field_type.kind, field_type.precision, field_type.scale, field_type.vec_elem].pack("CvvC")
|
|
586
|
+
end
|
|
587
|
+
|
|
588
|
+
def build_aad(prefix, database, table, column, public_header)
|
|
589
|
+
db_b = validate_name!(database, "database")
|
|
590
|
+
tbl_b = validate_name!(table, "table")
|
|
591
|
+
col_b = validate_name!(column, "column")
|
|
592
|
+
|
|
593
|
+
out = prefix.encode("utf-8").b
|
|
594
|
+
out << [db_b.bytesize].pack("v") << db_b
|
|
595
|
+
out << [tbl_b.bytesize].pack("v") << tbl_b
|
|
596
|
+
out << [col_b.bytesize].pack("v") << col_b
|
|
597
|
+
out << public_header
|
|
598
|
+
out
|
|
599
|
+
end
|
|
600
|
+
|
|
601
|
+
def validate_name!(val, what)
|
|
602
|
+
raise Error.new("invalid_argument", "#{what} must not be empty") unless val.is_a?(String) && !val.empty?
|
|
603
|
+
b = val.encode("utf-8").b
|
|
604
|
+
raise Error.new("invalid_argument", "#{what} exceeds maximum length") if b.bytesize > 0xFFFF
|
|
605
|
+
b
|
|
606
|
+
end
|
|
607
|
+
|
|
608
|
+
def encode_scalar(ft, value)
|
|
609
|
+
case ft.kind
|
|
610
|
+
when Protocol::KIND_UUID
|
|
611
|
+
if value.is_a?(String)
|
|
612
|
+
clean = value.delete("-")
|
|
613
|
+
raise Error.new("invalid_argument", "invalid UUID") unless clean.match?(/\A[0-9a-fA-F]{32}\z/)
|
|
614
|
+
[clean].pack("H*")
|
|
615
|
+
elsif value.is_a?(String) && value.bytesize == 16
|
|
616
|
+
value.b
|
|
617
|
+
else
|
|
618
|
+
raise Error.new("invalid_argument", "invalid UUID for encrypted field")
|
|
619
|
+
end
|
|
620
|
+
when Protocol::KIND_STRING, Protocol::KIND_TEXT
|
|
621
|
+
raise Error.new("invalid_argument", "encrypted string value must be a string") unless value.is_a?(String)
|
|
622
|
+
b = value.encode("utf-8").b
|
|
623
|
+
[b.bytesize].pack("V") + b
|
|
624
|
+
when Protocol::KIND_BLOB
|
|
625
|
+
raise Error.new("invalid_argument", "encrypted BLOB value must be bytes") unless value.is_a?(String)
|
|
626
|
+
b = value.b
|
|
627
|
+
[b.bytesize].pack("V") + b
|
|
628
|
+
when Protocol::KIND_BOOL
|
|
629
|
+
raise Error.new("invalid_argument", "encrypted BOOL value must be boolean") unless value == true || value == false
|
|
630
|
+
value ? "\x01".b : "\x00".b
|
|
631
|
+
when Protocol::KIND_INT8
|
|
632
|
+
raise Error.new("invalid_argument", "encrypted int value out of range") if value < -0x80 || value > 0x7F
|
|
633
|
+
[value].pack("c")
|
|
634
|
+
when Protocol::KIND_INT16
|
|
635
|
+
raise Error.new("invalid_argument", "encrypted int value out of range") if value < -0x8000 || value > 0x7FFF
|
|
636
|
+
[value].pack("s<")
|
|
637
|
+
when Protocol::KIND_INT32
|
|
638
|
+
raise Error.new("invalid_argument", "encrypted int value out of range") if value < -0x80000000 || value > 0x7FFFFFFF
|
|
639
|
+
[value].pack("l<")
|
|
640
|
+
when Protocol::KIND_INT64
|
|
641
|
+
raise Error.new("invalid_argument", "encrypted int value out of range") if value < -0x8000000000000000 || value > 0x7FFFFFFFFFFFFFFF
|
|
642
|
+
[value].pack("q<")
|
|
643
|
+
when Protocol::KIND_UINT8
|
|
644
|
+
raise Error.new("invalid_argument", "encrypted uint value out of range") if value.negative? || value > 0xFF
|
|
645
|
+
[value].pack("C")
|
|
646
|
+
when Protocol::KIND_UINT16
|
|
647
|
+
raise Error.new("invalid_argument", "encrypted uint value out of range") if value.negative? || value > 0xFFFF
|
|
648
|
+
[value].pack("S<")
|
|
649
|
+
when Protocol::KIND_UINT32
|
|
650
|
+
raise Error.new("invalid_argument", "encrypted uint value out of range") if value.negative? || value > 0xFFFFFFFF
|
|
651
|
+
[value].pack("L<")
|
|
652
|
+
when Protocol::KIND_UINT64
|
|
653
|
+
raise Error.new("invalid_argument", "encrypted uint value out of range") if value.negative? || value > 0xFFFFFFFFFFFFFFFF
|
|
654
|
+
[value].pack("Q<")
|
|
655
|
+
when Protocol::KIND_TIMESTAMPTZ
|
|
656
|
+
if value.is_a?(Time)
|
|
657
|
+
ns = (value.to_r * 1_000_000_000).to_i
|
|
658
|
+
[ns].pack("q<")
|
|
659
|
+
elsif value.is_a?(Integer)
|
|
660
|
+
[value].pack("q<")
|
|
661
|
+
else
|
|
662
|
+
raise Error.new("invalid_argument", "encrypted TIMESTAMPTZ value must be Time or unix nanoseconds")
|
|
663
|
+
end
|
|
664
|
+
when Protocol::KIND_DECIMAL
|
|
665
|
+
dec_str = value.is_a?(BigDecimal) ? value.to_s("F") : value.to_s
|
|
666
|
+
Protocol.encode_decimal(dec_str)
|
|
667
|
+
when Protocol::KIND_JSON
|
|
668
|
+
json_str = value.is_a?(String) ? value : JSON.generate(value)
|
|
669
|
+
b = json_str.encode("utf-8").b
|
|
670
|
+
[b.bytesize].pack("V") + b
|
|
671
|
+
else
|
|
672
|
+
raise Error.new("invalid_argument", "unsupported client-encrypted type")
|
|
673
|
+
end
|
|
674
|
+
end
|
|
675
|
+
|
|
676
|
+
def decode_scalar(ft, raw)
|
|
677
|
+
case ft.kind
|
|
678
|
+
when Protocol::KIND_UUID
|
|
679
|
+
raise Error.new("invalid_format", "invalid encrypted UUID payload") if raw.bytesize != 16
|
|
680
|
+
Protocol.format_uuid(raw)
|
|
681
|
+
when Protocol::KIND_STRING, Protocol::KIND_TEXT
|
|
682
|
+
raise Error.new("invalid_format", "truncated string payload") if raw.bytesize < 4
|
|
683
|
+
len = raw[0, 4].unpack1("V")
|
|
684
|
+
raise Error.new("invalid_format", "string payload length mismatch") if raw.bytesize != 4 + len
|
|
685
|
+
raw[4..].force_encoding("utf-8")
|
|
686
|
+
when Protocol::KIND_BLOB
|
|
687
|
+
raise Error.new("invalid_format", "truncated blob payload") if raw.bytesize < 4
|
|
688
|
+
len = raw[0, 4].unpack1("V")
|
|
689
|
+
raise Error.new("invalid_format", "blob payload length mismatch") if raw.bytesize != 4 + len
|
|
690
|
+
raw[4..]
|
|
691
|
+
when Protocol::KIND_BOOL
|
|
692
|
+
raise Error.new("invalid_format", "invalid encrypted bool payload") if raw.bytesize != 1
|
|
693
|
+
raw.getbyte(0) != 0
|
|
694
|
+
when Protocol::KIND_INT8
|
|
695
|
+
raise Error.new("invalid_format", "invalid encrypted int payload") if raw.bytesize != 1
|
|
696
|
+
raw.unpack1("c")
|
|
697
|
+
when Protocol::KIND_INT16
|
|
698
|
+
raise Error.new("invalid_format", "invalid encrypted int payload") if raw.bytesize != 2
|
|
699
|
+
raw.unpack1("s<")
|
|
700
|
+
when Protocol::KIND_INT32
|
|
701
|
+
raise Error.new("invalid_format", "invalid encrypted int payload") if raw.bytesize != 4
|
|
702
|
+
raw.unpack1("l<")
|
|
703
|
+
when Protocol::KIND_INT64
|
|
704
|
+
raise Error.new("invalid_format", "invalid encrypted int payload") if raw.bytesize != 8
|
|
705
|
+
raw.unpack1("q<")
|
|
706
|
+
when Protocol::KIND_UINT8
|
|
707
|
+
raise Error.new("invalid_format", "invalid encrypted uint payload") if raw.bytesize != 1
|
|
708
|
+
raw.unpack1("C")
|
|
709
|
+
when Protocol::KIND_UINT16
|
|
710
|
+
raise Error.new("invalid_format", "invalid encrypted uint payload") if raw.bytesize != 2
|
|
711
|
+
raw.unpack1("S<")
|
|
712
|
+
when Protocol::KIND_UINT32
|
|
713
|
+
raise Error.new("invalid_format", "invalid encrypted uint payload") if raw.bytesize != 4
|
|
714
|
+
raw.unpack1("L<")
|
|
715
|
+
when Protocol::KIND_UINT64
|
|
716
|
+
raise Error.new("invalid_format", "invalid encrypted uint payload") if raw.bytesize != 8
|
|
717
|
+
raw.unpack1("Q<")
|
|
718
|
+
when Protocol::KIND_TIMESTAMPTZ
|
|
719
|
+
raise Error.new("invalid_format", "invalid encrypted timestamptz payload") if raw.bytesize != 8
|
|
720
|
+
ns = raw.unpack1("q<")
|
|
721
|
+
Time.at(Rational(ns, 1_000_000_000)).utc
|
|
722
|
+
when Protocol::KIND_DECIMAL
|
|
723
|
+
raise Error.new("invalid_format", "truncated decimal payload") if raw.bytesize < 4
|
|
724
|
+
len = raw[0, 4].unpack1("V")
|
|
725
|
+
raise Error.new("invalid_format", "decimal payload length mismatch") if raw.bytesize != 4 + len
|
|
726
|
+
Protocol.decode_decimal(raw[4..])
|
|
727
|
+
when Protocol::KIND_JSON
|
|
728
|
+
raise Error.new("invalid_format", "truncated json payload") if raw.bytesize < 4
|
|
729
|
+
len = raw[0, 4].unpack1("V")
|
|
730
|
+
raise Error.new("invalid_format", "json payload length mismatch") if raw.bytesize != 4 + len
|
|
731
|
+
JSON.parse(raw[4..].force_encoding("utf-8"))
|
|
732
|
+
else
|
|
733
|
+
raise Error.new("invalid_format", "unsupported client-encrypted type")
|
|
734
|
+
end
|
|
735
|
+
end
|
|
736
|
+
|
|
737
|
+
# --- RFC 5297 AES-SIV implementation ---
|
|
738
|
+
|
|
739
|
+
def hkdf_sha256(ikm, info, length = 32)
|
|
740
|
+
prk = OpenSSL::HMAC.digest("SHA256", "".b, ikm)
|
|
741
|
+
t = "".b
|
|
742
|
+
okm = +"".b
|
|
743
|
+
i = 1
|
|
744
|
+
while okm.bytesize < length
|
|
745
|
+
t = OpenSSL::HMAC.digest("SHA256", prk, t + info + [i].pack("C"))
|
|
746
|
+
okm << t
|
|
747
|
+
i += 1
|
|
748
|
+
end
|
|
749
|
+
okm[0, length]
|
|
750
|
+
end
|
|
751
|
+
|
|
752
|
+
def aes_block(key, block)
|
|
753
|
+
c = OpenSSL::Cipher.new("aes-128-ecb")
|
|
754
|
+
c.encrypt
|
|
755
|
+
c.key = key
|
|
756
|
+
c.padding = 0
|
|
757
|
+
c.update(block) + c.final
|
|
758
|
+
end
|
|
759
|
+
|
|
760
|
+
def dbl(block)
|
|
761
|
+
out = +"\x00".b * 16
|
|
762
|
+
carry = 0
|
|
763
|
+
15.downto(0) do |i|
|
|
764
|
+
b = block.getbyte(i)
|
|
765
|
+
out.setbyte(i, ((b << 1) & 0xFF) | carry)
|
|
766
|
+
carry = (b >> 7) & 1
|
|
767
|
+
end
|
|
768
|
+
out.setbyte(15, out.getbyte(15) ^ 0x87) if carry != 0
|
|
769
|
+
out
|
|
770
|
+
end
|
|
771
|
+
|
|
772
|
+
def xor_16(a, b)
|
|
773
|
+
res = +"\x00".b * 16
|
|
774
|
+
16.times { |i| res.setbyte(i, a.getbyte(i) ^ b.getbyte(i)) }
|
|
775
|
+
res
|
|
776
|
+
end
|
|
777
|
+
|
|
778
|
+
def cmac(key, message)
|
|
779
|
+
k1 = dbl(aes_block(key, "\x00".b * 16))
|
|
780
|
+
k2 = dbl(k1)
|
|
781
|
+
n = message.bytesize
|
|
782
|
+
blocks = [1, (n + 15) / 16].max
|
|
783
|
+
last = if n.positive? && (n % 16).zero?
|
|
784
|
+
xor_16(message[(n - 16)..], k1)
|
|
785
|
+
else
|
|
786
|
+
rem = n % 16
|
|
787
|
+
pad = message[(n - rem)..] + "\x80".b + ("\x00".b * (15 - rem))
|
|
788
|
+
xor_16(pad, k2)
|
|
789
|
+
end
|
|
790
|
+
y = "\x00".b * 16
|
|
791
|
+
(blocks - 1).times do |i|
|
|
792
|
+
blk = message[(i * 16), 16]
|
|
793
|
+
y = aes_block(key, xor_16(y, blk))
|
|
794
|
+
end
|
|
795
|
+
aes_block(key, xor_16(y, last))
|
|
796
|
+
end
|
|
797
|
+
|
|
798
|
+
def s2v(key, ad, plain)
|
|
799
|
+
d = dbl(cmac(key, "\x00".b * 16))
|
|
800
|
+
d = xor_16(d, cmac(key, ad))
|
|
801
|
+
t = if plain.bytesize >= 16
|
|
802
|
+
off = plain.bytesize - 16
|
|
803
|
+
plain[0...off] + xor_16(plain[off..], d)
|
|
804
|
+
else
|
|
805
|
+
pad = plain + "\x80".b + ("\x00".b * (15 - plain.bytesize))
|
|
806
|
+
xor_16(dbl(d), pad)
|
|
807
|
+
end
|
|
808
|
+
cmac(key, t)
|
|
809
|
+
end
|
|
810
|
+
|
|
811
|
+
def ctr_crypt(key, tag, input)
|
|
812
|
+
q = tag.dup
|
|
813
|
+
q.setbyte(8, q.getbyte(8) & 0x7F)
|
|
814
|
+
q.setbyte(12, q.getbyte(12) & 0x7F)
|
|
815
|
+
c = OpenSSL::Cipher.new("aes-128-ctr")
|
|
816
|
+
c.encrypt
|
|
817
|
+
c.key = key
|
|
818
|
+
c.iv = q
|
|
819
|
+
c.update(input) + c.final
|
|
820
|
+
end
|
|
821
|
+
end
|
|
822
|
+
end
|
|
823
|
+
end
|
data/lib/nextsql/pool.rb
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "monitor"
|
|
4
|
+
|
|
5
|
+
module NextSQL
|
|
6
|
+
# Thread-safe connection pool for NextSQL Ruby driver.
|
|
7
|
+
#
|
|
8
|
+
# pool = NextSQL.create_pool(cfg, min_size: 2, max_size: 10)
|
|
9
|
+
# pool.with do |conn|
|
|
10
|
+
# res = conn.exec("SELECT * FROM users")
|
|
11
|
+
# end
|
|
12
|
+
#
|
|
13
|
+
# # Or directly on the pool:
|
|
14
|
+
# pool.exec("INSERT INTO logs VALUES ($1)", ["event"])
|
|
15
|
+
# pool.close
|
|
16
|
+
class ConnectionPool
|
|
17
|
+
class PooledItem
|
|
18
|
+
attr_reader :conn
|
|
19
|
+
attr_accessor :last_used
|
|
20
|
+
|
|
21
|
+
def initialize(conn)
|
|
22
|
+
@conn = conn
|
|
23
|
+
@last_used = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def initialize(config, min_size: 1, max_size: 10, timeout: 30.0, max_idle: 300.0, statement_cache_size: 50)
|
|
28
|
+
raise Error.new("invalid_argument", "min_size must be >= 0") if min_size < 0
|
|
29
|
+
raise Error.new("invalid_argument", "max_size must be >= 1") if max_size < 1
|
|
30
|
+
raise Error.new("invalid_argument", "max_size must be >= min_size") if max_size < min_size
|
|
31
|
+
raise Error.new("invalid_argument", "statement_cache_size must be >= 0") if statement_cache_size < 0
|
|
32
|
+
|
|
33
|
+
@config = config.dup
|
|
34
|
+
@config.statement_cache_size = statement_cache_size if @config.respond_to?(:statement_cache_size=)
|
|
35
|
+
@min_size = min_size
|
|
36
|
+
@max_size = max_size
|
|
37
|
+
@timeout = timeout
|
|
38
|
+
@max_idle = max_idle
|
|
39
|
+
|
|
40
|
+
@monitor = Monitor.new
|
|
41
|
+
@cond = @monitor.new_cond
|
|
42
|
+
@idle = []
|
|
43
|
+
@in_use = {}.compare_by_identity
|
|
44
|
+
@closed = false
|
|
45
|
+
|
|
46
|
+
if min_size > 0
|
|
47
|
+
min_size.times do
|
|
48
|
+
begin
|
|
49
|
+
conn = Connection.connect(@config)
|
|
50
|
+
@idle << PooledItem.new(conn)
|
|
51
|
+
rescue StandardError
|
|
52
|
+
close
|
|
53
|
+
raise
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def size
|
|
60
|
+
@monitor.synchronize { @idle.size + @in_use.size }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def idle_count
|
|
64
|
+
@monitor.synchronize { @idle.size }
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def in_use_count
|
|
68
|
+
@monitor.synchronize { @in_use.size }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def closed?
|
|
72
|
+
@monitor.synchronize { @closed }
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Acquires a connection from the pool, waiting up to +timeout+ seconds if
|
|
76
|
+
# all connections are currently in use.
|
|
77
|
+
def acquire(timeout: nil)
|
|
78
|
+
wait_time = timeout || @timeout
|
|
79
|
+
deadline = wait_time > 0 ? Process.clock_gettime(Process::CLOCK_MONOTONIC) + wait_time : Float::INFINITY
|
|
80
|
+
|
|
81
|
+
@monitor.synchronize do
|
|
82
|
+
loop do
|
|
83
|
+
raise Error.new("unavailable", "connection pool is closed") if @closed
|
|
84
|
+
|
|
85
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
86
|
+
while (item = @idle.pop)
|
|
87
|
+
if @max_idle > 0 && (now - item.last_used) > @max_idle
|
|
88
|
+
safe_close(item.conn)
|
|
89
|
+
next
|
|
90
|
+
end
|
|
91
|
+
if !alive?(item.conn)
|
|
92
|
+
safe_close(item.conn)
|
|
93
|
+
next
|
|
94
|
+
end
|
|
95
|
+
@in_use[item.conn] = item
|
|
96
|
+
return item.conn
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
if (@idle.size + @in_use.size) < @max_size
|
|
100
|
+
conn = Connection.connect(@config)
|
|
101
|
+
item = PooledItem.new(conn)
|
|
102
|
+
@in_use[conn] = item
|
|
103
|
+
return conn
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
107
|
+
if remaining <= 0
|
|
108
|
+
raise Error.new("resource_exhausted", "connection pool exhausted, wait timeout exceeded")
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
@cond.wait([remaining, 0.5].min)
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Returns a connection to the pool.
|
|
117
|
+
def release(conn)
|
|
118
|
+
@monitor.synchronize do
|
|
119
|
+
item = @in_use.delete(conn)
|
|
120
|
+
return if item.nil?
|
|
121
|
+
|
|
122
|
+
if @closed
|
|
123
|
+
safe_close(conn)
|
|
124
|
+
@cond.signal
|
|
125
|
+
return
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
unless clean_connection(item)
|
|
129
|
+
safe_close(conn)
|
|
130
|
+
@cond.signal
|
|
131
|
+
return
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
item.last_used = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
135
|
+
@idle << item
|
|
136
|
+
@cond.signal
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Checks out a connection for the duration of the block and guarantees its
|
|
141
|
+
# release even if the block raises.
|
|
142
|
+
def with(timeout: nil)
|
|
143
|
+
conn = acquire(timeout: timeout)
|
|
144
|
+
begin
|
|
145
|
+
yield conn
|
|
146
|
+
ensure
|
|
147
|
+
release(conn)
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Executes a statement on a pooled connection and returns the result.
|
|
152
|
+
def exec(sql, params = nil)
|
|
153
|
+
with { |conn| conn.exec(sql, params) }
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Queries a statement on a pooled connection, buffering all rows into
|
|
157
|
+
# a Result before releasing the connection.
|
|
158
|
+
def query(sql, params = nil)
|
|
159
|
+
with do |conn|
|
|
160
|
+
rows = conn.query(sql, params)
|
|
161
|
+
rows.collect
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Closes all idle and active connections in the pool.
|
|
166
|
+
def close
|
|
167
|
+
@monitor.synchronize do
|
|
168
|
+
return if @closed
|
|
169
|
+
|
|
170
|
+
@closed = true
|
|
171
|
+
@idle.each { |item| safe_close(item.conn) }
|
|
172
|
+
@idle.clear
|
|
173
|
+
|
|
174
|
+
@in_use.each_key { |conn| safe_close(conn) }
|
|
175
|
+
@in_use.clear
|
|
176
|
+
|
|
177
|
+
@cond.broadcast
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
private
|
|
182
|
+
|
|
183
|
+
def safe_close(conn)
|
|
184
|
+
conn.close
|
|
185
|
+
rescue StandardError
|
|
186
|
+
nil
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def alive?(conn)
|
|
190
|
+
if conn.instance_variable_defined?(:@sock) && conn.instance_variable_get(:@sock).nil?
|
|
191
|
+
return false
|
|
192
|
+
end
|
|
193
|
+
true
|
|
194
|
+
rescue StandardError
|
|
195
|
+
true
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def clean_connection(item)
|
|
199
|
+
item.conn.exec("ROLLBACK")
|
|
200
|
+
true
|
|
201
|
+
rescue StandardError
|
|
202
|
+
false
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def self.create_pool(config, min_size: 1, max_size: 10, timeout: 30.0, max_idle: 300.0)
|
|
207
|
+
ConnectionPool.new(
|
|
208
|
+
config,
|
|
209
|
+
min_size: min_size,
|
|
210
|
+
max_size: max_size,
|
|
211
|
+
timeout: timeout,
|
|
212
|
+
max_idle: max_idle,
|
|
213
|
+
)
|
|
214
|
+
end
|
|
215
|
+
end
|
data/lib/nextsql/version.rb
CHANGED
data/lib/nextsql.rb
CHANGED
|
@@ -25,8 +25,10 @@
|
|
|
25
25
|
require_relative "nextsql/version"
|
|
26
26
|
require_relative "nextsql/errors"
|
|
27
27
|
require_relative "nextsql/protocol"
|
|
28
|
+
require_relative "nextsql/field_encryption"
|
|
28
29
|
require_relative "nextsql/client"
|
|
29
30
|
require_relative "nextsql/cluster"
|
|
31
|
+
require_relative "nextsql/pool"
|
|
30
32
|
|
|
31
33
|
module NextSQL
|
|
32
34
|
READ_STRONG = Protocol::READ_STRONG
|
|
@@ -65,4 +67,9 @@ module NextSQL
|
|
|
65
67
|
def self.connect_cluster(cfg)
|
|
66
68
|
Cluster.connect(cfg)
|
|
67
69
|
end
|
|
70
|
+
|
|
71
|
+
# Creates a connection pool managing a set of connections to a NextSQL server.
|
|
72
|
+
def self.create_pool(cfg, **kwargs)
|
|
73
|
+
ConnectionPool.new(cfg, **kwargs)
|
|
74
|
+
end
|
|
68
75
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: bzync-nextsql
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Rayan Reynaldo
|
|
@@ -10,8 +10,8 @@ cert_chain: []
|
|
|
10
10
|
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
11
|
dependencies: []
|
|
12
12
|
description: 'Native-protocol client for NextSQL: TLS 1.3, prepared statements, follower-read
|
|
13
|
-
routing, and NSCE1 client-side field encryption. Keys and passwords are never
|
|
14
|
-
in a URL.'
|
|
13
|
+
routing, and NSCE1/NSCE2 client-side field encryption. Keys and passwords are never
|
|
14
|
+
accepted in a URL.'
|
|
15
15
|
executables: []
|
|
16
16
|
extensions: []
|
|
17
17
|
extra_rdoc_files: []
|
|
@@ -22,6 +22,8 @@ files:
|
|
|
22
22
|
- lib/nextsql/client.rb
|
|
23
23
|
- lib/nextsql/cluster.rb
|
|
24
24
|
- lib/nextsql/errors.rb
|
|
25
|
+
- lib/nextsql/field_encryption.rb
|
|
26
|
+
- lib/nextsql/pool.rb
|
|
25
27
|
- lib/nextsql/protocol.rb
|
|
26
28
|
- lib/nextsql/version.rb
|
|
27
29
|
homepage: https://nextsql.bzync.com/docs/drivers
|
|
@@ -47,7 +49,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
47
49
|
- !ruby/object:Gem::Version
|
|
48
50
|
version: '0'
|
|
49
51
|
requirements: []
|
|
50
|
-
rubygems_version: 4.0.
|
|
52
|
+
rubygems_version: 4.0.23
|
|
51
53
|
specification_version: 4
|
|
52
54
|
summary: Official NextSQL Ruby driver. Speaks the native NSQL v1 protocol.
|
|
53
55
|
test_files: []
|