@syncular/client-plugin-encryption 0.0.1-60
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.
- package/dist/index.d.ts +78 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +713 -0
- package/dist/index.js.map +1 -0
- package/dist/key-sharing.d.ts +124 -0
- package/dist/key-sharing.d.ts.map +1 -0
- package/dist/key-sharing.js +408 -0
- package/dist/key-sharing.js.map +1 -0
- package/package.json +65 -0
- package/src/__tests__/key-sharing.test.ts +193 -0
- package/src/__tests__/refresh-encrypted-fields.test.ts +182 -0
- package/src/index.ts +1011 -0
- package/src/key-sharing.ts +555 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,713 @@
|
|
|
1
|
+
import { xchacha20poly1305 } from '@noble/ciphers/chacha.js';
|
|
2
|
+
import { sql } from 'kysely';
|
|
3
|
+
// Re-export key sharing utilities
|
|
4
|
+
export * from './key-sharing';
|
|
5
|
+
const DEFAULT_PREFIX = 'dgsync:e2ee:1:';
|
|
6
|
+
const encoder = new TextEncoder();
|
|
7
|
+
const decoder = new TextDecoder();
|
|
8
|
+
// Base64 lookup tables for universal encoding/decoding (works in all runtimes)
|
|
9
|
+
const BASE64_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
|
|
10
|
+
const BASE64_LOOKUP = new Uint8Array(256);
|
|
11
|
+
for (let i = 0; i < BASE64_CHARS.length; i++) {
|
|
12
|
+
BASE64_LOOKUP[BASE64_CHARS.charCodeAt(i)] = i;
|
|
13
|
+
}
|
|
14
|
+
function isRecord(value) {
|
|
15
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
16
|
+
}
|
|
17
|
+
function randomBytes(length) {
|
|
18
|
+
const cryptoObj = globalThis.crypto;
|
|
19
|
+
if (!cryptoObj?.getRandomValues) {
|
|
20
|
+
throw new Error('Secure random generator is not available (crypto.getRandomValues). ' +
|
|
21
|
+
'Ensure you are running in a secure context or polyfill crypto.');
|
|
22
|
+
}
|
|
23
|
+
const out = new Uint8Array(length);
|
|
24
|
+
cryptoObj.getRandomValues(out);
|
|
25
|
+
return out;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Universal base64 encoding that works in all JavaScript runtimes.
|
|
29
|
+
* Uses Buffer for Node/Bun (fast), lookup table for others (RN-compatible).
|
|
30
|
+
*/
|
|
31
|
+
function bytesToBase64(bytes) {
|
|
32
|
+
// Node/Bun fast path
|
|
33
|
+
if (typeof Buffer !== 'undefined') {
|
|
34
|
+
return Buffer.from(bytes).toString('base64');
|
|
35
|
+
}
|
|
36
|
+
// Universal fallback using lookup table (works in RN, browsers, etc.)
|
|
37
|
+
let result = '';
|
|
38
|
+
const len = bytes.length;
|
|
39
|
+
const remainder = len % 3;
|
|
40
|
+
// Process 3 bytes at a time
|
|
41
|
+
for (let i = 0; i < len - remainder; i += 3) {
|
|
42
|
+
const a = bytes[i];
|
|
43
|
+
const b = bytes[i + 1];
|
|
44
|
+
const c = bytes[i + 2];
|
|
45
|
+
result +=
|
|
46
|
+
BASE64_CHARS.charAt((a >> 2) & 0x3f) +
|
|
47
|
+
BASE64_CHARS.charAt(((a << 4) | (b >> 4)) & 0x3f) +
|
|
48
|
+
BASE64_CHARS.charAt(((b << 2) | (c >> 6)) & 0x3f) +
|
|
49
|
+
BASE64_CHARS.charAt(c & 0x3f);
|
|
50
|
+
}
|
|
51
|
+
// Handle remaining bytes
|
|
52
|
+
if (remainder === 1) {
|
|
53
|
+
const a = bytes[len - 1];
|
|
54
|
+
result +=
|
|
55
|
+
BASE64_CHARS.charAt((a >> 2) & 0x3f) +
|
|
56
|
+
BASE64_CHARS.charAt((a << 4) & 0x3f) +
|
|
57
|
+
'==';
|
|
58
|
+
}
|
|
59
|
+
else if (remainder === 2) {
|
|
60
|
+
const a = bytes[len - 2];
|
|
61
|
+
const b = bytes[len - 1];
|
|
62
|
+
result +=
|
|
63
|
+
BASE64_CHARS.charAt((a >> 2) & 0x3f) +
|
|
64
|
+
BASE64_CHARS.charAt(((a << 4) | (b >> 4)) & 0x3f) +
|
|
65
|
+
BASE64_CHARS.charAt((b << 2) & 0x3f) +
|
|
66
|
+
'=';
|
|
67
|
+
}
|
|
68
|
+
return result;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Universal base64 decoding that works in all JavaScript runtimes.
|
|
72
|
+
* Uses Buffer for Node/Bun (fast), lookup table for others (RN-compatible).
|
|
73
|
+
*/
|
|
74
|
+
function base64ToBytes(base64) {
|
|
75
|
+
// Node/Bun fast path
|
|
76
|
+
if (typeof Buffer !== 'undefined') {
|
|
77
|
+
return new Uint8Array(Buffer.from(base64, 'base64'));
|
|
78
|
+
}
|
|
79
|
+
// Universal fallback using lookup table (works in RN, browsers, etc.)
|
|
80
|
+
// Remove padding and calculate output length
|
|
81
|
+
const len = base64.length;
|
|
82
|
+
let padding = 0;
|
|
83
|
+
if (base64[len - 1] === '=')
|
|
84
|
+
padding++;
|
|
85
|
+
if (base64[len - 2] === '=')
|
|
86
|
+
padding++;
|
|
87
|
+
const outputLen = (len * 3) / 4 - padding;
|
|
88
|
+
const out = new Uint8Array(outputLen);
|
|
89
|
+
let outIdx = 0;
|
|
90
|
+
for (let i = 0; i < len; i += 4) {
|
|
91
|
+
const a = BASE64_LOOKUP[base64.charCodeAt(i)];
|
|
92
|
+
const b = BASE64_LOOKUP[base64.charCodeAt(i + 1)];
|
|
93
|
+
const c = BASE64_LOOKUP[base64.charCodeAt(i + 2)];
|
|
94
|
+
const d = BASE64_LOOKUP[base64.charCodeAt(i + 3)];
|
|
95
|
+
out[outIdx++] = (a << 2) | (b >> 4);
|
|
96
|
+
if (outIdx < outputLen)
|
|
97
|
+
out[outIdx++] = ((b << 4) | (c >> 2)) & 0xff;
|
|
98
|
+
if (outIdx < outputLen)
|
|
99
|
+
out[outIdx++] = ((c << 6) | d) & 0xff;
|
|
100
|
+
}
|
|
101
|
+
return out;
|
|
102
|
+
}
|
|
103
|
+
function bytesToBase64Url(bytes) {
|
|
104
|
+
return bytesToBase64(bytes)
|
|
105
|
+
.replace(/\+/g, '-')
|
|
106
|
+
.replace(/\//g, '_')
|
|
107
|
+
.replace(/=+$/g, '');
|
|
108
|
+
}
|
|
109
|
+
function base64UrlToBytes(base64url) {
|
|
110
|
+
const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/');
|
|
111
|
+
const padded = base64 + '==='.slice((base64.length + 3) % 4);
|
|
112
|
+
return base64ToBytes(padded);
|
|
113
|
+
}
|
|
114
|
+
function hexToBytes(hex) {
|
|
115
|
+
const normalized = hex.trim().toLowerCase();
|
|
116
|
+
if (normalized.length % 2 !== 0) {
|
|
117
|
+
throw new Error('Invalid hex string (length must be even)');
|
|
118
|
+
}
|
|
119
|
+
const out = new Uint8Array(normalized.length / 2);
|
|
120
|
+
for (let i = 0; i < out.length; i++) {
|
|
121
|
+
const byte = Number.parseInt(normalized.slice(i * 2, i * 2 + 2), 16);
|
|
122
|
+
if (!Number.isFinite(byte))
|
|
123
|
+
throw new Error('Invalid hex string');
|
|
124
|
+
out[i] = byte;
|
|
125
|
+
}
|
|
126
|
+
return out;
|
|
127
|
+
}
|
|
128
|
+
function decodeKeyMaterial(key) {
|
|
129
|
+
if (key instanceof Uint8Array)
|
|
130
|
+
return key;
|
|
131
|
+
const trimmed = key.trim();
|
|
132
|
+
if (trimmed.startsWith('hex:'))
|
|
133
|
+
return hexToBytes(trimmed.slice('hex:'.length));
|
|
134
|
+
if (trimmed.startsWith('base64:'))
|
|
135
|
+
return base64ToBytes(trimmed.slice('base64:'.length));
|
|
136
|
+
if (trimmed.startsWith('base64url:'))
|
|
137
|
+
return base64UrlToBytes(trimmed.slice('base64url:'.length));
|
|
138
|
+
// Heuristic: 64 hex chars → 32-byte key.
|
|
139
|
+
if (/^[0-9a-fA-F]{64}$/.test(trimmed))
|
|
140
|
+
return hexToBytes(trimmed);
|
|
141
|
+
return base64UrlToBytes(trimmed);
|
|
142
|
+
}
|
|
143
|
+
export function createStaticFieldEncryptionKeys(args) {
|
|
144
|
+
const cache = new Map();
|
|
145
|
+
return {
|
|
146
|
+
async getKey(kid) {
|
|
147
|
+
const cached = cache.get(kid);
|
|
148
|
+
if (cached)
|
|
149
|
+
return cached;
|
|
150
|
+
const raw = args.keys[kid];
|
|
151
|
+
if (!raw)
|
|
152
|
+
throw new Error(`Missing encryption key for kid "${kid}"`);
|
|
153
|
+
const decoded = decodeKeyMaterial(raw);
|
|
154
|
+
cache.set(kid, decoded);
|
|
155
|
+
return decoded;
|
|
156
|
+
},
|
|
157
|
+
getEncryptionKid() {
|
|
158
|
+
return args.encryptionKid ?? 'default';
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
function makeAadBytes(args) {
|
|
163
|
+
// Keep this stable; changing it breaks decryption.
|
|
164
|
+
const s = `${args.scope}\u001f${args.table}\u001f${args.rowId}\u001f${args.field}`;
|
|
165
|
+
return encoder.encode(s);
|
|
166
|
+
}
|
|
167
|
+
function encodeEnvelope(prefix, args) {
|
|
168
|
+
return `${prefix}${args.kid}:${bytesToBase64Url(args.nonce)}:${bytesToBase64Url(args.ciphertext)}`;
|
|
169
|
+
}
|
|
170
|
+
function decodeEnvelope(prefix, value) {
|
|
171
|
+
if (!value.startsWith(prefix))
|
|
172
|
+
return null;
|
|
173
|
+
const rest = value.slice(prefix.length);
|
|
174
|
+
const parts = rest.split(':');
|
|
175
|
+
if (parts.length !== 3)
|
|
176
|
+
return null;
|
|
177
|
+
const [kid, nonceB64, ctB64] = parts;
|
|
178
|
+
if (!kid || !nonceB64 || !ctB64)
|
|
179
|
+
return null;
|
|
180
|
+
return {
|
|
181
|
+
kid,
|
|
182
|
+
nonce: base64UrlToBytes(nonceB64),
|
|
183
|
+
ciphertext: base64UrlToBytes(ctB64),
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
async function getKeyOrThrow(keys, kid) {
|
|
187
|
+
const key = await keys.getKey(kid);
|
|
188
|
+
if (!(key instanceof Uint8Array)) {
|
|
189
|
+
throw new Error(`Encryption key for kid "${kid}" must be a Uint8Array`);
|
|
190
|
+
}
|
|
191
|
+
if (key.length !== 32) {
|
|
192
|
+
throw new Error(`Encryption key for kid "${kid}" must be 32 bytes (got ${key.length})`);
|
|
193
|
+
}
|
|
194
|
+
return key;
|
|
195
|
+
}
|
|
196
|
+
async function encryptValue(args) {
|
|
197
|
+
if (args.value === null || args.value === undefined)
|
|
198
|
+
return args.value;
|
|
199
|
+
if (typeof args.value === 'string') {
|
|
200
|
+
const parsed = decodeEnvelope(args.prefix, args.value);
|
|
201
|
+
if (parsed)
|
|
202
|
+
return args.value;
|
|
203
|
+
}
|
|
204
|
+
const kid = (await args.keys.getEncryptionKid?.(args.ctx, {
|
|
205
|
+
scope: args.scope,
|
|
206
|
+
table: args.table,
|
|
207
|
+
rowId: args.rowId,
|
|
208
|
+
field: args.field,
|
|
209
|
+
})) ?? 'default';
|
|
210
|
+
if (typeof kid !== 'string' || kid.length === 0) {
|
|
211
|
+
throw new Error('Encryption key id must be a non-empty string');
|
|
212
|
+
}
|
|
213
|
+
if (kid.includes(':')) {
|
|
214
|
+
throw new Error('Encryption key id must not contain ":"');
|
|
215
|
+
}
|
|
216
|
+
const key = await getKeyOrThrow(args.keys, kid);
|
|
217
|
+
const nonce = randomBytes(24); // XChaCha20-Poly1305 nonce size
|
|
218
|
+
const aad = makeAadBytes({
|
|
219
|
+
scope: args.scope,
|
|
220
|
+
table: args.table,
|
|
221
|
+
rowId: args.rowId,
|
|
222
|
+
field: args.field,
|
|
223
|
+
});
|
|
224
|
+
const plaintext = encoder.encode(JSON.stringify(args.value));
|
|
225
|
+
const aead = xchacha20poly1305(key, nonce, aad);
|
|
226
|
+
const ciphertext = aead.encrypt(plaintext);
|
|
227
|
+
return encodeEnvelope(args.prefix, { kid, nonce, ciphertext });
|
|
228
|
+
}
|
|
229
|
+
async function decryptValue(args) {
|
|
230
|
+
if (typeof args.value !== 'string')
|
|
231
|
+
return args.value;
|
|
232
|
+
const parsed = decodeEnvelope(args.prefix, args.value);
|
|
233
|
+
if (!parsed)
|
|
234
|
+
return args.value;
|
|
235
|
+
try {
|
|
236
|
+
const key = await getKeyOrThrow(args.keys, parsed.kid);
|
|
237
|
+
const aad = makeAadBytes({
|
|
238
|
+
scope: args.scope,
|
|
239
|
+
table: args.table,
|
|
240
|
+
rowId: args.rowId,
|
|
241
|
+
field: args.field,
|
|
242
|
+
});
|
|
243
|
+
const aead = xchacha20poly1305(key, parsed.nonce, aad);
|
|
244
|
+
const plaintext = aead.decrypt(parsed.ciphertext);
|
|
245
|
+
const json = decoder.decode(plaintext);
|
|
246
|
+
return JSON.parse(json);
|
|
247
|
+
}
|
|
248
|
+
catch (err) {
|
|
249
|
+
if (args.decryptionErrorMode === 'keepCiphertext')
|
|
250
|
+
return args.value;
|
|
251
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
252
|
+
throw new Error(`Failed to decrypt ${args.scope}.${args.table}.${args.field} row=${args.rowId}: ${message}`);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
function buildRuleIndex(rules) {
|
|
256
|
+
const byScopeTable = new Map();
|
|
257
|
+
const tablesByScope = new Map();
|
|
258
|
+
for (const rule of rules) {
|
|
259
|
+
const scope = rule.scope;
|
|
260
|
+
const table = rule.table ?? '*';
|
|
261
|
+
const key = `${scope}\u001f${table}`;
|
|
262
|
+
const rowIdField = rule.rowIdField ?? 'id';
|
|
263
|
+
const existing = byScopeTable.get(key);
|
|
264
|
+
if (existing) {
|
|
265
|
+
if (existing.rowIdField !== rowIdField) {
|
|
266
|
+
throw new Error(`Conflicting rowIdField for rule ${scope}/${table}: "${existing.rowIdField}" vs "${rowIdField}"`);
|
|
267
|
+
}
|
|
268
|
+
const merged = new Set(existing.fields);
|
|
269
|
+
for (const f of rule.fields)
|
|
270
|
+
merged.add(f);
|
|
271
|
+
byScopeTable.set(key, { fields: merged, rowIdField });
|
|
272
|
+
}
|
|
273
|
+
else {
|
|
274
|
+
byScopeTable.set(key, { fields: new Set(rule.fields), rowIdField });
|
|
275
|
+
}
|
|
276
|
+
if (table !== '*') {
|
|
277
|
+
const set = tablesByScope.get(scope) ?? new Set();
|
|
278
|
+
set.add(table);
|
|
279
|
+
tablesByScope.set(scope, set);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
// Freeze sets to make accidental mutation harder.
|
|
283
|
+
for (const [k, v] of byScopeTable) {
|
|
284
|
+
byScopeTable.set(k, {
|
|
285
|
+
fields: new Set(v.fields),
|
|
286
|
+
rowIdField: v.rowIdField,
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
return { byScopeTable, tablesByScope };
|
|
290
|
+
}
|
|
291
|
+
function getRuleConfig(index, args) {
|
|
292
|
+
const exact = index.byScopeTable.get(`${args.scope}\u001f${args.table}`);
|
|
293
|
+
if (exact)
|
|
294
|
+
return exact;
|
|
295
|
+
const wildcard = index.byScopeTable.get(`${args.scope}\u001f*`);
|
|
296
|
+
return wildcard ?? null;
|
|
297
|
+
}
|
|
298
|
+
function inferSnapshotTable(args) {
|
|
299
|
+
if (isRecord(args.row)) {
|
|
300
|
+
const tn = args.row.table_name;
|
|
301
|
+
if (typeof tn === 'string' && tn.length > 0)
|
|
302
|
+
return tn;
|
|
303
|
+
const tt = args.row.__table;
|
|
304
|
+
if (typeof tt === 'string' && tt.length > 0)
|
|
305
|
+
return tt;
|
|
306
|
+
}
|
|
307
|
+
const tables = args.index.tablesByScope.get(args.scope);
|
|
308
|
+
if (tables && tables.size === 1)
|
|
309
|
+
return Array.from(tables)[0];
|
|
310
|
+
throw new Error(`Cannot infer table for snapshot row (scope="${args.scope}"). Provide FieldEncryptionRule.table or include "table_name"/"__table" in snapshot rows.`);
|
|
311
|
+
}
|
|
312
|
+
function getSnapshotRowId(args) {
|
|
313
|
+
if (!isRecord(args.row)) {
|
|
314
|
+
throw new Error(`Snapshot row for ${args.scope}/${args.table} must be an object`);
|
|
315
|
+
}
|
|
316
|
+
const raw = args.row[args.rowIdField];
|
|
317
|
+
const rowId = String(raw ?? '');
|
|
318
|
+
if (!rowId) {
|
|
319
|
+
throw new Error(`Snapshot row for ${args.scope}/${args.table} is missing row id field "${args.rowIdField}"`);
|
|
320
|
+
}
|
|
321
|
+
return rowId;
|
|
322
|
+
}
|
|
323
|
+
async function transformRecordFields(args) {
|
|
324
|
+
const config = getRuleConfig(args.index, {
|
|
325
|
+
scope: args.scope,
|
|
326
|
+
table: args.table,
|
|
327
|
+
});
|
|
328
|
+
if (!config || config.fields.size === 0)
|
|
329
|
+
return args.record;
|
|
330
|
+
let changed = false;
|
|
331
|
+
const next = { ...args.record };
|
|
332
|
+
for (const field of config.fields) {
|
|
333
|
+
if (!(field in next))
|
|
334
|
+
continue;
|
|
335
|
+
const value = next[field];
|
|
336
|
+
const transformed = args.mode === 'encrypt'
|
|
337
|
+
? await encryptValue({
|
|
338
|
+
ctx: args.ctx,
|
|
339
|
+
keys: args.keys,
|
|
340
|
+
prefix: args.prefix,
|
|
341
|
+
scope: args.scope,
|
|
342
|
+
table: args.table,
|
|
343
|
+
rowId: args.rowId,
|
|
344
|
+
field,
|
|
345
|
+
value,
|
|
346
|
+
})
|
|
347
|
+
: await decryptValue({
|
|
348
|
+
keys: args.keys,
|
|
349
|
+
prefix: args.prefix,
|
|
350
|
+
decryptionErrorMode: args.decryptionErrorMode,
|
|
351
|
+
scope: args.scope,
|
|
352
|
+
table: args.table,
|
|
353
|
+
rowId: args.rowId,
|
|
354
|
+
field,
|
|
355
|
+
value,
|
|
356
|
+
});
|
|
357
|
+
if (transformed !== value) {
|
|
358
|
+
next[field] = transformed;
|
|
359
|
+
changed = true;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
return changed ? next : args.record;
|
|
363
|
+
}
|
|
364
|
+
function parseRuleKey(key) {
|
|
365
|
+
const splitAt = key.indexOf('\u001f');
|
|
366
|
+
if (splitAt < 0)
|
|
367
|
+
return { scope: key, table: '*' };
|
|
368
|
+
return {
|
|
369
|
+
scope: key.slice(0, splitAt),
|
|
370
|
+
table: key.slice(splitAt + 1),
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
function coerceSqlValue(value) {
|
|
374
|
+
if (typeof value === 'boolean')
|
|
375
|
+
return value ? 1 : 0;
|
|
376
|
+
return value;
|
|
377
|
+
}
|
|
378
|
+
function resolveRefreshTargets(args) {
|
|
379
|
+
const merged = new Map();
|
|
380
|
+
if (!args.targets || args.targets.length === 0) {
|
|
381
|
+
for (const [key, config] of args.index.byScopeTable) {
|
|
382
|
+
const parsed = parseRuleKey(key);
|
|
383
|
+
if (parsed.table === '*')
|
|
384
|
+
continue;
|
|
385
|
+
const mergedKey = `${parsed.scope}\u001f${parsed.table}`;
|
|
386
|
+
const existing = merged.get(mergedKey);
|
|
387
|
+
if (existing) {
|
|
388
|
+
for (const field of config.fields)
|
|
389
|
+
existing.fields.add(field);
|
|
390
|
+
}
|
|
391
|
+
else {
|
|
392
|
+
merged.set(mergedKey, {
|
|
393
|
+
scope: parsed.scope,
|
|
394
|
+
table: parsed.table,
|
|
395
|
+
rowIdField: config.rowIdField,
|
|
396
|
+
fields: new Set(config.fields),
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
else {
|
|
402
|
+
for (const target of args.targets) {
|
|
403
|
+
const config = getRuleConfig(args.index, {
|
|
404
|
+
scope: target.scope,
|
|
405
|
+
table: target.table,
|
|
406
|
+
});
|
|
407
|
+
if (!config) {
|
|
408
|
+
throw new Error(`No field encryption rule configured for ${target.scope}/${target.table}`);
|
|
409
|
+
}
|
|
410
|
+
const selectedFields = target.fields ?? Array.from(config.fields);
|
|
411
|
+
if (selectedFields.length === 0) {
|
|
412
|
+
throw new Error(`Refresh target ${target.scope}/${target.table} has no fields`);
|
|
413
|
+
}
|
|
414
|
+
for (const field of selectedFields) {
|
|
415
|
+
if (!config.fields.has(field)) {
|
|
416
|
+
throw new Error(`Field "${field}" is not configured for encryption in ${target.scope}/${target.table}`);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
const mergedKey = `${target.scope}\u001f${target.table}`;
|
|
420
|
+
const existing = merged.get(mergedKey);
|
|
421
|
+
if (existing) {
|
|
422
|
+
if (existing.rowIdField !== config.rowIdField) {
|
|
423
|
+
throw new Error(`Conflicting rowIdField for ${target.scope}/${target.table}: "${existing.rowIdField}" vs "${config.rowIdField}"`);
|
|
424
|
+
}
|
|
425
|
+
for (const field of selectedFields)
|
|
426
|
+
existing.fields.add(field);
|
|
427
|
+
}
|
|
428
|
+
else {
|
|
429
|
+
merged.set(mergedKey, {
|
|
430
|
+
scope: target.scope,
|
|
431
|
+
table: target.table,
|
|
432
|
+
rowIdField: config.rowIdField,
|
|
433
|
+
fields: new Set(selectedFields),
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
return Array.from(merged.values()).map((target) => ({
|
|
439
|
+
scope: target.scope,
|
|
440
|
+
table: target.table,
|
|
441
|
+
rowIdField: target.rowIdField,
|
|
442
|
+
fields: Array.from(target.fields),
|
|
443
|
+
}));
|
|
444
|
+
}
|
|
445
|
+
async function refreshEncryptedFields(options) {
|
|
446
|
+
const prefix = options.envelopePrefix ?? DEFAULT_PREFIX;
|
|
447
|
+
if (!prefix.endsWith(':')) {
|
|
448
|
+
throw new Error('RefreshEncryptedFieldsOptions.envelopePrefix must end with ":"');
|
|
449
|
+
}
|
|
450
|
+
const decryptionErrorMode = options.decryptionErrorMode ?? 'throw';
|
|
451
|
+
const index = buildRuleIndex(options.rules ?? []);
|
|
452
|
+
const targets = resolveRefreshTargets({
|
|
453
|
+
index,
|
|
454
|
+
targets: options.targets,
|
|
455
|
+
});
|
|
456
|
+
if (targets.length === 0) {
|
|
457
|
+
return {
|
|
458
|
+
tablesProcessed: 0,
|
|
459
|
+
rowsScanned: 0,
|
|
460
|
+
rowsUpdated: 0,
|
|
461
|
+
fieldsUpdated: 0,
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
const ctx = {
|
|
465
|
+
actorId: options.ctx?.actorId ?? 'local-refresh',
|
|
466
|
+
clientId: options.ctx?.clientId ?? 'local-refresh',
|
|
467
|
+
};
|
|
468
|
+
let rowsScanned = 0;
|
|
469
|
+
let rowsUpdated = 0;
|
|
470
|
+
let fieldsUpdated = 0;
|
|
471
|
+
const updatedRows = [];
|
|
472
|
+
await options.db.transaction().execute(async (trx) => {
|
|
473
|
+
for (const target of targets) {
|
|
474
|
+
const columns = [target.rowIdField, ...target.fields];
|
|
475
|
+
const rowsResult = await sql `
|
|
476
|
+
select ${sql.join(columns.map((column) => sql.ref(column)), sql `, `)}
|
|
477
|
+
from ${sql.table(target.table)}
|
|
478
|
+
`.execute(trx);
|
|
479
|
+
rowsScanned += rowsResult.rows.length;
|
|
480
|
+
for (const row of rowsResult.rows) {
|
|
481
|
+
if (!isRecord(row))
|
|
482
|
+
continue;
|
|
483
|
+
const rowIdValue = row[target.rowIdField];
|
|
484
|
+
if (rowIdValue === null || rowIdValue === undefined)
|
|
485
|
+
continue;
|
|
486
|
+
const rowId = String(rowIdValue);
|
|
487
|
+
if (!rowId)
|
|
488
|
+
continue;
|
|
489
|
+
let hasCiphertext = false;
|
|
490
|
+
for (const field of target.fields) {
|
|
491
|
+
const value = row[field];
|
|
492
|
+
if (typeof value === 'string' && value.startsWith(prefix)) {
|
|
493
|
+
hasCiphertext = true;
|
|
494
|
+
break;
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
if (!hasCiphertext)
|
|
498
|
+
continue;
|
|
499
|
+
const nextRow = await transformRecordFields({
|
|
500
|
+
ctx,
|
|
501
|
+
index,
|
|
502
|
+
keys: options.keys,
|
|
503
|
+
prefix,
|
|
504
|
+
decryptionErrorMode,
|
|
505
|
+
mode: 'decrypt',
|
|
506
|
+
scope: target.scope,
|
|
507
|
+
table: target.table,
|
|
508
|
+
rowId,
|
|
509
|
+
record: row,
|
|
510
|
+
});
|
|
511
|
+
if (nextRow === row)
|
|
512
|
+
continue;
|
|
513
|
+
const assignments = [];
|
|
514
|
+
let changedFields = 0;
|
|
515
|
+
for (const field of target.fields) {
|
|
516
|
+
if (!(field in nextRow))
|
|
517
|
+
continue;
|
|
518
|
+
const previousValue = row[field];
|
|
519
|
+
const nextValue = nextRow[field];
|
|
520
|
+
if (nextValue === previousValue)
|
|
521
|
+
continue;
|
|
522
|
+
assignments.push(sql `${sql.ref(field)} = ${sql.val(coerceSqlValue(nextValue))}`);
|
|
523
|
+
changedFields += 1;
|
|
524
|
+
}
|
|
525
|
+
if (assignments.length === 0)
|
|
526
|
+
continue;
|
|
527
|
+
await sql `
|
|
528
|
+
update ${sql.table(target.table)}
|
|
529
|
+
set ${sql.join(assignments, sql `, `)}
|
|
530
|
+
where ${sql.ref(target.rowIdField)} = ${sql.val(coerceSqlValue(rowIdValue))}
|
|
531
|
+
`.execute(trx);
|
|
532
|
+
rowsUpdated += 1;
|
|
533
|
+
fieldsUpdated += changedFields;
|
|
534
|
+
updatedRows.push({ table: target.table, rowId });
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
});
|
|
538
|
+
if (updatedRows.length > 0 && options.engine) {
|
|
539
|
+
const deduped = new Map();
|
|
540
|
+
for (const row of updatedRows) {
|
|
541
|
+
deduped.set(`${row.table}\u001f${row.rowId}`, row);
|
|
542
|
+
}
|
|
543
|
+
options.engine.recordLocalMutations(Array.from(deduped.values()).map((row) => ({
|
|
544
|
+
table: row.table,
|
|
545
|
+
rowId: row.rowId,
|
|
546
|
+
op: 'upsert',
|
|
547
|
+
})));
|
|
548
|
+
}
|
|
549
|
+
return {
|
|
550
|
+
tablesProcessed: targets.length,
|
|
551
|
+
rowsScanned,
|
|
552
|
+
rowsUpdated,
|
|
553
|
+
fieldsUpdated,
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
export function createFieldEncryptionPlugin(pluginOptions) {
|
|
557
|
+
const name = pluginOptions.name ?? 'field-encryption';
|
|
558
|
+
const prefix = pluginOptions.envelopePrefix ?? DEFAULT_PREFIX;
|
|
559
|
+
const decryptionErrorMode = pluginOptions.decryptionErrorMode ?? 'throw';
|
|
560
|
+
if (!prefix.endsWith(':')) {
|
|
561
|
+
throw new Error('FieldEncryptionPluginOptions.envelopePrefix must end with ":"');
|
|
562
|
+
}
|
|
563
|
+
const index = buildRuleIndex(pluginOptions.rules ?? []);
|
|
564
|
+
return {
|
|
565
|
+
name,
|
|
566
|
+
refreshEncryptedFields: (options) => refreshEncryptedFields({
|
|
567
|
+
db: options.db,
|
|
568
|
+
engine: options.engine,
|
|
569
|
+
rules: pluginOptions.rules,
|
|
570
|
+
keys: pluginOptions.keys,
|
|
571
|
+
envelopePrefix: prefix,
|
|
572
|
+
decryptionErrorMode,
|
|
573
|
+
targets: options.targets,
|
|
574
|
+
ctx: options.ctx,
|
|
575
|
+
}),
|
|
576
|
+
async beforePush(ctx, request) {
|
|
577
|
+
if ((pluginOptions.rules?.length ?? 0) === 0)
|
|
578
|
+
return request;
|
|
579
|
+
if ((request.operations?.length ?? 0) === 0)
|
|
580
|
+
return request;
|
|
581
|
+
const nextOps = await Promise.all(request.operations.map(async (op) => {
|
|
582
|
+
if (op.op !== 'upsert')
|
|
583
|
+
return op;
|
|
584
|
+
if (!op.payload)
|
|
585
|
+
return op;
|
|
586
|
+
const payload = op.payload;
|
|
587
|
+
const nextPayload = await transformRecordFields({
|
|
588
|
+
ctx,
|
|
589
|
+
index,
|
|
590
|
+
keys: pluginOptions.keys,
|
|
591
|
+
prefix,
|
|
592
|
+
decryptionErrorMode,
|
|
593
|
+
mode: 'encrypt',
|
|
594
|
+
scope: op.table,
|
|
595
|
+
table: op.table,
|
|
596
|
+
rowId: op.row_id,
|
|
597
|
+
record: payload,
|
|
598
|
+
});
|
|
599
|
+
if (nextPayload === payload)
|
|
600
|
+
return op;
|
|
601
|
+
return { ...op, payload: nextPayload };
|
|
602
|
+
}));
|
|
603
|
+
return { ...request, operations: nextOps };
|
|
604
|
+
},
|
|
605
|
+
async afterPush(ctx, args) {
|
|
606
|
+
const { request, response } = args;
|
|
607
|
+
if ((pluginOptions.rules?.length ?? 0) === 0)
|
|
608
|
+
return response;
|
|
609
|
+
if ((response.results?.length ?? 0) === 0)
|
|
610
|
+
return response;
|
|
611
|
+
const nextResults = await Promise.all(response.results.map(async (r) => {
|
|
612
|
+
if (r.status !== 'conflict' || !('server_row' in r))
|
|
613
|
+
return r;
|
|
614
|
+
if (r.server_row == null)
|
|
615
|
+
return r;
|
|
616
|
+
const op = request.operations[r.opIndex];
|
|
617
|
+
if (!op)
|
|
618
|
+
return r;
|
|
619
|
+
if (!isRecord(r.server_row))
|
|
620
|
+
return r;
|
|
621
|
+
const nextRow = await transformRecordFields({
|
|
622
|
+
ctx,
|
|
623
|
+
index,
|
|
624
|
+
keys: pluginOptions.keys,
|
|
625
|
+
prefix,
|
|
626
|
+
decryptionErrorMode,
|
|
627
|
+
mode: 'decrypt',
|
|
628
|
+
scope: op.table,
|
|
629
|
+
table: op.table,
|
|
630
|
+
rowId: op.row_id,
|
|
631
|
+
record: r.server_row,
|
|
632
|
+
});
|
|
633
|
+
if (nextRow === r.server_row)
|
|
634
|
+
return r;
|
|
635
|
+
return { ...r, server_row: nextRow };
|
|
636
|
+
}));
|
|
637
|
+
return { ...response, results: nextResults };
|
|
638
|
+
},
|
|
639
|
+
async afterPull(ctx, args) {
|
|
640
|
+
const { response } = args;
|
|
641
|
+
if ((pluginOptions.rules?.length ?? 0) === 0)
|
|
642
|
+
return response;
|
|
643
|
+
const nextSubscriptions = await Promise.all(response.subscriptions.map(async (sub) => {
|
|
644
|
+
// Bootstrap snapshots
|
|
645
|
+
if (sub.bootstrap) {
|
|
646
|
+
const nextSnapshots = await Promise.all((sub.snapshots ?? []).map(async (snapshot) => {
|
|
647
|
+
const scope = snapshot.table;
|
|
648
|
+
const rows = snapshot.rows ?? [];
|
|
649
|
+
if (rows.length === 0)
|
|
650
|
+
return snapshot;
|
|
651
|
+
const nextRows = await Promise.all(rows.map(async (row) => {
|
|
652
|
+
const table = inferSnapshotTable({ index, scope, row });
|
|
653
|
+
const config = getRuleConfig(index, { scope, table });
|
|
654
|
+
if (!config || config.fields.size === 0)
|
|
655
|
+
return row;
|
|
656
|
+
const rowId = getSnapshotRowId({
|
|
657
|
+
row,
|
|
658
|
+
rowIdField: config.rowIdField,
|
|
659
|
+
scope,
|
|
660
|
+
table,
|
|
661
|
+
});
|
|
662
|
+
if (!isRecord(row))
|
|
663
|
+
return row;
|
|
664
|
+
const nextRow = await transformRecordFields({
|
|
665
|
+
ctx,
|
|
666
|
+
index,
|
|
667
|
+
keys: pluginOptions.keys,
|
|
668
|
+
prefix,
|
|
669
|
+
decryptionErrorMode,
|
|
670
|
+
mode: 'decrypt',
|
|
671
|
+
scope,
|
|
672
|
+
table,
|
|
673
|
+
rowId,
|
|
674
|
+
record: row,
|
|
675
|
+
});
|
|
676
|
+
return nextRow;
|
|
677
|
+
}));
|
|
678
|
+
return { ...snapshot, rows: nextRows };
|
|
679
|
+
}));
|
|
680
|
+
return { ...sub, snapshots: nextSnapshots };
|
|
681
|
+
}
|
|
682
|
+
// Incremental commits
|
|
683
|
+
const nextCommits = await Promise.all((sub.commits ?? []).map(async (commit) => {
|
|
684
|
+
const nextChanges = await Promise.all((commit.changes ?? []).map(async (change) => {
|
|
685
|
+
if (change.op !== 'upsert')
|
|
686
|
+
return change;
|
|
687
|
+
if (!isRecord(change.row_json))
|
|
688
|
+
return change;
|
|
689
|
+
const nextRow = await transformRecordFields({
|
|
690
|
+
ctx,
|
|
691
|
+
index,
|
|
692
|
+
keys: pluginOptions.keys,
|
|
693
|
+
prefix,
|
|
694
|
+
decryptionErrorMode,
|
|
695
|
+
mode: 'decrypt',
|
|
696
|
+
scope: change.table,
|
|
697
|
+
table: change.table,
|
|
698
|
+
rowId: change.row_id,
|
|
699
|
+
record: change.row_json,
|
|
700
|
+
});
|
|
701
|
+
if (nextRow === change.row_json)
|
|
702
|
+
return change;
|
|
703
|
+
return { ...change, row_json: nextRow };
|
|
704
|
+
}));
|
|
705
|
+
return { ...commit, changes: nextChanges };
|
|
706
|
+
}));
|
|
707
|
+
return { ...sub, commits: nextCommits };
|
|
708
|
+
}));
|
|
709
|
+
return { ...response, subscriptions: nextSubscriptions };
|
|
710
|
+
},
|
|
711
|
+
};
|
|
712
|
+
}
|
|
713
|
+
//# sourceMappingURL=index.js.map
|