From f606dbd84929f5061f43e992af360c9bcc63f59f Mon Sep 17 00:00:00 2001 From: James Arthur Date: Mon, 9 Dec 2024 08:55:35 +0100 Subject: [PATCH] examples: add encryption example. (#2117) https://github.com/electric-sql/electric/pull/2117/files#diff-2af99c046c43f919c4cfe72af4c3870c1ca943f076d92e7cb5c39a882c5b8d19 --- examples/encryption/.eslintignore | 1 + examples/encryption/.eslintrc.cjs | 41 +++++ examples/encryption/.gitignore | 2 + examples/encryption/.prettierrc | 6 + examples/encryption/README.md | 64 +++++++ examples/encryption/backend/api.js | 70 ++++++++ .../db/migrations/01-create_items_table.sql | 5 + examples/encryption/index.html | 13 ++ examples/encryption/package.json | 43 +++++ examples/encryption/public/favicon.ico | Bin 0 -> 4286 bytes examples/encryption/public/robots.txt | 3 + examples/encryption/src/App.css | 25 +++ examples/encryption/src/App.tsx | 16 ++ examples/encryption/src/Example.css | 79 ++++++++ examples/encryption/src/Example.tsx | 168 ++++++++++++++++++ examples/encryption/src/assets/logo.svg | 11 ++ examples/encryption/src/main.tsx | 10 ++ examples/encryption/src/style.css | 13 ++ examples/encryption/src/vite-env.d.ts | 1 + examples/encryption/tsconfig.json | 24 +++ examples/encryption/vite.config.ts | 10 ++ pnpm-lock.yaml | 76 +++++++- 22 files changed, 675 insertions(+), 6 deletions(-) create mode 100644 examples/encryption/.eslintignore create mode 100644 examples/encryption/.eslintrc.cjs create mode 100644 examples/encryption/.gitignore create mode 100644 examples/encryption/.prettierrc create mode 100644 examples/encryption/README.md create mode 100644 examples/encryption/backend/api.js create mode 100644 examples/encryption/db/migrations/01-create_items_table.sql create mode 100644 examples/encryption/index.html create mode 100644 examples/encryption/package.json create mode 100644 examples/encryption/public/favicon.ico create mode 100644 examples/encryption/public/robots.txt create mode 100644 examples/encryption/src/App.css create mode 100644 examples/encryption/src/App.tsx create mode 100644 examples/encryption/src/Example.css create mode 100644 examples/encryption/src/Example.tsx create mode 100644 examples/encryption/src/assets/logo.svg create mode 100644 examples/encryption/src/main.tsx create mode 100644 examples/encryption/src/style.css create mode 100644 examples/encryption/src/vite-env.d.ts create mode 100644 examples/encryption/tsconfig.json create mode 100644 examples/encryption/vite.config.ts diff --git a/examples/encryption/.eslintignore b/examples/encryption/.eslintignore new file mode 100644 index 0000000000..e32a3e1834 --- /dev/null +++ b/examples/encryption/.eslintignore @@ -0,0 +1 @@ +/build/** diff --git a/examples/encryption/.eslintrc.cjs b/examples/encryption/.eslintrc.cjs new file mode 100644 index 0000000000..b0858622eb --- /dev/null +++ b/examples/encryption/.eslintrc.cjs @@ -0,0 +1,41 @@ +module.exports = { + env: { + browser: true, + es2021: true, + node: true, + }, + extends: [ + `eslint:recommended`, + `plugin:@typescript-eslint/recommended`, + `plugin:prettier/recommended`, + ], + parserOptions: { + ecmaVersion: 2022, + requireConfigFile: false, + sourceType: `module`, + ecmaFeatures: { + jsx: true, + }, + }, + parser: `@typescript-eslint/parser`, + plugins: [`prettier`], + rules: { + quotes: [`error`, `single`], + "no-unused-vars": `off`, + "@typescript-eslint/no-unused-vars": [ + `error`, + { + argsIgnorePattern: `^_`, + varsIgnorePattern: `^_`, + caughtErrorsIgnorePattern: `^_`, + }, + ], + }, + ignorePatterns: [ + `**/node_modules/**`, + `**/dist/**`, + `tsup.config.ts`, + `vitest.config.ts`, + `.eslintrc.js`, + ], +}; diff --git a/examples/encryption/.gitignore b/examples/encryption/.gitignore new file mode 100644 index 0000000000..9829edff9a --- /dev/null +++ b/examples/encryption/.gitignore @@ -0,0 +1,2 @@ +dist +.env.local diff --git a/examples/encryption/.prettierrc b/examples/encryption/.prettierrc new file mode 100644 index 0000000000..ad4d895523 --- /dev/null +++ b/examples/encryption/.prettierrc @@ -0,0 +1,6 @@ +{ + "semi": false, + "singleQuote": true, + "tabWidth": 2, + "trailingComma": "es5" +} diff --git a/examples/encryption/README.md b/examples/encryption/README.md new file mode 100644 index 0000000000..41db4cf653 --- /dev/null +++ b/examples/encryption/README.md @@ -0,0 +1,64 @@ + +# Encryption example + +This is an example of encryption with Electric. It's a React app with a very simple Express API server. + +The Electric-specific code is in [`./src/Example.tsx`](./src/Example.tsx). It demonstrates: + +- encrypting data before sending to the API server +- decrypting data after it syncs in through Electric + +## Setup + +This example is part of the [ElectricSQL monorepo](../..) and is designed to be built and run as part of the [pnpm workspace](https://pnpm.io/workspaces) defined in [`../../pnpm-workspace.yaml`](../../pnpm-workspace.yaml). + +Navigate to the root directory of the monorepo, e.g.: + +```shell +cd ../../ +``` + +Install and build all of the workspace packages and examples: + +```shell +pnpm install +pnpm run -r build +``` + +Navigate back to this directory: + +```shell +cd examples/basic-example +``` + +Start the example backend services using [Docker Compose](https://docs.docker.com/compose/): + +```shell +pnpm backend:up +``` + +Now start the dev server: + +```shell +pnpm dev +``` + +Open [localhost:5173]http://localhost:5173] in your web browser. When you add items, the plaintext is encrypted before it leaves the app. You can see the ciphertext in Postgres, e.g.: + +```console +$ psql "postgresql://postgres:password@localhost:54321/electric" +psql (16.4) +Type "help" for help. + +electric=# select * from items; + id | ciphertext | iv +--------------------------------------+------------------------------+------------------ + 491b2654-5714-48bb-a206-59f87a2dc33c | vDwv3IX5AGXJVi2jNJJDPE25MwiS | 0gwdqHvqiJ8lJqaS +(1 row) +``` + +When you're done, stop the backend services using: + +```shell +pnpm backend:down +``` \ No newline at end of file diff --git a/examples/encryption/backend/api.js b/examples/encryption/backend/api.js new file mode 100644 index 0000000000..9ff630f84e --- /dev/null +++ b/examples/encryption/backend/api.js @@ -0,0 +1,70 @@ +import bodyParser from 'body-parser' +import cors from 'cors' +import express from 'express' +import pg from 'pg' + +import { z } from 'zod' + +// Connect to Postgres. +const DATABASE_URL = process.env.DATABASE_URL || 'postgresql://postgres:password@localhost:54321/electric' +const DATABASE_USE_SSL = process.env.DATABASE_USE_SSL === 'true' || false +const pool = new pg.Pool({connectionString: DATABASE_URL, ssl: DATABASE_USE_SSL}) +const db = await pool.connect() + +// Expose an HTTP server. +const PORT = parseInt(process.env.PORT || '3001') +const app = express() +app.use(bodyParser.json()) +app.use(cors()) + +// Validate user input +const createSchema = z.object({ + id: z.string().uuid(), + ciphertext: z.string(), + iv: z.string() +}) + +// Expose `POST {data} /items`. +app.post(`/items`, async (req, res) => { + let data + try { + data = createSchema.parse(req.body) + } + catch (err) { + return res.status(400).json({ errors: err.errors }) + } + + // Insert the item into the database. + const sql = ` + INSERT INTO items ( + id, + ciphertext, + iv + ) + VALUES ( + $1, + $2, + $3 + ) + ` + + const params = [ + data.id, + data.ciphertext, + data.iv + ] + + try { + await db.query(sql, params) + } + catch (err) { + return res.status(500).json({ errors: err }) + } + + return res.status(200).json({ status: 'OK' }) +}) + +// Start the server +app.listen(PORT, () => { + console.log(`Server listening at http://localhost:${PORT}`) +}) diff --git a/examples/encryption/db/migrations/01-create_items_table.sql b/examples/encryption/db/migrations/01-create_items_table.sql new file mode 100644 index 0000000000..e04b61407e --- /dev/null +++ b/examples/encryption/db/migrations/01-create_items_table.sql @@ -0,0 +1,5 @@ +CREATE TABLE IF NOT EXISTS items ( + id UUID PRIMARY KEY NOT NULL, + ciphertext TEXT NOT NULL, + iv TEXT NOT NULL +); diff --git a/examples/encryption/index.html b/examples/encryption/index.html new file mode 100644 index 0000000000..f69b26596f --- /dev/null +++ b/examples/encryption/index.html @@ -0,0 +1,13 @@ + + + + + + + Web Example - ElectricSQL + + +
+ + + diff --git a/examples/encryption/package.json b/examples/encryption/package.json new file mode 100644 index 0000000000..e84a37bc31 --- /dev/null +++ b/examples/encryption/package.json @@ -0,0 +1,43 @@ +{ + "name": "@electric-examples/encryption", + "private": true, + "version": "0.0.1", + "author": "ElectricSQL", + "license": "Apache-2.0", + "type": "module", + "scripts": { + "backend:up": "PROJECT_NAME=basic-example pnpm -C ../../ run example-backend:up && pnpm db:migrate", + "backend:down": "PROJECT_NAME=basic-example pnpm -C ../../ run example-backend:down", + "db:migrate": "dotenv -e ../../.env.dev -- pnpm exec pg-migrations apply --directory ./db/migrations", + "dev": "concurrently \"vite\" \"node backend/api.js\"", + "build": "vite build", + "format": "eslint . --ext ts,tsx --fix", + "stylecheck": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", + "preview": "vite preview", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@electric-sql/react": "workspace:*", + "base64-js": "^1.5.1", + "body-parser": "^1.20.2", + "cors": "^2.8.5", + "express": "^4.19.2", + "pg": "^8.12.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "uuid": "^10.0.0", + "zod": "^3.23.8" + }, + "devDependencies": { + "@databases/pg-migrations": "^5.0.3", + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "@types/uuid": "^10.0.0", + "@vitejs/plugin-react": "^4.3.1", + "concurrently": "^8.2.2", + "dotenv": "^16.4.5", + "eslint": "^8.57.0", + "typescript": "^5.5.3", + "vite": "^5.3.4" + } +} diff --git a/examples/encryption/public/favicon.ico b/examples/encryption/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..55765b6d8922b420e9394d826ede5a5f4c9bb85a GIT binary patch literal 4286 zcmeHL+fKqj5FP)(C!a}t@riHnbNvQ2(THd?8oc8b6P0M9K7kadc$n!@Mbn)&OWM`Q zl0%c-%$~#S>={y`8UB +
+ logo + +
+ + ) +} diff --git a/examples/encryption/src/Example.css b/examples/encryption/src/Example.css new file mode 100644 index 0000000000..e7fbf3c7aa --- /dev/null +++ b/examples/encryption/src/Example.css @@ -0,0 +1,79 @@ +.controls { + margin-bottom: 1.5rem; +} + +.button { + display: inline-block; + line-height: 1.3; + text-align: center; + text-decoration: none; + vertical-align: middle; + cursor: pointer; + user-select: none; + width: calc(15vw + 100px); + margin-right: 0.5rem !important; + margin-left: 0.5rem !important; + border-radius: 32px; + text-shadow: 2px 6px 20px rgba(0, 0, 0, 0.4); + box-shadow: rgba(0, 0, 0, 0.5) 1px 2px 8px 0px; + background: #1e2123; + border: 2px solid #229089; + color: #f9fdff; + font-size: 16px; + font-weight: 500; + padding: 10px 18px; +} + +.item { + display: block; + line-height: 1.3; + text-align: center; + vertical-align: middle; + width: calc(30vw - 1.5rem + 200px); + margin-right: auto; + margin-left: auto; + border-radius: 9px; + border: 1px solid #D0BCFF; + background: #1e2123; + color: #D0BCFF; + font-size: 13px; + padding: 10px 18px; +} + +form { + border-top: 0.5px solid rgba(227, 227, 239, 0.32); + width: calc(30vw - 1.5rem + 200px); + margin: 20px auto; + padding: 20px 0; +} + +form input[type=text] { + padding: 12px 18px; + margin-bottom: 18px; + background: #1e2123; + border: 1px solid rgba(227, 227, 239, 0.92); + border-radius: 9px; + color: #f5f5f5; + outline: none; + font-size: 14px; + display: block; + text-align: center; + width: calc(30vw - 1.5rem + 160px); + margin-right: auto; + margin-left: auto; +} + +form input[type=text]::placeholder { + color: rgba(227, 227, 239, 0.62); +} + +form button[type=submit] { + background: #D0BCFF; + border: none; + padding: 8px 20px; + border-radius: 9px; + color: #1e2123; + font-size: 15px; + font-weight: 500; + cursor: pointer; +} \ No newline at end of file diff --git a/examples/encryption/src/Example.tsx b/examples/encryption/src/Example.tsx new file mode 100644 index 0000000000..53c171bb27 --- /dev/null +++ b/examples/encryption/src/Example.tsx @@ -0,0 +1,168 @@ +import base64 from 'base64-js' +import React, { useEffect, useState } from 'react' +import { useShape } from '@electric-sql/react' +import './Example.css' + +type Item = { + id: string + title: string +} + +type EncryptedItem = { + id: string + ciphertext: string + iv: string +} + +const API_URL = import.meta.env.API_URL || 'http://localhost:3001' +const ELECTRIC_URL = import.meta.env.ELECTRIC_URL ?? 'http://localhost:3000' + +// For this example, we hardcode a deterministic key that works across page loads. +// In a real app, you would implement a key management strategy. Electric is great +// at syncing keys between users :) +const rawKey = new Uint8Array(16) +const key = await crypto.subtle.importKey('raw', rawKey, 'AES-GCM', true, [ + 'encrypt', + 'decrypt', +]) + +/* + * Encrypt an `Item` into an `EncryptedItem`. + */ +async function encrypt(item: Item): Promise { + const { id, title } = item + + const enc = new TextEncoder() + const encoded = enc.encode(title) + const iv = crypto.getRandomValues(new Uint8Array(12)) + + const encrypted = await crypto.subtle.encrypt( + { + iv, + name: 'AES-GCM', + }, + key, + encoded + ) + + const ciphertext = base64.fromByteArray(new Uint8Array(encrypted)) + const iv_str = base64.fromByteArray(iv) + + return { + id, + ciphertext, + iv: iv_str, + } +} + +/* + * Decrypt an `EncryptedItem` to an `Item`. + */ +async function decrypt(item: EncryptedItem): Promise { + const { id, ciphertext, iv: iv_str } = item + + const encrypted = base64.toByteArray(ciphertext) + const iv = base64.toByteArray(iv_str) + + const decrypted = await crypto.subtle.decrypt( + { + iv, + name: 'AES-GCM', + }, + key, + encrypted + ) + + const dec = new TextDecoder() + const title = dec.decode(decrypted) + + return { + id, + title, + } +} + +export const Example = () => { + const [items, setItems] = useState() + + const { data } = useShape({ + url: `${ELECTRIC_URL}/v1/shape`, + params: { + table: 'items', + }, + }) + + const rows = data !== undefined ? data : [] + + // There are more efficient ways of updating state than always decrypting + // all the items on any change but just to demonstate the decryption ... + useEffect(() => { + async function init() { + const items = await Promise.all( + rows.map(async (row) => await decrypt(row)) + ) + + setItems(items) + } + + init() + }, [rows]) + + /* + * Handle adding an item by creating the item data, encrypting it + * and sending it to the API + */ + async function createItem(event: React.FormEvent) { + event.preventDefault() + + const form = event.target as HTMLFormElement + const formData = new FormData(form) + const title = formData.get('title') as string + + const id = crypto.randomUUID() + const item = { + id, + title, + } + + const data = await encrypt(item) + + const url = `${API_URL}/items` + const options = { + method: 'POST', + body: JSON.stringify(data), + headers: { + 'Content-Type': 'application/json', + }, + } + + await fetch(url, options) + + form.reset() + } + + if (items === undefined) { + return
Loading...
+ } + + return ( +
+
+ {items.map((item: Item, index: number) => ( +

+ {item.title} +

+ ))} +
+
+ + +
+
+ ) +} diff --git a/examples/encryption/src/assets/logo.svg b/examples/encryption/src/assets/logo.svg new file mode 100644 index 0000000000..9d122343d7 --- /dev/null +++ b/examples/encryption/src/assets/logo.svg @@ -0,0 +1,11 @@ + + + + diff --git a/examples/encryption/src/main.tsx b/examples/encryption/src/main.tsx new file mode 100644 index 0000000000..2a6077e907 --- /dev/null +++ b/examples/encryption/src/main.tsx @@ -0,0 +1,10 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import App from './App' +import './style.css' + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + +) diff --git a/examples/encryption/src/style.css b/examples/encryption/src/style.css new file mode 100644 index 0000000000..82edb15a5d --- /dev/null +++ b/examples/encryption/src/style.css @@ -0,0 +1,13 @@ +body { + margin: 0; + font-family: 'Helvetica Neue', Helvetica, sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + background: #1c1e20; + min-width: 360px; +} + +code { + font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', + monospace; +} \ No newline at end of file diff --git a/examples/encryption/src/vite-env.d.ts b/examples/encryption/src/vite-env.d.ts new file mode 100644 index 0000000000..11f02fe2a0 --- /dev/null +++ b/examples/encryption/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/examples/encryption/tsconfig.json b/examples/encryption/tsconfig.json new file mode 100644 index 0000000000..5710471917 --- /dev/null +++ b/examples/encryption/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "ESNext", + "useDefineForClassFields": true, + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/examples/encryption/vite.config.ts b/examples/encryption/vite.config.ts new file mode 100644 index 0000000000..b2e7564e33 --- /dev/null +++ b/examples/encryption/vite.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +// https://vitejs.dev/config/ +export default defineConfig({ + build: { + target: 'esnext', + }, + plugins: [react()], +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 27d5d77da2..40755304df 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -54,6 +54,70 @@ importers: specifier: ^5.3.4 version: 5.4.10(@types/node@20.17.6) + examples/encryption: + dependencies: + '@electric-sql/react': + specifier: workspace:* + version: link:../../packages/react-hooks + base64-js: + specifier: ^1.5.1 + version: 1.5.1 + body-parser: + specifier: ^1.20.2 + version: 1.20.3 + cors: + specifier: ^2.8.5 + version: 2.8.5 + express: + specifier: ^4.19.2 + version: 4.21.1 + pg: + specifier: ^8.12.0 + version: 8.13.1 + react: + specifier: ^18.3.1 + version: 18.3.1 + react-dom: + specifier: ^18.3.1 + version: 18.3.1(react@18.3.1) + uuid: + specifier: ^10.0.0 + version: 10.0.0 + zod: + specifier: ^3.23.8 + version: 3.23.8 + devDependencies: + '@databases/pg-migrations': + specifier: ^5.0.3 + version: 5.0.3(typescript@5.6.3) + '@types/react': + specifier: ^18.3.3 + version: 18.3.12 + '@types/react-dom': + specifier: ^18.3.0 + version: 18.3.1 + '@types/uuid': + specifier: ^10.0.0 + version: 10.0.0 + '@vitejs/plugin-react': + specifier: ^4.3.1 + version: 4.3.3(vite@5.4.10(@types/node@20.17.6)) + concurrently: + specifier: ^8.2.2 + version: 8.2.2 + dotenv: + specifier: ^16.4.5 + version: 16.4.5 + eslint: + specifier: ^8.57.0 + version: 8.57.1 + typescript: + specifier: ^5.5.3 + version: 5.6.3 + vite: + specifier: ^5.3.4 + version: 5.4.10(@types/node@20.17.6) + examples/gatekeeper-auth: dependencies: '@electric-sql/client': @@ -7631,13 +7695,8 @@ packages: tsx@4.19.2: resolution: {integrity: sha512-pOUl6Vo2LUq/bSa8S5q7b91cgNSjctn9ugq/+Mvow99qW6x/UZYwzxy/3NmqoT66eHYfCVvFvACC58UBPFf28g==} - dependencies: - esbuild: 0.23.1 - get-tsconfig: 4.8.1 engines: {node: '>=18.0.0'} hasBin: true - optionalDependencies: - fsevents: 2.3.3 turbo-stream@2.4.0: resolution: {integrity: sha512-FHncC10WpBd2eOmGwpmQsWLDoK4cqsA/UT/GqNoaKOQnT8uzhtCbg3EoUDMvqpOSAI0S26mr0rkjzbOO6S3v1g==} @@ -15812,7 +15871,12 @@ snapshots: - tsx - yaml - tsx@4.19.2: {} + tsx@4.19.2: + dependencies: + esbuild: 0.23.1 + get-tsconfig: 4.8.1 + optionalDependencies: + fsevents: 2.3.3 turbo-stream@2.4.0: {}