@syncular/client-plugin-encryption 0.0.1 → 0.0.2-126
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/README.md +36 -0
- 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 +6 -15
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +77 -137
- package/dist/index.js.map +1 -1
- package/dist/key-sharing.d.ts +5 -6
- 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 +28 -5
- 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 +78 -152
- package/src/key-sharing.ts +41 -127
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;
|
|
@@ -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:
|
|
871
|
-
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:
|
|
910
|
-
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:
|
|
993
|
-
table:
|
|
918
|
+
scope: target.scope,
|
|
919
|
+
table: target.table,
|
|
994
920
|
rowId: change.row_id,
|
|
995
921
|
record: change.row_json,
|
|
996
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
|
}
|