Skip to content

Commit

Permalink
Merge pull request #354 from powersync-ja/feat/sqlcipher-op-sqlite
Browse files Browse the repository at this point in the history
Feat: Encryption with SQLCipher for OPSQLite
  • Loading branch information
mugikhan authored Oct 15, 2024
2 parents f88a162 + 4e214bb commit 214e884
Show file tree
Hide file tree
Showing 6 changed files with 16,904 additions and 20,705 deletions.
5 changes: 5 additions & 0 deletions .changeset/strong-hotels-deliver.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@powersync/op-sqlite': patch
---

Encryption for databases using SQLCipher.
27 changes: 26 additions & 1 deletion packages/powersync-op-sqlite/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

## Overview

This package (`packages/powersync-op-sqlite`) enables using [OP-SQLite](https://github.com/op-engineering/op-sqlite) with PowerSync alongside the [React Native SDK](https://docs.powersync.com/client-sdk-references/react-native-and-expo).
This package (`packages/powersync-op-sqlite`) enables using [OP-SQLite](https://github.com/op-engineering/op-sqlite) with PowerSync alongside the [React Native SDK](https://docs.powersync.com/client-sdk-references/react-native-and-expo).

If you are not yet familiar with PowerSync, please see the [PowerSync React Native SDK README](https://github.com/powersync-ja/powersync-js/tree/main/packages/react-native) for more information.

Expand Down Expand Up @@ -43,6 +43,31 @@ const factory = new OPSqliteOpenFactory({
this.powersync = new PowerSyncDatabase({ database: factory, schema: AppSchema });
```

### Encryption with SQLCipher

To enable SQLCipher you need to add the following configuration option to your application's `package.json`

```json
{
// your normal package.json
// ...
"op-sqlite": {
"sqlcipher": true
}
}
```

Additionally you will need to add an [encryption key](https://www.zetetic.net/sqlcipher/sqlcipher-api/#key) to the OPSQLite factory constructor

```typescript
const factory = new OPSqliteOpenFactory({
dbFilename: 'sqlite.db',
sqliteOptions: {
encryptionKey: 'your-encryption-key'
}
});
```

## Native Projects

This package uses native libraries. Create native Android and iOS projects (if not created already) by running:
Expand Down
4 changes: 2 additions & 2 deletions packages/powersync-op-sqlite/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@
"access": "public"
},
"peerDependencies": {
"@op-engineering/op-sqlite": "^9.1.3",
"@op-engineering/op-sqlite": "^9.2.1",
"@powersync/common": "workspace:^1.20.0",
"react": "*",
"react-native": "*"
Expand All @@ -75,7 +75,7 @@
"async-lock": "^1.4.0"
},
"devDependencies": {
"@op-engineering/op-sqlite": "^9.1.3",
"@op-engineering/op-sqlite": "^9.2.1",
"@react-native/eslint-config": "^0.73.1",
"@types/async-lock": "^1.4.0",
"@types/react": "^18.2.44",
Expand Down
59 changes: 31 additions & 28 deletions packages/powersync-op-sqlite/src/db/OPSqliteAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { ANDROID_DATABASE_PATH, IOS_LIBRARY_PATH, open, type DB } from '@op-engi
import Lock from 'async-lock';
import { OPSQLiteConnection } from './OPSQLiteConnection';
import { NativeModules, Platform } from 'react-native';
import { DEFAULT_SQLITE_OPTIONS, SqliteOptions } from './SqliteOptions';
import { SqliteOptions } from './SqliteOptions';

/**
* Adapter for React Native Quick SQLite
Expand Down Expand Up @@ -50,15 +50,10 @@ export class OPSQLiteDBAdapter extends BaseObserver<DBAdapterListener> implement
}

protected async init() {
const { lockTimeoutMs, journalMode, journalSizeLimit, synchronous } = this.options.sqliteOptions;
// const { dbFilename, dbLocation } = this.options;
const { lockTimeoutMs, journalMode, journalSizeLimit, synchronous, encryptionKey } = this.options.sqliteOptions;
const dbFilename = this.options.name;
//This is needed because an undefined dbLocation will cause the open function to fail
const location = this.getDbLocation(this.options.dbLocation);
const DB: DB = open({
name: dbFilename,
location: location
});

this.writeConnection = await this.openConnection(dbFilename);

const statements: string[] = [
`PRAGMA busy_timeout = ${lockTimeoutMs}`,
Expand All @@ -70,7 +65,7 @@ export class OPSQLiteDBAdapter extends BaseObserver<DBAdapterListener> implement
for (const statement of statements) {
for (let tries = 0; tries < 30; tries++) {
try {
await DB.execute(statement);
await this.writeConnection!.execute(statement);
break;
} catch (e: any) {
if (e instanceof Error && e.message.includes('database is locked') && tries < 29) {
Expand All @@ -82,34 +77,24 @@ export class OPSQLiteDBAdapter extends BaseObserver<DBAdapterListener> implement
}
}

this.loadExtension(DB);

await DB.execute('SELECT powersync_init()');
// Changes should only occur in the write connection
this.writeConnection!.registerListener({
tablesUpdated: (notification) => this.iterateListeners((cb) => cb.tablesUpdated?.(notification))
});

this.readConnections = [];
for (let i = 0; i < READ_CONNECTIONS; i++) {
// Workaround to create read-only connections
let dbName = './'.repeat(i + 1) + dbFilename;
const conn = await this.openConnection(location, dbName);
const conn = await this.openConnection(dbName);
await conn.execute('PRAGMA query_only = true');
this.readConnections.push(conn);
}

this.writeConnection = new OPSQLiteConnection({
baseDB: DB
});

// Changes should only occur in the write connection
this.writeConnection!.registerListener({
tablesUpdated: (notification) => this.iterateListeners((cb) => cb.tablesUpdated?.(notification))
});
}

protected async openConnection(dbLocation: string, filenameOverride?: string): Promise<OPSQLiteConnection> {
const DB: DB = open({
name: filenameOverride ?? this.options.name,
location: dbLocation
});
protected async openConnection(filenameOverride?: string): Promise<OPSQLiteConnection> {
const dbFilename = filenameOverride ?? this.options.name;
const DB: DB = this.openDatabase(dbFilename, this.options.sqliteOptions.encryptionKey);

//Load extension for all connections
this.loadExtension(DB);
Expand All @@ -129,6 +114,24 @@ export class OPSQLiteDBAdapter extends BaseObserver<DBAdapterListener> implement
}
}

private openDatabase(dbFilename: string, encryptionKey?: string): DB {
//This is needed because an undefined/null dbLocation will cause the open function to fail
const location = this.getDbLocation(this.options.dbLocation);
//Simarlily if the encryption key is undefined/null when using SQLCipher it will cause the open function to fail
if (encryptionKey) {
return open({
name: dbFilename,
location: location,
encryptionKey: encryptionKey
});
} else {
return open({
name: dbFilename,
location: location
});
}
}

private loadExtension(DB: DB) {
if (Platform.OS === 'ios') {
const bundlePath: string = NativeModules.PowerSyncOpSqlite.getBundlePath();
Expand Down
11 changes: 9 additions & 2 deletions packages/powersync-op-sqlite/src/db/SqliteOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@ export interface SqliteOptions {
* Set to null or zero to fail immediately when the database is locked.
*/
lockTimeoutMs?: number;

/**
* Encryption key for the database.
* If set, the database will be encrypted using SQLCipher.
*/
encryptionKey?: string;
}

// SQLite journal mode. Set on the primary connection.
Expand All @@ -36,19 +42,20 @@ enum SqliteJournalMode {
truncate = 'TRUNCATE',
persist = 'PERSIST',
memory = 'MEMORY',
off = 'OFF',
off = 'OFF'
}

// SQLite file commit mode.
enum SqliteSynchronous {
normal = 'NORMAL',
full = 'FULL',
off = 'OFF',
off = 'OFF'
}

export const DEFAULT_SQLITE_OPTIONS: Required<SqliteOptions> = {
journalMode: SqliteJournalMode.wal,
synchronous: SqliteSynchronous.normal,
journalSizeLimit: 6 * 1024 * 1024,
lockTimeoutMs: 30000,
encryptionKey: null
};
Loading

0 comments on commit 214e884

Please sign in to comment.