bzync-nextsql 0.1.0 → 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.
@@ -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