@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
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import {
|
|
3
|
+
base64UrlToKey,
|
|
4
|
+
decodeWrappedKey,
|
|
5
|
+
encodeWrappedKey,
|
|
6
|
+
generateKeypair,
|
|
7
|
+
generateSymmetricKey,
|
|
8
|
+
keyToBase64Url,
|
|
9
|
+
keyToJson,
|
|
10
|
+
keyToMnemonic,
|
|
11
|
+
keyToShareUrl,
|
|
12
|
+
mnemonicToKey,
|
|
13
|
+
mnemonicToPublicKey,
|
|
14
|
+
normalizeMnemonicInput,
|
|
15
|
+
parseKeyShareJson,
|
|
16
|
+
parseShareUrl,
|
|
17
|
+
publicKeyToJson,
|
|
18
|
+
publicKeyToMnemonic,
|
|
19
|
+
publicKeyToShareUrl,
|
|
20
|
+
unwrapKey,
|
|
21
|
+
wrapKeyForRecipient,
|
|
22
|
+
} from '../key-sharing';
|
|
23
|
+
|
|
24
|
+
describe('key-sharing', () => {
|
|
25
|
+
describe('symmetric key utilities', () => {
|
|
26
|
+
test('generateSymmetricKey returns 32 bytes', () => {
|
|
27
|
+
const key = generateSymmetricKey();
|
|
28
|
+
expect(key).toBeInstanceOf(Uint8Array);
|
|
29
|
+
expect(key.length).toBe(32);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('keyToMnemonic produces 24 words', () => {
|
|
33
|
+
const key = generateSymmetricKey();
|
|
34
|
+
const mnemonic = keyToMnemonic(key);
|
|
35
|
+
const words = mnemonic.split(' ');
|
|
36
|
+
expect(words.length).toBe(24);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('mnemonicToKey roundtrip', () => {
|
|
40
|
+
const key = generateSymmetricKey();
|
|
41
|
+
const mnemonic = keyToMnemonic(key);
|
|
42
|
+
const recovered = mnemonicToKey(mnemonic);
|
|
43
|
+
expect(recovered).toEqual(key);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('normalizeMnemonicInput strips numbered list noise', () => {
|
|
47
|
+
const key = generateSymmetricKey();
|
|
48
|
+
const mnemonic = keyToMnemonic(key);
|
|
49
|
+
const words = mnemonic.split(' ');
|
|
50
|
+
const numbered = words.map((word, i) => `${i + 1}. ${word}`).join('\n');
|
|
51
|
+
|
|
52
|
+
const normalized = normalizeMnemonicInput(numbered);
|
|
53
|
+
expect(normalized).toBe(mnemonic);
|
|
54
|
+
|
|
55
|
+
const recovered = mnemonicToKey(numbered);
|
|
56
|
+
expect(recovered).toEqual(key);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('keyToBase64Url roundtrip', () => {
|
|
60
|
+
const key = generateSymmetricKey();
|
|
61
|
+
const encoded = keyToBase64Url(key);
|
|
62
|
+
const decoded = base64UrlToKey(encoded);
|
|
63
|
+
expect(decoded).toEqual(key);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('base64UrlToKey rejects malformed base64url', () => {
|
|
67
|
+
expect(() => base64UrlToKey('@@@@')).toThrow();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('base64UrlToKey rejects wrong-length payloads', () => {
|
|
71
|
+
expect(() => base64UrlToKey('QQ')).toThrow(
|
|
72
|
+
'Invalid key length: expected 32 bytes, got 1'
|
|
73
|
+
);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('keyToMnemonic throws for wrong length', () => {
|
|
77
|
+
expect(() => keyToMnemonic(new Uint8Array(16))).toThrow();
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe('X25519 keypair utilities', () => {
|
|
82
|
+
test('generateKeypair returns valid keys', () => {
|
|
83
|
+
const { publicKey, privateKey } = generateKeypair();
|
|
84
|
+
expect(publicKey).toBeInstanceOf(Uint8Array);
|
|
85
|
+
expect(privateKey).toBeInstanceOf(Uint8Array);
|
|
86
|
+
expect(publicKey.length).toBe(32);
|
|
87
|
+
expect(privateKey.length).toBe(32);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('publicKeyToMnemonic roundtrip', () => {
|
|
91
|
+
const { publicKey } = generateKeypair();
|
|
92
|
+
const mnemonic = publicKeyToMnemonic(publicKey);
|
|
93
|
+
const recovered = mnemonicToPublicKey(mnemonic);
|
|
94
|
+
expect(recovered).toEqual(publicKey);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe('key wrapping', () => {
|
|
99
|
+
test('wrap and unwrap symmetric key', () => {
|
|
100
|
+
const alice = generateKeypair();
|
|
101
|
+
const symmetricKey = generateSymmetricKey();
|
|
102
|
+
|
|
103
|
+
const wrapped = wrapKeyForRecipient(alice.publicKey, symmetricKey);
|
|
104
|
+
expect(wrapped.ephemeralPublic.length).toBe(32);
|
|
105
|
+
expect(wrapped.ciphertext.length).toBe(72);
|
|
106
|
+
|
|
107
|
+
const unwrapped = unwrapKey(alice.privateKey, wrapped);
|
|
108
|
+
expect(unwrapped).toEqual(symmetricKey);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test('encodeWrappedKey roundtrip', () => {
|
|
112
|
+
const alice = generateKeypair();
|
|
113
|
+
const symmetricKey = generateSymmetricKey();
|
|
114
|
+
|
|
115
|
+
const wrapped = wrapKeyForRecipient(alice.publicKey, symmetricKey);
|
|
116
|
+
const encoded = encodeWrappedKey(wrapped);
|
|
117
|
+
const decoded = decodeWrappedKey(encoded);
|
|
118
|
+
|
|
119
|
+
expect(decoded.ephemeralPublic).toEqual(wrapped.ephemeralPublic);
|
|
120
|
+
expect(decoded.ciphertext).toEqual(wrapped.ciphertext);
|
|
121
|
+
|
|
122
|
+
const unwrapped = unwrapKey(alice.privateKey, decoded);
|
|
123
|
+
expect(unwrapped).toEqual(symmetricKey);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test('wrong private key fails to unwrap', () => {
|
|
127
|
+
const alice = generateKeypair();
|
|
128
|
+
const bob = generateKeypair();
|
|
129
|
+
const symmetricKey = generateSymmetricKey();
|
|
130
|
+
|
|
131
|
+
const wrapped = wrapKeyForRecipient(alice.publicKey, symmetricKey);
|
|
132
|
+
|
|
133
|
+
expect(() => unwrapKey(bob.privateKey, wrapped)).toThrow();
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
describe('share URL format', () => {
|
|
138
|
+
test('keyToShareUrl roundtrip', () => {
|
|
139
|
+
const key = generateSymmetricKey();
|
|
140
|
+
const url = keyToShareUrl(key);
|
|
141
|
+
const parsed = parseShareUrl(url);
|
|
142
|
+
|
|
143
|
+
expect(parsed.type).toBe('symmetric');
|
|
144
|
+
if (parsed.type === 'symmetric') {
|
|
145
|
+
expect(parsed.key).toEqual(key);
|
|
146
|
+
expect(parsed.kid).toBeUndefined();
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test('keyToShareUrl with kid', () => {
|
|
151
|
+
const key = generateSymmetricKey();
|
|
152
|
+
const url = keyToShareUrl(key, 'my-key-id');
|
|
153
|
+
const parsed = parseShareUrl(url);
|
|
154
|
+
|
|
155
|
+
expect(parsed.type).toBe('symmetric');
|
|
156
|
+
if (parsed.type === 'symmetric') {
|
|
157
|
+
expect(parsed.key).toEqual(key);
|
|
158
|
+
expect(parsed.kid).toBe('my-key-id');
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test('publicKeyToShareUrl roundtrip', () => {
|
|
163
|
+
const { publicKey } = generateKeypair();
|
|
164
|
+
const url = publicKeyToShareUrl(publicKey);
|
|
165
|
+
const parsed = parseShareUrl(url);
|
|
166
|
+
|
|
167
|
+
expect(parsed.type).toBe('publicKey');
|
|
168
|
+
if (parsed.type === 'publicKey') {
|
|
169
|
+
expect(parsed.publicKey).toEqual(publicKey);
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test('parseShareUrl throws for invalid URL', () => {
|
|
174
|
+
expect(() => parseShareUrl('https://example.com')).toThrow();
|
|
175
|
+
expect(() => parseShareUrl('sync://invalid')).toThrow();
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test('parseShareUrl rejects malformed base64url payloads', () => {
|
|
179
|
+
expect(() => parseShareUrl('sync://k/1/@@@@')).toThrow();
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test('parseShareUrl rejects wrong-length key payloads', () => {
|
|
183
|
+
expect(() => parseShareUrl('sync://pk/1/QQ')).toThrow(
|
|
184
|
+
'Invalid key length: expected 32 bytes, got 1'
|
|
185
|
+
);
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
describe('JSON format', () => {
|
|
190
|
+
test('keyToJson roundtrip', () => {
|
|
191
|
+
const key = generateSymmetricKey();
|
|
192
|
+
const json = keyToJson(key, 'test-kid');
|
|
193
|
+
const parsed = parseKeyShareJson(JSON.stringify(json));
|
|
194
|
+
|
|
195
|
+
expect(parsed.type).toBe('symmetric');
|
|
196
|
+
if (parsed.type === 'symmetric') {
|
|
197
|
+
expect(parsed.key).toEqual(key);
|
|
198
|
+
expect(parsed.kid).toBe('test-kid');
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test('publicKeyToJson roundtrip', () => {
|
|
203
|
+
const { publicKey } = generateKeypair();
|
|
204
|
+
const json = publicKeyToJson(publicKey);
|
|
205
|
+
const parsed = parseKeyShareJson(JSON.stringify(json));
|
|
206
|
+
|
|
207
|
+
expect(parsed.type).toBe('publicKey');
|
|
208
|
+
if (parsed.type === 'publicKey') {
|
|
209
|
+
expect(parsed.publicKey).toEqual(publicKey);
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test('parseKeyShareJson rejects malformed payloads', () => {
|
|
214
|
+
expect(() =>
|
|
215
|
+
parseKeyShareJson(JSON.stringify({ type: 'symmetric', k: '@@@@' }))
|
|
216
|
+
).toThrow();
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test('parseKeyShareJson rejects wrong-length payloads', () => {
|
|
220
|
+
expect(() =>
|
|
221
|
+
parseKeyShareJson(JSON.stringify({ type: 'publicKey', pk: 'QQ' }))
|
|
222
|
+
).toThrow('Invalid key length: expected 32 bytes, got 1');
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
});
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { Database } from 'bun:sqlite';
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
|
|
3
|
+
import type { SyncClientDb } from '@syncular/client';
|
|
4
|
+
import { Kysely } from 'kysely';
|
|
5
|
+
import { BunSqliteDialect } from 'kysely-bun-sqlite';
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
createFieldEncryptionPlugin,
|
|
9
|
+
createStaticFieldEncryptionKeys,
|
|
10
|
+
generateSymmetricKey,
|
|
11
|
+
} from '../index';
|
|
12
|
+
|
|
13
|
+
interface SharedTasksTable {
|
|
14
|
+
id: string;
|
|
15
|
+
share_id: string;
|
|
16
|
+
owner_id: string;
|
|
17
|
+
title: string;
|
|
18
|
+
completed: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface TestDb extends SyncClientDb {
|
|
22
|
+
shared_tasks: SharedTasksTable;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function createTestDb(): Promise<Kysely<TestDb>> {
|
|
26
|
+
const db = new Kysely<TestDb>({
|
|
27
|
+
dialect: new BunSqliteDialect({
|
|
28
|
+
database: new Database(':memory:'),
|
|
29
|
+
}),
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
await db.schema
|
|
33
|
+
.createTable('shared_tasks')
|
|
34
|
+
.addColumn('id', 'text', (col) => col.primaryKey())
|
|
35
|
+
.addColumn('share_id', 'text', (col) => col.notNull())
|
|
36
|
+
.addColumn('owner_id', 'text', (col) => col.notNull())
|
|
37
|
+
.addColumn('title', 'text', (col) => col.notNull())
|
|
38
|
+
.addColumn('completed', 'integer', (col) => col.notNull().defaultTo(0))
|
|
39
|
+
.execute();
|
|
40
|
+
|
|
41
|
+
return db;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
describe('refreshEncryptedFields', () => {
|
|
45
|
+
let db: Kysely<TestDb>;
|
|
46
|
+
|
|
47
|
+
beforeEach(async () => {
|
|
48
|
+
db = await createTestDb();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
afterEach(async () => {
|
|
52
|
+
await db.destroy();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('decrypts existing ciphertext rows and records local mutations', async () => {
|
|
56
|
+
const ownerKid = 'share-demo';
|
|
57
|
+
const ownerKey = generateSymmetricKey();
|
|
58
|
+
const rule = {
|
|
59
|
+
scope: 'shared_tasks',
|
|
60
|
+
table: 'shared_tasks',
|
|
61
|
+
fields: ['title'],
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const ownerPlugin = createFieldEncryptionPlugin({
|
|
65
|
+
rules: [rule],
|
|
66
|
+
keys: createStaticFieldEncryptionKeys({
|
|
67
|
+
keys: { [ownerKid]: ownerKey },
|
|
68
|
+
encryptionKid: ownerKid,
|
|
69
|
+
}),
|
|
70
|
+
decryptionErrorMode: 'keepCiphertext',
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const pushRequest = {
|
|
74
|
+
clientId: 'alice-client',
|
|
75
|
+
clientCommitId: 'commit-1',
|
|
76
|
+
schemaVersion: 1,
|
|
77
|
+
operations: [
|
|
78
|
+
{
|
|
79
|
+
table: 'shared_tasks',
|
|
80
|
+
row_id: 'task-1',
|
|
81
|
+
op: 'upsert' as const,
|
|
82
|
+
base_version: null,
|
|
83
|
+
payload: {
|
|
84
|
+
id: 'task-1',
|
|
85
|
+
share_id: 'share-1',
|
|
86
|
+
owner_id: 'alice',
|
|
87
|
+
title: 'Top secret',
|
|
88
|
+
completed: 0,
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
],
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const encryptedRequest = await ownerPlugin.beforePush!(
|
|
95
|
+
{ actorId: 'alice', clientId: 'alice-client' },
|
|
96
|
+
pushRequest
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
const encryptedTitle =
|
|
100
|
+
encryptedRequest.operations[0]?.payload?.title ?? null;
|
|
101
|
+
expect(typeof encryptedTitle).toBe('string');
|
|
102
|
+
expect(String(encryptedTitle).startsWith('dgsync:e2ee:1:')).toBe(true);
|
|
103
|
+
|
|
104
|
+
await db
|
|
105
|
+
.insertInto('shared_tasks')
|
|
106
|
+
.values({
|
|
107
|
+
id: 'task-1',
|
|
108
|
+
share_id: 'share-1',
|
|
109
|
+
owner_id: 'alice',
|
|
110
|
+
title: String(encryptedTitle),
|
|
111
|
+
completed: 0,
|
|
112
|
+
})
|
|
113
|
+
.execute();
|
|
114
|
+
|
|
115
|
+
let importedKey: Uint8Array | null = null;
|
|
116
|
+
const recipientPlugin = createFieldEncryptionPlugin({
|
|
117
|
+
rules: [rule],
|
|
118
|
+
keys: {
|
|
119
|
+
async getKey(kid: string): Promise<Uint8Array> {
|
|
120
|
+
if (!importedKey || kid !== ownerKid) {
|
|
121
|
+
throw new Error(`Missing encryption key for kid "${kid}"`);
|
|
122
|
+
}
|
|
123
|
+
return importedKey;
|
|
124
|
+
},
|
|
125
|
+
getEncryptionKid() {
|
|
126
|
+
return ownerKid;
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
decryptionErrorMode: 'keepCiphertext',
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
const recordLocalMutations = mock(
|
|
133
|
+
(
|
|
134
|
+
_inputs: Array<{
|
|
135
|
+
table: string;
|
|
136
|
+
rowId: string;
|
|
137
|
+
op: 'upsert' | 'delete';
|
|
138
|
+
}>
|
|
139
|
+
) => {}
|
|
140
|
+
);
|
|
141
|
+
const engine = { recordLocalMutations };
|
|
142
|
+
|
|
143
|
+
const beforeImport = await recipientPlugin.refreshEncryptedFields({
|
|
144
|
+
db,
|
|
145
|
+
engine,
|
|
146
|
+
ctx: { actorId: 'bob', clientId: 'bob-client' },
|
|
147
|
+
targets: [
|
|
148
|
+
{ scope: 'shared_tasks', table: 'shared_tasks', fields: ['title'] },
|
|
149
|
+
],
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
expect(beforeImport.rowsUpdated).toBe(0);
|
|
153
|
+
expect(beforeImport.fieldsUpdated).toBe(0);
|
|
154
|
+
expect(recordLocalMutations).toHaveBeenCalledTimes(0);
|
|
155
|
+
|
|
156
|
+
importedKey = ownerKey;
|
|
157
|
+
|
|
158
|
+
const afterImport = await recipientPlugin.refreshEncryptedFields({
|
|
159
|
+
db,
|
|
160
|
+
engine,
|
|
161
|
+
ctx: { actorId: 'bob', clientId: 'bob-client' },
|
|
162
|
+
targets: [
|
|
163
|
+
{ scope: 'shared_tasks', table: 'shared_tasks', fields: ['title'] },
|
|
164
|
+
],
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
expect(afterImport.rowsUpdated).toBe(1);
|
|
168
|
+
expect(afterImport.fieldsUpdated).toBe(1);
|
|
169
|
+
expect(recordLocalMutations).toHaveBeenCalledTimes(1);
|
|
170
|
+
expect(recordLocalMutations.mock.calls[0]?.[0]).toEqual([
|
|
171
|
+
{ table: 'shared_tasks', rowId: 'task-1', op: 'upsert' },
|
|
172
|
+
]);
|
|
173
|
+
|
|
174
|
+
const row = await db
|
|
175
|
+
.selectFrom('shared_tasks')
|
|
176
|
+
.select(['title'])
|
|
177
|
+
.where('id', '=', 'task-1')
|
|
178
|
+
.executeTakeFirstOrThrow();
|
|
179
|
+
|
|
180
|
+
expect(row.title).toBe('Top secret');
|
|
181
|
+
});
|
|
182
|
+
});
|
|
@@ -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
|
+
});
|