@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.
@@ -0,0 +1,202 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import type {
3
+ SyncPullRequest,
4
+ SyncPullResponse,
5
+ SyncPushRequest,
6
+ SyncPushResponse,
7
+ } from '@syncular/core';
8
+ import {
9
+ createFieldEncryptionPlugin,
10
+ createStaticFieldEncryptionKeys,
11
+ generateSymmetricKey,
12
+ } from '../index';
13
+
14
+ const keyId = 'scope-resolution';
15
+ const keys = createStaticFieldEncryptionKeys({
16
+ keys: { [keyId]: generateSymmetricKey() },
17
+ encryptionKid: keyId,
18
+ });
19
+
20
+ const plugin = createFieldEncryptionPlugin({
21
+ rules: [
22
+ {
23
+ scope: 'workspace_tasks',
24
+ table: 'tasks',
25
+ fields: ['title'],
26
+ },
27
+ ],
28
+ keys,
29
+ });
30
+
31
+ const context = { actorId: 'actor-1', clientId: 'client-1' };
32
+
33
+ async function buildEncryptedTitle(): Promise<string> {
34
+ const request: SyncPushRequest = {
35
+ clientId: 'client-1',
36
+ clientCommitId: 'commit-1',
37
+ schemaVersion: 1,
38
+ operations: [
39
+ {
40
+ table: 'tasks',
41
+ row_id: 'task-1',
42
+ op: 'upsert',
43
+ payload: {
44
+ id: 'task-1',
45
+ title: 'Secret title',
46
+ },
47
+ base_version: null,
48
+ },
49
+ ],
50
+ };
51
+
52
+ const encrypted = await plugin.beforePush!(context, request);
53
+ const encryptedTitle = encrypted.operations[0]?.payload?.title;
54
+ if (typeof encryptedTitle !== 'string') {
55
+ throw new Error('Expected encrypted title to be a string');
56
+ }
57
+ return encryptedTitle;
58
+ }
59
+
60
+ describe('Field encryption scope/table resolution', () => {
61
+ test('encrypts beforePush when rule scope differs from operation table', async () => {
62
+ const request: SyncPushRequest = {
63
+ clientId: 'client-1',
64
+ clientCommitId: 'commit-1',
65
+ schemaVersion: 1,
66
+ operations: [
67
+ {
68
+ table: 'tasks',
69
+ row_id: 'task-1',
70
+ op: 'upsert',
71
+ payload: {
72
+ id: 'task-1',
73
+ title: 'Secret title',
74
+ completed: false,
75
+ },
76
+ base_version: null,
77
+ },
78
+ ],
79
+ };
80
+
81
+ const encrypted = await plugin.beforePush!(context, request);
82
+ const payload = encrypted.operations[0]?.payload;
83
+ const encryptedTitle = payload?.title;
84
+
85
+ expect(typeof encryptedTitle).toBe('string');
86
+ expect(encryptedTitle).not.toBe('Secret title');
87
+ expect(String(encryptedTitle).startsWith('dgsync:e2ee:1:')).toBe(true);
88
+ expect(payload?.completed).toBe(false);
89
+ });
90
+
91
+ test('decrypts afterPush conflict rows with scope/table mismatch rules', async () => {
92
+ const encryptedTitle = await buildEncryptedTitle();
93
+
94
+ const request: SyncPushRequest = {
95
+ clientId: 'client-1',
96
+ clientCommitId: 'commit-2',
97
+ schemaVersion: 1,
98
+ operations: [
99
+ {
100
+ table: 'tasks',
101
+ row_id: 'task-1',
102
+ op: 'upsert',
103
+ payload: {
104
+ id: 'task-1',
105
+ title: 'Secret title',
106
+ },
107
+ base_version: null,
108
+ },
109
+ ],
110
+ };
111
+
112
+ const response: SyncPushResponse = {
113
+ ok: true,
114
+ status: 'rejected',
115
+ results: [
116
+ {
117
+ opIndex: 0,
118
+ status: 'conflict',
119
+ message: 'conflict',
120
+ server_version: 2,
121
+ server_row: {
122
+ id: 'task-1',
123
+ title: encryptedTitle,
124
+ },
125
+ },
126
+ ],
127
+ };
128
+
129
+ const next = await plugin.afterPush!(context, { request, response });
130
+ const conflict = next.results[0];
131
+
132
+ if (!conflict || conflict.status !== 'conflict') {
133
+ throw new Error('Expected conflict result in afterPush response');
134
+ }
135
+ if (!('server_row' in conflict)) {
136
+ throw new Error('Expected conflict server_row in afterPush response');
137
+ }
138
+ if (
139
+ typeof conflict.server_row !== 'object' ||
140
+ conflict.server_row === null ||
141
+ Array.isArray(conflict.server_row)
142
+ ) {
143
+ throw new Error('Expected conflict server_row to be an object');
144
+ }
145
+ expect(conflict.server_row.title).toBe('Secret title');
146
+ });
147
+
148
+ test('decrypts incremental pull rows when change.table is the scope name', async () => {
149
+ const encryptedTitle = await buildEncryptedTitle();
150
+
151
+ const request: SyncPullRequest = {
152
+ clientId: 'client-1',
153
+ limitCommits: 50,
154
+ subscriptions: [],
155
+ };
156
+
157
+ const response: SyncPullResponse = {
158
+ ok: true,
159
+ subscriptions: [
160
+ {
161
+ id: 'workspace-sub',
162
+ status: 'active',
163
+ scopes: {},
164
+ bootstrap: false,
165
+ nextCursor: 1,
166
+ commits: [
167
+ {
168
+ commitSeq: 1,
169
+ createdAt: new Date(0).toISOString(),
170
+ actorId: 'actor-1',
171
+ changes: [
172
+ {
173
+ table: 'workspace_tasks',
174
+ row_id: 'task-1',
175
+ op: 'upsert',
176
+ row_json: {
177
+ id: 'task-1',
178
+ title: encryptedTitle,
179
+ },
180
+ row_version: 2,
181
+ scopes: {},
182
+ },
183
+ ],
184
+ },
185
+ ],
186
+ },
187
+ ],
188
+ };
189
+
190
+ const next = await plugin.afterPull!(context, { request, response });
191
+ const change =
192
+ next.subscriptions[0]?.commits[0]?.changes[0]?.row_json ?? null;
193
+ if (
194
+ typeof change !== 'object' ||
195
+ change === null ||
196
+ Array.isArray(change)
197
+ ) {
198
+ throw new Error('Expected decrypted change row_json object');
199
+ }
200
+ expect(change.title).toBe('Secret title');
201
+ });
202
+ });
@@ -0,0 +1,84 @@
1
+ import { afterEach, describe, expect, test } from 'bun:test';
2
+ import {
3
+ base64ToBytes,
4
+ base64UrlToBytes,
5
+ bytesToBase64,
6
+ bytesToBase64Url,
7
+ hexToBytes,
8
+ randomBytes,
9
+ } from './crypto-utils';
10
+
11
+ let originalBuffer: typeof Buffer | undefined;
12
+ let bufferOverridden = false;
13
+
14
+ function disableBufferRuntime(): void {
15
+ originalBuffer = globalThis.Buffer;
16
+ Object.defineProperty(globalThis, 'Buffer', {
17
+ value: undefined,
18
+ writable: true,
19
+ configurable: true,
20
+ });
21
+ bufferOverridden = true;
22
+ }
23
+
24
+ function restoreBufferRuntime(): void {
25
+ if (!bufferOverridden) return;
26
+ Object.defineProperty(globalThis, 'Buffer', {
27
+ value: originalBuffer,
28
+ writable: true,
29
+ configurable: true,
30
+ });
31
+ bufferOverridden = false;
32
+ }
33
+
34
+ afterEach(() => {
35
+ restoreBufferRuntime();
36
+ });
37
+
38
+ describe('crypto-utils', () => {
39
+ test('encodes and decodes base64 payloads', () => {
40
+ const bytes = new Uint8Array([0, 1, 2, 253, 254, 255]);
41
+ const encoded = bytesToBase64(bytes);
42
+ const decoded = base64ToBytes(encoded);
43
+ expect(decoded).toEqual(bytes);
44
+ });
45
+
46
+ test('encodes and decodes base64url payloads', () => {
47
+ const bytes = new Uint8Array([0, 1, 2, 253, 254, 255]);
48
+ const encoded = bytesToBase64Url(bytes);
49
+ const decoded = base64UrlToBytes(encoded);
50
+ expect(decoded).toEqual(bytes);
51
+ });
52
+
53
+ test('rejects malformed base64 inputs', () => {
54
+ expect(() => base64ToBytes('@@@@')).toThrow('Invalid base64 string');
55
+ expect(() => base64UrlToBytes('@@@@')).toThrow('Invalid base64url string');
56
+ });
57
+
58
+ test('works when Buffer is unavailable', () => {
59
+ disableBufferRuntime();
60
+
61
+ const bytes = new Uint8Array([0, 1, 2, 253, 254, 255]);
62
+ const encoded = bytesToBase64(bytes);
63
+ const decoded = base64ToBytes(encoded);
64
+ expect(decoded).toEqual(bytes);
65
+
66
+ const encodedUrl = bytesToBase64Url(bytes);
67
+ const decodedUrl = base64UrlToBytes(encodedUrl);
68
+ expect(decodedUrl).toEqual(bytes);
69
+ });
70
+
71
+ test('parses hex strings', () => {
72
+ expect(hexToBytes('00a1ff')).toEqual(new Uint8Array([0, 161, 255]));
73
+ expect(() => hexToBytes('0')).toThrow(
74
+ 'Invalid hex string (length must be even)'
75
+ );
76
+ expect(() => hexToBytes('zz')).toThrow('Invalid hex string');
77
+ });
78
+
79
+ test('creates random byte arrays', () => {
80
+ const bytes = randomBytes(32);
81
+ expect(bytes).toBeInstanceOf(Uint8Array);
82
+ expect(bytes.length).toBe(32);
83
+ });
84
+ });
@@ -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
+ }