Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion packages/bruno-electron/src/ipc/sqlite.js
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -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);
}
Expand Down Expand Up @@ -48,4 +55,4 @@ const shutdown = () => {

const getStatements = () => (ipc ? ipc.statements : null);

module.exports = { registerSqliteIpc, shutdown, getStatements };
module.exports = { registerSqliteIpc, shutdown, getStatements, codec };
40 changes: 40 additions & 0 deletions packages/bruno-electron/src/ipc/sqlite.spec.js
Original file line number Diff line number Diff line change
@@ -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('');
});
});
3 changes: 2 additions & 1 deletion packages/bruno-sqlite/scripts/verify-migrations.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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.');
Expand Down
22 changes: 22 additions & 0 deletions packages/bruno-sqlite/src/node/codec.ts
Original file line number Diff line number Diff line change
@@ -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));
};
27 changes: 25 additions & 2 deletions packages/bruno-sqlite/src/node/db.ts
Original file line number Diff line number Diff line change
@@ -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');

Expand Down Expand Up @@ -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;
}
Expand All @@ -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;

Expand Down
5 changes: 3 additions & 2 deletions packages/bruno-sqlite/src/node/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
139 changes: 139 additions & 0 deletions packages/bruno-sqlite/tests/node/codec.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
3 changes: 3 additions & 0 deletions packages/bruno-sqlite/tests/node/migrations.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
});

Expand Down
Loading