@syncular/client-plugin-encryption 0.0.1 → 0.0.2-127

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/src/index.ts CHANGED
@@ -11,7 +11,15 @@ import type {
11
11
  SyncPushRequest,
12
12
  SyncPushResponse,
13
13
  } from '@syncular/core';
14
+ import { isRecord } from '@syncular/core';
14
15
  import { type Kysely, sql } from 'kysely';
16
+ import {
17
+ base64ToBytes,
18
+ base64UrlToBytes,
19
+ bytesToBase64Url,
20
+ hexToBytes,
21
+ randomBytes,
22
+ } from './crypto-utils';
15
23
 
16
24
  // Re-export key sharing utilities
17
25
  export * from './key-sharing';
@@ -119,140 +127,6 @@ const DEFAULT_PREFIX = 'dgsync:e2ee:1:';
119
127
  const encoder = new TextEncoder();
120
128
  const decoder = new TextDecoder();
121
129
 
122
- // Base64 lookup tables for universal encoding/decoding (works in all runtimes)
123
- const BASE64_CHARS =
124
- 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
125
- const BASE64_LOOKUP = new Uint8Array(256);
126
- for (let i = 0; i < BASE64_CHARS.length; i++) {
127
- BASE64_LOOKUP[BASE64_CHARS.charCodeAt(i)] = i;
128
- }
129
-
130
- function isRecord(value: unknown): value is Record<string, unknown> {
131
- return typeof value === 'object' && value !== null && !Array.isArray(value);
132
- }
133
-
134
- function randomBytes(length: number): Uint8Array {
135
- const cryptoObj = globalThis.crypto;
136
- if (!cryptoObj?.getRandomValues) {
137
- throw new Error(
138
- 'Secure random generator is not available (crypto.getRandomValues). ' +
139
- 'Ensure you are running in a secure context or polyfill crypto.'
140
- );
141
- }
142
- const out = new Uint8Array(length);
143
- cryptoObj.getRandomValues(out);
144
- return out;
145
- }
146
-
147
- /**
148
- * Universal base64 encoding that works in all JavaScript runtimes.
149
- * Uses Buffer for Node/Bun (fast), lookup table for others (RN-compatible).
150
- */
151
- function bytesToBase64(bytes: Uint8Array): string {
152
- // Node/Bun fast path
153
- if (typeof Buffer !== 'undefined') {
154
- return Buffer.from(bytes).toString('base64');
155
- }
156
-
157
- // Universal fallback using lookup table (works in RN, browsers, etc.)
158
- let result = '';
159
- const len = bytes.length;
160
- const remainder = len % 3;
161
-
162
- // Process 3 bytes at a time
163
- for (let i = 0; i < len - remainder; i += 3) {
164
- const a = bytes[i]!;
165
- const b = bytes[i + 1]!;
166
- const c = bytes[i + 2]!;
167
- result +=
168
- BASE64_CHARS.charAt((a >> 2) & 0x3f) +
169
- BASE64_CHARS.charAt(((a << 4) | (b >> 4)) & 0x3f) +
170
- BASE64_CHARS.charAt(((b << 2) | (c >> 6)) & 0x3f) +
171
- BASE64_CHARS.charAt(c & 0x3f);
172
- }
173
-
174
- // Handle remaining bytes
175
- if (remainder === 1) {
176
- const a = bytes[len - 1]!;
177
- result +=
178
- BASE64_CHARS.charAt((a >> 2) & 0x3f) +
179
- BASE64_CHARS.charAt((a << 4) & 0x3f) +
180
- '==';
181
- } else if (remainder === 2) {
182
- const a = bytes[len - 2]!;
183
- const b = bytes[len - 1]!;
184
- result +=
185
- BASE64_CHARS.charAt((a >> 2) & 0x3f) +
186
- BASE64_CHARS.charAt(((a << 4) | (b >> 4)) & 0x3f) +
187
- BASE64_CHARS.charAt((b << 2) & 0x3f) +
188
- '=';
189
- }
190
-
191
- return result;
192
- }
193
-
194
- /**
195
- * Universal base64 decoding that works in all JavaScript runtimes.
196
- * Uses Buffer for Node/Bun (fast), lookup table for others (RN-compatible).
197
- */
198
- function base64ToBytes(base64: string): Uint8Array {
199
- // Node/Bun fast path
200
- if (typeof Buffer !== 'undefined') {
201
- return new Uint8Array(Buffer.from(base64, 'base64'));
202
- }
203
-
204
- // Universal fallback using lookup table (works in RN, browsers, etc.)
205
- // Remove padding and calculate output length
206
- const len = base64.length;
207
- let padding = 0;
208
- if (base64[len - 1] === '=') padding++;
209
- if (base64[len - 2] === '=') padding++;
210
-
211
- const outputLen = (len * 3) / 4 - padding;
212
- const out = new Uint8Array(outputLen);
213
-
214
- let outIdx = 0;
215
- for (let i = 0; i < len; i += 4) {
216
- const a = BASE64_LOOKUP[base64.charCodeAt(i)]!;
217
- const b = BASE64_LOOKUP[base64.charCodeAt(i + 1)]!;
218
- const c = BASE64_LOOKUP[base64.charCodeAt(i + 2)]!;
219
- const d = BASE64_LOOKUP[base64.charCodeAt(i + 3)]!;
220
-
221
- out[outIdx++] = (a << 2) | (b >> 4);
222
- if (outIdx < outputLen) out[outIdx++] = ((b << 4) | (c >> 2)) & 0xff;
223
- if (outIdx < outputLen) out[outIdx++] = ((c << 6) | d) & 0xff;
224
- }
225
-
226
- return out;
227
- }
228
-
229
- function bytesToBase64Url(bytes: Uint8Array): string {
230
- return bytesToBase64(bytes)
231
- .replace(/\+/g, '-')
232
- .replace(/\//g, '_')
233
- .replace(/=+$/g, '');
234
- }
235
-
236
- function base64UrlToBytes(base64url: string): Uint8Array {
237
- const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/');
238
- const padded = base64 + '==='.slice((base64.length + 3) % 4);
239
- return base64ToBytes(padded);
240
- }
241
-
242
- function hexToBytes(hex: string): Uint8Array {
243
- const normalized = hex.trim().toLowerCase();
244
- if (normalized.length % 2 !== 0) {
245
- throw new Error('Invalid hex string (length must be even)');
246
- }
247
- const out = new Uint8Array(normalized.length / 2);
248
- for (let i = 0; i < out.length; i++) {
249
- const byte = Number.parseInt(normalized.slice(i * 2, i * 2 + 2), 16);
250
- if (!Number.isFinite(byte)) throw new Error('Invalid hex string');
251
- out[i] = byte;
252
- }
253
- return out;
254
- }
255
-
256
130
  function decodeKeyMaterial(key: Uint8Array | string): Uint8Array {
257
131
  if (key instanceof Uint8Array) return key;
258
132
  const trimmed = key.trim();
@@ -281,6 +155,11 @@ export function createStaticFieldEncryptionKeys(args: {
281
155
  const raw = args.keys[kid];
282
156
  if (!raw) throw new Error(`Missing encryption key for kid "${kid}"`);
283
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
+ }
284
163
  cache.set(kid, decoded);
285
164
  return decoded;
286
165
  },
@@ -318,11 +197,15 @@ function decodeEnvelope(
318
197
  if (parts.length !== 3) return null;
319
198
  const [kid, nonceB64, ctB64] = parts;
320
199
  if (!kid || !nonceB64 || !ctB64) return null;
321
- return {
322
- kid,
323
- nonce: base64UrlToBytes(nonceB64),
324
- ciphertext: base64UrlToBytes(ctB64),
325
- };
200
+ try {
201
+ return {
202
+ kid,
203
+ nonce: base64UrlToBytes(nonceB64),
204
+ ciphertext: base64UrlToBytes(ctB64),
205
+ };
206
+ } catch {
207
+ return null;
208
+ }
326
209
  }
327
210
 
328
211
  async function getKeyOrThrow(
@@ -427,9 +310,11 @@ async function decryptValue(args: {
427
310
  function buildRuleIndex(rules: FieldEncryptionRule[]): {
428
311
  byScopeTable: Map<string, RuleConfig>;
429
312
  tablesByScope: Map<string, Set<string>>;
313
+ scopesByTable: Map<string, Set<string>>;
430
314
  } {
431
315
  const byScopeTable = new Map<string, RuleConfig>();
432
316
  const tablesByScope = new Map<string, Set<string>>();
317
+ const scopesByTable = new Map<string, Set<string>>();
433
318
 
434
319
  for (const rule of rules) {
435
320
  const scope = rule.scope;
@@ -452,9 +337,13 @@ function buildRuleIndex(rules: FieldEncryptionRule[]): {
452
337
  }
453
338
 
454
339
  if (table !== '*') {
455
- const set = tablesByScope.get(scope) ?? new Set<string>();
456
- set.add(table);
457
- tablesByScope.set(scope, set);
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);
458
347
  }
459
348
  }
460
349
 
@@ -466,7 +355,7 @@ function buildRuleIndex(rules: FieldEncryptionRule[]): {
466
355
  });
467
356
  }
468
357
 
469
- return { byScopeTable, tablesByScope };
358
+ return { byScopeTable, tablesByScope, scopesByTable };
470
359
  }
471
360
 
472
361
  function getRuleConfig(
@@ -479,6 +368,33 @@ function getRuleConfig(
479
368
  return wildcard ?? null;
480
369
  }
481
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
+
482
398
  function inferSnapshotTable(args: {
483
399
  index: ReturnType<typeof buildRuleIndex>;
484
400
  scope: string;
@@ -679,9 +595,7 @@ function resolveRefreshTargets(args: {
679
595
  }));
680
596
  }
681
597
 
682
- async function refreshEncryptedFields<
683
- DB extends SyncClientDb = SyncClientDb,
684
- >(
598
+ async function refreshEncryptedFields<DB extends SyncClientDb = SyncClientDb>(
685
599
  options: RefreshEncryptedFieldsOptions<DB>
686
600
  ): Promise<RefreshEncryptedFieldsResult> {
687
601
  const prefix = options.envelopePrefix ?? DEFAULT_PREFIX;
@@ -860,6 +774,10 @@ export function createFieldEncryptionPlugin(
860
774
  if (!op.payload) return op;
861
775
 
862
776
  const payload = op.payload as Record<string, unknown>;
777
+ const target = resolveScopeAndTable({
778
+ index,
779
+ identifier: op.table,
780
+ });
863
781
  const nextPayload = await transformRecordFields({
864
782
  ctx,
865
783
  index,
@@ -867,8 +785,8 @@ export function createFieldEncryptionPlugin(
867
785
  prefix,
868
786
  decryptionErrorMode,
869
787
  mode: 'encrypt',
870
- scope: op.table,
871
- table: op.table,
788
+ scope: target.scope,
789
+ table: target.table,
872
790
  rowId: op.row_id,
873
791
  record: payload,
874
792
  });
@@ -898,6 +816,10 @@ export function createFieldEncryptionPlugin(
898
816
  if (!op) return r;
899
817
 
900
818
  if (!isRecord(r.server_row)) return r;
819
+ const target = resolveScopeAndTable({
820
+ index,
821
+ identifier: op.table,
822
+ });
901
823
 
902
824
  const nextRow = await transformRecordFields({
903
825
  ctx,
@@ -906,8 +828,8 @@ export function createFieldEncryptionPlugin(
906
828
  prefix,
907
829
  decryptionErrorMode,
908
830
  mode: 'decrypt',
909
- scope: op.table,
910
- table: op.table,
831
+ scope: target.scope,
832
+ table: target.table,
911
833
  rowId: op.row_id,
912
834
  record: r.server_row,
913
835
  });
@@ -981,6 +903,10 @@ export function createFieldEncryptionPlugin(
981
903
  (commit.changes ?? []).map(async (change) => {
982
904
  if (change.op !== 'upsert') return change;
983
905
  if (!isRecord(change.row_json)) return change;
906
+ const target = resolveScopeAndTable({
907
+ index,
908
+ identifier: change.table,
909
+ });
984
910
 
985
911
  const nextRow = await transformRecordFields({
986
912
  ctx,
@@ -989,8 +915,8 @@ export function createFieldEncryptionPlugin(
989
915
  prefix,
990
916
  decryptionErrorMode,
991
917
  mode: 'decrypt',
992
- scope: change.table,
993
- table: change.table,
918
+ scope: target.scope,
919
+ table: target.table,
994
920
  rowId: change.row_id,
995
921
  record: change.row_json,
996
922
  });
@@ -13,6 +13,12 @@ import { hkdf } from '@noble/hashes/hkdf.js';
13
13
  import { sha256 } from '@noble/hashes/sha2.js';
14
14
  import { entropyToMnemonic, mnemonicToEntropy } from '@scure/bip39';
15
15
  import { wordlist } from '@scure/bip39/wordlists/english.js';
16
+ import { isRecord } from '@syncular/core';
17
+ import {
18
+ base64UrlToBytes,
19
+ bytesToBase64Url,
20
+ randomBytes,
21
+ } from './crypto-utils';
16
22
 
17
23
  const WORD_SET = new Set(wordlist);
18
24
 
@@ -20,122 +26,6 @@ const WORD_SET = new Set(wordlist);
20
26
  // Utility Functions
21
27
  // ============================================================================
22
28
 
23
- // Base64 lookup tables for universal encoding/decoding (works in all runtimes)
24
- const BASE64_CHARS =
25
- 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
26
- const BASE64_LOOKUP = new Uint8Array(256);
27
- for (let i = 0; i < BASE64_CHARS.length; i++) {
28
- BASE64_LOOKUP[BASE64_CHARS.charCodeAt(i)] = i;
29
- }
30
-
31
- function randomBytes(length: number): Uint8Array {
32
- const cryptoObj = globalThis.crypto;
33
- if (!cryptoObj?.getRandomValues) {
34
- throw new Error(
35
- 'Secure random generator is not available (crypto.getRandomValues). ' +
36
- 'Ensure you are running in a secure context or polyfill crypto.'
37
- );
38
- }
39
- const out = new Uint8Array(length);
40
- cryptoObj.getRandomValues(out);
41
- return out;
42
- }
43
-
44
- /**
45
- * Universal base64 encoding that works in all JavaScript runtimes.
46
- * Uses Buffer for Node/Bun (fast), lookup table for others (RN-compatible).
47
- */
48
- function bytesToBase64(bytes: Uint8Array): string {
49
- // Node/Bun fast path
50
- if (typeof Buffer !== 'undefined') {
51
- return Buffer.from(bytes).toString('base64');
52
- }
53
-
54
- // Universal fallback using lookup table (works in RN, browsers, etc.)
55
- let result = '';
56
- const len = bytes.length;
57
- const remainder = len % 3;
58
-
59
- // Process 3 bytes at a time
60
- for (let i = 0; i < len - remainder; i += 3) {
61
- const a = bytes[i]!;
62
- const b = bytes[i + 1]!;
63
- const c = bytes[i + 2]!;
64
- result +=
65
- BASE64_CHARS.charAt((a >> 2) & 0x3f) +
66
- BASE64_CHARS.charAt(((a << 4) | (b >> 4)) & 0x3f) +
67
- BASE64_CHARS.charAt(((b << 2) | (c >> 6)) & 0x3f) +
68
- BASE64_CHARS.charAt(c & 0x3f);
69
- }
70
-
71
- // Handle remaining bytes
72
- if (remainder === 1) {
73
- const a = bytes[len - 1]!;
74
- result +=
75
- BASE64_CHARS.charAt((a >> 2) & 0x3f) +
76
- BASE64_CHARS.charAt((a << 4) & 0x3f) +
77
- '==';
78
- } else if (remainder === 2) {
79
- const a = bytes[len - 2]!;
80
- const b = bytes[len - 1]!;
81
- result +=
82
- BASE64_CHARS.charAt((a >> 2) & 0x3f) +
83
- BASE64_CHARS.charAt(((a << 4) | (b >> 4)) & 0x3f) +
84
- BASE64_CHARS.charAt((b << 2) & 0x3f) +
85
- '=';
86
- }
87
-
88
- return result;
89
- }
90
-
91
- /**
92
- * Universal base64 decoding that works in all JavaScript runtimes.
93
- * Uses Buffer for Node/Bun (fast), lookup table for others (RN-compatible).
94
- */
95
- function base64ToBytes(base64: string): Uint8Array {
96
- // Node/Bun fast path
97
- if (typeof Buffer !== 'undefined') {
98
- return new Uint8Array(Buffer.from(base64, 'base64'));
99
- }
100
-
101
- // Universal fallback using lookup table (works in RN, browsers, etc.)
102
- // Remove padding and calculate output length
103
- const len = base64.length;
104
- let padding = 0;
105
- if (base64[len - 1] === '=') padding++;
106
- if (base64[len - 2] === '=') padding++;
107
-
108
- const outputLen = (len * 3) / 4 - padding;
109
- const out = new Uint8Array(outputLen);
110
-
111
- let outIdx = 0;
112
- for (let i = 0; i < len; i += 4) {
113
- const a = BASE64_LOOKUP[base64.charCodeAt(i)]!;
114
- const b = BASE64_LOOKUP[base64.charCodeAt(i + 1)]!;
115
- const c = BASE64_LOOKUP[base64.charCodeAt(i + 2)]!;
116
- const d = BASE64_LOOKUP[base64.charCodeAt(i + 3)]!;
117
-
118
- out[outIdx++] = (a << 2) | (b >> 4);
119
- if (outIdx < outputLen) out[outIdx++] = ((b << 4) | (c >> 2)) & 0xff;
120
- if (outIdx < outputLen) out[outIdx++] = ((c << 6) | d) & 0xff;
121
- }
122
-
123
- return out;
124
- }
125
-
126
- function bytesToBase64Url(bytes: Uint8Array): string {
127
- return bytesToBase64(bytes)
128
- .replace(/\+/g, '-')
129
- .replace(/\//g, '_')
130
- .replace(/=+$/g, '');
131
- }
132
-
133
- function base64UrlToBytes(base64url: string): Uint8Array {
134
- const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/');
135
- const padded = base64 + '==='.slice((base64.length + 3) % 4);
136
- return base64ToBytes(padded);
137
- }
138
-
139
29
  function concatBytes(...arrays: Uint8Array[]): Uint8Array {
140
30
  const totalLength = arrays.reduce((sum, arr) => sum + arr.length, 0);
141
31
  const result = new Uint8Array(totalLength);
@@ -240,7 +130,11 @@ export function keyToBase64Url(key: Uint8Array): string {
240
130
  * Decode URL-safe base64 back to key bytes.
241
131
  */
242
132
  export function base64UrlToKey(encoded: string): Uint8Array {
243
- return base64UrlToBytes(encoded);
133
+ const key = base64UrlToBytes(encoded);
134
+ if (key.length !== 32) {
135
+ throw new Error(`Invalid key length: expected 32 bytes, got ${key.length}`);
136
+ }
137
+ return key;
244
138
  }
245
139
 
246
140
  // ============================================================================
@@ -478,13 +372,13 @@ export function parseShareUrl(url: string): ParsedShare {
478
372
  }
479
373
 
480
374
  if (type === 'k') {
481
- const key = base64UrlToBytes(encoded);
375
+ const key = base64UrlToKey(encoded);
482
376
  const kid = kidEncoded ? decodeURIComponent(kidEncoded) : undefined;
483
377
  return { type: 'symmetric', key, kid };
484
378
  }
485
379
 
486
380
  if (type === 'pk') {
487
- const publicKey = base64UrlToBytes(encoded);
381
+ const publicKey = base64UrlToKey(encoded);
488
382
  return { type: 'publicKey', publicKey };
489
383
  }
490
384
 
@@ -506,8 +400,6 @@ interface PublicKeyJson {
506
400
  pk: string;
507
401
  }
508
402
 
509
- type KeyShareJson = SymmetricKeyJson | PublicKeyJson;
510
-
511
403
  /**
512
404
  * Encode a symmetric key as JSON.
513
405
  */
@@ -533,23 +425,45 @@ export function publicKeyToJson(publicKey: Uint8Array): PublicKeyJson {
533
425
  * Parse a JSON key share string.
534
426
  */
535
427
  export function parseKeyShareJson(json: string): ParsedShare {
536
- const parsed = JSON.parse(json) as KeyShareJson;
537
- const parsedType = parsed.type;
428
+ let parsedValue: unknown;
429
+ try {
430
+ parsedValue = JSON.parse(json);
431
+ } catch {
432
+ throw new Error('Invalid key share JSON');
433
+ }
434
+
435
+ if (!isRecord(parsedValue)) {
436
+ throw new Error('Invalid key share JSON');
437
+ }
438
+
439
+ const parsedType = parsedValue.type;
538
440
 
539
441
  if (parsedType === 'symmetric') {
442
+ const keyMaterial = parsedValue.k;
443
+ if (typeof keyMaterial !== 'string') {
444
+ throw new Error('Invalid symmetric key share JSON');
445
+ }
446
+ const kidValue = parsedValue.kid;
447
+ if (kidValue !== undefined && typeof kidValue !== 'string') {
448
+ throw new Error('Invalid symmetric key share JSON');
449
+ }
540
450
  return {
541
451
  type: 'symmetric',
542
- key: base64UrlToBytes(parsed.k),
543
- kid: parsed.kid,
452
+ key: base64UrlToKey(keyMaterial),
453
+ kid: kidValue,
544
454
  };
545
455
  }
546
456
 
547
457
  if (parsedType === 'publicKey') {
458
+ const publicKeyMaterial = parsedValue.pk;
459
+ if (typeof publicKeyMaterial !== 'string') {
460
+ throw new Error('Invalid public key share JSON');
461
+ }
548
462
  return {
549
463
  type: 'publicKey',
550
- publicKey: base64UrlToBytes(parsed.pk),
464
+ publicKey: base64UrlToKey(publicKeyMaterial),
551
465
  };
552
466
  }
553
467
 
554
- throw new Error(`Unknown key share type: ${parsedType}`);
468
+ throw new Error(`Unknown key share type: ${String(parsedType)}`);
555
469
  }