Skip to content

Commit 77543e3

Browse files
authored
Web database encryption (#439)
1 parent c2d0679 commit 77543e3

File tree

9 files changed

+18573
-23183
lines changed

9 files changed

+18573
-23183
lines changed

demos/react-supabase-todolist/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
"@powersync/web": "workspace:*",
1414
"@emotion/react": "11.11.4",
1515
"@emotion/styled": "11.11.5",
16-
"@journeyapps/wa-sqlite": "^1.0.0",
16+
"@journeyapps/wa-sqlite": "^1.1.1",
1717
"@mui/icons-material": "^5.15.12",
1818
"@mui/material": "^5.15.12",
1919
"@mui/x-data-grid": "^6.19.6",

packages/web/README.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,39 @@ Install it in your app with:
2828
npm install @journeyapps/wa-sqlite
2929
```
3030

31+
### Encryption with Multiple Ciphers
32+
33+
To enable encryption you need to specify an encryption key when instantiating the PowerSync database.
34+
35+
> The PowerSync Web SDK uses the ChaCha20 cipher algorithm by [default](https://utelle.github.io/SQLite3MultipleCiphers/docs/ciphers/cipher_chacha20/).
36+
37+
```typescript
38+
export const db = new PowerSyncDatabase({
39+
// The schema you defined
40+
schema: AppSchema,
41+
database: {
42+
// Filename for the SQLite database — it's important to only instantiate one instance per file.
43+
dbFilename: 'example.db'
44+
// Optional. Directory where the database file is located.'
45+
// dbLocation: 'path/to/directory'
46+
},
47+
// Encryption key for the database.
48+
encryptionKey: 'your-encryption-key'
49+
});
50+
51+
// If you are using a custom WASQLiteOpenFactory or WASQLiteDBAdapter, you need specify the encryption key inside the construtor
52+
export const db = new PowerSyncDatabase({
53+
schema: AppSchema,
54+
database: new WASQLiteOpenFactory({
55+
//new WASQLiteDBAdapter
56+
dbFilename: 'example.db',
57+
vfs: WASQLiteVFS.OPFSCoopSyncVFS,
58+
// Encryption key for the database.
59+
encryptionKey: 'your-encryption-key'
60+
})
61+
});
62+
```
63+
3164
## Webpack
3265

3366
See the [example Webpack config](https://github.com/powersync-ja/powersync-js/blob/main/demos/example-webpack/webpack.config.js) for details on polyfills and requirements.

packages/web/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@
6060
"author": "JOURNEYAPPS",
6161
"license": "Apache-2.0",
6262
"peerDependencies": {
63-
"@journeyapps/wa-sqlite": "^1.0.0",
63+
"@journeyapps/wa-sqlite": "^1.1.1",
6464
"@powersync/common": "workspace:^1.22.0"
6565
},
6666
"dependencies": {
@@ -72,7 +72,7 @@
7272
"js-logger": "^1.6.1"
7373
},
7474
"devDependencies": {
75-
"@journeyapps/wa-sqlite": "^1.0.0",
75+
"@journeyapps/wa-sqlite": "^1.1.1",
7676
"@types/uuid": "^9.0.6",
7777
"@vitest/browser": "^2.1.4",
7878
"crypto-browserify": "^3.12.0",

packages/web/src/db/PowerSyncDatabase.ts

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import {
66
AbstractPowerSyncDatabase,
77
DBAdapter,
88
DEFAULT_POWERSYNC_CLOSE_OPTIONS,
9+
isDBAdapter,
10+
isSQLOpenFactory,
911
PowerSyncDatabaseOptions,
1012
PowerSyncDatabaseOptionsWithDBAdapter,
1113
PowerSyncDatabaseOptionsWithOpenFactory,
@@ -56,14 +58,24 @@ type WithWebSyncOptions<Base> = Base & {
5658
sync?: WebSyncOptions;
5759
};
5860

61+
export interface WebEncryptionOptions {
62+
/**
63+
* Encryption key for the database.
64+
* If set, the database will be encrypted using Multiple Ciphers.
65+
*/
66+
encryptionKey?: string;
67+
}
68+
69+
type WithWebEncryptionOptions<Base> = Base & WebEncryptionOptions;
70+
5971
export type WebPowerSyncDatabaseOptionsWithAdapter = WithWebSyncOptions<
6072
WithWebFlags<PowerSyncDatabaseOptionsWithDBAdapter>
6173
>;
6274
export type WebPowerSyncDatabaseOptionsWithOpenFactory = WithWebSyncOptions<
6375
WithWebFlags<PowerSyncDatabaseOptionsWithOpenFactory>
6476
>;
6577
export type WebPowerSyncDatabaseOptionsWithSettings = WithWebSyncOptions<
66-
WithWebFlags<PowerSyncDatabaseOptionsWithSettings>
78+
WithWebFlags<WithWebEncryptionOptions<PowerSyncDatabaseOptionsWithSettings>>
6779
>;
6880

6981
export type WebPowerSyncDatabaseOptions = WithWebSyncOptions<WithWebFlags<PowerSyncDatabaseOptions>>;
@@ -81,6 +93,20 @@ export const resolveWebPowerSyncFlags = (flags?: WebPowerSyncFlags): Required<We
8193
};
8294
};
8395

96+
/**
97+
* Asserts that the database options are valid for custom database constructors.
98+
*/
99+
function assertValidDatabaseOptions(options: WebPowerSyncDatabaseOptions): void {
100+
if ('database' in options && 'encryptionKey' in options) {
101+
const { database } = options;
102+
if (isSQLOpenFactory(database) || isDBAdapter(database)) {
103+
throw new Error(
104+
`Invalid configuration: 'encryptionKey' should only be included inside the database object when using a custom ${isSQLOpenFactory(database) ? 'WASQLiteOpenFactory' : 'WASQLiteDBAdapter'} constructor.`
105+
);
106+
}
107+
}
108+
}
109+
84110
/**
85111
* A PowerSync database which provides SQLite functionality
86112
* which is automatically synced.
@@ -108,6 +134,8 @@ export class PowerSyncDatabase extends AbstractPowerSyncDatabase {
108134
constructor(protected options: WebPowerSyncDatabaseOptions) {
109135
super(options);
110136

137+
assertValidDatabaseOptions(options);
138+
111139
this.resolvedFlags = resolveWebPowerSyncFlags(options.flags);
112140

113141
if (this.resolvedFlags.enableMultiTabs && !this.resolvedFlags.externallyUnload) {
@@ -121,7 +149,8 @@ export class PowerSyncDatabase extends AbstractPowerSyncDatabase {
121149
protected openDBAdapter(options: WebPowerSyncDatabaseOptionsWithSettings): DBAdapter {
122150
const defaultFactory = new WASQLiteOpenFactory({
123151
...options.database,
124-
flags: resolveWebPowerSyncFlags(options.flags)
152+
flags: resolveWebPowerSyncFlags(options.flags),
153+
encryptionKey: options.encryptionKey
125154
});
126155
return defaultFactory.openDB();
127156
}

packages/web/src/db/adapters/wa-sqlite/WASQLiteConnection.ts

Lines changed: 57 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ export type SQLiteModule = Parameters<typeof SQLite.Factory>[0];
3636
/**
3737
* @internal
3838
*/
39-
export type WASQLiteModuleFactoryOptions = { dbFileName: string };
39+
export type WASQLiteModuleFactoryOptions = { dbFileName: string; encryptionKey?: string };
4040

4141
/**
4242
* @internal
@@ -53,6 +53,14 @@ export const AsyncWASQLiteModuleFactory = async () => {
5353
return factory();
5454
};
5555

56+
/**
57+
* @internal
58+
*/
59+
export const MultiCipherAsyncWASQLiteModuleFactory = async () => {
60+
const { default: factory } = await import('@journeyapps/wa-sqlite/dist/mc-wa-sqlite-async.mjs');
61+
return factory();
62+
};
63+
5664
/**
5765
* @internal
5866
*/
@@ -61,12 +69,25 @@ export const SyncWASQLiteModuleFactory = async () => {
6169
return factory();
6270
};
6371

72+
/**
73+
* @internal
74+
*/
75+
export const MultiCipherSyncWASQLiteModuleFactory = async () => {
76+
const { default: factory } = await import('@journeyapps/wa-sqlite/dist/mc-wa-sqlite.mjs');
77+
return factory();
78+
};
79+
6480
/**
6581
* @internal
6682
*/
6783
export const DEFAULT_MODULE_FACTORIES = {
6884
[WASQLiteVFS.IDBBatchAtomicVFS]: async (options: WASQLiteModuleFactoryOptions) => {
69-
const module = await AsyncWASQLiteModuleFactory();
85+
let module;
86+
if (options.encryptionKey) {
87+
module = await MultiCipherAsyncWASQLiteModuleFactory();
88+
} else {
89+
module = await AsyncWASQLiteModuleFactory();
90+
}
7091
const { IDBBatchAtomicVFS } = await import('@journeyapps/wa-sqlite/src/examples/IDBBatchAtomicVFS.js');
7192
return {
7293
module,
@@ -75,7 +96,12 @@ export const DEFAULT_MODULE_FACTORIES = {
7596
};
7697
},
7798
[WASQLiteVFS.AccessHandlePoolVFS]: async (options: WASQLiteModuleFactoryOptions) => {
78-
const module = await SyncWASQLiteModuleFactory();
99+
let module;
100+
if (options.encryptionKey) {
101+
module = await MultiCipherSyncWASQLiteModuleFactory();
102+
} else {
103+
module = await SyncWASQLiteModuleFactory();
104+
}
79105
// @ts-expect-error The types for this static method are missing upstream
80106
const { AccessHandlePoolVFS } = await import('@journeyapps/wa-sqlite/src/examples/AccessHandlePoolVFS.js');
81107
return {
@@ -84,7 +110,12 @@ export const DEFAULT_MODULE_FACTORIES = {
84110
};
85111
},
86112
[WASQLiteVFS.OPFSCoopSyncVFS]: async (options: WASQLiteModuleFactoryOptions) => {
87-
const module = await SyncWASQLiteModuleFactory();
113+
let module;
114+
if (options.encryptionKey) {
115+
module = await MultiCipherSyncWASQLiteModuleFactory();
116+
} else {
117+
module = await SyncWASQLiteModuleFactory();
118+
}
88119
// @ts-expect-error The types for this static method are missing upstream
89120
const { OPFSCoopSyncVFS } = await import('@journeyapps/wa-sqlite/src/examples/OPFSCoopSyncVFS.js');
90121
return {
@@ -146,15 +177,35 @@ export class WASqliteConnection
146177
return this._dbP;
147178
}
148179

180+
protected async executeEncryptionPragma(): Promise<void> {
181+
if (this.options.encryptionKey) {
182+
await this.executeSingleStatement(`PRAGMA key = "${this.options.encryptionKey}"`);
183+
}
184+
return;
185+
}
186+
149187
protected async openSQLiteAPI(): Promise<SQLiteAPI> {
150-
const { module, vfs } = await this._moduleFactory({ dbFileName: this.options.dbFilename });
188+
const { module, vfs } = await this._moduleFactory({
189+
dbFileName: this.options.dbFilename,
190+
encryptionKey: this.options.encryptionKey
191+
});
151192
const sqlite3 = SQLite.Factory(module);
152193
sqlite3.vfs_register(vfs, true);
153194
/**
154195
* Register the PowerSync core SQLite extension
155196
*/
156197
module.ccall('powersync_init_static', 'int', []);
157198

199+
/**
200+
* Create the multiple cipher vfs if an encryption key is provided
201+
*/
202+
if (this.options.encryptionKey) {
203+
const createResult = module.ccall('sqlite3mc_vfs_create', 'int', ['string', 'int'], [this.options.dbFilename, 1]);
204+
if (createResult !== 0) {
205+
throw new Error('Failed to create multiple cipher vfs, Database encryption will not work');
206+
}
207+
}
208+
158209
return sqlite3;
159210
}
160211

@@ -182,6 +233,7 @@ export class WASqliteConnection
182233
await this.openDB();
183234
this.registerBroadcastListeners();
184235
await this.executeSingleStatement(`PRAGMA temp_store = ${this.options.temporaryStorage};`);
236+
await this.executeEncryptionPragma();
185237

186238
this.sqliteAPI.update_hook(this.dbP, (updateType: number, dbName: string | null, tableName: string | null) => {
187239
if (!tableName) {

packages/web/src/db/adapters/wa-sqlite/WASQLiteDBAdapter.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,12 @@ export interface WASQLiteDBAdapterOptions extends Omit<PowerSyncOpenFactoryOptio
2727

2828
vfs?: WASQLiteVFS;
2929
temporaryStorage?: TemporaryStorageOption;
30+
31+
/**
32+
* Encryption key for the database.
33+
* If set, the database will be encrypted using multiple-ciphers.
34+
*/
35+
encryptionKey?: string;
3036
}
3137

3238
/**
@@ -46,7 +52,8 @@ export class WASQLiteDBAdapter extends LockedAsyncDatabaseAdapter {
4652
baseConnection: await remote({
4753
...options,
4854
temporaryStorage: temporaryStorage ?? TemporaryStorageOption.MEMORY,
49-
flags: resolveWebPowerSyncFlags(options.flags)
55+
flags: resolveWebPowerSyncFlags(options.flags),
56+
encryptionKey: options.encryptionKey
5057
})
5158
});
5259
}
@@ -58,6 +65,7 @@ export class WASQLiteDBAdapter extends LockedAsyncDatabaseAdapter {
5865
temporaryStorage,
5966
logger: options.logger,
6067
vfs: options.vfs,
68+
encryptionKey: options.encryptionKey,
6169
worker: options.worker
6270
});
6371
return openFactory.openConnection();

packages/web/src/db/adapters/wa-sqlite/WASQLiteOpenFactory.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,11 @@ export class WASQLiteOpenFactory extends AbstractWebSQLOpenFactory {
4141

4242
async openConnection(): Promise<AsyncDatabaseConnection> {
4343
const { enableMultiTabs, useWebWorker } = this.resolvedFlags;
44-
const { vfs = WASQLiteVFS.IDBBatchAtomicVFS, temporaryStorage = TemporaryStorageOption.MEMORY } = this.waOptions;
44+
const {
45+
vfs = WASQLiteVFS.IDBBatchAtomicVFS,
46+
temporaryStorage = TemporaryStorageOption.MEMORY,
47+
encryptionKey
48+
} = this.waOptions;
4549

4650
if (!enableMultiTabs) {
4751
this.logger.warn('Multiple tabs are not enabled in this browser');
@@ -56,7 +60,8 @@ export class WASQLiteOpenFactory extends AbstractWebSQLOpenFactory {
5660
optionsDbWorker({
5761
...this.options,
5862
temporaryStorage,
59-
flags: this.resolvedFlags
63+
flags: this.resolvedFlags,
64+
encryptionKey
6065
})
6166
)
6267
: openWorkerDatabasePort(this.options.dbFilename, enableMultiTabs, optionsDbWorker, this.waOptions.vfs);
@@ -69,7 +74,8 @@ export class WASQLiteOpenFactory extends AbstractWebSQLOpenFactory {
6974
dbFilename: this.options.dbFilename,
7075
vfs,
7176
temporaryStorage,
72-
flags: this.resolvedFlags
77+
flags: this.resolvedFlags,
78+
encryptionKey: encryptionKey
7379
}),
7480
identifier: this.options.dbFilename
7581
});
@@ -81,7 +87,8 @@ export class WASQLiteOpenFactory extends AbstractWebSQLOpenFactory {
8187
debugMode: this.options.debugMode,
8288
vfs,
8389
temporaryStorage,
84-
flags: this.resolvedFlags
90+
flags: this.resolvedFlags,
91+
encryptionKey: encryptionKey
8592
});
8693
}
8794
}

packages/web/src/db/adapters/web-sql-flags.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,12 @@ export interface ResolvedWebSQLOpenOptions extends SQLOpenOptions {
4646
* Setting this to `FILESYSTEM` can cause issues with larger queries or datasets.
4747
*/
4848
temporaryStorage: TemporaryStorageOption;
49+
50+
/**
51+
* Encryption key for the database.
52+
* If set, the database will be encrypted using ChaCha20.
53+
*/
54+
encryptionKey?: string;
4955
}
5056

5157
export enum TemporaryStorageOption {
@@ -73,6 +79,12 @@ export interface WebSQLOpenFactoryOptions extends SQLOpenOptions {
7379
* Setting this to `FILESYSTEM` can cause issues with larger queries or datasets.
7480
*/
7581
temporaryStorage?: TemporaryStorageOption;
82+
83+
/**
84+
* Encryption key for the database.
85+
* If set, the database will be encrypted using ChaCha20.
86+
*/
87+
encryptionKey?: string;
7688
}
7789

7890
export function isServerSide() {

0 commit comments

Comments
 (0)