@bzync/nextsql 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,634 @@
1
+ 'use strict';
2
+
3
+ // Node-specific NSCE1 implementation. The dependency injection keeps this
4
+ // module on the driver's single public NextSQLError/Kind surface.
5
+ const { createCipheriv, createDecipheriv, randomBytes } = require('node:crypto');
6
+ const fs = require('node:fs/promises');
7
+
8
+ module.exports = function createClientEncryption(deps) {
9
+ const { NextSQLError, Kind, encodeDecimalString, decodeDecimal, decodeNSJB, formatUUID, parseUUID } = deps;
10
+ const PREFIX = 'NSCE1.';
11
+ const MAX_PLAINTEXT = 1 << 20;
12
+ const MAX_KEY_ID = 64;
13
+ const MAX_KEYS = 64;
14
+ const NONCE_SIZE = 12;
15
+ const TAG_SIZE = 16;
16
+
17
+ const FieldType = Object.freeze({
18
+ UUID: Object.freeze({ kind: Kind.UUID, precision: 0, scale: 0, vecElem: 0 }),
19
+ String: Object.freeze({ kind: Kind.String, precision: 0, scale: 0, vecElem: 0 }),
20
+ Text: Object.freeze({ kind: Kind.Text, precision: 0, scale: 0, vecElem: 0 }),
21
+ TimestampTZ: Object.freeze({ kind: Kind.TimestampTZ, precision: 0, scale: 0, vecElem: 0 }),
22
+ JSON: Object.freeze({ kind: Kind.JSON, precision: 0, scale: 0, vecElem: 0 }),
23
+ Bool: Object.freeze({ kind: Kind.Bool, precision: 0, scale: 0, vecElem: 0 }),
24
+ Blob: Object.freeze({ kind: Kind.Blob, precision: 0, scale: 0, vecElem: 0 }),
25
+ Int8: Object.freeze({ kind: Kind.Int8, precision: 0, scale: 0, vecElem: 0 }),
26
+ Int16: Object.freeze({ kind: Kind.Int16, precision: 0, scale: 0, vecElem: 0 }),
27
+ Int32: Object.freeze({ kind: Kind.Int32, precision: 0, scale: 0, vecElem: 0 }),
28
+ Int64: Object.freeze({ kind: Kind.Int64, precision: 0, scale: 0, vecElem: 0 }),
29
+ Uint8: Object.freeze({ kind: Kind.Uint8, precision: 0, scale: 0, vecElem: 0 }),
30
+ Uint16: Object.freeze({ kind: Kind.Uint16, precision: 0, scale: 0, vecElem: 0 }),
31
+ Uint32: Object.freeze({ kind: Kind.Uint32, precision: 0, scale: 0, vecElem: 0 }),
32
+ Uint64: Object.freeze({ kind: Kind.Uint64, precision: 0, scale: 0, vecElem: 0 }),
33
+ Decimal(precision, scale) {
34
+ const type = normalizeType({ kind: Kind.Decimal, precision, scale, vecElem: 0 });
35
+ return Object.freeze(type);
36
+ },
37
+ });
38
+
39
+ function fail(code, message) { throw new NextSQLError(code, message); }
40
+
41
+ function validKeyID(id) {
42
+ return typeof id === 'string' && id.length >= 1 && id.length <= MAX_KEY_ID &&
43
+ /^[A-Za-z0-9._-]+$/.test(id) && Buffer.byteLength(id) === id.length;
44
+ }
45
+
46
+ function keyMaterial(key, expectedID) {
47
+ if (!key || !validKeyID(key.id) || (expectedID !== undefined && key.id !== expectedID)) {
48
+ fail('crypto', 'field key unavailable or revoked');
49
+ }
50
+ const material = Buffer.from(key.material || []);
51
+ if (material.length !== 32 || !material.some((b) => b !== 0)) fail('crypto', 'invalid field key');
52
+ return Buffer.from(material);
53
+ }
54
+
55
+ function normalizeType(type, code = 'invalid_argument') {
56
+ const t = {
57
+ kind: Number(type && type.kind),
58
+ precision: Number((type && type.precision) || 0),
59
+ scale: Number((type && type.scale) || 0),
60
+ vecElem: Number((type && type.vecElem) || 0),
61
+ };
62
+ const scalar = [
63
+ Kind.UUID, Kind.String, Kind.Text, Kind.Blob, Kind.TimestampTZ, Kind.JSON, Kind.Bool,
64
+ Kind.Int8, Kind.Int16, Kind.Int32, Kind.Int64, Kind.Uint8, Kind.Uint16, Kind.Uint32, Kind.Uint64,
65
+ ].includes(t.kind);
66
+ const decimal = t.kind === Kind.Decimal && Number.isInteger(t.precision) && Number.isInteger(t.scale) &&
67
+ t.precision >= 1 && t.precision <= 38 && t.scale >= 0 && t.scale <= t.precision;
68
+ if ((!scalar && !decimal) || !Number.isInteger(t.kind) || !Number.isInteger(t.precision) ||
69
+ !Number.isInteger(t.scale) || !Number.isInteger(t.vecElem) ||
70
+ (scalar && (t.precision !== 0 || t.scale !== 0)) || t.vecElem !== 0) {
71
+ fail(code, 'unsupported client-encrypted type');
72
+ }
73
+ return t;
74
+ }
75
+
76
+ function sameType(a, b) {
77
+ return a.kind === b.kind && a.precision === b.precision && a.scale === b.scale && a.vecElem === b.vecElem;
78
+ }
79
+
80
+ function validateName(name) {
81
+ const raw = Buffer.from(typeof name === 'string' ? name : '', 'utf8');
82
+ if (raw.length < 1 || raw.length > 0xffff) fail('invalid_argument', 'database, table, and column are required and bounded');
83
+ return raw;
84
+ }
85
+
86
+ function u16(n) { const b = Buffer.alloc(2); b.writeUInt16LE(n); return b; }
87
+ function u32(n) { const b = Buffer.alloc(4); b.writeUInt32LE(n); return b; }
88
+ function lengthBytes(raw) { return Buffer.concat([u32(raw.length), raw]); }
89
+
90
+ function readLengthBytes(raw) {
91
+ if (raw.length < 4) fail('invalid_format', 'invalid encrypted value');
92
+ const n = raw.readUInt32LE(0);
93
+ if (n > MAX_PLAINTEXT || n + 4 !== raw.length) fail('invalid_format', 'invalid encrypted value');
94
+ return raw.subarray(4);
95
+ }
96
+
97
+ function encodeScalar(type, value) {
98
+ switch (type.kind) {
99
+ case Kind.UUID:
100
+ return Buffer.from(parseUUID(value));
101
+ case Kind.String:
102
+ case Kind.Text:
103
+ if (typeof value !== 'string') fail('invalid_argument', 'encrypted string value must be a string');
104
+ return lengthBytes(Buffer.from(value, 'utf8'));
105
+ case Kind.Blob:
106
+ if (!Buffer.isBuffer(value) && !(value instanceof Uint8Array)) {
107
+ fail('invalid_argument', 'encrypted BLOB value must be a Buffer or Uint8Array');
108
+ }
109
+ return lengthBytes(Buffer.from(value));
110
+ case Kind.Decimal: {
111
+ const normalized = normalizeDecimalValue(type, value);
112
+ const raw = Buffer.from(encodeDecimalString(normalized));
113
+ if (raw.readUInt16LE(6) !== type.scale) fail('invalid_argument', 'decimal scale does not match encrypted column type');
114
+ return raw;
115
+ }
116
+ case Kind.TimestampTZ: {
117
+ const ns = typeof value === 'bigint' ? value :
118
+ value instanceof Date && Number.isFinite(value.getTime()) ? BigInt(value.getTime()) * 1000000n : null;
119
+ if (ns === null || ns < -(1n << 63n) || ns >= (1n << 63n)) fail('invalid_argument', 'TIMESTAMPTZ must be a Date or unix-nanosecond bigint');
120
+ const out = Buffer.alloc(8);
121
+ out.writeBigInt64LE(ns);
122
+ return out;
123
+ }
124
+ case Kind.JSON:
125
+ return lengthBytes(encodeNSJB(value));
126
+ case Kind.Bool:
127
+ if (typeof value !== 'boolean') fail('invalid_argument', 'encrypted BOOL value must be boolean');
128
+ return Buffer.from([value ? 1 : 0]);
129
+ case Kind.Int8:
130
+ case Kind.Int16:
131
+ case Kind.Int32:
132
+ case Kind.Int64:
133
+ return encodeInt(type.kind, value);
134
+ case Kind.Uint8:
135
+ case Kind.Uint16:
136
+ case Kind.Uint32:
137
+ case Kind.Uint64:
138
+ return encodeUint(type.kind, value);
139
+ default:
140
+ fail('invalid_argument', 'unsupported client-encrypted type');
141
+ }
142
+ }
143
+
144
+ // encodeInt/decodeInt/encodeUint/decodeUint (D2/D3, Datatype expansion
145
+ // track) use the same fixed-width raw-byte plaintext shape as the server's
146
+ // row encoding (internal/sql/types/row.go encodeScalar) — not the
147
+ // length-prefixed shape STRING/BLOB/DECIMAL use — so any official driver
148
+ // can decrypt a field another driver encrypted.
149
+ const INT_ENC_RANGES = {
150
+ [Kind.Int8]: [-0x80n, 0x7fn, 1],
151
+ [Kind.Int16]: [-0x8000n, 0x7fffn, 2],
152
+ [Kind.Int32]: [-0x80000000n, 0x7fffffffn, 4],
153
+ [Kind.Int64]: [-0x8000000000000000n, 0x7fffffffffffffffn, 8],
154
+ };
155
+
156
+ function encodeInt(kind, value) {
157
+ const [lo, hi, width] = INT_ENC_RANGES[kind];
158
+ let n;
159
+ try {
160
+ n = BigInt(value);
161
+ } catch {
162
+ fail('invalid_argument', 'encrypted int value must be an integer');
163
+ }
164
+ if (n < lo || n > hi) fail('invalid_argument', 'encrypted int value out of range');
165
+ const full = Buffer.alloc(8);
166
+ full.writeBigInt64LE(n, 0);
167
+ return full.subarray(0, width);
168
+ }
169
+
170
+ function decodeInt(kind, raw) {
171
+ const [, , width] = INT_ENC_RANGES[kind];
172
+ if (raw.length !== width) fail('invalid_format', 'invalid encrypted value');
173
+ const full = Buffer.alloc(8);
174
+ raw.copy(full, 0, 0, width);
175
+ if (width < 8 && (raw[width - 1] & 0x80) !== 0) full.fill(0xff, width);
176
+ const n = full.readBigInt64LE(0);
177
+ return width === 8 ? n : Number(n);
178
+ }
179
+
180
+ const UINT_ENC_RANGES = {
181
+ [Kind.Uint8]: [0xffn, 1],
182
+ [Kind.Uint16]: [0xffffn, 2],
183
+ [Kind.Uint32]: [0xffffffffn, 4],
184
+ [Kind.Uint64]: [0xffffffffffffffffn, 8],
185
+ };
186
+
187
+ function encodeUint(kind, value) {
188
+ const [hi, width] = UINT_ENC_RANGES[kind];
189
+ let n;
190
+ try {
191
+ n = BigInt(value);
192
+ } catch {
193
+ fail('invalid_argument', 'encrypted uint value must be an integer');
194
+ }
195
+ if (n < 0n || n > hi) fail('invalid_argument', 'encrypted uint value out of range');
196
+ const full = Buffer.alloc(8);
197
+ full.writeBigUInt64LE(n, 0);
198
+ return full.subarray(0, width);
199
+ }
200
+
201
+ function decodeUint(kind, raw) {
202
+ const [, width] = UINT_ENC_RANGES[kind];
203
+ if (raw.length !== width) fail('invalid_format', 'invalid encrypted value');
204
+ const full = Buffer.alloc(8);
205
+ raw.copy(full, 0, 0, width);
206
+ const n = full.readBigUInt64LE(0);
207
+ return width === 8 ? n : Number(n);
208
+ }
209
+
210
+ function normalizeDecimalValue(type, value) {
211
+ const original = String(value).trim();
212
+ const sign = original.startsWith('-') ? '-' : '';
213
+ let text = original;
214
+ if (text.startsWith('+') || text.startsWith('-')) text = text.slice(1);
215
+ if (!/^\d+(\.\d+)?$/.test(text)) fail('invalid_argument', 'invalid decimal');
216
+ const [whole, inputFraction = ''] = text.split('.');
217
+ let fraction = inputFraction;
218
+ if (fraction.length > type.scale) {
219
+ if (!/^0*$/.test(fraction.slice(type.scale))) fail('invalid_argument', 'decimal would lose scale');
220
+ fraction = fraction.slice(0, type.scale);
221
+ }
222
+ fraction = fraction.padEnd(type.scale, '0');
223
+ const digits = (whole + fraction).replace(/^0+/, '') || '0';
224
+ if (digits.length > type.precision) fail('invalid_argument', 'decimal exceeds encrypted column precision');
225
+ return sign + whole + (type.scale > 0 ? '.' + fraction : '');
226
+ }
227
+
228
+ function decodeScalar(type, raw) {
229
+ try {
230
+ switch (type.kind) {
231
+ case Kind.UUID:
232
+ if (raw.length !== 16) fail('invalid_format', 'invalid encrypted value');
233
+ return formatUUID(raw);
234
+ case Kind.String:
235
+ case Kind.Text:
236
+ return new TextDecoder('utf-8', { fatal: true }).decode(readLengthBytes(raw));
237
+ case Kind.Blob:
238
+ return Buffer.from(readLengthBytes(raw));
239
+ case Kind.Decimal:
240
+ return decodeDecimal(readLengthBytes(raw));
241
+ case Kind.TimestampTZ: {
242
+ if (raw.length !== 8) fail('invalid_format', 'invalid encrypted value');
243
+ const ns = raw.readBigInt64LE(0);
244
+ return ns % 1000000n === 0n ? new Date(Number(ns / 1000000n)) : ns;
245
+ }
246
+ case Kind.JSON:
247
+ return decodeNSJB(readLengthBytes(raw));
248
+ case Kind.Bool:
249
+ if (raw.length !== 1 || raw[0] > 1) fail('invalid_format', 'invalid encrypted value');
250
+ return raw[0] === 1;
251
+ case Kind.Int8:
252
+ case Kind.Int16:
253
+ case Kind.Int32:
254
+ case Kind.Int64:
255
+ return decodeInt(type.kind, raw);
256
+ case Kind.Uint8:
257
+ case Kind.Uint16:
258
+ case Kind.Uint32:
259
+ case Kind.Uint64:
260
+ return decodeUint(type.kind, raw);
261
+ default:
262
+ fail('invalid_format', 'unsupported encrypted logical type');
263
+ }
264
+ } catch (err) {
265
+ if (err instanceof NextSQLError) throw err;
266
+ fail('invalid_format', 'invalid encrypted value');
267
+ }
268
+ }
269
+
270
+ function encodeNSJB(value) {
271
+ const body = encodeJSONValue(value, 0);
272
+ const out = Buffer.concat([Buffer.from('NSJB\x01', 'binary'), body]);
273
+ if (out.length > MAX_PLAINTEXT) fail('exhausted', 'JSON exceeds field limit');
274
+ return out;
275
+ }
276
+
277
+ function jsonUTF8(value) {
278
+ const raw = Buffer.from(value, 'utf8');
279
+ if (raw.toString('utf8') !== value) fail('invalid_argument', 'JSON string is not valid Unicode');
280
+ return raw;
281
+ }
282
+
283
+ function encodeJSONValue(value, depth) {
284
+ if (depth > 32) fail('exhausted', 'JSON exceeds depth limit');
285
+ if (value === null) return Buffer.from([0]);
286
+ if (value === false) return Buffer.from([1]);
287
+ if (value === true) return Buffer.from([2]);
288
+ if (typeof value === 'string') return jsonTagged(4, jsonUTF8(value));
289
+ if (typeof value === 'bigint') {
290
+ if (value < -(1n << 63n) || value >= (1n << 63n)) fail('invalid_argument', 'JSON integer is out of range');
291
+ const b = Buffer.alloc(9); b[0] = 3; b.writeBigInt64LE(value, 1); return b;
292
+ }
293
+ if (typeof value === 'number') {
294
+ if (!Number.isFinite(value)) fail('invalid_argument', 'JSON number is not finite');
295
+ if (Number.isSafeInteger(value)) {
296
+ const b = Buffer.alloc(9); b[0] = 3; b.writeBigInt64LE(BigInt(value), 1); return b;
297
+ }
298
+ return jsonTagged(5, Buffer.from(String(value), 'utf8'));
299
+ }
300
+ if (Array.isArray(value)) {
301
+ if (value.length > (1 << 20)) fail('exhausted', 'JSON array exceeds limit');
302
+ const vals = [];
303
+ let size = 4;
304
+ for (const entry of value) {
305
+ const encoded = encodeJSONValue(entry, depth + 1);
306
+ size += encoded.length;
307
+ if (size > MAX_PLAINTEXT) fail('exhausted', 'JSON exceeds field limit');
308
+ vals.push(encoded);
309
+ }
310
+ const body = Buffer.concat([u32(vals.length), ...vals]);
311
+ return Buffer.concat([Buffer.from([6]), u32(body.length), body]);
312
+ }
313
+ if (value && typeof value === 'object' && Object.getPrototypeOf(value) === Object.prototype) {
314
+ const keys = Object.keys(value).sort((a, b) => Buffer.compare(jsonUTF8(a), jsonUTF8(b)));
315
+ if (keys.length > 0xffff) fail('exhausted', 'JSON object exceeds limit');
316
+ const entries = [u16(keys.length)];
317
+ let size = 2;
318
+ for (const key of keys) {
319
+ const k = jsonUTF8(key);
320
+ if (k.length > 0xffff) fail('exhausted', 'JSON key exceeds limit');
321
+ const encoded = encodeJSONValue(value[key], depth + 1);
322
+ size += 2 + k.length + encoded.length;
323
+ if (size > MAX_PLAINTEXT) fail('exhausted', 'JSON exceeds field limit');
324
+ entries.push(u16(k.length), k, encoded);
325
+ }
326
+ const body = Buffer.concat(entries);
327
+ return Buffer.concat([Buffer.from([7]), u32(body.length), body]);
328
+ }
329
+ fail('invalid_argument', 'unsupported JSON value');
330
+ }
331
+
332
+ function jsonTagged(tag, raw) {
333
+ if (raw.length > MAX_PLAINTEXT) fail('exhausted', 'JSON string exceeds limit');
334
+ return Buffer.concat([Buffer.from([tag]), u32(raw.length), raw]);
335
+ }
336
+
337
+ function makeHeader(keyID, type, nonce) {
338
+ const id = Buffer.from(keyID, 'ascii');
339
+ const out = Buffer.alloc(3 + id.length + 6 + NONCE_SIZE);
340
+ out[0] = 1; out[1] = 1; out[2] = id.length; id.copy(out, 3);
341
+ const off = 3 + id.length;
342
+ out[off] = type.kind; out.writeUInt16LE(type.precision, off + 1); out.writeUInt16LE(type.scale, off + 3); out[off + 5] = type.vecElem;
343
+ nonce.copy(out, off + 6);
344
+ return out;
345
+ }
346
+
347
+ function aad(database, table, column, publicHeader) {
348
+ const parts = [Buffer.from(PREFIX, 'ascii')];
349
+ for (const name of [database, table, column]) {
350
+ const raw = validateName(name);
351
+ parts.push(u16(raw.length), raw);
352
+ }
353
+ parts.push(publicHeader);
354
+ return Buffer.concat(parts);
355
+ }
356
+
357
+ function inspectField(ciphertext) {
358
+ if (typeof ciphertext !== 'string' || !ciphertext.startsWith(PREFIX)) fail('invalid_format', 'invalid client ciphertext prefix');
359
+ const enc = ciphertext.slice(PREFIX.length);
360
+ if (!enc || enc.length % 4 === 1 || !/^[A-Za-z0-9_-]+$/.test(enc) || enc.length > Math.ceil((MAX_PLAINTEXT + 101) * 4 / 3)) {
361
+ fail('invalid_format', 'client ciphertext length out of range');
362
+ }
363
+ const body = Buffer.from(enc, 'base64url');
364
+ if (body.toString('base64url') !== enc || body.length < 38 || body[0] !== 1 || body[1] !== 1) fail('invalid_format', 'unsupported or truncated client ciphertext');
365
+ const n = body[2];
366
+ if (n < 1 || n > MAX_KEY_ID || body.length < 3 + n + 6 + NONCE_SIZE + TAG_SIZE) fail('invalid_format', 'invalid field key id length');
367
+ const keyID = body.subarray(3, 3 + n).toString('ascii');
368
+ if (!validKeyID(keyID)) fail('invalid_format', 'invalid field key id');
369
+ const off = 3 + n;
370
+ const type = normalizeType({ kind: body[off], precision: body.readUInt16LE(off + 1), scale: body.readUInt16LE(off + 3), vecElem: body[off + 5] }, 'invalid_format');
371
+ return { keyID, type, body, headerLength: off + 6 + NONCE_SIZE };
372
+ }
373
+
374
+ async function encryptField(provider, database, table, column, type, value) {
375
+ if (value === null || value === undefined) return null;
376
+ if (!provider || typeof provider.currentFieldKey !== 'function') fail('invalid_argument', 'field key provider is required');
377
+ const t = normalizeType(type);
378
+ const plain = encodeScalar(t, value);
379
+ if (plain.length > MAX_PLAINTEXT) fail('exhausted', 'plaintext exceeds field limit');
380
+ let key;
381
+ try { key = await provider.currentFieldKey(database, table, column); } catch { fail('crypto', 'field key unavailable'); }
382
+ const material = keyMaterial(key);
383
+ const nonce = randomBytes(NONCE_SIZE);
384
+ const header = makeHeader(key.id, t, nonce);
385
+ const publicHeader = header.subarray(0, header.length - NONCE_SIZE);
386
+ try {
387
+ const cipher = createCipheriv('aes-256-gcm', material, nonce, { authTagLength: TAG_SIZE });
388
+ cipher.setAAD(aad(database, table, column, publicHeader));
389
+ const sealed = Buffer.concat([cipher.update(plain), cipher.final(), cipher.getAuthTag()]);
390
+ return PREFIX + Buffer.concat([header, sealed]).toString('base64url');
391
+ } catch { fail('crypto', 'field encryption failed'); }
392
+ }
393
+
394
+ async function decryptField(provider, database, table, column, expectedType, ciphertext) {
395
+ const expected = normalizeType(expectedType);
396
+ if (ciphertext === null || ciphertext === undefined) return null;
397
+ if (!provider || typeof provider.fieldKey !== 'function') fail('invalid_argument', 'field key provider is required');
398
+ const parsed = inspectField(ciphertext);
399
+ if (!sameType(expected, parsed.type)) fail('invalid_format', 'encrypted logical type mismatch');
400
+ let key;
401
+ try { key = await provider.fieldKey(database, table, column, parsed.keyID); } catch { fail('crypto', 'field key unavailable or revoked'); }
402
+ const material = keyMaterial(key, parsed.keyID);
403
+ const nonce = parsed.body.subarray(parsed.headerLength - NONCE_SIZE, parsed.headerLength);
404
+ const payload = parsed.body.subarray(parsed.headerLength);
405
+ try {
406
+ const decipher = createDecipheriv('aes-256-gcm', material, nonce, { authTagLength: TAG_SIZE });
407
+ decipher.setAAD(aad(database, table, column, parsed.body.subarray(0, parsed.headerLength - NONCE_SIZE)));
408
+ decipher.setAuthTag(payload.subarray(payload.length - TAG_SIZE));
409
+ const plain = Buffer.concat([decipher.update(payload.subarray(0, payload.length - TAG_SIZE)), decipher.final()]);
410
+ if (plain.length > MAX_PLAINTEXT) fail('invalid_format', 'plaintext exceeds field limit');
411
+ return decodeScalar(expected, plain);
412
+ } catch (err) {
413
+ if (err instanceof NextSQLError) throw err;
414
+ fail('crypto', 'ciphertext authentication failed');
415
+ }
416
+ }
417
+
418
+ function generateFieldKey(id) {
419
+ if (!validKeyID(id)) fail('invalid_argument', 'invalid field key id');
420
+ return { id, material: randomBytes(32) };
421
+ }
422
+
423
+ class MemoryFieldKeyring {
424
+ constructor(current, ...overlap) {
425
+ const keys = [current, ...overlap];
426
+ if (keys.length > MAX_KEYS) fail('invalid_argument', 'too many field keys');
427
+ this._keys = new Map();
428
+ for (const key of keys) {
429
+ const material = keyMaterial(key);
430
+ if (this._keys.has(key.id)) fail('invalid_argument', 'duplicate field key id');
431
+ this._keys.set(key.id, { id: key.id, material });
432
+ }
433
+ this._current = current.id;
434
+ }
435
+ async currentFieldKey() {
436
+ const key = this._keys.get(this._current);
437
+ if (!key) fail('crypto', 'current field key unavailable');
438
+ return { id: key.id, material: Buffer.from(key.material) };
439
+ }
440
+ async fieldKey(_database, _table, _column, id) {
441
+ const key = this._keys.get(id);
442
+ if (!key) fail('crypto', 'field key unavailable or revoked');
443
+ return { id: key.id, material: Buffer.from(key.material) };
444
+ }
445
+ rotate(key) {
446
+ const material = keyMaterial(key);
447
+ if (!this._keys.has(key.id) && this._keys.size >= MAX_KEYS) fail('exhausted', 'field key limit reached');
448
+ this._keys.set(key.id, { id: key.id, material });
449
+ this._current = key.id;
450
+ }
451
+ revoke(id) {
452
+ if (id === this._current) fail('conflict', 'cannot revoke current field key');
453
+ this._keys.delete(id);
454
+ }
455
+ }
456
+
457
+ // NSFK1 is the durable field-keyring format used by FileFieldKeyring (see
458
+ // drivers/js/client-encryption.mjs for the identical shared (Bun) codec and
459
+ // drivers/go/nextsql.go for the Go implementation):
460
+ //
461
+ // magic "NSFK" (4) | version u16=1 | count u16
462
+ // per record: idLen u8 | id bytes | created u64 (unix seconds) |
463
+ // flags u8 (bit0=current, bit1=revoked) | material [32]byte
464
+ // (all-zero when revoked)
465
+ const FIELD_KEYRING_MAGIC = 'NSFK';
466
+ const FIELD_KEYRING_VERSION = 1;
467
+ const FK_FLAG_CURRENT = 1 << 0;
468
+ const FK_FLAG_REVOKED = 1 << 1;
469
+
470
+ function u16(n) {
471
+ const b = Buffer.alloc(2);
472
+ b.writeUInt16LE(n);
473
+ return b;
474
+ }
475
+
476
+ function encodeFieldKeyring(records) {
477
+ if (records.length > MAX_KEYS) fail('invalid_argument', 'too many field keys');
478
+ const parts = [Buffer.from(FIELD_KEYRING_MAGIC, 'ascii'), u16(FIELD_KEYRING_VERSION), u16(records.length)];
479
+ for (const rec of records) {
480
+ if (!validKeyID(rec.id)) fail('invalid_format', 'invalid field key id length');
481
+ const idBuf = Buffer.from(rec.id, 'ascii');
482
+ const material = Buffer.from(rec.material || []);
483
+ if (material.length !== 32) fail('invalid_format', 'invalid field key material size');
484
+ let flags = 0;
485
+ if (rec.current) flags |= FK_FLAG_CURRENT;
486
+ if (rec.revoked) flags |= FK_FLAG_REVOKED;
487
+ const created = Buffer.alloc(8);
488
+ created.writeBigUInt64LE(BigInt(Math.trunc(rec.created)));
489
+ parts.push(Buffer.from([idBuf.length]), idBuf, created, Buffer.from([flags]), material);
490
+ }
491
+ return Buffer.concat(parts);
492
+ }
493
+
494
+ function decodeFieldKeyring(input) {
495
+ const raw = Buffer.isBuffer(input) ? input : Buffer.from(input);
496
+ const bad = (msg) => fail('invalid_format', msg);
497
+ if (raw.length < 8) bad('truncated keyring');
498
+ if (raw.toString('ascii', 0, 4) !== FIELD_KEYRING_MAGIC) bad('bad keyring magic');
499
+ if (raw.readUInt16LE(4) !== FIELD_KEYRING_VERSION) bad('unsupported keyring version');
500
+ const count = raw.readUInt16LE(6);
501
+ if (count > MAX_KEYS) bad('key count exceeds limit');
502
+ const records = [];
503
+ const seen = new Set();
504
+ let off = 8;
505
+ let currentCount = 0;
506
+ for (let i = 0; i < count; i++) {
507
+ if (off >= raw.length) bad('truncated id length');
508
+ const idLen = raw[off];
509
+ off += 1;
510
+ if (idLen < 1 || idLen > MAX_KEY_ID) bad('invalid field key id length');
511
+ if (off + idLen > raw.length) bad('truncated field key id');
512
+ const id = raw.toString('ascii', off, off + idLen);
513
+ off += idLen;
514
+ if (!validKeyID(id)) bad('invalid field key id');
515
+ if (off + 8 > raw.length) bad('truncated created time');
516
+ const created = Number(raw.readBigUInt64LE(off));
517
+ off += 8;
518
+ if (off >= raw.length) bad('truncated flags');
519
+ const flags = raw[off];
520
+ off += 1;
521
+ if (off + 32 > raw.length) bad('truncated field key material');
522
+ const material = Buffer.from(raw.subarray(off, off + 32));
523
+ off += 32;
524
+ if (seen.has(id)) bad('duplicate field key id');
525
+ seen.add(id);
526
+ const current = (flags & FK_FLAG_CURRENT) !== 0;
527
+ const revoked = (flags & FK_FLAG_REVOKED) !== 0;
528
+ if (current && revoked) bad('current field key cannot be revoked');
529
+ const allZero = !material.some((b) => b !== 0);
530
+ if (revoked) {
531
+ if (!allZero) bad('revoked field key retains material');
532
+ } else if (allZero) {
533
+ bad('empty field key material');
534
+ }
535
+ if (current) currentCount++;
536
+ records.push({ id, created, current, revoked, material });
537
+ }
538
+ if (off !== raw.length) bad('trailing keyring bytes');
539
+ if (records.length === 0) bad('keyring has no keys');
540
+ if (currentCount !== 1) bad('keyring must have exactly one current key');
541
+ return records;
542
+ }
543
+
544
+ // FileFieldKeyring is a durable, atomic, file-backed FieldKeyProvider:
545
+ // rotation and revocation persist across process restarts, unlike
546
+ // MemoryFieldKeyring. Production applications with an existing secret
547
+ // manager or KMS should implement the provider contract directly against
548
+ // that system instead.
549
+ class FileFieldKeyring {
550
+ constructor(path, records) {
551
+ this._path = path;
552
+ this._records = records;
553
+ }
554
+
555
+ static async create(path, current) {
556
+ try {
557
+ await fs.stat(path);
558
+ fail('already_exists', 'keyring file exists');
559
+ } catch (err) {
560
+ if (err.code !== 'ENOENT') throw err;
561
+ }
562
+ const material = keyMaterial(current);
563
+ const record = { id: current.id, created: Math.floor(Date.now() / 1000), current: true, revoked: false, material };
564
+ const kr = new FileFieldKeyring(path, [record]);
565
+ await kr._persist();
566
+ return kr;
567
+ }
568
+
569
+ static async open(path) {
570
+ const raw = await fs.readFile(path);
571
+ const records = decodeFieldKeyring(raw);
572
+ return new FileFieldKeyring(path, records);
573
+ }
574
+
575
+ get path() {
576
+ return this._path;
577
+ }
578
+
579
+ async currentFieldKey() {
580
+ const rec = this._records.find((r) => r.current && !r.revoked);
581
+ if (!rec) fail('crypto', 'current field key unavailable');
582
+ return { id: rec.id, material: Buffer.from(rec.material) };
583
+ }
584
+
585
+ async fieldKey(_database, _table, _column, id) {
586
+ const rec = this._records.find((r) => r.id === id);
587
+ if (!rec || rec.revoked) fail('crypto', 'field key unavailable or revoked');
588
+ return { id: rec.id, material: Buffer.from(rec.material) };
589
+ }
590
+
591
+ async rotate(key) {
592
+ const material = keyMaterial(key);
593
+ let rec = this._records.find((r) => r.id === key.id);
594
+ if (rec && rec.revoked) fail('conflict', 'cannot reuse a revoked field key id');
595
+ if (!rec) {
596
+ if (this._records.length >= MAX_KEYS) fail('exhausted', 'field key limit reached');
597
+ rec = { id: key.id, created: Math.floor(Date.now() / 1000), current: false, revoked: false, material };
598
+ this._records.push(rec);
599
+ }
600
+ for (const r of this._records) r.current = false;
601
+ rec.current = true;
602
+ rec.material = material;
603
+ await this._persist();
604
+ }
605
+
606
+ async revoke(id) {
607
+ const rec = this._records.find((r) => r.id === id);
608
+ if (!rec) fail('not_found', 'unknown field key id');
609
+ if (rec.current) fail('conflict', 'cannot revoke the current field key');
610
+ if (rec.revoked) return;
611
+ rec.revoked = true;
612
+ rec.material = Buffer.alloc(32);
613
+ await this._persist();
614
+ }
615
+
616
+ async reload() {
617
+ const raw = await fs.readFile(this._path);
618
+ this._records = decodeFieldKeyring(raw);
619
+ }
620
+
621
+ list() {
622
+ return this._records.map((r) => ({ id: r.id, created: r.created, current: r.current, revoked: r.revoked }));
623
+ }
624
+
625
+ async _persist() {
626
+ const raw = encodeFieldKeyring(this._records);
627
+ const tmp = `${this._path}.tmp`;
628
+ await fs.writeFile(tmp, raw, { mode: 0o600 });
629
+ await fs.rename(tmp, this._path);
630
+ }
631
+ }
632
+
633
+ return { FieldType, MemoryFieldKeyring, FileFieldKeyring, decryptField, encryptField, generateFieldKey, inspectField };
634
+ };