diff --git a/packages/bruno-electron/src/ipc/sqlite.js b/packages/bruno-electron/src/ipc/sqlite.js index 421f2ed0acf..07829a78e5c 100644 --- a/packages/bruno-electron/src/ipc/sqlite.js +++ b/packages/bruno-electron/src/ipc/sqlite.js @@ -1,6 +1,12 @@ const path = require('path'); const { app, ipcMain } = require('electron'); const { createDatabase, registerSQLiteIpc, SQLITE_MUTATION_CHANNEL } = require('@usebruno/sqlite'); +const { encryptString, decryptStringSafe } = require('../utils/encryption'); + +const codec = { + encrypt: encryptString, + decrypt: (value) => decryptStringSafe(value).value +}; let ipc = null; @@ -11,6 +17,7 @@ class SqliteEventModel { constructor(window) { this._window = window; const { db, statements } = createDatabase(path.join(app.getPath('userData'), 'bruno.db'), { + codec, onMutation: (event) => { this._window?.webContents?.send(SQLITE_MUTATION_CHANNEL, event); } @@ -48,4 +55,4 @@ const shutdown = () => { const getStatements = () => (ipc ? ipc.statements : null); -module.exports = { registerSqliteIpc, shutdown, getStatements }; +module.exports = { registerSqliteIpc, shutdown, getStatements, codec }; diff --git a/packages/bruno-electron/src/ipc/sqlite.spec.js b/packages/bruno-electron/src/ipc/sqlite.spec.js new file mode 100644 index 00000000000..d3d0bb3d203 --- /dev/null +++ b/packages/bruno-electron/src/ipc/sqlite.spec.js @@ -0,0 +1,40 @@ +jest.mock('electron', () => ({ + ipcMain: { handle: jest.fn(), on: jest.fn() }, + app: { getPath: jest.fn(() => require('node:os').tmpdir()) }, + safeStorage: { isEncryptionAvailable: jest.fn(() => false) } +})); + +const { codec } = require('./sqlite'); + +const REQUEST = JSON.stringify({ url: 'https://example.com', headers: { authorization: 'Bearer secret' } }); + +describe('sqlite codec', () => { + let error; + + beforeEach(() => { + error = jest.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + error.mockRestore(); + }); + + it('round-trips a value through encrypt and decrypt', () => { + expect(codec.decrypt(codec.encrypt(REQUEST))).toBe(REQUEST); + }); + + it('keeps the plaintext out of the ciphertext', () => { + const encrypted = codec.encrypt(REQUEST); + + expect(encrypted).not.toContain('example.com'); + expect(encrypted).not.toContain('secret'); + }); + + it('yields an empty value for a row written before encryption was introduced', () => { + expect(codec.decrypt(REQUEST)).toBe(''); + }); + + it('yields an empty value for a payload it cannot decrypt', () => { + expect(codec.decrypt('$01:not-decryptable')).toBe(''); + }); +}); diff --git a/packages/bruno-sqlite/scripts/verify-migrations.ts b/packages/bruno-sqlite/scripts/verify-migrations.ts index eeec1c1ee48..1dc2052b74a 100644 --- a/packages/bruno-sqlite/scripts/verify-migrations.ts +++ b/packages/bruno-sqlite/scripts/verify-migrations.ts @@ -1,4 +1,5 @@ import { DB } from '../src/node/db'; +import { passthroughCodec } from '../src/node/codec'; import type { Migration, StatementDef } from '../src/shared/types'; import { loadMigrations, loadStatements } from './lib/sources'; import { vacuumIntoStatement } from './lib/sql'; @@ -35,7 +36,7 @@ const main = () => { const backupPath = path.join(tempDir, 'bruno_backup.db'); dbHandle = new DatabaseSync(dbPath); dbHandle.exec(vacuumIntoStatement(backupPath)); - backupHandle = new DB(backupPath, migrations); + backupHandle = new DB(backupPath, migrations, { codec: passthroughCodec }); const migratedDb = backupHandle._db; if (migratedDb === undefined) throw new Error('the migrated database is not open.'); diff --git a/packages/bruno-sqlite/src/node/codec.ts b/packages/bruno-sqlite/src/node/codec.ts new file mode 100644 index 00000000000..070027ca1b3 --- /dev/null +++ b/packages/bruno-sqlite/src/node/codec.ts @@ -0,0 +1,22 @@ +import type { DatabaseSync } from 'node:sqlite'; + +export const ENCRYPT_FUNCTION = 'bruno_encrypt'; +export const DECRYPT_FUNCTION = 'bruno_decrypt'; + +export type Codec = { + encrypt: (value: string) => string; + decrypt: (value: string) => string; +}; + +export const passthroughCodec: Codec = { + encrypt: (value) => value, + decrypt: (value) => value +}; + +const nullable = (transform: (value: string) => string) => (value: unknown): string | null => + value === null || value === undefined ? null : transform(String(value)); + +export const registerCodec = (db: DatabaseSync, codec: Codec): void => { + db.function(ENCRYPT_FUNCTION, nullable(codec.encrypt)); + db.function(DECRYPT_FUNCTION, nullable(codec.decrypt)); +}; diff --git a/packages/bruno-sqlite/src/node/db.ts b/packages/bruno-sqlite/src/node/db.ts index 6718b456b59..229edde5ebb 100644 --- a/packages/bruno-sqlite/src/node/db.ts +++ b/packages/bruno-sqlite/src/node/db.ts @@ -1,8 +1,11 @@ import { DatabaseSync, DatabaseSyncOptions } from 'node:sqlite'; import { createHash } from 'node:crypto'; import type { Migration } from '../shared/types'; +import { Codec, ENCRYPT_FUNCTION, passthroughCodec, registerCodec } from './codec'; -export type DatabaseOptions = DatabaseSyncOptions; +export type DatabaseOptions = DatabaseSyncOptions & { + codec?: Codec; +}; const MIGRATION_ERROR = Symbol.for('@usebruno/sqlite:migration-error'); @@ -35,9 +38,19 @@ export class DB { )`; constructor(path: string, migrations: Migration[], options: DatabaseOptions = {}) { + const { codec, ...sqliteOptions } = options; + + try { + this._db = new DatabaseSync(path, sqliteOptions); + } catch (err) { + this._db = undefined; + throw err; + } + try { - this._db = new DatabaseSync(path, options); + this._registerCodec(path, codec); } catch (err) { + this._db.close(); this._db = undefined; throw err; } @@ -51,6 +64,16 @@ export class DB { } } + _registerCodec(path: string, codec: Codec | undefined): void { + if (this._db === undefined) return; + if (codec === undefined) { + console.warn( + `no codec was provided for the database at "${path}"; values written through ${ENCRYPT_FUNCTION} will be stored as plaintext.` + ); + } + registerCodec(this._db, codec ?? passthroughCodec); + } + _runMigrations(migrations: Migration[]) { if (this._db === undefined) return; diff --git a/packages/bruno-sqlite/src/node/index.ts b/packages/bruno-sqlite/src/node/index.ts index 603ac513ebc..9cd3ae11509 100644 --- a/packages/bruno-sqlite/src/node/index.ts +++ b/packages/bruno-sqlite/src/node/index.ts @@ -1,11 +1,12 @@ -import { existsSync, mkdirSync, renameSync, rmSync } from 'node:fs'; -import { basename, dirname, join } from 'node:path'; +import { rmSync } from 'node:fs'; import { DB, DatabaseOptions, isDatabaseMigrationError } from './db'; import { Statements, OnMutation } from './statements'; import { migrations } from '../generated/node/migrations'; export { DB, DatabaseMigrationError, isDatabaseMigrationError } from './db'; export type { DatabaseOptions } from './db'; +export { passthroughCodec } from './codec'; +export type { Codec } from './codec'; export { Statements } from './statements'; export type { OnMutation } from './statements'; export { registerSQLiteIpc } from './ipc'; diff --git a/packages/bruno-sqlite/tests/node/codec.spec.ts b/packages/bruno-sqlite/tests/node/codec.spec.ts new file mode 100644 index 00000000000..17644492a56 --- /dev/null +++ b/packages/bruno-sqlite/tests/node/codec.spec.ts @@ -0,0 +1,139 @@ +import type { Codec } from '../../src/node/codec'; +import type { Migration, StatementDef } from '../../src/shared/types'; + +const migrations: Migration[] = [ + { + sequence: 1, + name: 'create-secrets', + up: 'CREATE TABLE secrets (uid TEXT PRIMARY KEY, first TEXT, second TEXT)', + down: 'DROP TABLE secrets' + } +]; + +const statements: StatementDef[] = [ + { + name: 'upsertSecret', + type: 'exec', + sql: `INSERT OR REPLACE INTO secrets (uid, first, second) VALUES ( + @uid, + COALESCE(bruno_encrypt(@first), (SELECT first FROM secrets WHERE uid = @uid)), + COALESCE(bruno_encrypt(@second), (SELECT second FROM secrets WHERE uid = @uid)) + )`, + tables: ['secrets'] + }, + { + name: 'getSecret', + type: 'one', + sql: 'SELECT bruno_decrypt(first) AS first, bruno_decrypt(second) AS second FROM secrets WHERE uid = @uid', + tables: ['secrets'] + } +]; + +jest.doMock('../../src/generated/node/migrations', () => ({ migrations })); +jest.doMock('../../src/generated/node/statements', () => ({ statements })); + +const { DB } = require('../../src/node/db'); +const { createDatabase } = require('../../src/node/index'); + +const IN_MEMORY = ':memory:'; + +const codec: Codec = { + encrypt: (value) => `$01:${Buffer.from(value).toString('hex')}`, + decrypt: (value) => (value.startsWith('$01:') ? Buffer.from(value.slice(4), 'hex').toString() : '') +}; + +const first = '{"url":"https://usebruno.com"}'; +const second = '{"status":200}'; + +describe('codec', () => { + let warn: jest.SpyInstance; + let error: jest.SpyInstance; + + beforeEach(() => { + warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + error = jest.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + warn.mockRestore(); + error.mockRestore(); + }); + + const storedRow = (db: any) => db._db.prepare('SELECT first, second FROM secrets WHERE uid = ?').get('s1'); + + it('round-trips a value written and read through a statement', () => { + const { db, statements: prepared } = createDatabase(IN_MEMORY, { codec }); + + prepared.execute('upsertSecret', { uid: 's1', first, second }); + + expect(prepared.execute('getSecret', { uid: 's1' })).toEqual({ first, second }); + db.close(); + }); + + it('stores ciphertext rather than the plaintext it was given', () => { + const { db, statements: prepared } = createDatabase(IN_MEMORY, { codec }); + + prepared.execute('upsertSecret', { uid: 's1', first, second }); + + expect(storedRow(db)).toEqual({ first: codec.encrypt(first), second: codec.encrypt(second) }); + db.close(); + }); + + it('leaves a null parameter null so a partial write keeps the sibling column', () => { + const { db, statements: prepared } = createDatabase(IN_MEMORY, { codec }); + + prepared.execute('upsertSecret', { uid: 's1', first, second: null }); + prepared.execute('upsertSecret', { uid: 's1', first: null, second }); + + expect(prepared.execute('getSecret', { uid: 's1' })).toEqual({ first, second }); + db.close(); + }); + + it('surfaces the codec fallback for a stored value it cannot decrypt', () => { + const { db, statements: prepared } = createDatabase(IN_MEMORY, { codec }); + db._db.prepare('INSERT INTO secrets (uid, first, second) VALUES (?, ?, ?)').run('s1', first, second); + + expect(prepared.execute('getSecret', { uid: 's1' })).toEqual({ first: '', second: '' }); + db.close(); + }); + + it('is registered before the migrations run', () => { + const seeding: Migration[] = [ + { + sequence: 1, + name: 'seed-encrypted', + up: `CREATE TABLE seeded (secret TEXT); INSERT INTO seeded (secret) VALUES (bruno_encrypt('seed'));`, + down: 'DROP TABLE seeded' + } + ]; + const db = new DB(IN_MEMORY, seeding, { codec }); + + expect(db._db.prepare('SELECT secret FROM seeded').get()).toEqual({ secret: codec.encrypt('seed') }); + db.close(); + }); + + it('warns and stores plaintext when no codec is provided', () => { + const { db, statements: prepared } = createDatabase(IN_MEMORY); + + prepared.execute('upsertSecret', { uid: 's1', first, second }); + + expect(warn).toHaveBeenCalledWith(expect.stringContaining('no codec was provided')); + expect(storedRow(db)).toEqual({ first, second }); + db.close(); + }); + + it('aborts the write and stores nothing when the codec throws', () => { + const failing: Codec = { + encrypt: () => { + throw new Error('keychain unavailable'); + }, + decrypt: (value) => value + }; + const { db, statements: prepared } = createDatabase(IN_MEMORY, { codec: failing }); + + expect(() => prepared.execute('upsertSecret', { uid: 's1', first, second })).toThrow('keychain unavailable'); + + expect(db._db.prepare('SELECT count(*) AS rows FROM secrets').get()).toEqual({ rows: 0 }); + db.close(); + }); +}); diff --git a/packages/bruno-sqlite/tests/node/migrations.spec.ts b/packages/bruno-sqlite/tests/node/migrations.spec.ts index 5963e9db4b4..f0d6046261a 100644 --- a/packages/bruno-sqlite/tests/node/migrations.spec.ts +++ b/packages/bruno-sqlite/tests/node/migrations.spec.ts @@ -31,13 +31,16 @@ const appliedSequences = (db: any): number[] => describe('DB migrations', () => { let dir: string; let dbPath: string; + let warn: jest.SpyInstance; beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'bruno-sqlite-')); dbPath = join(dir, 'test.db'); + warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); }); afterEach(() => { + warn.mockRestore(); rmSync(dir, { recursive: true, force: true }); });