Skip to content

Commit

Permalink
feat: encrypt connection secret field
Browse files Browse the repository at this point in the history
  • Loading branch information
rolznz committed Oct 7, 2024
1 parent 7ffa89f commit 7130318
Show file tree
Hide file tree
Showing 15 changed files with 168 additions and 15 deletions.
4 changes: 3 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
LOG_LEVEL=debug
BASE_URL=http://localhost:8080
DATABASE_URL=postgresql://myuser:mypass@localhost:5432/alby_lite
DATABASE_URL=postgresql://myuser:mypass@localhost:5432/alby_lite
# generate using deno task db:generate:key
ENCRYPTION_KEY=
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
6 changes: 4 additions & 2 deletions deno.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
19 changes: 19 additions & 0 deletions deno.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

File renamed without changes.
2 changes: 1 addition & 1 deletion drizzle/meta/0000_snapshot.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
4 changes: 2 additions & 2 deletions drizzle/meta/_journal.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
{
"idx": 0,
"version": "7",
"when": 1727367193876,
"tag": "0000_great_darwin",
"when": 1728309815221,
"tag": "0000_greedy_phalanx",
"breakpoints": true
}
]
Expand Down
35 changes: 35 additions & 0 deletions src/db/aesgcm.test.ts
Original file line number Diff line number Diff line change
@@ -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");
}
});
69 changes: 69 additions & 0 deletions src/db/aesgcm.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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<string> {
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);
}
10 changes: 7 additions & 3 deletions src/db/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

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

Expand All @@ -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;
}
}
16 changes: 16 additions & 0 deletions src/db/generateKey.ts
Original file line number Diff line number Diff line change
@@ -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")
);
2 changes: 1 addition & 1 deletion src/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
});
2 changes: 1 addition & 1 deletion src/lnurlp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
6 changes: 4 additions & 2 deletions src/nwc/nwcPool.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion src/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down

0 comments on commit 7130318

Please sign in to comment.