@syncular/client-plugin-encryption 0.0.1-100
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/crypto-utils.d.ts +7 -0
- package/dist/crypto-utils.d.ts.map +1 -0
- package/dist/crypto-utils.js +110 -0
- package/dist/crypto-utils.js.map +1 -0
- package/dist/index.d.ts +78 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +639 -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 +332 -0
- package/dist/key-sharing.js.map +1 -0
- package/package.json +65 -0
- package/src/__tests__/field-encryption-keys.test.ts +68 -0
- package/src/__tests__/key-sharing.test.ts +225 -0
- package/src/__tests__/refresh-encrypted-fields.test.ts +182 -0
- package/src/__tests__/scope-resolution.test.ts +202 -0
- package/src/crypto-utils.test.ts +84 -0
- package/src/crypto-utils.ts +125 -0
- package/src/index.ts +939 -0
- package/src/key-sharing.ts +469 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,939 @@
|
|
|
1
|
+
import { xchacha20poly1305 } from '@noble/ciphers/chacha.js';
|
|
2
|
+
import type {
|
|
3
|
+
SyncClientDb,
|
|
4
|
+
SyncClientPlugin,
|
|
5
|
+
SyncClientPluginContext,
|
|
6
|
+
SyncEngine,
|
|
7
|
+
} from '@syncular/client';
|
|
8
|
+
import type {
|
|
9
|
+
SyncPullRequest,
|
|
10
|
+
SyncPullResponse,
|
|
11
|
+
SyncPushRequest,
|
|
12
|
+
SyncPushResponse,
|
|
13
|
+
} from '@syncular/core';
|
|
14
|
+
import { isRecord } from '@syncular/core';
|
|
15
|
+
import { type Kysely, sql } from 'kysely';
|
|
16
|
+
import {
|
|
17
|
+
base64ToBytes,
|
|
18
|
+
base64UrlToBytes,
|
|
19
|
+
bytesToBase64Url,
|
|
20
|
+
hexToBytes,
|
|
21
|
+
randomBytes,
|
|
22
|
+
} from './crypto-utils';
|
|
23
|
+
|
|
24
|
+
// Re-export key sharing utilities
|
|
25
|
+
export * from './key-sharing';
|
|
26
|
+
|
|
27
|
+
type EncryptOrDecrypt = 'encrypt' | 'decrypt';
|
|
28
|
+
|
|
29
|
+
type FieldDecryptionErrorMode = 'throw' | 'keepCiphertext';
|
|
30
|
+
|
|
31
|
+
interface FieldEncryptionRule {
|
|
32
|
+
scope: string;
|
|
33
|
+
/**
|
|
34
|
+
* Optional table selector. Strongly recommended for correctness:
|
|
35
|
+
* - Push/incremental changes have a table name.
|
|
36
|
+
* - Snapshot rows often do not; if omitted, the plugin must be able to infer it.
|
|
37
|
+
*/
|
|
38
|
+
table?: string;
|
|
39
|
+
/** Column names to encrypt/decrypt */
|
|
40
|
+
fields: string[];
|
|
41
|
+
/**
|
|
42
|
+
* Row id column in snapshot row objects (defaults to "id").
|
|
43
|
+
* Push/incremental changes use the protocol `row_id` and ignore this.
|
|
44
|
+
*/
|
|
45
|
+
rowIdField?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface FieldEncryptionKeys {
|
|
49
|
+
/**
|
|
50
|
+
* Resolve a 32-byte symmetric key for a given key id.
|
|
51
|
+
* Throws (or rejects) when the key is unavailable.
|
|
52
|
+
*/
|
|
53
|
+
getKey: (kid: string) => Uint8Array | Promise<Uint8Array>;
|
|
54
|
+
/**
|
|
55
|
+
* Select which key id to use when encrypting new values.
|
|
56
|
+
* Defaults to "default".
|
|
57
|
+
*/
|
|
58
|
+
getEncryptionKid?: (
|
|
59
|
+
ctx: SyncClientPluginContext,
|
|
60
|
+
args: { scope: string; table: string; rowId: string; field: string }
|
|
61
|
+
) => string | Promise<string>;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
interface FieldEncryptionPluginOptions {
|
|
65
|
+
name?: string;
|
|
66
|
+
rules: FieldEncryptionRule[];
|
|
67
|
+
keys: FieldEncryptionKeys;
|
|
68
|
+
/**
|
|
69
|
+
* Controls what happens when ciphertext is present but decryption fails
|
|
70
|
+
* (unknown key, bad AAD, corrupted data).
|
|
71
|
+
*/
|
|
72
|
+
decryptionErrorMode?: FieldDecryptionErrorMode;
|
|
73
|
+
/**
|
|
74
|
+
* Envelope prefix written into the DB. Changing this breaks decryption
|
|
75
|
+
* for existing rows.
|
|
76
|
+
*/
|
|
77
|
+
envelopePrefix?: string;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
interface RefreshEncryptedFieldsTarget {
|
|
81
|
+
scope: string;
|
|
82
|
+
table: string;
|
|
83
|
+
fields?: string[];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface RefreshEncryptedFieldsResult {
|
|
87
|
+
tablesProcessed: number;
|
|
88
|
+
rowsScanned: number;
|
|
89
|
+
rowsUpdated: number;
|
|
90
|
+
fieldsUpdated: number;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
interface RefreshEncryptedFieldsOptions<
|
|
94
|
+
DB extends SyncClientDb = SyncClientDb,
|
|
95
|
+
> {
|
|
96
|
+
db: Kysely<DB>;
|
|
97
|
+
engine?: Pick<SyncEngine<DB>, 'recordLocalMutations'>;
|
|
98
|
+
rules: FieldEncryptionRule[];
|
|
99
|
+
keys: FieldEncryptionKeys;
|
|
100
|
+
envelopePrefix?: string;
|
|
101
|
+
decryptionErrorMode?: FieldDecryptionErrorMode;
|
|
102
|
+
targets?: RefreshEncryptedFieldsTarget[];
|
|
103
|
+
ctx?: Partial<SyncClientPluginContext>;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export interface FieldEncryptionPluginRefreshRequest<
|
|
107
|
+
DB extends SyncClientDb = SyncClientDb,
|
|
108
|
+
> {
|
|
109
|
+
db: Kysely<DB>;
|
|
110
|
+
engine?: Pick<SyncEngine<DB>, 'recordLocalMutations'>;
|
|
111
|
+
targets?: RefreshEncryptedFieldsTarget[];
|
|
112
|
+
ctx?: Partial<SyncClientPluginContext>;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export interface FieldEncryptionPlugin extends SyncClientPlugin {
|
|
116
|
+
refreshEncryptedFields: <DB extends SyncClientDb = SyncClientDb>(
|
|
117
|
+
options: FieldEncryptionPluginRefreshRequest<DB>
|
|
118
|
+
) => Promise<RefreshEncryptedFieldsResult>;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
type RuleConfig = {
|
|
122
|
+
fields: ReadonlySet<string>;
|
|
123
|
+
rowIdField: string;
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const DEFAULT_PREFIX = 'dgsync:e2ee:1:';
|
|
127
|
+
const encoder = new TextEncoder();
|
|
128
|
+
const decoder = new TextDecoder();
|
|
129
|
+
|
|
130
|
+
function decodeKeyMaterial(key: Uint8Array | string): Uint8Array {
|
|
131
|
+
if (key instanceof Uint8Array) return key;
|
|
132
|
+
const trimmed = key.trim();
|
|
133
|
+
if (trimmed.startsWith('hex:'))
|
|
134
|
+
return hexToBytes(trimmed.slice('hex:'.length));
|
|
135
|
+
if (trimmed.startsWith('base64:'))
|
|
136
|
+
return base64ToBytes(trimmed.slice('base64:'.length));
|
|
137
|
+
if (trimmed.startsWith('base64url:'))
|
|
138
|
+
return base64UrlToBytes(trimmed.slice('base64url:'.length));
|
|
139
|
+
|
|
140
|
+
// Heuristic: 64 hex chars → 32-byte key.
|
|
141
|
+
if (/^[0-9a-fA-F]{64}$/.test(trimmed)) return hexToBytes(trimmed);
|
|
142
|
+
return base64UrlToBytes(trimmed);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function createStaticFieldEncryptionKeys(args: {
|
|
146
|
+
keys: Record<string, Uint8Array | string>;
|
|
147
|
+
encryptionKid?: string;
|
|
148
|
+
}): FieldEncryptionKeys {
|
|
149
|
+
const cache = new Map<string, Uint8Array>();
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
async getKey(kid: string): Promise<Uint8Array> {
|
|
153
|
+
const cached = cache.get(kid);
|
|
154
|
+
if (cached) return cached;
|
|
155
|
+
const raw = args.keys[kid];
|
|
156
|
+
if (!raw) throw new Error(`Missing encryption key for kid "${kid}"`);
|
|
157
|
+
const decoded = decodeKeyMaterial(raw);
|
|
158
|
+
if (decoded.length !== 32) {
|
|
159
|
+
throw new Error(
|
|
160
|
+
`Encryption key for kid "${kid}" must be 32 bytes (got ${decoded.length})`
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
cache.set(kid, decoded);
|
|
164
|
+
return decoded;
|
|
165
|
+
},
|
|
166
|
+
getEncryptionKid() {
|
|
167
|
+
return args.encryptionKid ?? 'default';
|
|
168
|
+
},
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function makeAadBytes(args: {
|
|
173
|
+
scope: string;
|
|
174
|
+
table: string;
|
|
175
|
+
rowId: string;
|
|
176
|
+
field: string;
|
|
177
|
+
}): Uint8Array {
|
|
178
|
+
// Keep this stable; changing it breaks decryption.
|
|
179
|
+
const s = `${args.scope}\u001f${args.table}\u001f${args.rowId}\u001f${args.field}`;
|
|
180
|
+
return encoder.encode(s);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function encodeEnvelope(
|
|
184
|
+
prefix: string,
|
|
185
|
+
args: { kid: string; nonce: Uint8Array; ciphertext: Uint8Array }
|
|
186
|
+
): string {
|
|
187
|
+
return `${prefix}${args.kid}:${bytesToBase64Url(args.nonce)}:${bytesToBase64Url(args.ciphertext)}`;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function decodeEnvelope(
|
|
191
|
+
prefix: string,
|
|
192
|
+
value: string
|
|
193
|
+
): { kid: string; nonce: Uint8Array; ciphertext: Uint8Array } | null {
|
|
194
|
+
if (!value.startsWith(prefix)) return null;
|
|
195
|
+
const rest = value.slice(prefix.length);
|
|
196
|
+
const parts = rest.split(':');
|
|
197
|
+
if (parts.length !== 3) return null;
|
|
198
|
+
const [kid, nonceB64, ctB64] = parts;
|
|
199
|
+
if (!kid || !nonceB64 || !ctB64) return null;
|
|
200
|
+
try {
|
|
201
|
+
return {
|
|
202
|
+
kid,
|
|
203
|
+
nonce: base64UrlToBytes(nonceB64),
|
|
204
|
+
ciphertext: base64UrlToBytes(ctB64),
|
|
205
|
+
};
|
|
206
|
+
} catch {
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async function getKeyOrThrow(
|
|
212
|
+
keys: FieldEncryptionKeys,
|
|
213
|
+
kid: string
|
|
214
|
+
): Promise<Uint8Array> {
|
|
215
|
+
const key = await keys.getKey(kid);
|
|
216
|
+
if (!(key instanceof Uint8Array)) {
|
|
217
|
+
throw new Error(`Encryption key for kid "${kid}" must be a Uint8Array`);
|
|
218
|
+
}
|
|
219
|
+
if (key.length !== 32) {
|
|
220
|
+
throw new Error(
|
|
221
|
+
`Encryption key for kid "${kid}" must be 32 bytes (got ${key.length})`
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
return key;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async function encryptValue(args: {
|
|
228
|
+
ctx: SyncClientPluginContext;
|
|
229
|
+
keys: FieldEncryptionKeys;
|
|
230
|
+
prefix: string;
|
|
231
|
+
scope: string;
|
|
232
|
+
table: string;
|
|
233
|
+
rowId: string;
|
|
234
|
+
field: string;
|
|
235
|
+
value: unknown;
|
|
236
|
+
}): Promise<unknown> {
|
|
237
|
+
if (args.value === null || args.value === undefined) return args.value;
|
|
238
|
+
if (typeof args.value === 'string') {
|
|
239
|
+
const parsed = decodeEnvelope(args.prefix, args.value);
|
|
240
|
+
if (parsed) return args.value;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const kid =
|
|
244
|
+
(await args.keys.getEncryptionKid?.(args.ctx, {
|
|
245
|
+
scope: args.scope,
|
|
246
|
+
table: args.table,
|
|
247
|
+
rowId: args.rowId,
|
|
248
|
+
field: args.field,
|
|
249
|
+
})) ?? 'default';
|
|
250
|
+
|
|
251
|
+
if (typeof kid !== 'string' || kid.length === 0) {
|
|
252
|
+
throw new Error('Encryption key id must be a non-empty string');
|
|
253
|
+
}
|
|
254
|
+
if (kid.includes(':')) {
|
|
255
|
+
throw new Error('Encryption key id must not contain ":"');
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const key = await getKeyOrThrow(args.keys, kid);
|
|
259
|
+
const nonce = randomBytes(24); // XChaCha20-Poly1305 nonce size
|
|
260
|
+
const aad = makeAadBytes({
|
|
261
|
+
scope: args.scope,
|
|
262
|
+
table: args.table,
|
|
263
|
+
rowId: args.rowId,
|
|
264
|
+
field: args.field,
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
const plaintext = encoder.encode(JSON.stringify(args.value));
|
|
268
|
+
const aead = xchacha20poly1305(key, nonce, aad);
|
|
269
|
+
const ciphertext = aead.encrypt(plaintext);
|
|
270
|
+
|
|
271
|
+
return encodeEnvelope(args.prefix, { kid, nonce, ciphertext });
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
async function decryptValue(args: {
|
|
275
|
+
keys: FieldEncryptionKeys;
|
|
276
|
+
prefix: string;
|
|
277
|
+
decryptionErrorMode: FieldDecryptionErrorMode;
|
|
278
|
+
scope: string;
|
|
279
|
+
table: string;
|
|
280
|
+
rowId: string;
|
|
281
|
+
field: string;
|
|
282
|
+
value: unknown;
|
|
283
|
+
}): Promise<unknown> {
|
|
284
|
+
if (typeof args.value !== 'string') return args.value;
|
|
285
|
+
|
|
286
|
+
const parsed = decodeEnvelope(args.prefix, args.value);
|
|
287
|
+
if (!parsed) return args.value;
|
|
288
|
+
|
|
289
|
+
try {
|
|
290
|
+
const key = await getKeyOrThrow(args.keys, parsed.kid);
|
|
291
|
+
const aad = makeAadBytes({
|
|
292
|
+
scope: args.scope,
|
|
293
|
+
table: args.table,
|
|
294
|
+
rowId: args.rowId,
|
|
295
|
+
field: args.field,
|
|
296
|
+
});
|
|
297
|
+
const aead = xchacha20poly1305(key, parsed.nonce, aad);
|
|
298
|
+
const plaintext = aead.decrypt(parsed.ciphertext);
|
|
299
|
+
const json = decoder.decode(plaintext);
|
|
300
|
+
return JSON.parse(json);
|
|
301
|
+
} catch (err) {
|
|
302
|
+
if (args.decryptionErrorMode === 'keepCiphertext') return args.value;
|
|
303
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
304
|
+
throw new Error(
|
|
305
|
+
`Failed to decrypt ${args.scope}.${args.table}.${args.field} row=${args.rowId}: ${message}`
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function buildRuleIndex(rules: FieldEncryptionRule[]): {
|
|
311
|
+
byScopeTable: Map<string, RuleConfig>;
|
|
312
|
+
tablesByScope: Map<string, Set<string>>;
|
|
313
|
+
scopesByTable: Map<string, Set<string>>;
|
|
314
|
+
} {
|
|
315
|
+
const byScopeTable = new Map<string, RuleConfig>();
|
|
316
|
+
const tablesByScope = new Map<string, Set<string>>();
|
|
317
|
+
const scopesByTable = new Map<string, Set<string>>();
|
|
318
|
+
|
|
319
|
+
for (const rule of rules) {
|
|
320
|
+
const scope = rule.scope;
|
|
321
|
+
const table = rule.table ?? '*';
|
|
322
|
+
const key = `${scope}\u001f${table}`;
|
|
323
|
+
const rowIdField = rule.rowIdField ?? 'id';
|
|
324
|
+
|
|
325
|
+
const existing = byScopeTable.get(key);
|
|
326
|
+
if (existing) {
|
|
327
|
+
if (existing.rowIdField !== rowIdField) {
|
|
328
|
+
throw new Error(
|
|
329
|
+
`Conflicting rowIdField for rule ${scope}/${table}: "${existing.rowIdField}" vs "${rowIdField}"`
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
const merged = new Set(existing.fields);
|
|
333
|
+
for (const f of rule.fields) merged.add(f);
|
|
334
|
+
byScopeTable.set(key, { fields: merged, rowIdField });
|
|
335
|
+
} else {
|
|
336
|
+
byScopeTable.set(key, { fields: new Set(rule.fields), rowIdField });
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
if (table !== '*') {
|
|
340
|
+
const tables = tablesByScope.get(scope) ?? new Set<string>();
|
|
341
|
+
tables.add(table);
|
|
342
|
+
tablesByScope.set(scope, tables);
|
|
343
|
+
|
|
344
|
+
const scopes = scopesByTable.get(table) ?? new Set<string>();
|
|
345
|
+
scopes.add(scope);
|
|
346
|
+
scopesByTable.set(table, scopes);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// Freeze sets to make accidental mutation harder.
|
|
351
|
+
for (const [k, v] of byScopeTable) {
|
|
352
|
+
byScopeTable.set(k, {
|
|
353
|
+
fields: new Set(v.fields),
|
|
354
|
+
rowIdField: v.rowIdField,
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
return { byScopeTable, tablesByScope, scopesByTable };
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function getRuleConfig(
|
|
362
|
+
index: ReturnType<typeof buildRuleIndex>,
|
|
363
|
+
args: { scope: string; table: string }
|
|
364
|
+
): RuleConfig | null {
|
|
365
|
+
const exact = index.byScopeTable.get(`${args.scope}\u001f${args.table}`);
|
|
366
|
+
if (exact) return exact;
|
|
367
|
+
const wildcard = index.byScopeTable.get(`${args.scope}\u001f*`);
|
|
368
|
+
return wildcard ?? null;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function resolveScopeAndTable(args: {
|
|
372
|
+
index: ReturnType<typeof buildRuleIndex>;
|
|
373
|
+
identifier: string;
|
|
374
|
+
}): { scope: string; table: string } {
|
|
375
|
+
const direct = getRuleConfig(args.index, {
|
|
376
|
+
scope: args.identifier,
|
|
377
|
+
table: args.identifier,
|
|
378
|
+
});
|
|
379
|
+
if (direct) {
|
|
380
|
+
return { scope: args.identifier, table: args.identifier };
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const tablesForScope = args.index.tablesByScope.get(args.identifier);
|
|
384
|
+
if (tablesForScope && tablesForScope.size === 1) {
|
|
385
|
+
const table = Array.from(tablesForScope)[0]!;
|
|
386
|
+
return { scope: args.identifier, table };
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const scopesForTable = args.index.scopesByTable.get(args.identifier);
|
|
390
|
+
if (scopesForTable && scopesForTable.size === 1) {
|
|
391
|
+
const scope = Array.from(scopesForTable)[0]!;
|
|
392
|
+
return { scope, table: args.identifier };
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
return { scope: args.identifier, table: args.identifier };
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function inferSnapshotTable(args: {
|
|
399
|
+
index: ReturnType<typeof buildRuleIndex>;
|
|
400
|
+
scope: string;
|
|
401
|
+
row: unknown;
|
|
402
|
+
}): string {
|
|
403
|
+
if (isRecord(args.row)) {
|
|
404
|
+
const tn = args.row.table_name;
|
|
405
|
+
if (typeof tn === 'string' && tn.length > 0) return tn;
|
|
406
|
+
const tt = args.row.__table;
|
|
407
|
+
if (typeof tt === 'string' && tt.length > 0) return tt;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
const tables = args.index.tablesByScope.get(args.scope);
|
|
411
|
+
if (tables && tables.size === 1) return Array.from(tables)[0]!;
|
|
412
|
+
|
|
413
|
+
throw new Error(
|
|
414
|
+
`Cannot infer table for snapshot row (scope="${args.scope}"). Provide FieldEncryptionRule.table or include "table_name"/"__table" in snapshot rows.`
|
|
415
|
+
);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function getSnapshotRowId(args: {
|
|
419
|
+
row: unknown;
|
|
420
|
+
rowIdField: string;
|
|
421
|
+
scope: string;
|
|
422
|
+
table: string;
|
|
423
|
+
}): string {
|
|
424
|
+
if (!isRecord(args.row)) {
|
|
425
|
+
throw new Error(
|
|
426
|
+
`Snapshot row for ${args.scope}/${args.table} must be an object`
|
|
427
|
+
);
|
|
428
|
+
}
|
|
429
|
+
const raw = args.row[args.rowIdField];
|
|
430
|
+
const rowId = String(raw ?? '');
|
|
431
|
+
if (!rowId) {
|
|
432
|
+
throw new Error(
|
|
433
|
+
`Snapshot row for ${args.scope}/${args.table} is missing row id field "${args.rowIdField}"`
|
|
434
|
+
);
|
|
435
|
+
}
|
|
436
|
+
return rowId;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
async function transformRecordFields(args: {
|
|
440
|
+
ctx: SyncClientPluginContext;
|
|
441
|
+
index: ReturnType<typeof buildRuleIndex>;
|
|
442
|
+
keys: FieldEncryptionKeys;
|
|
443
|
+
prefix: string;
|
|
444
|
+
decryptionErrorMode: FieldDecryptionErrorMode;
|
|
445
|
+
mode: EncryptOrDecrypt;
|
|
446
|
+
scope: string;
|
|
447
|
+
table: string;
|
|
448
|
+
rowId: string;
|
|
449
|
+
record: Record<string, unknown>;
|
|
450
|
+
}): Promise<Record<string, unknown>> {
|
|
451
|
+
const config = getRuleConfig(args.index, {
|
|
452
|
+
scope: args.scope,
|
|
453
|
+
table: args.table,
|
|
454
|
+
});
|
|
455
|
+
if (!config || config.fields.size === 0) return args.record;
|
|
456
|
+
|
|
457
|
+
let changed = false;
|
|
458
|
+
const next: Record<string, unknown> = { ...args.record };
|
|
459
|
+
|
|
460
|
+
for (const field of config.fields) {
|
|
461
|
+
if (!(field in next)) continue;
|
|
462
|
+
const value = next[field];
|
|
463
|
+
const transformed =
|
|
464
|
+
args.mode === 'encrypt'
|
|
465
|
+
? await encryptValue({
|
|
466
|
+
ctx: args.ctx,
|
|
467
|
+
keys: args.keys,
|
|
468
|
+
prefix: args.prefix,
|
|
469
|
+
scope: args.scope,
|
|
470
|
+
table: args.table,
|
|
471
|
+
rowId: args.rowId,
|
|
472
|
+
field,
|
|
473
|
+
value,
|
|
474
|
+
})
|
|
475
|
+
: await decryptValue({
|
|
476
|
+
keys: args.keys,
|
|
477
|
+
prefix: args.prefix,
|
|
478
|
+
decryptionErrorMode: args.decryptionErrorMode,
|
|
479
|
+
scope: args.scope,
|
|
480
|
+
table: args.table,
|
|
481
|
+
rowId: args.rowId,
|
|
482
|
+
field,
|
|
483
|
+
value,
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
if (transformed !== value) {
|
|
487
|
+
next[field] = transformed;
|
|
488
|
+
changed = true;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
return changed ? next : args.record;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
type ResolvedRefreshTarget = {
|
|
496
|
+
scope: string;
|
|
497
|
+
table: string;
|
|
498
|
+
rowIdField: string;
|
|
499
|
+
fields: string[];
|
|
500
|
+
};
|
|
501
|
+
|
|
502
|
+
function parseRuleKey(key: string): { scope: string; table: string } {
|
|
503
|
+
const splitAt = key.indexOf('\u001f');
|
|
504
|
+
if (splitAt < 0) return { scope: key, table: '*' };
|
|
505
|
+
return {
|
|
506
|
+
scope: key.slice(0, splitAt),
|
|
507
|
+
table: key.slice(splitAt + 1),
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function coerceSqlValue(value: unknown): unknown {
|
|
512
|
+
if (typeof value === 'boolean') return value ? 1 : 0;
|
|
513
|
+
return value;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
function resolveRefreshTargets(args: {
|
|
517
|
+
index: ReturnType<typeof buildRuleIndex>;
|
|
518
|
+
targets?: RefreshEncryptedFieldsTarget[];
|
|
519
|
+
}): ResolvedRefreshTarget[] {
|
|
520
|
+
const merged = new Map<
|
|
521
|
+
string,
|
|
522
|
+
{ scope: string; table: string; rowIdField: string; fields: Set<string> }
|
|
523
|
+
>();
|
|
524
|
+
|
|
525
|
+
if (!args.targets || args.targets.length === 0) {
|
|
526
|
+
for (const [key, config] of args.index.byScopeTable) {
|
|
527
|
+
const parsed = parseRuleKey(key);
|
|
528
|
+
if (parsed.table === '*') continue;
|
|
529
|
+
|
|
530
|
+
const mergedKey = `${parsed.scope}\u001f${parsed.table}`;
|
|
531
|
+
const existing = merged.get(mergedKey);
|
|
532
|
+
if (existing) {
|
|
533
|
+
for (const field of config.fields) existing.fields.add(field);
|
|
534
|
+
} else {
|
|
535
|
+
merged.set(mergedKey, {
|
|
536
|
+
scope: parsed.scope,
|
|
537
|
+
table: parsed.table,
|
|
538
|
+
rowIdField: config.rowIdField,
|
|
539
|
+
fields: new Set(config.fields),
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
} else {
|
|
544
|
+
for (const target of args.targets) {
|
|
545
|
+
const config = getRuleConfig(args.index, {
|
|
546
|
+
scope: target.scope,
|
|
547
|
+
table: target.table,
|
|
548
|
+
});
|
|
549
|
+
if (!config) {
|
|
550
|
+
throw new Error(
|
|
551
|
+
`No field encryption rule configured for ${target.scope}/${target.table}`
|
|
552
|
+
);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
const selectedFields = target.fields ?? Array.from(config.fields);
|
|
556
|
+
if (selectedFields.length === 0) {
|
|
557
|
+
throw new Error(
|
|
558
|
+
`Refresh target ${target.scope}/${target.table} has no fields`
|
|
559
|
+
);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
for (const field of selectedFields) {
|
|
563
|
+
if (!config.fields.has(field)) {
|
|
564
|
+
throw new Error(
|
|
565
|
+
`Field "${field}" is not configured for encryption in ${target.scope}/${target.table}`
|
|
566
|
+
);
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
const mergedKey = `${target.scope}\u001f${target.table}`;
|
|
571
|
+
const existing = merged.get(mergedKey);
|
|
572
|
+
if (existing) {
|
|
573
|
+
if (existing.rowIdField !== config.rowIdField) {
|
|
574
|
+
throw new Error(
|
|
575
|
+
`Conflicting rowIdField for ${target.scope}/${target.table}: "${existing.rowIdField}" vs "${config.rowIdField}"`
|
|
576
|
+
);
|
|
577
|
+
}
|
|
578
|
+
for (const field of selectedFields) existing.fields.add(field);
|
|
579
|
+
} else {
|
|
580
|
+
merged.set(mergedKey, {
|
|
581
|
+
scope: target.scope,
|
|
582
|
+
table: target.table,
|
|
583
|
+
rowIdField: config.rowIdField,
|
|
584
|
+
fields: new Set(selectedFields),
|
|
585
|
+
});
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
return Array.from(merged.values()).map((target) => ({
|
|
591
|
+
scope: target.scope,
|
|
592
|
+
table: target.table,
|
|
593
|
+
rowIdField: target.rowIdField,
|
|
594
|
+
fields: Array.from(target.fields),
|
|
595
|
+
}));
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
async function refreshEncryptedFields<DB extends SyncClientDb = SyncClientDb>(
|
|
599
|
+
options: RefreshEncryptedFieldsOptions<DB>
|
|
600
|
+
): Promise<RefreshEncryptedFieldsResult> {
|
|
601
|
+
const prefix = options.envelopePrefix ?? DEFAULT_PREFIX;
|
|
602
|
+
if (!prefix.endsWith(':')) {
|
|
603
|
+
throw new Error(
|
|
604
|
+
'RefreshEncryptedFieldsOptions.envelopePrefix must end with ":"'
|
|
605
|
+
);
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
const decryptionErrorMode = options.decryptionErrorMode ?? 'throw';
|
|
609
|
+
const index = buildRuleIndex(options.rules ?? []);
|
|
610
|
+
const targets = resolveRefreshTargets({
|
|
611
|
+
index,
|
|
612
|
+
targets: options.targets,
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
if (targets.length === 0) {
|
|
616
|
+
return {
|
|
617
|
+
tablesProcessed: 0,
|
|
618
|
+
rowsScanned: 0,
|
|
619
|
+
rowsUpdated: 0,
|
|
620
|
+
fieldsUpdated: 0,
|
|
621
|
+
};
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
const ctx: SyncClientPluginContext = {
|
|
625
|
+
actorId: options.ctx?.actorId ?? 'local-refresh',
|
|
626
|
+
clientId: options.ctx?.clientId ?? 'local-refresh',
|
|
627
|
+
};
|
|
628
|
+
|
|
629
|
+
let rowsScanned = 0;
|
|
630
|
+
let rowsUpdated = 0;
|
|
631
|
+
let fieldsUpdated = 0;
|
|
632
|
+
const updatedRows: Array<{ table: string; rowId: string }> = [];
|
|
633
|
+
|
|
634
|
+
await options.db.transaction().execute(async (trx) => {
|
|
635
|
+
for (const target of targets) {
|
|
636
|
+
const columns = [target.rowIdField, ...target.fields];
|
|
637
|
+
const rowsResult = await sql<Record<string, unknown>>`
|
|
638
|
+
select ${sql.join(
|
|
639
|
+
columns.map((column) => sql.ref(column)),
|
|
640
|
+
sql`, `
|
|
641
|
+
)}
|
|
642
|
+
from ${sql.table(target.table)}
|
|
643
|
+
`.execute(trx);
|
|
644
|
+
|
|
645
|
+
rowsScanned += rowsResult.rows.length;
|
|
646
|
+
|
|
647
|
+
for (const row of rowsResult.rows) {
|
|
648
|
+
if (!isRecord(row)) continue;
|
|
649
|
+
|
|
650
|
+
const rowIdValue = row[target.rowIdField];
|
|
651
|
+
if (rowIdValue === null || rowIdValue === undefined) continue;
|
|
652
|
+
const rowId = String(rowIdValue);
|
|
653
|
+
if (!rowId) continue;
|
|
654
|
+
|
|
655
|
+
let hasCiphertext = false;
|
|
656
|
+
for (const field of target.fields) {
|
|
657
|
+
const value = row[field];
|
|
658
|
+
if (typeof value === 'string' && value.startsWith(prefix)) {
|
|
659
|
+
hasCiphertext = true;
|
|
660
|
+
break;
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
if (!hasCiphertext) continue;
|
|
664
|
+
|
|
665
|
+
const nextRow = await transformRecordFields({
|
|
666
|
+
ctx,
|
|
667
|
+
index,
|
|
668
|
+
keys: options.keys,
|
|
669
|
+
prefix,
|
|
670
|
+
decryptionErrorMode,
|
|
671
|
+
mode: 'decrypt',
|
|
672
|
+
scope: target.scope,
|
|
673
|
+
table: target.table,
|
|
674
|
+
rowId,
|
|
675
|
+
record: row,
|
|
676
|
+
});
|
|
677
|
+
|
|
678
|
+
if (nextRow === row) continue;
|
|
679
|
+
|
|
680
|
+
const assignments = [];
|
|
681
|
+
let changedFields = 0;
|
|
682
|
+
|
|
683
|
+
for (const field of target.fields) {
|
|
684
|
+
if (!(field in nextRow)) continue;
|
|
685
|
+
const previousValue = row[field];
|
|
686
|
+
const nextValue = nextRow[field];
|
|
687
|
+
if (nextValue === previousValue) continue;
|
|
688
|
+
|
|
689
|
+
assignments.push(
|
|
690
|
+
sql`${sql.ref(field)} = ${sql.val(coerceSqlValue(nextValue))}`
|
|
691
|
+
);
|
|
692
|
+
changedFields += 1;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
if (assignments.length === 0) continue;
|
|
696
|
+
|
|
697
|
+
await sql`
|
|
698
|
+
update ${sql.table(target.table)}
|
|
699
|
+
set ${sql.join(assignments, sql`, `)}
|
|
700
|
+
where ${sql.ref(target.rowIdField)} = ${sql.val(
|
|
701
|
+
coerceSqlValue(rowIdValue)
|
|
702
|
+
)}
|
|
703
|
+
`.execute(trx);
|
|
704
|
+
|
|
705
|
+
rowsUpdated += 1;
|
|
706
|
+
fieldsUpdated += changedFields;
|
|
707
|
+
updatedRows.push({ table: target.table, rowId });
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
});
|
|
711
|
+
|
|
712
|
+
if (updatedRows.length > 0 && options.engine) {
|
|
713
|
+
const deduped = new Map<string, { table: string; rowId: string }>();
|
|
714
|
+
for (const row of updatedRows) {
|
|
715
|
+
deduped.set(`${row.table}\u001f${row.rowId}`, row);
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
options.engine.recordLocalMutations(
|
|
719
|
+
Array.from(deduped.values()).map((row) => ({
|
|
720
|
+
table: row.table,
|
|
721
|
+
rowId: row.rowId,
|
|
722
|
+
op: 'upsert',
|
|
723
|
+
}))
|
|
724
|
+
);
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
return {
|
|
728
|
+
tablesProcessed: targets.length,
|
|
729
|
+
rowsScanned,
|
|
730
|
+
rowsUpdated,
|
|
731
|
+
fieldsUpdated,
|
|
732
|
+
};
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
export function createFieldEncryptionPlugin(
|
|
736
|
+
pluginOptions: FieldEncryptionPluginOptions
|
|
737
|
+
): FieldEncryptionPlugin {
|
|
738
|
+
const name = pluginOptions.name ?? 'field-encryption';
|
|
739
|
+
const prefix = pluginOptions.envelopePrefix ?? DEFAULT_PREFIX;
|
|
740
|
+
const decryptionErrorMode = pluginOptions.decryptionErrorMode ?? 'throw';
|
|
741
|
+
|
|
742
|
+
if (!prefix.endsWith(':')) {
|
|
743
|
+
throw new Error(
|
|
744
|
+
'FieldEncryptionPluginOptions.envelopePrefix must end with ":"'
|
|
745
|
+
);
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
const index = buildRuleIndex(pluginOptions.rules ?? []);
|
|
749
|
+
|
|
750
|
+
return {
|
|
751
|
+
name,
|
|
752
|
+
|
|
753
|
+
refreshEncryptedFields: <DB extends SyncClientDb = SyncClientDb>(
|
|
754
|
+
options: FieldEncryptionPluginRefreshRequest<DB>
|
|
755
|
+
) =>
|
|
756
|
+
refreshEncryptedFields({
|
|
757
|
+
db: options.db,
|
|
758
|
+
engine: options.engine,
|
|
759
|
+
rules: pluginOptions.rules,
|
|
760
|
+
keys: pluginOptions.keys,
|
|
761
|
+
envelopePrefix: prefix,
|
|
762
|
+
decryptionErrorMode,
|
|
763
|
+
targets: options.targets,
|
|
764
|
+
ctx: options.ctx,
|
|
765
|
+
}),
|
|
766
|
+
|
|
767
|
+
async beforePush(ctx, request): Promise<SyncPushRequest> {
|
|
768
|
+
if ((pluginOptions.rules?.length ?? 0) === 0) return request;
|
|
769
|
+
if ((request.operations?.length ?? 0) === 0) return request;
|
|
770
|
+
|
|
771
|
+
const nextOps = await Promise.all(
|
|
772
|
+
request.operations.map(async (op) => {
|
|
773
|
+
if (op.op !== 'upsert') return op;
|
|
774
|
+
if (!op.payload) return op;
|
|
775
|
+
|
|
776
|
+
const payload = op.payload as Record<string, unknown>;
|
|
777
|
+
const target = resolveScopeAndTable({
|
|
778
|
+
index,
|
|
779
|
+
identifier: op.table,
|
|
780
|
+
});
|
|
781
|
+
const nextPayload = await transformRecordFields({
|
|
782
|
+
ctx,
|
|
783
|
+
index,
|
|
784
|
+
keys: pluginOptions.keys,
|
|
785
|
+
prefix,
|
|
786
|
+
decryptionErrorMode,
|
|
787
|
+
mode: 'encrypt',
|
|
788
|
+
scope: target.scope,
|
|
789
|
+
table: target.table,
|
|
790
|
+
rowId: op.row_id,
|
|
791
|
+
record: payload,
|
|
792
|
+
});
|
|
793
|
+
|
|
794
|
+
if (nextPayload === payload) return op;
|
|
795
|
+
return { ...op, payload: nextPayload };
|
|
796
|
+
})
|
|
797
|
+
);
|
|
798
|
+
|
|
799
|
+
return { ...request, operations: nextOps };
|
|
800
|
+
},
|
|
801
|
+
|
|
802
|
+
async afterPush(
|
|
803
|
+
ctx,
|
|
804
|
+
args: { request: SyncPushRequest; response: SyncPushResponse }
|
|
805
|
+
): Promise<SyncPushResponse> {
|
|
806
|
+
const { request, response } = args;
|
|
807
|
+
if ((pluginOptions.rules?.length ?? 0) === 0) return response;
|
|
808
|
+
if ((response.results?.length ?? 0) === 0) return response;
|
|
809
|
+
|
|
810
|
+
const nextResults = await Promise.all(
|
|
811
|
+
response.results.map(async (r) => {
|
|
812
|
+
if (r.status !== 'conflict' || !('server_row' in r)) return r;
|
|
813
|
+
if (r.server_row == null) return r;
|
|
814
|
+
|
|
815
|
+
const op = request.operations[r.opIndex];
|
|
816
|
+
if (!op) return r;
|
|
817
|
+
|
|
818
|
+
if (!isRecord(r.server_row)) return r;
|
|
819
|
+
const target = resolveScopeAndTable({
|
|
820
|
+
index,
|
|
821
|
+
identifier: op.table,
|
|
822
|
+
});
|
|
823
|
+
|
|
824
|
+
const nextRow = await transformRecordFields({
|
|
825
|
+
ctx,
|
|
826
|
+
index,
|
|
827
|
+
keys: pluginOptions.keys,
|
|
828
|
+
prefix,
|
|
829
|
+
decryptionErrorMode,
|
|
830
|
+
mode: 'decrypt',
|
|
831
|
+
scope: target.scope,
|
|
832
|
+
table: target.table,
|
|
833
|
+
rowId: op.row_id,
|
|
834
|
+
record: r.server_row,
|
|
835
|
+
});
|
|
836
|
+
|
|
837
|
+
if (nextRow === r.server_row) return r;
|
|
838
|
+
return { ...r, server_row: nextRow };
|
|
839
|
+
})
|
|
840
|
+
);
|
|
841
|
+
|
|
842
|
+
return { ...response, results: nextResults };
|
|
843
|
+
},
|
|
844
|
+
|
|
845
|
+
async afterPull(
|
|
846
|
+
ctx,
|
|
847
|
+
args: { request: SyncPullRequest; response: SyncPullResponse }
|
|
848
|
+
): Promise<SyncPullResponse> {
|
|
849
|
+
const { response } = args;
|
|
850
|
+
if ((pluginOptions.rules?.length ?? 0) === 0) return response;
|
|
851
|
+
|
|
852
|
+
const nextSubscriptions = await Promise.all(
|
|
853
|
+
response.subscriptions.map(async (sub) => {
|
|
854
|
+
// Bootstrap snapshots
|
|
855
|
+
if (sub.bootstrap) {
|
|
856
|
+
const nextSnapshots = await Promise.all(
|
|
857
|
+
(sub.snapshots ?? []).map(async (snapshot) => {
|
|
858
|
+
const scope = snapshot.table;
|
|
859
|
+
const rows = snapshot.rows ?? [];
|
|
860
|
+
if (rows.length === 0) return snapshot;
|
|
861
|
+
|
|
862
|
+
const nextRows = await Promise.all(
|
|
863
|
+
rows.map(async (row) => {
|
|
864
|
+
const table = inferSnapshotTable({ index, scope, row });
|
|
865
|
+
const config = getRuleConfig(index, { scope, table });
|
|
866
|
+
if (!config || config.fields.size === 0) return row;
|
|
867
|
+
|
|
868
|
+
const rowId = getSnapshotRowId({
|
|
869
|
+
row,
|
|
870
|
+
rowIdField: config.rowIdField,
|
|
871
|
+
scope,
|
|
872
|
+
table,
|
|
873
|
+
});
|
|
874
|
+
|
|
875
|
+
if (!isRecord(row)) return row;
|
|
876
|
+
const nextRow = await transformRecordFields({
|
|
877
|
+
ctx,
|
|
878
|
+
index,
|
|
879
|
+
keys: pluginOptions.keys,
|
|
880
|
+
prefix,
|
|
881
|
+
decryptionErrorMode,
|
|
882
|
+
mode: 'decrypt',
|
|
883
|
+
scope,
|
|
884
|
+
table,
|
|
885
|
+
rowId,
|
|
886
|
+
record: row,
|
|
887
|
+
});
|
|
888
|
+
return nextRow;
|
|
889
|
+
})
|
|
890
|
+
);
|
|
891
|
+
|
|
892
|
+
return { ...snapshot, rows: nextRows };
|
|
893
|
+
})
|
|
894
|
+
);
|
|
895
|
+
|
|
896
|
+
return { ...sub, snapshots: nextSnapshots };
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
// Incremental commits
|
|
900
|
+
const nextCommits = await Promise.all(
|
|
901
|
+
(sub.commits ?? []).map(async (commit) => {
|
|
902
|
+
const nextChanges = await Promise.all(
|
|
903
|
+
(commit.changes ?? []).map(async (change) => {
|
|
904
|
+
if (change.op !== 'upsert') return change;
|
|
905
|
+
if (!isRecord(change.row_json)) return change;
|
|
906
|
+
const target = resolveScopeAndTable({
|
|
907
|
+
index,
|
|
908
|
+
identifier: change.table,
|
|
909
|
+
});
|
|
910
|
+
|
|
911
|
+
const nextRow = await transformRecordFields({
|
|
912
|
+
ctx,
|
|
913
|
+
index,
|
|
914
|
+
keys: pluginOptions.keys,
|
|
915
|
+
prefix,
|
|
916
|
+
decryptionErrorMode,
|
|
917
|
+
mode: 'decrypt',
|
|
918
|
+
scope: target.scope,
|
|
919
|
+
table: target.table,
|
|
920
|
+
rowId: change.row_id,
|
|
921
|
+
record: change.row_json,
|
|
922
|
+
});
|
|
923
|
+
|
|
924
|
+
if (nextRow === change.row_json) return change;
|
|
925
|
+
return { ...change, row_json: nextRow };
|
|
926
|
+
})
|
|
927
|
+
);
|
|
928
|
+
return { ...commit, changes: nextChanges };
|
|
929
|
+
})
|
|
930
|
+
);
|
|
931
|
+
|
|
932
|
+
return { ...sub, commits: nextCommits };
|
|
933
|
+
})
|
|
934
|
+
);
|
|
935
|
+
|
|
936
|
+
return { ...response, subscriptions: nextSubscriptions };
|
|
937
|
+
},
|
|
938
|
+
};
|
|
939
|
+
}
|