diff --git a/.env.example b/.env.example index fef399c..370bd2e 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,5 @@ LOG_LEVEL=debug BASE_URL=http://localhost:8080 -DATABASE_URL=postgresql://myuser:mypass@localhost:5432/alby_lite \ No newline at end of file +DATABASE_URL=postgresql://myuser:mypass@localhost:5432/alby_lite +# generate using deno task db:generate:key +ENCRYPTION_KEY= \ No newline at end of file diff --git a/README.md b/README.md index 3adaa7d..132ea38 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,11 @@ A minimal LNURL + Zapper service powered powered by [NWC](https://nwc.dev) ### Creating a new migration - Create the migration files: `deno task db:generate` -- Run the migration: `deno task db:migrate` +- The migration will automatically happen when the app starts. + +### Running Tests + +`deno task test` ## Deployment diff --git a/deno.json b/deno.json index bc546d3..746cac4 100644 --- a/deno.json +++ b/deno.json @@ -9,8 +9,10 @@ "cache": "deno cache ./src/main.ts ./src/db/schema.ts npm:@libsql/client", "cache:reload": "deno cache --reload ./src/main.ts ./src/db/schema.ts", "db:generate": "deno run -A --node-modules-dir npm:drizzle-kit generate", - "dev": "deno run --env --allow-net --allow-env --unstable-ffi --allow-ffi --allow-read --allow-write --watch src/main.ts", - "start": "deno run --allow-net --allow-env --allow-read=favicon.ico src/main.ts" + "db:generate:key": "deno run ./src/db/generateKey.ts", + "dev": "deno run --env --allow-net --allow-env --allow-read --allow-write --watch src/main.ts", + "start": "deno run --env --allow-net --allow-env --allow-read --allow-write src/main.ts", + "test": "deno test --env --allow-env" }, "compilerOptions": { "jsx": "precompile", diff --git a/deno.lock b/deno.lock index 93f832d..fb08840 100644 --- a/deno.lock +++ b/deno.lock @@ -3,6 +3,9 @@ "packages": { "specifiers": { "jsr:@hono/hono@^4.5.5": "jsr:@hono/hono@4.5.5", + "jsr:@std/assert@^1.0.6": "jsr:@std/assert@1.0.6", + "jsr:@std/expect": "jsr:@std/expect@1.0.4", + "jsr:@std/internal@^1.0.4": "jsr:@std/internal@1.0.4", "npm:@getalby/sdk": "npm:@getalby/sdk@3.7.1", "npm:@hono/sentry": "npm:@hono/sentry@1.2.0_hono@4.6.3", "npm:@libsql/client": "npm:@libsql/client@0.14.0", @@ -15,6 +18,22 @@ "jsr": { "@hono/hono@4.5.5": { "integrity": "e5a63b5f535475cd80974b65fed23a138d0cbb91fe1cc9a17a7c7278e835c308" + }, + "@std/assert@1.0.6": { + "integrity": "1904c05806a25d94fe791d6d883b685c9e2dcd60e4f9fc30f4fc5cf010c72207", + "dependencies": [ + "jsr:@std/internal@^1.0.4" + ] + }, + "@std/expect@1.0.4": { + "integrity": "97f68a445a9de0d9670200d2b7a19a7505a01b2cb390a983ba8d97d90ce30c4f", + "dependencies": [ + "jsr:@std/assert@^1.0.6", + "jsr:@std/internal@^1.0.4" + ] + }, + "@std/internal@1.0.4": { + "integrity": "62e8e4911527e5e4f307741a795c0b0a9e6958d0b3790716ae71ce085f755422" } }, "npm": { diff --git a/drizzle/0000_great_darwin.sql b/drizzle/0000_greedy_phalanx.sql similarity index 100% rename from drizzle/0000_great_darwin.sql rename to drizzle/0000_greedy_phalanx.sql diff --git a/drizzle/meta/0000_snapshot.json b/drizzle/meta/0000_snapshot.json index 38a5de5..a65db74 100644 --- a/drizzle/meta/0000_snapshot.json +++ b/drizzle/meta/0000_snapshot.json @@ -1,5 +1,5 @@ { - "id": "cd75d88d-77c5-4f62-a5e6-af13e35b5514", + "id": "e292703e-d08b-4f8b-a9eb-3937fe872be7", "prevId": "00000000-0000-0000-0000-000000000000", "version": "7", "dialect": "postgresql", diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index d4f4470..47b1a8c 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -5,8 +5,8 @@ { "idx": 0, "version": "7", - "when": 1727367193876, - "tag": "0000_great_darwin", + "when": 1728309815221, + "tag": "0000_greedy_phalanx", "breakpoints": true } ] diff --git a/src/db/aesgcm.test.ts b/src/db/aesgcm.test.ts new file mode 100644 index 0000000..6301a69 --- /dev/null +++ b/src/db/aesgcm.test.ts @@ -0,0 +1,35 @@ +import { expect } from "jsr:@std/expect"; +import { decrypt, encrypt } from "./aesgcm.ts"; + +const NWC_URL = + "nostr+walletconnect://0ba9d3de7e3e201aad29ee6b9fca20da0e5fc638c4b0513671eaea9c16a3989f?relay=wss://relay.getalby.com/v1&secret=bdaec8619bcf63a7c797043092ef72a6f62270c0f832561faf8f51f0cfdfce33"; + +Deno.test("encrypt and decrypt with correct key", async () => { + const plaintext = NWC_URL; + const encrypted = await encrypt(plaintext); + const encrypted2 = await encrypt(plaintext); + expect(encrypted).not.toEqual(plaintext); + expect(encrypted2).not.toEqual(encrypted); + const decrypted = await decrypt(encrypted); + expect(decrypted).toEqual(plaintext); +}); + +Deno.test("cannot decrypt with incorrect key", async () => { + const plaintext = NWC_URL; + const encrypted = await encrypt(plaintext); + try { + const incorrectKey = await crypto.subtle.generateKey( + { + name: "AES-GCM", + length: 256, // Can be 128, 192, or 256 + }, + true, // extractable + ["encrypt", "decrypt"] + ); + await decrypt(encrypted, incorrectKey); + // should never get here + expect(true).toBe(false); + } catch (error) { + expect(error.toString()).toEqual("OperationError: Decryption failed"); + } +}); diff --git a/src/db/aesgcm.ts b/src/db/aesgcm.ts new file mode 100644 index 0000000..21e0c8e --- /dev/null +++ b/src/db/aesgcm.ts @@ -0,0 +1,69 @@ +import { Buffer } from "node:buffer"; + +const encryptionKeyBase64 = Deno.env.get("ENCRYPTION_KEY"); +if (!encryptionKeyBase64) { + console.log("no ENCRYPTION_KEY provided, exiting"); + Deno.exit(1); +} + +const encryptionKey = await crypto.subtle.importKey( + "raw", + Buffer.from(encryptionKeyBase64, "base64"), + { + name: "AES-GCM", + length: 256, // Can be 128, 192, or 256 + }, + true, // extractable + ["encrypt", "decrypt"] +); + +const IV_LENGTH = 12; + +// Encrypt with random IV and prepend IV to ciphertext +export async function encrypt( + plaintext: string, + key = encryptionKey +): Promise { + const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH)); // Secure random IV + + const encoded = new TextEncoder().encode(plaintext); + + const ciphertext = await crypto.subtle.encrypt( + { + name: "AES-GCM", + iv: iv, + }, + key, + encoded + ); + + // Combine IV and ciphertext + const combined = new Uint8Array(iv.length + ciphertext.byteLength); + combined.set(iv); + combined.set(new Uint8Array(ciphertext), iv.length); + + return Buffer.from(combined.buffer).toString("base64"); +} + +// Decrypt by extracting IV from the beginning of ciphertext +export async function decrypt( + combinedBase64: string, + key = encryptionKey +): Promise { + const combined = Buffer.from(combinedBase64, "base64"); + + const iv = combined.subarray(0, IV_LENGTH); // Extract first IV_LENGTH bytes as IV + const ciphertext = combined.subarray(IV_LENGTH); + + const decrypted = await crypto.subtle.decrypt( + { + name: "AES-GCM", + iv: iv, + }, + key, + ciphertext + ); + + const decoder = new TextDecoder(); + return decoder.decode(decrypted); +} diff --git a/src/db/db.ts b/src/db/db.ts index 11432c9..cb42297 100644 --- a/src/db/db.ts +++ b/src/db/db.ts @@ -5,6 +5,7 @@ import postgres from "postgres"; import { eq } from "drizzle-orm"; import { DATABASE_URL } from "../constants.ts"; +import { decrypt, encrypt } from "./aesgcm.ts"; import * as schema from "./schema.ts"; import { users } from "./schema.ts"; @@ -37,8 +38,10 @@ export class DB { // TODO: use haikunator username = username || Math.floor(Math.random() * 100000000000).toString(); + const encryptedConnectionSecret = await encrypt(connectionSecret); + await this._db.insert(users).values({ - connectionSecret, + encryptedConnectionSecret, username, }); @@ -49,13 +52,14 @@ export class DB { return this._db.query.users.findMany(); } - async findWalletConnection(username: string) { + async findWalletConnectionSecret(username: string) { const result = await this._db.query.users.findFirst({ where: eq(users.username, username), }); if (!result) { throw new Error("user not found"); } - return result?.connectionSecret; + const connectionSecret = await decrypt(result.encryptedConnectionSecret); + return connectionSecret; } } diff --git a/src/db/generateKey.ts b/src/db/generateKey.ts new file mode 100644 index 0000000..51c5597 --- /dev/null +++ b/src/db/generateKey.ts @@ -0,0 +1,16 @@ +import { Buffer } from "node:buffer"; +console.log( + Buffer.from( + await crypto.subtle.exportKey( + "raw", + await crypto.subtle.generateKey( + { + name: "AES-GCM", + length: 256, + }, + true, + ["encrypt", "decrypt"] + ) + ) + ).toString("base64") +); diff --git a/src/db/schema.ts b/src/db/schema.ts index a7f6566..e5c9504 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -2,7 +2,7 @@ import { pgTable, serial, text, timestamp } from "drizzle-orm/pg-core"; export const users = pgTable("users", { id: serial("id").primaryKey(), - connectionSecret: text("connection_secret").notNull(), + encryptedConnectionSecret: text("connection_secret").notNull(), username: text("username").unique().notNull(), createdAt: timestamp("created_at").notNull().defaultNow(), }); diff --git a/src/lnurlp.ts b/src/lnurlp.ts index c5f5bb6..90a895a 100644 --- a/src/lnurlp.ts +++ b/src/lnurlp.ts @@ -38,7 +38,7 @@ export function createLnurlApp(db: DB) { return c.text("No amount provided", 404); } - const connectionSecret = await db.findWalletConnection(username); + const connectionSecret = await db.findWalletConnectionSecret(username); const nwcClient = new nwc.NWCClient({ nostrWalletConnectUrl: connectionSecret, diff --git a/src/nwc/nwcPool.ts b/src/nwc/nwcPool.ts index b001a9e..0b47f29 100644 --- a/src/nwc/nwcPool.ts +++ b/src/nwc/nwcPool.ts @@ -1,4 +1,5 @@ import { nwc } from "npm:@getalby/sdk"; +import { decrypt } from "../db/aesgcm.ts"; import { DB } from "../db/db.ts"; import { logger } from "../logger.ts"; @@ -11,11 +12,12 @@ export class NWCPool { async init() { const users = await this._db.getAllUsers(); for (const user of users) { - this.addNWCClient(user.connectionSecret, user.username); + const connectionSecret = await decrypt(user.encryptedConnectionSecret); + this.subscribeUser(connectionSecret, user.username); } } - addNWCClient(connectionSecret: string, username: string) { + subscribeUser(connectionSecret: string, username: string) { logger.debug("subscribing to user", { username }); const nwcClient = new nwc.NWCClient({ nostrWalletConnectUrl: connectionSecret, diff --git a/src/users.ts b/src/users.ts index 14642ba..0b9f3e9 100644 --- a/src/users.ts +++ b/src/users.ts @@ -24,7 +24,7 @@ export function createUsersApp(db: DB, nwcPool: NWCPool) { const lightningAddress = user.username + "@" + DOMAIN; - nwcPool.addNWCClient(createUserRequest.connectionSecret, user.username); + nwcPool.subscribeUser(createUserRequest.connectionSecret, user.username); return c.json({ lightningAddress,