diff --git a/.changeset/nervous-seahorses-mate.md b/.changeset/nervous-seahorses-mate.md new file mode 100644 index 000000000..f59667dfd --- /dev/null +++ b/.changeset/nervous-seahorses-mate.md @@ -0,0 +1,6 @@ +--- +'@electric-sql/pglite-socket': patch +'@electric-sql/pglite': patch +--- + +added pgcrypto extension diff --git a/docs/extensions/extensions.data.ts b/docs/extensions/extensions.data.ts index d7fe4e035..d66992983 100644 --- a/docs/extensions/extensions.data.ts +++ b/docs/extensions/extensions.data.ts @@ -545,6 +545,20 @@ const baseExtensions: Extension[] = [ importName: 'pg_ivm', size: 24865, }, + { + name: 'pgcrypto', + description: ` + The pgcrypto module provides cryptographic functions for PostgreSQL. + `, + shortDescription: + 'The pgcrypto module provides cryptographic functions for PostgreSQL.', + docs: 'https://www.postgresql.org/docs/current/pgcrypto.html', + tags: ['postgres extension', 'postgres/contrib'], + importPath: '@electric-sql/pglite/contrib/pgcrypto', + importName: 'pgcrypto', + core: true, + size: 1148162, + }, { name: 'pg_hashids', description: ` diff --git a/docs/repl/allExtensions.ts b/docs/repl/allExtensions.ts index 3551ba37f..9c8c75f9f 100644 --- a/docs/repl/allExtensions.ts +++ b/docs/repl/allExtensions.ts @@ -20,10 +20,11 @@ export { pg_buffercache } from '@electric-sql/pglite/contrib/pg_buffercache' export { pg_freespacemap } from '@electric-sql/pglite/contrib/pg_freespacemap' export { pg_surgery } from '@electric-sql/pglite/contrib/pg_surgery' export { pg_trgm } from '@electric-sql/pglite/contrib/pg_trgm' +export { pg_uuidv7 } from '@electric-sql/pglite/pg_uuidv7' export { pg_visibility } from '@electric-sql/pglite/contrib/pg_visibility' export { pg_walinspect } from '@electric-sql/pglite/contrib/pg_walinspect' +export { pgcrypto } from '@electric-sql/pglite/contrib/pgcrypto' export { pgtap } from '@electric-sql/pglite/pgtap' -export { pg_uuidv7 } from '@electric-sql/pglite/pg_uuidv7' export { seg } from '@electric-sql/pglite/contrib/seg' export { tablefunc } from '@electric-sql/pglite/contrib/tablefunc' export { tcn } from '@electric-sql/pglite/contrib/tcn' diff --git a/packages/pglite-socket/tests/server.test.ts b/packages/pglite-socket/tests/server.test.ts index 4953b7ece..8c5a5d725 100644 --- a/packages/pglite-socket/tests/server.test.ts +++ b/packages/pglite-socket/tests/server.test.ts @@ -49,6 +49,10 @@ describe('Server Script Tests', () => { output += data.toString() }) + serverProcess.stderr?.on('data', (data) => { + console.error(data.toString()) + }) + await new Promise((resolve) => { serverProcess.on('exit', (code) => { expect(code).toBe(0) @@ -78,6 +82,10 @@ describe('Server Script Tests', () => { output += data.toString() }) + serverProcess.stderr?.on('data', (data) => { + console.error(data.toString()) + }) + // Wait for server to start await waitForPort(testPort) @@ -126,6 +134,10 @@ describe('Server Script Tests', () => { output += data.toString() }) + serverProcess.stderr?.on('data', (data) => { + console.error(data.toString()) + }) + // Wait for server to be ready const isReady = await waitForPort(testPort) expect(isReady).toBe(true) @@ -159,6 +171,10 @@ describe('Server Script Tests', () => { output += data.toString() }) + serverProcess.stderr?.on('data', (data) => { + console.error(data.toString()) + }) + const isReady = await waitForPort(testPort) expect(isReady).toBe(true) expect(output).toContain('Initializing PGLite with database: memory://') @@ -198,6 +214,10 @@ describe('Server Script Tests', () => { output += data.toString() }) + serverProcess.stderr?.on('data', (data) => { + console.error(data.toString()) + }) + const isReady = await waitForPort(testPort) expect(isReady).toBe(true) serverProcess.kill() diff --git a/packages/pglite/package.json b/packages/pglite/package.json index 9a85b0e10..0005aa059 100644 --- a/packages/pglite/package.json +++ b/packages/pglite/package.json @@ -188,6 +188,7 @@ "bun": "^1.1.30", "concurrently": "^8.2.2", "http-server": "^14.1.1", + "openpgp": "^6.3.0", "playwright": "^1.48.0", "tinytar": "^0.1.0", "vitest": "^2.1.2" diff --git a/packages/pglite/src/contrib/pgcrypto.ts b/packages/pglite/src/contrib/pgcrypto.ts new file mode 100644 index 000000000..063d87dfc --- /dev/null +++ b/packages/pglite/src/contrib/pgcrypto.ts @@ -0,0 +1,16 @@ +import type { + Extension, + ExtensionSetupResult, + PGliteInterface, +} from '../interface' + +const setup = async (_pg: PGliteInterface, _emscriptenOpts: any) => { + return { + bundlePath: new URL('../../release/pgcrypto.tar.gz', import.meta.url), + } satisfies ExtensionSetupResult +} + +export const pgcrypto = { + name: 'pgcrypto', + setup, +} satisfies Extension diff --git a/packages/pglite/tests/contrib/pgcrypto.test.js b/packages/pglite/tests/contrib/pgcrypto.test.js new file mode 100644 index 000000000..a60d577b6 --- /dev/null +++ b/packages/pglite/tests/contrib/pgcrypto.test.js @@ -0,0 +1,252 @@ +import { describe, it, expect } from 'vitest' +import { PGlite } from '../../dist/index.js' +import { pgcrypto } from '../../dist/contrib/pgcrypto.js' +import * as openpgp from 'openpgp' + +describe('pg_pgcryptotrgm', () => { + it('digest', async () => { + const pg = new PGlite({ + extensions: { + pgcrypto, + }, + }) + + await pg.exec('CREATE EXTENSION IF NOT EXISTS pgcrypto;') + + const res = await pg.query( + "SELECT encode(digest(convert_to('test', 'UTF8'), 'sha1'), 'hex') as value;", + ) + expect(res.rows[0].value, 'a94a8fe5ccb19ba61c4c0873d391e987982fbbd3') + }) + + it('hmac', async () => { + const pg = new PGlite({ + extensions: { + pgcrypto, + }, + }) + + await pg.exec('CREATE EXTENSION IF NOT EXISTS pgcrypto;') + + const res = await pg.query( + "SELECT encode(hmac(convert_to('test', 'UTF8'), convert_to('key', 'UTF8'), 'sha1'), 'hex') as value;", + ) + expect(res.rows[0].value).toEqual( + '671f54ce0c540f78ffe1e26dcf9c2a047aea4fda', + ) + }) + + it('crypt', async () => { + const pg = new PGlite({ + extensions: { + pgcrypto, + }, + }) + + await pg.exec('CREATE EXTENSION IF NOT EXISTS pgcrypto;') + + const res = await pg.query("SELECT crypt('test', gen_salt('bf')) as value;") + expect(res.rows[0].value.length).toEqual(60) + }) + + it('gen_salt', async () => { + const pg = new PGlite({ + extensions: { + pgcrypto, + }, + }) + + await pg.exec('CREATE EXTENSION IF NOT EXISTS pgcrypto;') + + const res = await pg.query("SELECT gen_salt('bf') as value;") + expect(res.rows[0].value.length).toEqual(29) + }) + + it('armor', async () => { + const pg = new PGlite({ + extensions: { + pgcrypto, + }, + }) + + await pg.exec('CREATE EXTENSION IF NOT EXISTS pgcrypto;') + + const res = await pg.query("SELECT armor(digest('test', 'sha1')) as value;") + expect(res.rows[0].value).toContain('-----BEGIN PGP MESSAGE-----') + expect(res.rows[0].value).toContain('-----END PGP MESSAGE-----') + }) + + it('pgp_sym_encrypt and pgp_sym_decrypt', async () => { + const pg = new PGlite({ + extensions: { + pgcrypto, + }, + }) + + await pg.exec('CREATE EXTENSION IF NOT EXISTS pgcrypto;') + + const res = await pg.query( + "SELECT pgp_sym_encrypt('test', 'key') as value;", + ) + const encrypted = res.rows[0].value + + const res2 = await pg.query("SELECT pgp_sym_decrypt($1, 'key') as value;", [ + encrypted, + ]) + expect(res2.rows[0].value).toEqual('test') + }) + + it('pgp_pub_encrypt and pgp_pub_decrypt', async () => { + const pg = new PGlite({ + extensions: { + pgcrypto, + }, + }) + + await pg.exec('CREATE EXTENSION IF NOT EXISTS pgcrypto;') + + const { privateKey, publicKey } = await openpgp.generateKey({ + type: 'rsa', + rsaBits: 2048, + userIDs: [{ name: 'PGlite', email: 'hello@pglite.dev' }], + passphrase: '', + }) + + const toEncrypt = 'PGlite@$#%!^$&*WQFgjqPkVERewfreg094340f1012-=' + + const e2 = await pg.exec( + ` +WITH encrypted AS ( + SELECT pgp_pub_encrypt('${toEncrypt}', dearmor('${publicKey}')) AS encrypted +) +SELECT + pgp_pub_decrypt(encrypted, dearmor('${privateKey}')) as decrypted_output +FROM encrypted; +`, + ) + expect(e2[0].rows[0].decrypted_output, toEncrypt) + }) + + it('pgp_key_id', async () => { + const pg = new PGlite({ + extensions: { + pgcrypto, + }, + }) + + await pg.exec('CREATE EXTENSION IF NOT EXISTS pgcrypto;') + + const { publicKey } = await openpgp.generateKey({ + type: 'rsa', + rsaBits: 2048, + userIDs: [{ name: 'PGlite', email: 'hello@pglite.dev' }], + passphrase: '', + }) + + const res = await pg.query( + `SELECT pgp_key_id(dearmor('${publicKey}')) as value;`, + ) + // pgp_key_id returns a 16-character hex string + expect(res.rows[0].value).toHaveLength(16) + expect(res.rows[0].value).toMatch(/^[0-9A-F]+$/) + }) + + it('pgp_armor_headers', async () => { + const pg = new PGlite({ + extensions: { + pgcrypto, + }, + }) + + await pg.exec('CREATE EXTENSION IF NOT EXISTS pgcrypto;') + + // Create armored data with headers + const res = await pg.query( + `SELECT armor(digest('test', 'sha1'), ARRAY['key1'], ARRAY['value1']) as armored;`, + ) + const armored = res.rows[0].armored + + const res2 = await pg.query(`SELECT * FROM pgp_armor_headers($1);`, [ + armored, + ]) + expect(res2.rows).toContainEqual({ key: 'key1', value: 'value1' }) + }) + + it('encrypt and decrypt', async () => { + const pg = new PGlite({ + extensions: { + pgcrypto, + }, + }) + + await pg.exec('CREATE EXTENSION IF NOT EXISTS pgcrypto;') + + const res = await pg.query( + `SELECT encrypt('test data'::bytea, 'secret key'::bytea, 'aes') as encrypted;`, + ) + const encrypted = res.rows[0].encrypted + + const res2 = await pg.query( + `SELECT convert_from(decrypt($1, 'secret key'::bytea, 'aes'), 'UTF8') as decrypted;`, + [encrypted], + ) + expect(res2.rows[0].decrypted).toEqual('test data') + }) + + it('encrypt_iv and decrypt_iv', async () => { + const pg = new PGlite({ + extensions: { + pgcrypto, + }, + }) + + await pg.exec('CREATE EXTENSION IF NOT EXISTS pgcrypto;') + + // AES block size is 16 bytes, so IV must be 16 bytes + const iv = '1234567890123456' + + const res = await pg.query( + `SELECT encrypt_iv('test data'::bytea, 'secret key'::bytea, '${iv}'::bytea, 'aes') as encrypted;`, + ) + const encrypted = res.rows[0].encrypted + + const res2 = await pg.query( + `SELECT convert_from(decrypt_iv($1, 'secret key'::bytea, '${iv}'::bytea, 'aes'), 'UTF8') as decrypted;`, + [encrypted], + ) + expect(res2.rows[0].decrypted).toEqual('test data') + }) + + it('gen_random_bytes', async () => { + const pg = new PGlite({ + extensions: { + pgcrypto, + }, + }) + + await pg.exec('CREATE EXTENSION IF NOT EXISTS pgcrypto;') + + const res = await pg.query( + `SELECT length(gen_random_bytes(32)) as len, encode(gen_random_bytes(16), 'hex') as bytes;`, + ) + expect(res.rows[0].len).toEqual(32) + // 16 bytes = 32 hex characters + expect(res.rows[0].bytes).toHaveLength(32) + }) + + it('gen_random_uuid', async () => { + const pg = new PGlite({ + extensions: { + pgcrypto, + }, + }) + + await pg.exec('CREATE EXTENSION IF NOT EXISTS pgcrypto;') + + const res = await pg.query(`SELECT gen_random_uuid() as uuid;`) + // UUID format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx + expect(res.rows[0].uuid).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/, + ) + }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4fe1e8540..1693c8901 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -189,6 +189,9 @@ importers: http-server: specifier: ^14.1.1 version: 14.1.1 + openpgp: + specifier: ^6.3.0 + version: 6.3.0 playwright: specifier: ^1.48.0 version: 1.48.0 @@ -3384,6 +3387,10 @@ packages: resolution: {integrity: sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==} hasBin: true + openpgp@6.3.0: + resolution: {integrity: sha512-pLzCU8IgyKXPSO11eeharQkQ4GzOKNWhXq79pQarIRZEMt1/ssyr+MIuWBv1mNoenJLg04gvPx+fi4gcKZ4bag==} + engines: {node: '>= 18.0.0'} + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -8008,6 +8015,8 @@ snapshots: opener@1.5.2: {} + openpgp@6.3.0: {} + optionator@0.9.4: dependencies: deep-is: 0.1.4 diff --git a/postgres-pglite b/postgres-pglite index 3e6196922..bee4a36b7 160000 --- a/postgres-pglite +++ b/postgres-pglite @@ -1 +1 @@ -Subproject commit 3e61969226dc2bc0010b9617e755cba62a9a1540 +Subproject commit bee4a36b76d2607f5c1d2ca61fd013958b17d0e9