@syncular/client-plugin-encryption 0.0.1-60

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/package.json ADDED
@@ -0,0 +1,65 @@
1
+ {
2
+ "name": "@syncular/client-plugin-encryption",
3
+ "version": "0.0.1-60",
4
+ "description": "End-to-end encryption plugin for the Syncular client",
5
+ "license": "MIT",
6
+ "author": "Benjamin Kniffler",
7
+ "homepage": "https://syncular.dev",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "https://github.com/syncular/syncular.git",
11
+ "directory": "packages/client-plugin-encryption"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/syncular/syncular/issues"
15
+ },
16
+ "keywords": [
17
+ "sync",
18
+ "offline-first",
19
+ "realtime",
20
+ "database",
21
+ "typescript",
22
+ "encryption",
23
+ "e2ee",
24
+ "security"
25
+ ],
26
+ "private": false,
27
+ "publishConfig": {
28
+ "access": "public"
29
+ },
30
+ "type": "module",
31
+ "exports": {
32
+ ".": {
33
+ "bun": "./src/index.ts",
34
+ "import": {
35
+ "types": "./dist/index.d.ts",
36
+ "default": "./dist/index.js"
37
+ }
38
+ }
39
+ },
40
+ "scripts": {
41
+ "test": "bun test --pass-with-no-tests",
42
+ "tsgo": "tsgo --noEmit",
43
+ "build": "rm -rf dist && tsgo",
44
+ "release": "bun pm pack --destination . && npm publish ./*.tgz --tag latest && rm -f ./*.tgz"
45
+ },
46
+ "dependencies": {
47
+ "@noble/ciphers": "^2.1.1",
48
+ "@noble/curves": "^2.0.1",
49
+ "@noble/hashes": "^2.0.1",
50
+ "@scure/bip39": "^2.0.1",
51
+ "@syncular/client": "0.0.1",
52
+ "@syncular/core": "0.0.1"
53
+ },
54
+ "devDependencies": {
55
+ "@syncular/config": "0.0.0",
56
+ "kysely-bun-sqlite": "^0.4.0"
57
+ },
58
+ "peerDependencies": {
59
+ "kysely": "^0.28.0"
60
+ },
61
+ "files": [
62
+ "dist",
63
+ "src"
64
+ ]
65
+ }
@@ -0,0 +1,193 @@
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('keyToMnemonic throws for wrong length', () => {
67
+ expect(() => keyToMnemonic(new Uint8Array(16))).toThrow();
68
+ });
69
+ });
70
+
71
+ describe('X25519 keypair utilities', () => {
72
+ test('generateKeypair returns valid keys', () => {
73
+ const { publicKey, privateKey } = generateKeypair();
74
+ expect(publicKey).toBeInstanceOf(Uint8Array);
75
+ expect(privateKey).toBeInstanceOf(Uint8Array);
76
+ expect(publicKey.length).toBe(32);
77
+ expect(privateKey.length).toBe(32);
78
+ });
79
+
80
+ test('publicKeyToMnemonic roundtrip', () => {
81
+ const { publicKey } = generateKeypair();
82
+ const mnemonic = publicKeyToMnemonic(publicKey);
83
+ const recovered = mnemonicToPublicKey(mnemonic);
84
+ expect(recovered).toEqual(publicKey);
85
+ });
86
+ });
87
+
88
+ describe('key wrapping', () => {
89
+ test('wrap and unwrap symmetric key', () => {
90
+ const alice = generateKeypair();
91
+ const symmetricKey = generateSymmetricKey();
92
+
93
+ const wrapped = wrapKeyForRecipient(alice.publicKey, symmetricKey);
94
+ expect(wrapped.ephemeralPublic.length).toBe(32);
95
+ expect(wrapped.ciphertext.length).toBe(72);
96
+
97
+ const unwrapped = unwrapKey(alice.privateKey, wrapped);
98
+ expect(unwrapped).toEqual(symmetricKey);
99
+ });
100
+
101
+ test('encodeWrappedKey roundtrip', () => {
102
+ const alice = generateKeypair();
103
+ const symmetricKey = generateSymmetricKey();
104
+
105
+ const wrapped = wrapKeyForRecipient(alice.publicKey, symmetricKey);
106
+ const encoded = encodeWrappedKey(wrapped);
107
+ const decoded = decodeWrappedKey(encoded);
108
+
109
+ expect(decoded.ephemeralPublic).toEqual(wrapped.ephemeralPublic);
110
+ expect(decoded.ciphertext).toEqual(wrapped.ciphertext);
111
+
112
+ const unwrapped = unwrapKey(alice.privateKey, decoded);
113
+ expect(unwrapped).toEqual(symmetricKey);
114
+ });
115
+
116
+ test('wrong private key fails to unwrap', () => {
117
+ const alice = generateKeypair();
118
+ const bob = generateKeypair();
119
+ const symmetricKey = generateSymmetricKey();
120
+
121
+ const wrapped = wrapKeyForRecipient(alice.publicKey, symmetricKey);
122
+
123
+ expect(() => unwrapKey(bob.privateKey, wrapped)).toThrow();
124
+ });
125
+ });
126
+
127
+ describe('share URL format', () => {
128
+ test('keyToShareUrl roundtrip', () => {
129
+ const key = generateSymmetricKey();
130
+ const url = keyToShareUrl(key);
131
+ const parsed = parseShareUrl(url);
132
+
133
+ expect(parsed.type).toBe('symmetric');
134
+ if (parsed.type === 'symmetric') {
135
+ expect(parsed.key).toEqual(key);
136
+ expect(parsed.kid).toBeUndefined();
137
+ }
138
+ });
139
+
140
+ test('keyToShareUrl with kid', () => {
141
+ const key = generateSymmetricKey();
142
+ const url = keyToShareUrl(key, 'my-key-id');
143
+ const parsed = parseShareUrl(url);
144
+
145
+ expect(parsed.type).toBe('symmetric');
146
+ if (parsed.type === 'symmetric') {
147
+ expect(parsed.key).toEqual(key);
148
+ expect(parsed.kid).toBe('my-key-id');
149
+ }
150
+ });
151
+
152
+ test('publicKeyToShareUrl roundtrip', () => {
153
+ const { publicKey } = generateKeypair();
154
+ const url = publicKeyToShareUrl(publicKey);
155
+ const parsed = parseShareUrl(url);
156
+
157
+ expect(parsed.type).toBe('publicKey');
158
+ if (parsed.type === 'publicKey') {
159
+ expect(parsed.publicKey).toEqual(publicKey);
160
+ }
161
+ });
162
+
163
+ test('parseShareUrl throws for invalid URL', () => {
164
+ expect(() => parseShareUrl('https://example.com')).toThrow();
165
+ expect(() => parseShareUrl('sync://invalid')).toThrow();
166
+ });
167
+ });
168
+
169
+ describe('JSON format', () => {
170
+ test('keyToJson roundtrip', () => {
171
+ const key = generateSymmetricKey();
172
+ const json = keyToJson(key, 'test-kid');
173
+ const parsed = parseKeyShareJson(JSON.stringify(json));
174
+
175
+ expect(parsed.type).toBe('symmetric');
176
+ if (parsed.type === 'symmetric') {
177
+ expect(parsed.key).toEqual(key);
178
+ expect(parsed.kid).toBe('test-kid');
179
+ }
180
+ });
181
+
182
+ test('publicKeyToJson roundtrip', () => {
183
+ const { publicKey } = generateKeypair();
184
+ const json = publicKeyToJson(publicKey);
185
+ const parsed = parseKeyShareJson(JSON.stringify(json));
186
+
187
+ expect(parsed.type).toBe('publicKey');
188
+ if (parsed.type === 'publicKey') {
189
+ expect(parsed.publicKey).toEqual(publicKey);
190
+ }
191
+ });
192
+ });
193
+ });
@@ -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
+ });