@syncular/client-plugin-encryption 0.0.1-73 → 0.0.1-89
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.map +1 -1
- package/dist/index.js +62 -136
- package/dist/index.js.map +1 -1
- package/dist/key-sharing.d.ts.map +1 -1
- package/dist/key-sharing.js +36 -112
- package/dist/key-sharing.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/field-encryption-keys.test.ts +68 -0
- package/src/__tests__/key-sharing.test.ts +32 -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 +77 -149
- package/src/key-sharing.ts +41 -127
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
const BASE64_CHARS =
|
|
2
|
+
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
|
|
3
|
+
const BASE64_LOOKUP = new Uint8Array(256);
|
|
4
|
+
|
|
5
|
+
for (let i = 0; i < BASE64_CHARS.length; i++) {
|
|
6
|
+
BASE64_LOOKUP[BASE64_CHARS.charCodeAt(i)] = i;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const BASE64_PATTERN =
|
|
10
|
+
/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
|
|
11
|
+
const BASE64_URL_PATTERN = /^[A-Za-z0-9_-]*$/;
|
|
12
|
+
|
|
13
|
+
export function randomBytes(length: number): Uint8Array {
|
|
14
|
+
const cryptoObj = globalThis.crypto;
|
|
15
|
+
if (!cryptoObj?.getRandomValues) {
|
|
16
|
+
throw new Error(
|
|
17
|
+
'Secure random generator is not available (crypto.getRandomValues). ' +
|
|
18
|
+
'Ensure you are running in a secure context or polyfill crypto.'
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
const out = new Uint8Array(length);
|
|
22
|
+
cryptoObj.getRandomValues(out);
|
|
23
|
+
return out;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function bytesToBase64(bytes: Uint8Array): string {
|
|
27
|
+
if (typeof Buffer !== 'undefined') {
|
|
28
|
+
return Buffer.from(bytes).toString('base64');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
let result = '';
|
|
32
|
+
const len = bytes.length;
|
|
33
|
+
const remainder = len % 3;
|
|
34
|
+
|
|
35
|
+
for (let i = 0; i < len - remainder; i += 3) {
|
|
36
|
+
const a = bytes[i]!;
|
|
37
|
+
const b = bytes[i + 1]!;
|
|
38
|
+
const c = bytes[i + 2]!;
|
|
39
|
+
result +=
|
|
40
|
+
BASE64_CHARS.charAt((a >> 2) & 0x3f) +
|
|
41
|
+
BASE64_CHARS.charAt(((a << 4) | (b >> 4)) & 0x3f) +
|
|
42
|
+
BASE64_CHARS.charAt(((b << 2) | (c >> 6)) & 0x3f) +
|
|
43
|
+
BASE64_CHARS.charAt(c & 0x3f);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (remainder === 1) {
|
|
47
|
+
const a = bytes[len - 1]!;
|
|
48
|
+
result +=
|
|
49
|
+
BASE64_CHARS.charAt((a >> 2) & 0x3f) +
|
|
50
|
+
BASE64_CHARS.charAt((a << 4) & 0x3f) +
|
|
51
|
+
'==';
|
|
52
|
+
} else if (remainder === 2) {
|
|
53
|
+
const a = bytes[len - 2]!;
|
|
54
|
+
const b = bytes[len - 1]!;
|
|
55
|
+
result +=
|
|
56
|
+
BASE64_CHARS.charAt((a >> 2) & 0x3f) +
|
|
57
|
+
BASE64_CHARS.charAt(((a << 4) | (b >> 4)) & 0x3f) +
|
|
58
|
+
BASE64_CHARS.charAt((b << 2) & 0x3f) +
|
|
59
|
+
'=';
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return result;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function base64ToBytes(base64: string): Uint8Array {
|
|
66
|
+
if (!BASE64_PATTERN.test(base64)) {
|
|
67
|
+
throw new Error('Invalid base64 string');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (typeof Buffer !== 'undefined') {
|
|
71
|
+
return new Uint8Array(Buffer.from(base64, 'base64'));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const len = base64.length;
|
|
75
|
+
let padding = 0;
|
|
76
|
+
if (base64[len - 1] === '=') padding++;
|
|
77
|
+
if (base64[len - 2] === '=') padding++;
|
|
78
|
+
|
|
79
|
+
const outputLen = (len * 3) / 4 - padding;
|
|
80
|
+
const out = new Uint8Array(outputLen);
|
|
81
|
+
|
|
82
|
+
let outIdx = 0;
|
|
83
|
+
for (let i = 0; i < len; i += 4) {
|
|
84
|
+
const a = BASE64_LOOKUP[base64.charCodeAt(i)]!;
|
|
85
|
+
const b = BASE64_LOOKUP[base64.charCodeAt(i + 1)]!;
|
|
86
|
+
const c = BASE64_LOOKUP[base64.charCodeAt(i + 2)]!;
|
|
87
|
+
const d = BASE64_LOOKUP[base64.charCodeAt(i + 3)]!;
|
|
88
|
+
|
|
89
|
+
out[outIdx++] = (a << 2) | (b >> 4);
|
|
90
|
+
if (outIdx < outputLen) out[outIdx++] = ((b << 4) | (c >> 2)) & 0xff;
|
|
91
|
+
if (outIdx < outputLen) out[outIdx++] = ((c << 6) | d) & 0xff;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return out;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function bytesToBase64Url(bytes: Uint8Array): string {
|
|
98
|
+
return bytesToBase64(bytes)
|
|
99
|
+
.replace(/\+/g, '-')
|
|
100
|
+
.replace(/\//g, '_')
|
|
101
|
+
.replace(/=+$/g, '');
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function base64UrlToBytes(base64url: string): Uint8Array {
|
|
105
|
+
if (!BASE64_URL_PATTERN.test(base64url)) {
|
|
106
|
+
throw new Error('Invalid base64url string');
|
|
107
|
+
}
|
|
108
|
+
const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/');
|
|
109
|
+
const padded = base64 + '==='.slice((base64.length + 3) % 4);
|
|
110
|
+
return base64ToBytes(padded);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function hexToBytes(hex: string): Uint8Array {
|
|
114
|
+
const normalized = hex.trim().toLowerCase();
|
|
115
|
+
if (normalized.length % 2 !== 0) {
|
|
116
|
+
throw new Error('Invalid hex string (length must be even)');
|
|
117
|
+
}
|
|
118
|
+
const out = new Uint8Array(normalized.length / 2);
|
|
119
|
+
for (let i = 0; i < out.length; i++) {
|
|
120
|
+
const byte = Number.parseInt(normalized.slice(i * 2, i * 2 + 2), 16);
|
|
121
|
+
if (!Number.isFinite(byte)) throw new Error('Invalid hex string');
|
|
122
|
+
out[i] = byte;
|
|
123
|
+
}
|
|
124
|
+
return out;
|
|
125
|
+
}
|
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
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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
|
|
456
|
-
|
|
457
|
-
tablesByScope.set(scope,
|
|
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;
|
|
@@ -858,6 +774,10 @@ export function createFieldEncryptionPlugin(
|
|
|
858
774
|
if (!op.payload) return op;
|
|
859
775
|
|
|
860
776
|
const payload = op.payload as Record<string, unknown>;
|
|
777
|
+
const target = resolveScopeAndTable({
|
|
778
|
+
index,
|
|
779
|
+
identifier: op.table,
|
|
780
|
+
});
|
|
861
781
|
const nextPayload = await transformRecordFields({
|
|
862
782
|
ctx,
|
|
863
783
|
index,
|
|
@@ -865,8 +785,8 @@ export function createFieldEncryptionPlugin(
|
|
|
865
785
|
prefix,
|
|
866
786
|
decryptionErrorMode,
|
|
867
787
|
mode: 'encrypt',
|
|
868
|
-
scope:
|
|
869
|
-
table:
|
|
788
|
+
scope: target.scope,
|
|
789
|
+
table: target.table,
|
|
870
790
|
rowId: op.row_id,
|
|
871
791
|
record: payload,
|
|
872
792
|
});
|
|
@@ -896,6 +816,10 @@ export function createFieldEncryptionPlugin(
|
|
|
896
816
|
if (!op) return r;
|
|
897
817
|
|
|
898
818
|
if (!isRecord(r.server_row)) return r;
|
|
819
|
+
const target = resolveScopeAndTable({
|
|
820
|
+
index,
|
|
821
|
+
identifier: op.table,
|
|
822
|
+
});
|
|
899
823
|
|
|
900
824
|
const nextRow = await transformRecordFields({
|
|
901
825
|
ctx,
|
|
@@ -904,8 +828,8 @@ export function createFieldEncryptionPlugin(
|
|
|
904
828
|
prefix,
|
|
905
829
|
decryptionErrorMode,
|
|
906
830
|
mode: 'decrypt',
|
|
907
|
-
scope:
|
|
908
|
-
table:
|
|
831
|
+
scope: target.scope,
|
|
832
|
+
table: target.table,
|
|
909
833
|
rowId: op.row_id,
|
|
910
834
|
record: r.server_row,
|
|
911
835
|
});
|
|
@@ -979,6 +903,10 @@ export function createFieldEncryptionPlugin(
|
|
|
979
903
|
(commit.changes ?? []).map(async (change) => {
|
|
980
904
|
if (change.op !== 'upsert') return change;
|
|
981
905
|
if (!isRecord(change.row_json)) return change;
|
|
906
|
+
const target = resolveScopeAndTable({
|
|
907
|
+
index,
|
|
908
|
+
identifier: change.table,
|
|
909
|
+
});
|
|
982
910
|
|
|
983
911
|
const nextRow = await transformRecordFields({
|
|
984
912
|
ctx,
|
|
@@ -987,8 +915,8 @@ export function createFieldEncryptionPlugin(
|
|
|
987
915
|
prefix,
|
|
988
916
|
decryptionErrorMode,
|
|
989
917
|
mode: 'decrypt',
|
|
990
|
-
scope:
|
|
991
|
-
table:
|
|
918
|
+
scope: target.scope,
|
|
919
|
+
table: target.table,
|
|
992
920
|
rowId: change.row_id,
|
|
993
921
|
record: change.row_json,
|
|
994
922
|
});
|
package/src/key-sharing.ts
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
537
|
-
|
|
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:
|
|
543
|
-
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:
|
|
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
|
}
|